From 808934cbec82b23bf50642805db2525bb097a86c Mon Sep 17 00:00:00 2001 From: Aaron Date: Sat, 3 Jan 2026 17:20:18 -0500 Subject: [PATCH] Improve prep and smoke test tooling --- pikit-prep.sh | 56 +++++++++++ pikit-smoke-test.sh | 222 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 278 insertions(+) diff --git a/pikit-prep.sh b/pikit-prep.sh index d0837a6..5b5a49d 100755 --- a/pikit-prep.sh +++ b/pikit-prep.sh @@ -197,6 +197,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=0} + /^[[:space:]]*iface[[:space:]]+/ { + split($0, parts, /[[:space:]]+/); + if (parts[2]==target) { in=1; print "iface " target " inet dhcp"; next; } + else { in=0; } + } + { + if (in==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" @@ -418,6 +452,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 @@ -486,6 +524,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" @@ -595,6 +647,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 diff --git a/pikit-smoke-test.sh b/pikit-smoke-test.sh index 09276bd..519f8ff 100755 --- a/pikit-smoke-test.sh +++ b/pikit-smoke-test.sh @@ -25,6 +25,7 @@ Runs a quick post-prep smoke test: - 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) @@ -77,6 +78,10 @@ remote_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}' } @@ -213,6 +218,188 @@ 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" @@ -246,6 +433,41 @@ main() { 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 }