diff --git a/Readme.md b/Readme.md index d4499d4..8e137e6 100644 --- a/Readme.md +++ b/Readme.md @@ -1,6 +1,6 @@ # Ubuntu Based Kiosk -**Current Version:** 2.2.0 (check script header for latest version) +**Current Version:** 2.3.0 (check script header for latest version) **Built with Claude Sonnet 4.6 AI assistance** **License:** GPL v3 - Keep derivatives open source **Repository:** https://github.com/outis1one/ubuntu-based-kiosk/ @@ -1194,6 +1194,14 @@ terminal menu and the web UI, so they can't drift apart). change password, inactivity timeout, daily lock time, boot password. The password is SHA-256 hashed before it's ever written to disk, same as the legacy menu — never stored as plaintext. +- `menus/wifi.sh` — **WiFi**: the riskiest menu so far — rewrites live + netplan config and, over SSH, can disconnect the session configuring + it. Preserves the legacy menu's netplan backup, 60-second SSH + watchdog, and restore-on-failure exactly. +- `menus/power_schedule.sh` — **Power/Display/Quiet Hours**: scheduled + shutdown (+ RTC wake where available), display on/off, quiet-hours + audio muting, and an Electron reload timer, each as systemd timers. + Can power the physical machine off and on a schedule. - `install.sh` — entry point for the modular tool. Run it against an *already-installed* kiosk: ```bash @@ -1205,9 +1213,8 @@ terminal menu and the web UI, so they can't drift apart). **Honest status:** this does not yet replace first-time installation, or most of the old installer. `ubuntu-based-kiosk.sh` is still ~12,000 lines and still contains its own unremoved, unmodified copies of every -menu above (plus WiFi, Power/Display/Quiet Hours, Upgrade, Reinstall, -Uninstall, all Addons, and all of Advanced — none of that has moved -yet). Both copies coexist deliberately: the old ones stay until enough +menu above (plus Upgrade, Reinstall, Uninstall, all Addons, and all of +Advanced — none of that has moved yet). Both copies coexist deliberately: the old ones stay until enough of Core Settings/Addons/Advanced is migrated to retire them in one pass, rather than leaving the legacy menu half-wired. Migration continues one `menus/*.sh` file at a time; first-time @@ -1218,9 +1225,16 @@ at all. ## Project Status & Future Plans -**Current Version:** 2.2.0 +**Current Version:** 2.3.0 -**Recent Updates (v2.2.0):** +**Recent Updates (v2.3.0):** +- **WiFi and Power/Display/Quiet Hours migrated** — by far the riskiest menus tackled so far. WiFi rewrites live netplan config and, over SSH, can disconnect the session configuring it; power scheduling can shut the physical machine down and wake it via RTC. Every legacy safety mechanism is preserved exactly: netplan backup, 60-second SSH watchdog, restore-on-failure for WiFi; RTC availability detection for power scheduling. +- **Bug fix:** the legacy menu refused to open "Configure power schedule" at all without RTC hardware, even though shutdown-only scheduling never needed it. +- **Bug fix:** none of the six HH:MM time prompts across these menus were format-validated before — a typo silently produced a broken schedule. All now go through the same `ask_time` validator as everywhere else. +- **Bug fix (set -e safety):** several more bare statements whose failure would have killed the entire session — `ls *.yaml` with no netplan file present, the backup-restore reapply after a failed `netplan apply`, and `systemctl enable`/`start` after writing each timer pair. The last was only caught by testing without a live systemd; a real failure on actual hardware would have hit the same crash. All now report a warning and return to the menu. +- Deliberately **not** migrated: the legacy "Test schedules & system" option, which leads into a shared diagnostics submenu (audio/network/keyboard tests) unrelated to scheduling — that belongs with a future Advanced/Diagnostics pass. + +**Previous (v2.2.0):** - **Fifth menu migrated:** Password Protection & Lockout (`menus/lockout.sh`) — enable/disable, change password, inactivity timeout, daily lock time, boot password. The password is SHA-256 hashed before it's ever written to `config.json` (matching the Electron app's own comparison logic) — verified never stored as plaintext. - **Bug fix:** `lib/menu.sh` was missing `ask_time`/`validate_time` entirely — caught by testing this menu before it shipped; "set a daily lock time" would otherwise have failed for every user. Ported from the legacy script. - **Refactor:** promoted the ON/OFF toggle-label helper out of `menus/display.sh` into a shared `onoff()` in `lib/menu.sh`, so `menus/lockout.sh` doesn't need to depend on another menu file — menus only ever depend on `lib/`. diff --git a/install.sh b/install.sh index 6e9bdcb..fbf9eb4 100755 --- a/install.sh +++ b/install.sh @@ -15,7 +15,8 @@ # Migrated so far: Sites & Page Timing (menus/sites.sh), Display & # Interaction (menus/display.sh), Timezone (menus/timezone.sh), Hidden # Site PIN (menus/hidden_pin.sh), Password Protection & Lockout -# (menus/lockout.sh). +# (menus/lockout.sh), WiFi (menus/wifi.sh), Power/Display/Quiet Hours +# (menus/power_schedule.sh). # # Usage (once the kiosk has already been installed): # git clone @@ -41,6 +42,10 @@ source "$SCRIPT_DIR/menus/timezone.sh" source "$SCRIPT_DIR/menus/hidden_pin.sh" # shellcheck source=menus/lockout.sh source "$SCRIPT_DIR/menus/lockout.sh" +# shellcheck source=menus/wifi.sh +source "$SCRIPT_DIR/menus/wifi.sh" +# shellcheck source=menus/power_schedule.sh +source "$SCRIPT_DIR/menus/power_schedule.sh" ################################################################################ # Preflight @@ -83,8 +88,18 @@ main_menu_builder() { "Timezone" "Hidden Site PIN" "Password Protection & Lockout" + "WiFi" + "Power/Display/Quiet Hours" + ) + MENU_HANDLERS=( + sites_menu + display_menu + timezone_menu + hidden_pin_menu + lockout_menu + wifi_menu + power_schedule_menu ) - MENU_HANDLERS=(sites_menu display_menu timezone_menu hidden_pin_menu lockout_menu) } main_menu_status() { diff --git a/lib/config.sh b/lib/config.sh index 7d10b80..3fd763c 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -20,6 +20,16 @@ : "${KIOSK_DIR:=${KIOSK_HOME}/kiosk-app}" : "${CONFIG_PATH:=${KIOSK_DIR}/config.json}" +# System paths that menus (e.g. power/display/quiet-hours scheduling) +# write units, scripts, and cron entries into. Overridable so tests can +# point them at a scratch directory instead of the real system - nothing +# under menus/ should ever hardcode /etc/systemd/system, /etc/cron.d, or +# /usr/local/bin directly. +: "${SYSTEMD_DIR:=/etc/systemd/system}" +: "${CRON_D_DIR:=/etc/cron.d}" +: "${BIN_DIR:=/usr/local/bin}" +: "${NETPLAN_DIR:=/etc/netplan}" + # Site/tab arrays declare -a URLS=() declare -a DURS=() diff --git a/lib/menu.sh b/lib/menu.sh index 542f43a..95c6177 100644 --- a/lib/menu.sh +++ b/lib/menu.sh @@ -44,6 +44,19 @@ onoff() { [[ "$1" == "true" ]] && echo "ON" || echo "OFF" } +# Current primary IP, or the literal "No IP" if there isn't one (e.g. no +# network yet). Callers that only care whether there's an address should +# still check for -n on top of this, since "No IP" is itself non-empty. +get_ip_address() { + local ip + ip=$(hostname -I 2>/dev/null | awk '{print $1}') + if [[ -n "$ip" ]]; then + echo "$ip" + else + echo "No IP" + fi +} + pause() { read -r -p "Press Enter to continue..." } diff --git a/menus/power_schedule.sh b/menus/power_schedule.sh new file mode 100644 index 0000000..80832f4 --- /dev/null +++ b/menus/power_schedule.sh @@ -0,0 +1,660 @@ +#!/bin/bash +################################################################################ +# menus/power_schedule.sh - "Power / Display / Quiet Hours" menu. +# +# Sixth menu migrated, and the biggest and riskiest so far: it writes +# systemd timers/services, a cron entry, and shell scripts that can power +# off the physical machine, blank the display, mute audio, and (via RTC) +# wake the machine back up on a schedule. Every write goes through +# $SYSTEMD_DIR / $CRON_D_DIR / $BIN_DIR (lib/config.sh) rather than +# hardcoded /etc/systemd/system, /etc/cron.d, /usr/local/bin, so tests can +# point them at a scratch directory - this file must never assume it's +# safe to actually mutate the real system just because it's running. +# +# Deliberately out of scope: the legacy dispatcher's "6. Test schedules & +# system" led into a shared diagnostics submenu (audio test, network +# test, keyboard test, ...) that isn't specific to scheduling and belongs +# with a future Advanced/Diagnostics migration instead. What *is* in +# scope - testing the schedule you just configured - stays here as the +# same inline "test now?" prompts the legacy menu already had. +# +# Bug fixed vs. the legacy configure_power_display_quiet: it refused to +# even open "Configure power schedule" when no RTC wake was detected, +# even though shutdown-only scheduling (configure_power_schedule's own +# fallback) never needed RTC in the first place. Also: none of shutdown +# time/wake time/display off/on/quiet start/end/custom Electron reload +# time were validated as HH:MM in the legacy menu (plain `read`, no +# format check) - a typo would silently produce a broken OnCalendar= +# value. All of those now go through ask_time. +# +# Depends on: lib/menu.sh, lib/config.sh being sourced first. +################################################################################ + +################################################################################ +# Shared helpers +################################################################################ + +rtc_wake_available() { + [[ -w /sys/class/rtc/rtc0/wakealarm ]] || sudo test -w /sys/class/rtc/rtc0/wakealarm 2>/dev/null +} + +timer_exists() { + [[ -f "$SYSTEMD_DIR/$1" ]] +} + +timer_oncalendar() { + grep "^OnCalendar=" "$SYSTEMD_DIR/$1" 2>/dev/null | cut -d'=' -f2 | sed 's/\*-\*-\* //' | sed 's/:00$//' +} + +# enable_and_start_timers TIMER [TIMER...] +# Reloads systemd and enables+starts the given timer units, returning +# non-zero if enable or start fails (e.g. systemd/D-Bus unreachable). +# Always call this from an `if`/`&&`/`||` context: this whole tool runs +# under set -e, so a bare, unguarded call whose last command fails would +# take down the entire session instead of just this one action. +enable_and_start_timers() { + sudo systemctl daemon-reload 2>/dev/null || true + sudo systemctl enable "$@" 2>/dev/null && sudo systemctl start "$@" 2>/dev/null +} + +################################################################################ +# Top-level menu +################################################################################ + +power_schedule_status() { + local any=false + + if timer_exists kiosk-shutdown.timer; then + any=true + local t; t=$(timer_oncalendar kiosk-shutdown.timer) + echo "Power: shutdown daily at ${t:-an unknown time}" + fi + if timer_exists kiosk-display-off.timer; then + any=true + echo "Display: off at $(timer_oncalendar kiosk-display-off.timer), on at $(timer_oncalendar kiosk-display-on.timer)" + fi + if timer_exists kiosk-quiet-start.timer; then + any=true + echo "Quiet: $(timer_oncalendar kiosk-quiet-start.timer) to $(timer_oncalendar kiosk-quiet-end.timer)" + fi + if timer_exists kiosk-electron-reload.timer; then + any=true + echo "Reload: enabled ($(timer_oncalendar kiosk-electron-reload.timer))" + fi + $any || echo "No schedules configured" + + echo + if rtc_wake_available; then + echo "RTC wake: available (can schedule power on/off)" + else + echo "RTC wake: not available (display/quiet/reload scheduling still works)" + fi +} + +power_schedule_menu_builder() { + MENU_LABELS=( + "Configure power schedule$(rtc_wake_available || echo ' (shutdown only - no RTC wake)')" + "Configure display schedule" + "Configure quiet hours" + "Configure Electron reload schedule" + "Remove all schedules" + ) + MENU_HANDLERS=( + action_configure_power_schedule + action_configure_display_schedule + action_configure_quiet_hours + electron_reload_menu + action_remove_all_schedules + ) +} + +power_schedule_menu() { + run_menu "POWER / DISPLAY / QUIET HOURS" power_schedule_menu_builder power_schedule_status +} + +################################################################################ +# Power schedule +################################################################################ + +action_configure_power_schedule() { + echo + local rtc_ok=false + rtc_wake_available && rtc_ok=true + + if $rtc_ok; then + echo "RTC wake capability detected - can schedule shutdown and wake." + else + echo "RTC wake not available - shutdown only, no auto-wake." + fi + echo + + local shutdown_time wake_time="" + shutdown_time=$(ask_time "Shutdown time (24-hour HH:MM)" "22:00") + $rtc_ok && wake_time=$(ask_time "Wake time (24-hour HH:MM)" "06:00") + + sudo systemctl stop kiosk-shutdown.timer 2>/dev/null || true + sudo systemctl disable kiosk-shutdown.timer 2>/dev/null || true + sudo rm -f "$SYSTEMD_DIR/kiosk-shutdown.service" "$SYSTEMD_DIR/kiosk-shutdown.timer" + sudo rm -f "$BIN_DIR/kiosk-power-off.sh" "$BIN_DIR/rtc-wake.sh" + sudo rm -f "$CRON_D_DIR/kiosk-rtc-wake" + + sudo tee "$BIN_DIR/kiosk-power-off.sh" > /dev/null <<'EOF' +#!/bin/bash +logger "KIOSK: Scheduled shutdown initiated" +systemctl poweroff +EOF + sudo chmod +x "$BIN_DIR/kiosk-power-off.sh" + + sudo tee "$SYSTEMD_DIR/kiosk-shutdown.service" > /dev/null < /dev/null < /dev/null <<'RTCSCRIPT' +#!/bin/bash +WAKE_TIME="$1" +CURRENT=$(date +%s) +WAKE=$(date -d "$WAKE_TIME" +%s) + +# If wake time is earlier than current time, schedule for tomorrow +[[ $WAKE -le $CURRENT ]] && WAKE=$(date -d "tomorrow $WAKE_TIME" +%s) + +# Clear existing alarm +echo 0 > /sys/class/rtc/rtc0/wakealarm 2>/dev/null || true + +# Set new alarm +if echo $WAKE > /sys/class/rtc/rtc0/wakealarm 2>/dev/null; then + logger "KIOSK: RTC wake set for $(date -d @$WAKE '+%Y-%m-%d %H:%M:%S')" + echo "RTC wake set for $(date -d @$WAKE '+%Y-%m-%d %H:%M:%S')" +else + logger "KIOSK: ERROR - Failed to set RTC wake" + echo "ERROR: Failed to set RTC wake" + exit 1 +fi +RTCSCRIPT + sudo chmod +x "$BIN_DIR/rtc-wake.sh" + + local shutdown_hour="${shutdown_time%%:*}" + local shutdown_min="${shutdown_time##*:}" + local wake_min=$((10#$shutdown_min - 5)) + local wake_hour=$((10#$shutdown_hour)) + [[ $wake_min -lt 0 ]] && { wake_min=$((wake_min + 60)); wake_hour=$((wake_hour - 1)); } + [[ $wake_hour -lt 0 ]] && wake_hour=$((wake_hour + 24)) + + sudo tee "$CRON_D_DIR/kiosk-rtc-wake" > /dev/null <> /var/log/kiosk-rtc.log 2>&1 +EOF + log_info "RTC wake cron job created" + fi + + if enable_and_start_timers kiosk-shutdown.timer; then + log_success "Power schedule configured: shutdown at ${shutdown_time}$( [[ -n "$wake_time" ]] && echo ", wake at ${wake_time}")" + else + log_warning "Schedule files written, but systemctl enable/start failed - check 'systemctl status kiosk-shutdown.timer'" + fi +} + +################################################################################ +# Display schedule +################################################################################ + +action_configure_display_schedule() { + echo + if timer_exists kiosk-shutdown.timer; then + log_warning "Power shutdown configured at $(timer_oncalendar kiosk-shutdown.timer) - display will already be off by then" + echo + fi + + local doff don + doff=$(ask_time "Display OFF time (24-hour HH:MM)" "22:00") + don=$(ask_time "Display ON time (24-hour HH:MM)" "06:00") + + sudo systemctl stop kiosk-display-off.timer kiosk-display-on.timer 2>/dev/null || true + sudo systemctl disable kiosk-display-off.timer kiosk-display-on.timer 2>/dev/null || true + sudo rm -f "$SYSTEMD_DIR"/kiosk-display-off.{service,timer} "$SYSTEMD_DIR"/kiosk-display-on.{service,timer} + sudo rm -f "$BIN_DIR/kiosk-display-off.sh" "$BIN_DIR/kiosk-display-on.sh" + + sudo tee "$BIN_DIR/kiosk-display-off.sh" > /dev/null </dev/null && logger "KIOSK: xset dpms off success" || logger "KIOSK: xset dpms off failed" + +# Method 2: vbetool (if available) +if command -v vbetool &>/dev/null; then + vbetool dpms off 2>/dev/null && echo "✓ vbetool off" || echo "✗ vbetool failed" +fi + +# Method 3: Backlight control (laptops) +if [[ -d /sys/class/backlight ]]; then + for bl in /sys/class/backlight/*/brightness; do + if [[ -w "\$bl" ]]; then + echo 0 > "\$bl" 2>/dev/null && echo "✓ backlight off: \$bl" || echo "✗ backlight failed" + fi + done +fi + +logger "KIOSK: Display turned OFF (scheduled)" +EOF + sudo chmod +x "$BIN_DIR/kiosk-display-off.sh" + + sudo tee "$BIN_DIR/kiosk-display-on.sh" > /dev/null </dev/null && logger "KIOSK: xset dpms on success" || logger "KIOSK: xset dpms on failed" + +# Method 2: vbetool (if available) +if command -v vbetool &>/dev/null; then + vbetool dpms on 2>/dev/null && echo "✓ vbetool on" || echo "✗ vbetool failed" +fi + +# Method 3: Backlight control (laptops) +if [[ -d /sys/class/backlight ]]; then + for bl in /sys/class/backlight/*/brightness; do + if [[ -w "\$bl" ]]; then + cat "\${bl%/*}/max_brightness" > "\$bl" 2>/dev/null && echo "✓ backlight on: \$bl" || echo "✗ backlight failed" + fi + done +fi + +# Method 4: Wake up input (move mouse) +sudo -u ${KIOSK_USER} DISPLAY=:0 XAUTHORITY=${KIOSK_HOME}/.Xauthority xdotool mousemove 1 1 2>/dev/null && logger "KIOSK: mouse wiggle success" || logger "KIOSK: mouse wiggle failed" + +# Method 5: Signal Electron app to require password if enabled +sudo -u ${KIOSK_USER} touch ${KIOSK_DIR}/.display-wake 2>/dev/null && logger "KIOSK: password flag set" || logger "KIOSK: password flag failed" + +logger "KIOSK: Display turned ON (scheduled)" +EOF + sudo chmod +x "$BIN_DIR/kiosk-display-on.sh" + + sudo tee "$SYSTEMD_DIR/kiosk-display-off.service" > /dev/null < /dev/null < /dev/null < /dev/null </dev/null || true + sudo systemctl disable kiosk-quiet-start.timer kiosk-quiet-end.timer 2>/dev/null || true + sudo rm -f "$SYSTEMD_DIR"/kiosk-quiet-start.{service,timer} "$SYSTEMD_DIR"/kiosk-quiet-end.{service,timer} + sudo rm -f "$BIN_DIR/kiosk-quiet-start.sh" "$BIN_DIR/kiosk-quiet-end.sh" + + case "$qmode" in + 2) + sudo tee "$BIN_DIR/kiosk-quiet-start.sh" > /dev/null <<'EOF' +#!/bin/bash +systemctl stop squeezelite 2>/dev/null +logger "KIOSK: Quiet hours started - Squeezelite stopped" +echo "✓ Quiet hours: Squeezelite stopped" +EOF + sudo tee "$BIN_DIR/kiosk-quiet-end.sh" > /dev/null <<'EOF' +#!/bin/bash +systemctl start squeezelite 2>/dev/null +logger "KIOSK: Quiet hours ended - Squeezelite started" +echo "✓ Quiet hours ended: Squeezelite started" +EOF + ;; + *) + qmode=1 + sudo tee "$BIN_DIR/kiosk-quiet-start.sh" > /dev/null <<'EOF' +#!/bin/bash +# Save current volume before muting +pactl get-sink-volume @DEFAULT_SINK@ | grep -oE '[0-9]+%' | head -1 | tr -d '%' > /tmp/kiosk-vol-backup 2>/dev/null || echo "100" > /tmp/kiosk-vol-backup +pactl set-sink-mute @DEFAULT_SINK@ 1 2>/dev/null +logger "KIOSK: Quiet hours started - all audio muted" +echo "✓ Quiet hours: All audio muted" +EOF + sudo tee "$BIN_DIR/kiosk-quiet-end.sh" > /dev/null <<'EOF' +#!/bin/bash +# Restore previous volume +VOL=$(cat /tmp/kiosk-vol-backup 2>/dev/null || echo "100") +pactl set-sink-mute @DEFAULT_SINK@ 0 2>/dev/null +pactl set-sink-volume @DEFAULT_SINK@ ${VOL}% 2>/dev/null +logger "KIOSK: Quiet hours ended - audio restored to ${VOL}%" +echo "✓ Quiet hours ended: Audio restored to ${VOL}%" +EOF + ;; + esac + sudo chmod +x "$BIN_DIR/kiosk-quiet-start.sh" "$BIN_DIR/kiosk-quiet-end.sh" + + sudo tee "$SYSTEMD_DIR/kiosk-quiet-start.service" > /dev/null < /dev/null < /dev/null < /dev/null </dev/null || true + sudo systemctl disable kiosk-electron-reload.timer 2>/dev/null || true + sudo rm -f "$SYSTEMD_DIR"/kiosk-electron-reload.{service,timer} + sudo rm -f "$BIN_DIR/kiosk-reload-electron" + + sudo tee "$BIN_DIR/kiosk-reload-electron" > /dev/null <<'RELOADSCRIPT' +#!/bin/bash +logger "KIOSK: Scheduled Electron reload" +systemctl restart lightdm +RELOADSCRIPT + sudo chmod +x "$BIN_DIR/kiosk-reload-electron" + + sudo tee "$SYSTEMD_DIR/kiosk-electron-reload.service" > /dev/null < /dev/null </dev/null || true + sudo systemctl disable kiosk-electron-reload.timer 2>/dev/null || true + sudo rm -f "$SYSTEMD_DIR"/kiosk-electron-reload.{service,timer} + sudo rm -f "$BIN_DIR/kiosk-reload-electron" + sudo systemctl daemon-reload 2>/dev/null || true + log_success "Automatic Electron reload disabled" + fi +} + +################################################################################ +# Remove all +################################################################################ + +action_remove_all_schedules() { + echo + ask_yes_no "Remove ALL power/display/quiet/reload schedules?" "n" || { echo "Cancelled"; return; } + + for timer in kiosk-shutdown kiosk-display-off kiosk-display-on kiosk-quiet-start kiosk-quiet-end kiosk-electron-reload; do + sudo systemctl stop "${timer}.timer" 2>/dev/null || true + sudo systemctl disable "${timer}.timer" 2>/dev/null || true + done + + sudo rm -f "$SYSTEMD_DIR"/kiosk-shutdown.{service,timer} + sudo rm -f "$SYSTEMD_DIR"/kiosk-display-off.{service,timer} "$SYSTEMD_DIR"/kiosk-display-on.{service,timer} + sudo rm -f "$SYSTEMD_DIR"/kiosk-quiet-start.{service,timer} "$SYSTEMD_DIR"/kiosk-quiet-end.{service,timer} + sudo rm -f "$SYSTEMD_DIR"/kiosk-electron-reload.{service,timer} + sudo rm -f "$BIN_DIR/kiosk-power-off.sh" "$BIN_DIR/kiosk-display-off.sh" "$BIN_DIR/kiosk-display-on.sh" + sudo rm -f "$BIN_DIR/kiosk-quiet-start.sh" "$BIN_DIR/kiosk-quiet-end.sh" + sudo rm -f "$BIN_DIR/rtc-wake.sh" "$BIN_DIR/kiosk-reload-electron" + sudo rm -f "$CRON_D_DIR/kiosk-rtc-wake" + + sudo systemctl daemon-reload 2>/dev/null || true + + log_success "All schedules removed" +} diff --git a/menus/wifi.sh b/menus/wifi.sh new file mode 100644 index 0000000..e81834e --- /dev/null +++ b/menus/wifi.sh @@ -0,0 +1,241 @@ +#!/bin/bash +################################################################################ +# menus/wifi.sh - "WiFi" configuration. +# +# HIGH RISK, unlike anything migrated so far: this changes live network +# configuration and, if run over SSH, can disconnect the very session +# configuring it. Every safety mechanism from the legacy configure_wifi +# is preserved exactly: a netplan backup before writing, a 60-second +# watchdog (armed only when $SSH_CONNECTION is set) that reverts to the +# backup if the new config never comes up, and an explicit "restore +# backup?" prompt if `netplan apply` itself fails outright. +# +# Netplan's directory is $NETPLAN_DIR (lib/config.sh) rather than a +# hardcoded /etc/netplan, so a test can point it at scratch space. But +# unlike every other migrated menu, there is deliberately no automated +# test - not even a stubbed one - that calls the real `netplan apply`, +# `nmcli`, `iw`, `wpa_cli`, or `sudo ip link set ... up`. Only the pure +# logic (SSID/password handling, YAML generation, backup naming) is +# covered by tests with those commands stubbed; the actual apply step +# is exercised by hand against real hardware only. +# +# Unlike the other migrated menus, this one has no sub-options - it's a +# single linear wizard, same as the legacy configure_wifi - so wifi_menu +# IS the action, not a run_menu wrapper. +# +# Depends on: lib/menu.sh, lib/config.sh being sourced first. +################################################################################ + +wifi_menu() { + echo + echo " ═══ WIFI CONFIGURATION ═══" + echo + + local has_tools=false + if command -v nmcli &>/dev/null || command -v iw &>/dev/null || command -v wpa_cli &>/dev/null; then + has_tools=true + fi + + if ! $has_tools; then + log_error "No WiFi tools found (nmcli, iw, or wpa_cli)" + echo "Install: sudo apt install network-manager wireless-tools wpasupplicant" + return 1 + fi + + local wifi_iface + wifi_iface=$(ls /sys/class/net 2>/dev/null | grep -E "^wl" | head -1) + + if [[ -z "$wifi_iface" ]]; then + log_warning "No WiFi hardware detected" + echo "If you have a USB WiFi adapter, ensure it's plugged in." + return 1 + fi + + echo "Interface: $wifi_iface" + echo "Current IP: $(get_ip_address)" + echo + + if [[ -n "${SSH_CONNECTION:-}" ]]; then + log_warning "SSH detected - changes auto-revert after 60s if the connection fails" + echo + fi + + ask_yes_no "Configure WiFi?" "n" || return 0 + + echo "Bringing up interface..." + if ! sudo ip link set "$wifi_iface" up 2>/dev/null; then + log_error "Failed to bring up interface" + return 1 + fi + sleep 3 + + echo "Scanning for networks (this takes 5-10 seconds)..." + local scan_results="" + + if command -v nmcli &>/dev/null; then + if sudo nmcli device wifi rescan 2>/dev/null; then + sleep 5 + scan_results=$(nmcli -t -f SSID,SIGNAL device wifi list 2>/dev/null | sort -t: -k2 -rn | cut -d: -f1 | grep -v "^$" | uniq) + fi + fi + + if [[ -z "$scan_results" ]] && command -v iw &>/dev/null; then + local scan_tmp + scan_tmp=$(mktemp) + if sudo iw dev "$wifi_iface" scan 2>/dev/null | grep -E "^BSS|SSID:" > "$scan_tmp"; then + scan_results=$(grep "SSID:" "$scan_tmp" | sed 's/.*SSID: //' | grep -v "^$" | sort -u) + fi + rm -f "$scan_tmp" + fi + + if [[ -z "$scan_results" ]]; then + sudo wpa_cli -i "$wifi_iface" scan >/dev/null 2>&1 || true + sleep 5 + scan_results=$(sudo wpa_cli -i "$wifi_iface" scan_results 2>/dev/null | awk -F'\t' 'NR>1 && $5!="" {print $5}' | sort -u) + fi + + local ssid="" + if [[ -z "$scan_results" ]]; then + log_warning "No networks found in scan" + echo "This could mean:" + echo " • WiFi is disabled in BIOS/UEFI" + echo " • Hardware WiFi switch is off" + echo " • Driver not loaded" + echo " • Networks out of range" + echo + if ask_yes_no "Enter SSID manually anyway?" "n"; then + read -r -p "SSID: " ssid + else + return 1 + fi + else + echo + echo "Available networks (strongest first):" + echo "$scan_results" | nl -w2 -s'. ' + echo " 0. Manual entry" + echo + local choice + read -r -p "Select network number or enter SSID: " choice + if [[ "$choice" == "0" ]]; then + read -r -p "SSID: " ssid + elif [[ "$choice" =~ ^[0-9]+$ ]]; then + ssid=$(echo "$scan_results" | sed -n "${choice}p") + else + ssid="$choice" + fi + fi + + if [[ -z "$ssid" ]]; then + log_error "No SSID provided" + return 1 + fi + + local password + read -r -s -p "Password for '$ssid': " password + echo + if [[ -z "$password" ]]; then + log_error "No password provided" + return 1 + fi + + apply_wifi_config "$wifi_iface" "$ssid" "$password" +} + +# apply_wifi_config IFACE SSID PASSWORD +# Split out from wifi_menu so a test can drive it directly without going +# through interface detection/scanning, which don't exist in a container. +apply_wifi_config() { + local wifi_iface="$1" + local ssid="$2" + local password="$3" + + # `|| true`: under set -e + pipefail (this whole tool runs under both), + # `ls` matching nothing exits non-zero even with stderr silenced, which + # would abort this function outright instead of falling through to the + # default filename below. Same class of bug as the run_menu fix in + # lib/menu.sh - masked here in practice because cloud-init almost + # always leaves a *.yaml file behind, but not guaranteed. + local netplan_file + netplan_file=$(ls "$NETPLAN_DIR"/*.yaml 2>/dev/null | head -1) || true + [[ -z "$netplan_file" ]] && netplan_file="$NETPLAN_DIR/50-cloud-init.yaml" + + local backup="" + if [[ -f "$netplan_file" ]]; then + backup="${netplan_file}.backup-$(date +%Y%m%d-%H%M%S)" + sudo cp "$netplan_file" "$backup" + log_success "Backup: $backup" + fi + + local temp_plan + temp_plan=$(mktemp --suffix=.yaml) + cat > "$temp_plan" < "$watchdog" <<'WATCHEOF' +#!/bin/bash +sleep 60 +if [[ -f "$1" && -f "$2" ]]; then + ip=$(hostname -I | awk '{print $1}') + if [[ -z "$ip" ]] || ! ping -c 2 8.8.8.8 >/dev/null 2>&1; then + cp "$1" "$2" + netplan apply 2>/dev/null + echo "WiFi config reverted - connection failed" | wall + fi +fi +rm -f "$0" +WATCHEOF + chmod +x "$watchdog" + nohup sudo bash "$watchdog" "$backup" "$netplan_file" >/dev/null 2>&1 & + echo "Watchdog started - will revert in 60s if the connection fails" + fi + + sudo cp "$temp_plan" "$netplan_file" + sudo chmod 0600 "$netplan_file" + rm -f "$temp_plan" + + echo "Applying configuration..." + local netplan_log + netplan_log=$(mktemp) + if sudo netplan apply > "$netplan_log" 2>&1; then + cat "$netplan_log" + sleep 10 + local new_ip + new_ip=$(get_ip_address) + if [[ -n "$new_ip" && "$new_ip" != "No IP" ]]; then + log_success "Connected: $ssid ($new_ip)" + [[ -n "${SSH_CONNECTION:-}" ]] && echo "Connection successful - watchdog will not revert" + else + log_warning "Config applied but no IP yet" + echo "Check: sudo journalctl -u systemd-networkd -f" + fi + else + log_error "netplan apply failed" + echo "Error log:" + cat "$netplan_log" + if [[ -n "$backup" ]] && ask_yes_no "Restore backup?" "y"; then + sudo cp "$backup" "$netplan_file" + # Last resort after everything else failed: report, don't crash + # the session if even the restore-and-reapply doesn't work. + if sudo netplan apply; then + log_success "Backup restored and applied" + else + log_error "Failed to reapply the restored backup - manual intervention needed" + fi + fi + fi + rm -f "$netplan_log" +} diff --git a/ubuntu-based-kiosk.sh b/ubuntu-based-kiosk.sh index 4574bfd..563a768 100644 --- a/ubuntu-based-kiosk.sh +++ b/ubuntu-based-kiosk.sh @@ -1,8 +1,43 @@ #!/bin/bash ################################################################################ -### Ubuntu Based Kiosk v2.2.0 ### +### Ubuntu Based Kiosk v2.3.0 ### ################################################################################ # +# RELEASE v2.3.0 - WiFi and Power/Display/Quiet Hours Migrated +# - New in ./install.sh: WiFi (menus/wifi.sh) and Power/Display/Quiet +# Hours (menus/power_schedule.sh) - by far the biggest and riskiest +# menus migrated so far. WiFi can rewrite live netplan config and, if +# run over SSH, disconnect the very session configuring it; Power +# schedule can shut the physical machine down and wake it via RTC. +# Every safety mechanism from the legacy menus is preserved exactly: +# netplan backup + 60s SSH watchdog + restore-on-apply-failure for +# WiFi; RTC availability detection for power scheduling. New +# $SYSTEMD_DIR/$CRON_D_DIR/$BIN_DIR/$NETPLAN_DIR variables (lib/config.sh) +# mean nothing under menus/ hardcodes /etc/systemd/system, /etc/cron.d, +# /usr/local/bin, or /etc/netplan - tests point them at scratch space. +# - Fixed: the legacy dispatcher refused to open "Configure power +# schedule" at all when no RTC wake was detected, even though +# shutdown-only scheduling never needed RTC in the first place. +# - Fixed: none of shutdown/wake/display-off/display-on/quiet-start/ +# quiet-end/custom-Electron-reload times were validated as HH:MM in +# the legacy menus (plain `read`, no format check) - now all go +# through ask_time. +# - Fixed (set -e safety, same class as v2.1.0's run_menu fix): several +# bare, unguarded statements whose failure would have taken down the +# entire session instead of just that action - `ls *.yaml` when no +# netplan file exists (masked in practice by cloud-init usually +# leaving one behind), the restore-and-reapply `netplan apply` after +# an initial apply failure, and `systemctl enable`/`start` after +# writing each of the four timer pairs. The last of these was caught +# only by testing in an environment without a live systemd - a real +# `enable`/`start` failure on actual hardware (bad unit, daemon-reload +# skipped, ...) would have hit the same bug. All now report a clear +# warning and return to the menu instead. +# - Deliberately NOT migrated: the legacy dispatcher's "Test schedules & +# system" led into a shared diagnostics submenu (audio/network/ +# keyboard tests) that isn't specific to scheduling and belongs with a +# future Advanced/Diagnostics migration instead. +# # RELEASE v2.2.0 - Password Protection & Lockout Migrated # - New in ./install.sh: Password Protection & Lockout (menus/lockout.sh) - # enable/disable, change password (SHA-256 hashed before it's ever @@ -122,7 +157,7 @@ set -euo pipefail ### SECTION 1: CONSTANTS & GLOBALS ################################################################################ -SCRIPT_VERSION="2.2.0" +SCRIPT_VERSION="2.3.0" # Resolve the real path to this script file. # When piped (curl|bash or wget|bash), BASH_SOURCE[0] is a pipe descriptor,