Files
stack/hw/nanny.sh
kert 59eb56f659
Some checks failed
CI / skinny-install (aco) (push) Successful in 1m18s
CI / skinny-install (api) (push) Successful in 40s
CI / skinny-install (bcda) (push) Successful in 35s
CI / skinny-install (bib) (push) Successful in 38s
CI / skinny-install (cli) (push) Successful in 46s
CI / skinny-install (conf) (push) Successful in 36s
CI / skinny-install (opps) (push) Successful in 38s
CI / skinny-install (pfs) (push) Successful in 47s
CI / skinny-install (rex) (push) Successful in 35s
Infra CI / notebooks (push) Successful in 3m17s
CI / lint-test (push) Failing after 3m30s
CI / skinny-install (bls) (push) Successful in 34s
CI / skinny-install (ccw) (push) Successful in 45s
CI / skinny-install (cms) (push) Successful in 32s
CI / skinny-install (perf) (push) Successful in 43s
Deploy / build-scan-report (push) Has been cancelled
Infra CI / docs (push) Failing after 20s
Infra CI / api (push) Successful in 16s
Infra CI / mc (push) Successful in 12s
Package Supply Chain / pkg-supply-chain (push) Successful in 1m27s
Infra CI / zotero (push) Successful in 6m10s
chore: hw provisioning, test coverage, deps
2026-04-09 22:26:31 -04:00

937 lines
32 KiB
Bash
Executable File

#!/bin/bash
#
# nanny.sh — Full hardware enrollment for storage node build
#
# Tracks state in nanny.state so it can resume after interruption.
# Plug in drives/keys one at a time. Walks through Cloudflare setup.
#
# Outputs:
# drives.conf — 8 drive fingerprints
# drive-resolver.sh — Serial-to-device resolver for runtime
# yubikeys.conf — 2 YubiKey fingerprints
# ssh-keys/ — FIDO2 SSH key pairs
# authorized_keys — Ready for server
# cloudflare.conf — Tunnel token, certs, credentials
# nanny.state — Checkpoint state (for resume)
# nanny.log — Full session log
#
set -euo pipefail
WORK="$(pwd)"
CONF="${WORK}/drives.conf"
YKCONF="${WORK}/yubikeys.conf"
CFCONF="${WORK}/cloudflare.conf"
STATE="${WORK}/nanny.state"
LOG="${WORK}/nanny.log"
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
CYAN='\033[0;36m'
NC='\033[0m'
_log() { echo -e "$1" | tee -a "$LOG"; }
log() { _log "${GREEN}[+]${NC} $*"; }
warn() { _log "${YELLOW}[!]${NC} $*"; }
err() { _log "${RED}[!]${NC} $*"; exit 1; }
info() { _log "${CYAN}[i]${NC} $*"; }
[ "$(id -u)" -eq 0 ] || err "Must run as root (sudo ./nanny.sh)"
# ===================================================================
# State management
# ===================================================================
touch "$STATE" "$LOG"
state_done() {
grep -qx "$1" "$STATE" 2>/dev/null
}
state_mark() {
if ! state_done "$1"; then
echo "$1" >> "$STATE"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] STATE: $1 completed" >> "$LOG"
fi
}
state_show() {
echo ""
echo "─── Current State ───"
local steps=(
"phase1_init:Phase 1 init"
"drive_sata_1:SATA drive #1"
"drive_sata_2:SATA drive #2"
"drive_sata_3:SATA drive #3"
"drive_sata_4:SATA drive #4"
"drive_sata_5:SATA drive #5"
"drive_sata_6:SATA drive #6"
"drive_nvme:NVMe Docker drive"
"drive_hdd:HDD backup drive"
"phase1_resolver:Drive resolver generated"
"yubikey_1:YubiKey #1 (primary)"
"yubikey_2:YubiKey #2 (backup)"
"phase2_authkeys:authorized_keys generated"
"cf_account:Cloudflare account verified"
"cf_tunnel:Tunnel created"
"cf_routes:Public hostname routes"
"cf_access:Access policies (YubiKey gate)"
"cf_ssl:SSL/TLS mode"
"cf_api_token:API token"
"cf_cert:Origin certificate"
"cf_warp:WARP config"
"cf_rustfs_creds:RustFS credentials"
"cf_network:Network config"
)
for entry in "${steps[@]}"; do
key="${entry%%:*}"
label="${entry#*:}"
if state_done "$key"; then
echo -e " ${GREEN}[done]${NC} ${label}"
else
echo -e " ${CYAN}[todo]${NC} ${label}"
fi
done
echo ""
}
# ===================================================================
# Drive detection helpers
# ===================================================================
snapshot_devices() {
lsblk -dno NAME,TYPE 2>/dev/null | awk '$2=="disk"{print $1}' | sort
}
get_drive_info() {
local dev="$1"
local devpath="/dev/${dev}"
DRIVE_MODEL=$(lsblk -dno MODEL "$devpath" 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
DRIVE_SERIAL=$(lsblk -dno SERIAL "$devpath" 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
DRIVE_SIZE=$(lsblk -dno SIZE "$devpath" 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
DRIVE_SIZE_BYTES=$(blockdev --getsize64 "$devpath" 2>/dev/null || echo "unknown")
DRIVE_TRAN=$(lsblk -dno TRAN "$devpath" 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
DRIVE_WWN=$(lsblk -dno WWN "$devpath" 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
DRIVE_REV=$(lsblk -dno REV "$devpath" 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
DRIVE_VENDOR=$(lsblk -dno VENDOR "$devpath" 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
if [[ "$dev" == nvme* ]]; then
DRIVE_TRAN="nvme"
if command -v nvme &>/dev/null; then
local ns_serial
ns_serial=$(nvme id-ctrl "$devpath" 2>/dev/null | grep "^sn " | awk '{print $3}')
[ -n "$ns_serial" ] && DRIVE_SERIAL="$ns_serial"
fi
fi
# Fallback serial sources
if [ -z "$DRIVE_SERIAL" ] || [ "$DRIVE_SERIAL" = "" ]; then
DRIVE_SERIAL=$(udevadm info --query=property --name="$devpath" 2>/dev/null | grep "^ID_SERIAL_SHORT=" | cut -d= -f2)
fi
if [ -z "$DRIVE_SERIAL" ] || [ "$DRIVE_SERIAL" = "" ]; then
DRIVE_SERIAL=$(udevadm info --query=property --name="$devpath" 2>/dev/null | grep "^ID_SERIAL=" | cut -d= -f2)
fi
if [ -z "$DRIVE_WWN" ] || [ "$DRIVE_WWN" = "" ]; then
DRIVE_WWN=$(udevadm info --query=property --name="$devpath" 2>/dev/null | grep "^ID_WWN=" | cut -d= -f2)
fi
}
print_drive_info() {
echo ""
echo " ┌─────────────────────────────────────────────"
echo " │ Device: /dev/${1}"
echo " │ Model: ${DRIVE_MODEL:-unknown}"
echo " │ Serial: ${DRIVE_SERIAL:-unknown}"
echo " │ WWN: ${DRIVE_WWN:-none}"
echo " │ Firmware: ${DRIVE_REV:-unknown}"
echo " │ Size: ${DRIVE_SIZE} (${DRIVE_SIZE_BYTES} bytes)"
echo " │ Transport: ${DRIVE_TRAN:-unknown}"
echo " │ Vendor: ${DRIVE_VENDOR:-unknown}"
echo " └─────────────────────────────────────────────"
echo ""
}
is_serial_enrolled() {
grep -q "|${1}|" "$CONF" 2>/dev/null || grep -q "=${1}|" "$CONF" 2>/dev/null
}
# Detect and enroll a single drive. Returns 0 on success.
enroll_one_drive() {
local role_key="$1" # e.g. SATA_3, NVME_DOCKER, HDD_BACKUP
local role_desc="$2" # e.g. "SATA storage #3"
local target_tran="$3" # sata or nvme
echo ""
echo -e "${CYAN} Waiting for: ${role_desc}${NC}"
echo ""
BEFORE=$(snapshot_devices)
echo -n "Plug in the drive, then press Enter... "
read -r
sleep 2
AFTER=$(snapshot_devices)
NEW_DEV=$(comm -13 <(echo "$BEFORE") <(echo "$AFTER") | head -1)
# Rescan if not found
if [ -z "$NEW_DEV" ]; then
warn "No new device detected. Rescanning..."
for host in /sys/class/scsi_host/host*/scan; do
echo "- - -" > "$host" 2>/dev/null || true
done
sleep 3
AFTER=$(snapshot_devices)
NEW_DEV=$(comm -13 <(echo "$BEFORE") <(echo "$AFTER") | head -1)
fi
if [ -z "$NEW_DEV" ]; then
warn "Still no new device found."
echo "Current devices:"
lsblk -d -o NAME,SIZE,MODEL,SERIAL,TRAN | grep -v "^loop"
echo ""
echo -n "Enter device name manually (e.g., sda) or 'skip': "
read -r manual_dev
[ "$manual_dev" = "skip" ] && return 1
NEW_DEV="$manual_dev"
fi
[ -b "/dev/${NEW_DEV}" ] || { warn "/dev/${NEW_DEV} not found"; return 1; }
get_drive_info "$NEW_DEV"
print_drive_info "$NEW_DEV"
# Duplicate serial check
if [ -n "$DRIVE_SERIAL" ] && is_serial_enrolled "$DRIVE_SERIAL"; then
warn "This serial (${DRIVE_SERIAL}) is already enrolled."
warn "Your USB adapter may be reporting its own serial."
warn "Try a different adapter."
echo -n "Skip? [Y/n] "
read -r dup_skip
[ "$dup_skip" = "n" ] || return 1
fi
# Missing serial
if [ -z "$DRIVE_SERIAL" ] || [ "$DRIVE_SERIAL" = "unknown" ]; then
warn "Cannot read serial number."
echo " 1) Try a different USB adapter"
echo " 2) Enter serial manually (from drive label)"
echo " 3) Skip"
echo -n "Choice [1/2/3]: "
read -r serial_fix
case "$serial_fix" in
2)
echo -n "Enter drive serial: "
read -r DRIVE_SERIAL
[ -n "$DRIVE_SERIAL" ] || return 1
;;
*) return 1 ;;
esac
fi
# Confirm
echo ""
echo -e " ${CYAN}Enrolling as: ${role_desc}${NC}"
echo " Serial: ${DRIVE_SERIAL}"
echo " Model: ${DRIVE_MODEL}"
echo " Target: ${target_tran} (on server)"
echo ""
echo -n "Confirm? [Y/n] "
read -r confirm
[ "$confirm" = "n" ] || [ "$confirm" = "N" ] && return 1
# Write to manifest
echo "DRIVE_${role_key}=${DRIVE_SERIAL}|${DRIVE_MODEL}|${DRIVE_WWN:-none}|${DRIVE_SIZE_BYTES}|${target_tran}|enrolled_via=${DRIVE_TRAN}" >> "$CONF"
log "Enrolled: ${role_desc}${DRIVE_MODEL} (${DRIVE_SERIAL})"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] DRIVE: ${role_key} serial=${DRIVE_SERIAL} model=${DRIVE_MODEL}" >> "$LOG"
return 0
}
# ===================================================================
# PHASE 1: Drive Enrollment
# ===================================================================
echo ""
echo "=========================================="
echo " STORAGE NODE — Hardware Enrollment"
echo "=========================================="
state_show
echo "This script tracks state in nanny.state."
echo "If interrupted, re-run to resume where you left off."
echo ""
warn "All drives connected via USB for enrollment."
warn "Serials must come from the DRIVE, not the USB adapter."
echo ""
# Init drives.conf if needed
if ! state_done "phase1_init"; then
if [ -f "$CONF" ] && grep -q "^DRIVE_" "$CONF" 2>/dev/null; then
echo "Found existing drives.conf."
echo -n "Resume with existing drives (r) or start fresh (f)? [r/f] "
read -r choice
if [ "$choice" = "f" ]; then
rm -f "$CONF"
fi
fi
if [ ! -f "$CONF" ]; then
cat > "$CONF" << 'HEADER'
#
# Drive enrollment manifest — generated by nanny.sh
# Used by storage-setup.sh to identify drives by hardware serial
#
# Format: DRIVE_<role>=<serial>|<model>|<wwn>|<size_bytes>|<target_transport>|enrolled_via=<usb_transport>
#
HEADER
fi
state_mark "phase1_init"
fi
# Enroll SATA drives 1-6
for i in 1 2 3 4 5 6; do
state_key="drive_sata_${i}"
if state_done "$state_key"; then
continue
fi
echo ""
echo "─────────────────────────────────────────────"
echo " SATA Drive ${i}/6 — WD Blue SA510 for RustFS LVM"
echo "─────────────────────────────────────────────"
while ! state_done "$state_key"; do
if enroll_one_drive "SATA_${i}" "SATA storage #${i}" "sata"; then
state_mark "$state_key"
else
echo -n "Retry this drive? [Y/n] "
read -r retry
[ "$retry" = "n" ] && err "Cannot continue without all 6 SATA drives."
fi
done
done
# Enroll NVMe Docker drive
if ! state_done "drive_nvme"; then
echo ""
echo "─────────────────────────────────────────────"
echo " NVMe Drive — Modern 2TB for Docker/writes"
echo "─────────────────────────────────────────────"
while ! state_done "drive_nvme"; do
if enroll_one_drive "NVME_DOCKER" "NVMe Docker/writes" "nvme"; then
state_mark "drive_nvme"
else
echo -n "Retry? [Y/n] "
read -r retry
[ "$retry" = "n" ] && err "Cannot continue without NVMe drive."
fi
done
fi
# Enroll HDD backup drive
if ! state_done "drive_hdd"; then
echo ""
echo "─────────────────────────────────────────────"
echo " HDD — Spinning disk for nightly backups"
echo "─────────────────────────────────────────────"
while ! state_done "drive_hdd"; do
if enroll_one_drive "HDD_BACKUP" "HDD nightly backup" "sata"; then
state_mark "drive_hdd"
else
echo -n "Retry? [Y/n] "
read -r retry
[ "$retry" = "n" ] && err "Cannot continue without HDD backup drive."
fi
done
fi
# Generate drive-resolver.sh
if ! state_done "phase1_resolver"; then
log "Generating hardware-pinned drive resolver..."
cat > "${WORK}/drive-resolver.sh" << 'RESOLVER_HEAD'
#!/bin/sh
#
# drive-resolver.sh — Resolve enrolled drive serials to current /dev/ paths
# Generated by nanny.sh from actual hardware fingerprints
#
# Source this in storage-setup.sh: . /root/drive-resolver.sh
#
resolve_drive() {
local target_serial="$1"
local result=""
for dev in /sys/block/*; do
[ -d "$dev" ] || continue
devname=$(basename "$dev")
case "$devname" in
loop*|ram*|dm-*|md*|sr*|zram*) continue ;;
esac
devpath="/dev/${devname}"
[ -b "$devpath" ] || continue
serial=$(lsblk -dno SERIAL "$devpath" 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
if [ -z "$serial" ]; then
serial=$(udevadm info --query=property --name="$devpath" 2>/dev/null | grep "^ID_SERIAL_SHORT=" | cut -d= -f2)
fi
if [ -z "$serial" ]; then
serial=$(udevadm info --query=property --name="$devpath" 2>/dev/null | grep "^ID_SERIAL=" | cut -d= -f2)
fi
if [ "$serial" = "$target_serial" ]; then
result="$devpath"
break
fi
done
echo "$result"
}
echo "Resolving enrolled drives to current device paths..."
echo ""
RESOLVER_HEAD
# Append serial lookups
{
echo "# ── SATA drives (RustFS LVM volume group) ──"
echo "SATA_DRIVES=\"\""
for i in 1 2 3 4 5 6; do
line=$(grep "^DRIVE_SATA_${i}=" "$CONF" 2>/dev/null || true)
if [ -n "$line" ]; then
serial=$(echo "$line" | cut -d= -f2 | cut -d'|' -f1)
model=$(echo "$line" | cut -d'|' -f2)
echo ""
echo "# SATA #${i}: ${model} (${serial})"
echo "DEV_SATA_${i}=\$(resolve_drive \"${serial}\")"
echo "[ -n \"\$DEV_SATA_${i}\" ] || { echo \"ERROR: Cannot find SATA drive #${i} (serial: ${serial})\"; exit 1; }"
echo "echo \" SATA #${i}: \${DEV_SATA_${i}} → ${model}\""
echo "SATA_DRIVES=\"\${SATA_DRIVES} \${DEV_SATA_${i}}\""
fi
done
echo ""
echo "# Trim leading space"
echo "SATA_DRIVES=\$(echo \$SATA_DRIVES | sed 's/^ //')"
echo ""
echo "# ── NVMe drive (Docker/writes) ──"
line=$(grep "^DRIVE_NVME_DOCKER=" "$CONF" 2>/dev/null || true)
if [ -n "$line" ]; then
serial=$(echo "$line" | cut -d= -f2 | cut -d'|' -f1)
model=$(echo "$line" | cut -d'|' -f2)
echo ""
echo "# NVMe Docker: ${model} (${serial})"
echo "DEV_NVME=\$(resolve_drive \"${serial}\")"
echo "[ -n \"\$DEV_NVME\" ] || { echo \"ERROR: Cannot find NVMe Docker drive (serial: ${serial})\"; exit 1; }"
echo "echo \" NVMe: \${DEV_NVME} → ${model}\""
fi
echo ""
echo "# ── HDD backup drive ──"
line=$(grep "^DRIVE_HDD_BACKUP=" "$CONF" 2>/dev/null || true)
if [ -n "$line" ]; then
serial=$(echo "$line" | cut -d= -f2 | cut -d'|' -f1)
model=$(echo "$line" | cut -d'|' -f2)
echo ""
echo "# HDD Backup: ${model} (${serial})"
echo "DEV_HDD=\$(resolve_drive \"${serial}\")"
echo "[ -n \"\$DEV_HDD\" ] || { echo \"ERROR: Cannot find HDD backup drive (serial: ${serial})\"; exit 1; }"
echo "echo \" HDD: \${DEV_HDD} → ${model}\""
fi
echo ""
echo "echo \"\""
echo "echo \"All drives resolved successfully.\""
} >> "${WORK}/drive-resolver.sh"
chmod +x "${WORK}/drive-resolver.sh"
log "Generated: drive-resolver.sh"
state_mark "phase1_resolver"
fi
# ===================================================================
# PHASE 2: YubiKey Enrollment
# ===================================================================
echo ""
echo "=========================================="
echo " YUBIKEY ENROLLMENT"
echo "=========================================="
state_show
for cmd in ykman ssh-keygen; do
if ! command -v "$cmd" &>/dev/null; then
err "Required tool '${cmd}' not found. Install it first."
fi
done
mkdir -p "${WORK}/ssh-keys"
# Init yubikeys.conf if needed
if [ ! -f "$YKCONF" ]; then
cat > "$YKCONF" << 'YKHEADER'
#
# YubiKey enrollment manifest — generated by nanny.sh
#
# Format: YUBIKEY_<N>=<serial>|<model>|<firmware>|<ssh_pubkey_file>
#
YKHEADER
fi
for N in 1 2; do
state_key="yubikey_${N}"
if state_done "$state_key"; then
continue
fi
LABEL="PRIMARY"
[ "$N" -eq 2 ] && LABEL="BACKUP"
echo ""
echo "─────────────────────────────────────────────"
echo " YubiKey ${N}/2 (${LABEL})"
echo "─────────────────────────────────────────────"
echo ""
echo -n "Insert YubiKey #${N} (${LABEL}) and press Enter... "
read -r
sleep 2
YK_INFO=$(ykman info 2>/dev/null) || {
warn "Cannot read YubiKey. Is it inserted?"
echo -n "Retry? [Y/n] "
read -r retry
[ "$retry" = "n" ] && err "Cannot continue without YubiKey #${N}."
sleep 1
YK_INFO=$(ykman info 2>/dev/null) || err "Still cannot read YubiKey."
}
YK_SERIAL=$(echo "$YK_INFO" | grep "Serial number:" | awk '{print $NF}')
YK_FW=$(echo "$YK_INFO" | grep "Firmware version:" | awk '{print $NF}')
YK_TYPE=$(echo "$YK_INFO" | grep "Device type:" | sed 's/Device type:[[:space:]]*//')
YK_FORM=$(echo "$YK_INFO" | grep "Form factor:" | sed 's/Form factor:[[:space:]]*//')
echo ""
echo " ┌─────────────────────────────────────────────"
echo " │ YubiKey ${N} (${LABEL})"
echo " │ Type: ${YK_TYPE:-unknown}"
echo " │ Serial: ${YK_SERIAL:-unknown}"
echo " │ Firmware: ${YK_FW:-unknown}"
echo " │ Form: ${YK_FORM:-unknown}"
echo " └─────────────────────────────────────────────"
echo ""
[ -n "$YK_SERIAL" ] || err "Could not read YubiKey serial number."
if grep -q "${YK_SERIAL}" "$YKCONF" 2>/dev/null; then
warn "YubiKey ${YK_SERIAL} is already enrolled. Remove it and insert the other one."
continue
fi
# Check / program slot 2
info "Checking OTP slot 2 (challenge-response)..."
SLOT2_STATUS=$(ykman otp info 2>/dev/null | grep "Slot 2:" || true)
if echo "$SLOT2_STATUS" | grep -qi "programmed"; then
log "Slot 2 is programmed."
info "Testing challenge-response (touch the key if it blinks)..."
if echo -n "nanny-test" | ykman otp calculate 2 - &>/dev/null; then
log "Challenge-response working."
else
warn "Challenge-response test failed."
echo -n "Program slot 2 now? [Y/n] "
read -r prog
[ "$prog" = "n" ] || ykman otp chalresp --touch --generate 2 --force
fi
else
warn "Slot 2 is empty."
echo -n "Program slot 2 with HMAC-SHA1 challenge-response? [Y/n] "
read -r prog
[ "$prog" = "n" ] || ykman otp chalresp --touch --generate 2 --force
fi
# FIDO2 SSH key
SSH_KEY_FILE="${WORK}/ssh-keys/yubikey${N}_storagenode"
SSH_KEY_TYPE="ed25519-sk"
FIDO_STATUS=$(ykman fido info 2>/dev/null) || true
if [ -z "$FIDO_STATUS" ]; then
warn "FIDO2 not available (needs firmware 5.0+). Using standard ed25519."
SSH_KEY_TYPE="ed25519"
fi
if [ ! -f "${SSH_KEY_FILE}.pub" ]; then
if [ "$SSH_KEY_TYPE" = "ed25519-sk" ]; then
info "Generating FIDO2 SSH key (touch the key when it blinks)..."
ssh-keygen -t ed25519-sk \
-O resident \
-O application=ssh:storagenode \
-C "yubikey-${N}-storagenode-${YK_SERIAL}" \
-f "$SSH_KEY_FILE" \
-N ""
else
ssh-keygen -t ed25519 \
-C "yubikey-${N}-storagenode-${YK_SERIAL}" \
-f "$SSH_KEY_FILE" \
-N ""
fi
log "SSH key generated: ${SSH_KEY_FILE}.pub"
else
log "SSH key already exists: ${SSH_KEY_FILE}.pub"
fi
echo "YUBIKEY_${N}=${YK_SERIAL}|${YK_TYPE}|${YK_FW}|yubikey${N}_storagenode.pub" >> "$YKCONF"
log "Enrolled YubiKey #${N} (${LABEL}): ${YK_TYPE} serial ${YK_SERIAL}"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] YUBIKEY: #${N} serial=${YK_SERIAL} type=${YK_TYPE}" >> "$LOG"
state_mark "$state_key"
if [ "$N" -eq 1 ]; then
echo ""
warn "Remove YubiKey #1 and insert #2 (backup)."
fi
done
# Generate authorized_keys
if ! state_done "phase2_authkeys"; then
log "Generating authorized_keys..."
{
echo "# Generated by nanny.sh — YubiKey SSH public keys for storagenode"
for kf in "${WORK}"/ssh-keys/*.pub; do
[ -f "$kf" ] || continue
echo "# $(basename "$kf")"
cat "$kf"
echo ""
done
} > "${WORK}/authorized_keys"
state_mark "phase2_authkeys"
fi
# ===================================================================
# PHASE 3: Cloudflare Configuration
# ===================================================================
echo ""
echo "=========================================="
echo " CLOUDFLARE SETUP"
echo "=========================================="
state_show
[ -f "$CFCONF" ] || cat > "$CFCONF" << 'CFHEADER'
#
# Cloudflare configuration — generated by nanny.sh
#
CFHEADER
# 3a. Cloudflare account
if ! state_done "cf_account"; then
echo ""
echo "─── Step 1: Cloudflare Account ───"
echo ""
echo "Verify fhirworx.io is active at https://dash.cloudflare.com"
echo ""
echo -n "Is fhirworx.io active on Cloudflare? [Y/n] "
read -r cf_active
if [ "$cf_active" = "n" ]; then
echo "Add fhirworx.io and update nameservers first."
echo -n "Press Enter when ready..."
read -r
fi
state_mark "cf_account"
fi
# 3b. Create tunnel
if ! state_done "cf_tunnel"; then
echo ""
echo "─── Step 2: Create Cloudflare Tunnel ───"
echo ""
echo "1. Go to: https://one.dash.cloudflare.com"
echo "2. Networks → Tunnels → Create a tunnel"
echo "3. Type: Cloudflared"
echo "4. Name: rig"
echo "5. Copy the token (starts with eyJ...)"
echo " DO NOT install the connector — we run it in Docker."
echo ""
echo -n "Paste tunnel token: "
read -r TUNNEL_TOKEN
if [ -n "$TUNNEL_TOKEN" ]; then
echo "CF_TUNNEL_TOKEN=${TUNNEL_TOKEN}" >> "$CFCONF"
log "Tunnel token saved."
else
echo "CF_TUNNEL_TOKEN=PASTE_YOUR_TOKEN_HERE" >> "$CFCONF"
warn "No token — add it later in cloudflare.conf."
fi
state_mark "cf_tunnel"
fi
# 3c. Public hostnames
if ! state_done "cf_routes"; then
echo ""
echo "─── Step 3: Public Hostname Routes ───"
echo ""
echo "In the tunnel config → Public Hostname tab, add:"
echo ""
echo " ┌────────────────────────────────┬──────────────────────┐"
echo " │ s3.rig.fhirworx.io │ http://rustfs:9000 │"
echo " │ console.rig.fhirworx.io │ http://rustfs:9001 │"
echo " │ rig.fhirworx.io │ http://rustfs:9000 │"
echo " └────────────────────────────────┴──────────────────────┘"
echo ""
echo " Type: HTTP | No TLS Verify: ON"
echo ""
echo -n "Routes added? [Y/n] "
read -r routes_done
[ "$routes_done" = "n" ] && { echo -n "Press Enter when done..."; read -r; }
state_mark "cf_routes"
fi
# 3d. Access policies — YubiKey gate
if ! state_done "cf_access"; then
echo ""
echo "─── Step 4: Cloudflare Access (YubiKey gate) ───"
echo ""
echo "Settings → Authentication → Login methods:"
echo " - Enable 'Hardware Keys' (WebAuthn/FIDO2)"
echo " - DISABLE all other methods (OTP, Google, etc.)"
echo ""
echo -n "Hardware Keys is the ONLY login method? [Y/n] "
read -r hw_ok
[ "$hw_ok" = "n" ] && { echo "Fix this first."; echo -n "Press Enter when done..."; read -r; }
echo ""
echo "Create Access Applications:"
echo ""
echo " App 1: 'RustFS Console'"
echo " Domain: console.rig.fhirworx.io"
echo " Policy: Allow | Include: your email | Require: auth method = hwk"
echo ""
echo " App 2: 'RustFS S3 API'"
echo " Domains: s3.rig.fhirworx.io + rig.fhirworx.io"
echo " Policy 1: Allow (same hwk policy)"
echo " Policy 2: Service Auth (for programmatic access)"
echo ""
echo -n "Both Access applications created? [Y/n] "
read -r access_ok
[ "$access_ok" = "n" ] && { echo -n "Press Enter when done..."; read -r; }
echo ""
echo -n "Create a Service Token for S3 API? [Y/n] "
read -r create_svc
if [ "$create_svc" != "n" ]; then
echo " Access → Service Auth → Create Service Token → name: rig-s3-api"
echo -n "CF-Access-Client-Id: "
read -r CF_SVC_ID
echo -n "CF-Access-Client-Secret: "
read -rs CF_SVC_SECRET
echo ""
if [ -n "$CF_SVC_ID" ] && [ -n "$CF_SVC_SECRET" ]; then
echo "CF_SVC_CLIENT_ID=${CF_SVC_ID}" >> "$CFCONF"
echo "CF_SVC_CLIENT_SECRET=${CF_SVC_SECRET}" >> "$CFCONF"
log "Service token saved."
fi
fi
echo "CF_ACCESS_CONFIGURED=true" >> "$CFCONF"
state_mark "cf_access"
fi
# 3e. SSL
if ! state_done "cf_ssl"; then
echo ""
echo "─── Step 5: SSL/TLS Mode ───"
echo ""
echo "dash.cloudflare.com → fhirworx.io → SSL/TLS → Overview"
echo "Set to: Full"
echo ""
echo -n "Done? [Y/n] "
read -r ssl_ok
[ "$ssl_ok" = "n" ] && echo "Set it before deploying."
state_mark "cf_ssl"
fi
# 3f. API token
if ! state_done "cf_api_token"; then
echo ""
echo "─── Step 6: API Token (optional) ───"
echo ""
echo "For DNS challenge cert renewal."
echo "Create at: https://dash.cloudflare.com/profile/api-tokens"
echo "Template: 'Edit zone DNS', scope: fhirworx.io"
echo ""
echo -n "Paste API token (Enter to skip): "
read -r CF_API_TOKEN
if [ -n "$CF_API_TOKEN" ]; then
echo "CF_API_TOKEN=${CF_API_TOKEN}" >> "$CFCONF"
mkdir -p "${WORK}/certs"
echo "dns_cloudflare_api_token = ${CF_API_TOKEN}" > "${WORK}/certs/cf-credentials.ini"
chmod 600 "${WORK}/certs/cf-credentials.ini"
log "API token saved."
fi
state_mark "cf_api_token"
fi
# 3g. Origin cert
if ! state_done "cf_cert"; then
echo ""
echo "─── Step 7: Origin Certificate ───"
echo ""
echo " 1) Skip — tunnel handles everything"
echo " 2) Cloudflare Origin CA — 15 year, CF-trusted only"
echo " 3) Let's Encrypt — trusted everywhere"
echo ""
echo -n "Choose [1/2/3]: "
read -r cert_choice
case "$cert_choice" in
2)
echo ""
echo "dash.cloudflare.com → fhirworx.io → SSL/TLS → Origin Server"
echo "Create: ECDSA, hosts: rig.fhirworx.io + *.rig.fhirworx.io, 15 years"
echo ""
mkdir -p "${WORK}/certs"
echo "Paste CERTIFICATE PEM, then Ctrl+D:"
cat > "${WORK}/certs/origin.pem"
echo "Paste PRIVATE KEY PEM, then Ctrl+D:"
cat > "${WORK}/certs/origin-key.pem"
chmod 600 "${WORK}/certs/origin-key.pem"
echo "CF_CERT_TYPE=origin-ca" >> "$CFCONF"
log "Origin CA certificate saved."
;;
3)
echo "CF_CERT_TYPE=letsencrypt" >> "$CFCONF"
log "Let's Encrypt will be configured on the server."
;;
*)
echo "CF_CERT_TYPE=none" >> "$CFCONF"
;;
esac
state_mark "cf_cert"
fi
# 3h. WARP
if ! state_done "cf_warp"; then
echo ""
echo "─── Step 8: Cloudflare WARP ───"
echo ""
echo -n "Enable WARP on the server? [Y/n] "
read -r warp_choice
if [ "$warp_choice" = "n" ]; then
echo "CF_WARP_ENABLED=false" >> "$CFCONF"
else
echo "CF_WARP_ENABLED=true" >> "$CFCONF"
log "WARP enabled."
fi
state_mark "cf_warp"
fi
# 3i. RustFS credentials
if ! state_done "cf_rustfs_creds"; then
echo ""
echo "─── Step 9: RustFS Internal Credentials ───"
echo ""
echo "RustFS needs an internal admin user/password."
echo "This is behind Cloudflare Access (YubiKey required first)."
echo ""
RUSTFS_USER="admin"
RUSTFS_PASS=$(head -c 32 /dev/urandom | base64 | tr -d '/+=' | head -c 24)
echo " Auto-generated (Access is the real gate):"
echo " Username: ${RUSTFS_USER}"
echo " Password: ${RUSTFS_PASS}"
echo ""
echo -n "Accept or enter custom? [accept/custom] "
read -r cred_choice
if [ "$cred_choice" = "custom" ]; then
echo -n "Username [admin]: "
read -r RUSTFS_USER
RUSTFS_USER="${RUSTFS_USER:-admin}"
while true; do
echo -n "Password (min 8 chars): "
read -rs RUSTFS_PASS
echo ""
[ ${#RUSTFS_PASS} -ge 8 ] || { warn "Too short."; continue; }
echo -n "Confirm: "
read -rs RUSTFS_PASS2
echo ""
[ "$RUSTFS_PASS" = "$RUSTFS_PASS2" ] || { warn "Mismatch."; continue; }
break
done
fi
echo "RUSTFS_ROOT_USER=${RUSTFS_USER}" >> "$CFCONF"
echo "RUSTFS_ROOT_PASSWORD=${RUSTFS_PASS}" >> "$CFCONF"
log "RustFS credentials saved."
state_mark "cf_rustfs_creds"
fi
# 3j. Network
if ! state_done "cf_network"; then
echo ""
echo "─── Step 10: Server Network ───"
echo ""
echo -n "Static IP (e.g., 192.168.1.100): "
read -r STATIC_IP
echo -n "CIDR mask (e.g., 24): "
read -r MASK
MASK="${MASK:-24}"
echo -n "Gateway (e.g., 192.168.1.1): "
read -r GW
echo -n "DNS [1.1.1.1]: "
read -r DNS
DNS="${DNS:-1.1.1.1}"
if [ -n "$STATIC_IP" ] && [ -n "$GW" ]; then
echo "NET_STATIC_IP=${STATIC_IP}/${MASK}" >> "$CFCONF"
echo "NET_GATEWAY=${GW}" >> "$CFCONF"
echo "NET_DNS=${DNS}" >> "$CFCONF"
log "Network: ${STATIC_IP}/${MASK} via ${GW}"
else
warn "Incomplete — will use DHCP."
fi
state_mark "cf_network"
fi
# ===================================================================
# SUMMARY
# ===================================================================
echo ""
echo ""
echo "=========================================="
echo " ENROLLMENT COMPLETE"
echo "=========================================="
state_show
echo "── Files ──"
for f in drives.conf drive-resolver.sh yubikeys.conf authorized_keys cloudflare.conf; do
[ -f "${WORK}/${f}" ] && echo -e " ${GREEN}[ok]${NC} ${f}" || echo -e " ${RED}[!!]${NC} ${f}"
done
[ -d "${WORK}/ssh-keys" ] && echo -e " ${GREEN}[ok]${NC} ssh-keys/ ($(ls "${WORK}"/ssh-keys/*.pub 2>/dev/null | wc -l) keys)"
[ -d "${WORK}/certs" ] && echo -e " ${GREEN}[ok]${NC} certs/"
echo -e " ${GREEN}[ok]${NC} nanny.state ($(wc -l < "$STATE") checkpoints)"
echo -e " ${GREEN}[ok]${NC} nanny.log ($(wc -l < "$LOG") lines)"
echo ""
echo "── Drives ──"
grep "^DRIVE_" "$CONF" | while IFS='=' read -r key val; do
serial=$(echo "$val" | cut -d'|' -f1)
model=$(echo "$val" | cut -d'|' -f2)
echo " ${key}: ${model} (${serial})"
done
echo ""
echo "── YubiKeys ──"
grep "^YUBIKEY_" "$YKCONF" | while IFS='=' read -r key val; do
serial=$(echo "$val" | cut -d'|' -f1)
type=$(echo "$val" | cut -d'|' -f2)
echo " ${key}: ${type} (${serial})"
done
echo ""
echo "── Cloudflare ──"
grep -v "PASSWORD\|TOKEN\|SECRET\|API_TOKEN" "$CFCONF" 2>/dev/null | grep -v "^#" | grep -v "^$" || true
echo " (secrets redacted)"
echo ""
log "All enrollment complete. Run: ./build-iso.sh"