From 600f5174d897b92a1ce2be0770b4948dbce20f6d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 19:48:09 +0000 Subject: [PATCH] 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