ISO: add --offline bundle so the kiosk installs with no internet
`iso/build-iso.sh --offline` (and the workflow's new `offline` box, on by default) adds ~420 MB to the ISO and copies it to /opt/kiosk-offline: - apt/: local repository with every package install.sh's provisioning and the CUPS addon install ask for, NodeSource's nodejs, and all dependencies a fresh server install doesn't already have. Resolved against the source ISO's own package manifest via a synthesized dpkg status (iso/offline/fake-dpkg-status.py), so apt downloads only what's missing and makes the same virtual/alternative choices as the target. - npm/: prebuilt kiosk-app (Electron 42, binary included - it no longer downloads at npm install time) and Web UI node_modules. On first boot, kiosk-firstboot installs online as before when there's internet; with none it waits KIOSK_OFFLINE_WAIT (90s), then runs `kiosk-offline-apt on` (apt.conf.d drop-in: bundle is the ONLY source) and turns it off again afterwards. install.sh notices offline mode via offline_mode_active (lib/config.sh) and installs nodejs from the bundle and unpacks prebuilt node_modules instead of npm install. Package lists moved into PROVISION_APT_PACKAGES / PROVISION_INTEL_APT_PACKAGES / CUPS_APT_PACKAGES so the bundler reads the same lists install.sh installs from. Tested: built against the 26.04.1 ISO, then in a network-less chroot of the ISO's own server filesystem installed every package set, ran provision_create_kiosk_user/install_nodejs/install_app and webui_install_app_files in offline mode (Electron runs, no missing libraries, chrome-sandbox setuid), and ran kiosk-firstboot through the offline switch with a stub install.sh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
This commit is contained in:
Executable
+213
@@ -0,0 +1,213 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# iso/offline/build-bundle.sh - Build the offline install bundle that
|
||||
# `iso/build-iso.sh --offline` puts on the ISO, so a kiosk can be installed
|
||||
# with no internet at all. Called by build-iso.sh; not normally run by hand.
|
||||
#
|
||||
# Usage: build-bundle.sh OUT_DIR SOURCE_ISO REPO_DIR CACHE_DIR
|
||||
#
|
||||
# OUT_DIR ends up as /opt/kiosk-offline on the installed system:
|
||||
# apt/ Local apt repository (flat, [trusted=yes]) with every
|
||||
# package install.sh's provisioning and the CUPS addon
|
||||
# install ask for, NodeSource's nodejs, and all their
|
||||
# dependencies that a fresh Ubuntu Server install from
|
||||
# SOURCE_ISO doesn't already have.
|
||||
# npm/kiosk-app-node_modules.tar.gz Prebuilt node_modules (Electron
|
||||
# npm/webui-node_modules.tar.gz included) in place of `npm install`.
|
||||
# bundle-info.txt What was bundled, from where, when.
|
||||
#
|
||||
# Package lists are read from REPO_DIR's own lib/provision.sh and
|
||||
# menus/addon_cups.sh (PROVISION_APT_PACKAGES, PROVISION_INTEL_APT_PACKAGES,
|
||||
# CUPS_APT_PACKAGES), so the bundle always matches what install.sh asks for.
|
||||
#
|
||||
# Dependencies are resolved against SOURCE_ISO's own package manifest - a
|
||||
# fake dpkg status built from it (fake-dpkg-status.py) - so apt only
|
||||
# downloads what's actually missing and makes the same choices (virtual
|
||||
# packages, alternatives) it will make on the real target.
|
||||
#
|
||||
# CACHE_DIR keeps apt's downloaded .debs, npm's cache and Electron's
|
||||
# download between builds, so a rebuild only fetches what changed.
|
||||
#
|
||||
# Build host: Ubuntu or Debian, amd64 (Node.js from the bundled .deb is
|
||||
# run here to build node_modules). Needs: apt-get, apt-ftparchive
|
||||
# (apt-utils), dpkg-deb, python3, gpg, curl, xorriso, and
|
||||
# /usr/share/keyrings/ubuntu-archive-keyring.gpg (ubuntu-keyring).
|
||||
################################################################################
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
OUT_DIR="$1"
|
||||
SRC_ISO="$2"
|
||||
REPO_DIR="$3"
|
||||
CACHE_DIR="$4"
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
UBUNTU_MIRROR="${UBUNTU_MIRROR:-http://archive.ubuntu.com/ubuntu}"
|
||||
NODESOURCE_REPO="https://deb.nodesource.com/node_22.x"
|
||||
NODESOURCE_KEY="https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key"
|
||||
UBUNTU_KEYRING=/usr/share/keyrings/ubuntu-archive-keyring.gpg
|
||||
|
||||
# Needed on the target beyond install.sh's own lists: kiosk-firstboot's
|
||||
# prerequisites, wget (lib/electron.sh's fallback), and openssh-server
|
||||
# (installed by the Ubuntu installer only when it had a network).
|
||||
EXTRA_APT_PACKAGES=(nodejs jq git curl wget unzip openssh-server)
|
||||
|
||||
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||
info() { echo " $*"; }
|
||||
|
||||
for cmd in apt-get apt-ftparchive dpkg-deb dpkg python3 gpg curl xorriso tar gzip; do
|
||||
command -v "$cmd" &>/dev/null \
|
||||
|| die "--offline needs '$cmd' - on Ubuntu/Debian: sudo apt install apt-utils dpkg python3 gnupg curl xorriso"
|
||||
done
|
||||
[[ "$(dpkg --print-architecture)" == "amd64" ]] \
|
||||
|| die "--offline must be built on an amd64 machine (it runs the bundled Node.js to build node_modules)"
|
||||
[[ -r "$UBUNTU_KEYRING" ]] \
|
||||
|| die "missing $UBUNTU_KEYRING - install it: sudo apt install ubuntu-keyring"
|
||||
|
||||
rm -rf "$OUT_DIR/apt" "$OUT_DIR/npm"
|
||||
mkdir -p "$OUT_DIR/apt" "$OUT_DIR/npm" "$CACHE_DIR"
|
||||
# apt's Dir= and Signed-By need absolute paths.
|
||||
OUT_DIR="$(cd "$OUT_DIR" && pwd)"
|
||||
CACHE_DIR="$(cd "$CACHE_DIR" && pwd)"
|
||||
REPO_DIR="$(cd "$REPO_DIR" && pwd)"
|
||||
SRC_ISO="$(cd "$(dirname "$SRC_ISO")" && pwd)/$(basename "$SRC_ISO")"
|
||||
|
||||
################################################################################
|
||||
# Which Ubuntu release, and what a default server install already has
|
||||
################################################################################
|
||||
|
||||
codename="$(xorriso -indev "$SRC_ISO" -ls /dists 2>/dev/null \
|
||||
| tr -d "'" | awk '{print $NF}' | grep -E '^[a-z]+$' | head -1)"
|
||||
[[ -n "$codename" ]] || die "couldn't tell the Ubuntu release of $SRC_ISO (no /dists on it)"
|
||||
|
||||
manifest="$CACHE_DIR/server.manifest"
|
||||
xorriso -osirrox on -indev "$SRC_ISO" \
|
||||
-extract /casper/ubuntu-server-minimal.ubuntu-server.manifest.full "$manifest" 2>/dev/null \
|
||||
|| die "no server package manifest on $SRC_ISO (casper/ubuntu-server-minimal.ubuntu-server.manifest.full)"
|
||||
chmod u+w "$manifest"
|
||||
|
||||
################################################################################
|
||||
# Standalone apt environment for $codename (never touches the host's apt)
|
||||
################################################################################
|
||||
|
||||
R="$CACHE_DIR/aptroot-$codename"
|
||||
mkdir -p "$R/etc/apt/apt.conf.d" "$R/etc/apt/preferences.d" "$R/etc/apt/sources.list.d" \
|
||||
"$R/var/lib/apt/lists/partial" "$R/var/cache/apt/archives/partial" "$R/var/lib/dpkg" "$R/keys"
|
||||
: > "$R/etc/apt/sources.list"
|
||||
cp "$UBUNTU_KEYRING" "$R/keys/ubuntu.gpg"
|
||||
curl -fsSL "$NODESOURCE_KEY" | gpg --dearmor --yes -o "$R/keys/nodesource.gpg"
|
||||
cat > "$R/etc/apt/sources.list.d/bundle.sources" <<EOF
|
||||
Types: deb
|
||||
URIs: $UBUNTU_MIRROR
|
||||
Suites: $codename $codename-updates $codename-security
|
||||
Components: main restricted universe multiverse
|
||||
Signed-By: $R/keys/ubuntu.gpg
|
||||
|
||||
Types: deb
|
||||
URIs: $NODESOURCE_REPO
|
||||
Suites: nodistro
|
||||
Components: main
|
||||
Signed-By: $R/keys/nodesource.gpg
|
||||
EOF
|
||||
|
||||
APT=(apt-get
|
||||
-o "Dir=$R" -o "Dir::State::status=$R/var/lib/dpkg/status"
|
||||
-o APT::Architecture=amd64 -o APT::Architectures::=amd64
|
||||
-o Acquire::Languages=none -o Acquire::GzipIndexes=false
|
||||
-o Debug::NoLocking=1 -q)
|
||||
|
||||
info "Fetching $codename package indexes..."
|
||||
: > "$R/var/lib/dpkg/status"
|
||||
"${APT[@]}" update >/dev/null
|
||||
python3 "$HERE/fake-dpkg-status.py" "$manifest" "$R/var/lib/apt/lists" > "$R/var/lib/dpkg/status"
|
||||
"${APT[@]}" check >/dev/null || die "baseline from the ISO manifest is inconsistent"
|
||||
|
||||
################################################################################
|
||||
# Packages
|
||||
################################################################################
|
||||
|
||||
# shellcheck disable=SC1091,SC2034
|
||||
mapfile -t packages < <(
|
||||
SCRIPT_DIR="$REPO_DIR"
|
||||
source "$REPO_DIR/lib/provision.sh"
|
||||
source "$REPO_DIR/menus/addon_cups.sh"
|
||||
printf '%s\n' "${PROVISION_APT_PACKAGES[@]}" "${PROVISION_INTEL_APT_PACKAGES[@]}" \
|
||||
"${CUPS_APT_PACKAGES[@]}" "${EXTRA_APT_PACKAGES[@]}"
|
||||
)
|
||||
(( ${#packages[@]} > 20 )) || die "couldn't read the package lists from $REPO_DIR"
|
||||
|
||||
info "Resolving ${#packages[@]} packages (with recommends, as install.sh installs them)..."
|
||||
# --print-uris against an empty archive dir: it leaves out anything already
|
||||
# in the cache, which on a rebuild would be everything.
|
||||
rm -rf "$R/empty-archives"; mkdir -p "$R/empty-archives/partial"
|
||||
mapfile -t debs < <("${APT[@]}" -qq -o "Dir::Cache::archives=$R/empty-archives" \
|
||||
install --print-uris -y "${packages[@]}" | awk '{print $2}')
|
||||
(( ${#debs[@]} )) || die "apt resolved nothing to download - check the output above"
|
||||
|
||||
info "Downloading ${#debs[@]} .debs (cached between builds)..."
|
||||
"${APT[@]}" install --download-only -y "${packages[@]}" >/dev/null
|
||||
|
||||
for f in "${debs[@]}"; do
|
||||
cp "$R/var/cache/apt/archives/$f" "$OUT_DIR/apt/"
|
||||
done
|
||||
(
|
||||
cd "$OUT_DIR/apt"
|
||||
apt-ftparchive packages . > Packages 2>/dev/null
|
||||
gzip -9 -k Packages
|
||||
apt-ftparchive release . > Release
|
||||
)
|
||||
|
||||
################################################################################
|
||||
# node_modules, built with the exact Node.js being bundled
|
||||
################################################################################
|
||||
|
||||
node_deb="$(printf '%s\n' "${debs[@]}" | grep -E '^nodejs_' | head -1 || true)"
|
||||
[[ -n "$node_deb" ]] || node_deb="$(cd "$R/var/cache/apt/archives" && ls nodejs_*_amd64.deb 2>/dev/null | sort -V | tail -1)"
|
||||
[[ -n "$node_deb" ]] || die "no nodejs .deb downloaded"
|
||||
node_root="$CACHE_DIR/node-root"
|
||||
rm -rf "$node_root"
|
||||
dpkg-deb -x "$R/var/cache/apt/archives/$node_deb" "$node_root"
|
||||
export PATH="$node_root/usr/bin:$PATH"
|
||||
export npm_config_cache="$CACHE_DIR/npm-cache"
|
||||
export electron_config_cache="$CACHE_DIR/electron-cache"
|
||||
export npm_config_update_notifier=false npm_config_fund=false npm_config_audit=false
|
||||
info "Building node_modules with Node.js $(node -v)..."
|
||||
|
||||
# build_modules NAME SRC_DIR NPM_ARGS...
|
||||
build_modules() {
|
||||
local name="$1" src="$2"; shift 2
|
||||
local tmp="$CACHE_DIR/build-$name"
|
||||
rm -rf "$tmp"; mkdir -p "$tmp"
|
||||
cp "$src/package.json" "$tmp/"
|
||||
[[ -f "$src/package-lock.json" ]] && cp "$src/package-lock.json" "$tmp/"
|
||||
(cd "$tmp" && npm install --no-progress "$@" >/dev/null) || die "npm install failed for $name"
|
||||
# Electron's package no longer fetches its binary at install time (no
|
||||
# postinstall since ~v42) - lib/electron.sh runs this same install.js
|
||||
# on the target when online; offline it has to already be in dist/.
|
||||
if [[ -f "$tmp/node_modules/electron/install.js" ]]; then
|
||||
(cd "$tmp" && node node_modules/electron/install.js) || die "Electron binary download failed"
|
||||
fi
|
||||
tar -czf "$OUT_DIR/npm/${name}-node_modules.tar.gz" --owner=0 --group=0 -C "$tmp" node_modules
|
||||
info "$name: $(du -sh "$tmp/node_modules" | cut -f1) -> $(du -h "$OUT_DIR/npm/${name}-node_modules.tar.gz" | cut -f1) compressed"
|
||||
rm -rf "$tmp"
|
||||
}
|
||||
|
||||
build_modules kiosk-app "$REPO_DIR/kiosk-app"
|
||||
tar -tzf "$OUT_DIR/npm/kiosk-app-node_modules.tar.gz" node_modules/electron/dist/electron >/dev/null 2>&1 \
|
||||
|| die "Electron binary missing from the built kiosk-app modules (download blocked?)"
|
||||
build_modules webui "$REPO_DIR/webui" --omit=dev
|
||||
|
||||
################################################################################
|
||||
|
||||
electron_ver="$(tar -xzOf "$OUT_DIR/npm/kiosk-app-node_modules.tar.gz" node_modules/electron/package.json \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["version"])')"
|
||||
{
|
||||
echo "Ubuntu Based Kiosk offline bundle"
|
||||
echo "Built: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "Ubuntu: $codename (resolved against $(basename "$SRC_ISO"))"
|
||||
echo "Packages: ${#debs[@]} .debs for: ${packages[*]}"
|
||||
echo "Node.js: ${node_deb}"
|
||||
echo "Electron: $electron_ver"
|
||||
} > "$OUT_DIR/bundle-info.txt"
|
||||
|
||||
info "Bundle: $(du -sh "$OUT_DIR" | cut -f1) (${#debs[@]} packages, Node.js $(node -v), Electron $electron_ver)"
|
||||
Executable
+104
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Write a dpkg status file claiming a set of packages is installed.
|
||||
|
||||
Usage: fake-dpkg-status.py MANIFEST LISTS_DIR > status
|
||||
|
||||
MANIFEST is an Ubuntu ISO's casper/*.manifest.full ("name[:arch]<TAB>version"
|
||||
per line) - the packages a default Ubuntu Server install ends up with.
|
||||
LISTS_DIR is an apt lists directory holding uncompressed *_Packages indexes.
|
||||
|
||||
iso/offline/build-bundle.sh resolves the kiosk's packages against this, so
|
||||
apt only downloads what a freshly installed server is actually missing
|
||||
(and makes the same alternative/virtual-package choices the real target
|
||||
will), instead of the whole dependency closure down to libc6.
|
||||
"""
|
||||
import glob
|
||||
import os
|
||||
import sys
|
||||
|
||||
KEEP = ("Package", "Essential", "Priority", "Section", "Architecture",
|
||||
"Multi-Arch", "Source", "Version", "Replaces", "Provides", "Depends",
|
||||
"Pre-Depends", "Recommends", "Suggests", "Breaks", "Conflicts")
|
||||
|
||||
|
||||
def stanzas(path):
|
||||
with open(path, encoding="utf-8", errors="replace") as f:
|
||||
cur = []
|
||||
for line in f:
|
||||
if line.strip() == "":
|
||||
if cur:
|
||||
yield cur
|
||||
cur = []
|
||||
else:
|
||||
cur.append(line.rstrip("\n"))
|
||||
if cur:
|
||||
yield cur
|
||||
|
||||
|
||||
def parse(lines):
|
||||
fields, order, key = {}, [], None
|
||||
for line in lines:
|
||||
if line[:1] in (" ", "\t") and key:
|
||||
fields[key] += "\n" + line
|
||||
else:
|
||||
key, _, val = line.partition(":")
|
||||
fields[key] = val.strip()
|
||||
order.append(key)
|
||||
return fields
|
||||
|
||||
|
||||
def main():
|
||||
manifest, lists_dir = sys.argv[1], sys.argv[2]
|
||||
wanted = {}
|
||||
with open(manifest) as f:
|
||||
for line in f:
|
||||
parts = line.rstrip("\n").split("\t")
|
||||
if len(parts) != 2 or parts[0].startswith("snap:"):
|
||||
continue
|
||||
wanted[parts[0].split(":")[0]] = parts[1]
|
||||
|
||||
exact, any_version = {}, {}
|
||||
for path in glob.glob(os.path.join(lists_dir, "*_Packages")):
|
||||
for st in stanzas(path):
|
||||
p = parse(st)
|
||||
name = p.get("Package")
|
||||
if name not in wanted:
|
||||
continue
|
||||
any_version.setdefault(name, p)
|
||||
if p.get("Version") == wanted[name]:
|
||||
exact[name] = p
|
||||
|
||||
missing = 0
|
||||
for name, version in sorted(wanted.items()):
|
||||
p = exact.get(name) or any_version.get(name)
|
||||
if p is None:
|
||||
missing += 1
|
||||
continue
|
||||
out = [f"Package: {name}", "Status: install ok installed"]
|
||||
for k in KEEP[1:]:
|
||||
if k == "Version":
|
||||
out.append(f"Version: {version}")
|
||||
elif k in p:
|
||||
val = p[k]
|
||||
# The archive no longer carries this exact version (an
|
||||
# update superseded it), so these fields came from a
|
||||
# different one and its dependencies (version pins on
|
||||
# siblings, a newer kernel ABI...) don't describe what's
|
||||
# installed. Already-installed packages' own dependencies
|
||||
# are assumed satisfied anyway, so just leave them out.
|
||||
if name not in exact and k in ("Depends", "Pre-Depends", "Recommends"):
|
||||
continue
|
||||
# Same for versioned Breaks/Conflicts - drop them outright
|
||||
# (unversioned ones still hold for any version).
|
||||
if name not in exact and k in ("Breaks", "Conflicts"):
|
||||
val = ", ".join(c.strip() for c in val.split(",") if "(" not in c)
|
||||
if not val:
|
||||
continue
|
||||
out.append(f"{k}: {val}")
|
||||
print("\n".join(out) + "\n")
|
||||
print(f"fake-dpkg-status: {len(wanted) - missing} packages marked installed,"
|
||||
f" {missing} not in the archive indexes (skipped)", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user