diff --git a/.gitignore b/.gitignore index c2658d7..557d4b9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules/ +webui/test/fixtures/.fake-state/ diff --git a/Readme.md b/Readme.md index f54abe2..3dabbee 100644 --- a/Readme.md +++ b/Readme.md @@ -1,6 +1,6 @@ # Ubuntu Based Kiosk -**Current Version:** 2.16.0 (check script header for latest version) +**Current Version:** 2.17.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/ @@ -1291,13 +1291,17 @@ terminal menu and the web UI, so they can't drift apart). steps, so any code or hardware-config change picked up by the pull actually takes effect. Also offers an on-demand Electron version check. See "Recent Updates (v2.15.0)" below. -- `webui/` + `menus/addon_webui.sh` — **Web UI** (Addons): a small - Node/Express app installed as a systemd service under `$KIOSK_USER`, - giving a browser-based editor for Sites & Page Timing, Display & - Interaction, and Password Protection & Lockout — no login of its own, - put it behind your own reverse proxy with Authelia forward-auth if - it needs to be reachable beyond a trusted LAN. See "Recent Updates - (v2.16.0)" below. +- `webui/` + `menus/addon_webui.sh` — **Web UI** (Addons, installed by + default during provisioning): a small Node/Express app running as a + systemd service under `$KIOSK_USER` with zero ambient `sudo`, giving + a browser-based editor for Sites & Page Timing, Display & Interaction, + and Password Protection & Lockout, plus install/reconfigure for CUPS, + LMS/Squeezelite, and Asterisk Intercom, and Update — each reached + through a narrow, allow-listed root helper + (`webui_write_helper_script`) rather than any ambient privilege on + the service itself. No login of its own, put it behind your own + reverse proxy with Authelia forward-auth if it needs to be reachable + beyond a trusted LAN. See "Recent Updates (v2.17.0)" below. - `install.sh` — entry point for the modular tool, now grouped **Core Settings / Addons / Advanced** like the legacy menu. On a machine with no kiosk installed yet, it provisions one first (see @@ -1353,9 +1357,18 @@ full migration pass. ## Project Status & Future Plans -**Current Version:** 2.16.0 +**Current Version:** 2.17.0 -**Recent Updates (v2.16.0):** +**Recent Updates (v2.17.0):** +- **Web UI now installs by default** during first-time provisioning (fixed port 8090, no prompt) instead of being opt-in — the Addons menu entry still works standalone for reconfiguring the port or reinstalling it on a kiosk provisioned before this change. +- **The web UI can now install/reconfigure CUPS Printing, LMS Server, Squeezelite Player, and Asterisk Intercom, and check for updates** — the same four addons plus Update named directly. Every one of these is the exact same interactive `action_*` function the terminal menu already uses (no prompt/mutation refactor of any addon file), driven by piping the right answers on stdin — the same technique this project's own bash tests already use to drive these functions. +- **Privilege model:** the web service itself still runs as `$KIOSK_USER` with zero ambient `sudo`. A new narrow, allow-listed root helper is the only way it ever gains privilege — reachable only via a single-path passwordless sudo rule (generated and validated with `visudo -c -f` before being installed), and it re-checks its own fixed action allow-list before dispatching anything. Chosen over running the whole service as root after asking directly: since this repo has no login of its own by design, a request that reaches the web UI with no reverse proxy in front is effectively unauthenticated, so the allow-list bounds what that can actually do to five vetted actions, never a root shell. +- Long-running installs stream live output to the browser via Server-Sent Events, one action at a time — a second request while one is in flight gets a clear `409`, never silently queued or dropped. +- **Full visual redesign:** a sidebar shell (Sites/Display/Lockout/Addons/Update) replacing the single scrolling page of three cards, both light and dark themes via `prefers-color-scheme`, no external font/CDN dependency. +- A real bug was found and fixed by actually driving the redesigned UI in a headless browser, not just by reading the code: refreshing an addon's pill/button after a successful install used to rebuild the whole card, which raced (and usually lost to) the success status/log that same job had just written a moment earlier. Fixed to update the pill/buttons in place, leaving the completed job's log exactly as the user left it. +- Uninstall-via-web is deliberately still not offered, for any addon — flagged as needing its own double-confirmation design, not bundled into this pass. WiFi, Timezone, Power/Display/Quiet Hours, Diagnostics, Remote Access, Authelia, Factory Reset, Virtual Consoles, Emergency Hotspot, Clone Settings, and the fleet/multi-kiosk dashboard all remain out of scope for the web UI too — each a named, sequenced follow-up, not an oversight. + +**Previous (v2.16.0):** - **New: Web UI** (Addons → Web UI) — a small Node/Express app (`webui/`) giving a browser-based editor for Sites & Page Timing, Display & Interaction, and Password Protection & Lockout, the three Core Settings menus that are pure `config.json` read/write with no privileged system mutation involved. Runs as a systemd service under `$KIOSK_USER` (the same user Electron runs as), so it never needs `sudo` — it reads/writes `config.json` with normal filesystem permissions. - `webui/lib/config.js` re-implements `lib/config.sh`'s exact field list, defaults, and merge-on-save contract in JS — `kiosk-app/main.js` already reads the same `config.json` directly in JS, so this isn't a new pattern — meaning it can never silently clobber fields it doesn't track (Authelia's credentials, the unused quiet-hours fields, etc), the same failure mode previously fixed in `lib/config.sh`'s own history. - **No login of its own, by design.** Authelia runs elsewhere; the expectation is a reverse proxy (e.g. Caddy) with Authelia forward-auth in front of it, the same way other self-hosted apps get protected — Authelia integration is explicitly out of scope for this repo. Direct LAN access with no proxy in front has no authentication at all — treat it like SSH access to the kiosk. diff --git a/lib/config.sh b/lib/config.sh index b9fce29..9c66a5f 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -33,6 +33,13 @@ : "${WIREGUARD_DIR:=/etc/wireguard}" : "${WEBUI_DIR:=/opt/kiosk-webui}" : "${WEBUI_ENV_DIR:=/etc/kiosk-webui}" +: "${SUDOERS_D_DIR:=/etc/sudoers.d}" +# The one root-owned script the Web UI's addon-install/Update actions are +# allowed to invoke via passwordless sudo (menus/addon_webui.sh writes it +# and the matching /etc/sudoers.d/kiosk-webui rule - see that file's +# header). Under $BIN_DIR since that's already the convention for every +# other addon's own scripts. +: "${WEBUI_HELPER_PATH:=$BIN_DIR/kiosk-webui-helper}" # The admin account actually running this tool (as opposed to $KIOSK_USER, # the kiosk's own restricted account) - used where an addon needs to grant diff --git a/lib/provision.sh b/lib/provision.sh index 24fc64d..961eff2 100644 --- a/lib/provision.sh +++ b/lib/provision.sh @@ -38,7 +38,8 @@ # which is more risk than this pass should take on. # # Depends on: lib/menu.sh, lib/config.sh, lib/electron.sh being sourced -# first, and every menus/*.sh this calls into for configuration. +# first, and every menus/*.sh this calls into for configuration +# (including menus/addon_webui.sh, for provision_configure_webui below). ################################################################################ PROVISION_FILES="$SCRIPT_DIR/provision/files" @@ -54,7 +55,7 @@ provision_install_file() { } provision_install_packages() { - echo "[1/9] Installing packages..." + echo "[1/10] Installing packages..." sudo apt update sudo apt install -y \ xorg openbox lightdm unclutter screen curl git build-essential \ @@ -79,7 +80,7 @@ provision_install_packages() { } provision_create_kiosk_user() { - echo "[2/9] Creating kiosk user..." + echo "[2/10] Creating kiosk user..." if ! id "$KIOSK_USER" &>/dev/null; then sudo useradd -m -s /bin/bash -G audio,video,input,plugdev,netdev "$KIOSK_USER" echo "$KIOSK_USER:kiosk" | sudo chpasswd @@ -98,7 +99,7 @@ provision_create_kiosk_user() { } provision_install_nodejs() { - echo "[3/9] Installing Node.js..." + echo "[3/10] Installing Node.js..." if ! command -v node &>/dev/null; then curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - sudo apt install -y nodejs @@ -107,7 +108,7 @@ provision_install_nodejs() { } provision_install_app() { - echo "[4/9] Installing kiosk app..." + echo "[4/10] Installing kiosk app..." sudo cp "$KIOSK_APP_SRC"/*.js "$KIOSK_APP_SRC"/*.html "$KIOSK_APP_SRC/package.json" "$KIOSK_APP_SRC/start.sh" "$KIOSK_DIR/" sudo chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR"/*.js "$KIOSK_DIR"/*.html "$KIOSK_DIR/package.json" "$KIOSK_DIR/start.sh" sudo chmod +x "$KIOSK_DIR/start.sh" @@ -150,7 +151,7 @@ EOF } provision_configure_display() { - echo "[5/9] Configuring display (LightDM/Openbox/touch/video)..." + echo "[5/10] Configuring display (LightDM/Openbox/touch/video)..." sudo -u "$KIOSK_USER" mkdir -p "$KIOSK_HOME/.config/openbox" "$KIOSK_HOME/.config/pulse" sudo mkdir -p /etc/X11/xorg.conf.d @@ -183,7 +184,7 @@ provision_configure_display() { } provision_configure_firewall() { - echo "[6/9] Configuring firewall..." + echo "[6/10] Configuring firewall..." sudo ufw --force enable sudo ufw default deny incoming sudo ufw default allow outgoing @@ -192,7 +193,7 @@ provision_configure_firewall() { } provision_configure_power_management() { - echo "[7/9] Configuring power management..." + echo "[7/10] Configuring power management..." sudo mkdir -p /etc/polkit-1/localauthority/50-local.d provision_install_file "etc/polkit-1/localauthority/50-local.d/kiosk-power.pkla" /etc/polkit-1/localauthority/50-local.d/kiosk-power.pkla @@ -226,11 +227,43 @@ provision_configure_power_management() { fi } +# Installed by default now, not opt-in - reuses menus/addon_webui.sh's +# own install helpers directly rather than duplicating them, with a +# fixed default port and no prompt (first-time install already has +# plenty). See that file's header for the privilege model (a narrow +# allow-listed root helper, not the service itself running as root) and +# why this exists at all: browser-based config editing, and - via that +# helper - install/reconfigure for CUPS/LMS/Squeezelite/Asterisk +# Intercom and Update, none of which are reimplemented here. +provision_configure_webui() { + echo "[8/10] Installing Web UI..." + if ! webui_install_app_files; then + log_warning "Web UI install failed - configure later: Addons -> Web UI" + return 0 + fi + + local port="8090" + webui_write_env_file "$port" + webui_write_unit_file + webui_write_helper_script + if ! webui_write_sudoers_file; then + log_warning "Web UI installed but its addon-install/Update helper could not be granted permission - configure later: Addons -> Web UI" + return 0 + fi + + if enable_and_start_units kiosk-webui; then + sudo ufw allow "${port}/tcp" comment 'Kiosk Web UI' 2>/dev/null || true + log_success "Web UI installed: http://$(get_ip_address):${port}" + else + log_warning "Web UI installed but failed to start - check: sudo journalctl -u kiosk-webui -n 50" + fi +} + # Configuration from here on is NOT reimplemented - it's the exact same # Core Settings / Advanced menus this tool already uses to manage a # kiosk after install, called directly instead of duplicated. provision_configure_kiosk_settings() { - echo "[8/9] Configuring kiosk settings..." + echo "[9/10] Configuring kiosk settings..." echo "Core Settings is next - sites, timezone, touch/navigation," echo "password protection, WiFi, and schedules. Skip and configure" echo "later via ./install.sh if you'd rather do this after reboot." @@ -256,7 +289,7 @@ provision_configure_kiosk_settings() { } provision_finish() { - echo "[9/9] Done." + echo "[10/10] Done." echo log_success "Core installation complete!" echo @@ -289,6 +322,9 @@ run_first_time_install() { echo " - Hardware video acceleration" echo " - Audio support (PipeWire)" echo " - Time synchronization (NTP)" + echo " - Web UI (browser-based Sites/Display/Lockout editor, plus" + echo " addon install/reconfigure and Update - no login of its own," + echo " see Addons -> Web UI)" echo echo "OPTIONAL (configure after install, via Addons):" echo " - Lyrion Music Server (LMS) / Squeezelite" @@ -317,6 +353,7 @@ run_first_time_install() { provision_configure_display provision_configure_firewall provision_configure_power_management + provision_configure_webui provision_configure_kiosk_settings provision_finish return 0 diff --git a/menus/addon_webui.sh b/menus/addon_webui.sh index 950f01b..b454ed3 100644 --- a/menus/addon_webui.sh +++ b/menus/addon_webui.sh @@ -3,24 +3,48 @@ # menus/addon_webui.sh - "Web UI" addon: installs webui/ (a small Node/ # Express app) as a systemd service running as $KIOSK_USER, giving a # browser-based editor for Sites & Page Timing, Display & Interaction, -# and Password Protection & Lockout - the three Core Settings menus that -# are pure config.json read/write with no privileged system mutation -# involved. The remaining Core Settings/Addons/Advanced menus (WiFi, -# Timezone, Power/Display/Quiet Hours, Complete Uninstall, every other -# addon, everything in Advanced) stay terminal-only for now - a -# network-facing process shouldn't be handed sudo-level system mutation -# without a lot more thought than this first pass gives it. +# and Password Protection & Lockout - config.json read/write only, no +# sudo needed, since that file is already owned by $KIOSK_USER. +# +# Installed by default during first-time provisioning now +# (lib/provision.sh's provision_configure_webui, which calls the same +# install helpers this file defines) - this menu remains here for +# reconfiguring the port, restarting the service, or reinstalling it on +# a kiosk provisioned before this change. +# +# Also lets the web UI trigger a fixed, vetted set of privileged actions +# - install/reconfigure CUPS/LMS/Squeezelite/Asterisk Intercom, and +# Update - through a narrow, allow-listed root helper +# (webui_write_helper_script below), rather than by giving the service +# itself any elevated privilege. The helper is reachable only via a +# single-path passwordless sudo rule (webui_write_sudoers_file) and +# re-checks its own fixed action allow-list before dispatching anything, +# even though the sudoers rule alone already restricts which script can +# run - defense in depth. Each allow-listed action is the exact same +# interactive `action_*` function the terminal menu already uses, +# driven by piping the right answers on stdin, the same technique this +# project's own bash tests already use (see webui/lib/actions.js and +# webui/test/*.test.js). No prompt/mutation refactor of any addon file +# was needed for this to work. Everything else (WiFi, Timezone, Power/ +# Display/Quiet Hours, Complete Uninstall, Remote Access, Authelia, +# Factory Reset, Virtual Consoles, Emergency Hotspot, Clone Settings) +# stays terminal-only for now. # # No login of its own, by design: this addon assumes it'll be put behind # a reverse proxy (e.g. Caddy) with Authelia forward-auth in front, the # same way other self-hosted apps get protected - Authelia integration # is explicitly out of scope for this repo (Authelia runs elsewhere). # Direct LAN access with no proxy in front has no authentication at all - -# treat it the same as SSH access to this kiosk. +# treat it the same as SSH access to this kiosk, which is also the +# access level the privileged helper effectively grants if reached +# without a proxy in front: bounded to its fixed action list, not a +# root shell, but real system mutation all the same. # -# webui/'s own app-level logic (config.json schema/merge, API validation) -# lives and is tested entirely under webui/ - this file only wires it up -# as a system service and never touches config.json itself. +# webui/'s own app-level logic (config.json schema/merge, API +# validation, the action allow-list/stdin synthesis, the job/SSE +# system) lives and is tested entirely under webui/ - this file only +# wires it up as a system service plus the privileged helper, and never +# touches config.json itself. # # Depends on: lib/menu.sh, lib/config.sh being sourced first. ################################################################################ @@ -102,9 +126,122 @@ webui_write_env_file() { PORT=${port} BIND_ADDR=0.0.0.0 CONFIG_PATH=${CONFIG_PATH} +HELPER_PATH=${WEBUI_HELPER_PATH} EOF } +# Writes the one root-owned script the Web UI is ever allowed to reach +# via sudo (see the sudoers rule webui_write_sudoers_file writes right +# after this). Sources the exact same files install.sh does - full path +# list baked in at generation time from $SCRIPT_DIR, so the generated +# script has no runtime dependency on where it happens to be invoked +# from. The fixed ALLOWED_ACTIONS allow-list inside the script itself is +# the real gate (checked in addition to, not instead of, the sudoers +# rule only permitting this one script path) - a request that reaches +# this script can only ever trigger one of these exact, already-tested +# interactive `action_*` functions, driven the same way this project's +# own bash tests already drive them: real answers piped in on stdin, in +# the exact order the function's own prompts expect them. No prompt/ +# mutation refactor of any addon file needed for this to work. +webui_write_helper_script() { + sudo mkdir -p "$(dirname "$WEBUI_HELPER_PATH")" + sudo tee "$WEBUI_HELPER_PATH" > /dev/null <&2 + exit 1 +fi + +"\$action" +EOF + sudo chown root:root "$WEBUI_HELPER_PATH" + sudo chmod 750 "$WEBUI_HELPER_PATH" +} + +# Grants $KIOSK_USER passwordless sudo on exactly this one script path - +# no argument wildcarding at the sudoers level, since the script's own +# ALLOWED_ACTIONS check above is the real gate. Validated with +# `visudo -c -f` on a temp file before it's moved into place: a +# malformed sudoers snippet can break sudo system-wide, so this step is +# never skipped. +webui_write_sudoers_file() { + local tmp + tmp=$(mktemp) + echo "${KIOSK_USER} ALL=(root) NOPASSWD: ${WEBUI_HELPER_PATH}" > "$tmp" + + if ! sudo visudo -c -f "$tmp" &>/dev/null; then + log_error "Generated sudoers rule failed validation - not installed" + rm -f "$tmp" + return 1 + fi + + sudo mkdir -p "$SUDOERS_D_DIR" + sudo install -m 0440 -o root -g root "$tmp" "$SUDOERS_D_DIR/kiosk-webui" + rm -f "$tmp" +} + webui_write_unit_file() { sudo tee "$SYSTEMD_DIR/kiosk-webui.service" > /dev/null </dev/null || true @@ -204,6 +347,7 @@ webui_do_uninstall() { sudo systemctl disable kiosk-webui 2>/dev/null || true sudo rm -f "$SYSTEMD_DIR/kiosk-webui.service" sudo systemctl daemon-reload 2>/dev/null || true + sudo rm -f "$SUDOERS_D_DIR/kiosk-webui" "$WEBUI_HELPER_PATH" sudo rm -rf "$WEBUI_DIR" "$WEBUI_ENV_DIR" log_success "Web UI removed" } diff --git a/ubuntu-based-kiosk.sh b/ubuntu-based-kiosk.sh index 001e8af..a152cf0 100644 --- a/ubuntu-based-kiosk.sh +++ b/ubuntu-based-kiosk.sh @@ -1,8 +1,61 @@ #!/bin/bash ################################################################################ -### Ubuntu Based Kiosk v2.16.0 ### +### Ubuntu Based Kiosk v2.17.0 ### ################################################################################ # +# RELEASE v2.17.0 - Web UI Now Installs by Default and Can Install/ +# Reconfigure Addons + Check for Updates +# - Web UI (Addons -> Web UI) now installs by default during first-time +# provisioning (lib/provision.sh's provision_configure_webui), not +# opt-in - fixed port 8090, no prompt (matches every other core step). +# The Addons menu entry still works standalone for reconfiguring the +# port or reinstalling it on a kiosk provisioned before this change. +# - The web UI can now install/reconfigure CUPS Printing, LMS Server, +# Squeezelite Player, and Asterisk Intercom, and check for updates - +# the same four addons plus Update this project's user asked for by +# name. Every one of these is the exact same interactive action_* +# function the terminal menu already uses (action_install_cups, +# action_install_lms, action_install_squeezelite, +# action_configure_asterisk_intercom, action_upgrade) - no +# prompt/mutation refactor of any addon file, driven instead by +# piping the right answers on stdin, the same technique this +# project's own bash tests already use to drive these functions. +# - Privilege model: the web service itself still runs as $KIOSK_USER +# with zero ambient sudo. A new narrow, allow-listed root helper +# (menus/addon_webui.sh's webui_write_helper_script, reachable only +# via a single-path passwordless sudo rule generated and validated +# with `visudo -c -f` before being installed) is the only way the web +# UI ever gains privilege, and it re-checks its own fixed action +# allow-list before dispatching anything - a request that reaches it +# can only ever trigger one of five vetted actions, never a root +# shell. Chosen over running the whole service as root after asking +# directly: this repo has no login of its own by design (Authelia +# runs elsewhere), so a request that reaches it with no reverse proxy +# in front is effectively unauthenticated - the allow-list bounds +# what that can actually do. +# - Long-running installs stream live output to the browser via +# Server-Sent Events (webui/lib/jobs.js), with only one action +# running at a time (a second request while one is in flight gets a +# clear 409, not silently queued or dropped). +# - Full visual redesign: a sidebar shell (Sites/Display/Lockout/ +# Addons/Update) replacing the single scrolling page of three cards, +# both light and dark themes via prefers-color-scheme, no external +# font/CDN dependency. +# - A real bug was found and fixed by actually driving the redesigned +# UI in a headless browser, not just by reading the code: refreshing +# an addon's pill/button after a successful install used to rebuild +# the whole card, which raced (and usually lost to) the success +# status/log that same job had just written a moment earlier. Fixed +# to update pill/buttons in place, leaving the completed job's log +# exactly as the user left it. +# - Uninstall-via-web is deliberately still not offered, for any addon - +# flagged as needing its own double-confirmation design, not bundled +# into this pass. WiFi, Timezone, Power/Display/Quiet Hours, +# Diagnostics, Remote Access, Authelia, Factory Reset, Virtual +# Consoles, Emergency Hotspot, Clone Settings, and the fleet/ +# multi-kiosk dashboard all remain out of scope for the web UI too, +# each a named, sequenced follow-up rather than an oversight. +# # RELEASE v2.16.0 - Web UI: Browser-Based Config Editor (install.sh -> # Addons -> Web UI) # - New: a small Node/Express app (webui/) installable via ./install.sh's @@ -580,7 +633,7 @@ set -euo pipefail ### SECTION 1: CONSTANTS & GLOBALS ################################################################################ -SCRIPT_VERSION="2.16.0" +SCRIPT_VERSION="2.17.0" # Resolve the real path to this script file. # When piped (curl|bash or wget|bash), BASH_SOURCE[0] is a pipe descriptor, diff --git a/webui/lib/actions.js b/webui/lib/actions.js new file mode 100644 index 0000000..8a9476d --- /dev/null +++ b/webui/lib/actions.js @@ -0,0 +1,171 @@ +'use strict'; + +// webui/lib/actions.js - the web UI's allow-list of privileged actions, +// and how to turn a web form's fields into the exact stdin sequence the +// real bash `action_*` function expects. +// +// Mirrors menus/addon_webui.sh's ALLOWED_ACTIONS array (inside the +// generated kiosk-webui-helper script) - kept in sync by hand rather +// than shared/generated, since both lists are short and deliberately +// curated. A mismatch between the two just means an action fails closed +// on whichever side is missing it, never open on both: server.js checks +// this list before spawning anything, and the helper script re-checks +// its own list before dispatching regardless of what server.js sent. +// +// Every buildStdin() below was verified against the real menus/*.sh +// source (prompt order, defaults, and which fields reject a blank +// answer and re-prompt) - see webui/test/actions.test.js, which drives +// the actual bash functions with this exact output and checks the real +// resulting state (cups_is_installed, lms_is_installed, etc), not just +// that the process exits 0. + +const ACTIONS = { + install_cups: { + helperAction: 'action_install_cups', + label: 'Install CUPS Printing', + fields: [], + // action_install_cups's only prompt is "Install CUPS printing? + // (n)" - the web UI's own install button is the confirmation, + // so this always answers yes. No pause() in this function. + buildStdin() { + return 'y\n'; + }, + }, + + reconfigure_cups: { + helperAction: 'action_reconfigure_cups', + label: 'Reconfigure CUPS for network access', + fields: [], + // No prompts at all, no pause(). + buildStdin() { + return ''; + }, + }, + + install_lms: { + helperAction: 'action_install_lms', + label: 'Install / reconfigure LMS Server', + // fields.alreadyInstalled must reflect real current state + // (server.js fills this in from lms_is_installed via the + // helper's own status check before offering the reconfigure + // fields) - action_install_lms branches on it internally and a + // wrong guess here desyncs the stdin sequence from what the + // real function actually prompts for. + fields: ['alreadyInstalled', 'reconfigurePort', 'newPort'], + buildStdin(f) { + if (f.alreadyInstalled) { + if (!f.reconfigurePort) { + return 'n\n\n'; // decline reconfigure, then pause() + } + const port = Number(f.newPort); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error('newPort must be an integer 1-65535'); + } + return `y\n${port}\n\n`; // accept, new port, pause() + } + return '\n'; // fresh install: fully automated except pause() + }, + }, + + install_squeezelite: { + helperAction: 'action_install_squeezelite', + label: 'Install / reconfigure Squeezelite Player', + // If already installed, the real function first asks + // "Reconfigure?" (default n) and, if declined, returns + // immediately after just the pause() - it does NOT fall through + // to the player-name/server prompts. f.reconfigure must be + // explicit (not inferred from other fields) so the web UI can + // offer "leave it as-is" without also having to resend the + // current values. + fields: ['alreadyInstalled', 'reconfigure', 'playerName', 'lmsServer'], + buildStdin(f) { + if (f.alreadyInstalled && !f.reconfigure) { + return 'n\n\n'; // decline reconfigure, then pause() + } + const lines = []; + if (f.alreadyInstalled) lines.push('y'); // "Reconfigure?" + lines.push(f.playerName || ''); // blank -> "Kiosk" default + lines.push(f.lmsServer || ''); // blank -> auto-discovery + // "Reboot now?" is always answered "n" here regardless of + // what the UI shows - triggering a real `sudo reboot` from + // inside a one-click addon-install action is out of scope + // for this pass (see webui phase-2 plan). The UI surfaces + // "reboot required to start Squeezelite" as an info banner + // instead of a real remote reboot trigger. + lines.push('n'); + lines.push(''); // pause() + return lines.join('\n') + '\n'; + }, + }, + + configure_asterisk_intercom: { + helperAction: 'action_configure_asterisk_intercom', + label: 'Configure Asterisk Intercom', + // Same shape as Squeezelite's reconfigure gate: if already + // installed, the real function asks "Reconfigure with a + // different server/extension?" (default n) and returns after + // just the pause() if declined - the rest of this sequence is + // never reached in that case. + fields: ['alreadyInstalled', 'reconfigure', 'serverIp', 'serverPort', 'extension', 'password', 'autoAnswer', 'useTls'], + buildStdin(f) { + if (f.alreadyInstalled && !f.reconfigure) { + return 'n\n\n'; // decline reconfigure, then pause() + } + const lines = []; + // Only present at all when baresip_is_installed is already + // true - a fresh install has no "Reconfigure?" prompt. + if (f.alreadyInstalled) lines.push('y'); + + // Server IP and extension reject a blank answer and + // re-prompt (a `while [[ -z ... ]]` loop in the real + // function) - sending an empty line here would desync the + // rest of the sequence by consuming a second prompt cycle, + // so these are validated up front instead. + if (!f.serverIp || !String(f.serverIp).trim()) throw new Error('serverIp is required'); + lines.push(String(f.serverIp).trim()); + + lines.push(f.serverPort != null && f.serverPort !== '' ? String(f.serverPort) : ''); + + if (!f.extension || !String(f.extension).trim()) throw new Error('extension is required'); + lines.push(String(f.extension).trim()); + + // Password also rejects blank and re-prompts, same reason. + if (!f.password) throw new Error('password is required'); + lines.push(f.password); + + lines.push(f.autoAnswer ? 'y' : 'n'); + lines.push(f.useTls ? 'y' : 'n'); + // "Proceed with installation?" (default y) - already + // confirmed by the web click that got us here. + lines.push('y'); + lines.push(''); // pause() + return lines.join('\n') + '\n'; + }, + }, + + upgrade: { + helperAction: 'action_upgrade', + label: 'Check for and apply updates', + fields: [], + buildStdin() { + // action_upgrade's own flow: "Pull these changes...?" (y), + // then - only if there was anything to pull - + // "Restart kiosk display now...?" (y), then always + // "Check for and install the latest Electron...?", answered + // n here. That sub-flow's own prompts default to declining + // and aren't a good fit for one-click automation yet (see + // webui phase-2 plan, "Explicitly deferred"). Answering "n" + // to a prompt that never actually gets shown (nothing to + // pull, or the display-restart question) is harmless - a + // synthesized line bash never reads is simply left unread, + // not an error. + return 'y\ny\nn\n'; + }, + }, +}; + +function getAction(name) { + return Object.prototype.hasOwnProperty.call(ACTIONS, name) ? ACTIONS[name] : undefined; +} + +module.exports = { ACTIONS, getAction }; diff --git a/webui/lib/jobs.js b/webui/lib/jobs.js new file mode 100644 index 0000000..a6c1d6e --- /dev/null +++ b/webui/lib/jobs.js @@ -0,0 +1,97 @@ +'use strict'; + +// webui/lib/jobs.js - runs one privileged action at a time via the +// allow-listed root helper (menus/addon_webui.sh's kiosk-webui-helper), +// and keeps an in-memory log so /api/actions/:name/run's caller and any +// number of SSE stream reconnects all see the same output. No database - +// this tool manages one kiosk, a Map is plenty. +// +// HELPER_PATH and SUDO_CMD are both overridable via environment (see +// webui/test/jobs.test.js): tests point HELPER_PATH at a small fake +// script and clear SUDO_CMD, so the real test suite never needs actual +// root or a real addon install - the same principle as every stubbed +// bash test in this project, just on the Node side. + +const { spawn } = require('child_process'); +const { randomUUID } = require('crypto'); +const { getAction } = require('./actions'); + +const HELPER_PATH = process.env.HELPER_PATH || '/usr/local/bin/kiosk-webui-helper'; +const SUDO_CMD = process.env.SUDO_CMD !== undefined ? process.env.SUDO_CMD : 'sudo'; + +const jobs = new Map(); +let activeJobId = null; + +function startJob(actionName, fields) { + const action = getAction(actionName); + if (!action) { + const err = new Error(`Unknown action: ${actionName}`); + err.status = 400; + throw err; + } + if (activeJobId) { + const err = new Error('Another action is already running - wait for it to finish first'); + err.status = 409; + throw err; + } + + // buildStdin() validates its own required fields and throws a plain + // Error with a human-readable message on bad input - treated as a + // 400 here, before anything is spawned. + let stdin; + try { + stdin = action.buildStdin(fields || {}); + } catch (e) { + e.status = 400; + throw e; + } + + const jobId = randomUUID(); + const job = { + id: jobId, + name: actionName, + label: action.label, + status: 'running', + log: [], + exitCode: null, + listeners: new Set(), + }; + jobs.set(jobId, job); + activeJobId = jobId; + + const child = SUDO_CMD + ? spawn(SUDO_CMD, [HELPER_PATH, action.helperAction]) + : spawn(HELPER_PATH, [action.helperAction]); + + const appendLine = (chunk) => { + const text = chunk.toString(); + job.log.push(text); + for (const listener of job.listeners) listener(text); + }; + child.stdout.on('data', appendLine); + child.stderr.on('data', appendLine); + + const finish = (status, exitCode) => { + if (job.status !== 'running') return; // 'error' and 'close' can both fire + job.status = status; + job.exitCode = exitCode; + for (const listener of job.listeners) listener(null); + if (activeJobId === jobId) activeJobId = null; + }; + child.on('close', (code) => finish(code === 0 ? 'success' : 'failed', code)); + child.on('error', (err) => { + job.log.push(`\n[error] ${err.message}\n`); + finish('failed', null); + }); + + child.stdin.write(stdin); + child.stdin.end(); + + return job; +} + +function getJob(jobId) { + return jobs.get(jobId); +} + +module.exports = { startJob, getJob }; diff --git a/webui/public/app.js b/webui/public/app.js index f4488f2..705c859 100644 --- a/webui/public/app.js +++ b/webui/public/app.js @@ -1,10 +1,10 @@ 'use strict'; // webui/public/app.js - vanilla JS, no framework/build step. Every -// user-controlled value (site URL/name/username) is set via .value or -// .textContent, never innerHTML, so nothing typed into a site name or -// URL can execute as markup - the one new attack surface a browser-based -// config UI has that the terminal menus never did. +// user-controlled value (site URL/name/username, addon form fields) is +// set via .value or .textContent, never innerHTML, so nothing typed +// into a form can execute as markup - the one new attack surface a +// browser-based config UI has that the terminal menus never did. const msgEl = document.getElementById('msg'); let currentConfig = null; @@ -17,6 +17,25 @@ function showMessage(text, isError) { showMessage._t = setTimeout(() => { msgEl.hidden = true; }, 5000); } +/* ---------------------------------------------------------------------- */ +/* Sidebar navigation */ +/* ---------------------------------------------------------------------- */ + +document.querySelectorAll('.nav-item').forEach((btn) => { + btn.addEventListener('click', () => { + document.querySelectorAll('.nav-item').forEach((b) => b.classList.remove('active')); + document.querySelectorAll('.page').forEach((p) => p.classList.remove('active')); + btn.classList.add('active'); + document.getElementById(`page-${btn.dataset.page}`).classList.add('active'); + }); +}); + +document.getElementById('brand-sub').textContent = location.host || 'this kiosk'; + +/* ---------------------------------------------------------------------- */ +/* Config API (Sites / Display / Lockout) */ +/* ---------------------------------------------------------------------- */ + async function apiGet() { const res = await fetch('/api/config'); if (!res.ok) throw new Error('Failed to load configuration'); @@ -95,7 +114,6 @@ function collectTabs() { tab.username = row.querySelector('.site-auth-username').value; const newPass = row.querySelector('.site-auth-password').value; if (newPass) tab.password = newPass; - // else: omit `password` entirely - server keeps the existing one. } else { tab.username = ''; tab.password = ''; @@ -104,10 +122,6 @@ function collectTabs() { }); } -// A "Save" per row would be simpler individually, but sites.sh's own -// save_config always rewrites the whole tabs array too - this mirrors -// that, saving all sites (and the home-page selection they feed into) -// together whenever anything in the Sites section changes. async function saveSites() { const tabs = collectTabs(); if (tabs.some((t) => !t.url)) { @@ -227,10 +241,6 @@ lockoutForm.addEventListener('submit', async (e) => { } }); -/* ---------------------------------------------------------------------- */ -/* Populate forms from a config snapshot */ -/* ---------------------------------------------------------------------- */ - function populateAll(config) { displayForm.elements.swipeMode.value = config.swipeMode; displayForm.elements.allowNavigation.value = config.allowNavigation; @@ -251,6 +261,352 @@ function populateAll(config) { refreshLockoutFieldVisibility(); } +/* ---------------------------------------------------------------------- */ +/* Shared: run a privileged action and stream its log via SSE */ +/* ---------------------------------------------------------------------- */ + +// jobPanelEls = { panel, status, log }. Returns a promise resolving to +// {status, exitCode} once the job finishes (or rejects on a request-level +// error before a job even started, e.g. validation). +async function runAction(actionName, fields, jobPanelEls) { + const res = await fetch(`/api/actions/${actionName}/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(fields || {}), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error || 'Could not start action'); + + jobPanelEls.panel.classList.add('open'); + jobPanelEls.log.textContent = ''; + setJobStatus(jobPanelEls.status, 'running'); + + return new Promise((resolve, reject) => { + const source = new EventSource(`/api/actions/jobs/${body.jobId}/stream`); + source.addEventListener('log', (ev) => { + jobPanelEls.log.textContent += JSON.parse(ev.data); + jobPanelEls.log.scrollTop = jobPanelEls.log.scrollHeight; + }); + source.addEventListener('done', (ev) => { + const result = JSON.parse(ev.data); + setJobStatus(jobPanelEls.status, result.status); + source.close(); + resolve(result); + }); + source.onerror = () => { + source.close(); + reject(new Error('Lost connection to the log stream')); + }; + }); +} + +function setJobStatus(el, status) { + el.className = `job-status ${status}`; + if (status === 'running') { + el.innerHTML = ''; + const spinner = document.createElement('span'); + spinner.className = 'spinner'; + el.appendChild(spinner); + el.appendChild(document.createTextNode('Running')); + } else { + el.textContent = status === 'success' ? 'Success' : 'Failed'; + } +} + +/* ---------------------------------------------------------------------- */ +/* Addons */ +/* ---------------------------------------------------------------------- */ + +const addonsList = document.getElementById('addons-list'); +const addonCardTemplate = document.getElementById('addon-card-template'); + +const ICONS = { + printer: '', + music: '', + speaker: '', + phone: '', +}; + +function pillHtml(state) { + if (state === true) return 'Installed'; + if (state === false) return 'Not installed'; + return 'Unknown'; +} + +let addonStatus = {}; + +async function loadAddonStatus() { + try { + const res = await fetch('/api/addons/status'); + if (res.ok) addonStatus = await res.json(); + } catch (e) { + // leave addonStatus as-is (pills show "Unknown"); not fatal to the page + } +} + +// Keyed by addon, so a single card can be refreshed in place after its +// own job finishes (see refreshAddonCard) without touching the other +// three, and - critically - without recreating the job-panel/log the +// user is currently looking at. An earlier version called the full +// renderAddons() rebuild after every successful job "to update the +// pill"; that raced (and usually lost to) the same success/log state it +// had just written a moment earlier, since rebuilding the whole list +// replaces the job-panel node with a fresh empty one. Caught by an +// actual headless-browser run, not just reading the code - the log +// looked fine reading it, but watching it in Chromium showed the +// "Success" state flash and vanish. +const ADDON_RECIPES = {}; + +function buildAddonCard(recipe) { + ADDON_RECIPES[recipe.key] = recipe; + const node = addonCardTemplate.content.firstElementChild.cloneNode(true); + node.dataset.addon = recipe.key; + fillAddonCard(node, recipe); + return node; +} + +// (Re)fills everything in a card EXCEPT the job-panel/log, which is +// left exactly as it is - so refreshing a card's install-state after a +// job finishes doesn't erase the result the user just watched stream in. +function fillAddonCard(node, { key, title, desc, icon, buildForm }) { + node.querySelector('.addon-icon').innerHTML = ICONS[icon]; + node.querySelector('.addon-name').textContent = title; + node.querySelector('.addon-desc').textContent = desc; + node.querySelector('.pill').outerHTML = pillHtml(addonStatus[key]); + + const actionsEl = node.querySelector('.addon-actions'); + const formEl = node.querySelector('.addon-form'); + actionsEl.textContent = ''; + formEl.textContent = ''; + formEl.className = 'addon-form'; + + const jobPanelEls = { + panel: node.querySelector('.job-panel'), + status: node.querySelector('.job-status'), + log: node.querySelector('.job-log'), + }; + buildForm({ node, actionsEl, formEl, jobPanelEls, installed: addonStatus[key] === true }); +} + +// Called after one addon's own job finishes - refreshes just that +// card's pill/buttons/form (e.g. "Install" -> "Reconfigure") in place. +async function refreshAddonCard(key) { + await loadAddonStatus(); + const node = addonsList.querySelector(`[data-addon="${key}"]`); + if (node) fillAddonCard(node, ADDON_RECIPES[key]); +} + +function addSubmitAction(formEl, jobPanelEls, actionName, collectFields, onDone) { + formEl.addEventListener('submit', async (e) => { + e.preventDefault(); + const submitBtn = formEl.querySelector('button[type=submit]'); + submitBtn.disabled = true; + try { + const result = await runAction(actionName, collectFields(), jobPanelEls); + if (result.status === 'success') { + showMessage('Done'); + if (onDone) await onDone(); + } else { + showMessage('Action failed - see the log below', true); + } + } catch (err) { + showMessage(err.message, true); + } finally { + submitBtn.disabled = false; + } + }); +} + +function renderAddons() { + addonsList.textContent = ''; + + // CUPS: no fields at all - the button itself is the whole form. + addonsList.appendChild(buildAddonCard({ + key: 'cups', title: 'CUPS Printing', desc: 'Network printer sharing', icon: 'printer', + buildForm({ actionsEl, formEl, jobPanelEls, installed }) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = installed ? 'Reconfigure for network access' : 'Install CUPS Printing'; + btn.addEventListener('click', async () => { + btn.disabled = true; + try { + const action = installed ? 'reconfigure_cups' : 'install_cups'; + const result = await runAction(action, {}, jobPanelEls); + if (result.status === 'success') { showMessage('Done'); await refreshAddonCard('cups'); } + else showMessage('Action failed - see the log below', true); + } catch (err) { + showMessage(err.message, true); + } finally { + btn.disabled = false; + } + }); + actionsEl.appendChild(btn); + }, + })); + + // LMS Server: fresh install has no fields; once installed, an + // optional port-reconfigure field. + addonsList.appendChild(buildAddonCard({ + key: 'lms', title: 'LMS Server', desc: 'Lyrion / Logitech Media Server', icon: 'music', + buildForm({ actionsEl, formEl, jobPanelEls, installed }) { + if (!installed) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = 'Install LMS Server'; + btn.addEventListener('click', async () => { + btn.disabled = true; + try { + const result = await runAction('install_lms', { alreadyInstalled: false }, jobPanelEls); + if (result.status === 'success') { showMessage('Done'); await refreshAddonCard('lms'); } + else showMessage('Action failed - see the log below', true); + } catch (err) { + showMessage(err.message, true); + } finally { + btn.disabled = false; + } + }); + actionsEl.appendChild(btn); + return; + } + const toggleBtn = document.createElement('button'); + toggleBtn.type = 'button'; + toggleBtn.className = 'secondary'; + toggleBtn.textContent = 'Reconfigure port'; + toggleBtn.addEventListener('click', () => formEl.classList.toggle('open')); + actionsEl.appendChild(toggleBtn); + + const portLabel = document.createElement('label'); + portLabel.innerHTML = 'New HTTP port'; + const portInput = document.createElement('input'); + portInput.type = 'number'; portInput.min = '1'; portInput.max = '65535'; portInput.value = '9000'; + portLabel.appendChild(portInput); + formEl.appendChild(portLabel); + const submitBtn = document.createElement('button'); + submitBtn.type = 'submit'; + submitBtn.textContent = 'Apply new port'; + formEl.appendChild(submitBtn); + + addSubmitAction(formEl, jobPanelEls, 'install_lms', () => ({ + alreadyInstalled: true, reconfigurePort: true, newPort: parseInt(portInput.value, 10), + }), () => refreshAddonCard('lms')); + }, + })); + + // Squeezelite: player name + LMS server, both for install and + // reconfigure - the form is the same either way. + addonsList.appendChild(buildAddonCard({ + key: 'squeezelite', title: 'Squeezelite Player', desc: 'Turns this kiosk into an LMS-connected speaker', icon: 'speaker', + buildForm({ actionsEl, formEl, jobPanelEls, installed }) { + formEl.classList.add('open'); + + const nameLabel = document.createElement('label'); + nameLabel.textContent = 'Player name'; + const nameInput = document.createElement('input'); + nameInput.type = 'text'; nameInput.value = 'Kiosk'; nameInput.placeholder = 'Kiosk'; + nameLabel.appendChild(nameInput); + formEl.appendChild(nameLabel); + + const serverLabel = document.createElement('label'); + serverLabel.innerHTML = 'LMS server (IP:PORT, blank for auto-discovery)'; + const serverInput = document.createElement('input'); + serverInput.type = 'text'; serverInput.placeholder = '192.168.1.100:3483'; + serverLabel.appendChild(serverInput); + formEl.appendChild(serverLabel); + + const rebootHint = document.createElement('p'); + rebootHint.className = 'hint'; + rebootHint.textContent = 'A reboot is required after install/reconfigure before Squeezelite starts.'; + formEl.appendChild(rebootHint); + + const submitBtn = document.createElement('button'); + submitBtn.type = 'submit'; + submitBtn.textContent = installed ? 'Reconfigure Squeezelite' : 'Install Squeezelite'; + formEl.appendChild(submitBtn); + + addSubmitAction(formEl, jobPanelEls, 'install_squeezelite', () => ({ + alreadyInstalled: installed, reconfigure: true, + playerName: nameInput.value.trim(), lmsServer: serverInput.value.trim(), + }), () => refreshAddonCard('squeezelite')); + }, + })); + + // Asterisk Intercom: server/extension/password/options, always shown. + addonsList.appendChild(buildAddonCard({ + key: 'asterisk_intercom', title: 'Asterisk Intercom', desc: 'SIP extension client (Baresip)', icon: 'phone', + buildForm({ actionsEl, formEl, jobPanelEls, installed }) { + formEl.classList.add('open'); + + const mk = (label, type, opts) => { + const l = document.createElement('label'); + l.textContent = label; + const i = document.createElement('input'); + i.type = type; + Object.assign(i, opts || {}); + l.appendChild(i); + formEl.appendChild(l); + return i; + }; + const ip = mk('Server IP or hostname', 'text', { placeholder: '10.0.0.5' }); + const port = mk('Server port', 'number', { placeholder: '5060', min: '1', max: '65535' }); + const ext = mk('Extension number', 'text', { placeholder: '201' }); + const pass = mk('SIP password', 'password', { autocomplete: 'new-password' }); + + const autoAnswerLabel = document.createElement('label'); + autoAnswerLabel.className = 'checkbox'; + const autoAnswer = document.createElement('input'); + autoAnswer.type = 'checkbox'; + autoAnswerLabel.appendChild(autoAnswer); + autoAnswerLabel.appendChild(document.createTextNode('Auto-answer incoming calls (intercom mode)')); + formEl.appendChild(autoAnswerLabel); + + const tlsLabel = document.createElement('label'); + tlsLabel.className = 'checkbox'; + const useTls = document.createElement('input'); + useTls.type = 'checkbox'; + tlsLabel.appendChild(useTls); + tlsLabel.appendChild(document.createTextNode('Use TLS encryption')); + formEl.appendChild(tlsLabel); + + const submitBtn = document.createElement('button'); + submitBtn.type = 'submit'; + submitBtn.textContent = installed ? 'Reconfigure Asterisk Intercom' : 'Connect to Asterisk server'; + formEl.appendChild(submitBtn); + + addSubmitAction(formEl, jobPanelEls, 'configure_asterisk_intercom', () => ({ + alreadyInstalled: installed, reconfigure: true, + serverIp: ip.value.trim(), serverPort: port.value ? parseInt(port.value, 10) : undefined, + extension: ext.value.trim(), password: pass.value, + autoAnswer: autoAnswer.checked, useTls: useTls.checked, + }), () => refreshAddonCard('asterisk_intercom')); + }, + })); +} + +/* ---------------------------------------------------------------------- */ +/* Update */ +/* ---------------------------------------------------------------------- */ + +document.getElementById('run-upgrade').addEventListener('click', async (e) => { + const btn = e.currentTarget; + btn.disabled = true; + try { + const result = await runAction('upgrade', {}, { + panel: document.getElementById('job-panel-upgrade'), + status: document.getElementById('job-status-upgrade'), + log: document.getElementById('job-log-upgrade'), + }); + showMessage(result.status === 'success' ? 'Update finished' : 'Update failed - see the log below', result.status !== 'success'); + } catch (err) { + showMessage(err.message, true); + } finally { + btn.disabled = false; + } +}); + +/* ---------------------------------------------------------------------- */ +/* Init */ +/* ---------------------------------------------------------------------- */ + async function init() { try { currentConfig = await apiGet(); @@ -259,6 +615,8 @@ async function init() { } catch (e) { showMessage(e.message, true); } + await loadAddonStatus(); + renderAddons(); } init(); diff --git a/webui/public/index.html b/webui/public/index.html index c6e2df9..2b3d183 100644 --- a/webui/public/index.html +++ b/webui/public/index.html @@ -7,100 +7,177 @@ -
-

Kiosk Web UI

-

Sites, display behavior, and lockout - saved directly to this kiosk's config.json.

-
- -
- - -
-

Sites & Page Timing

-
-
- - -
-
- -
-

Display & Interaction

-
- - - - - - - - -
- Home page - - -
- - -
-
- -
-

Password Protection & Lockout

-
- - -
- - - - - - - - - +
+
-
+ + + + + +
+ + + +
+ +
+
+
+ + +
+
+
+ + +
+ +
+
+ + + + + + + + +
+ Home page + + +
+ +
+
+
+
+ + +
+ +
+
+ + +
+ + + + + + + + + +
+ +
+
+
+
+ + +
+ +
+
+ + +
+ +
+
+
+
+ Update + +
+

+        
+
+
+
+ + + diff --git a/webui/public/style.css b/webui/public/style.css index c0673ce..a540215 100644 --- a/webui/public/style.css +++ b/webui/public/style.css @@ -1,85 +1,222 @@ +/* Design tokens - light by default, dark via prefers-color-scheme. + No external font/CDN dependency (self-hosted admin tool shouldn't + phone out to Google Fonts) - a well-tuned system-ui stack plus real + spacing/depth/motion is what actually reads as "modern", not the + typeface. */ + :root { - --bg: #0f1115; - --panel: #171a21; - --panel-inset: #1e222b; - --border: #2a2f3a; - --text: #e6e9ef; - --text-dim: #9aa3b2; - --accent: #4f8cff; + --font: -apple-system, BlinkMacSystemFont, "Segoe UI", ui-sans-serif, Roboto, Helvetica, Arial, sans-serif; + --font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace; + + --bg: #f5f6f8; + --bg-elevated: #ffffff; + --bg-sunken: #eef0f3; + --border: #e2e5ea; + --border-strong: #cbd0d8; + --text: #14161a; + --text-dim: #5c6370; + --text-faint: #8b929e; + --accent: #3b6ff0; + --accent-hover: #2f5cd6; --accent-text: #ffffff; - --danger: #e5566a; - --success: #3fb87f; - --error: #e5566a; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --accent-soft: #e8effe; + --success: #1c8a5b; + --success-soft: #e3f6ec; + --warning: #a9660a; + --warning-soft: #fdf1de; + --danger: #d13a3a; + --danger-soft: #fbe8e8; + --shadow-sm: 0 1px 2px rgba(20, 22, 26, 0.06); + --shadow-md: 0 4px 16px rgba(20, 22, 26, 0.08); + --radius: 10px; + --radius-lg: 14px; + --sidebar-w: 232px; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #101216; + --bg-elevated: #17191f; + --bg-sunken: #0c0d10; + --border: #262a33; + --border-strong: #363c48; + --text: #eceef2; + --text-dim: #9aa1ad; + --text-faint: #6b7280; + --accent: #5b8cff; + --accent-hover: #7ba0ff; + --accent-text: #0a0e18; + --accent-soft: #17233f; + --success: #3ecf8e; + --success-soft: #103527; + --warning: #e2a53f; + --warning-soft: #3a2c11; + --danger: #f0605f; + --danger-soft: #3a1616; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-md: 0 8px 24px rgba(0, 0, 0, 0.4); + } } * { box-sizing: border-box; } +::selection { background: var(--accent-soft); } + +html, body { + height: 100%; +} body { margin: 0; background: var(--bg); color: var(--text); - line-height: 1.5; + font-family: var(--font); + font-size: 14.5px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; } -header { - padding: 2rem 1.5rem 1rem; - max-width: 720px; - margin: 0 auto; +a { color: var(--accent); } + +/* ---------------------------------------------------------------- */ +/* Shell: fixed sidebar + main content */ +/* ---------------------------------------------------------------- */ + +.shell { + display: flex; + min-height: 100vh; } -h1 { margin: 0 0 0.25rem; font-size: 1.5rem; } -.subtitle { margin: 0; color: var(--text-dim); font-size: 0.9rem; } - -main { - max-width: 720px; - margin: 0 auto; - padding: 0 1.5rem 3rem; +.sidebar { + width: var(--sidebar-w); + flex-shrink: 0; + background: var(--bg-elevated); + border-right: 1px solid var(--border); display: flex; flex-direction: column; - gap: 1.5rem; + padding: 20px 12px; + position: sticky; + top: 0; + height: 100vh; } +.brand { + display: flex; + align-items: center; + gap: 10px; + padding: 4px 10px 22px; +} + +.brand-mark { + width: 30px; + height: 30px; + border-radius: 8px; + background: linear-gradient(135deg, var(--accent), var(--accent-hover)); + display: flex; + align-items: center; + justify-content: center; + color: var(--accent-text); + flex-shrink: 0; +} +.brand-mark svg { width: 17px; height: 17px; } + +.brand-text { + display: flex; + flex-direction: column; + line-height: 1.25; + min-width: 0; +} +.brand-text strong { font-size: 14px; } +.brand-text span { font-size: 11.5px; color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.nav { display: flex; flex-direction: column; gap: 2px; } + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 10px; + border-radius: 8px; + color: var(--text-dim); + font-size: 13.5px; + font-weight: 500; + cursor: pointer; + border: none; + background: transparent; + text-align: left; + width: 100%; +} +.nav-item svg { width: 17px; height: 17px; flex-shrink: 0; opacity: 0.85; } +.nav-item:hover { background: var(--bg-sunken); color: var(--text); } +.nav-item.active { background: var(--accent-soft); color: var(--accent); } +.nav-item.active svg { opacity: 1; } + +.sidebar-footer { + margin-top: auto; + padding: 10px; + font-size: 11.5px; + color: var(--text-faint); + border-top: 1px solid var(--border); + padding-top: 14px; +} + +.main { + flex: 1; + min-width: 0; + padding: 32px 40px 60px; + max-width: 880px; +} + +.page { display: none; } +.page.active { display: block; } + +.page-header { margin-bottom: 24px; } +.page-header h1 { margin: 0 0 4px; font-size: 20px; letter-spacing: -0.01em; } +.page-header p { margin: 0; color: var(--text-dim); font-size: 13.5px; } + +/* ---------------------------------------------------------------- */ +/* Cards, forms, buttons */ +/* ---------------------------------------------------------------- */ + .card { - background: var(--panel); + background: var(--bg-elevated); border: 1px solid var(--border); - border-radius: 10px; - padding: 1.25rem; + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + padding: 20px 22px; + margin-bottom: 18px; } -.card h2 { - margin: 0 0 1rem; - font-size: 1.1rem; -} +.card h2 { margin: 0 0 14px; font-size: 14.5px; font-weight: 600; } .card-inset { - background: var(--panel-inset); + background: var(--bg-sunken); border: 1px solid var(--border); - border-radius: 8px; - padding: 1rem; - margin-bottom: 0.75rem; + border-radius: var(--radius); + padding: 14px 16px; + margin-bottom: 10px; } -form { display: flex; flex-direction: column; gap: 1rem; } +form { display: flex; flex-direction: column; gap: 14px; } label { display: flex; flex-direction: column; - gap: 0.3rem; - font-size: 0.9rem; + gap: 5px; + font-size: 12.5px; + font-weight: 600; color: var(--text-dim); } label.checkbox { flex-direction: row; align-items: center; - gap: 0.5rem; + gap: 8px; + font-weight: 500; color: var(--text); } +label.checkbox input { width: 16px; height: 16px; accent-color: var(--accent); } -label.checkbox input { width: 1.05rem; height: 1.05rem; } - -.hint { color: var(--text-dim); font-size: 0.8rem; font-weight: normal; } +.hint { color: var(--text-faint); font-size: 11.5px; font-weight: normal; } input[type="text"], input[type="password"], @@ -87,96 +224,172 @@ input[type="number"], input[type="time"], select { background: var(--bg); - border: 1px solid var(--border); - border-radius: 6px; + border: 1px solid var(--border-strong); + border-radius: 8px; color: var(--text); - padding: 0.5rem 0.6rem; - font-size: 0.95rem; + padding: 8px 10px; + font-size: 13.5px; + font-family: inherit; + transition: border-color 0.12s ease; } - input:focus, select:focus { - outline: 2px solid var(--accent); - outline-offset: 1px; + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); } fieldset { border: 1px solid var(--border); - border-radius: 8px; - padding: 0.75rem 1rem 1rem; + border-radius: var(--radius); + padding: 14px 16px 16px; display: flex; flex-direction: column; - gap: 1rem; + gap: 14px; + margin: 0; } - -legend { padding: 0 0.4rem; color: var(--text-dim); font-size: 0.85rem; } +legend { padding: 0 6px; color: var(--text-dim); font-size: 12px; font-weight: 600; } button { border: none; - border-radius: 6px; - padding: 0.55rem 1rem; - font-size: 0.9rem; + border-radius: 8px; + padding: 8px 15px; + font-size: 13px; + font-weight: 600; + font-family: inherit; cursor: pointer; background: var(--accent); color: var(--accent-text); align-self: flex-start; + transition: background-color 0.12s ease, transform 0.05s ease; } +button:hover { background: var(--accent-hover); } +button:active { transform: translateY(1px); } +button:disabled { opacity: 0.55; cursor: not-allowed; } button.secondary { background: transparent; - border: 1px solid var(--border); + border: 1px solid var(--border-strong); color: var(--text); } +button.secondary:hover { background: var(--bg-sunken); } button.danger { background: transparent; border: 1px solid var(--danger); color: var(--danger); } +button.danger:hover { background: var(--danger-soft); } -button:hover { filter: brightness(1.1); } +.button-row { display: flex; gap: 8px; margin-top: 4px; flex-wrap: wrap; } -.button-row { - display: flex; - gap: 0.6rem; - margin-top: 0.75rem; +/* ---------------------------------------------------------------- */ +/* Addon cards, status pills, job log panel */ +/* ---------------------------------------------------------------- */ + +.addon-card { display: flex; flex-direction: column; gap: 12px; } +.addon-card-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.addon-title { display: flex; align-items: center; gap: 10px; } +.addon-icon { + width: 34px; height: 34px; border-radius: 9px; + background: var(--bg-sunken); display: flex; align-items: center; justify-content: center; + color: var(--text-dim); flex-shrink: 0; } +.addon-icon svg { width: 18px; height: 18px; } +.addon-name { font-weight: 600; font-size: 14px; } +.addon-desc { color: var(--text-faint); font-size: 12px; margin-top: 1px; } + +.pill { + display: inline-flex; align-items: center; gap: 5px; + padding: 3px 9px; border-radius: 999px; + font-size: 11px; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase; +} +.pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; } +.pill.installed { background: var(--success-soft); color: var(--success); } +.pill.installed::before { background: var(--success); } +.pill.not-installed { background: var(--bg-sunken); color: var(--text-faint); } +.pill.not-installed::before { background: var(--text-faint); } +.pill.unknown { background: var(--warning-soft); color: var(--warning); } +.pill.unknown::before { background: var(--warning); } + +.addon-form { display: none; } +.addon-form.open { display: flex; padding-top: 4px; border-top: 1px solid var(--border); margin-top: 4px; } + +.job-panel { + display: none; + background: var(--bg-sunken); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} +.job-panel.open { display: block; } +.job-panel-header { + display: flex; align-items: center; justify-content: space-between; + padding: 10px 14px; border-bottom: 1px solid var(--border); + font-size: 12.5px; font-weight: 600; +} +.job-status { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.02em; } +.job-status.running { color: var(--accent); } +.job-status.success { color: var(--success); } +.job-status.failed { color: var(--danger); } +.spinner { + width: 12px; height: 12px; border-radius: 50%; + border: 2px solid var(--accent-soft); border-top-color: var(--accent); + animation: spin 0.7s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +.job-log { + margin: 0; padding: 12px 14px; + font-family: var(--font-mono); font-size: 12px; line-height: 1.6; + color: var(--text-dim); + max-height: 320px; overflow-y: auto; + white-space: pre-wrap; word-break: break-word; +} + +/* ---------------------------------------------------------------- */ +/* Site rows (Sites & Page Timing) */ +/* ---------------------------------------------------------------- */ .site-row-grid { display: grid; grid-template-columns: 2fr 1fr 1fr; - gap: 0.75rem; -} - -@media (max-width: 560px) { - .site-row-grid { grid-template-columns: 1fr; } + gap: 12px; } +@media (max-width: 640px) { .site-row-grid { grid-template-columns: 1fr; } } .site-auth { - margin-top: 0.75rem; + margin-top: 10px; border-top: 1px solid var(--border); - padding-top: 0.75rem; + padding-top: 10px; } +.site-auth summary { cursor: pointer; color: var(--text-dim); font-size: 12px; font-weight: 600; } +.site-auth[open] summary { margin-bottom: 10px; } +.auth-state { color: var(--text-faint); font-weight: normal; } -.site-auth summary { - cursor: pointer; - color: var(--text-dim); - font-size: 0.85rem; -} - -.site-auth[open] summary { margin-bottom: 0.6rem; } - -.auth-state { color: var(--text-dim); } - -.remove-site { margin-top: 0.75rem; } +/* ---------------------------------------------------------------- */ +/* Toast banner */ +/* ---------------------------------------------------------------- */ .banner { - border-radius: 8px; - padding: 0.75rem 1rem; - font-size: 0.9rem; - position: sticky; - top: 0.75rem; - z-index: 10; + border-radius: var(--radius); + padding: 11px 15px; + font-size: 13px; + font-weight: 500; + position: fixed; + top: 18px; + right: 18px; + max-width: 360px; + box-shadow: var(--shadow-md); + z-index: 50; } +.banner.success { background: var(--success-soft); border: 1px solid var(--success); color: var(--success); } +.banner.error { background: var(--danger-soft); border: 1px solid var(--danger); color: var(--danger); } -.banner.success { background: rgba(63, 184, 127, 0.15); border: 1px solid var(--success); color: var(--success); } -.banner.error { background: rgba(229, 86, 106, 0.15); border: 1px solid var(--error); color: var(--error); } +@media (max-width: 800px) { + .shell { flex-direction: column; } + .sidebar { width: 100%; height: auto; position: static; flex-direction: row; align-items: center; padding: 12px; overflow-x: auto; } + .brand { padding: 0 10px 0 0; } + .nav { flex-direction: row; } + .sidebar-footer { display: none; } + .main { padding: 20px; } +} diff --git a/webui/server.js b/webui/server.js index 07da88e..e70c0f2 100644 --- a/webui/server.js +++ b/webui/server.js @@ -17,8 +17,14 @@ // config.json - so it never needs sudo. const path = require('path'); +const { execFile } = require('child_process'); const express = require('express'); const { loadConfig, saveConfig } = require('./lib/config'); +const { ACTIONS } = require('./lib/actions'); +const { startJob, getJob } = require('./lib/jobs'); + +const HELPER_PATH = process.env.HELPER_PATH || '/usr/local/bin/kiosk-webui-helper'; +const SUDO_CMD = process.env.SUDO_CMD !== undefined ? process.env.SUDO_CMD : 'sudo'; const app = express(); app.use(express.json({ limit: '256kb' })); @@ -130,6 +136,80 @@ app.put('/api/config', (req, res) => { } }); +/* ---------------------------------------------------------------------- */ +/* Addons: install/reconfigure via the allow-listed root helper */ +/* ---------------------------------------------------------------------- */ + +app.get('/api/actions', (req, res) => { + const list = Object.entries(ACTIONS).map(([name, a]) => ({ name, label: a.label, fields: a.fields })); + res.json(list); +}); + +app.get('/api/addons/status', (req, res) => { + const cmd = SUDO_CMD || HELPER_PATH; + const args = SUDO_CMD ? [HELPER_PATH, 'status_all'] : ['status_all']; + execFile(cmd, args, { timeout: 10_000 }, (err, stdout, stderr) => { + if (err) { + console.error('status_all failed:', stderr || err.message); + return res.status(500).json({ error: 'Could not read addon status' }); + } + try { + res.json(JSON.parse(stdout.trim())); + } catch (e) { + res.status(500).json({ error: 'Malformed status response' }); + } + }); +}); + +app.post('/api/actions/:name/run', (req, res) => { + try { + const job = startJob(req.params.name, req.body || {}); + res.json({ jobId: job.id, status: job.status, label: job.label }); + } catch (e) { + res.status(e.status || 500).json({ error: e.message }); + } +}); + +app.get('/api/actions/jobs/:jobId', (req, res) => { + const job = getJob(req.params.jobId); + if (!job) return res.status(404).json({ error: 'Unknown job' }); + res.json({ id: job.id, name: job.name, label: job.label, status: job.status, exitCode: job.exitCode, log: job.log.join('') }); +}); + +// Server-Sent Events: replays whatever's already logged, then streams +// new lines as they arrive, then a final `done` event - works whether +// the client connects before the job starts producing output or +// reconnects partway through (e.g. after a page reload). +app.get('/api/actions/jobs/:jobId/stream', (req, res) => { + const job = getJob(req.params.jobId); + if (!job) return res.status(404).end(); + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + + if (job.log.length) { + res.write(`event: log\ndata: ${JSON.stringify(job.log.join(''))}\n\n`); + } + if (job.status !== 'running') { + res.write(`event: done\ndata: ${JSON.stringify({ status: job.status, exitCode: job.exitCode })}\n\n`); + return res.end(); + } + + const listener = (text) => { + if (text === null) { + res.write(`event: done\ndata: ${JSON.stringify({ status: job.status, exitCode: job.exitCode })}\n\n`); + res.end(); + } else { + res.write(`event: log\ndata: ${JSON.stringify(text)}\n\n`); + } + }; + job.listeners.add(listener); + req.on('close', () => job.listeners.delete(listener)); +}); + const PORT = process.env.PORT || 8090; const BIND_ADDR = process.env.BIND_ADDR || '0.0.0.0'; diff --git a/webui/test/fixtures/fake-helper-with-status.sh b/webui/test/fixtures/fake-helper-with-status.sh new file mode 100755 index 0000000..3a49f29 --- /dev/null +++ b/webui/test/fixtures/fake-helper-with-status.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# Variant of fake-helper.sh that also implements status_all with real, +# minimally stateful JSON (tracked via marker files alongside itself), +# for browser/manual smoke testing of the Addons page's pills/buttons +# actually flipping after a real install - not just that a job reports +# success. jobs.test.js intentionally uses the plainer fake-helper.sh +# instead, to exercise the malformed-status-response error path. +STATE_DIR="$(dirname "$0")/.fake-state" +mkdir -p "$STATE_DIR" + +if [[ "$1" == "status_all" ]]; then + state() { [[ -f "$STATE_DIR/$1" ]] && echo true || echo false; } + echo "{\"cups\":$(state cups),\"lms\":$(state lms),\"squeezelite\":$(state squeezelite),\"asterisk_intercom\":$(state asterisk_intercom)}" + exit 0 +fi + +echo "fake-helper: action=$1" +stdin_content=$(cat) +echo "fake-helper: stdin-bytes=${#stdin_content}" +sleep 0.3 + +case "$1" in + action_install_cups | action_reconfigure_cups) touch "$STATE_DIR/cups" ;; + action_install_lms) touch "$STATE_DIR/lms" ;; + action_install_squeezelite) touch "$STATE_DIR/squeezelite" ;; + action_configure_asterisk_intercom) touch "$STATE_DIR/asterisk_intercom" ;; +esac + +echo "fake-helper: done" +exit "${FAKE_HELPER_EXIT_CODE:-0}" diff --git a/webui/test/fixtures/fake-helper.sh b/webui/test/fixtures/fake-helper.sh new file mode 100755 index 0000000..ac8392f --- /dev/null +++ b/webui/test/fixtures/fake-helper.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# webui/test/fixtures/fake-helper.sh - stands in for the real, root-owned +# kiosk-webui-helper (menus/addon_webui.sh) in webui/test/jobs.test.js, +# so the job/SSE system can be tested without real root or a real addon +# install. Echoes what it received, sleeps briefly (long enough for the +# "another job is already running" test to reliably observe it), then +# exits with a controllable code. +echo "fake-helper: action=$1" +stdin_content=$(cat) +echo "fake-helper: stdin-bytes=${#stdin_content}" +sleep 0.2 +echo "fake-helper: done" +exit "${FAKE_HELPER_EXIT_CODE:-0}" diff --git a/webui/test/jobs.test.js b/webui/test/jobs.test.js new file mode 100644 index 0000000..c5ba9ed --- /dev/null +++ b/webui/test/jobs.test.js @@ -0,0 +1,190 @@ +'use strict'; + +// webui/test/jobs.test.js - integration test for the addon-install job +// system (/api/actions/*, /api/addons/status) against a fake helper +// script (test/fixtures/fake-helper.sh) instead of the real, root-owned +// kiosk-webui-helper - no real root, apt, or system mutation involved, +// matching this project's rule of never touching real system state in +// tests. Run with: node test/jobs.test.js + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const assert = require('assert'); + +const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webui-jobs-test-')); +process.env.CONFIG_PATH = path.join(scratchDir, 'config.json'); +process.env.HELPER_PATH = path.join(__dirname, 'fixtures', 'fake-helper.sh'); +process.env.SUDO_CMD = ''; // run the fake helper directly, no real sudo + +const app = require('../server'); + +let failures = 0; +async function check(label, fn) { + try { + await fn(); + console.log(`PASS: ${label}`); + } catch (e) { + failures++; + console.log(`FAIL: ${label} - ${e.message}`); + } +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForDone(base, jobId, timeoutMs = 3000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const res = await fetch(`${base}/api/actions/jobs/${jobId}`); + const body = await res.json(); + if (body.status !== 'running') return body; + await sleep(20); + } + throw new Error('timed out waiting for job to finish'); +} + +async function main() { + const server = app.listen(0, '127.0.0.1'); + await new Promise((resolve) => server.once('listening', resolve)); + const port = server.address().port; + const base = `http://127.0.0.1:${port}`; + + await check('GET /api/actions lists the allow-listed actions with their fields', async () => { + const res = await fetch(`${base}/api/actions`); + assert.strictEqual(res.status, 200); + const body = await res.json(); + const names = body.map((a) => a.name); + assert.ok(names.includes('install_cups')); + assert.ok(names.includes('configure_asterisk_intercom')); + const asterisk = body.find((a) => a.name === 'configure_asterisk_intercom'); + assert.ok(asterisk.fields.includes('serverIp')); + }); + + await check('GET /api/addons/status returns the fake helper\'s status_all JSON', async () => { + // fake-helper.sh doesn't implement status_all specially - it just + // echoes/exits 0 with non-JSON text, so this exercises the + // malformed-response error path rather than a real status shape. + const res = await fetch(`${base}/api/addons/status`); + assert.strictEqual(res.status, 500); + }); + + let jobId; + await check('POST /api/actions/install_cups/run starts a job and returns its id', async () => { + const res = await fetch(`${base}/api/actions/install_cups/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + assert.strictEqual(res.status, 200); + const body = await res.json(); + assert.ok(body.jobId); + assert.strictEqual(body.status, 'running'); + jobId = body.jobId; + }); + + await check('a second action while one is running is rejected with 409', async () => { + const res = await fetch(`${base}/api/actions/reconfigure_cups/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + assert.strictEqual(res.status, 409); + }); + + await check('the job completes successfully and the log shows what the helper received', async () => { + const body = await waitForDone(base, jobId); + assert.strictEqual(body.status, 'success'); + assert.strictEqual(body.exitCode, 0); + assert.ok(body.log.includes('fake-helper: action=action_install_cups'), body.log); + assert.ok(body.log.includes('fake-helper: stdin-bytes='), body.log); + assert.ok(body.log.includes('fake-helper: done'), body.log); + }); + + await check('after completion, a new action is accepted again (not stuck busy)', async () => { + const res = await fetch(`${base}/api/actions/reconfigure_cups/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + assert.strictEqual(res.status, 200); + const body = await res.json(); + await waitForDone(base, body.jobId); + }); + + await check('unknown action name is rejected with 400, no job created', async () => { + const res = await fetch(`${base}/api/actions/definitely_not_real/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + assert.strictEqual(res.status, 400); + }); + + await check('missing required field is rejected with 400 before spawning anything', async () => { + const res = await fetch(`${base}/api/actions/configure_asterisk_intercom/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ alreadyInstalled: false, extension: '201', password: 'x' }), + }); + assert.strictEqual(res.status, 400); + const body = await res.json(); + assert.ok(/serverIp/.test(body.error), body.error); + }); + + await check('a failing helper is reflected as status failed with the real exit code', async () => { + process.env.FAKE_HELPER_EXIT_CODE = '1'; + // jobs.js reads process.env.HELPER_PATH/SUDO_CMD once at module + // load, but FAKE_HELPER_EXIT_CODE is read fresh by the spawned + // shell script every time, so no re-require needed here. + const res = await fetch(`${base}/api/actions/reconfigure_cups/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + const { jobId: failId } = await res.json(); + const body = await waitForDone(base, failId); + assert.strictEqual(body.status, 'failed'); + assert.strictEqual(body.exitCode, 1); + delete process.env.FAKE_HELPER_EXIT_CODE; + }); + + await check('GET /api/actions/jobs/:id/stream (SSE) replays the log and sends a final done event', async () => { + const res = await fetch(`${base}/api/actions/install_lms/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ alreadyInstalled: false }), + }); + const { jobId: streamJobId } = await res.json(); + + const streamRes = await fetch(`${base}/api/actions/jobs/${streamJobId}/stream`); + assert.strictEqual(streamRes.status, 200); + assert.strictEqual(streamRes.headers.get('content-type'), 'text/event-stream'); + + const reader = streamRes.body.getReader(); + const decoder = new TextDecoder(); + let raw = ''; + const deadline = Date.now() + 3000; + while (!raw.includes('event: done') && Date.now() < deadline) { + const { value, done } = await reader.read(); + if (done) break; + raw += decoder.decode(value, { stream: true }); + } + assert.ok(raw.includes('event: log'), raw); + assert.ok(raw.includes('fake-helper: action=action_install_lms'), raw); + assert.ok(raw.includes('event: done'), raw); + assert.ok(/data: \{"status":"success","exitCode":0\}/.test(raw), raw); + }); + + server.close(); + fs.rmSync(scratchDir, { recursive: true, force: true }); + + if (failures > 0) { + console.log(`${failures} FAILURE(S)`); + process.exit(1); + } + console.log('ALL DONE'); +} + +main();