20 Commits

Author SHA1 Message Date
Aaron
5b0cc80d0a Fix firstboot tls bundle script and prep checks 2026-01-03 17:51:11 -05:00
Aaron
452b787c30 Clean additional Pi-hole artifacts in prep 2026-01-03 17:26:29 -05:00
Aaron
808934cbec Improve prep and smoke test tooling 2026-01-03 17:20:18 -05:00
Aaron
1ddffee077 Add dns-stack profile and stable IP prompt 2026-01-03 17:17:27 -05:00
Aaron
a67b1a55d4 Update stable manifest for v0.1.4 2026-01-03 12:34:30 -05:00
Aaron
ed162d3c1d Release prep tweaks and version bump 0.1.4 2026-01-03 12:29:55 -05:00
Aaron
d07fab99a6 Add stable release checklist 2026-01-03 11:56:39 -05:00
Aaron
511978666a Add mock API server for Playwright tests 2026-01-03 11:49:41 -05:00
Aaron
453deac601 Update stable manifest for v0.1.3 2026-01-03 00:18:17 -05:00
Aaron
cb2d75387d Bump version to 0.1.3 2026-01-03 00:15:01 -05:00
Aaron
77bc4c1c36 Fix smoke test JSON parsing 2026-01-02 23:58:54 -05:00
Aaron
13e5788fe1 Fix smoke test JSON parsing and set unattended defaults 2026-01-02 23:56:48 -05:00
Aaron
24e89b516f Use local API endpoint in smoke test 2026-01-02 23:49:38 -05:00
Aaron
9c3156df35 Fix smoke test redirects and default updates to stable 2026-01-02 23:45:47 -05:00
Aaron
b01c2ba737 Add smoke test script and expand manufacturing workflow 2026-01-02 23:38:22 -05:00
Aaron
bc97e0374f Add one-time SSH hardening notice after forced password change 2026-01-02 23:29:17 -05:00
Aaron
0a23902eb0 Default to forcing password change after prep 2026-01-02 23:26:03 -05:00
Aaron
4632704092 Allow forcing password change after prep 2026-01-02 23:23:21 -05:00
Aaron
36d30da30a Make firstboot updates configurable 2026-01-02 23:07:36 -05:00
Aaron
c62f1f018f Note browser restart after CA install 2026-01-02 23:03:00 -05:00
16 changed files with 1283 additions and 55 deletions

View File

@@ -8,9 +8,12 @@ This documents the *current* workflow and the *target* workflow once profiles +
- Nginx + PiKit dashboard - Nginx + PiKit dashboard
- DietPi dashboard - DietPi dashboard
3) Update the system if needed. 3) Update the system if needed.
4) Run the prep scrub: 4) Run the prep scrub + verify (prep now prompts to shut down):
- `sudo ./pikit-prep.sh` - `sudo ./pikit-prep.sh`
- (optional) `sudo ./check-pikit-clean.sh` - (optional) `sudo ./pikit-prep.sh --no-shutdown`
- (optional) `sudo ./pikit-prep.sh --shutdown-now`
- `./pikit-smoke-test.sh`
- (optional) `sudo ./pikit-prep.sh --check-only`
5) Image the SD card with DietPi Imager. 5) Image the SD card with DietPi Imager.
6) Store it as the golden base (e.g., `images/base/pikit-base-YYYYMMDD.img.xz`). 6) Store it as the golden base (e.g., `images/base/pikit-base-YYYYMMDD.img.xz`).
@@ -24,9 +27,12 @@ This documents the *current* workflow and the *target* workflow once profiles +
4) Add dashboard services using the UI (Add Service modal). 4) Add dashboard services using the UI (Add Service modal).
5) Open any needed ports in ufw (done as part of testing/config): 5) Open any needed ports in ufw (done as part of testing/config):
- `sudo ufw allow from <LAN subnet> to any port <port>` - `sudo ufw allow from <LAN subnet> to any port <port>`
6) Run the prep scrub: 6) Run the prep scrub + verify (prep now prompts to shut down):
- `sudo ./pikit-prep.sh` - `sudo ./pikit-prep.sh`
- (optional) `sudo ./check-pikit-clean.sh` - (optional) `sudo ./pikit-prep.sh --no-shutdown`
- (optional) `sudo ./pikit-prep.sh --shutdown-now`
- `./pikit-smoke-test.sh`
- (optional) `sudo ./pikit-prep.sh --check-only`
7) Image the SD card via the QEMU DietPi VM: 7) Image the SD card via the QEMU DietPi VM:
- Insert the SD card into your desktop. - Insert the SD card into your desktop.
- Identify it with `lsblk`. - Identify it with `lsblk`.
@@ -52,8 +58,12 @@ This documents the *current* workflow and the *target* workflow once profiles +
- Merges services into `/etc/pikit/services.json` (idempotent). - Merges services into `/etc/pikit/services.json` (idempotent).
5) Run the drift check (planned script): 5) Run the drift check (planned script):
- Confirms services + ports match the profile + base. - Confirms services + ports match the profile + base.
6) Run the prep scrub: 6) Run the prep scrub + verify (prep now prompts to shut down):
- `sudo ./pikit-prep.sh` - `sudo ./pikit-prep.sh`
- (optional) `sudo ./pikit-prep.sh --no-shutdown`
- (optional) `sudo ./pikit-prep.sh --shutdown-now`
- `./pikit-smoke-test.sh`
- (optional) `sudo ./pikit-prep.sh --check-only`
7) Image the SD card with DietPi Imager. 7) Image the SD card with DietPi Imager.
First boot on the enduser device will: First boot on the enduser device will:
@@ -61,10 +71,84 @@ First boot on the enduser device will:
- Ensure the profiles firewall ports are open (LANonly). - Ensure the profiles firewall ports are open (LANonly).
- Show a progress overlay until complete. - Show a progress overlay until complete.
Optional: to skip the firstboot update step for faster startup, create
`/etc/pikit/firstboot.conf` with:
```
PIKIT_FIRSTBOOT_UPDATES=0
```
## 3) Flashing an image to SD ## 3) Flashing an image to SD
Use the helper: Use the helper:
- `sudo ./flash_sd.sh <image.img.xz> /dev/sdX` - `sudo ./flash_sd.sh <image.img.xz> /dev/sdX`
## 4) Manufacturing / imaging checklist (production)
1) Start from the golden base image (stored in `images/base/`).
2) Flash it to a knowngood SD card.
3) Boot and verify:
- `http://pikit.local` and `https://pikit.local`
- dashboard loads
- firstboot completes
4) Apply any required profile/services.
5) Run prep + verify (prep now prompts to shut down):
- `sudo ./pikit-prep.sh`
- (optional) `sudo ./pikit-prep.sh --no-shutdown`
- (optional) `sudo ./pikit-prep.sh --shutdown-now`
- `./pikit-smoke-test.sh`
6) Power down cleanly.
7) Image the SD card (DietPi Imager via QEMU or ondevice).
8) Name and archive the image:
- Base: `images/base/pikit-base-YYYYMMDD-dietpi9.20.1.img.xz`
- Profile: `images/profiles/pikit-<profile>-YYYYMMDD.img.xz`
- Testing/staging: `images/staging/pikit-<profile>-YYYYMMDD-rcN.img.xz`
9) Smoke test the flashed image on a second SD card:
- boot → firstboot → dashboard → services
## 5) Release checklist (stable)
1) Ensure `main` is clean and all changes are pushed.
2) Update `pikit-web/data/version.json` to the new version.
3) Build the web assets:
- `npm --prefix pikit-web run build`
4) Run tests:
- `npm --prefix pikit-web test`
- `./pikit-smoke-test.sh`
5) Commit version bump and push.
6) Tag the release:
- `git tag vX.Y.Z && git push origin vX.Y.Z`
7) Build release bundle + manifest:
- `./tools/release/make-release.sh X.Y.Z https://git.44r0n.cc/44r0n7/pi-kit/releases/download/vX.Y.Z`
8) Generate changelog from git:
- `git log --pretty=format:'- %s (%h)' vPREV..HEAD > out/releases/CHANGELOG-X.Y.Z.txt`
9) Create the Gitea release and upload assets:
- `pikit-X.Y.Z.tar.gz`
- `manifest.json`
- `CHANGELOG-X.Y.Z.txt`
10) Update `manifests/manifest-stable.json` with new version + sha256 and push.
## 5) Release checklist (stable)
1) Ensure `main` is clean and all changes are pushed.
2) Update `pikit-web/data/version.json` to the new version.
3) Build the web assets:
- `npm --prefix pikit-web run build`
4) Run tests:
- `npm --prefix pikit-web test`
- `./pikit-smoke-test.sh`
5) Commit version bump and push.
6) Tag the release:
- `git tag vX.Y.Z && git push origin vX.Y.Z`
7) Build release bundle + manifest:
- `./tools/release/make-release.sh X.Y.Z https://git.44r0n.cc/44r0n7/pi-kit/releases/download/vX.Y.Z`
8) Generate changelog from git:
- `git log --pretty=format:'- %s (%h)' vPREV..HEAD > out/releases/CHANGELOG-X.Y.Z.txt`
9) Create the Gitea release and upload assets:
- `pikit-X.Y.Z.tar.gz`
- `manifest.json`
- `CHANGELOG-X.Y.Z.txt`
10) Update `manifests/manifest-stable.json` with new version + sha256 and push.
## Notes ## Notes
- Profiles are additive to the base image defaults; do not include PiKit or DietPi dashboard entries in profiles. - Profiles are additive to the base image defaults; do not include PiKit or DietPi dashboard entries in profiles.
- Keep `RESCUE.md` in `/root` and `/home/dietpi` only (not in `/var/www`). - Keep `RESCUE.md` in `/root` and `/home/dietpi` only (not in `/var/www`).
- Prep enforces a password change for `dietpi` on first login; set `PIKIT_FORCE_PASSWORD_CHANGE=0` to skip.
- After the password change, a onetime SSH hardening tip is shown on login.
- End-user defaults: OS security unattended upgrades on; Pi-Kit updater auto-check on stable channel, auto-apply off (user can change in dashboard).

View File

@@ -1,9 +1,9 @@
{ {
"version": "0.1.2", "version": "0.1.4",
"_release_date": "2025-12-10T00:00:00Z", "_release_date": "2026-01-03T17:34:02Z",
"bundle": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.2/pikit-0.1.2.tar.gz", "bundle": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.4/pikit-0.1.4.tar.gz",
"changelog": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.2/CHANGELOG-0.1.2.txt", "changelog": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.4/CHANGELOG-0.1.4.txt",
"files": [ "files": [
{ "path": "bundle.tar.gz", "sha256": "8d2e0f8b260063cab0d52e862cb42f10472a643123f984af0248592479dd613d" } { "path": "bundle.tar.gz", "sha256": "9d4bc4f6eb5eaa83da28065ad664b4893095756e4584c25e7844b8098c5816ea" }
] ]
} }

View File

@@ -13,12 +13,17 @@ PIKIT_SSH_KEY="${PIKIT_SSH_KEY:-$HOME/.ssh/pikit}"
PIKIT_SSH_OPTS="${PIKIT_SSH_OPTS:-}" PIKIT_SSH_OPTS="${PIKIT_SSH_OPTS:-}"
PIKIT_REMOTE_TMP="${PIKIT_REMOTE_TMP:-/tmp/pikit-prep.sh}" PIKIT_REMOTE_TMP="${PIKIT_REMOTE_TMP:-/tmp/pikit-prep.sh}"
PIKIT_SELF_DELETE="${PIKIT_SELF_DELETE:-0}" PIKIT_SELF_DELETE="${PIKIT_SELF_DELETE:-0}"
PIKIT_FORCE_PASSWORD_CHANGE="${PIKIT_FORCE_PASSWORD_CHANGE:-1}"
PIKIT_SHUTDOWN_AFTER_PREP="${PIKIT_SHUTDOWN_AFTER_PREP:-1}"
PIKIT_SHUTDOWN_PROMPT="${PIKIT_SHUTDOWN_PROMPT:-1}"
MODE="both" MODE="both"
LOCAL_ONLY=0 LOCAL_ONLY=0
DID_PREP=0
ERRORS=0 ERRORS=0
WARNINGS=0 WARNINGS=0
STOPPED_PIHOLE_FTL=0
usage() { usage() {
cat <<'USAGE' cat <<'USAGE'
@@ -31,7 +36,14 @@ Options:
--prep-only Run prep only (no check) --prep-only Run prep only (no check)
--check-only Run checks only (no prep) --check-only Run checks only (no prep)
--local Force local execution (no SSH copy) --local Force local execution (no SSH copy)
--shutdown-now Shutdown after prep completes without prompting
--no-shutdown Skip shutdown prompt after prep
--help Show this help --help Show this help
Env:
PIKIT_FORCE_PASSWORD_CHANGE=0 Skip forcing a password change (default is on)
PIKIT_SHUTDOWN_AFTER_PREP=0 Skip shutdown prompt after prep (default on)
PIKIT_SHUTDOWN_PROMPT=0 Skip shutdown prompt (default on)
USAGE USAGE
} }
@@ -66,6 +78,8 @@ parse_args() {
--prep-only) MODE="prep" ;; --prep-only) MODE="prep" ;;
--check-only) MODE="check" ;; --check-only) MODE="check" ;;
--local) LOCAL_ONLY=1 ;; --local) LOCAL_ONLY=1 ;;
--shutdown-now) PIKIT_SHUTDOWN_AFTER_PREP=1; PIKIT_SHUTDOWN_PROMPT=0 ;;
--no-shutdown) PIKIT_SHUTDOWN_AFTER_PREP=0 ;;
--help|-h) usage; exit 0 ;; --help|-h) usage; exit 0 ;;
*) *)
echo "[FAIL] Unknown argument: $arg" >&2 echo "[FAIL] Unknown argument: $arg" >&2
@@ -82,12 +96,16 @@ run_remote() {
[ "$arg" = "--local" ] && continue [ "$arg" = "--local" ] && continue
forward+=("$arg") forward+=("$arg")
done done
local ssh_tty=()
if [ "$PIKIT_SHUTDOWN_AFTER_PREP" -eq 1 ] && [ "$PIKIT_SHUTDOWN_PROMPT" -eq 1 ] && [ -t 0 ]; then
ssh_tty=(-t)
fi
if ! command -v scp >/dev/null 2>&1 || ! command -v ssh >/dev/null 2>&1; then 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 echo "[FAIL] ssh/scp not available for remote prep" >&2
exit 1 exit 1
fi fi
scp -i "$PIKIT_SSH_KEY" $PIKIT_SSH_OPTS "$SCRIPT_PATH" "${PIKIT_USER}@${PIKIT_HOST}:${PIKIT_REMOTE_TMP}" 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}" \ ssh "${ssh_tty[@]}" -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" "sudo PIKIT_SELF_DELETE=1 bash ${PIKIT_REMOTE_TMP} --local ${forward[*]}; rc=\$?; rm -f ${PIKIT_REMOTE_TMP}; exit \$rc"
exit $? exit $?
} }
@@ -180,6 +198,40 @@ clean_home_dir() {
fi fi
} }
reset_iface_to_dhcp() {
local iface="$1"
local file="/etc/network/interfaces"
if [ ! -f "$file" ]; then
status SKIP "network config missing: $file"
return
fi
if ! grep -Eq "^[[:space:]]*iface[[:space:]]+$iface[[:space:]]+inet" "$file"; then
status SKIP "no iface config for $iface"
return
fi
local tmp
tmp="$(mktemp)"
awk -v target="$iface" '
BEGIN{in_iface=0}
/^[[:space:]]*iface[[:space:]]+/ {
split($0, parts, /[[:space:]]+/);
if (parts[2]==target) { in_iface=1; print "iface " target " inet dhcp"; next; }
else { in_iface=0; }
}
{
if (in_iface==1) {
if ($1=="address"||$1=="netmask"||$1=="gateway"||$1=="dns-nameservers") next;
}
print;
}' "$file" > "$tmp"
if mv "$tmp" "$file"; then
status CLEANED "forced DHCP for $iface in $file"
else
rm -f "$tmp" || true
status FAIL "update $file for $iface"
fi
}
prep_image() { prep_image() {
section "Prep" section "Prep"
@@ -206,6 +258,16 @@ prep_image() {
# --- Default login --- # --- Default login ---
if id -u dietpi >/dev/null 2>&1; then if id -u dietpi >/dev/null 2>&1; then
echo "dietpi:pikit" | chpasswd && status CLEANED "reset dietpi password" || status FAIL "reset dietpi password" 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 else
status SKIP "dietpi user missing" status SKIP "dietpi user missing"
fi fi
@@ -236,6 +298,8 @@ prep_image() {
clean_file /etc/pikit/certs/pikit.local.csr 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.crt
clean_file /var/www/pikit-web/assets/pikit-ca.sha256 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
# --- Backup/editor cruft --- # --- Backup/editor cruft ---
clean_backups /var/www/pikit-web clean_backups /var/www/pikit-web
@@ -258,9 +322,22 @@ prep_image() {
fi fi
if command -v pihole >/dev/null 2>&1; then if command -v pihole >/dev/null 2>&1; then
pihole -f >/dev/null 2>&1 && status CLEANED "pihole logs via pihole -f" || status FAIL "pihole -f" if command -v systemctl >/dev/null 2>&1; then
if systemctl stop pihole-FTL >/dev/null 2>&1; then
status CLEANED "stopped pihole-FTL"
STOPPED_PIHOLE_FTL=1
else
status WARN "unable to stop pihole-FTL"
fi
fi
clean_logs_dir /var/log/pihole '*' clean_logs_dir /var/log/pihole '*'
clean_file /etc/pihole/pihole-FTL.db clean_file /etc/pihole/pihole-FTL.db
clean_file /etc/pihole/pihole-FTL.db-wal
clean_file /etc/pihole/pihole-FTL.db-shm
clean_file /etc/pihole/dhcp.leases
clean_file /etc/pihole/install.log
clean_file /etc/pihole/gravity_old.db
truncate_file /var/log/pihole-FTL.log
fi fi
if [ -x /opt/AdGuardHome/AdGuardHome ]; then if [ -x /opt/AdGuardHome/AdGuardHome ]; then
@@ -389,6 +466,10 @@ prep_image() {
# --- DHCP leases --- # --- DHCP leases ---
clean_file /var/lib/dhcp/dhclient.eth0.leases clean_file /var/lib/dhcp/dhclient.eth0.leases
# --- Network config ---
reset_iface_to_dhcp eth0
reset_iface_to_dhcp wlan0
# --- Nginx caches --- # --- Nginx caches ---
if [ -d /var/lib/nginx ]; then if [ -d /var/lib/nginx ]; then
find /var/lib/nginx -mindepth 1 -maxdepth 1 -type d -exec rm -rf {} + 2>/dev/null find /var/lib/nginx -mindepth 1 -maxdepth 1 -type d -exec rm -rf {} + 2>/dev/null
@@ -457,6 +538,20 @@ check_file_empty_or_missing() {
fi fi
} }
check_iface_dhcp() {
local iface="$1"
local file="/etc/network/interfaces"
if [ ! -f "$file" ]; then
status WARN "network config missing: $file"
return
fi
if grep -Eq "^[[:space:]]*iface[[:space:]]+$iface[[:space:]]+inet[[:space:]]+static" "$file"; then
status WARN "$iface set to static in $file"
else
status OK "$iface not static in $file"
fi
}
check_image() { check_image() {
section "Check" section "Check"
@@ -517,15 +612,27 @@ check_image() {
check_file_missing /etc/pikit/certs/pikit.local.key 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.crt
check_file_missing /var/www/pikit-web/assets/pikit-ca.sha256 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" section "Logs"
if [ -d /var/log ]; then if [ -d /var/log ]; then
local nonempty local nonempty filtered
nonempty="$(find /var/log -type f -size +0c 2>/dev/null | wc -l | tr -d ' ')" nonempty="$(find /var/log -type f -size +0c 2>/dev/null)"
if [ "$nonempty" -gt 0 ]; then filtered="$(printf "%s\n" "$nonempty" | grep -Ev '/(lastlog|faillog|btmp|wtmp)$' || true)"
status WARN "/var/log has non-empty files: $nonempty" if [ -n "$filtered" ]; then
local count
count="$(printf "%s\n" "$filtered" | wc -l | tr -d ' ')"
status WARN "/var/log has non-empty files: $count"
printf "%s\n" "$filtered" | head -n 5 | sed 's/^/[WARN] /'
else else
status OK "/var/log empty" if [ -n "$nonempty" ]; then
local count
count="$(printf "%s\n" "$nonempty" | wc -l | tr -d ' ')"
status WARN "/var/log has only login tracking files: $count"
else
status OK "/var/log empty"
fi
fi fi
else else
status WARN "/var/log missing" status WARN "/var/log missing"
@@ -549,6 +656,14 @@ check_image() {
status FAIL "DietPi RAMlog store missing" status FAIL "DietPi RAMlog store missing"
fi fi
section "Pi-hole state"
check_file_missing /etc/pihole/pihole-FTL.db
check_file_missing /etc/pihole/pihole-FTL.db-wal
check_file_missing /etc/pihole/pihole-FTL.db-shm
check_file_missing /etc/pihole/dhcp.leases
check_file_missing /etc/pihole/install.log
check_file_missing /etc/pihole/gravity_old.db
section "Caches" section "Caches"
du -sh /var/cache/apt /var/lib/apt/lists /var/cache/debconf 2>/dev/null || true du -sh /var/cache/apt /var/lib/apt/lists /var/cache/debconf 2>/dev/null || true
@@ -564,6 +679,10 @@ check_image() {
section "DHCP lease" section "DHCP lease"
check_file_missing /var/lib/dhcp/dhclient.eth0.leases check_file_missing /var/lib/dhcp/dhclient.eth0.leases
section "Network config"
check_iface_dhcp eth0
check_iface_dhcp wlan0
section "Nginx cache dirs" section "Nginx cache dirs"
if [ -d /var/lib/nginx ]; then if [ -d /var/lib/nginx ]; then
local nginx_cache local nginx_cache
@@ -589,6 +708,40 @@ finalize() {
echo "[OK] Prep/check completed." echo "[OK] Prep/check completed."
} }
maybe_shutdown() {
if [ "$PIKIT_SHUTDOWN_AFTER_PREP" -ne 1 ] || [ "$DID_PREP" -ne 1 ]; then
return
fi
local do_shutdown=1
if [ "$PIKIT_SHUTDOWN_PROMPT" -eq 1 ]; then
if [ -t 0 ]; then
local reply=""
printf '\nShutdown now? [y/N] '
read -r reply || reply=""
case "${reply,,}" in
y|yes) do_shutdown=1 ;;
*) do_shutdown=0 ;;
esac
else
status WARN "no TTY; skipping shutdown (use --shutdown-now to force)"
do_shutdown=0
fi
fi
if [ "$do_shutdown" -eq 1 ]; then
status OK "Shutting down"
shutdown -f now || status FAIL "shutdown"
else
if [ "$STOPPED_PIHOLE_FTL" -eq 1 ] && command -v systemctl >/dev/null 2>&1; then
if systemctl start pihole-FTL >/dev/null 2>&1; then
status OK "restarted pihole-FTL (shutdown skipped)"
else
status WARN "failed to restart pihole-FTL after prep"
fi
fi
status OK "Shutdown skipped"
fi
}
maybe_self_delete() { maybe_self_delete() {
if [ "$PIKIT_SELF_DELETE" -eq 1 ] && [[ "$SCRIPT_PATH" == /tmp/* ]]; then if [ "$PIKIT_SELF_DELETE" -eq 1 ] && [[ "$SCRIPT_PATH" == /tmp/* ]]; then
rm -f "$SCRIPT_PATH" || true rm -f "$SCRIPT_PATH" || true
@@ -605,15 +758,17 @@ main() {
require_root require_root
case "$MODE" in case "$MODE" in
prep) prep_image ;; prep) prep_image; DID_PREP=1 ;;
check) check_image ;; check) check_image ;;
both) both)
prep_image prep_image
DID_PREP=1
check_image check_image
;; ;;
esac esac
finalize finalize
maybe_shutdown
maybe_self_delete maybe_self_delete
} }

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

@@ -0,0 +1,491 @@
#!/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)
- profile-specific checks (if /etc/pikit/profile.json exists)
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
}
remote_sudo_cmd() {
remote_cmd "sudo bash -c \"$1\""
}
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
local done_present error_file_present log_present state_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
done_present="$(remote_sudo_cmd "test -f /var/lib/pikit/firstboot/firstboot.done && echo yes || echo no" 2>/dev/null || true)"
error_file_present="$(remote_sudo_cmd "test -f /var/lib/pikit/firstboot/firstboot.error && echo yes || echo no" 2>/dev/null || true)"
log_present="$(remote_sudo_cmd "test -f /var/lib/pikit/firstboot/firstboot.log && echo yes || echo no" 2>/dev/null || true)"
state_present="$(remote_sudo_cmd "test -f /var/lib/pikit/firstboot/state.json && echo yes || echo no" 2>/dev/null || true)"
if [ "$state" = "done" ] && [ "$error_present" != "true" ]; then
status OK "firstboot completed"
return
fi
if [ "$state" = "error" ] || [ "$error_present" = "true" ] || [ "$error_file_present" = "yes" ]; then
status FAIL "firstboot failed (state=$state error=$error_present)"
return
fi
if [ "$done_present" = "yes" ]; then
status FAIL "firstboot state mismatch (done file present but state=$state)"
return
fi
if [ "$log_present" != "yes" ] && [ "$state_present" != "yes" ]; then
status WARN "firstboot not started yet (image prepped?)"
else
status WARN "firstboot in progress (state=$state)"
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
}
load_profile() {
local body
body="$(remote_cmd "python3 -c 'import json, pathlib, sys; p=pathlib.Path(\"/etc/pikit/profile.json\"); print(json.dumps(json.loads(p.read_text()))) if p.exists() else sys.exit(0)'" 2>/dev/null || true)"
body="$(printf "%s" "$body" | extract_json_line)"
if [ -n "$body" ]; then
printf "%s" "$body"
return 0
fi
return 1
}
json_get_list() {
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:
sys.exit(1)
val=data.get(key, [])
if not isinstance(val, list):
sys.exit(0)
for item in val:
print(json.dumps(item))
' "$key"
elif command -v jq >/dev/null 2>&1; then
jq -c --arg key "$key" '.[$key] // [] | .[]'
fi
}
check_profile_firewall() {
local firewall_enable="$1"
local ports_csv="$2"
local ufw_out
if [ "$firewall_enable" != "true" ]; then
status OK "profile firewall not required"
return
fi
if ! remote_cmd "test -x /usr/sbin/ufw || test -x /usr/bin/ufw"; then
status FAIL "ufw missing (profile expects firewall enabled)"
return
fi
ufw_out="$(remote_sudo_cmd "timeout 6 /usr/sbin/ufw status 2>/dev/null || /usr/bin/ufw status 2>/dev/null || ufw status 2>/dev/null" 2>/dev/null || true)"
if ! printf "%s" "$ufw_out" | grep -qi "Status: active"; then
status FAIL "ufw not active"
return
fi
status OK "ufw active"
if [ -n "$ports_csv" ]; then
IFS=',' read -r -a ports <<<"$ports_csv"
for port in "${ports[@]}"; do
if printf "%s" "$ufw_out" | grep -Eq "(^|[[:space:]])${port}(/|[[:space:]])"; then
status OK "ufw allows port $port"
else
status WARN "ufw rule missing for port $port"
fi
done
fi
}
check_profile_services() {
local services_json="$1"
local profile_services="$2"
if [ -z "$profile_services" ]; then
status OK "profile services: none"
return
fi
if [ -z "$services_json" ]; then
status FAIL "services.json missing (profile services expected)"
return
fi
if command -v python3 >/dev/null 2>&1; then
local result
result="$(SERVICES_JSON="$services_json" PROFILE_SERVICES="$profile_services" python3 - <<'PY'
import json, os, re
services_text = os.environ.get("SERVICES_JSON", "")
profile_text = os.environ.get("PROFILE_SERVICES", "")
try:
services_data = json.loads(services_text or "[]")
except Exception:
services_data = []
profile_lines = [line for line in profile_text.splitlines() if line.strip()]
services = [s for s in services_data if isinstance(s, dict)]
def norm(x):
return re.sub(r"\s+", " ", str(x or "")).strip().lower()
missing = []
for line in profile_lines:
try:
psvc = json.loads(line)
except Exception:
continue
name = norm(psvc.get("name"))
port = str(psvc.get("port") or "")
path = str(psvc.get("path") or "")
found = False
for svc in services:
sname = norm(svc.get("name"))
sport = str(svc.get("port") or "")
spath = str(svc.get("path") or "")
if name and sname == name:
found = True
if port and sport != port:
print(f"WARN: service {name} port mismatch ({sport} != {port})")
if path and spath != path:
print(f"WARN: service {name} path mismatch ({spath} != {path})")
break
if port and sport == port and path and spath == path:
found = True
break
if not found:
missing.append(name or f"port {port}")
if missing:
print("MISSING:" + ",".join(missing))
PY
)"
if [ -z "$result" ]; then
status OK "profile services registered"
return
fi
if echo "$result" | grep -q "^MISSING:"; then
status FAIL "profile services missing: ${result#MISSING:}"
fi
echo "$result" | grep "^WARN:" | while read -r line; do
status WARN "${line#WARN: }"
done
else
status WARN "python3 missing; profile service checks skipped"
fi
}
check_dns_stack_profile() {
section "Profile: dns-stack"
if remote_cmd "systemctl is-active --quiet pihole-FTL"; then
status OK "pihole-FTL active"
else
status FAIL "pihole-FTL not active"
fi
if remote_cmd "systemctl is-active --quiet unbound"; then
status OK "unbound active"
else
status FAIL "unbound not active"
fi
if remote_cmd "ss -lnt | grep -Eq ':53[[:space:]]'"; then
status OK "DNS port 53 listening"
else
status FAIL "DNS port 53 not listening"
fi
if remote_cmd "ss -lnt | grep -q '127.0.0.1:5335'"; then
status OK "Unbound 5335 listening on loopback"
else
status WARN "Unbound 5335 not bound to loopback"
fi
if remote_cmd "sudo grep -q '127.0.0.1#5335' /etc/pihole/pihole.toml"; then
status OK "Pi-hole upstream points to Unbound"
else
status FAIL "Pi-hole upstream not set to 127.0.0.1#5335"
fi
if remote_cmd "sudo grep -q 'interface: 127.0.0.1' /etc/unbound/unbound.conf.d/dietpi.conf"; then
status OK "Unbound listens on 127.0.0.1"
else
status WARN "Unbound interface not 127.0.0.1"
fi
if remote_cmd "sudo grep -q 'port: 5335' /etc/unbound/unbound.conf.d/dietpi.conf"; then
status OK "Unbound port 5335 configured"
else
status WARN "Unbound port 5335 not configured"
fi
if remote_sudo_cmd "test -f /etc/pihole/tls.pem && test -f /etc/pikit/certs/pikit.local.crt"; then
local fp_pikit fp_pihole
fp_pikit="$(remote_sudo_cmd "openssl x509 -in /etc/pikit/certs/pikit.local.crt -noout -fingerprint -sha256 | cut -d= -f2" 2>/dev/null || true)"
fp_pihole="$(remote_sudo_cmd "openssl x509 -in /etc/pihole/tls.pem -noout -fingerprint -sha256 | cut -d= -f2" 2>/dev/null || true)"
if [ -n "$fp_pikit" ] && [ "$fp_pikit" = "$fp_pihole" ]; then
status OK "Pi-hole TLS matches Pi-Kit cert"
else
status WARN "Pi-hole TLS does not match Pi-Kit cert"
fi
else
status WARN "Pi-hole TLS bundle missing"
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
section "Profile"
local profile_json
profile_json="$(load_profile || true)"
if [ -z "$profile_json" ]; then
status OK "profile not present; skipping profile checks"
finalize
exit 0
fi
local profile_id firewall_enable ports_csv
profile_id="$(printf "%s" "$profile_json" | json_get "id" || true)"
firewall_enable="$(printf "%s" "$profile_json" | json_get "firewall_enable" || true)"
ports_csv="$(printf "%s" "$profile_json" | python3 - <<'PY' 2>/dev/null || true
import json, sys
try:
data=json.load(sys.stdin)
except Exception:
sys.exit(0)
ports=data.get("firewall_ports") or []
print(",".join(str(p) for p in ports))
PY
)"
status OK "profile detected: ${profile_id:-unknown}"
check_profile_firewall "$firewall_enable" "$ports_csv"
local services_json profile_services_output
services_json="$(remote_sudo_cmd "cat /etc/pikit/services.json" 2>/dev/null || true)"
profile_services_output="$(printf "%s" "$profile_json" | json_get_list "services" || true)"
check_profile_services "$services_json" "$profile_services_output"
if [ "$profile_id" = "dns-stack" ]; then
check_dns_stack_profile
else
status OK "profile-specific checks skipped"
fi
finalize
}
main "$@"

View File

@@ -110,6 +110,13 @@ const firstbootErrorClose = document.getElementById("firstbootErrorClose");
const firstbootCopyError = document.getElementById("firstbootCopyError"); const firstbootCopyError = document.getElementById("firstbootCopyError");
const firstbootShowRecovery = document.getElementById("firstbootShowRecovery"); const firstbootShowRecovery = document.getElementById("firstbootShowRecovery");
const firstbootRecovery = document.getElementById("firstbootRecovery"); const firstbootRecovery = document.getElementById("firstbootRecovery");
const networkModal = document.getElementById("networkModal");
const networkClose = document.getElementById("networkClose");
const networkReserveBtn = document.getElementById("networkReserveBtn");
const networkStaticBtn = document.getElementById("networkStaticBtn");
const networkLaterBtn = document.getElementById("networkLaterBtn");
const networkHelpBtn = document.getElementById("networkHelpBtn");
const networkProfileHint = document.getElementById("networkProfileHint");
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");
@@ -172,6 +179,58 @@ const firstbootUI = createFirstbootUI({
showToast, showToast,
}); });
const networkState = {
shown: false,
profile: null,
};
function networkKey(profile) {
const id = profile?.id || profile?.name || "profile";
return `pikit:network-setup:${id}`;
}
function markNetworkHandled(profile, message) {
if (!profile) return;
try {
localStorage.setItem(networkKey(profile), "done");
} catch (err) {
// ignore storage failures
}
if (networkModal) networkModal.classList.add("hidden");
networkState.shown = false;
if (message) showToast?.(message, "success");
}
function openNetworkModal(profile, force = false) {
if (!networkModal) return;
if (!profile?.requires_stable_ip && !force) return;
if (networkProfileHint) {
const name = profile?.name ? `${profile.name} profile` : "This profile";
networkProfileHint.textContent = `${name} needs a stable IP address so your devices can always reach DNS.`;
}
networkModal.classList.remove("hidden");
networkState.shown = true;
networkState.profile = profile;
}
function shouldShowNetworkPrompt(firstbootData) {
if (!firstbootData || firstbootData.state !== "done") return false;
const profile = firstbootData.profile || null;
if (!profile?.requires_stable_ip) return false;
if (networkState.shown) return false;
try {
return localStorage.getItem(networkKey(profile)) !== "done";
} catch (err) {
return true;
}
}
function handleFirstbootStatus(firstbootData) {
if (shouldShowNetworkPrompt(firstbootData)) {
openNetworkModal(firstbootData.profile);
}
}
const statusController = createStatusController({ const statusController = createStatusController({
heroStats, heroStats,
servicesGrid, servicesGrid,
@@ -191,6 +250,7 @@ const statusController = createStatusController({
getError: getFirstbootError, getError: getFirstbootError,
ui: firstbootUI, ui: firstbootUI,
}, },
onFirstbootStatus: handleFirstbootStatus,
}); });
const { loadStatus } = statusController; const { loadStatus } = statusController;
@@ -220,6 +280,30 @@ function wireDialogs() {
addServiceModal?.addEventListener("click", (e) => { addServiceModal?.addEventListener("click", (e) => {
if (e.target === addServiceModal) addServiceModal.classList.add("hidden"); if (e.target === addServiceModal) addServiceModal.classList.add("hidden");
}); });
networkModal?.addEventListener("click", (e) => {
if (e.target === networkModal) {
markNetworkHandled(networkState.profile, "Network setup saved");
}
});
}
function wireNetworkSetup() {
networkClose?.addEventListener("click", () => {
markNetworkHandled(networkState.profile, "Network setup saved");
});
networkReserveBtn?.addEventListener("click", () => {
markNetworkHandled(networkState.profile, "Router reservation saved");
});
networkStaticBtn?.addEventListener("click", () => {
markNetworkHandled(networkState.profile, "Static IP reminder saved");
});
networkLaterBtn?.addEventListener("click", () => {
markNetworkHandled(networkState.profile, "Network setup saved");
});
networkHelpBtn?.addEventListener("click", () => {
if (helpModal) helpModal.classList.add("hidden");
openNetworkModal(networkState.profile || { requires_stable_ip: true }, true);
});
} }
// Testing hook // Testing hook
@@ -348,6 +432,7 @@ function main() {
window.__pikitTest.exposeServiceForm?.(); window.__pikitTest.exposeServiceForm?.();
} }
wireDialogs(); wireDialogs();
wireNetworkSetup();
wireResetAndUpdates(); wireResetAndUpdates();
wireAccordions({ wireAccordions({
forceOpen: typeof window !== "undefined" && window.__pikitTest?.forceAccordionsOpen, forceOpen: typeof window !== "undefined" && window.__pikitTest?.forceAccordionsOpen,

View File

@@ -18,6 +18,7 @@ export function createStatusController({
setUpdatesUI = null, setUpdatesUI = null,
updatesFlagEl = null, updatesFlagEl = null,
firstboot = null, firstboot = null,
onFirstbootStatus = null,
}) { }) {
let lastStatusData = null; let lastStatusData = null;
let lastFirstbootState = null; let lastFirstbootState = null;
@@ -88,6 +89,7 @@ export function createStatusController({
firstbootData = await firstboot.getStatus(); firstbootData = await firstboot.getStatus();
lastFirstbootState = firstbootData?.state || lastFirstbootState; lastFirstbootState = firstbootData?.state || lastFirstbootState;
firstboot.ui.update(firstbootData); firstboot.ui.update(firstbootData);
onFirstbootStatus?.(firstbootData);
if (firstbootData?.state === "error" && firstboot.getError) { if (firstbootData?.state === "error" && firstboot.getError) {
const err = await firstboot.getError(); const err = await firstboot.getError();
if (err?.present) { if (err?.present) {

View File

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

View File

@@ -132,6 +132,42 @@
</div> </div>
</div> </div>
<div id="networkModal" class="modal hidden">
<div class="modal-card wide">
<div class="panel-header">
<div>
<p class="eyebrow">Network setup</p>
<h3>Keep DNS reliable</h3>
<p class="hint" id="networkProfileHint">
This profile needs a stable IP address so your devices can always reach DNS.
</p>
</div>
<button id="networkClose" class="ghost icon-btn close-btn" title="Close network setup">
&times;
</button>
</div>
<div class="help-body">
<p>
The easiest option is a router reservation. It keeps the Pi on the same IP without
changing anything on the Pi itself.
</p>
<ol>
<li>Open your routers admin page (often <code>http://192.168.0.1</code> or <code>http://10.0.0.1</code>).</li>
<li>Find <strong>DHCP reservations</strong> or <strong>Static leases</strong>.</li>
<li>Reserve the Pis current IP and MAC address.</li>
</ol>
<div class="control-actions wrap gap">
<button id="networkReserveBtn">I set a router reservation</button>
<button id="networkStaticBtn" class="ghost">Ill set a static IP on the Pi</button>
<button id="networkLaterBtn" class="ghost">Ill do this later</button>
</div>
<p class="hint">
You can reopen this from <strong>Help → Network setup</strong> at any time.
</p>
</div>
</div>
</div>
<div id="changelogModal" class="modal hidden"> <div id="changelogModal" class="modal hidden">
<div class="modal-card wide"> <div class="modal-card wide">
<div class="panel-header sticky"> <div class="panel-header sticky">
@@ -726,6 +762,14 @@
<li>Factory reset reverts passwords and firewall rules and reboots. Use only when you need a clean slate.</li> <li>Factory reset reverts passwords and firewall rules and reboots. Use only when you need a clean slate.</li>
</ul> </ul>
<h4>Network setup (DNS profiles)</h4>
<ul>
<li>DNS profiles work best with a stable IP (router reservation or static IP).</li>
<li>
<button id="networkHelpBtn" class="ghost">Open network setup</button>
</li>
</ul>
<h4>Troubleshooting</h4> <h4>Troubleshooting</h4>
<ul> <ul>
<li>Service link fails? Make sure the service runs, port/path are right, and edit via the “…” menu.</li> <li>Service link fails? Make sure the service runs, port/path are right, and edit via the “…” menu.</li>

View File

@@ -48,6 +48,7 @@
Download Pi-Kit CA Download Pi-Kit CA
</a> </a>
</div> </div>
<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> <p class="checksum">SHA256: <code id="caHash" class="inline">Loading...</code></p>
<details> <details>
<summary id="win">Windows</summary> <summary id="win">Windows</summary>

View File

@@ -17,7 +17,7 @@ export default defineConfig({
trace: 'retain-on-failure', trace: 'retain-on-failure',
}, },
webServer: { webServer: {
command: 'npm run dev', command: 'node tests/mock-api.js & npm run dev',
url: BASE_URL, url: BASE_URL,
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
stdout: 'pipe', stdout: 'pipe',

View File

@@ -0,0 +1,85 @@
import http from 'http';
const port = Number(process.env.PIKIT_MOCK_API_PORT || 4000);
const updatesConfig = {
enabled: false,
scope: 'security',
cleanup: false,
bandwidth_limit_kbps: null,
auto_reboot: false,
reboot_time: '04:30',
reboot_with_users: false,
update_time: '04:00',
upgrade_time: '04:30',
state: { enabled: false },
};
const routes = {
'/health': { ok: true },
'/api/status': {
hostname: 'pikit-test',
uptime_seconds: 0,
load: [0, 0, 0],
memory_mb: { total: 1024, free: 1024 },
disk_mb: { total: 1024, free: 1024 },
cpu_temp_c: 35.0,
lan_ip: '127.0.0.1',
os_version: 'DietPi',
auto_updates_enabled: false,
auto_updates: { enabled: false },
updates_config: updatesConfig,
reboot_required: false,
ready: true,
services: [],
},
'/api/firstboot': {
state: 'done',
steps: [],
current_step: null,
log_tail: '',
error_present: false,
error_path: '/api/firstboot/error',
ca_hash: null,
ca_url: '/assets/pikit-ca.crt',
},
'/api/firstboot/error': { present: false, text: '' },
'/api/services': { services: [] },
'/api/updates/config': updatesConfig,
'/api/updates/auto': { enabled: false, details: { enabled: false } },
'/api/update/status': {
status: 'up_to_date',
current_version: '0.0.0',
latest_version: '0.0.0',
message: 'Mocked',
channel: 'stable',
in_progress: false,
},
'/api/update/releases': { releases: [] },
'/api/diag/log': { entries: [], state: { enabled: false, level: 'normal' } },
};
const server = http.createServer((req, res) => {
const url = (req.url || '/').split('?')[0];
const body = routes[url] || (url.startsWith('/api/') ? {} : null);
if (body === null) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'not found' }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
});
server.listen(port, '127.0.0.1', () => {
console.log(`[mock-api] listening on 127.0.0.1:${port}`);
});
const shutdown = () => {
server.close(() => process.exit(0));
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

View File

@@ -3,6 +3,8 @@ import pathlib
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from .constants import FIRSTBOOT_DIR, FIRSTBOOT_DONE, FIRSTBOOT_ERROR, FIRSTBOOT_LOG, FIRSTBOOT_STATE, WEB_ROOT from .constants import FIRSTBOOT_DIR, FIRSTBOOT_DONE, FIRSTBOOT_ERROR, FIRSTBOOT_LOG, FIRSTBOOT_STATE, WEB_ROOT
PROFILE_FILE = pathlib.Path("/etc/pikit/profile.json")
from .helpers import ensure_dir, sha256_file from .helpers import ensure_dir, sha256_file
DEFAULT_STEPS = [ DEFAULT_STEPS = [
@@ -82,6 +84,18 @@ def read_firstboot_status() -> Dict[str, Any]:
ca_path = WEB_ROOT / "assets" / "pikit-ca.crt" ca_path = WEB_ROOT / "assets" / "pikit-ca.crt"
ca_hash = sha256_file(ca_path) if ca_path.exists() else None ca_hash = sha256_file(ca_path) if ca_path.exists() else None
profile_summary: Dict[str, Any] = {}
if PROFILE_FILE.exists():
try:
data = json.loads(PROFILE_FILE.read_text())
profile_summary = {
"id": data.get("id"),
"name": data.get("name"),
"requires_stable_ip": bool(data.get("requires_stable_ip", False)),
}
except Exception:
profile_summary = {}
return { return {
"state": state, "state": state,
"steps": steps, "steps": steps,
@@ -91,6 +105,7 @@ def read_firstboot_status() -> Dict[str, Any]:
"error_path": "/api/firstboot/error", "error_path": "/api/firstboot/error",
"ca_hash": ca_hash, "ca_hash": ca_hash,
"ca_url": "/assets/pikit-ca.crt", "ca_url": "/assets/pikit-ca.crt",
"profile": profile_summary,
} }

View File

@@ -90,10 +90,10 @@ def load_update_state() -> Dict[str, Any]:
"last_check": None, "last_check": None,
"status": "unknown", "status": "unknown",
"message": "", "message": "",
"auto_check": False, "auto_check": True,
"in_progress": False, "in_progress": False,
"progress": None, "progress": None,
"channel": os.environ.get("PIKIT_CHANNEL", "dev"), "channel": os.environ.get("PIKIT_CHANNEL", "stable"),
"changelog_url": None, "changelog_url": None,
"latest_release_date": None, "latest_release_date": None,
"current_release_date": None, "current_release_date": None,

View File

@@ -0,0 +1,28 @@
{
"id": "dns-stack",
"name": "DNS Stack",
"requires_stable_ip": true,
"firewall_ports": [53, 8089, 8489],
"firewall_enable": true,
"services": [
{ "name": "Pi-hole", "port": 8489, "scheme": "https", "path": "/admin/" }
],
"actions": [
{
"type": "tls_bundle",
"source_cert": "/etc/pikit/certs/pikit.local.crt",
"source_key": "/etc/pikit/certs/pikit.local.key",
"dest": "/etc/pihole/tls.pem",
"owner": "root:pihole",
"mode": "640",
"restart": "pihole-FTL"
},
{
"type": "replace_text",
"file": "/etc/pihole/pihole.toml",
"match": "domain = \"pi.hole\"",
"replace": "domain = \"pikit.local\"",
"restart": "pihole-FTL"
}
]
}

View File

@@ -0,0 +1,26 @@
#!/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"
DONE_FILE=".pikit-first-login.done"
case "$-" in
*i*) interactive=1 ;;
*) interactive=0 ;;
esac
USER_NAME="$(id -un 2>/dev/null || echo "")"
DONE_PATH="${HOME:-}/$DONE_FILE"
if [ "$interactive" -eq 1 ] && [ -f "$FLAG" ] && [ "$USER_NAME" = "dietpi" ]; then
if [ -n "${HOME:-}" ] && [ -d "${HOME:-}" ] && [ ! -f "$DONE_PATH" ]; then
echo ""
echo "Pi-Kit: For better security, set up an SSH key and disable password auth once working."
echo " Run these from your computer (not the Pi):"
echo " ssh-keygen -t ed25519"
echo " ssh-copy-id dietpi@pikit.local"
echo ""
:> "$DONE_PATH" 2>/dev/null || true
fi
fi

View File

@@ -14,6 +14,9 @@ CERT_DIR="/etc/pikit/certs"
WEB_ASSETS="/var/www/pikit-web/assets" WEB_ASSETS="/var/www/pikit-web/assets"
PROFILE_FILE="/etc/pikit/profile.json" PROFILE_FILE="/etc/pikit/profile.json"
MOTD_FILE="/etc/motd" MOTD_FILE="/etc/motd"
FIRSTBOOT_CONF="/etc/pikit/firstboot.conf"
APT_UA_OVERRIDE="/etc/apt/apt.conf.d/51pikit-unattended.conf"
SERVICE_JSON="/etc/pikit/services.json"
STEPS=( STEPS=(
"Preparing system" "Preparing system"
@@ -26,11 +29,241 @@ STEPS=(
STEP_STATUS=(pending pending pending pending pending pending) STEP_STATUS=(pending pending pending pending pending pending)
CURRENT_STEP="" CURRENT_STEP=""
CURRENT_INDEX=-1 CURRENT_INDEX=-1
PIKIT_FIRSTBOOT_UPDATES="${PIKIT_FIRSTBOOT_UPDATES:-1}"
log() { log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$*" printf '[%s] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$*"
} }
load_config() {
if [ -f "$FIRSTBOOT_CONF" ]; then
# shellcheck disable=SC1090
. "$FIRSTBOOT_CONF"
fi
PIKIT_FIRSTBOOT_UPDATES="${PIKIT_FIRSTBOOT_UPDATES:-1}"
}
skip_updates() {
case "${PIKIT_FIRSTBOOT_UPDATES,,}" in
0|false|no|off) return 0 ;;
esac
return 1
}
configure_unattended_defaults() {
if [ -f "$APT_UA_OVERRIDE" ]; then
log "Unattended-upgrades config already present; skipping defaults."
return
fi
if ! command -v python3 >/dev/null 2>&1; then
log "python3 missing; skipping unattended-upgrades defaults."
return
fi
PROFILE_FILE="$PROFILE_FILE" SERVICE_JSON="$SERVICE_JSON" PYTHONPATH=/usr/local/bin python3 - <<'PY'
import sys
try:
from pikit_api.auto_updates import set_updates_config
except Exception as e:
print(f"pikit_api unavailable: {e}")
sys.exit(0)
set_updates_config({"enable": True, "scope": "security"})
PY
log "Unattended-upgrades defaults applied (security-only)."
}
apply_profile() {
if [ ! -f "$PROFILE_FILE" ]; then
log "Profile step skipped (no profile.json)."
return
fi
PYTHONPATH=/usr/local/bin python3 - <<'PY'
import json
import os
import pathlib
import pwd
import grp
import shutil
import subprocess
profile_path = pathlib.Path(os.environ.get("PROFILE_FILE", "/etc/pikit/profile.json"))
services_path = pathlib.Path(os.environ.get("SERVICE_JSON", "/etc/pikit/services.json"))
def log(msg: str) -> None:
print(msg)
def ipv6_enabled() -> bool:
cfg = pathlib.Path("/etc/default/ufw")
if not cfg.exists():
return True
for line in cfg.read_text().splitlines():
if line.strip().startswith("IPV6="):
return line.split("=", 1)[1].strip().lower() == "yes"
return True
def get_ipv6_prefixes() -> list:
if not ipv6_enabled():
return []
try:
out = subprocess.check_output(["ip", "-6", "addr", "show", "scope", "global"], text=True)
except Exception:
return []
prefixes = set()
for line in out.splitlines():
line = line.strip()
if not line.startswith("inet6 "):
continue
if " temporary " in f" {line} ":
continue
parts = line.split()
if len(parts) < 2:
continue
prefixes.add(parts[1])
return sorted(prefixes)
try:
profile = json.loads(profile_path.read_text())
except Exception as e:
log(f"Profile load failed: {e}")
profile = {}
ports = profile.get("firewall_ports") or []
firewall_enable = bool(profile.get("firewall_enable", False))
base_ports = profile.get("firewall_base_ports") or [22, 80, 443, 5252, 5253]
if ports:
if shutil.which("ufw"):
ipv6_prefixes = get_ipv6_prefixes()
if ipv6_prefixes:
log(f"IPv6 LAN prefixes: {', '.join(ipv6_prefixes)}")
for raw in ports:
try:
port = int(raw)
except Exception:
continue
for subnet in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "169.254.0.0/16"):
subprocess.run(["ufw", "allow", "from", subnet, "to", "any", "port", str(port)], check=False)
for prefix in ipv6_prefixes:
subprocess.run(["ufw", "allow", "from", prefix, "to", "any", "port", str(port)], check=False)
if firewall_enable:
for raw in base_ports:
try:
port = int(raw)
except Exception:
continue
for subnet in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "169.254.0.0/16"):
subprocess.run(["ufw", "allow", "from", subnet, "to", "any", "port", str(port)], check=False)
for prefix in ipv6_prefixes:
subprocess.run(["ufw", "allow", "from", prefix, "to", "any", "port", str(port)], check=False)
subprocess.run(["ufw", "--force", "enable"], check=False)
log("UFW enabled.")
log("Profile firewall rules applied.")
else:
log("Profile firewall step skipped (ufw missing).")
else:
log("Profile firewall step skipped (no ports).")
def normalize_name(name: str) -> str:
return name.strip().lower()
services = []
if services_path.exists():
try:
services = json.loads(services_path.read_text())
except Exception:
services = []
if not isinstance(services, list):
services = []
profile_services = profile.get("services") or []
if isinstance(profile_services, list) and profile_services:
for psvc in profile_services:
if not isinstance(psvc, dict):
continue
p_name = normalize_name(str(psvc.get("name", "")))
p_port = str(psvc.get("port", ""))
p_path = str(psvc.get("path", "")) if psvc.get("path") is not None else ""
replaced = False
for svc in services:
if not isinstance(svc, dict):
continue
s_name = normalize_name(str(svc.get("name", "")))
s_port = str(svc.get("port", ""))
s_path = str(svc.get("path", "")) if svc.get("path") is not None else ""
if (p_name and s_name == p_name) or (p_port and s_port == p_port and s_path == p_path):
svc.update(psvc)
replaced = True
break
if not replaced:
services.append(psvc)
services_path.parent.mkdir(parents=True, exist_ok=True)
services_path.write_text(json.dumps(services, indent=2))
log("Profile services merged.")
else:
log("Profile services step skipped (none).")
actions = profile.get("actions") or []
if isinstance(actions, list) and actions:
for action in actions:
if not isinstance(action, dict):
continue
action_type = action.get("type")
if action_type == "tls_bundle":
src_cert = pathlib.Path(action.get("source_cert", ""))
src_key = pathlib.Path(action.get("source_key", ""))
dest = pathlib.Path(action.get("dest", ""))
if not src_cert.exists() or not src_key.exists():
log(f"TLS bundle skipped (missing cert/key): {dest}")
continue
dest.parent.mkdir(parents=True, exist_ok=True)
content = src_cert.read_bytes() + b"\n" + src_key.read_bytes() + b"\n"
dest.write_bytes(content)
owner = action.get("owner")
if owner:
user, _, group = str(owner).partition(":")
try:
uid = pwd.getpwnam(user).pw_uid if user else -1
except Exception:
uid = -1
try:
gid = grp.getgrnam(group).gr_gid if group else -1
except Exception:
gid = -1
if uid != -1 or gid != -1:
os.chown(dest, uid if uid != -1 else -1, gid if gid != -1 else -1)
mode = action.get("mode")
if mode:
try:
os.chmod(dest, int(str(mode), 8))
except Exception:
pass
restart = action.get("restart")
if restart:
subprocess.run(["systemctl", "restart", str(restart)], check=False)
log(f"TLS bundle written: {dest}")
continue
if action_type == "replace_text":
file_path = pathlib.Path(action.get("file", ""))
match = str(action.get("match", ""))
replacement = str(action.get("replace", ""))
if not file_path.exists():
log(f"Replace skipped (missing file): {file_path}")
continue
content = file_path.read_text()
if match not in content:
log(f"Replace skipped (pattern not found): {file_path}")
continue
file_path.write_text(content.replace(match, replacement, 1))
restart = action.get("restart")
if restart:
subprocess.run(["systemctl", "restart", str(restart)], check=False)
log(f"Replaced text in: {file_path}")
continue
else:
log("Profile actions step skipped (none).")
PY
}
write_state() { write_state() {
local state="$1" local state="$1"
local current="$2" local current="$2"
@@ -121,6 +354,7 @@ mkdir -p "$FIRSTBOOT_DIR"
exec >>"$LOG_FILE" 2>&1 exec >>"$LOG_FILE" 2>&1
log "Pi-Kit firstboot starting" log "Pi-Kit firstboot starting"
load_config
if [ -f "$DONE_FILE" ]; then if [ -f "$DONE_FILE" ]; then
log "Firstboot already completed; exiting." log "Firstboot already completed; exiting."
@@ -206,42 +440,20 @@ fi
finish_step 2 finish_step 2
begin_step 3 begin_step 3
export DEBIAN_FRONTEND=noninteractive if skip_updates; then
mkdir -p /var/cache/apt/archives/partial /var/lib/apt/lists/partial log "Skipping software updates (PIKIT_FIRSTBOOT_UPDATES=$PIKIT_FIRSTBOOT_UPDATES)."
chmod 755 /var/cache/apt/archives /var/cache/apt/archives/partial /var/lib/apt/lists /var/lib/apt/lists/partial else
apt-get update export DEBIAN_FRONTEND=noninteractive
apt-get -y -o Dpkg::Options::=--force-confold full-upgrade 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 finish_step 3
begin_step 4 begin_step 4
if [ -f "$PROFILE_FILE" ] && command -v ufw >/dev/null 2>&1; then configure_unattended_defaults
python3 - <<'PY' > /tmp/pikit-profile-ports.txt apply_profile
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 if [ ! -f "$WEB_ASSETS/pikit-ca.crt" ]; then
echo "CA bundle missing in web assets" >&2 echo "CA bundle missing in web assets" >&2