#!/usr/bin/env bash # Install as /var/lib/dietpi/postboot.d/10-pikit-firstboot and chmod +x. # Runs once on first boot to finalize device-unique setup. set -euo pipefail FIRSTBOOT_DIR="/var/lib/pikit/firstboot" STATE_FILE="$FIRSTBOOT_DIR/state.json" LOG_FILE="$FIRSTBOOT_DIR/firstboot.log" ERROR_FILE="$FIRSTBOOT_DIR/firstboot.error" DONE_FILE="$FIRSTBOOT_DIR/firstboot.done" LOCK_FILE="$FIRSTBOOT_DIR/firstboot.lock" CERT_DIR="/etc/pikit/certs" WEB_ASSETS="/var/www/pikit-web/assets" PROFILE_FILE="/etc/pikit/profile.json" MOTD_FILE="/etc/motd" FIRSTBOOT_CONF="/etc/pikit/firstboot.conf" APT_UA_OVERRIDE="/etc/apt/apt.conf.d/51pikit-unattended.conf" SERVICE_JSON="/etc/pikit/services.json" STEPS=( "Preparing system" "Generating security keys" "Securing the dashboard" "Updating software (this can take a while)" "Final checks" "Starting Pi-Kit" ) STEP_STATUS=(pending pending pending pending pending pending) CURRENT_STEP="" CURRENT_INDEX=-1 PIKIT_FIRSTBOOT_UPDATES="${PIKIT_FIRSTBOOT_UPDATES:-1}" log() { printf '[%s] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$*" } load_config() { if [ -f "$FIRSTBOOT_CONF" ]; then # shellcheck disable=SC1090 . "$FIRSTBOOT_CONF" fi PIKIT_FIRSTBOOT_UPDATES="${PIKIT_FIRSTBOOT_UPDATES:-1}" } skip_updates() { case "${PIKIT_FIRSTBOOT_UPDATES,,}" in 0|false|no|off) return 0 ;; esac return 1 } configure_unattended_defaults() { if [ -f "$APT_UA_OVERRIDE" ]; then log "Unattended-upgrades config already present; skipping defaults." return fi if ! command -v python3 >/dev/null 2>&1; then log "python3 missing; skipping unattended-upgrades defaults." return fi PROFILE_FILE="$PROFILE_FILE" SERVICE_JSON="$SERVICE_JSON" PYTHONPATH=/usr/local/bin python3 - <<'PY' import sys try: from pikit_api.auto_updates import set_updates_config except Exception as e: print(f"pikit_api unavailable: {e}") sys.exit(0) set_updates_config({"enable": True, "scope": "security"}) PY log "Unattended-upgrades defaults applied (security-only)." } apply_profile() { if [ ! -f "$PROFILE_FILE" ]; then log "Profile step skipped (no profile.json)." return fi PYTHONPATH=/usr/local/bin python3 - <<'PY' import json import os import pathlib import pwd import grp import shutil import subprocess profile_path = pathlib.Path(os.environ.get("PROFILE_FILE", "/etc/pikit/profile.json")) services_path = pathlib.Path(os.environ.get("SERVICE_JSON", "/etc/pikit/services.json")) def log(msg: str) -> None: print(msg) def ipv6_enabled() -> bool: cfg = pathlib.Path("/etc/default/ufw") if not cfg.exists(): return True for line in cfg.read_text().splitlines(): if line.strip().startswith("IPV6="): return line.split("=", 1)[1].strip().lower() == "yes" return True def get_ipv6_prefixes() -> list: if not ipv6_enabled(): return [] try: out = subprocess.check_output(["ip", "-6", "addr", "show", "scope", "global"], text=True) except Exception: return [] prefixes = set() for line in out.splitlines(): line = line.strip() if not line.startswith("inet6 "): continue if " temporary " in f" {line} ": continue parts = line.split() if len(parts) < 2: continue prefixes.add(parts[1]) return sorted(prefixes) try: profile = json.loads(profile_path.read_text()) except Exception as e: log(f"Profile load failed: {e}") profile = {} ports = profile.get("firewall_ports") or [] firewall_enable = bool(profile.get("firewall_enable", False)) base_ports = profile.get("firewall_base_ports") or [22, 80, 443, 5252, 5253] if ports: if shutil.which("ufw"): ipv6_prefixes = get_ipv6_prefixes() if ipv6_prefixes: log(f"IPv6 LAN prefixes: {', '.join(ipv6_prefixes)}") for raw in ports: try: port = int(raw) except Exception: continue for subnet in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "169.254.0.0/16"): subprocess.run(["ufw", "allow", "from", subnet, "to", "any", "port", str(port)], check=False) for prefix in ipv6_prefixes: subprocess.run(["ufw", "allow", "from", prefix, "to", "any", "port", str(port)], check=False) if firewall_enable: for raw in base_ports: try: port = int(raw) except Exception: continue for subnet in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "169.254.0.0/16"): subprocess.run(["ufw", "allow", "from", subnet, "to", "any", "port", str(port)], check=False) for prefix in ipv6_prefixes: subprocess.run(["ufw", "allow", "from", prefix, "to", "any", "port", str(port)], check=False) subprocess.run(["ufw", "--force", "enable"], check=False) log("UFW enabled.") log("Profile firewall rules applied.") else: log("Profile firewall step skipped (ufw missing).") else: log("Profile firewall step skipped (no ports).") def normalize_name(name: str) -> str: return name.strip().lower() services = [] if services_path.exists(): try: services = json.loads(services_path.read_text()) except Exception: services = [] if not isinstance(services, list): services = [] profile_services = profile.get("services") or [] if isinstance(profile_services, list) and profile_services: for psvc in profile_services: if not isinstance(psvc, dict): continue p_name = normalize_name(str(psvc.get("name", ""))) p_port = str(psvc.get("port", "")) p_path = str(psvc.get("path", "")) if psvc.get("path") is not None else "" replaced = False for svc in services: if not isinstance(svc, dict): continue s_name = normalize_name(str(svc.get("name", ""))) s_port = str(svc.get("port", "")) s_path = str(svc.get("path", "")) if svc.get("path") is not None else "" if (p_name and s_name == p_name) or (p_port and s_port == p_port and s_path == p_path): svc.update(psvc) replaced = True break if not replaced: services.append(psvc) services_path.parent.mkdir(parents=True, exist_ok=True) services_path.write_text(json.dumps(services, indent=2)) log("Profile services merged.") else: log("Profile services step skipped (none).") actions = profile.get("actions") or [] if isinstance(actions, list) and actions: for action in actions: if not isinstance(action, dict): continue action_type = action.get("type") if action_type == "tls_bundle": src_cert = pathlib.Path(action.get("source_cert", "")) src_key = pathlib.Path(action.get("source_key", "")) dest = pathlib.Path(action.get("dest", "")) if not src_cert.exists() or not src_key.exists(): log(f"TLS bundle skipped (missing cert/key): {dest}") continue dest.parent.mkdir(parents=True, exist_ok=True) content = src_cert.read_bytes() + b"\n" + src_key.read_bytes() + b"\n" dest.write_bytes(content) owner = action.get("owner") if owner: user, _, group = str(owner).partition(":") try: uid = pwd.getpwnam(user).pw_uid if user else -1 except Exception: uid = -1 try: gid = grp.getgrnam(group).gr_gid if group else -1 except Exception: gid = -1 if uid != -1 or gid != -1: os.chown(dest, uid if uid != -1 else -1, gid if gid != -1 else -1) mode = action.get("mode") if mode: try: os.chmod(dest, int(str(mode), 8)) except Exception: pass restart = action.get("restart") if restart: subprocess.run(["systemctl", "restart", str(restart)], check=False) log(f"TLS bundle written: {dest}") continue if action_type == "replace_text": file_path = pathlib.Path(action.get("file", "")) match = str(action.get("match", "")) replacement = str(action.get("replace", "")) if not file_path.exists(): log(f"Replace skipped (missing file): {file_path}") continue content = file_path.read_text() if match not in content: log(f"Replace skipped (pattern not found): {file_path}") continue file_path.write_text(content.replace(match, replacement, 1)) restart = action.get("restart") if restart: subprocess.run(["systemctl", "restart", str(restart)], check=False) log(f"Replaced text in: {file_path}") continue else: log("Profile actions step skipped (none).") PY } write_state() { local state="$1" local current="$2" local steps_joined local status_joined steps_joined=$(IFS='|'; echo "${STEPS[*]}") status_joined=$(IFS='|'; echo "${STEP_STATUS[*]}") PIKIT_STATE_FILE="$STATE_FILE" PIKIT_STATE="$state" PIKIT_CURRENT_STEP="$current" PIKIT_STEPS="$steps_joined" PIKIT_STEP_STATUSES="$status_joined" \ python3 - <<'PY' import json import os import pathlib from datetime import datetime, timezone state_path = pathlib.Path(os.environ["PIKIT_STATE_FILE"]) if "PIKIT_STATE_FILE" in os.environ else pathlib.Path("/var/lib/pikit/firstboot/state.json") state = os.environ.get("PIKIT_STATE", "running") current = os.environ.get("PIKIT_CURRENT_STEP") or None steps = (os.environ.get("PIKIT_STEPS") or "").split("|") statuses = (os.environ.get("PIKIT_STEP_STATUSES") or "").split("|") if len(statuses) < len(steps): statuses += ["pending"] * (len(steps) - len(statuses)) updated_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") state_path.parent.mkdir(parents=True, exist_ok=True) state_path.write_text(json.dumps({ "state": state, "current_step": current, "steps": [{"label": label, "status": status} for label, status in zip(steps, statuses)], "updated_at": updated_at, }, indent=2)) PY } begin_step() { local idx="$1" if [ "$CURRENT_INDEX" -ge 0 ]; then STEP_STATUS[$CURRENT_INDEX]="done" fi CURRENT_INDEX="$idx" CURRENT_STEP="${STEPS[$idx]}" STEP_STATUS[$idx]="current" write_state "running" "$CURRENT_STEP" log "--- $CURRENT_STEP ---" } finish_step() { local idx="$1" local state="${2:-running}" local current="${3:-$CURRENT_STEP}" STEP_STATUS[$idx]="done" write_state "$state" "$current" } clear_motd_block() { if [ -f "$MOTD_FILE" ]; then sed -i '/^\[Pi-Kit firstboot\]/,/^\[\/Pi-Kit firstboot\]/d' "$MOTD_FILE" || true fi } write_motd_error() { clear_motd_block cat >> "$MOTD_FILE" <<'TXT' [Pi-Kit firstboot] Pi-Kit setup needs attention. Error log: sudo cat /var/lib/pikit/firstboot/firstboot.error Full log: sudo cat /var/lib/pikit/firstboot/firstboot.log If needed: sudo systemctl restart nginx pikit-api [/Pi-Kit firstboot] TXT } handle_error() { local line="$1" local msg="Firstboot failed at step: ${CURRENT_STEP:-unknown} (line $line)" log "$msg" STEP_STATUS[$CURRENT_INDEX]="error" write_state "error" "${CURRENT_STEP:-}" || true echo "$msg" > "$ERROR_FILE" if [ -f "$LOG_FILE" ]; then echo "--- recent log ---" >> "$ERROR_FILE" tail -n 120 "$LOG_FILE" >> "$ERROR_FILE" fi write_motd_error exit 1 } mkdir -p "$FIRSTBOOT_DIR" :> "$LOG_FILE" exec >>"$LOG_FILE" 2>&1 log "Pi-Kit firstboot starting" load_config if [ -f "$DONE_FILE" ]; then log "Firstboot already completed; exiting." exit 0 fi rm -f "$ERROR_FILE" clear_motd_block exec 9>"$LOCK_FILE" if ! flock -n 9; then log "Another firstboot run is in progress; exiting." exit 0 fi trap 'handle_error $LINENO' ERR begin_step 0 mkdir -p "$CERT_DIR" "$WEB_ASSETS" if getent group pikit-cert >/dev/null 2>&1; then chgrp pikit-cert "$CERT_DIR" || true fi chmod 750 "$CERT_DIR" finish_step 0 begin_step 1 if [ -x /usr/local/bin/pikit-certgen.sh ]; then /usr/local/bin/pikit-certgen.sh else if [ -s "$CERT_DIR/pikit-ca.crt" ] && [ -s "$CERT_DIR/pikit-ca.key" ] && [ -s "$CERT_DIR/pikit.local.crt" ] && [ -s "$CERT_DIR/pikit.local.key" ]; then log "TLS certs already present; skipping generation." else if ! command -v openssl >/dev/null 2>&1; then echo "openssl not installed" >&2 exit 1 fi rm -f "$CERT_DIR/pikit-ca.key" "$CERT_DIR/pikit-ca.crt" "$CERT_DIR/pikit-ca.srl" || true rm -f "$CERT_DIR/pikit.local.key" "$CERT_DIR/pikit.local.crt" "$CERT_DIR/pikit.local.csr" || true openssl genrsa -out "$CERT_DIR/pikit-ca.key" 2048 openssl req -x509 -new -nodes -key "$CERT_DIR/pikit-ca.key" -sha256 -days 3650 \ -out "$CERT_DIR/pikit-ca.crt" -subj "/CN=Pi-Kit CA" openssl genrsa -out "$CERT_DIR/pikit.local.key" 2048 openssl req -new -key "$CERT_DIR/pikit.local.key" -out "$CERT_DIR/pikit.local.csr" -subj "/CN=pikit.local" SAN_CFG=$(mktemp) cat > "$SAN_CFG" <<'CFG' authorityKeyIdentifier=keyid,issuer basicConstraints=CA:FALSE keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment subjectAltName = @alt_names [alt_names] DNS.1 = pikit.local DNS.2 = pikit CFG openssl x509 -req -in "$CERT_DIR/pikit.local.csr" -CA "$CERT_DIR/pikit-ca.crt" -CAkey "$CERT_DIR/pikit-ca.key" \ -CAcreateserial -out "$CERT_DIR/pikit.local.crt" -days 825 -sha256 -extfile "$SAN_CFG" rm -f "$SAN_CFG" "$CERT_DIR/pikit.local.csr" chmod 600 "$CERT_DIR/pikit-ca.key" "$CERT_DIR/pikit.local.key" chmod 644 "$CERT_DIR/pikit-ca.crt" "$CERT_DIR/pikit.local.crt" fi fi finish_step 1 begin_step 2 cp "$CERT_DIR/pikit-ca.crt" "$WEB_ASSETS/pikit-ca.crt" chmod 644 "$WEB_ASSETS/pikit-ca.crt" if command -v sha256sum >/dev/null 2>&1; then sha256sum "$WEB_ASSETS/pikit-ca.crt" | awk '{print $1}' > "$WEB_ASSETS/pikit-ca.sha256" elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$WEB_ASSETS/pikit-ca.crt" | awk '{print $2}' > "$WEB_ASSETS/pikit-ca.sha256" fi if [ -s "$WEB_ASSETS/pikit-ca.sha256" ]; then chmod 644 "$WEB_ASSETS/pikit-ca.sha256" fi if command -v systemctl >/dev/null 2>&1; then systemctl reload nginx || systemctl restart nginx fi finish_step 2 begin_step 3 if skip_updates; then log "Skipping software updates (PIKIT_FIRSTBOOT_UPDATES=$PIKIT_FIRSTBOOT_UPDATES)." else export DEBIAN_FRONTEND=noninteractive mkdir -p /var/cache/apt/archives/partial /var/lib/apt/lists/partial chmod 755 /var/cache/apt/archives /var/cache/apt/archives/partial /var/lib/apt/lists /var/lib/apt/lists/partial apt-get update apt-get -y -o Dpkg::Options::=--force-confold full-upgrade fi finish_step 3 begin_step 4 configure_unattended_defaults apply_profile if [ ! -f "$WEB_ASSETS/pikit-ca.crt" ]; then echo "CA bundle missing in web assets" >&2 exit 1 fi finish_step 4 begin_step 5 touch "$DONE_FILE" touch /var/run/pikit-ready finish_step 5 "done" "${STEPS[5]}" log "Pi-Kit firstboot complete" exit 0