9 Commits
v0.1.3 ... main

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
14 changed files with 914 additions and 56 deletions

View File

@@ -8,8 +8,10 @@ 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 + verify: 4) 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` - `./pikit-smoke-test.sh`
- (optional) `sudo ./pikit-prep.sh --check-only` - (optional) `sudo ./pikit-prep.sh --check-only`
5) Image the SD card with DietPi Imager. 5) Image the SD card with DietPi Imager.
@@ -25,8 +27,10 @@ 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 + verify: 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` - `./pikit-smoke-test.sh`
- (optional) `sudo ./pikit-prep.sh --check-only` - (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:
@@ -54,8 +58,10 @@ 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 + verify: 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` - `./pikit-smoke-test.sh`
- (optional) `sudo ./pikit-prep.sh --check-only` - (optional) `sudo ./pikit-prep.sh --check-only`
7) Image the SD card with DietPi Imager. 7) Image the SD card with DietPi Imager.
@@ -84,8 +90,10 @@ Use the helper:
- dashboard loads - dashboard loads
- firstboot completes - firstboot completes
4) Apply any required profile/services. 4) Apply any required profile/services.
5) Run prep + verify: 5) Run prep + 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` - `./pikit-smoke-test.sh`
6) Power down cleanly. 6) Power down cleanly.
7) Image the SD card (DietPi Imager via QEMU or ondevice). 7) Image the SD card (DietPi Imager via QEMU or ondevice).
@@ -96,6 +104,48 @@ Use the helper:
9) Smoke test the flashed image on a second SD card: 9) Smoke test the flashed image on a second SD card:
- boot → firstboot → dashboard → services - 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`).

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

@@ -14,12 +14,16 @@ 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_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'
@@ -32,10 +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: Env:
PIKIT_FORCE_PASSWORD_CHANGE=0 Skip forcing a password change (default is on) 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
} }
@@ -70,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
@@ -86,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 $?
} }
@@ -184,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"
@@ -274,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
@@ -405,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
@@ -473,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"
@@ -538,13 +617,23 @@ check_image() {
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
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 else
status OK "/var/log empty" status OK "/var/log empty"
fi fi
fi
else else
status WARN "/var/log missing" status WARN "/var/log missing"
fi fi
@@ -567,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
@@ -582,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
@@ -607,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
@@ -623,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
} }

View File

@@ -25,6 +25,7 @@ Runs a quick post-prep smoke test:
- API reachable and returns JSON - API reachable and returns JSON
- firstboot state done - firstboot state done
- core services active (nginx, pikit-api, dietpi-dashboard-frontend) - core services active (nginx, pikit-api, dietpi-dashboard-frontend)
- profile-specific checks (if /etc/pikit/profile.json exists)
Options: Options:
--local Run locally on the Pi (skip SSH) --local Run locally on the Pi (skip SSH)
@@ -77,6 +78,10 @@ remote_cmd() {
fi fi
} }
remote_sudo_cmd() {
remote_cmd "sudo bash -c \"$1\""
}
extract_json_line() { extract_json_line() {
awk 'BEGIN{found=0} /^[[:space:]]*[{[]/ {print; found=1; exit} END{if(!found) exit 0}' awk 'BEGIN{found=0} /^[[:space:]]*[{[]/ {print; found=1; exit} END{if(!found) exit 0}'
} }
@@ -164,6 +169,7 @@ sys.exit(1)
check_firstboot() { check_firstboot() {
local url="$1" local url="$1"
local body state error_present 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 if ! body="$(remote_cmd "curl -fsS --max-time 5 $url")"; then
status FAIL "firstboot API not reachable" status FAIL "firstboot API not reachable"
return return
@@ -181,10 +187,26 @@ check_firstboot() {
status FAIL "firstboot status invalid or missing" status FAIL "firstboot status invalid or missing"
return return
fi 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 if [ "$state" = "done" ] && [ "$error_present" != "true" ]; then
status OK "firstboot completed" 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 else
status FAIL "firstboot not complete (state=$state error=$error_present)" status WARN "firstboot in progress (state=$state)"
fi fi
} }
@@ -213,6 +235,188 @@ check_ports() {
fi 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() { finalize() {
section "Summary" section "Summary"
status OK "warnings: $WARNINGS" status OK "warnings: $WARNINGS"
@@ -246,6 +450,41 @@ main() {
section "Ports" section "Ports"
check_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 finalize
} }

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" "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

@@ -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

@@ -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

@@ -3,17 +3,24 @@
# Prints a one-time SSH hardening tip after the forced password change. # Prints a one-time SSH hardening tip after the forced password change.
FLAG="/var/lib/pikit/first-login.notice" FLAG="/var/lib/pikit/first-login.notice"
DONE_FILE=".pikit-first-login.done"
case "$-" in case "$-" in
*i*) interactive=1 ;; *i*) interactive=1 ;;
*) interactive=0 ;; *) interactive=0 ;;
esac esac
if [ "$interactive" -eq 1 ] && [ -f "$FLAG" ]; then 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 ""
echo "Pi-Kit: For better security, set up an SSH key and disable password auth once working." echo "Pi-Kit: For better security, set up an SSH key and disable password auth once working."
echo " Example: ssh-keygen -t ed25519" echo " Run these from your computer (not the Pi):"
echo " ssh-keygen -t ed25519"
echo " ssh-copy-id dietpi@pikit.local" echo " ssh-copy-id dietpi@pikit.local"
echo "" echo ""
rm -f "$FLAG" 2>/dev/null || true :> "$DONE_PATH" 2>/dev/null || true
fi
fi fi

View File

@@ -16,6 +16,7 @@ PROFILE_FILE="/etc/pikit/profile.json"
MOTD_FILE="/etc/motd" MOTD_FILE="/etc/motd"
FIRSTBOOT_CONF="/etc/pikit/firstboot.conf" FIRSTBOOT_CONF="/etc/pikit/firstboot.conf"
APT_UA_OVERRIDE="/etc/apt/apt.conf.d/51pikit-unattended.conf" APT_UA_OVERRIDE="/etc/apt/apt.conf.d/51pikit-unattended.conf"
SERVICE_JSON="/etc/pikit/services.json"
STEPS=( STEPS=(
"Preparing system" "Preparing system"
@@ -58,7 +59,7 @@ configure_unattended_defaults() {
log "python3 missing; skipping unattended-upgrades defaults." log "python3 missing; skipping unattended-upgrades defaults."
return return
fi fi
PYTHONPATH=/usr/local/bin python3 - <<'PY' PROFILE_FILE="$PROFILE_FILE" SERVICE_JSON="$SERVICE_JSON" PYTHONPATH=/usr/local/bin python3 - <<'PY'
import sys import sys
try: try:
from pikit_api.auto_updates import set_updates_config from pikit_api.auto_updates import set_updates_config
@@ -71,6 +72,198 @@ PY
log "Unattended-upgrades defaults applied (security-only)." 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"
@@ -260,34 +453,7 @@ finish_step 3
begin_step 4 begin_step 4
configure_unattended_defaults configure_unattended_defaults
if [ -f "$PROFILE_FILE" ] && command -v ufw >/dev/null 2>&1; then apply_profile
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 if [ ! -f "$WEB_ASSETS/pikit-ca.crt" ]; then
echo "CA bundle missing in web assets" >&2 echo "CA bundle missing in web assets" >&2