Compare commits
22 Commits
b01bfcd38e
...
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 | ||
|
|
a94cd17186 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -10,12 +10,14 @@ pikit-web/.cache/
|
|||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
backups/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
# OS/Editor
|
# OS/Editor
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
*.swp
|
*.swp
|
||||||
|
AGENTS.md
|
||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -1,49 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
echo "== Identity files =="
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
ls -l /etc/machine-id || true
|
exec "${SCRIPT_DIR}/pikit-prep.sh" --check-only "$@"
|
||||||
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"
|
|
||||||
|
|||||||
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",
|
"version": "0.1.3-dev6",
|
||||||
"_release_date": "2025-12-14T22:23:00Z",
|
"_release_date": "2025-12-15T00:26:36Z",
|
||||||
"bundle": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.3-dev1/pikit-0.1.3-dev1.tar.gz",
|
"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-dev1/CHANGELOG-0.1.3-dev1.txt",
|
"changelog": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.3-dev6/CHANGELOG-0.1.3-dev6.txt",
|
||||||
"files": [
|
"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
|
#!/bin/bash
|
||||||
# Pi-Kit DietPi image prep script
|
# Pi-Kit DietPi image prep + check script
|
||||||
# Cleans host-unique data, logs, caches, and temp files per pikit-prep-spec.
|
# Cleans host-unique data and optionally verifies the image state.
|
||||||
|
|
||||||
set -euo pipefail
|
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() {
|
clean_logs_dir() {
|
||||||
local dir="$1" pattern="${2:-*}"
|
local dir="$1" pattern="${2:-*}"
|
||||||
@@ -65,181 +155,484 @@ clean_backups() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Identity ---
|
clean_home_dir() {
|
||||||
# Keep machine-id file present but empty so systemd regenerates cleanly on next boot.
|
local dir="$1"
|
||||||
truncate -s 0 /etc/machine-id && status CLEANED /etc/machine-id || status FAIL /etc/machine-id
|
shift
|
||||||
mkdir -p /var/lib/dbus || true
|
local keep=("$@")
|
||||||
rm -f /var/lib/dbus/machine-id
|
if [ -d "$dir" ]; then
|
||||||
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"
|
shopt -s dotglob nullglob
|
||||||
clean_file /var/lib/systemd/random-seed
|
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 ---
|
prep_image() {
|
||||||
if ls /etc/ssh/ssh_host_* >/dev/null 2>&1; then
|
section "Prep"
|
||||||
rm -f /etc/ssh/ssh_host_* && status CLEANED "SSH host keys" || status FAIL "SSH host keys"
|
|
||||||
else
|
|
||||||
status SKIP "SSH host keys (none)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- SSH client traces ---
|
# --- Identity ---
|
||||||
:> /root/.ssh/known_hosts 2>/dev/null && status CLEANED "/root/.ssh/known_hosts" || status SKIP "/root/.ssh/known_hosts"
|
truncate -s 0 /etc/machine-id && status CLEANED /etc/machine-id || status FAIL /etc/machine-id
|
||||||
:> /home/dietpi/.ssh/known_hosts 2>/dev/null && status CLEANED "/home/dietpi/.ssh/known_hosts" || status SKIP "/home/dietpi/.ssh/known_hosts"
|
mkdir -p /var/lib/dbus || true
|
||||||
:> /home/dietpi/.ssh/authorized_keys 2>/dev/null && status CLEANED "/home/dietpi/.ssh/authorized_keys" || status SKIP "/home/dietpi/.ssh/authorized_keys"
|
rm -f /var/lib/dbus/machine-id
|
||||||
:> /root/.ssh/authorized_keys 2>/dev/null && status CLEANED "/root/.ssh/authorized_keys" || status SKIP "/root/.ssh/authorized_keys"
|
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 ---
|
# --- SSH host keys ---
|
||||||
:> /root/.bash_history 2>/dev/null && status CLEANED "/root/.bash_history" || status SKIP "/root/.bash_history"
|
if ls /etc/ssh/ssh_host_* >/dev/null 2>&1; then
|
||||||
:> /home/dietpi/.bash_history 2>/dev/null && status CLEANED "/home/dietpi/.bash_history" || status SKIP "/home/dietpi/.bash_history"
|
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 ---
|
# --- SSH client traces ---
|
||||||
clean_file /var/run/pikit-ready
|
:> /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 ---
|
# --- Default login ---
|
||||||
clean_backups /var/www/pikit-web
|
if id -u dietpi >/dev/null 2>&1; then
|
||||||
clean_backups /usr/local/bin
|
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 ---
|
# --- Shell history ---
|
||||||
clean_dir_files /var/log "*"
|
:> /root/.bash_history 2>/dev/null && status CLEANED "/root/.bash_history" || status SKIP "/root/.bash_history"
|
||||||
clean_dir_files /var/log/nginx "*"
|
:> /home/dietpi/.bash_history 2>/dev/null && status CLEANED "/home/dietpi/.bash_history" || status SKIP "/home/dietpi/.bash_history"
|
||||||
# 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
|
|
||||||
|
|
||||||
# Service-specific logs (best-effort, skip if absent)
|
# --- Home directories ---
|
||||||
if command -v pihole >/dev/null 2>&1; then
|
clean_home_dir /home/dietpi .bashrc .bash_logout .profile .ssh RESCUE.md
|
||||||
pihole -f >/dev/null 2>&1 && status CLEANED "pihole logs via pihole -f" || status FAIL "pihole -f"
|
clean_home_dir /root .bashrc .bash_logout .profile .ssh RESCUE.md
|
||||||
clean_logs_dir /var/log/pihole '*'
|
|
||||||
clean_file /etc/pihole/pihole-FTL.db # resets long-term query history; leave gravity.db untouched
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -x /opt/AdGuardHome/AdGuardHome ]; then
|
# --- Ready flag ---
|
||||||
clean_logs_dir /var/opt/AdGuardHome/data/logs '*'
|
clean_file /var/run/pikit-ready
|
||||||
clean_file /opt/AdGuardHome/data/querylog.db
|
|
||||||
fi
|
|
||||||
|
|
||||||
if command -v ufw >/dev/null 2>&1; then
|
# --- First-boot state + TLS ---
|
||||||
truncate_file /var/log/ufw.log
|
clean_dir_files /var/lib/pikit/firstboot "*"
|
||||||
fi
|
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
|
# --- Backup/editor cruft ---
|
||||||
truncate_file /var/log/fail2ban.log
|
clean_backups /var/www/pikit-web
|
||||||
fi
|
clean_backups /usr/local/bin
|
||||||
|
|
||||||
clean_logs_dir /var/log/unbound '*'
|
# --- Logs ---
|
||||||
clean_logs_dir /var/log/dnsmasq '*'
|
clean_dir_files /var/log "*"
|
||||||
clean_logs_dir /var/log/powerdns '*'
|
clean_dir_files /var/log/nginx "*"
|
||||||
clean_logs_dir /var/lib/technitium-dns/Logs '*'
|
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 '*'
|
if command -v pihole >/dev/null 2>&1; then
|
||||||
clean_logs_dir /var/lib/jellyfin/log '*'
|
pihole -f >/dev/null 2>&1 && status CLEANED "pihole logs via pihole -f" || status FAIL "pihole -f"
|
||||||
clean_logs_dir /var/log/jellyseerr '*'
|
clean_logs_dir /var/log/pihole '*'
|
||||||
clean_logs_dir /opt/jellyseerr/logs '*'
|
clean_file /etc/pihole/pihole-FTL.db
|
||||||
clean_logs_dir /var/log/ustreamer '*'
|
fi
|
||||||
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 '*'
|
|
||||||
|
|
||||||
# DB / metrics / web stacks
|
if [ -x /opt/AdGuardHome/AdGuardHome ]; then
|
||||||
clean_logs_dir /var/log/mysql '*'
|
clean_logs_dir /var/opt/AdGuardHome/data/logs '*'
|
||||||
clean_logs_dir /var/log/mariadb '*'
|
clean_file /opt/AdGuardHome/data/querylog.db
|
||||||
clean_logs_dir /var/log/postgresql '*'
|
fi
|
||||||
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 '*'
|
|
||||||
|
|
||||||
# App-specific logs stored with app data (truncate, keep structure)
|
if command -v ufw >/dev/null 2>&1; then
|
||||||
truncate_file /home/homeassistant/.homeassistant/home-assistant.log
|
truncate_file /var/log/ufw.log
|
||||||
clean_logs_dir /home/homeassistant/.homeassistant/logs '*'
|
fi
|
||||||
truncate_file /var/www/nextcloud/data/nextcloud.log
|
|
||||||
truncate_file /var/www/owncloud/data/owncloud.log
|
|
||||||
|
|
||||||
# Docker container JSON logs
|
if command -v fail2ban-client >/dev/null 2>&1; then
|
||||||
if [ -d /var/lib/docker/containers ]; then
|
truncate_file /var/log/fail2ban.log
|
||||||
find /var/lib/docker/containers -type f -name '*-json.log' -print0 2>/dev/null | while IFS= read -r -d '' f; do
|
fi
|
||||||
:> "$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 "*"
|
|
||||||
|
|
||||||
# --- Caches ---
|
clean_logs_dir /var/log/unbound '*'
|
||||||
apt-get clean >/dev/null 2>&1 && status CLEANED "apt cache" || status FAIL "apt cache"
|
clean_logs_dir /var/log/dnsmasq '*'
|
||||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/* 2>/dev/null && status CLEANED "apt lists/cache" || status FAIL "apt lists/cache"
|
clean_logs_dir /var/log/powerdns '*'
|
||||||
find /var/cache/debconf -type f -print -delete 2>/dev/null | sed 's/^/[CLEANED] /' || true
|
clean_logs_dir /var/lib/technitium-dns/Logs '*'
|
||||||
status CLEANED "/var/cache/debconf files"
|
|
||||||
|
|
||||||
# --- Temp directories ---
|
clean_logs_dir /var/log/jellyfin '*'
|
||||||
truncate_dir /tmp
|
clean_logs_dir /var/lib/jellyfin/log '*'
|
||||||
truncate_dir /var/tmp
|
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_logs_dir /var/log/mysql '*'
|
||||||
clean_file /var/lib/dhcp/dhclient.eth0.leases
|
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 ---
|
truncate_file /home/homeassistant/.homeassistant/home-assistant.log
|
||||||
if [ -d /var/lib/nginx ]; then
|
clean_logs_dir /home/homeassistant/.homeassistant/logs '*'
|
||||||
find /var/lib/nginx -mindepth 1 -maxdepth 1 -type d -exec rm -rf {} + 2>/dev/null
|
truncate_file /var/www/nextcloud/data/nextcloud.log
|
||||||
status CLEANED "/var/lib/nginx/*"
|
truncate_file /var/www/owncloud/data/owncloud.log
|
||||||
else
|
|
||||||
status SKIP "/var/lib/nginx"
|
|
||||||
fi
|
|
||||||
|
|
||||||
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.
|
# --- Caches ---
|
||||||
rm -- "$0"
|
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 getStatus = () => api("/api/status");
|
||||||
|
export const getFirstbootStatus = () => api("/api/firstboot");
|
||||||
|
export const getFirstbootError = () => api("/api/firstboot/error");
|
||||||
export const toggleUpdates = (enable) =>
|
export const toggleUpdates = (enable) =>
|
||||||
api("/api/updates/auto", {
|
api("/api/updates/auto", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -94,6 +94,60 @@
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.release-status-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-advanced {
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-advanced-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-card input[type="radio"] {
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-card-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-card-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-card .status-msg {
|
.modal-card .status-msg {
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
@@ -201,6 +255,94 @@
|
|||||||
box-shadow: var(--shadow);
|
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 {
|
.spinner {
|
||||||
margin: 12px auto 4px;
|
margin: 12px auto 4px;
|
||||||
width: 32px;
|
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
|
// Entry point for the dashboard: wires UI events, pulls status, and initializes
|
||||||
// feature modules (services, settings, stats).
|
// 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 { initServiceControls } from "./services.js";
|
||||||
import { placeholderStatus, renderStats } from "./status.js";
|
import { placeholderStatus, renderStats } from "./status.js";
|
||||||
import { initSettings } from "./settings.js";
|
import { initSettings } from "./settings.js";
|
||||||
@@ -99,6 +100,16 @@ const busyTitle = document.getElementById("busyTitle");
|
|||||||
const busyText = document.getElementById("busyText");
|
const busyText = document.getElementById("busyText");
|
||||||
const toastContainer = document.getElementById("toastContainer");
|
const toastContainer = document.getElementById("toastContainer");
|
||||||
const readyOverlay = document.getElementById("readyOverlay");
|
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 confirmModal = document.getElementById("confirmModal");
|
||||||
const confirmTitle = document.getElementById("confirmTitle");
|
const confirmTitle = document.getElementById("confirmTitle");
|
||||||
const confirmBody = document.getElementById("confirmBody");
|
const confirmBody = document.getElementById("confirmBody");
|
||||||
@@ -146,6 +157,21 @@ const confirmAction = createConfirmModal({
|
|||||||
});
|
});
|
||||||
const collapseAccordions = () => document.querySelectorAll(".accordion").forEach((a) => a.classList.remove("open"));
|
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({
|
const statusController = createStatusController({
|
||||||
heroStats,
|
heroStats,
|
||||||
servicesGrid,
|
servicesGrid,
|
||||||
@@ -160,6 +186,11 @@ const statusController = createStatusController({
|
|||||||
updatesFlagEl: setUpdatesFlag,
|
updatesFlagEl: setUpdatesFlag,
|
||||||
releaseUIGetter: () => releaseUI,
|
releaseUIGetter: () => releaseUI,
|
||||||
onReadyWait: () => setTimeout(() => statusController.loadStatus(), 3000),
|
onReadyWait: () => setTimeout(() => statusController.loadStatus(), 3000),
|
||||||
|
firstboot: {
|
||||||
|
getStatus: getFirstbootStatus,
|
||||||
|
getError: getFirstbootError,
|
||||||
|
ui: firstbootUI,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const { loadStatus } = statusController;
|
const { loadStatus } = statusController;
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,14 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
|
|||||||
const releaseProgress = document.getElementById("releaseProgress");
|
const releaseProgress = document.getElementById("releaseProgress");
|
||||||
const releaseCheckBtn = document.getElementById("releaseCheckBtn");
|
const releaseCheckBtn = document.getElementById("releaseCheckBtn");
|
||||||
const releaseApplyBtn = document.getElementById("releaseApplyBtn");
|
const releaseApplyBtn = document.getElementById("releaseApplyBtn");
|
||||||
const releaseVersionSelect = document.getElementById("releaseVersionSelect");
|
const releaseAdvancedToggle = document.getElementById("releaseAdvancedToggle");
|
||||||
|
const releaseAdvanced = document.getElementById("releaseAdvanced");
|
||||||
|
const releaseList = document.getElementById("releaseList");
|
||||||
const releaseApplyVersionBtn = document.getElementById("releaseApplyVersionBtn");
|
const releaseApplyVersionBtn = document.getElementById("releaseApplyVersionBtn");
|
||||||
const releaseAutoCheck = document.getElementById("releaseAutoCheck");
|
const releaseAutoCheck = document.getElementById("releaseAutoCheck");
|
||||||
|
const releaseStatusChip = document.getElementById("releaseStatusChip");
|
||||||
|
const releaseChannelChip = document.getElementById("releaseChannelChip");
|
||||||
|
const releaseLastCheckChip = document.getElementById("releaseLastCheckChip");
|
||||||
const releaseLog = document.getElementById("releaseLog");
|
const releaseLog = document.getElementById("releaseLog");
|
||||||
const releaseLogStatus = document.getElementById("releaseLogStatus");
|
const releaseLogStatus = document.getElementById("releaseLogStatus");
|
||||||
const releaseLogCopy = document.getElementById("releaseLogCopy");
|
const releaseLogCopy = document.getElementById("releaseLogCopy");
|
||||||
@@ -68,40 +73,70 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function loadReleaseList() {
|
async function loadReleaseList() {
|
||||||
if (!releaseVersionSelect) return;
|
if (!releaseList) return;
|
||||||
try {
|
try {
|
||||||
const data = await listReleases();
|
const data = await listReleases();
|
||||||
releaseOptions = data.releases || [];
|
releaseOptions = data.releases || [];
|
||||||
releaseVersionSelect.innerHTML = "";
|
renderReleaseList();
|
||||||
if (!releaseOptions.length) {
|
|
||||||
const opt = document.createElement("option");
|
|
||||||
opt.value = "";
|
|
||||||
opt.textContent = "No releases found";
|
|
||||||
releaseVersionSelect.appendChild(opt);
|
|
||||||
releaseVersionSelect.disabled = true;
|
|
||||||
releaseApplyVersionBtn && (releaseApplyVersionBtn.disabled = true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
releaseVersionSelect.disabled = false;
|
|
||||||
releaseOptions.forEach((r) => {
|
|
||||||
const opt = document.createElement("option");
|
|
||||||
opt.value = r.version;
|
|
||||||
const tag = r.prerelease ? " (dev)" : "";
|
|
||||||
opt.textContent = `${r.version}${tag}${r.published_at ? ` — ${fmtDate(r.published_at)}` : ""}`;
|
|
||||||
releaseVersionSelect.appendChild(opt);
|
|
||||||
});
|
|
||||||
if (releaseApplyVersionBtn) releaseApplyVersionBtn.disabled = false;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
releaseVersionSelect.innerHTML = "";
|
renderReleaseList(true);
|
||||||
const opt = document.createElement("option");
|
|
||||||
opt.value = "";
|
|
||||||
opt.textContent = "Failed to load releases";
|
|
||||||
releaseVersionSelect.appendChild(opt);
|
|
||||||
releaseVersionSelect.disabled = true;
|
|
||||||
if (releaseApplyVersionBtn) releaseApplyVersionBtn.disabled = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderReleaseList(error = false) {
|
||||||
|
if (!releaseList) return;
|
||||||
|
releaseList.innerHTML = "";
|
||||||
|
if (error) {
|
||||||
|
releaseList.textContent = "Failed to load releases.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!releaseOptions.length) {
|
||||||
|
releaseList.textContent = "No releases found.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
releaseOptions.forEach((r, idx) => {
|
||||||
|
const card = document.createElement("label");
|
||||||
|
card.className = "release-card";
|
||||||
|
card.setAttribute("role", "option");
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "radio";
|
||||||
|
input.name = "releaseVersion";
|
||||||
|
input.value = r.version;
|
||||||
|
if (idx === 0) input.checked = true;
|
||||||
|
const meta = document.createElement("div");
|
||||||
|
meta.className = "release-card-meta";
|
||||||
|
const title = document.createElement("div");
|
||||||
|
title.className = "release-card-title";
|
||||||
|
title.textContent = r.version;
|
||||||
|
const tags = document.createElement("div");
|
||||||
|
tags.className = "release-card-tags";
|
||||||
|
const chip = document.createElement("span");
|
||||||
|
chip.className = "status-chip ghost";
|
||||||
|
chip.textContent = r.prerelease ? "Dev" : "Stable";
|
||||||
|
tags.appendChild(chip);
|
||||||
|
if (r.published_at) {
|
||||||
|
const date = document.createElement("span");
|
||||||
|
date.className = "hint quiet";
|
||||||
|
date.textContent = fmtDate(r.published_at);
|
||||||
|
tags.appendChild(date);
|
||||||
|
}
|
||||||
|
meta.appendChild(title);
|
||||||
|
meta.appendChild(tags);
|
||||||
|
if (r.changelog_url) {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = r.changelog_url;
|
||||||
|
link.target = "_blank";
|
||||||
|
link.className = "hint";
|
||||||
|
link.textContent = "Changelog";
|
||||||
|
meta.appendChild(link);
|
||||||
|
}
|
||||||
|
card.appendChild(input);
|
||||||
|
card.appendChild(meta);
|
||||||
|
releaseList.appendChild(card);
|
||||||
|
});
|
||||||
|
if (releaseApplyVersionBtn) releaseApplyVersionBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
function setReleaseChip(state) {
|
function setReleaseChip(state) {
|
||||||
if (!releaseFlagTop) return;
|
if (!releaseFlagTop) return;
|
||||||
releaseFlagTop.textContent = "Pi-Kit: n/a";
|
releaseFlagTop.textContent = "Pi-Kit: n/a";
|
||||||
@@ -193,6 +228,7 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
|
|||||||
current_release_date = null,
|
current_release_date = null,
|
||||||
latest_release_date = null,
|
latest_release_date = null,
|
||||||
changelog_url = null,
|
changelog_url = null,
|
||||||
|
last_check = null,
|
||||||
} = data || {};
|
} = data || {};
|
||||||
releaseChannel = channel || "dev";
|
releaseChannel = channel || "dev";
|
||||||
if (releaseChannelToggle) releaseChannelToggle.checked = releaseChannel === "dev";
|
if (releaseChannelToggle) releaseChannelToggle.checked = releaseChannel === "dev";
|
||||||
@@ -214,6 +250,13 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
|
|||||||
if (releaseLatest) releaseLatest.textContent = latest_version;
|
if (releaseLatest) releaseLatest.textContent = latest_version;
|
||||||
if (releaseCurrentDate) releaseCurrentDate.textContent = fmtDate(current_release_date);
|
if (releaseCurrentDate) releaseCurrentDate.textContent = fmtDate(current_release_date);
|
||||||
if (releaseLatestDate) releaseLatestDate.textContent = fmtDate(latest_release_date);
|
if (releaseLatestDate) releaseLatestDate.textContent = fmtDate(latest_release_date);
|
||||||
|
if (releaseStatusChip) {
|
||||||
|
releaseStatusChip.textContent = `Status: ${status.replaceAll("_", " ")}`;
|
||||||
|
releaseStatusChip.classList.toggle("chip-warm", status === "update_available");
|
||||||
|
releaseStatusChip.classList.toggle("chip-off", status === "error");
|
||||||
|
}
|
||||||
|
if (releaseChannelChip) releaseChannelChip.textContent = `Channel: ${releaseChannel}`;
|
||||||
|
if (releaseLastCheckChip) releaseLastCheckChip.textContent = `Last check: ${last_check ? fmtDate(last_check) : "—"}`;
|
||||||
if (releaseAutoCheck) releaseAutoCheck.checked = !!auto_check;
|
if (releaseAutoCheck) releaseAutoCheck.checked = !!auto_check;
|
||||||
if (releaseProgress) releaseProgress.textContent = "";
|
if (releaseProgress) releaseProgress.textContent = "";
|
||||||
if (status === "in_progress" && progress) {
|
if (status === "in_progress" && progress) {
|
||||||
@@ -338,14 +381,22 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
releaseAdvancedToggle?.addEventListener("click", async () => {
|
||||||
|
releaseAdvanced?.classList.toggle("hidden");
|
||||||
|
if (!releaseAdvanced?.classList.contains("hidden")) {
|
||||||
|
await loadReleaseList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
releaseApplyVersionBtn?.addEventListener("click", async () => {
|
releaseApplyVersionBtn?.addEventListener("click", async () => {
|
||||||
if (!releaseVersionSelect || !releaseVersionSelect.value) {
|
const selected = releaseList?.querySelector("input[name='releaseVersion']:checked");
|
||||||
|
if (!selected) {
|
||||||
showToast("Select a version first", "error");
|
showToast("Select a version first", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
lastReleaseToastKey = null;
|
lastReleaseToastKey = null;
|
||||||
const ver = releaseVersionSelect.value;
|
const ver = selected.value;
|
||||||
logUi(`Install version ${ver} requested`);
|
logUi(`Install version ${ver} requested`);
|
||||||
releaseBusyActive = true;
|
releaseBusyActive = true;
|
||||||
showBusy(`Installing ${ver}…`, "Applying selected release. This can take up to a minute.");
|
showBusy(`Installing ${ver}…`, "Applying selected release. This can take up to a minute.");
|
||||||
@@ -401,7 +452,8 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
|
|||||||
|
|
||||||
releaseLogCopy?.addEventListener("click", async () => {
|
releaseLogCopy?.addEventListener("click", async () => {
|
||||||
try {
|
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) {
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ export function createStatusController({
|
|||||||
releaseUIGetter = () => null,
|
releaseUIGetter = () => null,
|
||||||
setUpdatesUI = null,
|
setUpdatesUI = null,
|
||||||
updatesFlagEl = null,
|
updatesFlagEl = null,
|
||||||
|
firstboot = null,
|
||||||
}) {
|
}) {
|
||||||
let lastStatusData = null;
|
let lastStatusData = null;
|
||||||
|
let lastFirstbootState = null;
|
||||||
|
|
||||||
function setTempFlag(tempC) {
|
function setTempFlag(tempC) {
|
||||||
if (!tempFlagTop) return;
|
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) {
|
if (data.ready) {
|
||||||
readyOverlay.classList.add("hidden");
|
readyOverlay.classList.add("hidden");
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"version": "0.1.3-dev1"
|
"version": "0.1.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,14 +80,55 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div id="readyOverlay" class="overlay hidden">
|
<div id="readyOverlay" class="overlay hidden firstboot-overlay">
|
||||||
<div class="overlay-box">
|
<div class="overlay-box firstboot-card" role="status" aria-live="polite">
|
||||||
<h3>Finishing setup</h3>
|
<div class="firstboot-header">
|
||||||
<p>
|
<h3>Finishing setup</h3>
|
||||||
This only takes a couple of minutes. You'll see the dashboard once
|
<p class="hint">This usually takes a few minutes. Please keep this tab open.</p>
|
||||||
Pi-Kit setup completes.
|
</div>
|
||||||
</p>
|
<div class="firstboot-body">
|
||||||
<div class="spinner"></div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -144,6 +185,11 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls column">
|
<div class="controls column">
|
||||||
|
<div class="release-status-bar">
|
||||||
|
<span id="releaseStatusChip" class="status-chip quiet">Status: n/a</span>
|
||||||
|
<span id="releaseChannelChip" class="status-chip quiet">Channel: n/a</span>
|
||||||
|
<span id="releaseLastCheckChip" class="status-chip quiet">Last check: —</span>
|
||||||
|
</div>
|
||||||
<div class="control-card release-versions">
|
<div class="control-card release-versions">
|
||||||
<div>
|
<div>
|
||||||
<p class="hint quiet">Current version</p>
|
<p class="hint quiet">Current version</p>
|
||||||
@@ -165,9 +211,8 @@
|
|||||||
<button id="releaseApplyBtn" title="Download and install the latest release">
|
<button id="releaseApplyBtn" title="Download and install the latest release">
|
||||||
Upgrade
|
Upgrade
|
||||||
</button>
|
</button>
|
||||||
<select id="releaseVersionSelect" class="ghost" title="Select a specific release to install"></select>
|
<button id="releaseAdvancedToggle" class="ghost" title="Open manual version picker">
|
||||||
<button id="releaseApplyVersionBtn" class="ghost" title="Install the selected release">
|
Manual selection
|
||||||
Install version
|
|
||||||
</button>
|
</button>
|
||||||
<label class="checkbox-row inline">
|
<label class="checkbox-row inline">
|
||||||
<input type="checkbox" id="releaseAutoCheck" />
|
<input type="checkbox" id="releaseAutoCheck" />
|
||||||
@@ -178,6 +223,18 @@
|
|||||||
<span>Allow dev builds</span>
|
<span>Allow dev builds</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="releaseAdvanced" class="release-advanced hidden">
|
||||||
|
<div class="release-advanced-head">
|
||||||
|
<div>
|
||||||
|
<p class="hint quiet">Choose a specific release</p>
|
||||||
|
<span class="hint">Dev builds only appear when “Allow dev builds” is on.</span>
|
||||||
|
</div>
|
||||||
|
<button id="releaseApplyVersionBtn" class="ghost" title="Install selected release">
|
||||||
|
Install selected
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="releaseList" class="release-list" role="listbox" aria-label="Available releases"></div>
|
||||||
|
</div>
|
||||||
<div id="releaseProgress" class="hint status-msg"></div>
|
<div id="releaseProgress" class="hint status-msg"></div>
|
||||||
<div class="log-card">
|
<div class="log-card">
|
||||||
<div class="log-header">
|
<div class="log-header">
|
||||||
|
|||||||
@@ -48,7 +48,8 @@
|
|||||||
Download Pi-Kit CA
|
Download Pi-Kit CA
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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>
|
<details>
|
||||||
<summary id="win">Windows</summary>
|
<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>
|
<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>
|
<script>
|
||||||
(function () {
|
(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 hasCookie = document.cookie.includes("pikit_https_ok=1");
|
||||||
const statusChip = document.getElementById("statusChip");
|
const statusChip = document.getElementById("statusChip");
|
||||||
const copyStatus = document.getElementById("copyStatus");
|
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", () => {
|
document.getElementById("continueBtn").addEventListener("click", () => {
|
||||||
window.location = target;
|
window.location = target;
|
||||||
@@ -147,6 +209,8 @@
|
|||||||
} else {
|
} else {
|
||||||
statusChip.textContent = "You’re on HTTP — trust the CA or click Go to secure dashboard.";
|
statusChip.textContent = "You’re on HTTP — trust the CA or click Go to secure dashboard.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadCaHash();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ API_PACKAGE_DIR = API_DIR / "pikit_api"
|
|||||||
BACKUP_ROOT = pathlib.Path("/var/backups/pikit")
|
BACKUP_ROOT = pathlib.Path("/var/backups/pikit")
|
||||||
TMP_ROOT = pathlib.Path("/var/tmp/pikit-update")
|
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 / unattended-upgrades
|
||||||
APT_AUTO_CFG = pathlib.Path("/etc/apt/apt.conf.d/20auto-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")
|
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 .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 .constants import CORE_NAME, CORE_PORTS, DIAG_LOG_FILE
|
||||||
from .diagnostics import _load_diag_state, _save_diag_state, dbg, diag_log, diag_read
|
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 .helpers import default_host, detect_https, normalize_path
|
||||||
from .releases import (
|
from .releases import (
|
||||||
check_for_update,
|
check_for_update,
|
||||||
@@ -48,6 +49,12 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if self.path.startswith("/api/status"):
|
if self.path.startswith("/api/status"):
|
||||||
return self._send(200, collect_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"):
|
if self.path.startswith("/api/services"):
|
||||||
return self._send(200, {"services": list_services_for_ui()})
|
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]:
|
def load_update_state() -> Dict[str, Any]:
|
||||||
UPDATE_STATE_DIR.mkdir(parents=True, exist_ok=True)
|
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():
|
if UPDATE_STATE.exists():
|
||||||
try:
|
try:
|
||||||
state = json.loads(UPDATE_STATE.read_text())
|
state = json.loads(UPDATE_STATE.read_text())
|
||||||
state.setdefault("changelog_url", None)
|
state.setdefault("changelog_url", None)
|
||||||
state.setdefault("latest_release_date", None)
|
state.setdefault("latest_release_date", None)
|
||||||
state.setdefault("current_release_date", None)
|
state.setdefault("current_release_date", None)
|
||||||
return state
|
return _reset_if_stale(state)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return {
|
return _reset_if_stale(
|
||||||
"current_version": read_current_version(),
|
{
|
||||||
"latest_version": None,
|
"current_version": read_current_version(),
|
||||||
"last_check": None,
|
"latest_version": None,
|
||||||
"status": "unknown",
|
"last_check": None,
|
||||||
"message": "",
|
"status": "unknown",
|
||||||
"auto_check": False,
|
"message": "",
|
||||||
"in_progress": False,
|
"auto_check": True,
|
||||||
"progress": None,
|
"in_progress": False,
|
||||||
"channel": os.environ.get("PIKIT_CHANNEL", "dev"),
|
"progress": None,
|
||||||
"changelog_url": None,
|
"channel": os.environ.get("PIKIT_CHANNEL", "stable"),
|
||||||
"latest_release_date": None,
|
"changelog_url": None,
|
||||||
"current_release_date": None,
|
"latest_release_date": None,
|
||||||
}
|
"current_release_date": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def save_update_state(state: Dict[str, Any]) -> None:
|
def save_update_state(state: Dict[str, Any]) -> None:
|
||||||
@@ -400,6 +433,19 @@ def fetch_text_with_auth(url: str):
|
|||||||
def acquire_lock():
|
def acquire_lock():
|
||||||
try:
|
try:
|
||||||
ensure_dir(UPDATE_LOCK.parent)
|
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")
|
lockfile = UPDATE_LOCK.open("w")
|
||||||
fcntl.flock(lockfile.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
fcntl.flock(lockfile.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
lockfile.write(str(os.getpid()))
|
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.rmtree(API_PACKAGE_DIR, ignore_errors=True)
|
||||||
shutil.copytree(staged_pkg, API_PACKAGE_DIR, dirs_exist_ok=True)
|
shutil.copytree(staged_pkg, API_PACKAGE_DIR, dirs_exist_ok=True)
|
||||||
|
|
||||||
for svc in ("pikit-api.service", "dietpi-dashboard-frontend.service"):
|
# Restart frontend to pick up new assets; avoid restarting this API process
|
||||||
subprocess.run(["systemctl", "restart", svc], check=False)
|
# 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.parent.mkdir(parents=True, exist_ok=True)
|
||||||
VERSION_FILE.write_text(str(latest))
|
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