11 Commits

Author SHA1 Message Date
Aaron
cb2d75387d Bump version to 0.1.3 2026-01-03 00:15:01 -05:00
Aaron
77bc4c1c36 Fix smoke test JSON parsing 2026-01-02 23:58:54 -05:00
Aaron
13e5788fe1 Fix smoke test JSON parsing and set unattended defaults 2026-01-02 23:56:48 -05:00
Aaron
24e89b516f Use local API endpoint in smoke test 2026-01-02 23:49:38 -05:00
Aaron
9c3156df35 Fix smoke test redirects and default updates to stable 2026-01-02 23:45:47 -05:00
Aaron
b01c2ba737 Add smoke test script and expand manufacturing workflow 2026-01-02 23:38:22 -05:00
Aaron
bc97e0374f Add one-time SSH hardening notice after forced password change 2026-01-02 23:29:17 -05:00
Aaron
0a23902eb0 Default to forcing password change after prep 2026-01-02 23:26:03 -05:00
Aaron
4632704092 Allow forcing password change after prep 2026-01-02 23:23:21 -05:00
Aaron
36d30da30a Make firstboot updates configurable 2026-01-02 23:07:36 -05:00
Aaron
c62f1f018f Note browser restart after CA install 2026-01-02 23:03:00 -05:00
8 changed files with 383 additions and 13 deletions

View File

@@ -8,9 +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: 4) Run the prep scrub + verify:
- `sudo ./pikit-prep.sh` - `sudo ./pikit-prep.sh`
- (optional) `sudo ./check-pikit-clean.sh` - `./pikit-smoke-test.sh`
- (optional) `sudo ./pikit-prep.sh --check-only`
5) Image the SD card with DietPi Imager. 5) Image the SD card with DietPi Imager.
6) Store it as the golden base (e.g., `images/base/pikit-base-YYYYMMDD.img.xz`). 6) Store it as the golden base (e.g., `images/base/pikit-base-YYYYMMDD.img.xz`).
@@ -24,9 +25,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: 6) Run the prep scrub + verify:
- `sudo ./pikit-prep.sh` - `sudo ./pikit-prep.sh`
- (optional) `sudo ./check-pikit-clean.sh` - `./pikit-smoke-test.sh`
- (optional) `sudo ./pikit-prep.sh --check-only`
7) Image the SD card via the QEMU DietPi VM: 7) Image the SD card via the QEMU DietPi VM:
- Insert the SD card into your desktop. - Insert the SD card into your desktop.
- Identify it with `lsblk`. - Identify it with `lsblk`.
@@ -52,8 +54,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: 6) Run the prep scrub + verify:
- `sudo ./pikit-prep.sh` - `sudo ./pikit-prep.sh`
- `./pikit-smoke-test.sh`
- (optional) `sudo ./pikit-prep.sh --check-only`
7) Image the SD card with DietPi Imager. 7) Image the SD card with DietPi Imager.
First boot on the enduser device will: First boot on the enduser device will:
@@ -61,10 +65,40 @@ First boot on the enduser device will:
- Ensure the profiles firewall ports are open (LANonly). - Ensure the profiles firewall ports are open (LANonly).
- Show a progress overlay until complete. - Show a progress overlay until complete.
Optional: to skip the firstboot update step for faster startup, create
`/etc/pikit/firstboot.conf` with:
```
PIKIT_FIRSTBOOT_UPDATES=0
```
## 3) Flashing an image to SD ## 3) Flashing an image to SD
Use the helper: Use the helper:
- `sudo ./flash_sd.sh <image.img.xz> /dev/sdX` - `sudo ./flash_sd.sh <image.img.xz> /dev/sdX`
## 4) Manufacturing / imaging checklist (production)
1) Start from the golden base image (stored in `images/base/`).
2) Flash it to a knowngood SD card.
3) Boot and verify:
- `http://pikit.local` and `https://pikit.local`
- dashboard loads
- firstboot completes
4) Apply any required profile/services.
5) Run prep + verify:
- `sudo ./pikit-prep.sh`
- `./pikit-smoke-test.sh`
6) Power down cleanly.
7) Image the SD card (DietPi Imager via QEMU or ondevice).
8) Name and archive the image:
- Base: `images/base/pikit-base-YYYYMMDD-dietpi9.20.1.img.xz`
- Profile: `images/profiles/pikit-<profile>-YYYYMMDD.img.xz`
- Testing/staging: `images/staging/pikit-<profile>-YYYYMMDD-rcN.img.xz`
9) Smoke test the flashed image on a second SD card:
- boot → firstboot → dashboard → services
## Notes ## Notes
- Profiles are additive to the base image defaults; do not include PiKit or DietPi dashboard entries in profiles. - Profiles are additive to the base image defaults; do not include PiKit or DietPi dashboard entries in profiles.
- Keep `RESCUE.md` in `/root` and `/home/dietpi` only (not in `/var/www`). - Keep `RESCUE.md` in `/root` and `/home/dietpi` only (not in `/var/www`).
- Prep enforces a password change for `dietpi` on first login; set `PIKIT_FORCE_PASSWORD_CHANGE=0` to skip.
- After the password change, a onetime SSH hardening tip is shown on login.
- End-user defaults: OS security unattended upgrades on; Pi-Kit updater auto-check on stable channel, auto-apply off (user can change in dashboard).

View File

@@ -13,6 +13,7 @@ PIKIT_SSH_KEY="${PIKIT_SSH_KEY:-$HOME/.ssh/pikit}"
PIKIT_SSH_OPTS="${PIKIT_SSH_OPTS:-}" PIKIT_SSH_OPTS="${PIKIT_SSH_OPTS:-}"
PIKIT_REMOTE_TMP="${PIKIT_REMOTE_TMP:-/tmp/pikit-prep.sh}" PIKIT_REMOTE_TMP="${PIKIT_REMOTE_TMP:-/tmp/pikit-prep.sh}"
PIKIT_SELF_DELETE="${PIKIT_SELF_DELETE:-0}" PIKIT_SELF_DELETE="${PIKIT_SELF_DELETE:-0}"
PIKIT_FORCE_PASSWORD_CHANGE="${PIKIT_FORCE_PASSWORD_CHANGE:-1}"
MODE="both" MODE="both"
LOCAL_ONLY=0 LOCAL_ONLY=0
@@ -32,6 +33,9 @@ Options:
--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)
--help Show this help --help Show this help
Env:
PIKIT_FORCE_PASSWORD_CHANGE=0 Skip forcing a password change (default is on)
USAGE USAGE
} }
@@ -206,6 +210,16 @@ prep_image() {
# --- Default login --- # --- Default login ---
if id -u dietpi >/dev/null 2>&1; then if id -u dietpi >/dev/null 2>&1; then
echo "dietpi:pikit" | chpasswd && status CLEANED "reset dietpi password" || status FAIL "reset dietpi password" echo "dietpi:pikit" | chpasswd && status CLEANED "reset dietpi password" || status FAIL "reset dietpi password"
mkdir -p /var/lib/pikit
rm -f /var/lib/pikit/first-login.notice
case "${PIKIT_FORCE_PASSWORD_CHANGE,,}" in
1|true|yes|on)
chage -d 0 dietpi && status CLEANED "force dietpi password change on next login" || status FAIL "force dietpi password change"
:> /var/lib/pikit/first-login.notice && chmod 644 /var/lib/pikit/first-login.notice \
&& status CLEANED "first-login notice armed" || status FAIL "first-login notice"
;;
*) ;;
esac
else else
status SKIP "dietpi user missing" status SKIP "dietpi user missing"
fi fi
@@ -236,6 +250,8 @@ prep_image() {
clean_file /etc/pikit/certs/pikit.local.csr clean_file /etc/pikit/certs/pikit.local.csr
clean_file /var/www/pikit-web/assets/pikit-ca.crt clean_file /var/www/pikit-web/assets/pikit-ca.crt
clean_file /var/www/pikit-web/assets/pikit-ca.sha256 clean_file /var/www/pikit-web/assets/pikit-ca.sha256
clean_file /var/lib/pikit-update/state.json
clean_file /var/run/pikit-update.lock
# --- Backup/editor cruft --- # --- Backup/editor cruft ---
clean_backups /var/www/pikit-web clean_backups /var/www/pikit-web
@@ -517,6 +533,8 @@ check_image() {
check_file_missing /etc/pikit/certs/pikit.local.key check_file_missing /etc/pikit/certs/pikit.local.key
check_file_missing /var/www/pikit-web/assets/pikit-ca.crt check_file_missing /var/www/pikit-web/assets/pikit-ca.crt
check_file_missing /var/www/pikit-web/assets/pikit-ca.sha256 check_file_missing /var/www/pikit-web/assets/pikit-ca.sha256
check_file_missing /var/lib/pikit-update/state.json
check_file_missing /var/run/pikit-update.lock
section "Logs" section "Logs"
if [ -d /var/log ]; then if [ -d /var/log ]; then

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

@@ -0,0 +1,252 @@
#!/bin/bash
# Pi-Kit post-prep smoke test (HTTP/HTTPS/API/firstboot/services)
set -euo pipefail
PIKIT_HOST="${PIKIT_HOST:-pikit.local}"
PIKIT_USER="${PIKIT_USER:-dietpi}"
PIKIT_SSH_KEY="${PIKIT_SSH_KEY:-$HOME/.ssh/pikit}"
PIKIT_SSH_OPTS="${PIKIT_SSH_OPTS:-}"
PIKIT_HTTP_URL="${PIKIT_HTTP_URL:-http://$PIKIT_HOST}"
PIKIT_HTTPS_URL="${PIKIT_HTTPS_URL:-https://$PIKIT_HOST}"
PIKIT_API_URL="${PIKIT_API_URL:-http://127.0.0.1:4000}"
LOCAL_ONLY=0
ERRORS=0
WARNINGS=0
REMOTE_MODE=0
usage() {
cat <<'USAGE'
Usage: pikit-smoke-test.sh [--local]
Runs a quick post-prep smoke test:
- HTTP/HTTPS reachable
- API reachable and returns JSON
- firstboot state done
- core services active (nginx, pikit-api, dietpi-dashboard-frontend)
Options:
--local Run locally on the Pi (skip SSH)
--help Show this help
Env:
PIKIT_HOST, PIKIT_USER, PIKIT_SSH_KEY, PIKIT_SSH_OPTS
PIKIT_HTTP_URL, PIKIT_HTTPS_URL
USAGE
}
status() {
local level="$1"
shift
printf '[%s] %s\n' "$level" "$*"
case "$level" in
FAIL) ERRORS=$((ERRORS + 1)) ;;
WARN) WARNINGS=$((WARNINGS + 1)) ;;
esac
}
section() {
printf '\n== %s ==\n' "$1"
}
is_dietpi() {
grep -qi "dietpi" /etc/os-release 2>/dev/null
}
parse_args() {
for arg in "$@"; do
case "$arg" in
--local) LOCAL_ONLY=1 ;;
--help|-h) usage; exit 0 ;;
*)
echo "[FAIL] Unknown argument: $arg" >&2
usage
exit 1
;;
esac
done
}
remote_cmd() {
local cmd="$1"
if [ "$LOCAL_ONLY" -eq 1 ] || is_dietpi; then
bash -c "$cmd"
else
ssh -i "$PIKIT_SSH_KEY" $PIKIT_SSH_OPTS -o ConnectTimeout=10 "${PIKIT_USER}@${PIKIT_HOST}" "$cmd"
fi
}
extract_json_line() {
awk 'BEGIN{found=0} /^[[:space:]]*[{[]/ {print; found=1; exit} END{if(!found) exit 0}'
}
json_get() {
local key="$1"
if command -v python3 >/dev/null 2>&1; then
python3 -c 'import json,sys
key=sys.argv[1]
try:
data=json.load(sys.stdin)
except Exception:
print("")
sys.exit(1)
val=data.get(key, "")
if isinstance(val, bool):
print("true" if val else "false")
else:
print(val)
' "$key"
elif command -v jq >/dev/null 2>&1; then
jq -r --arg key "$key" '.[$key] // empty'
else
cat >/dev/null
echo ""
return 1
fi
}
check_http() {
local url="$1"
local label="$2"
if curl -fsS --max-time 5 "$url" >/dev/null; then
status OK "$label reachable"
else
status FAIL "$label not reachable"
fi
}
check_https() {
local url="$1"
local label="$2"
if curl -kfsS --max-time 5 "$url" >/dev/null; then
status OK "$label reachable"
else
status FAIL "$label not reachable"
fi
}
check_api() {
local url="$1"
local body
if ! body="$(remote_cmd "curl -fsS --max-time 5 $url")"; then
status FAIL "API not reachable: $url"
return
fi
if [ "$REMOTE_MODE" -eq 1 ]; then
body="$(printf "%s\n" "$body" | extract_json_line)"
fi
if [ -z "$body" ]; then
status FAIL "API response empty or not JSON"
return
fi
if command -v python3 >/dev/null 2>&1; then
if printf "%s" "$body" | python3 -c 'import json,sys
try:
data=json.load(sys.stdin)
except Exception:
sys.exit(1)
for key in ("services","hostname","uptime_seconds"):
if key in data:
sys.exit(0)
sys.exit(1)
'
then
status OK "API responds with JSON"
else
status WARN "API response did not include expected fields"
fi
else
status WARN "python3 missing; API JSON check skipped"
fi
}
check_firstboot() {
local url="$1"
local body state error_present
if ! body="$(remote_cmd "curl -fsS --max-time 5 $url")"; then
status FAIL "firstboot API not reachable"
return
fi
if [ "$REMOTE_MODE" -eq 1 ]; then
body="$(printf "%s\n" "$body" | extract_json_line)"
fi
if [ -z "$body" ]; then
status FAIL "firstboot status invalid or missing"
return
fi
state="$(printf "%s" "$body" | json_get "state" || true)"
error_present="$(printf "%s" "$body" | json_get "error_present" || true)"
if [ -z "$state" ]; then
status FAIL "firstboot status invalid or missing"
return
fi
if [ "$state" = "done" ] && [ "$error_present" != "true" ]; then
status OK "firstboot completed"
else
status FAIL "firstboot not complete (state=$state error=$error_present)"
fi
}
check_services() {
local services=("nginx" "pikit-api" "dietpi-dashboard-frontend")
for svc in "${services[@]}"; do
if remote_cmd "systemctl is-active --quiet $svc"; then
status OK "$svc active"
else
status FAIL "$svc not active"
fi
done
}
check_ports() {
local cmd="ss -lnt | awk '{print \$4}' | grep -E ':(80|443|5252|5253)\$' | sort -u"
local out
if out="$(remote_cmd "$cmd" 2>/dev/null)"; then
if echo "$out" | grep -q ":80" && echo "$out" | grep -q ":443"; then
status OK "ports 80/443 listening"
else
status WARN "ports 80/443 not both listening"
fi
else
status WARN "unable to check ports"
fi
}
finalize() {
section "Summary"
status OK "warnings: $WARNINGS"
status OK "errors: $ERRORS"
if [ "$ERRORS" -gt 0 ]; then
echo "[FAIL] Smoke test failed."
exit 1
fi
echo "[OK] Smoke test passed."
}
main() {
parse_args "$@"
if [ "$LOCAL_ONLY" -eq 0 ] && ! is_dietpi; then
REMOTE_MODE=1
fi
section "HTTP/HTTPS"
check_http "$PIKIT_HTTP_URL" "HTTP"
check_https "$PIKIT_HTTPS_URL" "HTTPS"
section "API"
check_api "$PIKIT_API_URL/api/status"
section "Firstboot"
check_firstboot "$PIKIT_API_URL/api/firstboot"
section "Services"
check_services
section "Ports"
check_ports
finalize
}
main "$@"

View File

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

View File

@@ -48,6 +48,7 @@
Download Pi-Kit CA Download Pi-Kit CA
</a> </a>
</div> </div>
<p class="subtle">After installing the CA, close and reopen your browser so it takes effect.</p>
<p class="checksum">SHA256: <code id="caHash" class="inline">Loading...</code></p> <p class="checksum">SHA256: <code id="caHash" class="inline">Loading...</code></p>
<details> <details>
<summary id="win">Windows</summary> <summary id="win">Windows</summary>

View File

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

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env sh
# Install as /etc/profile.d/pikit-first-login.sh
# Prints a one-time SSH hardening tip after the forced password change.
FLAG="/var/lib/pikit/first-login.notice"
case "$-" in
*i*) interactive=1 ;;
*) interactive=0 ;;
esac
if [ "$interactive" -eq 1 ] && [ -f "$FLAG" ]; then
echo ""
echo "Pi-Kit: For better security, set up an SSH key and disable password auth once working."
echo " Example: ssh-keygen -t ed25519"
echo " ssh-copy-id dietpi@pikit.local"
echo ""
rm -f "$FLAG" 2>/dev/null || true
fi

View File

@@ -14,6 +14,8 @@ CERT_DIR="/etc/pikit/certs"
WEB_ASSETS="/var/www/pikit-web/assets" WEB_ASSETS="/var/www/pikit-web/assets"
PROFILE_FILE="/etc/pikit/profile.json" PROFILE_FILE="/etc/pikit/profile.json"
MOTD_FILE="/etc/motd" MOTD_FILE="/etc/motd"
FIRSTBOOT_CONF="/etc/pikit/firstboot.conf"
APT_UA_OVERRIDE="/etc/apt/apt.conf.d/51pikit-unattended.conf"
STEPS=( STEPS=(
"Preparing system" "Preparing system"
@@ -26,11 +28,49 @@ STEPS=(
STEP_STATUS=(pending pending pending pending pending pending) STEP_STATUS=(pending pending pending pending pending pending)
CURRENT_STEP="" CURRENT_STEP=""
CURRENT_INDEX=-1 CURRENT_INDEX=-1
PIKIT_FIRSTBOOT_UPDATES="${PIKIT_FIRSTBOOT_UPDATES:-1}"
log() { log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$*" printf '[%s] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$*"
} }
load_config() {
if [ -f "$FIRSTBOOT_CONF" ]; then
# shellcheck disable=SC1090
. "$FIRSTBOOT_CONF"
fi
PIKIT_FIRSTBOOT_UPDATES="${PIKIT_FIRSTBOOT_UPDATES:-1}"
}
skip_updates() {
case "${PIKIT_FIRSTBOOT_UPDATES,,}" in
0|false|no|off) return 0 ;;
esac
return 1
}
configure_unattended_defaults() {
if [ -f "$APT_UA_OVERRIDE" ]; then
log "Unattended-upgrades config already present; skipping defaults."
return
fi
if ! command -v python3 >/dev/null 2>&1; then
log "python3 missing; skipping unattended-upgrades defaults."
return
fi
PYTHONPATH=/usr/local/bin python3 - <<'PY'
import sys
try:
from pikit_api.auto_updates import set_updates_config
except Exception as e:
print(f"pikit_api unavailable: {e}")
sys.exit(0)
set_updates_config({"enable": True, "scope": "security"})
PY
log "Unattended-upgrades defaults applied (security-only)."
}
write_state() { write_state() {
local state="$1" local state="$1"
local current="$2" local current="$2"
@@ -121,6 +161,7 @@ mkdir -p "$FIRSTBOOT_DIR"
exec >>"$LOG_FILE" 2>&1 exec >>"$LOG_FILE" 2>&1
log "Pi-Kit firstboot starting" log "Pi-Kit firstboot starting"
load_config
if [ -f "$DONE_FILE" ]; then if [ -f "$DONE_FILE" ]; then
log "Firstboot already completed; exiting." log "Firstboot already completed; exiting."
@@ -206,14 +247,19 @@ fi
finish_step 2 finish_step 2
begin_step 3 begin_step 3
export DEBIAN_FRONTEND=noninteractive if skip_updates; then
mkdir -p /var/cache/apt/archives/partial /var/lib/apt/lists/partial log "Skipping software updates (PIKIT_FIRSTBOOT_UPDATES=$PIKIT_FIRSTBOOT_UPDATES)."
chmod 755 /var/cache/apt/archives /var/cache/apt/archives/partial /var/lib/apt/lists /var/lib/apt/lists/partial else
apt-get update export DEBIAN_FRONTEND=noninteractive
apt-get -y -o Dpkg::Options::=--force-confold full-upgrade mkdir -p /var/cache/apt/archives/partial /var/lib/apt/lists/partial
chmod 755 /var/cache/apt/archives /var/cache/apt/archives/partial /var/lib/apt/lists /var/lib/apt/lists/partial
apt-get update
apt-get -y -o Dpkg::Options::=--force-confold full-upgrade
fi
finish_step 3 finish_step 3
begin_step 4 begin_step 4
configure_unattended_defaults
if [ -f "$PROFILE_FILE" ] && command -v ufw >/dev/null 2>&1; then if [ -f "$PROFILE_FILE" ] && command -v ufw >/dev/null 2>&1; then
python3 - <<'PY' > /tmp/pikit-profile-ports.txt python3 - <<'PY' > /tmp/pikit-profile-ports.txt
import json import json