From d4f9233655830c199c83559e058e1732d0a2f375 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 18:42:28 +0000 Subject: [PATCH 1/2] 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 Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm --- Readme.md | 22 ++++ iso/e2b-contig.sh | 303 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 325 insertions(+) create mode 100755 iso/e2b-contig.sh diff --git a/Readme.md b/Readme.md index 62ba852..a3218b4 100644 --- a/Readme.md +++ b/Readme.md @@ -138,6 +138,27 @@ Notes: times, quiet hours and power schedules drift with 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 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). @@ -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. - **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. +- **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. - **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. diff --git a/iso/e2b-contig.sh b/iso/e2b-contig.sh new file mode 100755 index 0000000..4f8a06b --- /dev/null +++ b/iso/e2b-contig.sh @@ -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" From 600f5174d897b92a1ce2be0770b4948dbce20f6d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 19:48:09 +0000 Subject: [PATCH 2/2] Add a WiFi screen to the kiosk app (Shift+W+S+F+H) With the virtual consoles turned off there was no way to join a WiFi network at the kiosk itself. Holding Shift+W+S+F+H together (or Ctrl+Alt+Shift+Super+W) now opens a PIN-protected WiFi screen: scan, pick or type a network, enter the password, connect. The root side is a small helper (kiosk-wifi-helper: status, scan, connect only) that the kiosk user may run through a validated sudoers.d rule. It writes the same netplan file as Core Settings -> WiFi and puts the previous one back if the new network gives no address. Installed by provisioning and Advanced -> Upgrade, removed by Complete Uninstall. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm --- Readme.md | 14 + kiosk-app/main.js | 170 ++++++++++- kiosk-app/wifi.html | 253 ++++++++++++++++ lib/provision.sh | 24 ++ menus/complete_uninstall.sh | 1 + .../files/usr/local/bin/kiosk-wifi-helper | 273 ++++++++++++++++++ 6 files changed, 734 insertions(+), 1 deletion(-) create mode 100644 kiosk-app/wifi.html create mode 100755 provision/files/usr/local/bin/kiosk-wifi-helper diff --git a/Readme.md b/Readme.md index a3218b4..77af9a2 100644 --- a/Readme.md +++ b/Readme.md @@ -492,6 +492,19 @@ Both can be used at the same time — they serve different purposes: - **Multi-method WiFi scan** - nmcli, iw, wpa_cli fallbacks - **Watchdog support** - Auto-revert bad WiFi configs - **Emergency hotspot** - Fallback if no internet +- **WiFi from the kiosk screen** - hold **Shift+W+S+F+H** together (or press Ctrl+Alt+Shift+Super+W) to join a WiFi network without the admin console - see below + +### WiFi from the kiosk screen (consoles turned off) + +With virtual consoles switched off, there's no way to reach the admin menu at the kiosk itself. To join a WiFi network anyway, plug in a keyboard and **hold Shift, W, S, F and H all at once** (or press Ctrl+Alt+Shift+Super+W). A WiFi screen opens: + +1. Enter the hidden-sites PIN (the same one as F10 - default `1234`; skipped if the PIN is turned off). +2. Pick a network from the scan (strongest first, 🔒 = needs a password), or type its name. +3. Type the password (empty for an open network) and press **Connect**. + +It writes the same netplan file as Core Settings → WiFi (`/etc/netplan/60-kiosk-wifi.yaml`), so the connection persists across reboots. If the kiosk gets no address within 30 seconds (usually a wrong password) the previous WiFi setting is put back. Esc or Close shuts the screen; it also closes itself after 5 minutes untouched, and five wrong PINs close it. The keys only work as a chord (typing W-S-F-H one at a time does nothing), and they don't interfere with the tab or power shortcuts. + +The root part is `/usr/local/bin/kiosk-wifi-helper` (status/scan/connect only), which the kiosk user may run via sudo through `/etc/sudoers.d/kiosk-wifi` - nothing else. Existing kiosks get it with Advanced → Upgrade. --- @@ -1512,6 +1525,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. - **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. +- **New: WiFi from the kiosk screen** - hold Shift+W+S+F+H (or Ctrl+Alt+Shift+Super+W) to scan for and join a WiFi network right on the kiosk, PIN-protected, for kiosks with the consoles turned off. Reverts to the previous WiFi if the new one doesn't connect. See "WiFi from the kiosk screen". - **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. - **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. diff --git a/kiosk-app/main.js b/kiosk-app/main.js index 84deff2..cf8908d 100644 --- a/kiosk-app/main.js +++ b/kiosk-app/main.js @@ -1,5 +1,5 @@ const {app,BrowserWindow,BrowserView,globalShortcut,ipcMain,dialog,session}=require('electron'); -const {exec}=require('child_process'); +const {exec,spawn}=require('child_process'); const fs=require('fs'); const path=require('path'); const os=require('os'); @@ -159,6 +159,7 @@ function showLockoutScreen(){ isLockedOut=true; console.log('[LOCKOUT] Showing lockout screen'); + closeWifiWindow(); // Detach all browser views to prevent content from being visible console.log('[LOCKOUT] Detaching all browser views for security'); @@ -1189,7 +1190,170 @@ function showHiddenTab(index){ showingHidden=true; } +// WiFi screen (hold Shift+W+S+F+H, or Ctrl+Alt+Shift+Super+W): join a WiFi network from the kiosk +// itself, for when the console (Ctrl+Alt+F1..F6) is switched off and the +// admin menu can't be reached. Gated by the hidden-tab PIN (.jitsi-pin, +// same rules as pin-entry.html: no file = 1234, NOPIN = none). The root +// work is done by /usr/local/bin/kiosk-wifi-helper, which the kiosk user +// may run through sudo and nothing else (/etc/sudoers.d/kiosk-wifi). +const WIFI_HELPER='/usr/local/bin/kiosk-wifi-helper'; +const WIFI_IDLE_TIMEOUT=5*60000; // closes after 5 minutes untouched +const WIFI_MAX_PIN_TRIES=5; +let wifiWindow=null,wifiUnlocked=false,wifiPinTries=0,wifiIdleTimer=null; + +function readHiddenPin(){ + try{ + const stored=fs.readFileSync(path.join(__dirname,'.jitsi-pin'),'utf8').trim(); + if(stored==='NOPIN')return null; + if(stored)return stored; + }catch(e){} + return '1234'; +} + +function runWifiHelper(action,input){ + return new Promise(resolve=>{ + let out='',err='',done=false; + const finish=result=>{if(!done){done=true;resolve(result);}}; + let child; + try{ + child=spawn('sudo',['-n',WIFI_HELPER,action],{stdio:['pipe','pipe','pipe']}); + }catch(e){ + finish({ok:false,error:'Could not run the WiFi helper: '+e.message}); + return; + } + const killer=setTimeout(()=>{child.kill();finish({ok:false,error:'The WiFi helper timed out'});},120000); + child.stdout.on('data',d=>{out+=d;}); + child.stderr.on('data',d=>{err+=d;}); + child.on('error',e=>{clearTimeout(killer);finish({ok:false,error:'Could not run the WiFi helper: '+e.message});}); + child.on('close',()=>{ + clearTimeout(killer); + const line=out.trim().split('\n').pop(); + try{ + finish(JSON.parse(line)); + }catch(e){ + // Not our JSON - typically sudo refusing ("a password is required") + // on a kiosk set up before this feature: Upgrade installs the rule. + console.error('[WIFI] helper output:',out,err); + finish({ok:false,error:(err.trim()||'The WiFi helper is not installed')+' - run Advanced -> Upgrade from the admin menu'}); + } + }); + child.stdin.on('error',()=>{}); + child.stdin.end(input?JSON.stringify(input):''); + }); +} + +function resetWifiIdleTimer(){ + if(wifiIdleTimer)clearTimeout(wifiIdleTimer); + wifiIdleTimer=setTimeout(()=>{ + console.log('[WIFI] Closing after 5 minutes idle'); + closeWifiWindow(); + },WIFI_IDLE_TIMEOUT); +} + +function closeWifiWindow(){ + if(wifiWindow&&!wifiWindow.isDestroyed())wifiWindow.close(); +} + +function showWifiWindow(){ + if(isLockedOut)return; + if(wifiWindow&&!wifiWindow.isDestroyed()){ + wifiWindow.focus(); + return; + } + wifiUnlocked=(readHiddenPin()===null); + wifiPinTries=0; + let width=720,height=820; + if(mainWindow&&!mainWindow.isDestroyed()){ + const[w,h]=mainWindow.getContentSize(); + width=Math.min(width,w); + height=Math.min(height,h); + } + wifiWindow=new BrowserWindow({ + width,height, + frame:false, + alwaysOnTop:true, + parent:mainWindow, + modal:true, + webPreferences:{nodeIntegration:true,contextIsolation:false} + }); + wifiWindow.setMenu(null); + wifiWindow.loadFile(path.join(__dirname,'wifi.html')); + wifiWindow.on('closed',()=>{ + if(wifiIdleTimer){clearTimeout(wifiIdleTimer);wifiIdleTimer=null;} + wifiWindow=null; + wifiUnlocked=false; + }); + resetWifiIdleTimer(); + console.log('[WIFI] WiFi screen opened'); +} + +// Shift+W+S+F+H, all held together, opens the WiFi screen. A globalShortcut +// can only have one non-modifier key, so this watches the raw key events +// of every page/window instead (before-input-event): a key counts as held +// from its keyDown (autorepeat keeps refreshing it) until its keyUp, or +// for 1.5s if the keyUp never arrives (focus moved mid-press). Physical +// keys (input.code), so it's the same keys on any keyboard layout. The +// key presses that complete the chord are swallowed, not typed into the +// page. +const WIFI_CHORD_KEYS=['KeyW','KeyS','KeyF','KeyH']; +const WIFI_CHORD_HOLD_MS=1500; +const wifiChordDown=new Map(); + +function wifiChordInput(event,input){ + if(!WIFI_CHORD_KEYS.includes(input.code))return; + if(input.type==='keyUp'){wifiChordDown.delete(input.code);return;} + if(input.type!=='keyDown')return; + const now=Date.now(); + wifiChordDown.set(input.code,now); + if(!input.shift||input.control||input.alt||input.meta)return; + const all=WIFI_CHORD_KEYS.every(k=>now-(wifiChordDown.get(k)||0){ + contents.on('before-input-event',wifiChordInput); +}); + +ipcMain.handle('wifi-request',async(event,req)=>{ + if(!wifiWindow||wifiWindow.isDestroyed()||event.sender!==wifiWindow.webContents){ + return{ok:false,error:'Not allowed'}; + } + resetWifiIdleTimer(); + const action=req&&req.action; + if(action==='close'){closeWifiWindow();return{ok:true};} + if(action==='needs-pin')return{ok:true,needsPin:!wifiUnlocked}; + if(action==='unlock'){ + if(wifiUnlocked)return{ok:true}; + if(String(req.pin||'')===readHiddenPin()){ + wifiUnlocked=true; + return{ok:true}; + } + wifiPinTries++; + console.log('[WIFI] Wrong PIN, attempt',wifiPinTries); + if(wifiPinTries>=WIFI_MAX_PIN_TRIES){ + setTimeout(closeWifiWindow,1500); + return{ok:false,error:'Too many wrong PINs'}; + } + return{ok:false,error:'Incorrect PIN'}; + } + if(!wifiUnlocked)return{ok:false,error:'Enter the PIN first'}; + if(action==='status'||action==='scan')return runWifiHelper(action); + if(action==='connect'){ + console.log('[WIFI] Connecting to',JSON.stringify(String(req.ssid||''))); + const result=await runWifiHelper('connect',{ssid:String(req.ssid||''),password:String(req.password||'')}); + console.log('[WIFI] Connect result:',result.ok?('ok '+result.ip):result.error); + return result; + } + return{ok:false,error:'Unknown request'}; +}); + function forceReturnToTabs(){ + closeWifiWindow(); if(pinWindow&&!pinWindow.isDestroyed()){ pinWindow.close(); pinWindow=null; @@ -1625,6 +1789,10 @@ async function createWindow(){ globalShortcut.register('Control+Alt+Delete',showPowerMenu); globalShortcut.register('Control+Alt+P',showPowerMenu); globalShortcut.register('Control+Alt+Escape',showPowerMenu); + // Five keys on purpose - not something a visitor hits by accident. + if(!globalShortcut.register('Control+Alt+Shift+Super+W',showWifiWindow)){ + console.error('[WIFI] Could not register Ctrl+Alt+Shift+Super+W'); + } globalShortcut.register('Control+K',()=>{ if(htmlKeyboardWindow&&!htmlKeyboardWindow.isDestroyed()){ closeHTMLKeyboard(); diff --git a/kiosk-app/wifi.html b/kiosk-app/wifi.html new file mode 100644 index 0000000..b9a167d --- /dev/null +++ b/kiosk-app/wifi.html @@ -0,0 +1,253 @@ + + + + + WiFi + + + +

📶 WiFi

+ + + + + +
+
Esc closes this screen. Closes by itself after 5 minutes untouched.
+ + + + diff --git a/lib/provision.sh b/lib/provision.sh index d1fb3a5..96bf4ba 100644 --- a/lib/provision.sh +++ b/lib/provision.sh @@ -216,10 +216,34 @@ provision_configure_display() { sudo udevadm control --reload-rules provision_configure_lightdm_autologin + provision_install_wifi_helper log_success "Display configured" } +# The kiosk app's WiFi screen (Ctrl+Alt+Shift+Super+W) - joining WiFi from +# the kiosk itself when the console is switched off. The app runs as the +# kiosk user; the netplan write is done by this one root helper, which +# the kiosk user may run through sudo and nothing else (it only does +# status/scan/connect - see its header). Here rather than its own +# provisioning step so Advanced -> Upgrade, which re-runs this function, +# installs it on an existing kiosk too. The sudoers rule is validated +# before it goes in place, like the Web UI's: a broken sudoers.d file +# breaks sudo for everyone. +provision_install_wifi_helper() { + provision_install_file "usr/local/bin/kiosk-wifi-helper" "$BIN_DIR/kiosk-wifi-helper" 755 + local tmp + tmp=$(mktemp) + echo "${KIOSK_USER} ALL=(root) NOPASSWD: ${BIN_DIR}/kiosk-wifi-helper" > "$tmp" + if sudo visudo -c -f "$tmp" &>/dev/null; then + sudo mkdir -p "$SUDOERS_D_DIR" + sudo install -m 0440 -o root -g root "$tmp" "$SUDOERS_D_DIR/kiosk-wifi" + else + log_warning "Couldn't set up the kiosk WiFi screen's permission - Ctrl+Alt+Shift+Super+W won't be able to connect" + fi + rm -f "$tmp" +} + provision_configure_firewall() { echo "[6/10] Configuring firewall..." sudo ufw --force enable diff --git a/menus/complete_uninstall.sh b/menus/complete_uninstall.sh index 0230d45..c0f3747 100644 --- a/menus/complete_uninstall.sh +++ b/menus/complete_uninstall.sh @@ -116,6 +116,7 @@ action_complete_uninstall() { echo "[7/12] Removing remaining scripts..." sudo rm -f "$BIN_DIR"/kiosk-* + sudo rm -f "$SUDOERS_D_DIR/kiosk-wifi" sudo rm -f /etc/udev/rules.d/99-kiosk-hotplug.rules sudo udevadm control --reload-rules 2>/dev/null || true diff --git a/provision/files/usr/local/bin/kiosk-wifi-helper b/provision/files/usr/local/bin/kiosk-wifi-helper new file mode 100755 index 0000000..234909d --- /dev/null +++ b/provision/files/usr/local/bin/kiosk-wifi-helper @@ -0,0 +1,273 @@ +#!/bin/bash +################################################################################ +# kiosk-wifi-helper - the root side of the kiosk app's WiFi screen +# (Ctrl+Alt+Shift+Super+W), for joining a WiFi network from the kiosk +# itself when the console (Ctrl+Alt+F1..F6) is switched off. +# +# The kiosk user may run this one script via passwordless sudo +# (/etc/sudoers.d/kiosk-wifi, written by lib/provision.sh) - it does a +# fixed set of things and nothing else: +# +# kiosk-wifi-helper status {"iface","connected","ssid","ip","configured"} +# kiosk-wifi-helper scan {"iface","networks":[{"ssid","signal","secure"}]} +# kiosk-wifi-helper connect stdin {"ssid","password"} -> {"ok","ip"|"error"} +# +# Every reply is one JSON line on stdout, exit 0 on success. connect +# writes the same netplan file as Core Settings -> WiFi (menus/wifi.sh, +# 60-kiosk-wifi.yaml) and puts the previous one back if the interface +# gets no address - a typo in the password doesn't lose a WiFi network +# that was working. The password comes in on stdin, never argv (argv is +# visible to every user in ps). +################################################################################ +set -uo pipefail + +NETPLAN_DIR="${NETPLAN_DIR:-/etc/netplan}" +SYS_CLASS="${KIOSK_WIFI_SYS_CLASS:-/sys/class}" # overridable for tests +WIFI_NETPLAN_FILE="$NETPLAN_DIR/60-kiosk-wifi.yaml" +LOCK_FILE="${KIOSK_WIFI_LOCK:-/run/kiosk-wifi-helper.lock}" +CONNECT_WAIT="${KIOSK_WIFI_CONNECT_WAIT:-30}" + +reply_error() { + jq -cn --arg e "$1" '{ok:false,error:$e}' + exit 1 +} + +# The first wireless interface, by what it is (sysfs wireless/ or +# phy80211), falling back to the wl* naming convention. Same as +# menus/wifi.sh's wifi_detect_iface. +detect_iface() { + local d + for d in $SYS_CLASS/net/*; do + if [[ -d "$d/wireless" || -e "$d/phy80211" ]]; then + basename "$d" + return 0 + fi + done + for d in $SYS_CLASS/net/wl*; do + [[ -e "$d" ]] || continue + basename "$d" + return 0 + done + return 1 +} + +# Clear an rfkill soft block (a hard block can't be cleared from software). +unblock() { + local r + for r in $SYS_CLASS/rfkill/rfkill*; do + [[ "$(cat "$r/type" 2>/dev/null)" == "wlan" ]] || continue + [[ "$(cat "$r/soft" 2>/dev/null)" == "1" ]] && echo 0 > "$r/soft" 2>/dev/null + done + return 0 +} + +hard_blocked() { + local r + for r in $SYS_CLASS/rfkill/rfkill*; do + [[ "$(cat "$r/type" 2>/dev/null)" == "wlan" ]] || continue + [[ "$(cat "$r/hard" 2>/dev/null)" == "1" ]] && return 0 + done + return 1 +} + +iface_ip() { + ip -4 -o addr show dev "$1" 2>/dev/null \ + | awk '{for (i = 1; i < NF; i++) if ($i == "inet") { sub("/.*", "", $(i+1)); print $(i+1); exit }}' +} + +# YAML single-quoted scalar - the quote itself doubled is the only escape. +yaml_quote() { + local v="$1" + printf "'%s'" "${v//\'/\'\'}" +} + +# iw prints a backslash, a leading/trailing space and anything +# unprintable in an SSID as \xNN (a backslash itself is always \x5c, +# so there are no other escapes) - %b turns those back into the bytes. +unescape_ssid() { + printf '%b' "$1" +} + +# The SSID the interface is associated with right now, if any. +current_ssid() { + unescape_ssid "$(iw dev "$1" link 2>/dev/null | sed -n 's/^[[:space:]]*SSID: //p' | head -1)" +} + +# The SSID in our netplan file, if any (the first access point's key). +configured_ssid() { + [[ -f "$WIFI_NETPLAN_FILE" ]] || return 0 + python3 -I -c ' +import sys, yaml +try: + d = yaml.safe_load(open(sys.argv[1])) + for w in (d.get("network", {}).get("wifis") or {}).values(): + for ssid in (w.get("access-points") or {}): + print(ssid); sys.exit(0) +except Exception: + pass' "$WIFI_NETPLAN_FILE" 2>/dev/null +} + +need_iface() { + IFACE=$(detect_iface) || reply_error "No WiFi adapter found. If it's a USB adapter, check it's plugged in." + command -v iw >/dev/null || reply_error "WiFi support (iw, wpasupplicant) isn't installed - run Core Settings -> WiFi once from the admin menu" +} + +do_status() { + local iface ssid="" ip="" configured + configured=$(configured_ssid) + if ! iface=$(detect_iface); then + jq -cn --arg c "$configured" '{ok:true,iface:"",connected:false,ssid:"",ip:"",configured:$c}' + return 0 + fi + ssid=$(current_ssid "$iface") + ip=$(iface_ip "$iface") + jq -cn --arg i "$iface" --arg s "$ssid" --arg a "$ip" --arg c "$configured" \ + '{ok:true,iface:$i,connected:($s != "" and $a != ""),ssid:$s,ip:$a,configured:$c}' +} + +do_scan() { + need_iface + unblock + hard_blocked && reply_error "WiFi is switched off by a hardware switch or in the BIOS/UEFI" + ip link set "$IFACE" up 2>/dev/null || reply_error "Couldn't switch on $IFACE" + + # "Device or resource busy" while wpa_supplicant is mid-scan - retry. + local out="" try + for try in 1 2 3; do + out=$(iw dev "$IFACE" scan 2>/dev/null) && break + out="" + sleep 2 + done + # wpa_supplicant owns the interface and refuses iw: use its last scan. + if [[ -z "$out" ]] && command -v wpa_cli >/dev/null; then + wpa_cli -i "$IFACE" scan >/dev/null 2>&1 + sleep 4 + wpa_cli -i "$IFACE" scan_results 2>/dev/null | awk -F'\t' ' + NR > 1 && $5 != "" { secure = ($4 ~ /WPA|WEP|RSN|SAE/) ? 1 : 0; print $3 "\t" secure "\t" $5 }' \ + > "$TMP_SCAN" + else + # BSS blocks: signal, SSID, and RSN/WPA/Privacy for "secured". + printf '%s\n' "$out" | awk ' + function flush() { if (ssid != "") print sig "\t" sec "\t" ssid; ssid = ""; sig = -100; sec = 0 } + /^BSS / { flush() } + /^\tsignal:/ { sig = int($2) } + /^\tSSID: / { sub(/^\tSSID: /, ""); ssid = $0 } + /^\t(RSN|WPA):/ { sec = 1 } + /^\tcapability:.*Privacy/ { sec = 1 } + END { flush() }' \ + | while IFS=$'\t' read -r sig sec ssid; do + printf '%s\t%s\t%s\n' "$sig" "$sec" "$(unescape_ssid "$ssid")" + done > "$TMP_SCAN" + fi + + # Strongest first, one entry per SSID. + jq -cR --arg i "$IFACE" -n ' + [inputs | split("\t") | select(length >= 3) + | {signal: (.[0] | tonumber? // -100), secure: (.[1] == "1"), ssid: (.[2:] | join("\t"))}] + | group_by(.ssid) | map(max_by(.signal)) | sort_by(-.signal) + | {ok: true, iface: $i, networks: .}' < "$TMP_SCAN" +} + +do_connect() { + need_iface + local input ssid password + input=$(head -c 4096) + ssid=$(jq -er '.ssid | strings' <<< "$input" 2>/dev/null) || reply_error "No network name given" + password=$(jq -r '.password // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request" + + local ssid_bytes + ssid_bytes=$(printf '%s' "$ssid" | wc -c) + (( ssid_bytes >= 1 && ssid_bytes <= 32 )) || reply_error "A network name is 1-32 characters" + # No control characters (newlines etc.) in either - they can't be in a + # real SSID/passphrase and would only serve to break the YAML. + [[ "$ssid$password" == *[[:cntrl:]]* ]] && reply_error "The name or password contains an invalid character" + if [[ -n "$password" ]]; then + local plen=${#password} + if ! (( plen >= 8 && plen <= 63 )) && ! [[ "$password" =~ ^[0-9A-Fa-f]{64}$ ]]; then + reply_error "A WiFi password is 8-63 characters" + fi + fi + + unblock + hard_blocked && reply_error "WiFi is switched off by a hardware switch or in the BIOS/UEFI" + + local backup="" new + mkdir -p "$NETPLAN_DIR" + if [[ -f "$WIFI_NETPLAN_FILE" ]]; then + backup="${WIFI_NETPLAN_FILE}.backup-$(date +%Y%m%d-%H%M%S)" + cp -p "$WIFI_NETPLAN_FILE" "$backup" + fi + + new=$(mktemp "$NETPLAN_DIR/.kiosk-wifi.XXXXXX") + { + echo "network:" + echo " version: 2" + echo " renderer: networkd" + echo " wifis:" + echo " $IFACE:" + echo " dhcp4: true" + echo " dhcp6: false" + echo " optional: true" + echo " access-points:" + if [[ -n "$password" ]]; then + echo " $(yaml_quote "$ssid"):" + echo " password: $(yaml_quote "$password")" + else + echo " $(yaml_quote "$ssid"): {}" + fi + } > "$new" + chmod 0600 "$new" + mv -f "$new" "$WIFI_NETPLAN_FILE" + + local err + if ! err=$(netplan apply 2>&1); then + restore "$backup" + reply_error "netplan apply failed: ${err:0:300}" + fi + + local i addr="" now + for (( i = 0; i < CONNECT_WAIT; i++ )); do + now=$(current_ssid "$IFACE") + addr=$(iface_ip "$IFACE") + [[ "$now" == "$ssid" && -n "$addr" ]] && break + addr="" + sleep 1 + done + + if [[ -z "$addr" ]]; then + restore "$backup" + if [[ -n "$backup" ]]; then + reply_error "Couldn't connect to \"$ssid\" - wrong password, or out of range. The previous WiFi setting was put back." + fi + reply_error "Couldn't connect to \"$ssid\" - wrong password, or out of range." + fi + jq -cn --arg s "$ssid" --arg a "$addr" --arg i "$IFACE" '{ok:true,ssid:$s,ip:$a,iface:$i}' +} + +# Put the previous netplan WiFi file back (or remove ours if there was +# none) and re-apply, after a failed connect. +restore() { + if [[ -n "$1" && -f "$1" ]]; then + mv -f "$1" "$WIFI_NETPLAN_FILE" + else + rm -f "$WIFI_NETPLAN_FILE" + fi + netplan apply >/dev/null 2>&1 +} + +[[ $EUID -eq 0 ]] || reply_error "must run as root (sudo)" +command -v jq >/dev/null || { echo '{"ok":false,"error":"jq is not installed"}'; exit 1; } + +TMP_SCAN=$(mktemp) +trap 'rm -f "$TMP_SCAN"' EXIT + +# One at a time - two overlapping netplan applies/backups would race. +exec 9> "$LOCK_FILE" +flock -w 60 9 || reply_error "Another WiFi change is still running - try again in a moment" + +case "${1:-}" in + status) do_status ;; + scan) do_scan ;; + connect) do_connect ;; + *) reply_error "unknown action: ${1:-}" ;; +esac