Add firstboot onboarding and prep/check tooling
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -10,12 +10,14 @@ pikit-web/.cache/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
backups/
|
||||
*.pyc
|
||||
|
||||
# OS/Editor
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
AGENTS.md
|
||||
|
||||
# Build artifacts
|
||||
*.log
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
70
docs/workflow.md
Normal file
70
docs/workflow.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Pi-Kit Image Workflow
|
||||
|
||||
This documents the *current* workflow and the *target* workflow once profiles + first‑boot automation are implemented. It is meant to be a practical, repeatable checklist.
|
||||
|
||||
## 0) Keep a golden base image (do this first)
|
||||
1) Boot the known‑good base Pi.
|
||||
2) Verify core services:
|
||||
- Nginx + Pi‑Kit dashboard
|
||||
- DietPi dashboard
|
||||
3) Update the system if needed.
|
||||
4) Run the prep scrub:
|
||||
- `sudo ./pikit-prep.sh`
|
||||
- (optional) `sudo ./check-pikit-clean.sh`
|
||||
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 (Pi‑Kit 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:
|
||||
- `sudo ./pikit-prep.sh`
|
||||
- (optional) `sudo ./check-pikit-clean.sh`
|
||||
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 + first‑boot)
|
||||
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 first‑boot).
|
||||
- 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:
|
||||
- `sudo ./pikit-prep.sh`
|
||||
7) Image the SD card with DietPi Imager.
|
||||
|
||||
First boot on the end‑user device will:
|
||||
- Regenerate unique identity + TLS certs.
|
||||
- Ensure the profile’s firewall ports are open (LAN‑only).
|
||||
- Show a progress overlay until complete.
|
||||
|
||||
## 3) Flashing an image to SD
|
||||
Use the helper:
|
||||
- `sudo ./flash_sd.sh <image.img.xz> /dev/sdX`
|
||||
|
||||
## Notes
|
||||
- Profiles are additive to the base image defaults; do not include Pi‑Kit or DietPi dashboard entries in profiles.
|
||||
- Keep `RESCUE.md` in `/root` and `/home/dietpi` only (not in `/var/www`).
|
||||
693
pikit-prep.sh
Normal file → Executable file
693
pikit-prep.sh
Normal file → Executable file
@@ -1,10 +1,96 @@
|
||||
#!/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}"
|
||||
|
||||
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
|
||||
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 +151,468 @@ 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"
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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 "$@"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -255,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;
|
||||
|
||||
142
pikit-web/assets/firstboot-ui.js
Normal file
142
pikit-web/assets/firstboot-ui.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 couldn’t 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">
|
||||
×
|
||||
</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>
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
Download Pi-Kit CA
|
||||
</a>
|
||||
</div>
|
||||
<p class="checksum">SHA256: <code class="inline">6bc217c340e502ef20117bd4dc35e05f9f16c562cc3a236d3831a9947caddb97</code></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 +85,39 @@
|
||||
|
||||
<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 loadCaHash() {
|
||||
if (!caHash) return;
|
||||
try {
|
||||
const res = await fetch("/api/firstboot");
|
||||
const data = await res.json();
|
||||
caHash.textContent = data?.ca_hash || "Unavailable";
|
||||
} catch (err) {
|
||||
caHash.textContent = "Unavailable";
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("continueBtn").addEventListener("click", () => {
|
||||
window.location = target;
|
||||
@@ -147,6 +176,8 @@
|
||||
} else {
|
||||
statusChip.textContent = "You’re on HTTP — trust the CA or click Go to secure dashboard.";
|
||||
}
|
||||
|
||||
loadCaHash();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -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
100
pikit_api/firstboot.py
Normal 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)}
|
||||
@@ -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()})
|
||||
|
||||
|
||||
10
systemd/pikit-certgen.service
Normal file
10
systemd/pikit-certgen.service
Normal 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
|
||||
101
systemd/pikit-certgen.sh
Executable file
101
systemd/pikit-certgen.sh
Executable file
@@ -0,0 +1,101 @@
|
||||
#!/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' "$*"
|
||||
}
|
||||
|
||||
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
|
||||
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"
|
||||
|
||||
log "TLS certs generated."
|
||||
250
systemd/pikit-firstboot.sh
Executable file
250
systemd/pikit-firstboot.sh
Executable file
@@ -0,0 +1,250 @@
|
||||
#!/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"
|
||||
|
||||
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
|
||||
|
||||
log() {
|
||||
printf '[%s] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$*"
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
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 systemctl >/dev/null 2>&1; then
|
||||
systemctl reload nginx || systemctl restart nginx
|
||||
fi
|
||||
finish_step 2
|
||||
|
||||
begin_step 3
|
||||
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
|
||||
finish_step 3
|
||||
|
||||
begin_step 4
|
||||
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
|
||||
10
systemd/pikit-ready.service
Normal file
10
systemd/pikit-ready.service
Normal 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
11
systemd/pikit-ready.sh
Executable 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
|
||||
10
systemd/pikit-ssh-keygen.service
Normal file
10
systemd/pikit-ssh-keygen.service
Normal 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
|
||||
Reference in New Issue
Block a user