Unattended installs on an --offline ISO cap the online attempt (default 40 min) and finish from the bundle on timeout or failure. Interactive retry menu gains [O] to install from the bundle. NodeSource curl gets timeouts. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A97KUggSE3rBL8veDM5Mr4
302 lines
12 KiB
Bash
Executable File
302 lines
12 KiB
Bash
Executable File
#!/bin/bash
|
|
################################################################################
|
|
# kiosk-firstboot - runs ubuntu-based-kiosk's ./install.sh on the first
|
|
# boot of a system installed from the custom ISO (iso/build-iso.sh).
|
|
#
|
|
# install.sh refuses to run as root and expects a regular sudo user, so
|
|
# this runs it as the admin account the Ubuntu installer created (the
|
|
# first regular user, uid 1000), from a copy of the repo in that user's
|
|
# home - the same layout as a manual `git clone && ./install.sh`, so
|
|
# re-running ./install.sh later and Advanced -> Upgrade both work as
|
|
# usual afterwards.
|
|
#
|
|
# For the duration of the install only, that user gets passwordless sudo
|
|
# (nobody has typed a password yet, and in unattended mode nobody will);
|
|
# the drop-in is removed on every exit path, including the reboot at the
|
|
# end of install.sh (see ExecStopPost in kiosk-firstboot.service).
|
|
#
|
|
# Settings come from /etc/kiosk-firstboot.conf, written at ISO build time:
|
|
# KIOSK_UNATTENDED=1 take every prompt's default, skip the Core
|
|
# Settings menu, reboot into the kiosk when done.
|
|
# KIOSK_UNATTENDED=0 (default) normal interactive install.sh on tty1.
|
|
# KIOSK_OFFLINE_WAIT=N on an ISO built with --offline: seconds to wait
|
|
# for internet before installing from the bundled
|
|
# packages instead (default 90).
|
|
# KIOSK_ONLINE_TIMEOUT=N on an --offline ISO, unattended: if the online
|
|
# install hasn't finished after N seconds (a stalled
|
|
# download on a flaky link), stop it and finish from
|
|
# the bundled packages instead (default 2400).
|
|
################################################################################
|
|
|
|
set -uo pipefail
|
|
|
|
CONF=/etc/kiosk-firstboot.conf
|
|
STATE_DIR=/var/lib/kiosk-firstboot
|
|
PENDING="$STATE_DIR/pending"
|
|
DONE_MARKER="$STATE_DIR/done"
|
|
SUDOERS_DROPIN=/etc/sudoers.d/90-kiosk-firstboot
|
|
APT_DROPIN=/etc/apt/apt.conf.d/90-kiosk-firstboot
|
|
REPO_SRC=/opt/ubuntu-based-kiosk
|
|
LOG="$STATE_DIR/install.log"
|
|
OFFLINE_BUNDLE=/opt/kiosk-offline
|
|
OFFLINE_APT=/usr/local/sbin/kiosk-offline-apt
|
|
OFFLINE_FLAG="$STATE_DIR/offline-mode"
|
|
|
|
KIOSK_UNATTENDED=0
|
|
KIOSK_OFFLINE_WAIT=90
|
|
KIOSK_ONLINE_TIMEOUT=2400
|
|
# shellcheck source=/dev/null
|
|
[[ -f "$CONF" ]] && source "$CONF"
|
|
|
|
cleanup() {
|
|
rm -f "$SUDOERS_DROPIN" "$APT_DROPIN"
|
|
# Offline mode was only for this install - hand apt back to the real
|
|
# archive so normal updates work once the kiosk is online.
|
|
if [[ -f "$OFFLINE_FLAG" ]]; then
|
|
"$OFFLINE_APT" off >/dev/null 2>&1 || true
|
|
rm -f "$OFFLINE_FLAG"
|
|
fi
|
|
if [[ -f "$DONE_MARKER" ]]; then
|
|
rm -f "$PENDING"
|
|
fi
|
|
}
|
|
|
|
if [[ "${1:-}" == "--cleanup" ]]; then
|
|
cleanup
|
|
exit 0
|
|
fi
|
|
|
|
have_bundle() { [[ -f "$OFFLINE_BUNDLE/apt/Packages" ]]; }
|
|
|
|
# Finish this install from the bundled packages instead of the internet
|
|
# (cleanup() turns it back off). install.sh notices via lib/config.sh's
|
|
# offline_mode_active.
|
|
switch_to_offline() {
|
|
touch "$OFFLINE_FLAG"
|
|
if ! "$OFFLINE_APT" on; then
|
|
echo "ERROR: couldn't switch apt to the offline bundle."
|
|
rm -f "$OFFLINE_FLAG"
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# Make sure tty1 gets its login prompt back. getty@tty1 is ordered after
|
|
# this unit and normally starts by itself once we exit; this just covers
|
|
# a getty that was stopped or never queued (e.g. started by hand later).
|
|
release_tty() {
|
|
systemctl --no-block start getty@tty1.service 2>/dev/null || true
|
|
}
|
|
|
|
trap cleanup EXIT
|
|
trap 'exit 130' INT TERM HUP
|
|
|
|
banner() {
|
|
clear
|
|
echo "════════════════════════════════════════════════════════════"
|
|
echo " Ubuntu Based Kiosk - First Boot Setup"
|
|
echo "════════════════════════════════════════════════════════════"
|
|
echo
|
|
}
|
|
|
|
# Interactive-mode escape hatch when something needs a human: retry,
|
|
# drop to a shell as the admin user to fix it, or give up until next boot.
|
|
# Returns 0 to retry, 1 to give up.
|
|
offer_retry() {
|
|
local choice offline_opt=""
|
|
have_bundle && [[ ! -f "$OFFLINE_FLAG" ]] && offline_opt=" [O] Install from the offline bundle on this disk"
|
|
while true; do
|
|
echo
|
|
echo " [R] Retry [S] Shell (as $ADMIN_USER, exit to come back) [Q] Quit to login prompt$offline_opt"
|
|
read -r -p "Choice [R]: " choice
|
|
case "${choice,,}" in
|
|
""|r) return 0 ;;
|
|
o) [[ -n "$offline_opt" ]] && switch_to_offline && return 0 ;;
|
|
s) runuser -l "$ADMIN_USER" ;;
|
|
q)
|
|
echo "Setup will run again on next boot, or run it now with:"
|
|
echo " sudo systemctl start kiosk-firstboot"
|
|
return 1
|
|
;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
# Returns 0 once online, 2 to install from the offline bundle instead
|
|
# (only when there is one), 1 to give up.
|
|
wait_for_internet() {
|
|
local waited=0
|
|
until curl -fsS --max-time 10 -o /dev/null https://deb.nodesource.com/ 2>/dev/null \
|
|
|| curl -fsS --max-time 10 -o /dev/null http://archive.ubuntu.com/ubuntu/ 2>/dev/null; do
|
|
if (( waited == 0 )); then
|
|
echo "Waiting for an internet connection (needed for packages,"
|
|
echo "Node.js and Electron)..."
|
|
if [[ -f "$OFFLINE_BUNDLE/apt/Packages" ]]; then
|
|
echo "This disk has the offline bundle: installing from it instead"
|
|
echo "if there's still no internet after ${KIOSK_OFFLINE_WAIT}s."
|
|
fi
|
|
fi
|
|
sleep 10
|
|
waited=$((waited + 10))
|
|
if [[ -f "$OFFLINE_BUNDLE/apt/Packages" ]] && (( waited >= KIOSK_OFFLINE_WAIT )); then
|
|
echo "No internet after ${waited}s - installing from the offline bundle on this disk."
|
|
return 2
|
|
fi
|
|
if (( waited % 120 == 0 )); then
|
|
echo "Still no internet after ${waited}s. Network status:"
|
|
networkctl list --no-pager 2>/dev/null || ip -brief addr
|
|
if [[ "$KIOSK_UNATTENDED" != "1" ]]; then
|
|
echo "WiFi or static IP? Use the Shell option and edit /etc/netplan/*.yaml, then 'sudo netplan apply'."
|
|
offer_retry || return 1
|
|
fi
|
|
fi
|
|
done
|
|
echo "Internet connection OK."
|
|
}
|
|
|
|
################################################################################
|
|
|
|
# Keep kernel/systemd chatter off the screen the installer is drawing on.
|
|
dmesg -n 1 2>/dev/null || true
|
|
chvt 1 2>/dev/null || true
|
|
export TERM=linux
|
|
|
|
ADMIN_USER="$(getent passwd 1000 | cut -d: -f1)"
|
|
banner
|
|
if [[ -z "$ADMIN_USER" ]]; then
|
|
echo "ERROR: no regular user (uid 1000) found - can't run install.sh."
|
|
echo "Create one, then: sudo systemctl start kiosk-firstboot"
|
|
release_tty
|
|
exit 1
|
|
fi
|
|
if [[ "$ADMIN_USER" == "kiosk" ]]; then
|
|
echo "ERROR: the admin account is named 'kiosk', which install.sh reserves"
|
|
echo "for the kiosk's own restricted user. Reinstall with a different username."
|
|
release_tty
|
|
exit 1
|
|
fi
|
|
ADMIN_HOME="$(getent passwd "$ADMIN_USER" | cut -d: -f6)"
|
|
REPO_DEST="$ADMIN_HOME/ubuntu-based-kiosk"
|
|
|
|
if [[ ! -d "$REPO_DEST" ]]; then
|
|
cp -a "$REPO_SRC" "$REPO_DEST"
|
|
chmod -R u+w "$REPO_DEST"
|
|
chown -R "$ADMIN_USER:$ADMIN_USER" "$REPO_DEST"
|
|
fi
|
|
|
|
echo "Admin user : $ADMIN_USER"
|
|
echo "Installer : $REPO_DEST/install.sh"
|
|
echo "Mode : $([[ "$KIOSK_UNATTENDED" == "1" ]] && echo "unattended (defaults, reboots when done)" || echo "interactive")"
|
|
echo "Log : $LOG"
|
|
echo
|
|
|
|
# Anything else on a fresh system that wants the dpkg lock (e.g. the
|
|
# first unattended-upgrades run): wait for it rather than failing. Not
|
|
# `cloud-init status --wait` - cloud-init's final stage runs after
|
|
# multi-user.target, which waits for this service: a deadlock.
|
|
mkdir -p "$STATE_DIR"
|
|
printf 'DPkg::Lock::Timeout "900";\n' > "$APT_DROPIN"
|
|
|
|
wait_for_internet
|
|
case $? in
|
|
0) ;;
|
|
2)
|
|
# install.sh notices this by itself (lib/config.sh's
|
|
# offline_mode_active) and unpacks prebuilt node_modules instead
|
|
# of running npm; cleanup() switches it back off.
|
|
touch "$OFFLINE_FLAG"
|
|
if ! "$OFFLINE_APT" on; then
|
|
echo "ERROR: couldn't switch apt to the offline bundle."
|
|
release_tty
|
|
exit 1
|
|
fi
|
|
;;
|
|
*) release_tty; exit 1 ;;
|
|
esac
|
|
|
|
# install.sh's own preflight needs jq; Advanced -> Upgrade needs git.
|
|
echo "Installing installer prerequisites (jq, git)..."
|
|
DEBIAN_FRONTEND=noninteractive apt-get update -q >/dev/null
|
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -q jq git curl >/dev/null
|
|
|
|
cat > "$SUDOERS_DROPIN".tmp <<SUDOERS
|
|
# Temporary - removed by /usr/local/sbin/kiosk-firstboot when it exits.
|
|
$ADMIN_USER ALL=(ALL) NOPASSWD: ALL
|
|
Defaults:$ADMIN_USER env_keep += "DEBIAN_FRONTEND"
|
|
SUDOERS
|
|
chmod 440 "$SUDOERS_DROPIN".tmp
|
|
if ! visudo -cf "$SUDOERS_DROPIN".tmp >/dev/null; then
|
|
rm -f "$SUDOERS_DROPIN".tmp
|
|
echo "ERROR: generated sudoers drop-in failed validation."
|
|
release_tty
|
|
exit 1
|
|
fi
|
|
mv "$SUDOERS_DROPIN".tmp "$SUDOERS_DROPIN"
|
|
|
|
run_install() {
|
|
local -a envs=(
|
|
HOME="$ADMIN_HOME" USER="$ADMIN_USER" LOGNAME="$ADMIN_USER"
|
|
SHELL=/bin/bash TERM=linux LANG="${LANG:-C.UTF-8}"
|
|
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
|
# config.sh would otherwise derive this from SUDO_USER/whoami.
|
|
BUILD_USER="$ADMIN_USER"
|
|
KIOSK_UNATTENDED="$KIOSK_UNATTENDED"
|
|
KIOSK_PROVISION_DONE_MARKER="$DONE_MARKER"
|
|
)
|
|
# Even interactively: stdout goes through tee, which debconf dialogs
|
|
# cannot draw on, and nothing install.sh installs needs a debconf answer.
|
|
envs+=(DEBIAN_FRONTEND=noninteractive)
|
|
|
|
# runuser rather than sudo -u: sudo would set SUDO_USER=root, which
|
|
# config.sh reads as "the admin running this tool".
|
|
# A stalled download on a flaky connection would otherwise hang an
|
|
# unattended install forever: with the offline bundle as a safety net,
|
|
# cap the online attempt (timeout signals the whole process group).
|
|
local -a cap=()
|
|
if [[ "$KIOSK_UNATTENDED" == "1" ]] && have_bundle && [[ ! -f "$OFFLINE_FLAG" ]]; then
|
|
cap=(timeout --kill-after=30 "$KIOSK_ONLINE_TIMEOUT")
|
|
fi
|
|
"${cap[@]}" runuser -u "$ADMIN_USER" -- env -i "${envs[@]}" \
|
|
bash -c 'cd "$1" && exec ./install.sh' _ "$REPO_DEST" \
|
|
2>&1 | tee -a "$LOG"
|
|
return "${PIPESTATUS[0]}"
|
|
}
|
|
|
|
attempt=1
|
|
while true; do
|
|
run_install
|
|
rc=$?
|
|
if [[ -f "$DONE_MARKER" ]]; then
|
|
# Reached only when the final "Reboot now?" was declined.
|
|
echo
|
|
echo "Kiosk install complete. Reboot to start the kiosk: sudo reboot"
|
|
cleanup
|
|
release_tty
|
|
exit 0
|
|
fi
|
|
|
|
echo
|
|
echo "install.sh did not finish (exit code $rc). Full output: $LOG"
|
|
if [[ "$KIOSK_UNATTENDED" == "1" ]]; then
|
|
# Online attempt failed or stalled: the bundle on this disk is
|
|
# always going to work, so use it rather than retry the same thing.
|
|
if have_bundle && [[ ! -f "$OFFLINE_FLAG" ]]; then
|
|
echo "Online install didn't finish - continuing from the offline bundle on this disk."
|
|
if switch_to_offline; then
|
|
attempt=$((attempt + 1))
|
|
continue
|
|
fi
|
|
fi
|
|
if (( attempt >= 3 )); then
|
|
echo "Giving up after $attempt attempts; will try again next boot."
|
|
release_tty
|
|
exit 1
|
|
fi
|
|
attempt=$((attempt + 1))
|
|
echo "Retrying in 60 seconds (attempt $attempt of 3)..."
|
|
sleep 60
|
|
else
|
|
offer_retry || { release_tty; exit 1; }
|
|
fi
|
|
done
|