Add iso/e2b-contig.sh: make an ISO contiguous on a USB drive from Linux

Easy2Boot's UEFI menu returned straight to the menu for the new kiosk ISO
(a 3.5 GB copy on a well-used drive). Some multiboot modes need the ISO in
one unbroken piece, and Linux has no defragmenter for NTFS/FAT32/exFAT -
Easy2Boot's own Linux helpers are a 32-bit udefrag needing i386 libraries
and a FAT32-only perl script.

e2b-contig.sh rewrites just the one file, using only ordinary file
operations - never filesystem structures - so the worst case is "still
not contiguous", never a damaged drive:

1. fill the drive's free space with 4 MB filler files (plugs every gap;
   a plain copy, or fallocate where it exists at all, just fills the
   gaps again - measured on ntfs-3g: 7-8 pieces either way);
2. map the fillers (filefrag -e, which also works on ntfs-3g via FIBMAP)
   and find the longest physically unbroken run of single-piece ones;
3. free just enough of that run and write the copy - the only free space
   left - then delete the fillers;
4. verify the copy's SHA-256 against the original and rename it into
   place (same directory, so the data doesn't move).

If there's no run big enough while the original is on the drive,
--move-out parks it on this computer (checksummed), retries, and puts it
back verified if that still fails. --check only reports. Interrupts and
failures remove fillers and temp copies.

Tested on NTFS images through ntfs-3g: a 60 MB file in 7 pieces became 1
piece with identical content, no leftovers, free space unchanged (27 s for
~900 MB free); drives with no big enough free run (with and without
--move-out) report so and leave the original byte-identical, parked copy
cleaned up. FAT32/exFAT through their FUSE drivers can't report layout,
which the tool detects and stops on, naming the kernel driver to remount
with (vfat/exfat/ntfs3); the kernel drivers aren't available in the test
environment, so those paths are untested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
This commit is contained in:
Claude
2026-10-09 18:42:28 +00:00
parent a66b1e6079
commit d4f9233655
2 changed files with 325 additions and 0 deletions
+22
View File
@@ -138,6 +138,27 @@ Notes:
times, quiet hours and power schedules drift with it. times, quiet hours and power schedules drift with it.
- `sudo rm -rf /opt/kiosk-offline` frees the space if you don't need it. - `sudo rm -rf /opt/kiosk-offline` frees the space if you don't need it.
**Booting it from Easy2Boot (or another multiboot USB)?** Some of their boot
modes need the ISO stored in one unbroken piece on the drive, and a 3.5 GB
file copied onto a well-used drive usually isn't. The symptom is a brief
black screen and straight back to the menu. From Linux, with the drive
plugged in:
```bash
sudo iso/e2b-contig.sh --check /media/$USER/E2B/_ISO/LINUX/ubuntu-26.04.1-kiosk-offline-amd64.iso
sudo iso/e2b-contig.sh /media/$USER/E2B/_ISO/LINUX/ubuntu-26.04.1-kiosk-offline-amd64.iso
```
It rewrites just that file in one piece using ordinary file operations
(never the filesystem's own structures), and keeps the original if anything
doesn't verify. It temporarily fills the drive's free space to do it, so
it takes a few minutes. `--move-out` lets it park the original on this
computer when there isn't room for a second copy. NTFS, FAT32 and exFAT;
on NTFS mounted through `ntfs-3g` it can usually read the layout, and if
not it tells you to remount with the kernel's `ntfs3` driver. Also check
the copy isn't simply damaged: `sha256sum` it on the build machine and on
the drive.
**No Linux machine handy?** Build it on GitHub instead: **Actions → Build **No Linux machine handy?** Build it on GitHub instead: **Actions → Build
kiosk ISO → Run workflow**, pick a mode, and download the ISO from the run's kiosk ISO → Run workflow**, pick a mode, and download the ISO from the run's
**Artifacts** once it finishes (about 10 minutes; kept for 3 days by default). **Artifacts** once it finishes (about 10 minutes; kept for 3 days by default).
@@ -1491,6 +1512,7 @@ full migration pass.
- **Local files as sites:** an absolute path (`/home/kiosk/docs/menu.pdf`) shows a local page, PDF or image; a folder (`/home/kiosk/photos/`) plays as a full-screen **image slideshow** that picks up added/removed images within a minute. Works in both the Sites menu and the Web UI. - **Local files as sites:** an absolute path (`/home/kiosk/docs/menu.pdf`) shows a local page, PDF or image; a folder (`/home/kiosk/photos/`) plays as a full-screen **image slideshow** that picks up added/removed images within a minute. Works in both the Sites menu and the Web UI.
- **Fix: the first-boot kiosk setup never started on 26.04.** Its service was ordered after cloud-init's final stage, which itself runs after `multi-user.target` - an ordering cycle that systemd broke by dropping the kiosk setup. It also "conflicted" with the tty1 login prompt, which systemd can resolve the same way. Both removed; checked with `systemd-analyze verify` against 26.04's own units. Rebuild the ISO to pick this up. - **Fix: the first-boot kiosk setup never started on 26.04.** Its service was ordered after cloud-init's final stage, which itself runs after `multi-user.target` - an ordering cycle that systemd broke by dropping the kiosk setup. It also "conflicted" with the tty1 login prompt, which systemd can resolve the same way. Both removed; checked with `systemd-analyze verify` against 26.04's own units. Rebuild the ISO to pick this up.
- **WiFi tools in the base install:** `wpasupplicant` and `iw` are now installed (and bundled offline). A default Ubuntu Server install only has them if WiFi was set up in the installer; without them, Core Settings → WiFi couldn't connect an offline kiosk. The WiFi menu also installs them itself if missing. - **WiFi tools in the base install:** `wpasupplicant` and `iw` are now installed (and bundled offline). A default Ubuntu Server install only has them if WiFi was set up in the installer; without them, Core Settings → WiFi couldn't connect an offline kiosk. The WiFi menu also installs them itself if missing.
- **New: `iso/e2b-contig.sh`** - makes a file on an Easy2Boot (or any NTFS/FAT32/exFAT) USB drive contiguous from Linux, so ISOs that need to be in one piece boot.
- **Offline addons:** the `--offline` bundle now also carries LMS (Lyrion) + Squeezelite, Asterisk Intercom, VNC, WireGuard and the Emergency Hotspot; their normal installs use it automatically when there's no internet. - **Offline addons:** the `--offline` bundle now also carries LMS (Lyrion) + Squeezelite, Asterisk Intercom, VNC, WireGuard and the Emergency Hotspot; their normal installs use it automatically when there's no internet.
- **Emergency Hotspot fix:** by default it now starts only when the local network is unreachable (router *and* internet), not merely when there's no internet - on a LAN without internet it used to start on every boot. Existing setups keep the old check until Advanced → Emergency Hotspot → Reconfigure. - **Emergency Hotspot fix:** by default it now starts only when the local network is unreachable (router *and* internet), not merely when there's no internet - on a LAN without internet it used to start on every boot. Existing setups keep the old check until Advanced → Emergency Hotspot → Reconfigure.
- **New: Core Settings → Time Server (NTP)** - use a time server on your network, go back to Ubuntu's, or set the clock by hand; for networks without internet. - **New: Core Settings → Time Server (NTP)** - use a time server on your network, go back to Ubuntu's, or set the clock by hand; for networks without internet.
+303
View File
@@ -0,0 +1,303 @@
#!/bin/bash
################################################################################
# iso/e2b-contig.sh - Make a file on a USB drive contiguous, from Linux.
#
# Easy2Boot (and other multiboot loaders) can only boot some ISO files when
# they sit on the drive in one unbroken piece. A big ISO copied onto a
# well-used drive usually lands in several pieces, and Linux has no
# defragmenter for NTFS/FAT32/exFAT. This rewrites the one file so it is
# contiguous, using only ordinary file operations - it never writes
# filesystem structures directly, so the worst case is "still not
# contiguous", not a damaged drive.
#
# How: a plain copy just fills the drive's scattered free gaps again. So
# 1. fill the free space with temporary filler files (plugging every gap),
# 2. find the longest physically unbroken run among them and free exactly
# that run,
# 3. write the copy - the only free space left is that run,
# 4. delete the fillers, verify the copy's SHA-256, swap it in.
# This writes about as much data as the drive has free (progress shown) -
# minutes on USB 3, longer on USB 2 or a nearly empty big drive.
#
# If there's no unbroken run as big as the file while the original is still
# on the drive, --move-out first parks the original on this computer's disk
# (checksummed), deletes it from the drive to free its pieces, and retries.
#
# Usage:
# sudo iso/e2b-contig.sh [--check] [--move-out] [--temp-dir DIR] [--yes] FILE...
#
# --check Only report whether each file is contiguous.
# --move-out Allowed to park the original on this computer if needed
# (default temp dir: /var/tmp).
# --temp-dir D Where to park it.
# --yes Don't ask for confirmation.
#
# Works with NTFS (ntfs-3g or the ntfs3 kernel driver), FAT32 and exFAT -
# anything filefrag can map. Run as root (filefrag needs it on most of
# these filesystems).
################################################################################
set -uo pipefail
CHECK_ONLY=0
MOVE_OUT=0
ASSUME_YES=0
TEMP_DIR=/var/tmp
# Filler size. Small enough that fillers fit inside the drive's free gaps
# (a filler spanning two gaps can't be part of an unbroken run); kept in
# subfolders of 1000 because FAT32 caps the number of files per folder.
CHUNK_MB=4
die() { echo "ERROR: $*" >&2; exit 1; }
info() { echo "==> $*"; }
FILES=()
while [[ $# -gt 0 ]]; do
case "$1" in
--check) CHECK_ONLY=1; shift ;;
--move-out) MOVE_OUT=1; shift ;;
--temp-dir) TEMP_DIR="$2"; shift 2 ;;
--yes|-y) ASSUME_YES=1; shift ;;
-h|--help) sed -n '/^# Usage:/,/^################/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0 ;;
-*) die "unknown option: $1" ;;
*) FILES+=("$1"); shift ;;
esac
done
(( ${#FILES[@]} )) || die "no file given - usage: sudo $0 [--check] FILE..."
[[ $EUID -eq 0 ]] || die "run with sudo (filefrag needs root on NTFS/FAT/exFAT)"
for cmd in filefrag findmnt blkid sha256sum dd awk df stat; do
command -v "$cmd" &>/dev/null || die "'$cmd' not found (packages: e2fsprogs, util-linux, coreutils)"
done
# extents FILE -> "physical_start physical_end" per extent (filesystem
# blocks), in file order. Empty output = couldn't map (unsupported).
extents() {
filefrag -e "$1" 2>/dev/null | awk '
/^ *[0-9]+:/ {
gsub(/\.\./, " "); gsub(/:/, " ")
# fields: idx logical_start logical_end physical_start physical_end length ...
print $4, $5
}'
}
# Block size filefrag reports in.
fs_block_size() {
filefrag -e "$1" 2>/dev/null | sed -n 's/.*(\([0-9]*\) blocks of \([0-9]*\) bytes).*/\2/p' | head -1
}
# pieces FILE -> number of physically separate pieces (adjacent extents
# count as one; filefrag splits long runs into several extents).
pieces() {
extents "$1" | awk 'NR==1 {n=1; prev=$2; next} { if ($1 != prev + 1) n++; prev=$2 } END { print n+0 }'
}
confirm() {
(( ASSUME_YES )) && return 0
local reply
read -r -p "$1 [y/N] " reply
[[ "${reply,,}" == y || "${reply,,}" == yes ]]
}
FILL_DIR=""
TMP_COPY=""
cleanup() {
[[ -n "$FILL_DIR" && -d "$FILL_DIR" ]] && rm -rf -- "$FILL_DIR"
[[ -n "$TMP_COPY" && -f "$TMP_COPY" ]] && rm -f -- "$TMP_COPY"
sync
}
trap cleanup EXIT
trap 'echo; echo "Interrupted - removing temporary files..."; exit 130' INT TERM
# fill_and_carve MOUNT NEED_BYTES -> 0 if it left exactly one free run of at
# least NEED_BYTES (fillers stay in $FILL_DIR until cleanup), 1 if no such run.
fill_and_carve() {
local mnt="$1" need="$2"
FILL_DIR="$mnt/.e2b-contig-fill.$$"
mkdir -p "$FILL_DIR" || return 1
local free_mb
free_mb=$(( $(df -B1M --output=avail "$mnt" | tail -1) ))
info "Filling $free_mb MB of free space with temporary files to plug the gaps..."
local n=0 written=0 fname
while :; do
n=$((n + 1))
fname="$FILL_DIR/d$(printf %04d $((n / 1000)))/f$(printf %07d $n)"
mkdir -p "${fname%/*}" 2>/dev/null || break
# dd stops (keeping what it wrote) when the drive is full.
dd if=/dev/zero of="$fname" bs=1M count="$CHUNK_MB" status=none 2>/dev/null
local rc=$?
local sz
sz=$(stat -c %s "$fname" 2>/dev/null || echo 0)
written=$((written + sz))
(( n % 25 == 0 )) && printf '\r %d / %d MB' "$((written / 1048576))" "$free_mb"
(( rc != 0 || sz < CHUNK_MB * 1048576 )) && break
done
printf '\r %d / %d MB\n' "$((written / 1048576))" "$free_mb"
sync
# Single-piece fillers sorted by physical position; the longest chain
# where each starts right after the previous ends is the best run.
local bs
bs=$(fs_block_size "$FILL_DIR/d0000/f0000001")
[[ -n "$bs" ]] || { echo " Couldn't map the filler files (filefrag unsupported here)."; return 1; }
# The map lives on this computer, not on the (now full) drive.
local f list
list=$(mktemp) || return 1
info "Mapping where the filler files landed..."
for f in "$FILL_DIR"/d*/f*; do
[[ -s "$f" ]] || continue
# One filefrag call per filler (there can be thousands): keep it only
# if it's a single piece, as "start end path".
extents "$f" | awk -v f="$f" '
NR == 1 { s = $1; e = $2; n = 1; next }
{ if ($1 == e + 1) e = $2; else n++ }
END { if (NR > 0 && n == 1) print s, e, f }' >> "$list"
done
local best
best=$(sort -n "$list" | awk -v bs="$bs" '
{ if (NR == 1 || $1 != prev_end + 1) { start_line = NR; run_bytes = 0; files = "" }
run_bytes += ($2 - $1 + 1) * bs; files = files " " $3; prev_end = $2
if (run_bytes > best_bytes) { best_bytes = run_bytes; best_files = files } }
END { print best_bytes + 0, best_files }')
rm -f -- "$list"
local best_bytes=${best%% *}
info "Longest unbroken free run found: $((best_bytes / 1048576)) MB (need $((need / 1048576)) MB)"
if (( best_bytes < need )); then
return 1
fi
# Free just enough of that run (from its start) for the file - keeping the
# rest plugged so the copy can't wander elsewhere.
local freed=0 g
for g in ${best#* }; do
(( freed >= need )) && break
freed=$((freed + $(stat -c %s "$g")))
rm -f -- "$g"
done
sync
return 0
}
# restore_parked PARKED FILE SUM - copy a parked original back onto the
# drive (not contiguous - just safe) and only delete the parked copy once
# the restored one verifies.
restore_parked() {
local parked="$1" file="$2" sum="$3"
info "Putting the original back..."
if cp -- "$parked" "$file" && [[ "$(sha256sum "$file" | cut -d' ' -f1)" == "$sum" ]]; then
rm -f -- "$parked"
echo " Original restored (unchanged, still in pieces)."
else
echo " !! Couldn't verify the restored file - the original is kept safe at $parked"
fi
}
# make_contiguous FILE
make_contiguous() {
local file="$1"
[[ -f "$file" ]] || { echo "$file: not a file"; return 1; }
file=$(realpath "$file")
local mnt fstype dev
read -r dev fstype mnt < <(findmnt -n -o SOURCE,FSTYPE,TARGET --target "$file")
local size
size=$(stat -c %s "$file")
local p
p=$(pieces "$file")
echo
echo "$file"
echo " drive: $dev ($fstype) mounted at $mnt, size $((size / 1048576)) MB"
if [[ -z "$(extents "$file")" ]]; then
echo " Can't read this file's layout through this driver ($fstype)."
# FUSE drivers (fusefat, exfat-fuse) can't map files; the kernel
# drivers can - and ntfs-3g usually can, but ntfs3 always can.
local real kdrv=""
real=$(blkid -o value -s TYPE "$dev" 2>/dev/null)
case "$real" in
ntfs) kdrv=ntfs3 ;;
exfat) kdrv=exfat ;;
vfat|msdos) kdrv=vfat ;;
esac
if [[ -n "$kdrv" ]]; then
echo " Remount it with the kernel's $kdrv driver and retry:"
echo " sudo umount $dev && sudo mount -t $kdrv $dev $mnt"
fi
return 1
fi
if [[ "$p" == 1 ]]; then
echo " Contiguous (1 piece) - nothing to do."
return 0
fi
echo " NOT contiguous: $p pieces."
(( CHECK_ONLY )) && return 2
confirm " Rewrite it in one piece? (temporarily fills the drive's free space)" || { echo " Skipped."; return 2; }
info "Checksumming the original..."
local sum
sum=$(sha256sum "$file" | cut -d' ' -f1)
local source="$file" parked=""
if ! fill_and_carve "$mnt" "$size"; then
rm -rf -- "$FILL_DIR"; FILL_DIR=""; sync
if (( ! MOVE_OUT )); then
echo " Not enough unbroken free space with the original still on the drive."
echo " Re-run with --move-out to park the original on this computer meanwhile,"
echo " or free up space on the drive (move a few other large files off)."
return 1
fi
local avail
avail=$(( $(df -B1 --output=avail "$TEMP_DIR" | tail -1) ))
(( avail > size )) || { echo " Not enough room in $TEMP_DIR to park it ($((size / 1048576)) MB needed)."; return 1; }
parked="$TEMP_DIR/$(basename "$file").e2b-contig-parked"
info "Parking the original in $parked..."
cp -- "$file" "$parked" && [[ "$(sha256sum "$parked" | cut -d' ' -f1)" == "$sum" ]] \
|| { rm -f -- "$parked"; echo " Parking copy failed its checksum - nothing changed."; return 1; }
rm -f -- "$file"; sync
source="$parked"
if ! fill_and_carve "$mnt" "$size"; then
rm -rf -- "$FILL_DIR"; FILL_DIR=""; sync
echo " Still no unbroken run big enough."
restore_parked "$parked" "$file" "$sum"
echo " Free up space on the drive (it needs one unbroken $((size / 1048576)) MB area) and retry."
return 1
fi
fi
TMP_COPY="$(dirname "$file")/.e2b-contig.$$.tmp"
info "Writing the copy into the freed run..."
if ! dd if="$source" of="$TMP_COPY" bs=4M conv=fsync status=progress; then
echo " Copy failed."
rm -f -- "$TMP_COPY"; TMP_COPY=""; rm -rf -- "$FILL_DIR"; FILL_DIR=""; sync
[[ -n "$parked" ]] && restore_parked "$parked" "$file" "$sum"
return 1
fi
rm -rf -- "$FILL_DIR"; FILL_DIR=""
sync
local newp
newp=$(pieces "$TMP_COPY")
info "Verifying..."
if [[ "$(sha256sum "$TMP_COPY" | cut -d' ' -f1)" != "$sum" ]]; then
echo " !! The copy doesn't match the original's checksum - discarding it."
rm -f -- "$TMP_COPY"; TMP_COPY=""; sync
[[ -n "$parked" ]] && restore_parked "$parked" "$file" "$sum"
return 1
fi
if [[ "$newp" != 1 ]]; then
echo " The copy still came out in $newp pieces (was $p)."
fi
# Same directory, so a rename: the data stays where it was written.
mv -f -- "$TMP_COPY" "$file" && TMP_COPY=""
[[ -n "$parked" ]] && rm -f -- "$parked"
sync
if [[ "$(pieces "$file")" == 1 ]]; then
echo " ✓ Contiguous now (1 piece), checksum verified."
return 0
fi
echo " Checksum verified, but still $(pieces "$file") pieces - see the notes above."
return 1
}
rc=0
for f in "${FILES[@]}"; do
make_contiguous "$f" || rc=1
done
exit "$rc"