Compare commits
20 Commits
32a9f42361
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b0cc80d0a | ||
|
|
452b787c30 | ||
|
|
808934cbec | ||
|
|
1ddffee077 | ||
|
|
a67b1a55d4 | ||
|
|
ed162d3c1d | ||
|
|
d07fab99a6 | ||
|
|
511978666a | ||
|
|
453deac601 | ||
|
|
cb2d75387d | ||
|
|
77bc4c1c36 | ||
|
|
13e5788fe1 | ||
|
|
24e89b516f | ||
|
|
9c3156df35 | ||
|
|
b01c2ba737 | ||
|
|
bc97e0374f | ||
|
|
0a23902eb0 | ||
|
|
4632704092 | ||
|
|
36d30da30a | ||
|
|
c62f1f018f |
@@ -8,9 +8,12 @@ This documents the *current* workflow and the *target* workflow once profiles +
|
||||
- Nginx + Pi‑Kit dashboard
|
||||
- DietPi dashboard
|
||||
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`
|
||||
- (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.
|
||||
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).
|
||||
5) Open any needed ports in ufw (done as part of testing/config):
|
||||
- `sudo ufw allow from <LAN subnet> to any port <port>`
|
||||
6) Run the prep scrub:
|
||||
6) Run the prep scrub + verify (prep now prompts to shut down):
|
||||
- `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:
|
||||
- Insert the SD card into your desktop.
|
||||
- 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).
|
||||
5) Run the drift check (planned script):
|
||||
- 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`
|
||||
- (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.
|
||||
|
||||
First boot on the end‑user device will:
|
||||
@@ -61,10 +71,84 @@ First boot on the end‑user device will:
|
||||
- Ensure the profile’s firewall ports are open (LAN‑only).
|
||||
- Show a progress overlay until complete.
|
||||
|
||||
Optional: to skip the first‑boot update step for faster startup, create
|
||||
`/etc/pikit/firstboot.conf` with:
|
||||
|
||||
```
|
||||
PIKIT_FIRSTBOOT_UPDATES=0
|
||||
```
|
||||
|
||||
## 3) Flashing an image to SD
|
||||
Use the helper:
|
||||
- `sudo ./flash_sd.sh <image.img.xz> /dev/sdX`
|
||||
|
||||
## 4) Manufacturing / imaging checklist (production)
|
||||
1) Start from the golden base image (stored in `images/base/`).
|
||||
2) Flash it to a known‑good SD card.
|
||||
3) Boot and verify:
|
||||
- `http://pikit.local` and `https://pikit.local`
|
||||
- dashboard loads
|
||||
- first‑boot completes
|
||||
4) Apply any required profile/services.
|
||||
5) Run prep + verify (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 on‑device).
|
||||
8) Name and archive the image:
|
||||
- Base: `images/base/pikit-base-YYYYMMDD-dietpi9.20.1.img.xz`
|
||||
- Profile: `images/profiles/pikit-<profile>-YYYYMMDD.img.xz`
|
||||
- Testing/staging: `images/staging/pikit-<profile>-YYYYMMDD-rcN.img.xz`
|
||||
9) Smoke test the flashed image on a second SD card:
|
||||
- boot → first‑boot → dashboard → services
|
||||
|
||||
## 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
|
||||
- Profiles are additive to the base image defaults; do not include Pi‑Kit or DietPi dashboard entries in profiles.
|
||||
- Keep `RESCUE.md` in `/root` and `/home/dietpi` only (not in `/var/www`).
|
||||
- Prep enforces a password change for `dietpi` on first login; set `PIKIT_FORCE_PASSWORD_CHANGE=0` to skip.
|
||||
- After the password change, a one‑time SSH hardening tip is shown on login.
|
||||
- End-user defaults: OS security unattended upgrades on; Pi-Kit updater auto-check on stable channel, auto-apply off (user can change in dashboard).
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"version": "0.1.2",
|
||||
"_release_date": "2025-12-10T00:00:00Z",
|
||||
"bundle": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.2/pikit-0.1.2.tar.gz",
|
||||
"changelog": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.2/CHANGELOG-0.1.2.txt",
|
||||
"version": "0.1.4",
|
||||
"_release_date": "2026-01-03T17:34:02Z",
|
||||
"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.4/CHANGELOG-0.1.4.txt",
|
||||
"files": [
|
||||
{ "path": "bundle.tar.gz", "sha256": "8d2e0f8b260063cab0d52e862cb42f10472a643123f984af0248592479dd613d" }
|
||||
{ "path": "bundle.tar.gz", "sha256": "9d4bc4f6eb5eaa83da28065ad664b4893095756e4584c25e7844b8098c5816ea" }
|
||||
]
|
||||
}
|
||||
|
||||
169
pikit-prep.sh
169
pikit-prep.sh
@@ -13,12 +13,17 @@ PIKIT_SSH_KEY="${PIKIT_SSH_KEY:-$HOME/.ssh/pikit}"
|
||||
PIKIT_SSH_OPTS="${PIKIT_SSH_OPTS:-}"
|
||||
PIKIT_REMOTE_TMP="${PIKIT_REMOTE_TMP:-/tmp/pikit-prep.sh}"
|
||||
PIKIT_SELF_DELETE="${PIKIT_SELF_DELETE:-0}"
|
||||
PIKIT_FORCE_PASSWORD_CHANGE="${PIKIT_FORCE_PASSWORD_CHANGE:-1}"
|
||||
PIKIT_SHUTDOWN_AFTER_PREP="${PIKIT_SHUTDOWN_AFTER_PREP:-1}"
|
||||
PIKIT_SHUTDOWN_PROMPT="${PIKIT_SHUTDOWN_PROMPT:-1}"
|
||||
|
||||
MODE="both"
|
||||
LOCAL_ONLY=0
|
||||
DID_PREP=0
|
||||
|
||||
ERRORS=0
|
||||
WARNINGS=0
|
||||
STOPPED_PIHOLE_FTL=0
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
@@ -31,7 +36,14 @@ Options:
|
||||
--prep-only Run prep only (no check)
|
||||
--check-only Run checks only (no prep)
|
||||
--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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -66,6 +78,8 @@ parse_args() {
|
||||
--prep-only) MODE="prep" ;;
|
||||
--check-only) MODE="check" ;;
|
||||
--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 ;;
|
||||
*)
|
||||
echo "[FAIL] Unknown argument: $arg" >&2
|
||||
@@ -82,12 +96,16 @@ run_remote() {
|
||||
[ "$arg" = "--local" ] && continue
|
||||
forward+=("$arg")
|
||||
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
|
||||
echo "[FAIL] ssh/scp not available for remote prep" >&2
|
||||
exit 1
|
||||
fi
|
||||
scp -i "$PIKIT_SSH_KEY" $PIKIT_SSH_OPTS "$SCRIPT_PATH" "${PIKIT_USER}@${PIKIT_HOST}:${PIKIT_REMOTE_TMP}"
|
||||
ssh -i "$PIKIT_SSH_KEY" $PIKIT_SSH_OPTS "${PIKIT_USER}@${PIKIT_HOST}" \
|
||||
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"
|
||||
exit $?
|
||||
}
|
||||
@@ -180,6 +198,40 @@ clean_home_dir() {
|
||||
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() {
|
||||
section "Prep"
|
||||
|
||||
@@ -206,6 +258,16 @@ prep_image() {
|
||||
# --- Default login ---
|
||||
if id -u dietpi >/dev/null 2>&1; then
|
||||
echo "dietpi:pikit" | chpasswd && status CLEANED "reset dietpi password" || status FAIL "reset dietpi password"
|
||||
mkdir -p /var/lib/pikit
|
||||
rm -f /var/lib/pikit/first-login.notice
|
||||
case "${PIKIT_FORCE_PASSWORD_CHANGE,,}" in
|
||||
1|true|yes|on)
|
||||
chage -d 0 dietpi && status CLEANED "force dietpi password change on next login" || status FAIL "force dietpi password change"
|
||||
:> /var/lib/pikit/first-login.notice && chmod 644 /var/lib/pikit/first-login.notice \
|
||||
&& status CLEANED "first-login notice armed" || status FAIL "first-login notice"
|
||||
;;
|
||||
*) ;;
|
||||
esac
|
||||
else
|
||||
status SKIP "dietpi user missing"
|
||||
fi
|
||||
@@ -236,6 +298,8 @@ prep_image() {
|
||||
clean_file /etc/pikit/certs/pikit.local.csr
|
||||
clean_file /var/www/pikit-web/assets/pikit-ca.crt
|
||||
clean_file /var/www/pikit-web/assets/pikit-ca.sha256
|
||||
clean_file /var/lib/pikit-update/state.json
|
||||
clean_file /var/run/pikit-update.lock
|
||||
|
||||
# --- Backup/editor cruft ---
|
||||
clean_backups /var/www/pikit-web
|
||||
@@ -258,9 +322,22 @@ prep_image() {
|
||||
fi
|
||||
|
||||
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_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
|
||||
|
||||
if [ -x /opt/AdGuardHome/AdGuardHome ]; then
|
||||
@@ -389,6 +466,10 @@ prep_image() {
|
||||
# --- DHCP leases ---
|
||||
clean_file /var/lib/dhcp/dhclient.eth0.leases
|
||||
|
||||
# --- Network config ---
|
||||
reset_iface_to_dhcp eth0
|
||||
reset_iface_to_dhcp wlan0
|
||||
|
||||
# --- Nginx caches ---
|
||||
if [ -d /var/lib/nginx ]; then
|
||||
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
|
||||
}
|
||||
|
||||
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() {
|
||||
section "Check"
|
||||
|
||||
@@ -517,16 +612,28 @@ check_image() {
|
||||
check_file_missing /etc/pikit/certs/pikit.local.key
|
||||
check_file_missing /var/www/pikit-web/assets/pikit-ca.crt
|
||||
check_file_missing /var/www/pikit-web/assets/pikit-ca.sha256
|
||||
check_file_missing /var/lib/pikit-update/state.json
|
||||
check_file_missing /var/run/pikit-update.lock
|
||||
|
||||
section "Logs"
|
||||
if [ -d /var/log ]; then
|
||||
local nonempty
|
||||
nonempty="$(find /var/log -type f -size +0c 2>/dev/null | wc -l | tr -d ' ')"
|
||||
if [ "$nonempty" -gt 0 ]; then
|
||||
status WARN "/var/log has non-empty files: $nonempty"
|
||||
local nonempty filtered
|
||||
nonempty="$(find /var/log -type f -size +0c 2>/dev/null)"
|
||||
filtered="$(printf "%s\n" "$nonempty" | grep -Ev '/(lastlog|faillog|btmp|wtmp)$' || true)"
|
||||
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
|
||||
status OK "/var/log empty"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
status WARN "/var/log missing"
|
||||
fi
|
||||
@@ -549,6 +656,14 @@ check_image() {
|
||||
status FAIL "DietPi RAMlog store missing"
|
||||
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"
|
||||
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"
|
||||
check_file_missing /var/lib/dhcp/dhclient.eth0.leases
|
||||
|
||||
section "Network config"
|
||||
check_iface_dhcp eth0
|
||||
check_iface_dhcp wlan0
|
||||
|
||||
section "Nginx cache dirs"
|
||||
if [ -d /var/lib/nginx ]; then
|
||||
local nginx_cache
|
||||
@@ -589,6 +708,40 @@ finalize() {
|
||||
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() {
|
||||
if [ "$PIKIT_SELF_DELETE" -eq 1 ] && [[ "$SCRIPT_PATH" == /tmp/* ]]; then
|
||||
rm -f "$SCRIPT_PATH" || true
|
||||
@@ -605,15 +758,17 @@ main() {
|
||||
require_root
|
||||
|
||||
case "$MODE" in
|
||||
prep) prep_image ;;
|
||||
prep) prep_image; DID_PREP=1 ;;
|
||||
check) check_image ;;
|
||||
both)
|
||||
prep_image
|
||||
DID_PREP=1
|
||||
check_image
|
||||
;;
|
||||
esac
|
||||
|
||||
finalize
|
||||
maybe_shutdown
|
||||
maybe_self_delete
|
||||
}
|
||||
|
||||
|
||||
491
pikit-smoke-test.sh
Executable file
491
pikit-smoke-test.sh
Executable 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 "$@"
|
||||
@@ -110,6 +110,13 @@ const firstbootErrorClose = document.getElementById("firstbootErrorClose");
|
||||
const firstbootCopyError = document.getElementById("firstbootCopyError");
|
||||
const firstbootShowRecovery = document.getElementById("firstbootShowRecovery");
|
||||
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 confirmTitle = document.getElementById("confirmTitle");
|
||||
const confirmBody = document.getElementById("confirmBody");
|
||||
@@ -172,6 +179,58 @@ const firstbootUI = createFirstbootUI({
|
||||
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({
|
||||
heroStats,
|
||||
servicesGrid,
|
||||
@@ -191,6 +250,7 @@ const statusController = createStatusController({
|
||||
getError: getFirstbootError,
|
||||
ui: firstbootUI,
|
||||
},
|
||||
onFirstbootStatus: handleFirstbootStatus,
|
||||
});
|
||||
const { loadStatus } = statusController;
|
||||
|
||||
@@ -220,6 +280,30 @@ function wireDialogs() {
|
||||
addServiceModal?.addEventListener("click", (e) => {
|
||||
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
|
||||
@@ -348,6 +432,7 @@ function main() {
|
||||
window.__pikitTest.exposeServiceForm?.();
|
||||
}
|
||||
wireDialogs();
|
||||
wireNetworkSetup();
|
||||
wireResetAndUpdates();
|
||||
wireAccordions({
|
||||
forceOpen: typeof window !== "undefined" && window.__pikitTest?.forceAccordionsOpen,
|
||||
|
||||
@@ -18,6 +18,7 @@ export function createStatusController({
|
||||
setUpdatesUI = null,
|
||||
updatesFlagEl = null,
|
||||
firstboot = null,
|
||||
onFirstbootStatus = null,
|
||||
}) {
|
||||
let lastStatusData = null;
|
||||
let lastFirstbootState = null;
|
||||
@@ -88,6 +89,7 @@ export function createStatusController({
|
||||
firstbootData = await firstboot.getStatus();
|
||||
lastFirstbootState = firstbootData?.state || lastFirstbootState;
|
||||
firstboot.ui.update(firstbootData);
|
||||
onFirstbootStatus?.(firstbootData);
|
||||
if (firstbootData?.state === "error" && firstboot.getError) {
|
||||
const err = await firstboot.getError();
|
||||
if (err?.present) {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "0.1.3-dev6"
|
||||
"version": "0.1.4"
|
||||
}
|
||||
|
||||
@@ -132,6 +132,42 @@
|
||||
</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">
|
||||
×
|
||||
</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 router’s 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 Pi’s 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">I’ll set a static IP on the Pi</button>
|
||||
<button id="networkLaterBtn" class="ghost">I’ll 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 class="modal-card wide">
|
||||
<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>
|
||||
</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>
|
||||
<ul>
|
||||
<li>Service link fails? Make sure the service runs, port/path are right, and edit via the “…” menu.</li>
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
Download Pi-Kit CA
|
||||
</a>
|
||||
</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>
|
||||
<details>
|
||||
<summary id="win">Windows</summary>
|
||||
|
||||
@@ -17,7 +17,7 @@ export default defineConfig({
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
command: 'node tests/mock-api.js & npm run dev',
|
||||
url: BASE_URL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
stdout: 'pipe',
|
||||
|
||||
85
pikit-web/tests/mock-api.js
Normal file
85
pikit-web/tests/mock-api.js
Normal 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);
|
||||
@@ -3,6 +3,8 @@ import pathlib
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
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
|
||||
|
||||
DEFAULT_STEPS = [
|
||||
@@ -82,6 +84,18 @@ def read_firstboot_status() -> Dict[str, Any]:
|
||||
ca_path = WEB_ROOT / "assets" / "pikit-ca.crt"
|
||||
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 {
|
||||
"state": state,
|
||||
"steps": steps,
|
||||
@@ -91,6 +105,7 @@ def read_firstboot_status() -> Dict[str, Any]:
|
||||
"error_path": "/api/firstboot/error",
|
||||
"ca_hash": ca_hash,
|
||||
"ca_url": "/assets/pikit-ca.crt",
|
||||
"profile": profile_summary,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -90,10 +90,10 @@ def load_update_state() -> Dict[str, Any]:
|
||||
"last_check": None,
|
||||
"status": "unknown",
|
||||
"message": "",
|
||||
"auto_check": False,
|
||||
"auto_check": True,
|
||||
"in_progress": False,
|
||||
"progress": None,
|
||||
"channel": os.environ.get("PIKIT_CHANNEL", "dev"),
|
||||
"channel": os.environ.get("PIKIT_CHANNEL", "stable"),
|
||||
"changelog_url": None,
|
||||
"latest_release_date": None,
|
||||
"current_release_date": None,
|
||||
|
||||
28
profiles/dns-stack/profile.json
Normal file
28
profiles/dns-stack/profile.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
26
systemd/pikit-first-login.sh
Normal file
26
systemd/pikit-first-login.sh
Normal 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
|
||||
@@ -14,6 +14,9 @@ CERT_DIR="/etc/pikit/certs"
|
||||
WEB_ASSETS="/var/www/pikit-web/assets"
|
||||
PROFILE_FILE="/etc/pikit/profile.json"
|
||||
MOTD_FILE="/etc/motd"
|
||||
FIRSTBOOT_CONF="/etc/pikit/firstboot.conf"
|
||||
APT_UA_OVERRIDE="/etc/apt/apt.conf.d/51pikit-unattended.conf"
|
||||
SERVICE_JSON="/etc/pikit/services.json"
|
||||
|
||||
STEPS=(
|
||||
"Preparing system"
|
||||
@@ -26,11 +29,241 @@ STEPS=(
|
||||
STEP_STATUS=(pending pending pending pending pending pending)
|
||||
CURRENT_STEP=""
|
||||
CURRENT_INDEX=-1
|
||||
PIKIT_FIRSTBOOT_UPDATES="${PIKIT_FIRSTBOOT_UPDATES:-1}"
|
||||
|
||||
log() {
|
||||
printf '[%s] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$*"
|
||||
}
|
||||
|
||||
load_config() {
|
||||
if [ -f "$FIRSTBOOT_CONF" ]; then
|
||||
# shellcheck disable=SC1090
|
||||
. "$FIRSTBOOT_CONF"
|
||||
fi
|
||||
PIKIT_FIRSTBOOT_UPDATES="${PIKIT_FIRSTBOOT_UPDATES:-1}"
|
||||
}
|
||||
|
||||
skip_updates() {
|
||||
case "${PIKIT_FIRSTBOOT_UPDATES,,}" in
|
||||
0|false|no|off) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
configure_unattended_defaults() {
|
||||
if [ -f "$APT_UA_OVERRIDE" ]; then
|
||||
log "Unattended-upgrades config already present; skipping defaults."
|
||||
return
|
||||
fi
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
log "python3 missing; skipping unattended-upgrades defaults."
|
||||
return
|
||||
fi
|
||||
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() {
|
||||
local state="$1"
|
||||
local current="$2"
|
||||
@@ -121,6 +354,7 @@ mkdir -p "$FIRSTBOOT_DIR"
|
||||
exec >>"$LOG_FILE" 2>&1
|
||||
|
||||
log "Pi-Kit firstboot starting"
|
||||
load_config
|
||||
|
||||
if [ -f "$DONE_FILE" ]; then
|
||||
log "Firstboot already completed; exiting."
|
||||
@@ -206,42 +440,20 @@ fi
|
||||
finish_step 2
|
||||
|
||||
begin_step 3
|
||||
if skip_updates; then
|
||||
log "Skipping software updates (PIKIT_FIRSTBOOT_UPDATES=$PIKIT_FIRSTBOOT_UPDATES)."
|
||||
else
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
mkdir -p /var/cache/apt/archives/partial /var/lib/apt/lists/partial
|
||||
chmod 755 /var/cache/apt/archives /var/cache/apt/archives/partial /var/lib/apt/lists /var/lib/apt/lists/partial
|
||||
apt-get update
|
||||
apt-get -y -o Dpkg::Options::=--force-confold full-upgrade
|
||||
fi
|
||||
finish_step 3
|
||||
|
||||
begin_step 4
|
||||
if [ -f "$PROFILE_FILE" ] && command -v ufw >/dev/null 2>&1; then
|
||||
python3 - <<'PY' > /tmp/pikit-profile-ports.txt
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
profile = Path("/etc/pikit/profile.json")
|
||||
try:
|
||||
data = json.loads(profile.read_text())
|
||||
except Exception:
|
||||
data = {}
|
||||
ports = data.get("firewall_ports") or []
|
||||
for port in ports:
|
||||
try:
|
||||
port_int = int(port)
|
||||
except Exception:
|
||||
continue
|
||||
print(port_int)
|
||||
PY
|
||||
while read -r port; do
|
||||
[ -z "$port" ] && continue
|
||||
for subnet in 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 100.64.0.0/10 169.254.0.0/16; do
|
||||
ufw allow from "$subnet" to any port "$port" || true
|
||||
done
|
||||
done < /tmp/pikit-profile-ports.txt
|
||||
rm -f /tmp/pikit-profile-ports.txt
|
||||
else
|
||||
log "Profile firewall step skipped (no profile.json or ufw missing)"
|
||||
fi
|
||||
configure_unattended_defaults
|
||||
apply_profile
|
||||
|
||||
if [ ! -f "$WEB_ASSETS/pikit-ca.crt" ]; then
|
||||
echo "CA bundle missing in web assets" >&2
|
||||
|
||||
Reference in New Issue
Block a user