Compare commits
21 Commits
v0.1.3-dev
...
v0.1.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb2d75387d | ||
|
|
77bc4c1c36 | ||
|
|
13e5788fe1 | ||
|
|
24e89b516f | ||
|
|
9c3156df35 | ||
|
|
b01c2ba737 | ||
|
|
bc97e0374f | ||
|
|
0a23902eb0 | ||
|
|
4632704092 | ||
|
|
36d30da30a | ||
|
|
c62f1f018f | ||
|
|
32a9f42361 | ||
|
|
40b1b43449 | ||
|
|
ccc97f7912 | ||
|
|
f4090cbf1d | ||
|
|
f4d0765c93 | ||
|
|
99bd87c7f6 | ||
|
|
e87b90bf9f | ||
|
|
8557140193 | ||
|
|
86438b11f3 | ||
|
|
3a785832b1 |
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 "$@"
|
||||
|
||||
104
docs/workflow.md
Normal file
104
docs/workflow.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# 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 + 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 (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 + 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 + 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 + 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 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.
|
||||
|
||||
Optional: to skip the first‑boot 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 known‑good SD card.
|
||||
3) Boot and verify:
|
||||
- `http://pikit.local` and `https://pikit.local`
|
||||
- dashboard loads
|
||||
- first‑boot 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 on‑device).
|
||||
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 → first‑boot → dashboard → services
|
||||
|
||||
## 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`).
|
||||
- Prep enforces a password change for `dietpi` on first login; set `PIKIT_FORCE_PASSWORD_CHANGE=0` to skip.
|
||||
- After the password change, a one‑time 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).
|
||||
@@ -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
713
pikit-prep.sh
Normal file → Executable 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
252
pikit-smoke-test.sh
Executable 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 "$@"
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -452,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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "0.1.3-dev1"
|
||||
"version": "0.1.3"
|
||||
}
|
||||
|
||||
@@ -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,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 = "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()})
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
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
|
||||
116
systemd/pikit-certgen.sh
Executable file
116
systemd/pikit-certgen.sh
Executable 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."
|
||||
19
systemd/pikit-first-login.sh
Normal file
19
systemd/pikit-first-login.sh
Normal 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
304
systemd/pikit-firstboot.sh
Executable 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
|
||||
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