305 lines
8.7 KiB
Bash
Executable File
305 lines
8.7 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Install as /var/lib/dietpi/postboot.d/10-pikit-firstboot and chmod +x.
|
|
# Runs once on first boot to finalize device-unique setup.
|
|
|
|
set -euo pipefail
|
|
|
|
FIRSTBOOT_DIR="/var/lib/pikit/firstboot"
|
|
STATE_FILE="$FIRSTBOOT_DIR/state.json"
|
|
LOG_FILE="$FIRSTBOOT_DIR/firstboot.log"
|
|
ERROR_FILE="$FIRSTBOOT_DIR/firstboot.error"
|
|
DONE_FILE="$FIRSTBOOT_DIR/firstboot.done"
|
|
LOCK_FILE="$FIRSTBOOT_DIR/firstboot.lock"
|
|
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"
|
|
|
|
STEPS=(
|
|
"Preparing system"
|
|
"Generating security keys"
|
|
"Securing the dashboard"
|
|
"Updating software (this can take a while)"
|
|
"Final checks"
|
|
"Starting Pi-Kit"
|
|
)
|
|
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
|
|
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() {
|
|
local state="$1"
|
|
local current="$2"
|
|
local steps_joined
|
|
local status_joined
|
|
steps_joined=$(IFS='|'; echo "${STEPS[*]}")
|
|
status_joined=$(IFS='|'; echo "${STEP_STATUS[*]}")
|
|
PIKIT_STATE_FILE="$STATE_FILE" PIKIT_STATE="$state" PIKIT_CURRENT_STEP="$current" PIKIT_STEPS="$steps_joined" PIKIT_STEP_STATUSES="$status_joined" \
|
|
python3 - <<'PY'
|
|
import json
|
|
import os
|
|
import pathlib
|
|
from datetime import datetime, timezone
|
|
|
|
state_path = pathlib.Path(os.environ["PIKIT_STATE_FILE"]) if "PIKIT_STATE_FILE" in os.environ else pathlib.Path("/var/lib/pikit/firstboot/state.json")
|
|
state = os.environ.get("PIKIT_STATE", "running")
|
|
current = os.environ.get("PIKIT_CURRENT_STEP") or None
|
|
steps = (os.environ.get("PIKIT_STEPS") or "").split("|")
|
|
statuses = (os.environ.get("PIKIT_STEP_STATUSES") or "").split("|")
|
|
if len(statuses) < len(steps):
|
|
statuses += ["pending"] * (len(steps) - len(statuses))
|
|
updated_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
state_path.write_text(json.dumps({
|
|
"state": state,
|
|
"current_step": current,
|
|
"steps": [{"label": label, "status": status} for label, status in zip(steps, statuses)],
|
|
"updated_at": updated_at,
|
|
}, indent=2))
|
|
PY
|
|
}
|
|
|
|
begin_step() {
|
|
local idx="$1"
|
|
if [ "$CURRENT_INDEX" -ge 0 ]; then
|
|
STEP_STATUS[$CURRENT_INDEX]="done"
|
|
fi
|
|
CURRENT_INDEX="$idx"
|
|
CURRENT_STEP="${STEPS[$idx]}"
|
|
STEP_STATUS[$idx]="current"
|
|
write_state "running" "$CURRENT_STEP"
|
|
log "--- $CURRENT_STEP ---"
|
|
}
|
|
|
|
finish_step() {
|
|
local idx="$1"
|
|
local state="${2:-running}"
|
|
local current="${3:-$CURRENT_STEP}"
|
|
STEP_STATUS[$idx]="done"
|
|
write_state "$state" "$current"
|
|
}
|
|
|
|
clear_motd_block() {
|
|
if [ -f "$MOTD_FILE" ]; then
|
|
sed -i '/^\[Pi-Kit firstboot\]/,/^\[\/Pi-Kit firstboot\]/d' "$MOTD_FILE" || true
|
|
fi
|
|
}
|
|
|
|
write_motd_error() {
|
|
clear_motd_block
|
|
cat >> "$MOTD_FILE" <<'TXT'
|
|
[Pi-Kit firstboot]
|
|
Pi-Kit setup needs attention.
|
|
Error log: sudo cat /var/lib/pikit/firstboot/firstboot.error
|
|
Full log: sudo cat /var/lib/pikit/firstboot/firstboot.log
|
|
If needed: sudo systemctl restart nginx pikit-api
|
|
[/Pi-Kit firstboot]
|
|
TXT
|
|
}
|
|
|
|
handle_error() {
|
|
local line="$1"
|
|
local msg="Firstboot failed at step: ${CURRENT_STEP:-unknown} (line $line)"
|
|
log "$msg"
|
|
STEP_STATUS[$CURRENT_INDEX]="error"
|
|
write_state "error" "${CURRENT_STEP:-}" || true
|
|
echo "$msg" > "$ERROR_FILE"
|
|
if [ -f "$LOG_FILE" ]; then
|
|
echo "--- recent log ---" >> "$ERROR_FILE"
|
|
tail -n 120 "$LOG_FILE" >> "$ERROR_FILE"
|
|
fi
|
|
write_motd_error
|
|
exit 1
|
|
}
|
|
|
|
mkdir -p "$FIRSTBOOT_DIR"
|
|
:> "$LOG_FILE"
|
|
exec >>"$LOG_FILE" 2>&1
|
|
|
|
log "Pi-Kit firstboot starting"
|
|
load_config
|
|
|
|
if [ -f "$DONE_FILE" ]; then
|
|
log "Firstboot already completed; exiting."
|
|
exit 0
|
|
fi
|
|
|
|
rm -f "$ERROR_FILE"
|
|
clear_motd_block
|
|
|
|
exec 9>"$LOCK_FILE"
|
|
if ! flock -n 9; then
|
|
log "Another firstboot run is in progress; exiting."
|
|
exit 0
|
|
fi
|
|
|
|
trap 'handle_error $LINENO' ERR
|
|
|
|
begin_step 0
|
|
mkdir -p "$CERT_DIR" "$WEB_ASSETS"
|
|
if getent group pikit-cert >/dev/null 2>&1; then
|
|
chgrp pikit-cert "$CERT_DIR" || true
|
|
fi
|
|
chmod 750 "$CERT_DIR"
|
|
finish_step 0
|
|
|
|
begin_step 1
|
|
if [ -x /usr/local/bin/pikit-certgen.sh ]; then
|
|
/usr/local/bin/pikit-certgen.sh
|
|
else
|
|
if [ -s "$CERT_DIR/pikit-ca.crt" ] && [ -s "$CERT_DIR/pikit-ca.key" ] && [ -s "$CERT_DIR/pikit.local.crt" ] && [ -s "$CERT_DIR/pikit.local.key" ]; then
|
|
log "TLS certs already present; skipping generation."
|
|
else
|
|
if ! command -v openssl >/dev/null 2>&1; then
|
|
echo "openssl not installed" >&2
|
|
exit 1
|
|
fi
|
|
rm -f "$CERT_DIR/pikit-ca.key" "$CERT_DIR/pikit-ca.crt" "$CERT_DIR/pikit-ca.srl" || true
|
|
rm -f "$CERT_DIR/pikit.local.key" "$CERT_DIR/pikit.local.crt" "$CERT_DIR/pikit.local.csr" || true
|
|
|
|
openssl genrsa -out "$CERT_DIR/pikit-ca.key" 2048
|
|
openssl req -x509 -new -nodes -key "$CERT_DIR/pikit-ca.key" -sha256 -days 3650 \
|
|
-out "$CERT_DIR/pikit-ca.crt" -subj "/CN=Pi-Kit CA"
|
|
|
|
openssl genrsa -out "$CERT_DIR/pikit.local.key" 2048
|
|
openssl req -new -key "$CERT_DIR/pikit.local.key" -out "$CERT_DIR/pikit.local.csr" -subj "/CN=pikit.local"
|
|
|
|
SAN_CFG=$(mktemp)
|
|
cat > "$SAN_CFG" <<'CFG'
|
|
authorityKeyIdentifier=keyid,issuer
|
|
basicConstraints=CA:FALSE
|
|
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
|
|
subjectAltName = @alt_names
|
|
|
|
[alt_names]
|
|
DNS.1 = pikit.local
|
|
DNS.2 = pikit
|
|
CFG
|
|
|
|
openssl x509 -req -in "$CERT_DIR/pikit.local.csr" -CA "$CERT_DIR/pikit-ca.crt" -CAkey "$CERT_DIR/pikit-ca.key" \
|
|
-CAcreateserial -out "$CERT_DIR/pikit.local.crt" -days 825 -sha256 -extfile "$SAN_CFG"
|
|
rm -f "$SAN_CFG" "$CERT_DIR/pikit.local.csr"
|
|
|
|
chmod 600 "$CERT_DIR/pikit-ca.key" "$CERT_DIR/pikit.local.key"
|
|
chmod 644 "$CERT_DIR/pikit-ca.crt" "$CERT_DIR/pikit.local.crt"
|
|
fi
|
|
fi
|
|
finish_step 1
|
|
|
|
begin_step 2
|
|
cp "$CERT_DIR/pikit-ca.crt" "$WEB_ASSETS/pikit-ca.crt"
|
|
chmod 644 "$WEB_ASSETS/pikit-ca.crt"
|
|
if command -v sha256sum >/dev/null 2>&1; then
|
|
sha256sum "$WEB_ASSETS/pikit-ca.crt" | awk '{print $1}' > "$WEB_ASSETS/pikit-ca.sha256"
|
|
elif command -v openssl >/dev/null 2>&1; then
|
|
openssl dgst -sha256 "$WEB_ASSETS/pikit-ca.crt" | awk '{print $2}' > "$WEB_ASSETS/pikit-ca.sha256"
|
|
fi
|
|
if [ -s "$WEB_ASSETS/pikit-ca.sha256" ]; then
|
|
chmod 644 "$WEB_ASSETS/pikit-ca.sha256"
|
|
fi
|
|
if command -v systemctl >/dev/null 2>&1; then
|
|
systemctl reload nginx || systemctl restart nginx
|
|
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
|
|
configure_unattended_defaults
|
|
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
|
|
|
|
if [ ! -f "$WEB_ASSETS/pikit-ca.crt" ]; then
|
|
echo "CA bundle missing in web assets" >&2
|
|
exit 1
|
|
fi
|
|
finish_step 4
|
|
|
|
begin_step 5
|
|
touch "$DONE_FILE"
|
|
touch /var/run/pikit-ready
|
|
finish_step 5 "done" "${STEPS[5]}"
|
|
|
|
log "Pi-Kit firstboot complete"
|
|
exit 0
|