Offline addons, LAN-aware emergency hotspot, LAN time server

Offline bundle now carries every addon that works on a network without
internet: LMS (Lyrion's own .deb, from the addon's LMS_DEB_URL) +
Squeezelite, Asterisk Intercom (baresip), VNC, WireGuard and the
Emergency Hotspot packages - each list in a variable next to the code
that installs it (SQUEEZELITE_APT_PACKAGES, ASTERISK_INTERCOM_APT_PACKAGES,
VNC_APT_PACKAGES, WIREGUARD_APT_PACKAGES, EMERGENCY_HOTSPOT_APT_PACKAGES),
read by build-bundle.sh. Each install goes through
run_with_offline_fallback like CUPS, so they install offline from the
normal menus / Web UI. LMS installs as a bundle package when offline
(lms_offline_install_wanted); its online repo/direct-download paths are
unchanged. Tailscale/Netbird deliberately not bundled (vendor scripts,
vendor servers). Bundle: 524 MB (+87 MB).

Emergency hotspot: the boot check pinged only 8.8.8.8, so on a LAN with
no internet the hotspot started on every boot. Now chosen at setup:
"network" (default - router or internet must answer; a router that
ignores ping still counts if it answered ARP), "internet" (old check),
or "host" (a specific address). Three tries 10s apart. Existing scripts
keep the old check until Reconfigure.

New Core Settings -> Time Server (NTP) (menus/time_sync.sh): point
systemd-timesyncd at LAN time server(s) via a timesyncd.conf.d drop-in,
back to Ubuntu's defaults, or set the clock by hand (and the RTC) for a
network with no time server. Complete Uninstall resets it.

Also: lms_service_name used `systemctl list-unit-files | grep -q`, the
same pipefail/SIGPIPE pattern as the earlier cups_is_installed bug; now
`systemctl cat`.

Tested in a network-less chroot of the 26.04.1 server filesystem: kiosk
base, then action_wireguard_install and action_install_lms (real
functions) and the Squeezelite/Intercom/VNC/hotspot package sets all
installed from the bundle, apt left on normal sources after each.
Hotspot script generated for all three modes (plus an invalid address,
which falls back to "network") and its check run against simulated
networks: LAN-only with router answering ping or only ARP -> no hotspot
in "network" mode; nothing reachable -> hotspot. Time Server actions
driven with timedatectl stubbed: drop-in contents, input validation,
manual set-time sequence, reset removes the drop-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
This commit is contained in:
Claude
2026-10-03 16:08:46 +00:00
parent fb4ee6fa62
commit fcbb890dbf
13 changed files with 388 additions and 75 deletions
+5 -3
View File
@@ -36,6 +36,9 @@
################################################################################
BARESIP_CONFIG_DIR="${KIOSK_HOME}/.baresip"
# Also read by iso/offline/build-bundle.sh to bundle them for offline installs.
# pulseaudio-utils/pipewire-pulse: pactl, for audio routing.
ASTERISK_INTERCOM_APT_PACKAGES=(baresip pulseaudio-utils pipewire-pulse)
BARESIP_USER_SERVICE_DIR="${KIOSK_HOME}/.config/systemd/user"
# Runs `systemctl --user ...` as $KIOSK_USER with the runtime dir/D-Bus
@@ -169,14 +172,13 @@ action_configure_asterisk_intercom() {
echo
echo "Installing Baresip..."
if ! command -v baresip &>/dev/null; then
if ! sudo apt install -y baresip; then
if ! command -v baresip &>/dev/null || ! command -v pactl &>/dev/null; then
if ! run_with_offline_fallback sudo apt install -y "${ASTERISK_INTERCOM_APT_PACKAGES[@]}"; then
log_error "Failed to install baresip package"
pause
return 1
fi
fi
sudo apt install -y pulseaudio-utils pipewire-pulse 2>/dev/null || true
sudo mkdir -p "$BARESIP_CONFIG_DIR"
+57 -33
View File
@@ -22,10 +22,23 @@
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
################################################################################
# Also read by iso/offline/build-bundle.sh, to bundle the same versions
# for offline installs (the .deb itself plus its apt dependencies).
LMS_DEB_URL="https://downloads.lms-community.org/LyrionMusicServer_v9.0.3/lyrionmusicserver_9.0.3_amd64.deb"
SQUEEZELITE_APT_PACKAGES=(squeezelite)
# True on a kiosk installed from an --offline ISO that can't reach the
# internet right now - the one case where LMS comes from the bundle.
lms_offline_install_wanted() {
grep -q '^Package: lyrionmusicserver$' "$KIOSK_OFFLINE_DIR/apt/Packages" 2>/dev/null && ! have_internet
}
lms_service_name() {
if systemctl list-unit-files 2>/dev/null | grep -q "lyrionmusicserver.service"; then
# `systemctl cat`, not `list-unit-files | grep -q`: grep -q exiting
# early can SIGPIPE systemctl, failing the pipeline under pipefail.
if systemctl cat lyrionmusicserver.service &>/dev/null; then
echo "lyrionmusicserver"
elif systemctl list-unit-files 2>/dev/null | grep -q "logitechmediaserver.service"; then
elif systemctl cat logitechmediaserver.service &>/dev/null; then
echo "logitechmediaserver"
fi
}
@@ -114,42 +127,53 @@ action_install_lms() {
echo "Installing Lyrion Music Server..."
# Try the repository method first.
if wget -qO - https://debian.slimdevices.com/debian/squeezebox-keyring.gpg | sudo gpg --dearmor -o /usr/share/keyrings/lms-keyring.gpg 2>/dev/null; then
echo "deb [signed-by=/usr/share/keyrings/lms-keyring.gpg] http://debian.slimdevices.com/debian stable main" | sudo tee /etc/apt/sources.list.d/lms.list
# `|| true`: a bare, unguarded `apt update` failing here (bad
# mirror, no network) would otherwise crash the whole session
# under set -e instead of falling through to the direct-download
# fallback below, which is exactly the degrade path this is
# supposed to hit when the repository route doesn't work.
sudo apt update 2>/dev/null || true
if sudo apt install -y logitechmediaserver 2>/dev/null; then
log_success "LMS installed via repository"
else
log_warning "Repository install failed, trying direct download..."
if lms_offline_install_wanted; then
# Kiosk installed from an --offline ISO with no internet right now:
# the bundle carries the same LMS_DEB_URL .deb as a package.
if ! run_with_offline_fallback sudo apt install -y lyrionmusicserver; then
log_error "Failed to install LMS from the offline bundle"
pause
return 1
fi
fi
# Fall back to a direct .deb download if the repository didn't produce
# either possible package.
if ! command -v logitechmediaserver &>/dev/null && ! command -v lyrionmusicserver &>/dev/null; then
local lms_deb="/tmp/lms.deb"
echo "Downloading LMS v9.0.3..."
if wget -q https://downloads.lms-community.org/LyrionMusicServer_v9.0.3/lyrionmusicserver_9.0.3_amd64.deb -O "$lms_deb"; then
echo "Installing LMS package..."
if sudo apt install -y "$lms_deb"; then
log_success "LMS installed via direct download"
log_success "LMS installed from the offline bundle"
else
# Try the repository method first.
if wget -qO - https://debian.slimdevices.com/debian/squeezebox-keyring.gpg | sudo gpg --dearmor -o /usr/share/keyrings/lms-keyring.gpg 2>/dev/null; then
echo "deb [signed-by=/usr/share/keyrings/lms-keyring.gpg] http://debian.slimdevices.com/debian stable main" | sudo tee /etc/apt/sources.list.d/lms.list
# `|| true`: a bare, unguarded `apt update` failing here (bad
# mirror, no network) would otherwise crash the whole session
# under set -e instead of falling through to the direct-download
# fallback below, which is exactly the degrade path this is
# supposed to hit when the repository route doesn't work.
sudo apt update 2>/dev/null || true
if sudo apt install -y logitechmediaserver 2>/dev/null; then
log_success "LMS installed via repository"
else
log_error "Failed to install LMS package"
log_warning "Repository install failed, trying direct download..."
fi
fi
# Fall back to a direct .deb download if the repository didn't produce
# either possible package.
if ! command -v logitechmediaserver &>/dev/null && ! command -v lyrionmusicserver &>/dev/null; then
local lms_deb="/tmp/lms.deb"
echo "Downloading LMS ($(basename "$LMS_DEB_URL"))..."
if wget -q "$LMS_DEB_URL" -O "$lms_deb"; then
echo "Installing LMS package..."
if sudo apt install -y "$lms_deb"; then
log_success "LMS installed via direct download"
else
log_error "Failed to install LMS package"
rm -f "$lms_deb"
pause
return 1
fi
rm -f "$lms_deb"
else
log_error "Failed to download LMS from lms-community.org"
pause
return 1
fi
rm -f "$lms_deb"
else
log_error "Failed to download LMS from lms-community.org"
pause
return 1
fi
fi
@@ -250,7 +274,7 @@ action_install_squeezelite() {
fi
if ! command -v squeezelite &>/dev/null; then
if ! sudo apt install -y squeezelite; then
if ! run_with_offline_fallback sudo apt install -y "${SQUEEZELITE_APT_PACKAGES[@]}"; then
log_error "squeezelite package installation failed"
pause
return 1
+8 -2
View File
@@ -24,6 +24,12 @@
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
################################################################################
# Also read by iso/offline/build-bundle.sh to bundle them for offline
# installs. (Tailscale/Netbird aren't bundled: they install from their
# vendors' scripts and need their vendors' servers to work anyway.)
VNC_APT_PACKAGES=(x11vnc)
WIREGUARD_APT_PACKAGES=(wireguard wireguard-tools)
remote_access_status() {
echo "VNC: $(is_service_active x11vnc && echo "running" || echo "not installed")"
echo "WireGuard: $(wireguard_connected && echo "connected" || (command -v wg &>/dev/null && echo "installed, not connected" || echo "not installed"))"
@@ -70,7 +76,7 @@ action_vnc_install() {
echo
ask_yes_no "Install x11vnc?" "n" || { echo "Cancelled"; return; }
if ! sudo apt install -y x11vnc; then
if ! run_with_offline_fallback sudo apt install -y "${VNC_APT_PACKAGES[@]}"; then
log_error "x11vnc installation failed"
return 1
fi
@@ -182,7 +188,7 @@ action_wireguard_install() {
echo
ask_yes_no "Install WireGuard?" "n" || { echo "Cancelled"; return; }
if ! sudo apt install -y wireguard wireguard-tools; then
if ! run_with_offline_fallback sudo apt install -y "${WIREGUARD_APT_PACKAGES[@]}"; then
log_error "WireGuard installation failed"
return 1
fi
+1
View File
@@ -163,6 +163,7 @@ source "$SCRIPT_DIR/lib/electron.sh"
source "$SCRIPT_DIR/menus/sites.sh"
source "$SCRIPT_DIR/menus/display.sh"
source "$SCRIPT_DIR/menus/timezone.sh"
source "$SCRIPT_DIR/menus/time_sync.sh"
source "$SCRIPT_DIR/menus/hidden_pin.sh"
source "$SCRIPT_DIR/menus/lockout.sh"
source "$SCRIPT_DIR/menus/wifi.sh"
+72 -12
View File
@@ -1,8 +1,15 @@
#!/bin/bash
################################################################################
# menus/advanced_emergency_hotspot.sh - "Emergency Hotspot" (Advanced):
# auto-starts a WiFi hotspot if no internet is detected 60 seconds after
# boot, so the kiosk can be reached and reconfigured remotely.
# auto-starts a WiFi hotspot if the kiosk finds itself cut off 60 seconds
# after boot, so it can be reached and reconfigured remotely.
# "Cut off" is chosen at setup (EMERGENCY_HOTSPOT_CHECK_MODE in the
# generated script): by default the local network itself is unreachable -
# neither the router nor the internet answers - so a kiosk on a LAN with
# no internet (an offline install) doesn't start the hotspot on every
# boot. The old internet-only check (ping 8.8.8.8) is still an option, as
# is checking one specific address. Scripts generated before this choice
# existed keep the internet-only check until Reconfigure is run.
#
# Writes a standalone runtime script ($BIN_DIR/kiosk-emergency-hotspot)
# plus a oneshot systemd unit ($SYSTEMD_DIR) that runs it at boot - both
@@ -21,6 +28,8 @@
################################################################################
EMERGENCY_HOTSPOT_SCRIPT="$BIN_DIR/kiosk-emergency-hotspot"
# Also read by iso/offline/build-bundle.sh to bundle them for offline installs.
EMERGENCY_HOTSPOT_APT_PACKAGES=(hostapd dnsmasq iptables)
emergency_hotspot_is_configured() {
[[ -f "$EMERGENCY_HOTSPOT_SCRIPT" ]]
@@ -38,8 +47,8 @@ advanced_emergency_hotspot_status() {
else
echo "Emergency Hotspot: Not configured"
fi
echo "ℹ Auto-starts a WiFi hotspot if no internet is detected 60"
echo " seconds after boot, so you can connect and reconfigure remotely."
echo "ℹ Auto-starts a WiFi hotspot if the kiosk can't reach its network"
echo " 60 seconds after boot, so you can connect and reconfigure remotely."
}
advanced_emergency_hotspot_menu_builder() {
@@ -62,7 +71,7 @@ advanced_emergency_hotspot_menu() {
action_configure_emergency_hotspot() {
echo
if ! sudo apt install -y hostapd dnsmasq iptables; then
if ! run_with_offline_fallback sudo apt install -y "${EMERGENCY_HOTSPOT_APT_PACKAGES[@]}"; then
log_error "Failed to install hostapd/dnsmasq/iptables"
pause
return 1
@@ -91,6 +100,29 @@ action_configure_emergency_hotspot() {
[[ ${#hotspot_pass} -lt 8 ]] && log_error "Password must be at least 8 characters"
done
echo
echo "When should the hotspot start? (checked 60 seconds after boot)"
echo " 1) The local network is unreachable - neither the router nor the"
echo " internet answers. Recommended; right for networks without"
echo " internet too."
echo " 2) There's no internet. On a network without internet this starts"
echo " the hotspot on every boot."
echo " 3) A specific address on your network doesn't answer (e.g. a server)."
local check_mode="network" check_host="" check_choice
check_choice=$(ask_integer "Choice" 1 1 3)
case "$check_choice" in
2) check_mode="internet" ;;
3)
check_host=$(ask_text "Address to check (IP or hostname)" "")
if [[ "$check_host" =~ ^[A-Za-z0-9.:-]+$ ]]; then
check_mode="host"
else
log_warning "Not a valid address - using option 1 instead"
check_host=""
fi
;;
esac
local hotspot_ip="192.168.50.1"
sudo mkdir -p "$BIN_DIR"
@@ -98,9 +130,14 @@ action_configure_emergency_hotspot() {
#!/bin/bash
################################################################################
### KIOSK EMERGENCY HOTSPOT
### Auto-starts if no internet connection 60 seconds after boot
### Auto-starts if the network check fails 60 seconds after boot
################################################################################
# network = neither the default gateway (router) nor the internet answers
# internet = no internet (pings 8.8.8.8 / 1.1.1.1)
# host = CHECK_HOST doesn't answer
CHECK_MODE="$check_mode"
CHECK_HOST="$check_host"
WIFI_IFACE="$wifi_iface"
HOTSPOT_SSID="$hotspot_ssid"
HOTSPOT_PASS="$hotspot_pass"
@@ -110,13 +147,36 @@ KIOSK_USER="$KIOSK_USER"
# Wait 60 seconds after boot
sleep 60
# Check for internet connectivity
if ping -c 3 -W 5 8.8.8.8 >/dev/null 2>&1; then
logger "KIOSK: Internet connected - emergency hotspot not needed"
exit 0
fi
ping_ok() { ping -c 2 -W 3 "\$1" >/dev/null 2>&1; }
internet_ok() { ping_ok 8.8.8.8 || ping_ok 1.1.1.1; }
# The router counts as there if it answers ping - or, for routers set to
# ignore ping, if it answered the ARP lookup that ping just triggered.
gateway_ok() {
local gw
gw=\$(ip -4 route show default 2>/dev/null | awk '{print \$3; exit}')
[[ -n "\$gw" ]] || return 1
ping_ok "\$gw" && return 0
ip neigh show "\$gw" 2>/dev/null | grep -qE 'REACHABLE|STALE|DELAY|PROBE'
}
connected() {
case "\$CHECK_MODE" in
internet) internet_ok ;;
host) ping_ok "\$CHECK_HOST" ;;
*) gateway_ok || internet_ok ;;
esac
}
logger "KIOSK: No internet detected - starting emergency hotspot"
# Three tries, 10 seconds apart, so a slow DHCP lease or WiFi association
# isn't mistaken for being cut off.
for attempt in 1 2 3; do
if connected; then
logger "KIOSK: Network check (\$CHECK_MODE) passed - emergency hotspot not needed"
exit 0
fi
[[ \$attempt -lt 3 ]] && sleep 10
done
logger "KIOSK: Network check (\$CHECK_MODE) failed - starting emergency hotspot"
# Stop any conflicting services
systemctl stop wpa_supplicant 2>/dev/null || true
+4 -2
View File
@@ -8,7 +8,7 @@
# tailscale_do_uninstall, netbird_do_uninstall, lms_do_uninstall,
# squeezelite_do_uninstall, asterisk_intercom_do_uninstall,
# webui_do_uninstall, power_schedule_do_remove_all,
# emergency_hotspot_do_disable) instead of
# emergency_hotspot_do_disable, time_sync_do_reset) instead of
# re-implementing removal logic for each addon a second time here - if an
# addon's uninstall logic changes, this picks it up automatically. Only
# the pieces no single addon owns - the kiosk user/files, Node.js/
@@ -62,6 +62,7 @@ action_complete_uninstall() {
echo " • LightDM and Openbox"
echo " • All kiosk schedules and services"
echo " • Emergency hotspot configuration"
echo " • LAN time server setting (back to Ubuntu's defaults)"
echo
echo "⚠️ This CANNOT be undone!"
echo
@@ -90,9 +91,10 @@ action_complete_uninstall() {
asterisk_intercom_do_uninstall purge
webui_do_uninstall
echo "[3/12] Removing schedules and emergency hotspot..."
echo "[3/12] Removing schedules, emergency hotspot and time server setting..."
power_schedule_do_remove_all
emergency_hotspot_do_disable
time_sync_do_reset
# Must come after every addon teardown above - Asterisk Intercom's
# helper still needs this user to resolve its systemd --user session.
+167
View File
@@ -0,0 +1,167 @@
#!/bin/bash
################################################################################
# menus/time_sync.sh - "Time Server (NTP)" (Core Settings).
#
# The kiosk keeps its clock right with systemd-timesyncd, which by default
# asks Ubuntu's time servers on the internet. On a network with no
# internet that never works and the clock slowly drifts - which matters
# here, because lockout times, quiet hours and power schedules all go by
# it. This menu points timesyncd at a time server on the local network
# instead (most routers, Windows domain controllers and NAS boxes can
# serve NTP), or, with no time server at all, sets the clock by hand.
#
# Writes one drop-in, $TIMESYNCD_CONF_DIR/50-kiosk-ntp.conf - removing it
# (the "internet time servers" option, or Complete Uninstall via
# time_sync_do_reset) puts Ubuntu's defaults back untouched.
#
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
################################################################################
TIMESYNC_DROPIN="$TIMESYNCD_CONF_DIR/50-kiosk-ntp.conf"
# The time server(s) this menu configured, or empty for Ubuntu's defaults.
time_sync_servers() {
sed -n 's/^NTP=//p' "$TIMESYNC_DROPIN" 2>/dev/null | head -1
}
time_sync_status() {
local servers synced
servers=$(time_sync_servers)
synced=$(timedatectl show -p NTPSynchronized --value 2>/dev/null || echo "unknown")
echo "Current time: $(date '+%Y-%m-%d %H:%M:%S %Z')"
echo "Time servers: ${servers:-Ubuntu defaults (internet)}"
echo "Synchronized: $([[ "$synced" == "yes" ]] && echo "yes" || echo "no")"
}
time_sync_menu_builder() {
MENU_LABELS=(
"Use a time server on my network"
"Use the internet time servers (Ubuntu default)"
"Set the date and time manually"
"Show sync details"
)
MENU_HANDLERS=(
action_time_sync_lan
action_time_sync_default
action_time_sync_manual
action_time_sync_details
)
}
time_sync_menu() {
run_menu "TIME SERVER (NTP)" time_sync_menu_builder time_sync_status
}
# Validate a space-separated list of hostnames / IPv4 / IPv6 addresses.
time_sync_valid_servers() {
local s
[[ -n "$1" ]] || return 1
for s in $1; do
[[ "$s" =~ ^[A-Za-z0-9.:-]+$ ]] || return 1
done
}
time_sync_apply() {
sudo systemctl enable systemd-timesyncd 2>/dev/null || true
sudo timedatectl set-ntp true 2>/dev/null || true
sudo systemctl restart systemd-timesyncd 2>/dev/null || true
}
# Remove this menu's drop-in, back to Ubuntu's default time servers.
# Also used by Complete Uninstall.
time_sync_do_reset() {
sudo rm -f "$TIMESYNC_DROPIN"
time_sync_apply
}
################################################################################
# Actions
################################################################################
action_time_sync_lan() {
echo
echo "Enter the address of a time (NTP) server on your network - often"
echo "the router (e.g. 192.168.1.1), a Windows domain controller, or a"
echo "NAS. Several can be given, separated by spaces."
local current servers
current=$(time_sync_servers)
servers=$(ask_text "Time server(s)" "${current:-$(ip -4 route show default 2>/dev/null | awk '{print $3; exit}')}")
if ! time_sync_valid_servers "$servers"; then
log_error "Not a valid address list: '$servers'"
pause
return 1
fi
sudo mkdir -p "$TIMESYNCD_CONF_DIR"
sudo tee "$TIMESYNC_DROPIN" > /dev/null <<EOF
# Written by ubuntu-based-kiosk (Core Settings -> Time Server).
# Delete this file to go back to Ubuntu's default time servers.
[Time]
NTP=$servers
EOF
time_sync_apply
echo "Checking the time server (up to 15 seconds)..."
local i
for i in {1..15}; do
if [[ "$(timedatectl show -p NTPSynchronized --value 2>/dev/null)" == "yes" ]]; then
log_success "Clock synchronized with $servers"
pause
return
fi
sleep 1
done
log_warning "Saved, but no sync yet. timesyncd keeps retrying in the background."
echo "If it never syncs, check the address and that the server answers"
echo "NTP (UDP port 123): Core Settings -> Time Server -> Show sync details."
pause
}
action_time_sync_default() {
echo
if [[ ! -f "$TIMESYNC_DROPIN" ]]; then
echo "Already using Ubuntu's default (internet) time servers."
pause
return
fi
time_sync_do_reset
log_success "Back on Ubuntu's default (internet) time servers"
pause
}
# For a network with no time server at all: set the clock by hand.
# Automatic sync stays on afterwards - it just has nothing to sync with
# until a server becomes reachable, and then takes over again.
action_time_sync_manual() {
echo
echo "Current time: $(date '+%Y-%m-%d %H:%M:%S %Z')"
echo "Enter the correct local date and time (timezone: $(timedatectl show -p Timezone --value 2>/dev/null))."
local when
when=$(ask_text "Date and time (YYYY-MM-DD HH:MM)" "$(date '+%Y-%m-%d %H:%M')")
if [[ ! "$when" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}\ [0-9]{2}:[0-9]{2}(:[0-9]{2})?$ ]] || ! date -d "$when" &>/dev/null; then
log_error "Not a valid date/time: '$when' (expected e.g. 2026-10-03 14:30)"
pause
return 1
fi
[[ "$when" =~ :[0-9]{2}:[0-9]{2}$ ]] || when="$when:00"
# timedatectl refuses set-time while automatic sync is on.
sudo timedatectl set-ntp false 2>/dev/null || true
if sudo timedatectl set-time "$when"; then
# Keep the hardware clock in step so the time survives a reboot.
sudo hwclock --systohc 2>/dev/null || true
log_success "Clock set to $(date '+%Y-%m-%d %H:%M:%S %Z')"
else
log_error "Couldn't set the clock"
fi
sudo timedatectl set-ntp true 2>/dev/null || true
pause
}
action_time_sync_details() {
echo
timedatectl status 2>/dev/null || true
echo
timedatectl timesync-status 2>/dev/null || echo "(timesync-status not available)"
pause
}