22 Commits

Author SHA1 Message Date
Aaron
cb2d75387d Bump version to 0.1.3 2026-01-03 00:15:01 -05:00
Aaron
77bc4c1c36 Fix smoke test JSON parsing 2026-01-02 23:58:54 -05:00
Aaron
13e5788fe1 Fix smoke test JSON parsing and set unattended defaults 2026-01-02 23:56:48 -05:00
Aaron
24e89b516f Use local API endpoint in smoke test 2026-01-02 23:49:38 -05:00
Aaron
9c3156df35 Fix smoke test redirects and default updates to stable 2026-01-02 23:45:47 -05:00
Aaron
b01c2ba737 Add smoke test script and expand manufacturing workflow 2026-01-02 23:38:22 -05:00
Aaron
bc97e0374f Add one-time SSH hardening notice after forced password change 2026-01-02 23:29:17 -05:00
Aaron
0a23902eb0 Default to forcing password change after prep 2026-01-02 23:26:03 -05:00
Aaron
4632704092 Allow forcing password change after prep 2026-01-02 23:23:21 -05:00
Aaron
36d30da30a Make firstboot updates configurable 2026-01-02 23:07:36 -05:00
Aaron
c62f1f018f Note browser restart after CA install 2026-01-02 23:03:00 -05:00
Aaron
32a9f42361 Add CA hash sidecar for onboarding 2026-01-02 22:43:43 -05:00
Aaron
40b1b43449 Add firstboot onboarding and prep/check tooling 2026-01-02 22:28:57 -05:00
Aaron
ccc97f7912 Release 0.1.3-dev6: copy log fix 2025-12-14 19:27:17 -05:00
Aaron
f4090cbf1d Fix copy log button 2025-12-14 19:23:31 -05:00
Aaron
f4d0765c93 Release 0.1.3-dev5 for updater testing 2025-12-14 19:18:01 -05:00
Aaron
99bd87c7f6 Release 0.1.3-dev4: updater resilience 2025-12-14 19:16:29 -05:00
Aaron
e87b90bf9f Auto-clear stale updater state 2025-12-14 19:07:47 -05:00
Aaron
8557140193 Avoid restarting API during installs 2025-12-14 19:01:46 -05:00
Aaron
86438b11f3 Handle stale updater lockfiles by removing dead PID entries 2025-12-14 18:56:15 -05:00
Aaron
3a785832b1 Bump to 0.1.3-dev3 and update dev manifest 2025-12-14 18:51:03 -05:00
Aaron
a94cd17186 Redesign updater UI with manual version picker and status bar 2025-12-14 18:48:00 -05:00
26 changed files with 2140 additions and 277 deletions

2
.gitignore vendored
View File

@@ -10,12 +10,14 @@ pikit-web/.cache/
# Python
__pycache__/
backups/
*.pyc
# OS/Editor
.DS_Store
Thumbs.db
*.swp
AGENTS.md
# Build artifacts
*.log

View File

@@ -1,49 +1,5 @@
#!/bin/bash
set -euo pipefail
#!/bin/bash
set -euo pipefail
echo "== Identity files =="
ls -l /etc/machine-id || true
cat /etc/machine-id || true
[ -e /var/lib/dbus/machine-id ] && echo "dbus machine-id exists" || echo "dbus machine-id missing (expected)"
ls -l /var/lib/systemd/random-seed || true
echo -e "\n== SSH host keys =="
ls /etc/ssh/ssh_host_* 2>/dev/null || echo "no host keys (expected)"
echo -e "\n== SSH client traces =="
for f in /root/.ssh/known_hosts /home/dietpi/.ssh/known_hosts /home/dietpi/.ssh/authorized_keys; do
if [ -e "$f" ]; then
printf "%s: size %s\n" "$f" "$(stat -c%s "$f")"
[ -s "$f" ] && echo " WARNING: not empty"
else
echo "$f: missing"
fi
done
echo -e "\n== Ready flag =="
[ -e /var/run/pikit-ready ] && echo "READY FLAG STILL PRESENT" || echo "ready flag absent (expected)"
echo -e "\n== Logs =="
du -sh /var/log 2>/dev/null
du -sh /var/log/nginx 2>/dev/null
find /var/log -type f -maxdepth 2 -printf "%p %s bytes\n"
echo -e "\n== DietPi RAM logs =="
if [ -d /var/tmp/dietpi/logs ]; then
find /var/tmp/dietpi/logs -type f -printf "%p %s bytes\n"
else
echo "/var/tmp/dietpi/logs missing"
fi
echo -e "\n== Caches =="
du -sh /var/cache/apt /var/lib/apt/lists /var/cache/debconf 2>/dev/null || true
echo -e "\n== Temp dirs =="
du -sh /tmp /var/tmp 2>/dev/null || true
find /tmp /var/tmp -maxdepth 1 -mindepth 1 ! -name 'systemd-private-*' -print
echo -e "\n== DHCP lease =="
ls -l /var/lib/dhcp/dhclient.eth0.leases 2>/dev/null || echo "lease file missing (expected)"
echo -e "\n== Nginx cache dirs =="
[ -d /var/lib/nginx ] && find /var/lib/nginx -maxdepth 2 -type d -print || echo "/var/lib/nginx missing"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "${SCRIPT_DIR}/pikit-prep.sh" --check-only "$@"

104
docs/workflow.md Normal file
View File

@@ -0,0 +1,104 @@
# Pi-Kit Image Workflow
This documents the *current* workflow and the *target* workflow once profiles + firstboot automation are implemented. It is meant to be a practical, repeatable checklist.
## 0) Keep a golden base image (do this first)
1) Boot the knowngood base Pi.
2) Verify core services:
- Nginx + PiKit dashboard
- DietPi dashboard
3) Update the system if needed.
4) Run the prep scrub + verify:
- `sudo ./pikit-prep.sh`
- `./pikit-smoke-test.sh`
- (optional) `sudo ./pikit-prep.sh --check-only`
5) Image the SD card with DietPi Imager.
6) Store it as the golden base (e.g., `images/base/pikit-base-YYYYMMDD.img.xz`).
## 1) Build a profile image (current/manual workflow)
1) Identify the SD card:
- `lsblk`
2) Flash the golden base image to SD:
- `sudo ./flash_sd.sh qemu-dietpi/shared/base.img.xz /dev/sdX`
3) Boot the Pi and install/configure services manually.
- Avoid port 80/443 (PiKit already uses those).
4) Add dashboard services using the UI (Add Service modal).
5) Open any needed ports in ufw (done as part of testing/config):
- `sudo ufw allow from <LAN subnet> to any port <port>`
6) Run the prep scrub + verify:
- `sudo ./pikit-prep.sh`
- `./pikit-smoke-test.sh`
- (optional) `sudo ./pikit-prep.sh --check-only`
7) Image the SD card via the QEMU DietPi VM:
- Insert the SD card into your desktop.
- Identify it with `lsblk`.
- Start QEMU with passthrough:
- `./qemu-dietpi/run-dietpi.sh /dev/sdX`
- SSH in:
- `ssh -i qemu-dietpi/ssh/id_ed25519 -p 2222 root@localhost`
- In the VM, go to the shared mount and run DietPi Imager:
- `cd /mnt/images`
- `dietpi-imager`
- After imaging, shut down the VM:
- `shutdown`
8) Store the image as the profile name (e.g., `images/profiles/dns-stack.img.xz`).
## 2) Build a profile image (target workflow with profiles + firstboot)
1) Flash the golden base image to SD.
2) Boot the Pi and install/configure services manually.
3) Create or export the profile file locally: `profiles/<name>/profile.json`.
- Includes *additional* services and firewall ports only.
- Planned: export a profile from the running Pi (services + ufw) to avoid manual edits.
4) Apply the profile to the Pi (planned script, optional if already configured):
- Writes `/etc/pikit/profile.json` (for firstboot).
- Merges services into `/etc/pikit/services.json` (idempotent).
5) Run the drift check (planned script):
- Confirms services + ports match the profile + base.
6) Run the prep scrub + verify:
- `sudo ./pikit-prep.sh`
- `./pikit-smoke-test.sh`
- (optional) `sudo ./pikit-prep.sh --check-only`
7) Image the SD card with DietPi Imager.
First boot on the enduser device will:
- Regenerate unique identity + TLS certs.
- Ensure the profiles firewall ports are open (LANonly).
- Show a progress overlay until complete.
Optional: to skip the firstboot update step for faster startup, create
`/etc/pikit/firstboot.conf` with:
```
PIKIT_FIRSTBOOT_UPDATES=0
```
## 3) Flashing an image to SD
Use the helper:
- `sudo ./flash_sd.sh <image.img.xz> /dev/sdX`
## 4) Manufacturing / imaging checklist (production)
1) Start from the golden base image (stored in `images/base/`).
2) Flash it to a knowngood SD card.
3) Boot and verify:
- `http://pikit.local` and `https://pikit.local`
- dashboard loads
- firstboot completes
4) Apply any required profile/services.
5) Run prep + verify:
- `sudo ./pikit-prep.sh`
- `./pikit-smoke-test.sh`
6) Power down cleanly.
7) Image the SD card (DietPi Imager via QEMU or ondevice).
8) Name and archive the image:
- Base: `images/base/pikit-base-YYYYMMDD-dietpi9.20.1.img.xz`
- Profile: `images/profiles/pikit-<profile>-YYYYMMDD.img.xz`
- Testing/staging: `images/staging/pikit-<profile>-YYYYMMDD-rcN.img.xz`
9) Smoke test the flashed image on a second SD card:
- boot → firstboot → dashboard → services
## Notes
- Profiles are additive to the base image defaults; do not include PiKit or DietPi dashboard entries in profiles.
- Keep `RESCUE.md` in `/root` and `/home/dietpi` only (not in `/var/www`).
- Prep enforces a password change for `dietpi` on first login; set `PIKIT_FORCE_PASSWORD_CHANGE=0` to skip.
- After the password change, a onetime SSH hardening tip is shown on login.
- End-user defaults: OS security unattended upgrades on; Pi-Kit updater auto-check on stable channel, auto-apply off (user can change in dashboard).

View File

@@ -1,9 +1,9 @@
{
"version": "0.1.3-dev1",
"_release_date": "2025-12-14T22:23:00Z",
"bundle": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.3-dev1/pikit-0.1.3-dev1.tar.gz",
"changelog": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.3-dev1/CHANGELOG-0.1.3-dev1.txt",
"version": "0.1.3-dev6",
"_release_date": "2025-12-15T00:26:36Z",
"bundle": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.3-dev6/pikit-0.1.3-dev6.tar.gz",
"changelog": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.3-dev6/CHANGELOG-0.1.3-dev6.txt",
"files": [
{ "path": "bundle.tar.gz", "sha256": "290bc3ef0acbac8ffc1d283fdf5413bdd0dd6a90a9ccd2253dfd406773951b62" }
{ "path": "bundle.tar.gz", "sha256": "2d53fb5fc1b98193defac8566707ef49dd4e69a3befc31646eee1c972c102c9e" }
]
}

713
pikit-prep.sh Normal file → Executable file
View File

@@ -1,10 +1,100 @@
#!/bin/bash
# Pi-Kit DietPi image prep script
# Cleans host-unique data, logs, caches, and temp files per pikit-prep-spec.
# Pi-Kit DietPi image prep + check script
# Cleans host-unique data and optionally verifies the image state.
set -euo pipefail
status() { printf '[%s] %s\n' "$1" "$2"; }
SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
SCRIPT_NAME="$(basename "$SCRIPT_PATH")"
PIKIT_HOST="${PIKIT_HOST:-pikit.local}"
PIKIT_USER="${PIKIT_USER:-dietpi}"
PIKIT_SSH_KEY="${PIKIT_SSH_KEY:-$HOME/.ssh/pikit}"
PIKIT_SSH_OPTS="${PIKIT_SSH_OPTS:-}"
PIKIT_REMOTE_TMP="${PIKIT_REMOTE_TMP:-/tmp/pikit-prep.sh}"
PIKIT_SELF_DELETE="${PIKIT_SELF_DELETE:-0}"
PIKIT_FORCE_PASSWORD_CHANGE="${PIKIT_FORCE_PASSWORD_CHANGE:-1}"
MODE="both"
LOCAL_ONLY=0
ERRORS=0
WARNINGS=0
usage() {
cat <<'USAGE'
Usage: pikit-prep.sh [--prep-only|--check-only] [--local]
Defaults to prep + check (combined). When run on a non-DietPi host, it will
copy itself to the Pi (/tmp) and run with sudo, then clean up.
Options:
--prep-only Run prep only (no check)
--check-only Run checks only (no prep)
--local Force local execution (no SSH copy)
--help Show this help
Env:
PIKIT_FORCE_PASSWORD_CHANGE=0 Skip forcing a password change (default is on)
USAGE
}
status() {
local level="$1"
shift
printf '[%s] %s\n' "$level" "$*"
case "$level" in
FAIL) ERRORS=$((ERRORS + 1)) ;;
WARN) WARNINGS=$((WARNINGS + 1)) ;;
esac
}
section() {
printf '\n== %s ==\n' "$1"
}
is_dietpi() {
grep -qi "dietpi" /etc/os-release 2>/dev/null
}
require_root() {
if [ "${EUID:-$(id -u)}" -ne 0 ]; then
echo "[FAIL] This script must run as root (use sudo)." >&2
exit 1
fi
}
parse_args() {
for arg in "$@"; do
case "$arg" in
--prep-only) MODE="prep" ;;
--check-only) MODE="check" ;;
--local) LOCAL_ONLY=1 ;;
--help|-h) usage; exit 0 ;;
*)
echo "[FAIL] Unknown argument: $arg" >&2
usage
exit 1
;;
esac
done
}
run_remote() {
local forward=()
for arg in "$@"; do
[ "$arg" = "--local" ] && continue
forward+=("$arg")
done
if ! command -v scp >/dev/null 2>&1 || ! command -v ssh >/dev/null 2>&1; then
echo "[FAIL] ssh/scp not available for remote prep" >&2
exit 1
fi
scp -i "$PIKIT_SSH_KEY" $PIKIT_SSH_OPTS "$SCRIPT_PATH" "${PIKIT_USER}@${PIKIT_HOST}:${PIKIT_REMOTE_TMP}"
ssh -i "$PIKIT_SSH_KEY" $PIKIT_SSH_OPTS "${PIKIT_USER}@${PIKIT_HOST}" \
"sudo PIKIT_SELF_DELETE=1 bash ${PIKIT_REMOTE_TMP} --local ${forward[*]}; rc=\$?; rm -f ${PIKIT_REMOTE_TMP}; exit \$rc"
exit $?
}
clean_logs_dir() {
local dir="$1" pattern="${2:-*}"
@@ -65,181 +155,484 @@ clean_backups() {
fi
}
# --- Identity ---
# Keep machine-id file present but empty so systemd regenerates cleanly on next boot.
truncate -s 0 /etc/machine-id && status CLEANED /etc/machine-id || status FAIL /etc/machine-id
mkdir -p /var/lib/dbus || true
rm -f /var/lib/dbus/machine-id
ln -s /etc/machine-id /var/lib/dbus/machine-id && status CLEANED "/var/lib/dbus/machine-id -> /etc/machine-id" || status FAIL "/var/lib/dbus/machine-id"
clean_file /var/lib/systemd/random-seed
clean_home_dir() {
local dir="$1"
shift
local keep=("$@")
if [ -d "$dir" ]; then
shopt -s dotglob nullglob
for entry in "$dir"/*; do
local base
base="$(basename "$entry")"
case "$base" in
.|..) continue ;;
esac
local keep_it=0
for k in "${keep[@]}"; do
if [ "$base" = "$k" ]; then
keep_it=1
break
fi
done
if [ "$keep_it" -eq 0 ]; then
rm -rf "$entry" && status CLEANED "$dir/$base" || status FAIL "$dir/$base"
fi
done
shopt -u dotglob nullglob
else
status SKIP "$dir (missing)"
fi
}
# --- SSH host keys ---
if ls /etc/ssh/ssh_host_* >/dev/null 2>&1; then
rm -f /etc/ssh/ssh_host_* && status CLEANED "SSH host keys" || status FAIL "SSH host keys"
else
status SKIP "SSH host keys (none)"
fi
prep_image() {
section "Prep"
# --- SSH client traces ---
:> /root/.ssh/known_hosts 2>/dev/null && status CLEANED "/root/.ssh/known_hosts" || status SKIP "/root/.ssh/known_hosts"
:> /home/dietpi/.ssh/known_hosts 2>/dev/null && status CLEANED "/home/dietpi/.ssh/known_hosts" || status SKIP "/home/dietpi/.ssh/known_hosts"
:> /home/dietpi/.ssh/authorized_keys 2>/dev/null && status CLEANED "/home/dietpi/.ssh/authorized_keys" || status SKIP "/home/dietpi/.ssh/authorized_keys"
:> /root/.ssh/authorized_keys 2>/dev/null && status CLEANED "/root/.ssh/authorized_keys" || status SKIP "/root/.ssh/authorized_keys"
# --- Identity ---
truncate -s 0 /etc/machine-id && status CLEANED /etc/machine-id || status FAIL /etc/machine-id
mkdir -p /var/lib/dbus || true
rm -f /var/lib/dbus/machine-id
ln -s /etc/machine-id /var/lib/dbus/machine-id && status CLEANED "/var/lib/dbus/machine-id -> /etc/machine-id" || status FAIL "/var/lib/dbus/machine-id"
clean_file /var/lib/systemd/random-seed
# --- Shell history ---
:> /root/.bash_history 2>/dev/null && status CLEANED "/root/.bash_history" || status SKIP "/root/.bash_history"
:> /home/dietpi/.bash_history 2>/dev/null && status CLEANED "/home/dietpi/.bash_history" || status SKIP "/home/dietpi/.bash_history"
# --- SSH host keys ---
if ls /etc/ssh/ssh_host_* >/dev/null 2>&1; then
rm -f /etc/ssh/ssh_host_* && status CLEANED "SSH host keys" || status FAIL "SSH host keys"
else
status SKIP "SSH host keys (none)"
fi
# --- Ready flag ---
clean_file /var/run/pikit-ready
# --- SSH client traces ---
:> /root/.ssh/known_hosts 2>/dev/null && status CLEANED "/root/.ssh/known_hosts" || status SKIP "/root/.ssh/known_hosts"
:> /home/dietpi/.ssh/known_hosts 2>/dev/null && status CLEANED "/home/dietpi/.ssh/known_hosts" || status SKIP "/home/dietpi/.ssh/known_hosts"
:> /home/dietpi/.ssh/authorized_keys 2>/dev/null && status CLEANED "/home/dietpi/.ssh/authorized_keys" || status SKIP "/home/dietpi/.ssh/authorized_keys"
:> /root/.ssh/authorized_keys 2>/dev/null && status CLEANED "/root/.ssh/authorized_keys" || status SKIP "/root/.ssh/authorized_keys"
# --- Backup/editor cruft ---
clean_backups /var/www/pikit-web
clean_backups /usr/local/bin
# --- Default login ---
if id -u dietpi >/dev/null 2>&1; then
echo "dietpi:pikit" | chpasswd && status CLEANED "reset dietpi password" || status FAIL "reset dietpi password"
mkdir -p /var/lib/pikit
rm -f /var/lib/pikit/first-login.notice
case "${PIKIT_FORCE_PASSWORD_CHANGE,,}" in
1|true|yes|on)
chage -d 0 dietpi && status CLEANED "force dietpi password change on next login" || status FAIL "force dietpi password change"
:> /var/lib/pikit/first-login.notice && chmod 644 /var/lib/pikit/first-login.notice \
&& status CLEANED "first-login notice armed" || status FAIL "first-login notice"
;;
*) ;;
esac
else
status SKIP "dietpi user missing"
fi
# --- Logs ---
clean_dir_files /var/log "*"
clean_dir_files /var/log/nginx "*"
# systemd journal (persistent) if present
if [ -d /var/log/journal ]; then
find /var/log/journal -type f -print -delete 2>/dev/null | sed 's/^/[CLEANED] /' || true
status CLEANED "/var/log/journal"
else
status SKIP "/var/log/journal (missing)"
fi
# crash dumps
if [ -d /var/crash ]; then
find /var/crash -type f -print -delete 2>/dev/null | sed 's/^/[CLEANED] /' || true
status CLEANED "/var/crash"
else
status SKIP "/var/crash (missing)"
fi
# --- Shell history ---
:> /root/.bash_history 2>/dev/null && status CLEANED "/root/.bash_history" || status SKIP "/root/.bash_history"
:> /home/dietpi/.bash_history 2>/dev/null && status CLEANED "/home/dietpi/.bash_history" || status SKIP "/home/dietpi/.bash_history"
# Service-specific logs (best-effort, skip if absent)
if command -v pihole >/dev/null 2>&1; then
pihole -f >/dev/null 2>&1 && status CLEANED "pihole logs via pihole -f" || status FAIL "pihole -f"
clean_logs_dir /var/log/pihole '*'
clean_file /etc/pihole/pihole-FTL.db # resets long-term query history; leave gravity.db untouched
fi
# --- Home directories ---
clean_home_dir /home/dietpi .bashrc .bash_logout .profile .ssh RESCUE.md
clean_home_dir /root .bashrc .bash_logout .profile .ssh RESCUE.md
if [ -x /opt/AdGuardHome/AdGuardHome ]; then
clean_logs_dir /var/opt/AdGuardHome/data/logs '*'
clean_file /opt/AdGuardHome/data/querylog.db
fi
# --- Ready flag ---
clean_file /var/run/pikit-ready
if command -v ufw >/dev/null 2>&1; then
truncate_file /var/log/ufw.log
fi
# --- First-boot state + TLS ---
clean_dir_files /var/lib/pikit/firstboot "*"
clean_file /var/lib/pikit/firstboot/firstboot.done
clean_file /var/lib/pikit/firstboot/state.json
clean_file /var/lib/pikit/firstboot/firstboot.log
clean_file /var/lib/pikit/firstboot/firstboot.error
clean_file /var/lib/pikit/firstboot/firstboot.lock
clean_file /etc/pikit/certs/pikit-ca.crt
clean_file /etc/pikit/certs/pikit-ca.key
clean_file /etc/pikit/certs/pikit-ca.srl
clean_file /etc/pikit/certs/pikit.local.crt
clean_file /etc/pikit/certs/pikit.local.key
clean_file /etc/pikit/certs/pikit.local.csr
clean_file /var/www/pikit-web/assets/pikit-ca.crt
clean_file /var/www/pikit-web/assets/pikit-ca.sha256
clean_file /var/lib/pikit-update/state.json
clean_file /var/run/pikit-update.lock
if command -v fail2ban-client >/dev/null 2>&1; then
truncate_file /var/log/fail2ban.log
fi
# --- Backup/editor cruft ---
clean_backups /var/www/pikit-web
clean_backups /usr/local/bin
clean_logs_dir /var/log/unbound '*'
clean_logs_dir /var/log/dnsmasq '*'
clean_logs_dir /var/log/powerdns '*'
clean_logs_dir /var/lib/technitium-dns/Logs '*'
# --- Logs ---
clean_dir_files /var/log "*"
clean_dir_files /var/log/nginx "*"
if [ -d /var/log/journal ]; then
find /var/log/journal -type f -print -delete 2>/dev/null | sed 's/^/[CLEANED] /' || true
status CLEANED "/var/log/journal"
else
status SKIP "/var/log/journal (missing)"
fi
if [ -d /var/crash ]; then
find /var/crash -type f -print -delete 2>/dev/null | sed 's/^/[CLEANED] /' || true
status CLEANED "/var/crash"
else
status SKIP "/var/crash (missing)"
fi
clean_logs_dir /var/log/jellyfin '*'
clean_logs_dir /var/lib/jellyfin/log '*'
clean_logs_dir /var/log/jellyseerr '*'
clean_logs_dir /opt/jellyseerr/logs '*'
clean_logs_dir /var/log/ustreamer '*'
clean_logs_dir /var/log/gitea '*'
clean_logs_dir /var/lib/gitea/log '*'
clean_logs_dir /var/log/fmd '*'
clean_logs_dir /var/log/uptime-kuma '*'
clean_logs_dir /opt/uptime-kuma/data/logs '*'
clean_logs_dir /var/log/romm '*'
clean_logs_dir /var/log/privatebin '*'
clean_logs_dir /var/log/crafty '*'
clean_logs_dir /var/log/rustdesk '*'
clean_logs_dir /var/log/memos '*'
clean_logs_dir /var/lib/memos/logs '*'
clean_logs_dir /var/log/traccar '*'
clean_logs_dir /var/log/webmin '*'
clean_logs_dir /var/log/homarr '*'
clean_logs_dir /var/log/termix '*'
clean_logs_dir /var/log/syncthing '*'
clean_logs_dir /var/log/netdata '*'
clean_logs_dir /var/lib/netdata/dbengine '*'
clean_logs_dir /var/log/AdGuardHome '*'
if command -v pihole >/dev/null 2>&1; then
pihole -f >/dev/null 2>&1 && status CLEANED "pihole logs via pihole -f" || status FAIL "pihole -f"
clean_logs_dir /var/log/pihole '*'
clean_file /etc/pihole/pihole-FTL.db
fi
# DB / metrics / web stacks
clean_logs_dir /var/log/mysql '*'
clean_logs_dir /var/log/mariadb '*'
clean_logs_dir /var/log/postgresql '*'
truncate_file /var/log/redis/redis-server.log
clean_logs_dir /var/log/influxdb '*'
clean_logs_dir /var/log/prometheus '*'
clean_logs_dir /var/log/grafana '*'
clean_logs_dir /var/log/loki '*'
clean_logs_dir /var/log/caddy '*'
clean_logs_dir /var/log/apache2 '*'
clean_logs_dir /var/log/lighttpd '*'
clean_logs_dir /var/log/samba '*'
clean_logs_dir /var/log/mosquitto '*'
clean_logs_dir /var/log/openvpn '*'
clean_logs_dir /var/log/wireguard '*'
clean_logs_dir /var/log/node-red '*'
truncate_file /var/log/nodered-install.log
clean_logs_dir /var/log/transmission-daemon '*'
clean_logs_dir /var/log/deluge '*'
clean_logs_dir /var/log/qbittorrent '*'
clean_logs_dir /var/log/paperless-ngx '*'
clean_logs_dir /var/log/photoprism '*'
clean_logs_dir /var/log/navidrome '*'
clean_logs_dir /var/log/minio '*'
clean_logs_dir /var/log/nzbget '*'
clean_logs_dir /var/log/sabnzbd '*'
clean_logs_dir /var/log/jackett '*'
clean_logs_dir /var/log/radarr '*'
clean_logs_dir /var/log/sonarr '*'
clean_logs_dir /var/log/lidarr '*'
clean_logs_dir /var/log/prowlarr '*'
clean_logs_dir /var/log/bazarr '*'
clean_logs_dir /var/log/overseerr '*'
clean_logs_dir /var/log/emby-server '*'
if [ -x /opt/AdGuardHome/AdGuardHome ]; then
clean_logs_dir /var/opt/AdGuardHome/data/logs '*'
clean_file /opt/AdGuardHome/data/querylog.db
fi
# App-specific logs stored with app data (truncate, keep structure)
truncate_file /home/homeassistant/.homeassistant/home-assistant.log
clean_logs_dir /home/homeassistant/.homeassistant/logs '*'
truncate_file /var/www/nextcloud/data/nextcloud.log
truncate_file /var/www/owncloud/data/owncloud.log
if command -v ufw >/dev/null 2>&1; then
truncate_file /var/log/ufw.log
fi
# Docker container JSON logs
if [ -d /var/lib/docker/containers ]; then
find /var/lib/docker/containers -type f -name '*-json.log' -print0 2>/dev/null | while IFS= read -r -d '' f; do
:> "$f" && status CLEANED "truncated $f" || status FAIL "truncate $f"
done
else
status SKIP "/var/lib/docker/containers (missing)"
fi
clean_file /var/log/wtmp.db
clean_dir_files /var/tmp/dietpi/logs "*"
if command -v fail2ban-client >/dev/null 2>&1; then
truncate_file /var/log/fail2ban.log
fi
# --- Caches ---
apt-get clean >/dev/null 2>&1 && status CLEANED "apt cache" || status FAIL "apt cache"
rm -rf /var/lib/apt/lists/* /var/cache/apt/* 2>/dev/null && status CLEANED "apt lists/cache" || status FAIL "apt lists/cache"
find /var/cache/debconf -type f -print -delete 2>/dev/null | sed 's/^/[CLEANED] /' || true
status CLEANED "/var/cache/debconf files"
clean_logs_dir /var/log/unbound '*'
clean_logs_dir /var/log/dnsmasq '*'
clean_logs_dir /var/log/powerdns '*'
clean_logs_dir /var/lib/technitium-dns/Logs '*'
# --- Temp directories ---
truncate_dir /tmp
truncate_dir /var/tmp
clean_logs_dir /var/log/jellyfin '*'
clean_logs_dir /var/lib/jellyfin/log '*'
clean_logs_dir /var/log/jellyseerr '*'
clean_logs_dir /opt/jellyseerr/logs '*'
clean_logs_dir /var/log/ustreamer '*'
clean_logs_dir /var/log/gitea '*'
clean_logs_dir /var/lib/gitea/log '*'
clean_logs_dir /var/log/fmd '*'
clean_logs_dir /var/log/uptime-kuma '*'
clean_logs_dir /opt/uptime-kuma/data/logs '*'
clean_logs_dir /var/log/romm '*'
clean_logs_dir /var/log/privatebin '*'
clean_logs_dir /var/log/crafty '*'
clean_logs_dir /var/log/rustdesk '*'
clean_logs_dir /var/log/memos '*'
clean_logs_dir /var/lib/memos/logs '*'
clean_logs_dir /var/log/traccar '*'
clean_logs_dir /var/log/webmin '*'
clean_logs_dir /var/log/homarr '*'
clean_logs_dir /var/log/termix '*'
clean_logs_dir /var/log/syncthing '*'
clean_logs_dir /var/log/netdata '*'
clean_logs_dir /var/lib/netdata/dbengine '*'
clean_logs_dir /var/log/AdGuardHome '*'
# --- DHCP leases ---
clean_file /var/lib/dhcp/dhclient.eth0.leases
clean_logs_dir /var/log/mysql '*'
clean_logs_dir /var/log/mariadb '*'
clean_logs_dir /var/log/postgresql '*'
truncate_file /var/log/redis/redis-server.log
clean_logs_dir /var/log/influxdb '*'
clean_logs_dir /var/log/prometheus '*'
clean_logs_dir /var/log/grafana '*'
clean_logs_dir /var/log/loki '*'
clean_logs_dir /var/log/caddy '*'
clean_logs_dir /var/log/apache2 '*'
clean_logs_dir /var/log/lighttpd '*'
clean_logs_dir /var/log/samba '*'
clean_logs_dir /var/log/mosquitto '*'
clean_logs_dir /var/log/openvpn '*'
clean_logs_dir /var/log/wireguard '*'
clean_logs_dir /var/log/node-red '*'
truncate_file /var/log/nodered-install.log
clean_logs_dir /var/log/transmission-daemon '*'
clean_logs_dir /var/log/deluge '*'
clean_logs_dir /var/log/qbittorrent '*'
clean_logs_dir /var/log/paperless-ngx '*'
clean_logs_dir /var/log/photoprism '*'
clean_logs_dir /var/log/navidrome '*'
clean_logs_dir /var/log/minio '*'
clean_logs_dir /var/log/nzbget '*'
clean_logs_dir /var/log/sabnzbd '*'
clean_logs_dir /var/log/jackett '*'
clean_logs_dir /var/log/radarr '*'
clean_logs_dir /var/log/sonarr '*'
clean_logs_dir /var/log/lidarr '*'
clean_logs_dir /var/log/prowlarr '*'
clean_logs_dir /var/log/bazarr '*'
clean_logs_dir /var/log/overseerr '*'
clean_logs_dir /var/log/emby-server '*'
# --- Nginx caches ---
if [ -d /var/lib/nginx ]; then
find /var/lib/nginx -mindepth 1 -maxdepth 1 -type d -exec rm -rf {} + 2>/dev/null
status CLEANED "/var/lib/nginx/*"
else
status SKIP "/var/lib/nginx"
fi
truncate_file /home/homeassistant/.homeassistant/home-assistant.log
clean_logs_dir /home/homeassistant/.homeassistant/logs '*'
truncate_file /var/www/nextcloud/data/nextcloud.log
truncate_file /var/www/owncloud/data/owncloud.log
status DONE "Prep complete"
if [ -d /var/lib/docker/containers ]; then
find /var/lib/docker/containers -type f -name '*-json.log' -print0 2>/dev/null | while IFS= read -r -d '' f; do
:> "$f" && status CLEANED "truncated $f" || status FAIL "truncate $f"
done
else
status SKIP "/var/lib/docker/containers (missing)"
fi
clean_file /var/log/wtmp.db
clean_dir_files /var/tmp/dietpi/logs "*"
# Self-delete to avoid leaving the prep tool on the image.
rm -- "$0"
# --- Caches ---
apt-get clean >/dev/null 2>&1 && status CLEANED "apt cache" || status FAIL "apt cache"
rm -rf /var/lib/apt/lists/* /var/cache/apt/* 2>/dev/null && status CLEANED "apt lists/cache" || status FAIL "apt lists/cache"
find /var/cache/debconf -type f -print -delete 2>/dev/null | sed 's/^/[CLEANED] /' || true
status CLEANED "/var/cache/debconf files"
# --- Temp directories ---
truncate_dir /tmp
truncate_dir /var/tmp
# --- DietPi RAMlog store (preserve log dir structure) ---
if [ -d /var/log ]; then
local log_group="adm"
if ! getent group "$log_group" >/dev/null 2>&1; then
log_group="root"
fi
install -d -m 0755 -o root -g "$log_group" /var/log/nginx \
&& status CLEANED "/var/log/nginx (ensured)" \
|| status FAIL "/var/log/nginx"
else
status SKIP "/var/log (missing)"
fi
if [ -x /boot/dietpi/func/dietpi-ramlog ]; then
/boot/dietpi/func/dietpi-ramlog 1 >/dev/null 2>&1 \
&& status CLEANED "DietPi RAMlog store" \
|| status FAIL "DietPi RAMlog store"
else
status SKIP "DietPi RAMlog (missing)"
fi
# --- DHCP leases ---
clean_file /var/lib/dhcp/dhclient.eth0.leases
# --- Nginx caches ---
if [ -d /var/lib/nginx ]; then
find /var/lib/nginx -mindepth 1 -maxdepth 1 -type d -exec rm -rf {} + 2>/dev/null
status CLEANED "/var/lib/nginx/*"
else
status SKIP "/var/lib/nginx"
fi
status DONE "Prep complete"
}
check_home_dir() {
local dir="$1"
shift
local keep=("$@")
if [ -d "$dir" ]; then
local extra=()
shopt -s dotglob nullglob
for entry in "$dir"/*; do
local base
base="$(basename "$entry")"
case "$base" in
.|..) continue ;;
esac
local keep_it=0
for k in "${keep[@]}"; do
if [ "$base" = "$k" ]; then
keep_it=1
break
fi
done
if [ "$keep_it" -eq 0 ]; then
extra+=("$base")
fi
done
shopt -u dotglob nullglob
if [ "${#extra[@]}" -gt 0 ]; then
status WARN "extra files in $dir: ${extra[*]}"
else
status OK "home clean: $dir"
fi
else
status FAIL "missing home dir: $dir"
fi
}
check_file_missing() {
local path="$1"
if [ -e "$path" ]; then
status FAIL "unexpected file exists: $path"
else
status OK "missing as expected: $path"
fi
}
check_file_empty_or_missing() {
local path="$1"
if [ -e "$path" ]; then
if [ -s "$path" ]; then
status FAIL "file not empty: $path"
else
status OK "empty file: $path"
fi
else
status OK "missing (ok): $path"
fi
}
check_image() {
section "Check"
section "Identity files"
if [ -e /etc/machine-id ]; then
if [ -s /etc/machine-id ]; then
status FAIL "/etc/machine-id not empty"
else
status OK "/etc/machine-id empty"
fi
else
status FAIL "/etc/machine-id missing"
fi
if [ -L /var/lib/dbus/machine-id ]; then
status OK "/var/lib/dbus/machine-id symlink present"
else
status WARN "dbus machine-id missing or not a symlink"
fi
check_file_missing /var/lib/systemd/random-seed
section "SSH host keys"
if ls /etc/ssh/ssh_host_* >/dev/null 2>&1; then
status FAIL "SSH host keys still present"
else
status OK "no SSH host keys"
fi
section "SSH client traces"
check_file_empty_or_missing /root/.ssh/known_hosts
check_file_empty_or_missing /home/dietpi/.ssh/known_hosts
check_file_empty_or_missing /home/dietpi/.ssh/authorized_keys
check_file_empty_or_missing /root/.ssh/authorized_keys
section "Home directories"
check_home_dir /home/dietpi .bashrc .bash_logout .profile .ssh RESCUE.md
check_home_dir /root .bashrc .bash_logout .profile .ssh RESCUE.md
section "Ready flag"
check_file_missing /var/run/pikit-ready
section "Firstboot state"
if [ -d /var/lib/pikit/firstboot ]; then
local count
count="$(find /var/lib/pikit/firstboot -type f 2>/dev/null | wc -l | tr -d ' ')"
if [ "$count" -gt 0 ]; then
status WARN "firstboot files still present: $count"
else
status OK "firstboot dir empty"
fi
else
status OK "firstboot dir missing (ok)"
fi
section "TLS certs"
check_file_missing /etc/pikit/certs/pikit-ca.crt
check_file_missing /etc/pikit/certs/pikit-ca.key
check_file_missing /etc/pikit/certs/pikit.local.crt
check_file_missing /etc/pikit/certs/pikit.local.key
check_file_missing /var/www/pikit-web/assets/pikit-ca.crt
check_file_missing /var/www/pikit-web/assets/pikit-ca.sha256
check_file_missing /var/lib/pikit-update/state.json
check_file_missing /var/run/pikit-update.lock
section "Logs"
if [ -d /var/log ]; then
local nonempty
nonempty="$(find /var/log -type f -size +0c 2>/dev/null | wc -l | tr -d ' ')"
if [ "$nonempty" -gt 0 ]; then
status WARN "/var/log has non-empty files: $nonempty"
else
status OK "/var/log empty"
fi
else
status WARN "/var/log missing"
fi
if [ -d /var/log/nginx ]; then
local nginx_nonempty
nginx_nonempty="$(find /var/log/nginx -type f -size +0c 2>/dev/null | wc -l | tr -d ' ')"
if [ "$nginx_nonempty" -gt 0 ]; then
status WARN "/var/log/nginx has non-empty files: $nginx_nonempty"
else
status OK "/var/log/nginx empty"
fi
else
status WARN "/var/log/nginx missing"
fi
section "DietPi RAM logs"
if [ -d /var/tmp/dietpi/logs/dietpi-ramlog_store ]; then
status OK "DietPi RAMlog store present"
else
status FAIL "DietPi RAMlog store missing"
fi
section "Caches"
du -sh /var/cache/apt /var/lib/apt/lists /var/cache/debconf 2>/dev/null || true
section "Temp dirs"
local tmp_extra
tmp_extra="$(find /tmp /var/tmp -maxdepth 1 -mindepth 1 ! -name 'systemd-private-*' ! -path '/var/tmp/dietpi' 2>/dev/null | wc -l | tr -d ' ')"
if [ "$tmp_extra" -gt 0 ]; then
status WARN "temp dirs not empty"
else
status OK "temp dirs empty"
fi
section "DHCP lease"
check_file_missing /var/lib/dhcp/dhclient.eth0.leases
section "Nginx cache dirs"
if [ -d /var/lib/nginx ]; then
local nginx_cache
nginx_cache="$(find /var/lib/nginx -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l | tr -d ' ')"
if [ "$nginx_cache" -gt 0 ]; then
status WARN "nginx cache dirs present: $nginx_cache"
else
status OK "/var/lib/nginx empty"
fi
else
status OK "/var/lib/nginx missing (ok)"
fi
}
finalize() {
section "Summary"
status OK "warnings: $WARNINGS"
status OK "errors: $ERRORS"
if [ "$ERRORS" -gt 0 ]; then
echo "[FAIL] Prep/check completed with errors."
exit 1
fi
echo "[OK] Prep/check completed."
}
maybe_self_delete() {
if [ "$PIKIT_SELF_DELETE" -eq 1 ] && [[ "$SCRIPT_PATH" == /tmp/* ]]; then
rm -f "$SCRIPT_PATH" || true
fi
}
main() {
parse_args "$@"
if [ "$LOCAL_ONLY" -eq 0 ] && ! is_dietpi; then
run_remote "$@"
fi
require_root
case "$MODE" in
prep) prep_image ;;
check) check_image ;;
both)
prep_image
check_image
;;
esac
finalize
maybe_self_delete
}
main "$@"

252
pikit-smoke-test.sh Executable file
View File

@@ -0,0 +1,252 @@
#!/bin/bash
# Pi-Kit post-prep smoke test (HTTP/HTTPS/API/firstboot/services)
set -euo pipefail
PIKIT_HOST="${PIKIT_HOST:-pikit.local}"
PIKIT_USER="${PIKIT_USER:-dietpi}"
PIKIT_SSH_KEY="${PIKIT_SSH_KEY:-$HOME/.ssh/pikit}"
PIKIT_SSH_OPTS="${PIKIT_SSH_OPTS:-}"
PIKIT_HTTP_URL="${PIKIT_HTTP_URL:-http://$PIKIT_HOST}"
PIKIT_HTTPS_URL="${PIKIT_HTTPS_URL:-https://$PIKIT_HOST}"
PIKIT_API_URL="${PIKIT_API_URL:-http://127.0.0.1:4000}"
LOCAL_ONLY=0
ERRORS=0
WARNINGS=0
REMOTE_MODE=0
usage() {
cat <<'USAGE'
Usage: pikit-smoke-test.sh [--local]
Runs a quick post-prep smoke test:
- HTTP/HTTPS reachable
- API reachable and returns JSON
- firstboot state done
- core services active (nginx, pikit-api, dietpi-dashboard-frontend)
Options:
--local Run locally on the Pi (skip SSH)
--help Show this help
Env:
PIKIT_HOST, PIKIT_USER, PIKIT_SSH_KEY, PIKIT_SSH_OPTS
PIKIT_HTTP_URL, PIKIT_HTTPS_URL
USAGE
}
status() {
local level="$1"
shift
printf '[%s] %s\n' "$level" "$*"
case "$level" in
FAIL) ERRORS=$((ERRORS + 1)) ;;
WARN) WARNINGS=$((WARNINGS + 1)) ;;
esac
}
section() {
printf '\n== %s ==\n' "$1"
}
is_dietpi() {
grep -qi "dietpi" /etc/os-release 2>/dev/null
}
parse_args() {
for arg in "$@"; do
case "$arg" in
--local) LOCAL_ONLY=1 ;;
--help|-h) usage; exit 0 ;;
*)
echo "[FAIL] Unknown argument: $arg" >&2
usage
exit 1
;;
esac
done
}
remote_cmd() {
local cmd="$1"
if [ "$LOCAL_ONLY" -eq 1 ] || is_dietpi; then
bash -c "$cmd"
else
ssh -i "$PIKIT_SSH_KEY" $PIKIT_SSH_OPTS -o ConnectTimeout=10 "${PIKIT_USER}@${PIKIT_HOST}" "$cmd"
fi
}
extract_json_line() {
awk 'BEGIN{found=0} /^[[:space:]]*[{[]/ {print; found=1; exit} END{if(!found) exit 0}'
}
json_get() {
local key="$1"
if command -v python3 >/dev/null 2>&1; then
python3 -c 'import json,sys
key=sys.argv[1]
try:
data=json.load(sys.stdin)
except Exception:
print("")
sys.exit(1)
val=data.get(key, "")
if isinstance(val, bool):
print("true" if val else "false")
else:
print(val)
' "$key"
elif command -v jq >/dev/null 2>&1; then
jq -r --arg key "$key" '.[$key] // empty'
else
cat >/dev/null
echo ""
return 1
fi
}
check_http() {
local url="$1"
local label="$2"
if curl -fsS --max-time 5 "$url" >/dev/null; then
status OK "$label reachable"
else
status FAIL "$label not reachable"
fi
}
check_https() {
local url="$1"
local label="$2"
if curl -kfsS --max-time 5 "$url" >/dev/null; then
status OK "$label reachable"
else
status FAIL "$label not reachable"
fi
}
check_api() {
local url="$1"
local body
if ! body="$(remote_cmd "curl -fsS --max-time 5 $url")"; then
status FAIL "API not reachable: $url"
return
fi
if [ "$REMOTE_MODE" -eq 1 ]; then
body="$(printf "%s\n" "$body" | extract_json_line)"
fi
if [ -z "$body" ]; then
status FAIL "API response empty or not JSON"
return
fi
if command -v python3 >/dev/null 2>&1; then
if printf "%s" "$body" | python3 -c 'import json,sys
try:
data=json.load(sys.stdin)
except Exception:
sys.exit(1)
for key in ("services","hostname","uptime_seconds"):
if key in data:
sys.exit(0)
sys.exit(1)
'
then
status OK "API responds with JSON"
else
status WARN "API response did not include expected fields"
fi
else
status WARN "python3 missing; API JSON check skipped"
fi
}
check_firstboot() {
local url="$1"
local body state error_present
if ! body="$(remote_cmd "curl -fsS --max-time 5 $url")"; then
status FAIL "firstboot API not reachable"
return
fi
if [ "$REMOTE_MODE" -eq 1 ]; then
body="$(printf "%s\n" "$body" | extract_json_line)"
fi
if [ -z "$body" ]; then
status FAIL "firstboot status invalid or missing"
return
fi
state="$(printf "%s" "$body" | json_get "state" || true)"
error_present="$(printf "%s" "$body" | json_get "error_present" || true)"
if [ -z "$state" ]; then
status FAIL "firstboot status invalid or missing"
return
fi
if [ "$state" = "done" ] && [ "$error_present" != "true" ]; then
status OK "firstboot completed"
else
status FAIL "firstboot not complete (state=$state error=$error_present)"
fi
}
check_services() {
local services=("nginx" "pikit-api" "dietpi-dashboard-frontend")
for svc in "${services[@]}"; do
if remote_cmd "systemctl is-active --quiet $svc"; then
status OK "$svc active"
else
status FAIL "$svc not active"
fi
done
}
check_ports() {
local cmd="ss -lnt | awk '{print \$4}' | grep -E ':(80|443|5252|5253)\$' | sort -u"
local out
if out="$(remote_cmd "$cmd" 2>/dev/null)"; then
if echo "$out" | grep -q ":80" && echo "$out" | grep -q ":443"; then
status OK "ports 80/443 listening"
else
status WARN "ports 80/443 not both listening"
fi
else
status WARN "unable to check ports"
fi
}
finalize() {
section "Summary"
status OK "warnings: $WARNINGS"
status OK "errors: $ERRORS"
if [ "$ERRORS" -gt 0 ]; then
echo "[FAIL] Smoke test failed."
exit 1
fi
echo "[OK] Smoke test passed."
}
main() {
parse_args "$@"
if [ "$LOCAL_ONLY" -eq 0 ] && ! is_dietpi; then
REMOTE_MODE=1
fi
section "HTTP/HTTPS"
check_http "$PIKIT_HTTP_URL" "HTTP"
check_https "$PIKIT_HTTPS_URL" "HTTPS"
section "API"
check_api "$PIKIT_API_URL/api/status"
section "Firstboot"
check_firstboot "$PIKIT_API_URL/api/firstboot"
section "Services"
check_services
section "Ports"
check_ports
finalize
}
main "$@"

View File

@@ -31,6 +31,8 @@ export async function api(path, opts = {}) {
}
export const getStatus = () => api("/api/status");
export const getFirstbootStatus = () => api("/api/firstboot");
export const getFirstbootError = () => api("/api/firstboot/error");
export const toggleUpdates = (enable) =>
api("/api/updates/auto", {
method: "POST",

View File

@@ -94,6 +94,60 @@
text-align: right;
}
.release-status-bar {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
margin-bottom: 6px;
}
.release-advanced {
border: 1px dashed var(--border);
border-radius: 12px;
padding: 10px;
margin-top: 8px;
background: rgba(255, 255, 255, 0.02);
}
.release-advanced-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
}
.release-list {
display: grid;
gap: 8px;
}
.release-card {
border: 1px solid var(--border);
border-radius: 10px;
padding: 8px 10px;
display: grid;
grid-template-columns: auto 1fr;
gap: 10px;
align-items: center;
cursor: pointer;
}
.release-card input[type="radio"] {
accent-color: var(--accent);
}
.release-card-title {
font-weight: 600;
}
.release-card-tags {
display: flex;
gap: 8px;
align-items: center;
}
.modal-card .status-msg {
overflow-wrap: anywhere;
margin-top: 6px;
@@ -201,6 +255,94 @@
box-shadow: var(--shadow);
}
.firstboot-overlay .overlay-box {
width: min(92vw, 980px);
max-width: 980px;
text-align: left;
padding: 24px;
max-height: 90vh;
overflow: auto;
}
.firstboot-header h3 {
margin-bottom: 6px;
}
.firstboot-body {
display: grid;
grid-template-columns: 220px 1fr;
gap: 18px;
margin-top: 16px;
}
.firstboot-steps-list {
list-style: none;
padding: 0;
margin: 8px 0 0;
display: grid;
gap: 10px;
}
.firstboot-step {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.95rem;
color: var(--muted);
}
.firstboot-step .step-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--border);
flex: 0 0 10px;
}
.firstboot-step.current {
color: var(--text);
font-weight: 600;
}
.firstboot-step.current .step-dot {
background: var(--accent);
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.12);
}
.firstboot-step.done {
color: var(--text);
opacity: 0.7;
}
.firstboot-step.done .step-dot {
background: #22c55e;
}
.firstboot-step.error {
color: #ef4444;
font-weight: 600;
}
.firstboot-step.error .step-dot {
background: #ef4444;
}
.firstboot-current {
margin: 0 0 6px;
font-weight: 600;
}
.firstboot-log .log-box {
max-height: 240px;
min-height: 140px;
}
@media (max-width: 840px) {
.firstboot-body {
grid-template-columns: 1fr;
}
}
.spinner {
margin: 12px auto 4px;
width: 32px;

View File

@@ -0,0 +1,142 @@
const STATUS_CLASS = {
pending: "pending",
current: "current",
running: "current",
done: "done",
error: "error",
};
function normalizeStatus(status) {
const key = (status || "pending").toString().toLowerCase();
return STATUS_CLASS[key] || "pending";
}
function currentStepLabel(steps = [], fallback = "") {
const current = steps.find((step) => {
const status = typeof step === "string" ? "pending" : step.status;
return ["current", "running", "error"].includes(status);
});
if (current) {
return typeof current === "string" ? current : current.label;
}
const first = steps.find((step) => (typeof step === "string" ? step : step.label));
if (first) return typeof first === "string" ? first : first.label;
return fallback || "";
}
function renderSteps(stepsEl, steps = []) {
if (!stepsEl) return;
stepsEl.innerHTML = "";
steps.forEach((step) => {
const li = document.createElement("li");
const status = normalizeStatus(step.status);
li.className = `firstboot-step ${status}`;
const dot = document.createElement("span");
dot.className = "step-dot";
dot.setAttribute("aria-hidden", "true");
const label = document.createElement("span");
label.className = "step-label";
label.textContent = step.label || "";
li.appendChild(dot);
li.appendChild(label);
stepsEl.appendChild(li);
});
}
function setLogText(logEl, text) {
if (!logEl) return;
const value = text && text.trim().length ? text : "Waiting for setup logs...";
logEl.textContent = value;
logEl.scrollTop = logEl.scrollHeight;
}
function wireCopyButton(btn, getText, showToast) {
if (!btn) return;
btn.addEventListener("click", async () => {
const text = getText();
if (!text) return;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
showToast?.("Copied error log", "success");
} catch (err) {
showToast?.("Copy failed", "error");
}
});
}
export function createFirstbootUI({
overlay,
stepsEl,
currentStepEl,
logEl,
logNoteEl,
errorModal,
errorLogEl,
errorCloseBtn,
errorCopyBtn,
errorShowRecoveryBtn,
recoveryEl,
showToast,
}) {
let lastErrorText = "";
if (errorModal) {
errorModal.addEventListener("click", (e) => {
if (e.target === errorModal) errorModal.classList.add("hidden");
});
}
errorCloseBtn?.addEventListener("click", () => errorModal?.classList.add("hidden"));
errorShowRecoveryBtn?.addEventListener("click", () => recoveryEl?.classList.toggle("hidden"));
wireCopyButton(errorCopyBtn, () => lastErrorText, showToast);
function update(data) {
if (!data) return;
const steps = Array.isArray(data.steps) ? data.steps : [];
const current = data.current_step || currentStepLabel(steps);
renderSteps(
stepsEl,
steps.map((step) => {
const label = typeof step === "string" ? step : step.label || "";
const status = typeof step === "string" ? "pending" : step.status;
return { label, status: normalizeStatus(status) };
})
);
if (currentStepEl) {
currentStepEl.textContent = current ? `Current step: ${current}` : "Current step: preparing";
}
setLogText(logEl, data.log_tail || "");
if (logNoteEl) logNoteEl.textContent = "If this stalls for more than 10 minutes, refresh the page or check SSH.";
}
function showOverlay(show) {
if (!overlay) return;
overlay.classList.toggle("hidden", !show);
}
function showError(text) {
lastErrorText = text || "";
if (errorLogEl) {
errorLogEl.textContent = lastErrorText || "(no error log found)";
}
if (errorModal) errorModal.classList.remove("hidden");
}
return {
update,
showOverlay,
showError,
};
}

View File

@@ -1,6 +1,7 @@
// Entry point for the dashboard: wires UI events, pulls status, and initializes
// feature modules (services, settings, stats).
import { getStatus, triggerReset } from "./api.js";
import { getStatus, getFirstbootError, getFirstbootStatus, triggerReset } from "./api.js";
import { createFirstbootUI } from "./firstboot-ui.js";
import { initServiceControls } from "./services.js";
import { placeholderStatus, renderStats } from "./status.js";
import { initSettings } from "./settings.js";
@@ -99,6 +100,16 @@ const busyTitle = document.getElementById("busyTitle");
const busyText = document.getElementById("busyText");
const toastContainer = document.getElementById("toastContainer");
const readyOverlay = document.getElementById("readyOverlay");
const firstbootSteps = document.getElementById("firstbootSteps");
const firstbootCurrentStep = document.getElementById("firstbootCurrentStep");
const firstbootLog = document.getElementById("firstbootLog");
const firstbootLogNote = document.getElementById("firstbootLogNote");
const firstbootErrorModal = document.getElementById("firstbootErrorModal");
const firstbootErrorLog = document.getElementById("firstbootErrorLog");
const firstbootErrorClose = document.getElementById("firstbootErrorClose");
const firstbootCopyError = document.getElementById("firstbootCopyError");
const firstbootShowRecovery = document.getElementById("firstbootShowRecovery");
const firstbootRecovery = document.getElementById("firstbootRecovery");
const confirmModal = document.getElementById("confirmModal");
const confirmTitle = document.getElementById("confirmTitle");
const confirmBody = document.getElementById("confirmBody");
@@ -146,6 +157,21 @@ const confirmAction = createConfirmModal({
});
const collapseAccordions = () => document.querySelectorAll(".accordion").forEach((a) => a.classList.remove("open"));
const firstbootUI = createFirstbootUI({
overlay: readyOverlay,
stepsEl: firstbootSteps,
currentStepEl: firstbootCurrentStep,
logEl: firstbootLog,
logNoteEl: firstbootLogNote,
errorModal: firstbootErrorModal,
errorLogEl: firstbootErrorLog,
errorCloseBtn: firstbootErrorClose,
errorCopyBtn: firstbootCopyError,
errorShowRecoveryBtn: firstbootShowRecovery,
recoveryEl: firstbootRecovery,
showToast,
});
const statusController = createStatusController({
heroStats,
servicesGrid,
@@ -160,6 +186,11 @@ const statusController = createStatusController({
updatesFlagEl: setUpdatesFlag,
releaseUIGetter: () => releaseUI,
onReadyWait: () => setTimeout(() => statusController.loadStatus(), 3000),
firstboot: {
getStatus: getFirstbootStatus,
getError: getFirstbootError,
ui: firstbootUI,
},
});
const { loadStatus } = statusController;

View File

@@ -25,9 +25,14 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
const releaseProgress = document.getElementById("releaseProgress");
const releaseCheckBtn = document.getElementById("releaseCheckBtn");
const releaseApplyBtn = document.getElementById("releaseApplyBtn");
const releaseVersionSelect = document.getElementById("releaseVersionSelect");
const releaseAdvancedToggle = document.getElementById("releaseAdvancedToggle");
const releaseAdvanced = document.getElementById("releaseAdvanced");
const releaseList = document.getElementById("releaseList");
const releaseApplyVersionBtn = document.getElementById("releaseApplyVersionBtn");
const releaseAutoCheck = document.getElementById("releaseAutoCheck");
const releaseStatusChip = document.getElementById("releaseStatusChip");
const releaseChannelChip = document.getElementById("releaseChannelChip");
const releaseLastCheckChip = document.getElementById("releaseLastCheckChip");
const releaseLog = document.getElementById("releaseLog");
const releaseLogStatus = document.getElementById("releaseLogStatus");
const releaseLogCopy = document.getElementById("releaseLogCopy");
@@ -68,40 +73,70 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
};
async function loadReleaseList() {
if (!releaseVersionSelect) return;
if (!releaseList) return;
try {
const data = await listReleases();
releaseOptions = data.releases || [];
releaseVersionSelect.innerHTML = "";
if (!releaseOptions.length) {
const opt = document.createElement("option");
opt.value = "";
opt.textContent = "No releases found";
releaseVersionSelect.appendChild(opt);
releaseVersionSelect.disabled = true;
releaseApplyVersionBtn && (releaseApplyVersionBtn.disabled = true);
return;
}
releaseVersionSelect.disabled = false;
releaseOptions.forEach((r) => {
const opt = document.createElement("option");
opt.value = r.version;
const tag = r.prerelease ? " (dev)" : "";
opt.textContent = `${r.version}${tag}${r.published_at ? `${fmtDate(r.published_at)}` : ""}`;
releaseVersionSelect.appendChild(opt);
});
if (releaseApplyVersionBtn) releaseApplyVersionBtn.disabled = false;
renderReleaseList();
} catch (e) {
releaseVersionSelect.innerHTML = "";
const opt = document.createElement("option");
opt.value = "";
opt.textContent = "Failed to load releases";
releaseVersionSelect.appendChild(opt);
releaseVersionSelect.disabled = true;
if (releaseApplyVersionBtn) releaseApplyVersionBtn.disabled = true;
renderReleaseList(true);
}
}
function renderReleaseList(error = false) {
if (!releaseList) return;
releaseList.innerHTML = "";
if (error) {
releaseList.textContent = "Failed to load releases.";
return;
}
if (!releaseOptions.length) {
releaseList.textContent = "No releases found.";
return;
}
releaseOptions.forEach((r, idx) => {
const card = document.createElement("label");
card.className = "release-card";
card.setAttribute("role", "option");
const input = document.createElement("input");
input.type = "radio";
input.name = "releaseVersion";
input.value = r.version;
if (idx === 0) input.checked = true;
const meta = document.createElement("div");
meta.className = "release-card-meta";
const title = document.createElement("div");
title.className = "release-card-title";
title.textContent = r.version;
const tags = document.createElement("div");
tags.className = "release-card-tags";
const chip = document.createElement("span");
chip.className = "status-chip ghost";
chip.textContent = r.prerelease ? "Dev" : "Stable";
tags.appendChild(chip);
if (r.published_at) {
const date = document.createElement("span");
date.className = "hint quiet";
date.textContent = fmtDate(r.published_at);
tags.appendChild(date);
}
meta.appendChild(title);
meta.appendChild(tags);
if (r.changelog_url) {
const link = document.createElement("a");
link.href = r.changelog_url;
link.target = "_blank";
link.className = "hint";
link.textContent = "Changelog";
meta.appendChild(link);
}
card.appendChild(input);
card.appendChild(meta);
releaseList.appendChild(card);
});
if (releaseApplyVersionBtn) releaseApplyVersionBtn.disabled = false;
}
function setReleaseChip(state) {
if (!releaseFlagTop) return;
releaseFlagTop.textContent = "Pi-Kit: n/a";
@@ -193,6 +228,7 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
current_release_date = null,
latest_release_date = null,
changelog_url = null,
last_check = null,
} = data || {};
releaseChannel = channel || "dev";
if (releaseChannelToggle) releaseChannelToggle.checked = releaseChannel === "dev";
@@ -214,6 +250,13 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
if (releaseLatest) releaseLatest.textContent = latest_version;
if (releaseCurrentDate) releaseCurrentDate.textContent = fmtDate(current_release_date);
if (releaseLatestDate) releaseLatestDate.textContent = fmtDate(latest_release_date);
if (releaseStatusChip) {
releaseStatusChip.textContent = `Status: ${status.replaceAll("_", " ")}`;
releaseStatusChip.classList.toggle("chip-warm", status === "update_available");
releaseStatusChip.classList.toggle("chip-off", status === "error");
}
if (releaseChannelChip) releaseChannelChip.textContent = `Channel: ${releaseChannel}`;
if (releaseLastCheckChip) releaseLastCheckChip.textContent = `Last check: ${last_check ? fmtDate(last_check) : "—"}`;
if (releaseAutoCheck) releaseAutoCheck.checked = !!auto_check;
if (releaseProgress) releaseProgress.textContent = "";
if (status === "in_progress" && progress) {
@@ -338,14 +381,22 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
}
});
releaseAdvancedToggle?.addEventListener("click", async () => {
releaseAdvanced?.classList.toggle("hidden");
if (!releaseAdvanced?.classList.contains("hidden")) {
await loadReleaseList();
}
});
releaseApplyVersionBtn?.addEventListener("click", async () => {
if (!releaseVersionSelect || !releaseVersionSelect.value) {
const selected = releaseList?.querySelector("input[name='releaseVersion']:checked");
if (!selected) {
showToast("Select a version first", "error");
return;
}
try {
lastReleaseToastKey = null;
const ver = releaseVersionSelect.value;
const ver = selected.value;
logUi(`Install version ${ver} requested`);
releaseBusyActive = true;
showBusy(`Installing ${ver}`, "Applying selected release. This can take up to a minute.");
@@ -401,7 +452,8 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
releaseLogCopy?.addEventListener("click", async () => {
try {
const text = releaseLogLines.join("\n") || "No log entries yet.";
const lines = logger.getLines ? logger.getLines() : [];
const text = lines.join("\n") || "No log entries yet.";
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {

View File

@@ -17,8 +17,10 @@ export function createStatusController({
releaseUIGetter = () => null,
setUpdatesUI = null,
updatesFlagEl = null,
firstboot = null,
}) {
let lastStatusData = null;
let lastFirstbootState = null;
function setTempFlag(tempC) {
if (!tempFlagTop) return;
@@ -77,7 +79,30 @@ export function createStatusController({
}
}
}
if (readyOverlay) {
if (firstboot?.getStatus && firstboot?.ui) {
let firstbootData = null;
const shouldFetchFirstboot =
lastFirstbootState === null || !data.ready || lastFirstbootState === "running" || lastFirstbootState === "error";
if (shouldFetchFirstboot) {
try {
firstbootData = await firstboot.getStatus();
lastFirstbootState = firstbootData?.state || lastFirstbootState;
firstboot.ui.update(firstbootData);
if (firstbootData?.state === "error" && firstboot.getError) {
const err = await firstboot.getError();
if (err?.present) {
firstboot.ui.showError(err.text || "");
}
}
} catch (err) {
logUi?.(`First-boot status failed: ${err?.message || err}`, "error");
}
}
const readyNow = data.ready || firstbootData?.state === "done";
const showOverlay = !readyNow || firstbootData?.state === "error";
firstboot.ui.showOverlay(showOverlay);
if (showOverlay) onReadyWait?.();
} else if (readyOverlay) {
if (data.ready) {
readyOverlay.classList.add("hidden");
} else {

View File

@@ -1,3 +1,3 @@
{
"version": "0.1.3-dev1"
"version": "0.1.3"
}

View File

@@ -80,14 +80,55 @@
</section>
</main>
<div id="readyOverlay" class="overlay hidden">
<div class="overlay-box">
<h3>Finishing setup</h3>
<p>
This only takes a couple of minutes. You'll see the dashboard once
Pi-Kit setup completes.
</p>
<div class="spinner"></div>
<div id="readyOverlay" class="overlay hidden firstboot-overlay">
<div class="overlay-box firstboot-card" role="status" aria-live="polite">
<div class="firstboot-header">
<h3>Finishing setup</h3>
<p class="hint">This usually takes a few minutes. Please keep this tab open.</p>
</div>
<div class="firstboot-body">
<div class="firstboot-steps">
<p class="eyebrow">Steps</p>
<ol id="firstbootSteps" class="firstboot-steps-list"></ol>
</div>
<div class="firstboot-log">
<p id="firstbootCurrentStep" class="firstboot-current">Current step: preparing</p>
<p class="eyebrow">Live setup log</p>
<pre id="firstbootLog" class="log-box"></pre>
<p id="firstbootLogNote" class="hint"></p>
</div>
</div>
</div>
</div>
<div id="firstbootErrorModal" class="modal hidden">
<div class="modal-card wide">
<div class="panel-header sticky">
<div>
<p class="eyebrow">Setup</p>
<h3>Setup needs attention</h3>
<p class="hint">
Pi-Kit couldnt finish setup automatically. Nothing is broken, but a manual fix is needed.
Use SSH and review the error log below, then follow the recovery tips.
</p>
</div>
<button id="firstbootErrorClose" class="ghost icon-btn close-btn" title="Close setup error">
&times;
</button>
</div>
<div class="control-actions wrap gap">
<button id="firstbootCopyError" class="ghost">Copy error log</button>
<button id="firstbootShowRecovery" class="ghost">Show recovery steps</button>
</div>
<div id="firstbootRecovery" class="help-body hidden">
<ul>
<li>SSH: <code>ssh dietpi@pikit</code></li>
<li>Error log: <code>sudo cat /var/lib/pikit/firstboot/firstboot.error</code></li>
<li>Full log: <code>sudo cat /var/lib/pikit/firstboot/firstboot.log</code></li>
<li>If needed: <code>sudo systemctl restart nginx pikit-api</code></li>
</ul>
</div>
<pre id="firstbootErrorLog" class="log-box" aria-live="polite"></pre>
</div>
</div>
@@ -144,6 +185,11 @@
</button>
</div>
<div class="controls column">
<div class="release-status-bar">
<span id="releaseStatusChip" class="status-chip quiet">Status: n/a</span>
<span id="releaseChannelChip" class="status-chip quiet">Channel: n/a</span>
<span id="releaseLastCheckChip" class="status-chip quiet">Last check: —</span>
</div>
<div class="control-card release-versions">
<div>
<p class="hint quiet">Current version</p>
@@ -165,9 +211,8 @@
<button id="releaseApplyBtn" title="Download and install the latest release">
Upgrade
</button>
<select id="releaseVersionSelect" class="ghost" title="Select a specific release to install"></select>
<button id="releaseApplyVersionBtn" class="ghost" title="Install the selected release">
Install version
<button id="releaseAdvancedToggle" class="ghost" title="Open manual version picker">
Manual selection
</button>
<label class="checkbox-row inline">
<input type="checkbox" id="releaseAutoCheck" />
@@ -178,6 +223,18 @@
<span>Allow dev builds</span>
</label>
</div>
<div id="releaseAdvanced" class="release-advanced hidden">
<div class="release-advanced-head">
<div>
<p class="hint quiet">Choose a specific release</p>
<span class="hint">Dev builds only appear when “Allow dev builds” is on.</span>
</div>
<button id="releaseApplyVersionBtn" class="ghost" title="Install selected release">
Install selected
</button>
</div>
<div id="releaseList" class="release-list" role="listbox" aria-label="Available releases"></div>
</div>
<div id="releaseProgress" class="hint status-msg"></div>
<div class="log-card">
<div class="log-header">

View File

@@ -48,7 +48,8 @@
Download Pi-Kit CA
</a>
</div>
<p class="checksum">SHA256: <code class="inline">6bc217c340e502ef20117bd4dc35e05f9f16c562cc3a236d3831a9947caddb97</code></p>
<p class="subtle">After installing the CA, close and reopen your browser so it takes effect.</p>
<p class="checksum">SHA256: <code id="caHash" class="inline">Loading...</code></p>
<details>
<summary id="win">Windows</summary>
<p>Run <strong>mmc</strong> → Add/Remove Snap-in → Certificates (Computer) → Trusted Root CAs → Import <em>pikit-ca.crt</em>.</p>
@@ -85,10 +86,71 @@
<script>
(function () {
const target = `https://${location.hostname}`;
const host = location.hostname || "pikit.local";
const target = `https://${host}`;
const hasCookie = document.cookie.includes("pikit_https_ok=1");
const statusChip = document.getElementById("statusChip");
const copyStatus = document.getElementById("copyStatus");
const downloadCa = document.getElementById("downloadCa");
const caHash = document.getElementById("caHash");
const caUrl = `http://${host}/assets/pikit-ca.crt`;
if (downloadCa) downloadCa.href = caUrl;
const cmdTemplates = {
archCmd: `curl -s ${caUrl} -o /tmp/pikit-ca.crt && sudo install -m644 /tmp/pikit-ca.crt /etc/ca-certificates/trust-source/anchors/ && sudo trust extract-compat`,
debCmd: `curl -s ${caUrl} -o /tmp/pikit-ca.crt && sudo cp /tmp/pikit-ca.crt /usr/local/share/ca-certificates/pikit-ca.crt && sudo update-ca-certificates`,
fedoraCmd: `curl -s ${caUrl} -o /tmp/pikit-ca.crt && sudo cp /tmp/pikit-ca.crt /etc/pki/ca-trust/source/anchors/pikit-ca.crt && sudo update-ca-trust`,
bsdCmd: `fetch -o /tmp/pikit-ca.crt ${caUrl} && sudo install -m644 /tmp/pikit-ca.crt /usr/local/share/certs/pikit-ca.crt && sudo certctl rehash`,
};
Object.entries(cmdTemplates).forEach(([id, cmd]) => {
const el = document.getElementById(id);
if (el) el.textContent = cmd;
});
async function fetchText(url) {
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) return "";
return (await res.text()).trim();
}
async function fetchCaHashFromApi() {
const res = await fetch("/api/firstboot", { cache: "no-store" });
if (!res.ok) return "";
const data = await res.json();
return data?.ca_hash || "";
}
async function loadCaHash(retries = 10) {
if (!caHash) return;
try {
const assetHash = await fetchText("/assets/pikit-ca.sha256");
if (assetHash) {
caHash.textContent = assetHash.split(/\s+/)[0];
return;
}
} catch (err) {
// ignore and try API
}
try {
const apiHash = await fetchCaHashFromApi();
if (apiHash) {
caHash.textContent = apiHash;
return;
}
} catch (err) {
// fall through
}
if (retries > 0) {
caHash.textContent = "Generating...";
setTimeout(() => loadCaHash(retries - 1), 2000);
} else {
caHash.textContent = "Unavailable";
}
}
document.getElementById("continueBtn").addEventListener("click", () => {
window.location = target;
@@ -147,6 +209,8 @@
} else {
statusChip.textContent = "Youre on HTTP — trust the CA or click Go to secure dashboard.";
}
loadCaHash();
})();
</script>
</body>

View File

@@ -22,6 +22,13 @@ API_PACKAGE_DIR = API_DIR / "pikit_api"
BACKUP_ROOT = pathlib.Path("/var/backups/pikit")
TMP_ROOT = pathlib.Path("/var/tmp/pikit-update")
# First-boot state
FIRSTBOOT_DIR = pathlib.Path("/var/lib/pikit/firstboot")
FIRSTBOOT_STATE = FIRSTBOOT_DIR / "state.json"
FIRSTBOOT_LOG = FIRSTBOOT_DIR / "firstboot.log"
FIRSTBOOT_ERROR = FIRSTBOOT_DIR / "firstboot.error"
FIRSTBOOT_DONE = FIRSTBOOT_DIR / "firstboot.done"
# Apt / unattended-upgrades
APT_AUTO_CFG = pathlib.Path("/etc/apt/apt.conf.d/20auto-upgrades")
APT_UA_BASE = pathlib.Path("/etc/apt/apt.conf.d/50unattended-upgrades")

100
pikit_api/firstboot.py Normal file
View File

@@ -0,0 +1,100 @@
import json
import pathlib
from typing import Any, Dict, List, Optional
from .constants import FIRSTBOOT_DIR, FIRSTBOOT_DONE, FIRSTBOOT_ERROR, FIRSTBOOT_LOG, FIRSTBOOT_STATE, WEB_ROOT
from .helpers import ensure_dir, sha256_file
DEFAULT_STEPS = [
"Preparing system",
"Generating security keys",
"Securing the dashboard",
"Updating software (this can take a while)",
"Final checks",
"Starting Pi-Kit",
]
def _tail_text(path: pathlib.Path, max_lines: int = 200) -> str:
if not path.exists():
return ""
try:
text = path.read_text(errors="ignore")
except Exception:
return ""
lines = text.splitlines()
if len(lines) > max_lines:
lines = lines[-max_lines:]
return "\n".join(lines)
def _normalize_steps(raw_steps: Optional[List[Dict[str, Any]]], state: str) -> List[Dict[str, Any]]:
steps: List[Dict[str, Any]] = []
if raw_steps:
for entry in raw_steps:
label = (entry or {}).get("label") or (entry or {}).get("name")
if not label:
continue
status = (entry or {}).get("status") or "pending"
steps.append({"label": str(label), "status": str(status)})
if not steps:
steps = [{"label": label, "status": "pending"} for label in DEFAULT_STEPS]
if state == "done":
for step in steps:
step["status"] = "done"
return steps
def _current_step(steps: List[Dict[str, Any]]) -> Optional[str]:
for step in steps:
if step.get("status") in ("current", "running", "error"):
return step.get("label")
return None
def _load_state_file() -> Dict[str, Any]:
if FIRSTBOOT_STATE.exists():
try:
return json.loads(FIRSTBOOT_STATE.read_text())
except Exception:
return {}
return {}
def read_firstboot_status() -> Dict[str, Any]:
ensure_dir(FIRSTBOOT_DIR)
state_file = _load_state_file()
if FIRSTBOOT_ERROR.exists():
state = "error"
elif FIRSTBOOT_DONE.exists():
state = "done"
else:
state = state_file.get("state") or "running"
if state not in ("running", "done", "error"):
state = "running"
steps = _normalize_steps(state_file.get("steps"), state)
current_step = state_file.get("current_step") or _current_step(steps)
ca_path = WEB_ROOT / "assets" / "pikit-ca.crt"
ca_hash = sha256_file(ca_path) if ca_path.exists() else None
return {
"state": state,
"steps": steps,
"current_step": current_step,
"log_tail": _tail_text(FIRSTBOOT_LOG, 200),
"error_present": FIRSTBOOT_ERROR.exists(),
"error_path": "/api/firstboot/error",
"ca_hash": ca_hash,
"ca_url": "/assets/pikit-ca.crt",
}
def read_firstboot_error(max_lines: int = 200) -> Dict[str, Any]:
if not FIRSTBOOT_ERROR.exists():
return {"present": False, "text": ""}
return {"present": True, "text": _tail_text(FIRSTBOOT_ERROR, max_lines)}

View File

@@ -5,6 +5,7 @@ from http.server import BaseHTTPRequestHandler
from .auto_updates import auto_updates_state, read_updates_config, set_auto_updates, set_updates_config
from .constants import CORE_NAME, CORE_PORTS, DIAG_LOG_FILE
from .diagnostics import _load_diag_state, _save_diag_state, dbg, diag_log, diag_read
from .firstboot import read_firstboot_error, read_firstboot_status
from .helpers import default_host, detect_https, normalize_path
from .releases import (
check_for_update,
@@ -48,6 +49,12 @@ class Handler(BaseHTTPRequestHandler):
if self.path.startswith("/api/status"):
return self._send(200, collect_status())
if self.path.startswith("/api/firstboot/error"):
return self._send(200, read_firstboot_error())
if self.path.startswith("/api/firstboot"):
return self._send(200, read_firstboot_status())
if self.path.startswith("/api/services"):
return self._send(200, {"services": list_services_for_ui()})

View File

@@ -43,29 +43,62 @@ def read_current_version() -> str:
def load_update_state() -> Dict[str, Any]:
UPDATE_STATE_DIR.mkdir(parents=True, exist_ok=True)
def _reset_if_stale(state: Dict[str, Any]) -> Dict[str, Any]:
"""
If state thinks an update is running but the lock holder is gone,
clear it so the UI can recover instead of getting stuck forever.
"""
lock_alive = False
if UPDATE_LOCK.exists():
try:
pid = int(UPDATE_LOCK.read_text().strip() or "0")
if pid > 0:
os.kill(pid, 0)
lock_alive = True
else:
UPDATE_LOCK.unlink(missing_ok=True)
except OSError:
UPDATE_LOCK.unlink(missing_ok=True)
except Exception:
UPDATE_LOCK.unlink(missing_ok=True)
if state.get("in_progress") and not lock_alive:
state["in_progress"] = False
state["progress"] = None
if state.get("status") == "in_progress":
state["status"] = "up_to_date"
state["message"] = state.get("message") or "Recovered from interrupted update"
try:
save_update_state(state)
except Exception:
pass
return state
if UPDATE_STATE.exists():
try:
state = json.loads(UPDATE_STATE.read_text())
state.setdefault("changelog_url", None)
state.setdefault("latest_release_date", None)
state.setdefault("current_release_date", None)
return state
return _reset_if_stale(state)
except Exception:
pass
return {
"current_version": read_current_version(),
"latest_version": None,
"last_check": None,
"status": "unknown",
"message": "",
"auto_check": False,
"in_progress": False,
"progress": None,
"channel": os.environ.get("PIKIT_CHANNEL", "dev"),
"changelog_url": None,
"latest_release_date": None,
"current_release_date": None,
}
return _reset_if_stale(
{
"current_version": read_current_version(),
"latest_version": None,
"last_check": None,
"status": "unknown",
"message": "",
"auto_check": True,
"in_progress": False,
"progress": None,
"channel": os.environ.get("PIKIT_CHANNEL", "stable"),
"changelog_url": None,
"latest_release_date": None,
"current_release_date": None,
}
)
def save_update_state(state: Dict[str, Any]) -> None:
@@ -400,6 +433,19 @@ def fetch_text_with_auth(url: str):
def acquire_lock():
try:
ensure_dir(UPDATE_LOCK.parent)
# Clear stale lock if the recorded PID is not running
if UPDATE_LOCK.exists():
try:
pid = int(UPDATE_LOCK.read_text().strip() or "0")
if pid > 0:
os.kill(pid, 0)
else:
UPDATE_LOCK.unlink(missing_ok=True)
except OSError:
# Process not running
UPDATE_LOCK.unlink(missing_ok=True)
except Exception:
UPDATE_LOCK.unlink(missing_ok=True)
lockfile = UPDATE_LOCK.open("w")
fcntl.flock(lockfile.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
lockfile.write(str(os.getpid()))
@@ -523,8 +569,9 @@ def _install_manifest(manifest: Dict[str, Any], meta: Optional[Dict[str, Any]],
shutil.rmtree(API_PACKAGE_DIR, ignore_errors=True)
shutil.copytree(staged_pkg, API_PACKAGE_DIR, dirs_exist_ok=True)
for svc in ("pikit-api.service", "dietpi-dashboard-frontend.service"):
subprocess.run(["systemctl", "restart", svc], check=False)
# Restart frontend to pick up new assets; avoid restarting this API process
# mid-apply to prevent leaving state in_progress.
subprocess.run(["systemctl", "restart", "dietpi-dashboard-frontend.service"], check=False)
VERSION_FILE.parent.mkdir(parents=True, exist_ok=True)
VERSION_FILE.write_text(str(latest))

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Generate Pi-Kit TLS certs if missing
Before=nginx.service dietpi-dashboard-frontend.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/pikit-certgen.sh
[Install]
WantedBy=nginx.service dietpi-dashboard-frontend.service

116
systemd/pikit-certgen.sh Executable file
View File

@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# Generate Pi-Kit TLS CA + server cert if missing (idempotent).
set -euo pipefail
CERT_DIR="/etc/pikit/certs"
WEB_ASSETS="/var/www/pikit-web/assets"
CA_CRT="$CERT_DIR/pikit-ca.crt"
CA_KEY="$CERT_DIR/pikit-ca.key"
CA_SRL="$CERT_DIR/pikit-ca.srl"
SRV_KEY="$CERT_DIR/pikit.local.key"
SRV_CRT="$CERT_DIR/pikit.local.crt"
SRV_CSR="$CERT_DIR/pikit.local.csr"
CERT_GROUP="pikit-cert"
log() {
printf '[pikit-certgen] %s\n' "$*"
}
write_ca_hash() {
if [ -s "$WEB_ASSETS/pikit-ca.crt" ]; then
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
fi
}
ensure_group() {
if ! getent group "$CERT_GROUP" >/dev/null 2>&1; then
groupadd "$CERT_GROUP" || true
fi
for u in www-data dietpi-dashboard-frontend; do
if id -u "$u" >/dev/null 2>&1; then
usermod -a -G "$CERT_GROUP" "$u" || true
fi
done
}
fix_perms() {
ensure_group
if [ -d "$CERT_DIR" ]; then
chgrp "$CERT_GROUP" "$CERT_DIR" || true
chmod 750 "$CERT_DIR" || true
fi
for f in "$CA_CRT" "$CA_KEY" "$SRV_CRT" "$SRV_KEY"; do
if [ -e "$f" ]; then
chgrp "$CERT_GROUP" "$f" || true
fi
done
[ -e "$CA_KEY" ] && chmod 640 "$CA_KEY"
[ -e "$SRV_KEY" ] && chmod 640 "$SRV_KEY"
[ -e "$CA_CRT" ] && chmod 644 "$CA_CRT"
[ -e "$SRV_CRT" ] && chmod 644 "$SRV_CRT"
}
if [ -s "$CA_CRT" ] && [ -s "$CA_KEY" ] && [ -s "$SRV_KEY" ] && [ -s "$SRV_CRT" ]; then
mkdir -p "$WEB_ASSETS"
if [ ! -s "$WEB_ASSETS/pikit-ca.crt" ]; then
cp "$CA_CRT" "$WEB_ASSETS/pikit-ca.crt"
chmod 644 "$WEB_ASSETS/pikit-ca.crt"
log "Copied CA to web assets."
fi
write_ca_hash
fix_perms
log "TLS certs already present; skipping generation."
exit 0
fi
if ! command -v openssl >/dev/null 2>&1; then
log "openssl not installed; cannot generate certs."
exit 1
fi
log "Generating TLS certs..."
mkdir -p "$CERT_DIR" "$WEB_ASSETS"
ensure_group
chgrp "$CERT_GROUP" "$CERT_DIR" || true
chmod 750 "$CERT_DIR"
rm -f "$CA_KEY" "$CA_CRT" "$CA_SRL" "$SRV_KEY" "$SRV_CRT" "$SRV_CSR" || true
openssl genrsa -out "$CA_KEY" 2048
openssl req -x509 -new -nodes -key "$CA_KEY" -sha256 -days 3650 \
-out "$CA_CRT" -subj "/CN=Pi-Kit CA"
openssl genrsa -out "$SRV_KEY" 2048
openssl req -new -key "$SRV_KEY" -out "$SRV_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 "$SRV_CSR" -CA "$CA_CRT" -CAkey "$CA_KEY" \
-CAcreateserial -out "$SRV_CRT" -days 825 -sha256 -extfile "$SAN_CFG"
rm -f "$SAN_CFG" "$SRV_CSR"
fix_perms
cp "$CA_CRT" "$WEB_ASSETS/pikit-ca.crt"
chmod 644 "$WEB_ASSETS/pikit-ca.crt"
write_ca_hash
log "TLS certs generated."

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env sh
# Install as /etc/profile.d/pikit-first-login.sh
# Prints a one-time SSH hardening tip after the forced password change.
FLAG="/var/lib/pikit/first-login.notice"
case "$-" in
*i*) interactive=1 ;;
*) interactive=0 ;;
esac
if [ "$interactive" -eq 1 ] && [ -f "$FLAG" ]; then
echo ""
echo "Pi-Kit: For better security, set up an SSH key and disable password auth once working."
echo " Example: ssh-keygen -t ed25519"
echo " ssh-copy-id dietpi@pikit.local"
echo ""
rm -f "$FLAG" 2>/dev/null || true
fi

304
systemd/pikit-firstboot.sh Executable file
View File

@@ -0,0 +1,304 @@
#!/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"
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
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)."
}
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
if [ -f "$PROFILE_FILE" ] && command -v ufw >/dev/null 2>&1; then
python3 - <<'PY' > /tmp/pikit-profile-ports.txt
import json
import sys
from pathlib import Path
profile = Path("/etc/pikit/profile.json")
try:
data = json.loads(profile.read_text())
except Exception:
data = {}
ports = data.get("firewall_ports") or []
for port in ports:
try:
port_int = int(port)
except Exception:
continue
print(port_int)
PY
while read -r port; do
[ -z "$port" ] && 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; do
ufw allow from "$subnet" to any port "$port" || true
done
done < /tmp/pikit-profile-ports.txt
rm -f /tmp/pikit-profile-ports.txt
else
log "Profile firewall step skipped (no profile.json or ufw missing)"
fi
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

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Pi-Kit ready flag helper
ConditionPathExists=/var/lib/pikit/firstboot/firstboot.done
[Service]
Type=oneshot
ExecStart=/usr/local/bin/pikit-ready.sh
[Install]
WantedBy=multi-user.target

11
systemd/pikit-ready.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Touches /var/run/pikit-ready on boot when firstboot is complete.
set -euo pipefail
DONE_FILE="/var/lib/pikit/firstboot/firstboot.done"
READY_FILE="/var/run/pikit-ready"
if [ -f "$DONE_FILE" ]; then
touch "$READY_FILE"
fi

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Generate SSH host keys if missing
Before=ssh.service
[Service]
Type=oneshot
ExecStart=/usr/bin/ssh-keygen -A
[Install]
WantedBy=multi-user.target