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
937 lines
32 KiB
Bash
Executable File
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"
|