chore(hw): pivot module from ISO builder to live host inventory

The hw/ module used to build a bootable Alpine USB for a fresh
storage node — all the bats tests, build scripts, package.json,
RUNBOOK, and tunnel-setup docs were tooling for that flow.

The host (rack) is now provisioned and live; the only thing the
module needs to express is the hardware manifest. Trim hw/ down
to README + config.yaml describing the actual hardware (Pop!_OS
24.04, Ryzen 9 3950X, 125 GiB RAM, drive serials) plus pointers
to where each runtime concern actually lives (compose.yml for
services, infra/traefik for the proxy, etc.).

Also drop logs/dbt.log — stale build log that should never have
been tracked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kert
2026-04-28 17:01:08 -04:00
parent a663e77248
commit 705ee22b51
20 changed files with 37 additions and 5019 deletions

View File

@@ -1,3 +1,16 @@
# hw
The purpose of the hw module is to manage infrastructure.
Hardware inventory for `rack`, the storage host.
- `config.yaml` — host + drive manifest (single source of truth for hardware)
- `docs/` — motherboard, SSD, and M.2 carrier datasheets
Everything else about this host is already live and configured elsewhere:
| Concern | Where it lives |
|---|---|
| Docker services | `../compose.yml` |
| Reverse proxy | `../infra/traefik/` |
| Cloudflare tunnel | `../infra/cloudflared/config.yml` (routes `*.fhirworx.io` → traefik) |
| SSH / YubiKey auth | `~/.ssh/authorized_keys` on host (YubiKey FIDO2 + fallback keys; mirror in `config.yaml`) |
| LUKS2 boot volume | `/dev/nvme0n1p3` → `cryptdata` → LVM `data-root` (mounted `/`) |

View File

@@ -1,270 +0,0 @@
# Storage Node Build Runbook
Hardware: ASUS ROG Crosshair VIII Hero / AMD 3950X / 192GB RAM
OS: Alpine Linux 3.21 (custom ISO)
Security: LUKS2 encryption + YubiKey FIDO2 SSH + YubiKey challenge-response unlock
## Drive Map
| Device | Hardware | Mount | Encryption | Purpose |
|--------|----------|-------|------------|---------|
| Samsung Fit 128GB USB | Rear USB 3.1 port | `/` (read-only) | None (no secrets) | OS root |
| 6x WD Blue SA510 2TB | M.2→SATA adapters on SATA6G_1-6 | `/data/rustfs` (LVM, 12TB) | LUKS2 on LV | RustFS object storage |
| 1x Modern NVMe 2TB | M.2_1 onboard slot (PCIe 3.0 x4) | `/var/lib/docker` + `/var/cache` | LUKS2 per partition | Docker, writes, cache |
## Security Model
```
Boot → read-only root (no secrets, no data) → SSH only with YubiKey FIDO2
→ yubikey-unlock (challenge-response decrypts LUKS volumes)
→ Docker starts → RustFS serves data
```
- **At rest**: All data encrypted with LUKS2 (AES-XTS-512). Server can be physically stolen and data is safe.
- **SSH access**: Requires a hardware YubiKey with FIDO2 ed25519-sk key. No passwords accepted.
- **Volume unlock**: Requires physical YubiKey challenge-response (HMAC-SHA1 slot 2).
- **Backup access**: Emergency passphrase enrolled in LUKS slot 1 (store offline, safe deposit box).
- **Two YubiKeys enrolled**: Primary and backup, both work for SSH and LUKS.
## YubiKey Preparation (before build)
On your workstation, for EACH YubiKey:
```sh
# 1. Program HMAC-SHA1 challenge-response on slot 2
ykman otp chalresp --touch --generate 2
# 2. Generate FIDO2 SSH key (resident on the key)
ssh-keygen -t ed25519-sk -O resident -C "yubikey-1-storagenode" -f ~/.ssh/id_yubikey1_storagenode
# Repeat with second key:
ssh-keygen -t ed25519-sk -O resident -C "yubikey-2-storagenode" -f ~/.ssh/id_yubikey2_storagenode
```
Save both `.pub` files — you'll paste them into `/root/.ssh/authorized_keys` during setup.
## BIOS Settings
Enter BIOS: hold `Delete` during POST.
### Required
1. **Advanced → Onboard Devices Configuration**
- `M.2_2 PCIe Bandwidth Configuration` → `Disabled(X8 mode)`
- `HD Audio Controller` → `Disabled`
- `RGB LED lighting (working state)` → `Off`
- `RGB LED lighting (sleep/off)` → `Off`
2. **Advanced → CPU Configuration**
- `SVM Mode` → `Enabled` (AMD-V, for Docker/future VMs)
3. **Advanced → AMD fTPM configuration**
- `Firmware TPM` → `Enabled`
4. **Advanced → SATA Configuration**
- `SATA Mode` → `AHCI`
- Verify all 6 SATA ports show `Enabled`
5. **Advanced → APM Configuration**
- `Restore On AC Power Loss` → `Power On`
- `Power On By PCI-E/PCI` → `Enabled` (Wake-on-LAN)
6. **Extreme Tweaker**
- `TPU` → `TPU II` (water cooling overclock profile)
7. **Boot**
- Boot priority: USB drive first
- `CSM (Compatibility Support Module)` → `Disabled` (pure UEFI)
- `Fast Boot` → `Disabled` (until stable)
### Optional Performance
8. **Extreme Tweaker → PBO** (if available in BIOS update)
- Precision Boost Overdrive → `Enabled`
## Build Steps
### Phase 1: Build the ISO (on your current machine)
```sh
cd ~/stack/alpine-iso
chmod +x build-iso.sh
./build-iso.sh
```
If building from a non-Alpine system:
```sh
docker run --rm -v $(pwd):/work -w /work alpine:3.21 sh -c "
apk add alpine-sdk build-base alpine-conf syslinux xorriso \
mtools dosfstools grub grub-efi squashfs-tools git sudo && \
adduser -D build && \
addgroup build abuild && \
echo 'build ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers && \
su build -c 'abuild-keygen -an' && \
chmod +x build-iso.sh && \
./build-iso.sh
"
```
### Phase 2: Write ISO to a temporary USB
```sh
dd if=out/alpine-storagenode-*.iso of=/dev/sdX bs=4M status=progress
sync
```
### Phase 3: Boot and install to Samsung Fit
1. Plug BOTH the temp ISO USB and the Samsung Fit into rear USB ports
2. Boot from the ISO USB
3. Log in as `root` (no password)
4. Run: `setup-alpine` (use defaults, skip disk install)
5. Run: `chmod +x install-to-usb.sh && ./install-to-usb.sh`
6. Remove the ISO USB, reboot
### Phase 4: Configure storage + encryption
1. Boot from Samsung Fit
2. Log in as root (still has password access for initial setup)
3. Have YubiKey #1 inserted
4. Run: `chmod +x /root/storage-setup.sh && /root/storage-setup.sh`
- This creates the LVM volume group, encrypts all data volumes, enrolls both YubiKeys
- You'll be prompted for a backup passphrase — store this OFFLINE
5. Add your SSH public keys:
```sh
nano /root/.ssh/authorized_keys
# Paste both yubikey .pub lines
```
6. Edit the RustFS password:
```sh
nano /opt/rustfs/docker-compose.yml
```
7. Lock it down:
```sh
ro
```
### Phase 5: Test the full boot cycle
1. Reboot the server
2. From your workstation: `ssh -i ~/.ssh/id_yubikey1_storagenode root@<ip>`
- Touch YubiKey when it blinks (FIDO2 auth)
3. On the server: `yubikey-unlock`
- Touch YubiKey again (challenge-response to decrypt LUKS)
4. Start RustFS: `cd /opt/rustfs && docker compose up -d`
### Phase 6: Verify
```sh
# LUKS status
dmsetup ls
cryptsetup status docker-crypt
cryptsetup status rustfs-crypt
# LVM status
pvs
vgs
lvs
# Check mounts
mount | grep -E '(docker-crypt|cache-crypt|rustfs-crypt|tmpfs)'
# Docker
docker ps
docker logs rustfs
# Root is read-only
touch /testfile # should fail: "Read-only file system"
# RustFS health
curl http://localhost:9000/minio/health/live
# SMART status
for d in /dev/sd?; do smartctl -H "$d"; done
smartctl -H /dev/nvme0n1
```
## Daily Operation
```
Power on → server boots to locked state (SSH only)
→ SSH in with YubiKey
→ yubikey-unlock (decrypts data, starts Docker)
→ RustFS serving
Power off / reboot → data re-encrypted automatically
```
## Maintenance
```sh
# System updates
sys-update # handles rw/ro automatically
# LVM status
pvs
vgs
lvs
# Add a new SATA drive to expand storage
pvcreate /dev/sdX
vgextend rustfs-vg /dev/sdX
lvextend -l +100%FREE /dev/rustfs-vg/rustfs-lv
# Unlock rustfs-crypt first, then grow the filesystem live:
xfs_growfs /data/rustfs
# Replace a failed SATA drive
# 1. Move data off the dying drive:
pvmove /dev/sdX
# 2. Remove from VG:
vgreduce rustfs-vg /dev/sdX
pvremove /dev/sdX
# 3. Swap physical drive, then add the new one:
pvcreate /dev/sdY
vgextend rustfs-vg /dev/sdY
# Docker management (volumes must be unlocked)
cd /opt/rustfs
docker compose logs -f
docker compose restart
docker compose pull && docker compose up -d
# Manually lock volumes (before maintenance/travel)
yubikey-lock
```
## Emergency Recovery
If both YubiKeys are lost/destroyed:
```sh
# Boot server, SSH will fail (no valid keys)
# Connect keyboard + monitor directly
# Log in as root (if password still set) or boot from ISO
# Use backup passphrase to unlock:
vgchange -ay rustfs-vg # activate LVM first
cryptsetup open /dev/rustfs-vg/rustfs-lv rustfs-crypt # enter backup passphrase
cryptsetup open /dev/nvme0n1p1 docker-crypt # enter backup passphrase
cryptsetup open /dev/nvme0n1p2 cache-crypt # enter backup passphrase
mount /data/rustfs
mount /var/lib/docker
mount /var/cache
```
## Network (post-install)
```sh
rw
cat > /etc/network/interfaces << 'EOF'
auto lo
iface lo inet loopback
auto eth0
iface eth0 inet static
address 192.168.1.X/24
gateway 192.168.1.1
EOF
echo "nameserver 1.1.1.1" > /etc/resolv.conf
ro
reboot
```

View File

@@ -1,251 +0,0 @@
# Cloudflare Tunnel + Certs Setup for rig.fhirworx.io
## Architecture
```
Internet Your Server (rig)
┌──────────────────────────────┐
Users ──→ Cloudflare Edge │ │
(TLS termination) │ cloudflared ←──outbound──→ CF Edge
*.rig.fhirworx.io │ │ │
│ ├─→ rustfs:9000 (S3 API)│
│ └─→ rustfs:9001 (Console)│
│ │
Cloudflare WARP │ warp-svc (DNS/Zero Trust) │
└──────────────────────────────┘
```
All connections are OUTBOUND from your server. No inbound ports needed
except SSH (22) for YubiKey management.
## Step 1: Cloudflare Dashboard — DNS
1. Log into https://dash.cloudflare.com
2. Select **fhirworx.io** zone
3. Go to **DNS → Records**
4. You do NOT need to add A/AAAA records manually — the tunnel creates
CNAME records automatically. But verify the zone exists and is active.
## Step 2: Create the Tunnel
1. Go to https://one.dash.cloudflare.com (Zero Trust dashboard)
2. **Networks → Tunnels → Create a tunnel**
3. Tunnel name: `rig`
4. Choose **Cloudflared** connector
5. You'll get a tunnel token — it looks like:
```
eyJhIjoiNGY4...very-long-base64-string
```
6. **Copy this token** — you'll need it in Step 4
## Step 3: Configure Tunnel Routes (Public Hostnames)
Still in the tunnel config, add these public hostnames:
| Public Hostname | Service | Notes |
|----------------|---------|-------|
| `s3.rig.fhirworx.io` | `http://rustfs:9000` | S3 API endpoint |
| `console.rig.fhirworx.io` | `http://rustfs:9001` | Web console |
| `rig.fhirworx.io` | `http://rustfs:9000` | Default/root domain → S3 |
For each route:
- **Type**: HTTP (not HTTPS — cloudflared handles the tunnel encryption,
the local connection to the container is plaintext over Docker network)
- **TLS → Origin Server Name**: leave blank
- **No TLS Verify**: Yes (local traffic, no cert needed)
### Optional: Add SSH access through tunnel
| Public Hostname | Service | Notes |
|----------------|---------|-------|
| `ssh.rig.fhirworx.io` | `ssh://localhost:22` | Browser SSH or cloudflared access |
For SSH through tunnel, on the **Access** tab:
- Create an Access Application for `ssh.rig.fhirworx.io`
- Add an Access Policy (e.g., email allowlist, one-time PIN)
- This gives you browser-based SSH as a backup to direct SSH
## Step 4: Install the Token on Your Server
After `yubikey-unlock`, edit the env file:
```sh
rw
nano /opt/rustfs/.env
```
Replace `PASTE_YOUR_TOKEN_HERE` with the actual token from Step 2:
```
TUNNEL_TOKEN=eyJhIjoiNGY4...
```
Save and:
```sh
ro
cd /opt/rustfs && docker compose up -d
```
Verify the tunnel connects:
```sh
docker logs cloudflared
# Should show: "Connection registered" and "Tunnel is connected"
```
## Step 5: SSL/TLS Mode
1. In Cloudflare dashboard → **fhirworx.io** → **SSL/TLS → Overview**
2. Set mode to: **Full**
- NOT "Full (Strict)" — unless you set up an origin cert (see below)
- NOT "Flexible" — that's insecure
- With tunnels, "Full" is fine because the tunnel itself is encrypted
If you want "Full (Strict)" (belt AND suspenders), do Step 6.
## Step 6: Origin Certificate (Optional)
Only needed if:
- You want Full (Strict) SSL mode, OR
- You want direct HTTPS access on your LAN to rig.fhirworx.io
### Option A: Cloudflare Origin CA (simplest, 15-year validity)
1. Dashboard → **fhirworx.io** → **SSL/TLS → Origin Server**
2. **Create Certificate**
3. Settings:
- Key type: **ECDSA**
- Hostnames: `rig.fhirworx.io`, `*.rig.fhirworx.io`
- Validity: **15 years**
4. Copy the PEM certificate and private key
On the server:
```sh
rw
# Paste the certificate
nano /opt/certs/origin.pem
# Paste the private key
nano /opt/certs/origin-key.pem
chmod 644 /opt/certs/origin.pem
chmod 600 /opt/certs/origin-key.pem
ro
```
These certs are ONLY trusted by Cloudflare's edge — browsers connecting
directly will see a cert warning. That's expected and correct for origin certs.
### Option B: Let's Encrypt (trusted everywhere, auto-renews)
Use this if you also want trusted HTTPS directly on your LAN.
1. Create a Cloudflare API token:
- https://dash.cloudflare.com/profile/api-tokens
- **Create Token → Edit zone DNS** template
- Zone: `fhirworx.io`
- Copy the token
2. On the server:
```sh
rw
# Save the API token
cat > /opt/certs/cf-credentials.ini << EOF
dns_cloudflare_api_token = YOUR_TOKEN_HERE
EOF
chmod 600 /opt/certs/cf-credentials.ini
# Request the cert
apk add certbot-dns-cloudflare
certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /opt/certs/cf-credentials.ini \
-d "rig.fhirworx.io" \
-d "*.rig.fhirworx.io" \
--preferred-challenges dns-01 \
--non-interactive \
--agree-tos \
-m you@fhirworx.io
ro
```
Certs land at:
- `/etc/letsencrypt/live/rig.fhirworx.io/fullchain.pem`
- `/etc/letsencrypt/live/rig.fhirworx.io/privkey.pem`
Auto-renewal cron (add after setup):
```sh
rw
echo "0 3 * * * root mount -o remount,rw / && certbot renew --quiet && mount -o remount,ro /" >> /etc/crontabs/root
ro
```
## Step 7: Cloudflare WARP (Zero Trust DNS/VPN)
The WARP container gives you:
- DNS-over-HTTPS for all container traffic
- Option to route through Cloudflare's network
- Zero Trust device posture (if configured)
First run enrollment:
```sh
docker exec -it warp-svc warp-cli registration new
docker exec -it warp-svc warp-cli connect
```
To use WARP as the default DNS for the host:
```sh
rw
echo "nameserver 127.0.0.1" > /etc/resolv.conf
ro
```
## Step 8: Verify Everything
```sh
# Tunnel status
docker logs cloudflared
# Test S3 endpoint externally
curl -I https://s3.rig.fhirworx.io
# Should return 403 (no auth) or 200 with health check
# Test console
curl -I https://console.rig.fhirworx.io
# Should return 200 or 302 redirect to login
# Test from server locally
curl http://127.0.0.1:9000/minio/health/live
curl http://127.0.0.1:9001
# WARP status
docker exec warp-svc warp-cli status
```
## DNS Records (auto-created by tunnel)
After the tunnel connects, Cloudflare automatically creates:
```
s3.rig.fhirworx.io CNAME <tunnel-id>.cfargotunnel.com
console.rig.fhirworx.io CNAME <tunnel-id>.cfargotunnel.com
rig.fhirworx.io CNAME <tunnel-id>.cfargotunnel.com
```
You don't need to create these manually.
## Summary: What Needs a Cert and What Doesn't
| Connection | Encrypted By | Cert Needed? |
|-----------|-------------|-------------|
| User → Cloudflare Edge | Cloudflare Universal SSL | No (automatic) |
| Cloudflare Edge → cloudflared | Tunnel encryption (built-in) | No |
| cloudflared → RustFS container | Docker internal network (localhost) | No |
| Direct LAN → RustFS | Nothing (HTTP) or your own cert (HTTPS) | Only if you want LAN HTTPS |
**For your setup: you need ZERO certificates.** The tunnel handles everything.
Origin certs are a nice-to-have for defense in depth or direct LAN access.

File diff suppressed because it is too large Load Diff

View File

@@ -1,378 +0,0 @@
#!/bin/bash
#
# build.sh — Build bootable Alpine USB from config.yaml
#
# Reads config.yaml, builds a custom Alpine ISO with SSH keys baked in,
# and optionally flashes it to a USB drive.
#
# Usage:
# ./build.sh # build ISO only
# ./build.sh flash /dev/sdX # build + flash to USB
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG="${SCRIPT_DIR}/config.yaml"
WORKDIR="${SCRIPT_DIR}/build"
OUTDIR="${SCRIPT_DIR}/out"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
err() { echo -e "${RED}[!]${NC} $*"; exit 1; }
[ -f "$CONFIG" ] || err "config.yaml not found at ${CONFIG}"
# -------------------------------------------------------------------
# Parse config.yaml with Python (pyyaml)
# -------------------------------------------------------------------
parse_yaml() {
python3 - "$CONFIG" "$@" << 'PYEOF'
import sys, yaml
with open(sys.argv[1]) as f:
cfg = yaml.safe_load(f)
query = sys.argv[2] if len(sys.argv) > 2 else None
def resolve(obj, path):
for key in path.split('.'):
if obj is None:
return ''
if isinstance(obj, list):
try:
obj = obj[int(key)]
except (ValueError, IndexError):
return ''
elif isinstance(obj, dict):
obj = obj.get(key)
else:
return ''
if isinstance(obj, list):
print('\n'.join(str(x) for x in obj))
elif isinstance(obj, dict):
for k, v in obj.items():
if isinstance(v, list):
print('\n'.join(str(x) for x in v))
else:
print(v)
elif obj is None:
pass
else:
print(obj)
if query:
resolve(cfg, query)
else:
yaml.dump(cfg, sys.stdout, default_flow_style=False)
PYEOF
}
# Read config values
ALPINE_VERSION=$(parse_yaml alpine.version)
ARCH=$(parse_yaml alpine.arch)
MIRROR=$(parse_yaml alpine.mirror)
HOSTNAME=$(parse_yaml host.name)
TIMEZONE=$(parse_yaml host.timezone)
KEYMAP=$(parse_yaml host.keymap)
NET_MODE=$(parse_yaml network.mode)
NET_IFACE=$(parse_yaml network.interface)
SSH_PORT=$(parse_yaml ssh.port)
MAIN_REPO="${MIRROR}/v${ALPINE_VERSION}/main"
COMMUNITY_REPO="${MIRROR}/v${ALPINE_VERSION}/community"
log "Building Alpine ${ALPINE_VERSION} (${ARCH}) ISO for '${HOSTNAME}'"
log "Network: ${NET_MODE} on ${NET_IFACE}"
log "SSH port: ${SSH_PORT}"
# -------------------------------------------------------------------
# Collect all packages from config
# -------------------------------------------------------------------
ALL_PACKAGES=$(parse_yaml packages)
PACKAGE_LIST=$(echo "$ALL_PACKAGES" | sort -u | tr '\n' ' ')
log "Packages: $(echo "$ALL_PACKAGES" | wc -l) total"
# -------------------------------------------------------------------
# Collect SSH authorized keys
# -------------------------------------------------------------------
AUTH_KEYS=$(parse_yaml ssh.authorized_keys)
KEY_COUNT=$(echo "$AUTH_KEYS" | grep -c "^ssh-\|^ecdsa-\|^sk-" || echo 0)
[ "$KEY_COUNT" -ge 1 ] || err "No SSH keys found in config.yaml ssh.authorized_keys"
log "SSH keys: ${KEY_COUNT}"
# -------------------------------------------------------------------
# Clean + setup
# -------------------------------------------------------------------
rm -rf "${WORKDIR}"
mkdir -p "${WORKDIR}" "${OUTDIR}"
# -------------------------------------------------------------------
# Clone aports
# -------------------------------------------------------------------
if [ ! -d "${WORKDIR}/aports" ]; then
log "Cloning aports build infrastructure..."
git clone --depth 1 --branch "v${ALPINE_VERSION}" \
https://gitlab.alpinelinux.org/alpine/aports.git \
"${WORKDIR}/aports"
fi
# -------------------------------------------------------------------
# Write custom ISO profile
# -------------------------------------------------------------------
log "Writing ISO profile..."
cat > "${WORKDIR}/aports/scripts/mkimg.storagenode.sh" << PROFILE
profile_storagenode() {
title="Alpine Storage Node"
desc="Headless server — SSH ready"
profile_standard
arch="${ARCH}"
output_format="iso"
image_ext="iso"
kernel_flavors="lts"
apks="\$apks
${PACKAGE_LIST}
"
}
PROFILE
# -------------------------------------------------------------------
# Build the overlay (auto-configures on first boot)
# -------------------------------------------------------------------
log "Building boot overlay..."
OVERLAY="${WORKDIR}/aports/scripts/storagenode-overlay"
mkdir -p "${OVERLAY}/etc/ssh"
mkdir -p "${OVERLAY}/etc/network"
mkdir -p "${OVERLAY}/etc/local.d"
mkdir -p "${OVERLAY}/root/.ssh"
# --- Network ---
if [ "$NET_MODE" = "dhcp" ]; then
cat > "${OVERLAY}/etc/network/interfaces" << NETCFG
auto lo
iface lo inet loopback
auto ${NET_IFACE}
iface ${NET_IFACE} inet dhcp
NETCFG
else
NET_ADDR=$(parse_yaml network.address)
NET_GW=$(parse_yaml network.gateway)
cat > "${OVERLAY}/etc/network/interfaces" << NETCFG
auto lo
iface lo inet loopback
auto ${NET_IFACE}
iface ${NET_IFACE} inet static
address ${NET_ADDR}
gateway ${NET_GW}
NETCFG
fi
DNS_SERVERS=$(parse_yaml network.dns)
echo "$DNS_SERVERS" | while read -r ns; do
[ -n "$ns" ] && echo "nameserver ${ns}"
done > "${OVERLAY}/etc/resolv.conf"
# --- SSH authorized keys ---
echo "$AUTH_KEYS" > "${OVERLAY}/root/.ssh/authorized_keys"
chmod 700 "${OVERLAY}/root/.ssh"
chmod 600 "${OVERLAY}/root/.ssh/authorized_keys"
# --- SSH server config ---
PERMIT_ROOT=$(parse_yaml ssh.permit_root)
PASS_AUTH=$(parse_yaml ssh.password_auth)
if [ "$PERMIT_ROOT" = "True" ] || [ "$PERMIT_ROOT" = "true" ]; then
ROOT_LOGIN="prohibit-password"
else
ROOT_LOGIN="no"
fi
if [ "$PASS_AUTH" = "True" ] || [ "$PASS_AUTH" = "true" ]; then
PASS_CFG="yes"
else
PASS_CFG="no"
fi
cat > "${OVERLAY}/etc/ssh/sshd_config" << SSHD
Port ${SSH_PORT}
ListenAddress 0.0.0.0
Protocol 2
HostKey /etc/ssh/ssh_host_ed25519_key
HostKey /etc/ssh/ssh_host_rsa_key
PubkeyAuthentication yes
PubkeyAcceptedKeyTypes sk-ssh-ed25519@openssh.com,sk-ecdsa-sha2-nistp256@openssh.com,ssh-ed25519,ssh-rsa
PasswordAuthentication ${PASS_CFG}
PermitRootLogin ${ROOT_LOGIN}
PermitEmptyPasswords no
ChallengeResponseAuthentication no
UsePAM no
X11Forwarding no
PrintMotd yes
ClientAliveInterval 60
ClientAliveCountMax 3
MaxAuthTries 6
SSHD
# --- Auto-setup script (runs on first boot via local.d) ---
cat > "${OVERLAY}/etc/local.d/01-setup.start" << 'BOOT'
#!/bin/sh
#
# First-boot auto-setup: networking + SSH
# Runs via local.d on every boot (idempotent)
#
# Generate host keys if missing
[ -f /etc/ssh/ssh_host_ed25519_key ] || ssh-keygen -t ed25519 -f /etc/ssh/ssh_host_ed25519_key -N ""
[ -f /etc/ssh/ssh_host_rsa_key ] || ssh-keygen -t rsa -b 4096 -f /etc/ssh/ssh_host_rsa_key -N ""
# Enable and start services
rc-update add sshd default 2>/dev/null || true
rc-update add networking boot 2>/dev/null || true
rc-update add chronyd default 2>/dev/null || true
rc-update add local default 2>/dev/null || true
# Bring up networking if not already
rc-service networking start 2>/dev/null || true
rc-service sshd start 2>/dev/null || true
rc-service chronyd start 2>/dev/null || true
# Log IP for console viewers
echo ""
echo "=== SSH READY ==="
ip -4 addr show dev eth0 2>/dev/null | grep inet | awk '{print " ssh root@" $2}' | sed 's|/.*||'
echo "================="
BOOT
chmod +x "${OVERLAY}/etc/local.d/01-setup.start"
# --- Hostname ---
echo "${HOSTNAME}" > "${OVERLAY}/etc/hostname"
# --- Timezone ---
mkdir -p "${OVERLAY}/etc/zoneinfo"
echo "${TIMEZONE}" > "${OVERLAY}/etc/timezone"
# --- Auto-setup answer file (for setup-alpine if needed) ---
cat > "${OVERLAY}/auto-setup.conf" << ANSWERS
KEYMAPOPTS="${KEYMAP} ${KEYMAP}"
HOSTNAMEOPTS="-n ${HOSTNAME}"
INTERFACESOPTS="auto lo
iface lo inet loopback
auto ${NET_IFACE}
iface ${NET_IFACE} inet ${NET_MODE}
"
DNSOPTS="-n $(echo "$DNS_SERVERS" | head -2 | tr '\n' ' ')"
TIMEZONEOPTS="-z ${TIMEZONE}"
PROXYOPTS="none"
SSHDOPTS="-c openssh"
NTPOPTS="-c chrony"
DISKOPTS="none"
LBUOPTS="none"
APKCACHEOPTS="none"
ANSWERS
# --- MOTD ---
cat > "${OVERLAY}/etc/motd" << 'MOTD'
storagenode — Alpine Live USB
SSH is enabled. Run 'setup-alpine' for full install.
MOTD
# -------------------------------------------------------------------
# Inject overlay into the ISO profile
# -------------------------------------------------------------------
log "Injecting overlay into ISO profile..."
# Create the apkovl tarball that Alpine live boots will auto-extract
cd "${OVERLAY}"
tar czf "${WORKDIR}/aports/scripts/${HOSTNAME}.apkovl.tar.gz" \
--owner=root --group=root \
etc/ root/
cd "${SCRIPT_DIR}"
# Patch the profile to include the apkovl
cat >> "${WORKDIR}/aports/scripts/mkimg.storagenode.sh" << APKOVL
profile_storagenode_apkovl() {
arch="${ARCH}"
apkovl="${HOSTNAME}.apkovl.tar.gz"
}
APKOVL
# Also make the profile reference the apkovl
sed -i 's|profile_standard|profile_standard\n apkovl="../${HOSTNAME}.apkovl.tar.gz"|' \
"${WORKDIR}/aports/scripts/mkimg.storagenode.sh"
# -------------------------------------------------------------------
# Build the ISO
# -------------------------------------------------------------------
log "Building ISO (this takes a few minutes)..."
cd "${WORKDIR}/aports/scripts"
sh mkimage.sh \
--tag storagenode \
--outdir "${OUTDIR}" \
--arch "${ARCH}" \
--repository "${MAIN_REPO}" \
--repository "${COMMUNITY_REPO}" \
--profile storagenode
ISO_FILE=$(ls "${OUTDIR}"/alpine-storagenode-*.iso 2>/dev/null | head -1)
[ -f "$ISO_FILE" ] || err "ISO build failed — no output file"
log "ISO built: ${ISO_FILE}"
ls -lh "$ISO_FILE"
# -------------------------------------------------------------------
# Flash to USB if requested
# -------------------------------------------------------------------
if [ "${1:-}" = "flash" ]; then
USB_TARGET="${2:-}"
[ -n "$USB_TARGET" ] || err "Usage: $0 flash /dev/sdX"
[ -b "$USB_TARGET" ] || err "${USB_TARGET} is not a block device"
# Safety check: is it USB?
TRAN=$(lsblk -d -n -o TRAN "$USB_TARGET" 2>/dev/null || true)
if [ "$TRAN" != "usb" ]; then
warn "Device ${USB_TARGET} transport is '${TRAN}', not 'usb'"
echo -n "Are you SURE this is the target USB drive? [y/N] "
read -r confirm
[ "$confirm" = "y" ] || exit 1
fi
SIZE=$(lsblk -d -n -o SIZE "$USB_TARGET")
echo ""
echo "THIS WILL ERASE ${USB_TARGET} (${SIZE}) COMPLETELY."
echo -n "Continue? [y/N] "
read -r confirm
[ "$confirm" = "y" ] || exit 1
log "Flashing to ${USB_TARGET}..."
dd if="$ISO_FILE" of="$USB_TARGET" bs=4M status=progress conv=fsync
sync
log "Flash complete. Remove USB and boot from it."
log "SSH will be available immediately after boot on ${NET_IFACE} (${NET_MODE})."
fi
echo ""
log "=== DONE ==="
echo ""
echo "To flash manually:"
echo " dd if=${ISO_FILE} of=/dev/sdX bs=4M status=progress && sync"
echo ""
echo "After boot, SSH in with:"
echo " ssh root@<ip>"
echo ""

View File

@@ -1,31 +1,18 @@
# Storage Node ISO Configuration
# Single source of truth for building the bootable Alpine USB
alpine:
version: "3.21"
arch: x86_64
mirror: https://dl-cdn.alpinelinux.org/alpine
# Storage host — rack
# This machine is the storage node.
host:
name: storagenode
timezone: UTC
keymap: us
network:
# DHCP for initial boot / diagnostics; switch to static after setup
mode: dhcp
interface: eth0
# Uncomment for static:
# mode: static
# address: 192.168.1.100/24
# gateway: 192.168.1.1
dns:
- 1.1.1.1
- 1.0.0.1
name: rack
os: Pop!_OS 24.04 LTS
kernel: Linux 6.17.9-76061709-generic
vendor: ASUSTeK
board: ROG CROSSHAIR VIII HERO
cpu: AMD Ryzen 9 3950X (16C/32T)
memory_gib: 125
ssh:
port: 22
permit_root: true
permit_root: false
password_auth: false
authorized_keys:
# YubiKey 1 (ECDSA-SK FIDO2)
@@ -37,8 +24,17 @@ ssh:
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOhs9dzoN/4zNOzpVng2dAPVk7RDF4RmCbmZYA12pzaz kert@homelab
drives:
# Enrolled one at a time via USB adapter. Serial numbers used to resolve
# /dev paths at runtime regardless of plug order.
# Boot/system NVMe (online)
nvme_boot:
- serial: "20120820001696"
model: "PCIe SSD"
size: "1.8TB"
device: /dev/nvme0n1
encryption: LUKS2 (cryptdata → LVM data-root)
mount: /
# Pending SATA drives — currently connected via USB adapter, will be
# moved to onboard SATA6G_1..6 ports. Serials stay fixed across ports.
sata:
- serial: "21044J801864"
model: "WDC WDS200T2B0B-00YS70"
@@ -58,65 +54,10 @@ drives:
- serial: "21044J800830"
model: "WDC WDS200T2B0B-00YS70"
size: "2TB"
nvme:
- serial: "19081510241793"
model: "PCIe SSD"
size: "1TB"
# Spinning disk for nightly backups
hdd:
- serial: "69HEN2NNSW47"
model: "TOSHIBA DT01ABA100V"
revision: "ASA AB10"
size: "1TB"
packages:
base:
- openssh
- chrony
- htop
- nano
- curl
- bash
- lm-sensors
- smartmontools
- pciutils
- usbutils
- ethtool
- util-linux
- lsblk
storage:
- e2fsprogs
- xfsprogs
- dosfstools
- sgdisk
- sfdisk
- parted
- cryptsetup
- lvm2
- nvme-cli
- fio
- bonnie++
docker:
- docker
- docker-cli
- docker-compose
network:
- wireguard-tools
- nftables
- cloudflared
- certbot
- openssl
hardware:
- amd-ucode
- cpufrequtils
- irqbalance
- haveged
yubikey:
- yubikey-manager
- yubico-pam
- libfido2
- openssh-server-common-openrc
tools:
- wget
- rsync
- logrotate
- iotop

View File

@@ -1,188 +0,0 @@
#!/bin/sh
#
# install-to-usb.sh — Install Alpine to Samsung Fit 128GB USB drive
# Run this AFTER booting from the custom ISO and running setup-alpine
#
# This script partitions the USB drive and installs Alpine in sys mode
# with a layout optimized for read-only operation.
#
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
err() { echo -e "${RED}[!]${NC} $*"; exit 1; }
[ "$(id -u)" -eq 0 ] || err "Must run as root"
# Cleanup mounts on any failure
cleanup() {
echo -e "${YELLOW}[!]${NC} Cleaning up mounts..."
umount "${MOUNTPOINT}/boot/efi" 2>/dev/null || true
umount "${MOUNTPOINT}" 2>/dev/null || true
}
MOUNTPOINT="/mnt/usb-root"
trap cleanup EXIT
# Detect Samsung Fit USB drive
echo "Available block devices:"
echo ""
lsblk -d -o NAME,SIZE,MODEL,TRAN | grep -v "^loop"
echo ""
echo -n "Enter the USB drive device (e.g., sda): "
read -r USB_DEV
USB_DRIVE="/dev/${USB_DEV}"
[ -b "$USB_DRIVE" ] || err "${USB_DRIVE} is not a valid block device"
# Verify it's USB
TRAN=$(lsblk -d -n -o TRAN "$USB_DRIVE" 2>/dev/null || true)
if [ "$TRAN" != "usb" ]; then
warn "Device ${USB_DRIVE} transport is '${TRAN}', not 'usb'"
echo -n "Are you SURE this is the USB drive? [y/N] "
read -r confirm
[ "$confirm" = "y" ] || exit 1
fi
SIZE=$(lsblk -d -n -o SIZE "$USB_DRIVE")
log "Selected: ${USB_DRIVE} (${SIZE})"
echo ""
echo "THIS WILL ERASE ${USB_DRIVE} COMPLETELY."
echo -n "Continue? [y/N] "
read -r confirm
[ "$confirm" = "y" ] || exit 1
# -------------------------------------------------------------------
# Partition the USB drive
# -------------------------------------------------------------------
log "Partitioning ${USB_DRIVE}..."
# GPT partition table
sgdisk --zap-all "$USB_DRIVE"
sgdisk -n 1:0:+512M -t 1:EF00 -c 1:"EFI" "$USB_DRIVE"
sgdisk -n 2:0:+20G -t 2:8300 -c 2:"root" "$USB_DRIVE"
# Remaining space unused — 20GB is plenty for a read-only root
partprobe "$USB_DRIVE" 2>/dev/null || blockdev --rereadpt "$USB_DRIVE" || err "Failed to re-read partition table"
sleep 2
# Verify partition nodes appeared
wait_count=0
while [ $wait_count -lt 5 ]; do
if [ -b "${USB_DRIVE}1" ] || [ -b "${USB_DRIVE}p1" ]; then
break
fi
sleep 1
wait_count=$((wait_count + 1))
done
# Detect partition naming (sdX1 vs sdXp1)
if [ -b "${USB_DRIVE}1" ]; then
PART_EFI="${USB_DRIVE}1"
PART_ROOT="${USB_DRIVE}2"
elif [ -b "${USB_DRIVE}p1" ]; then
PART_EFI="${USB_DRIVE}p1"
PART_ROOT="${USB_DRIVE}p2"
else
err "Cannot find partitions on ${USB_DRIVE}"
fi
log "Formatting..."
mkfs.vfat -F 32 -n EFI "$PART_EFI"
mkfs.ext4 -L root -O ^has_journal "$PART_ROOT"
# No journal on USB flash — reduces write amplification significantly
# -------------------------------------------------------------------
# Install Alpine sys mode
# -------------------------------------------------------------------
log "Installing Alpine to USB..."
mkdir -p "${MOUNTPOINT}"
mount "$PART_ROOT" "${MOUNTPOINT}"
mkdir -p "${MOUNTPOINT}/boot/efi"
mount "$PART_EFI" "${MOUNTPOINT}/boot/efi"
# Use setup-disk to do the heavy lifting
BOOTLOADER=grub setup-disk -o "${MOUNTPOINT}" -k lts || err "setup-disk failed — check that you are running from the Alpine installer environment"
# Verify GRUB installed correctly; ensure fallback EFI path exists
if [ ! -f "${MOUNTPOINT}/boot/efi/EFI/BOOT/BOOTX64.EFI" ]; then
log "Creating fallback EFI boot path..."
mkdir -p "${MOUNTPOINT}/boot/efi/EFI/BOOT"
if [ -f "${MOUNTPOINT}/boot/efi/EFI/alpine/grubx64.efi" ]; then
cp "${MOUNTPOINT}/boot/efi/EFI/alpine/grubx64.efi" "${MOUNTPOINT}/boot/efi/EFI/BOOT/BOOTX64.EFI"
else
# Find any grub efi binary that was installed
grub_efi=$(find "${MOUNTPOINT}/boot/efi/EFI" -name "grubx64.efi" -print -quit 2>/dev/null)
if [ -n "$grub_efi" ]; then
cp "$grub_efi" "${MOUNTPOINT}/boot/efi/EFI/BOOT/BOOTX64.EFI"
else
warn "No grubx64.efi found — USB may not boot on all UEFI systems"
fi
fi
fi
# -------------------------------------------------------------------
# Post-install tweaks on the new root
# -------------------------------------------------------------------
log "Applying read-only root configuration..."
# fstab will be configured by storage-setup.sh after first boot
# For now, set root to ro
if grep -q 'errors=remount-ro' "${MOUNTPOINT}/etc/fstab"; then
sed -i 's|errors=remount-ro|ro,noatime,discard,errors=remount-ro|' "${MOUNTPOINT}/etc/fstab"
else
# Fallback: find the root entry and add ro directly
if grep -q "$PART_ROOT" "${MOUNTPOINT}/etc/fstab"; then
sed -i "s|\(${PART_ROOT}.*\)defaults|\1ro,noatime,discard|" "${MOUNTPOINT}/etc/fstab"
else
err "Could not find root partition in fstab — root will not be read-only. Fix /etc/fstab manually."
fi
fi
# Verify ro is actually in the root mount options
grep -q '\sro[,\s]' "${MOUNTPOINT}/etc/fstab" || err "Failed to set root filesystem to read-only in fstab"
# Copy the storage setup script
if [ -f /root/storage-setup.sh ]; then
cp /root/storage-setup.sh "${MOUNTPOINT}/root/storage-setup.sh"
chmod +x "${MOUNTPOINT}/root/storage-setup.sh"
else
err "storage-setup.sh not found at /root/storage-setup.sh — persistent storage will not be configured. Place the file and re-run."
fi
# Enable tmpfs for volatile dirs in the installed system
cat >> "${MOUNTPOINT}/etc/fstab" << 'TMPFS'
# tmpfs mounts — keep USB drive read-only
tmpfs /tmp tmpfs nosuid,nodev,size=8G 0 0
tmpfs /run tmpfs nosuid,nodev,mode=0755,size=2G 0 0
tmpfs /var/log tmpfs nosuid,nodev,noexec,size=2G 0 0
tmpfs /var/tmp tmpfs nosuid,nodev,size=2G 0 0
TMPFS
# GRUB: add console for headless serial access
if [ -f "${MOUNTPOINT}/etc/default/grub" ]; then
sed -i 's|GRUB_CMDLINE_LINUX_DEFAULT=".*"|GRUB_CMDLINE_LINUX_DEFAULT="modules=sd-mod,usb-storage,ext4 quiet rootfstype=ext4 console=tty0 console=ttyS0,115200n8"|' \
"${MOUNTPOINT}/etc/default/grub"
# Rebuild grub config
chroot "${MOUNTPOINT}" grub-mkconfig -o /boot/grub/grub.cfg 2>/dev/null || \
warn "grub-mkconfig failed — may need to run manually after boot"
fi
# -------------------------------------------------------------------
# Cleanup (trap handles umount on failure; do it explicitly on success)
# -------------------------------------------------------------------
log "Unmounting..."
umount "${MOUNTPOINT}/boot/efi"
umount "${MOUNTPOINT}"
trap - EXIT
log ""
log "=== USB INSTALL COMPLETE ==="
log ""
log "Remove the ISO boot media, set BIOS to boot from USB, and power on."
log "After first boot, run: /root/storage-setup.sh"

View File

@@ -1,936 +0,0 @@
#!/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"

49
hw/package-lock.json generated
View File

@@ -1,49 +0,0 @@
{
"name": "alpine-iso",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "alpine-iso",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"bats": "^1.13.0",
"bats-assert": "^2.2.4",
"bats-support": "^0.3.0"
}
},
"node_modules/bats": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/bats/-/bats-1.13.0.tgz",
"integrity": "sha512-giSYKGTOcPZyJDbfbTtzAedLcNWdjCLbXYU3/MwPnjyvDXzu6Dgw8d2M+8jHhZXSmsCMSQqCp+YBsJ603UO4vQ==",
"dev": true,
"license": "MIT",
"bin": {
"bats": "bin/bats"
}
},
"node_modules/bats-assert": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/bats-assert/-/bats-assert-2.2.4.tgz",
"integrity": "sha512-EcaY4Z+Tbz1c7pnC1SrVSq0epr7tLwFpz6qt7KUW9K8uSw8V12DTfH9d2HxZWvBEATaCuMsZ7KoZMFiSQPRoXw==",
"dev": true,
"license": "CC0-1.0",
"peerDependencies": {
"bats": "0.4 || ^1",
"bats-support": "^0.3"
}
},
"node_modules/bats-support": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/bats-support/-/bats-support-0.3.0.tgz",
"integrity": "sha512-z+2WzXbI4OZgLnynydqH8GpI3+DcOtepO66PlK47SfEzTkiuV9hxn9eIQX+uLVFbt2Oqoc7Ky3TJ/N83lqD+cg==",
"dev": true,
"license": "CC0-1.0",
"peerDependencies": {
"bats": "0.4 || ^1"
}
}
}
}

View File

@@ -1,25 +0,0 @@
{
"name": "alpine-iso",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "npx bats test/*.bats",
"test:state": "npx bats test/state.bats",
"test:drives": "npx bats test/drives.bats",
"test:resolver": "npx bats test/resolver.bats",
"test:yubikey": "npx bats test/yubikey.bats",
"test:cloudflare": "npx bats test/cloudflare.bats",
"test:backup": "npx bats test/backup.bats",
"test:build": "npx bats test/build_validation.bats",
"test:integration": "npx bats test/integration.bats"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"bats": "^1.13.0",
"bats-assert": "^2.2.4",
"bats-support": "^0.3.0"
}
}

View File

@@ -1,166 +0,0 @@
#!/usr/bin/env bats
# Tests for nightly backup script logic
load test_helper
setup() {
setup_test_work
# Create the backup script for testing
cat > "${TEST_WORK}/backup-nightly" << 'BACKUP'
#!/bin/sh
set -e
LOGFILE="${TEST_WORK}/backup.log"
BACKUP_DIR="${TEST_WORK}/backup"
DATE=$(date +%Y-%m-%d)
RETAIN_DAYS=7
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOGFILE"; }
# Simulate mount checks
check_volumes() {
[ -d "$BACKUP_DIR" ] && [ -f "${BACKUP_DIR}/.mounted" ]
}
do_backup() {
local SNAP_DIR="${BACKUP_DIR}/snapshots/${DATE}"
local LATEST_LINK="${BACKUP_DIR}/latest"
mkdir -p "$SNAP_DIR/rustfs" "$SNAP_DIR/docker-volumes" "$SNAP_DIR/config"
# Simulate data
echo "test-data-${DATE}" > "$SNAP_DIR/rustfs/test.dat"
echo "config-data" > "$SNAP_DIR/config/fstab"
# Update latest
rm -f "$LATEST_LINK"
ln -s "$SNAP_DIR" "$LATEST_LINK"
log "DONE: backup complete"
}
rotate() {
find "${BACKUP_DIR}/snapshots" -maxdepth 1 -type d -mtime +${RETAIN_DAYS} -exec rm -rf {} \; 2>/dev/null || true
}
BACKUP
chmod +x "${TEST_WORK}/backup-nightly"
# Source it for function access
. "${TEST_WORK}/backup-nightly"
mkdir -p "${TEST_WORK}/backup"
touch "${TEST_WORK}/backup/.mounted"
}
teardown() {
teardown_test_work
}
# ─── Backup directory structure ───
@test "backup creates dated snapshot directory" {
do_backup
local today
today=$(date +%Y-%m-%d)
assert [ -d "${TEST_WORK}/backup/snapshots/${today}" ]
}
@test "backup creates rustfs subdirectory" {
do_backup
local today
today=$(date +%Y-%m-%d)
assert [ -d "${TEST_WORK}/backup/snapshots/${today}/rustfs" ]
}
@test "backup creates docker-volumes subdirectory" {
do_backup
local today
today=$(date +%Y-%m-%d)
assert [ -d "${TEST_WORK}/backup/snapshots/${today}/docker-volumes" ]
}
@test "backup creates config subdirectory" {
do_backup
local today
today=$(date +%Y-%m-%d)
assert [ -d "${TEST_WORK}/backup/snapshots/${today}/config" ]
}
@test "backup writes data to snapshot" {
do_backup
local today
today=$(date +%Y-%m-%d)
assert [ -f "${TEST_WORK}/backup/snapshots/${today}/rustfs/test.dat" ]
}
# ─── Latest symlink ───
@test "backup updates latest symlink" {
do_backup
assert [ -L "${TEST_WORK}/backup/latest" ]
}
@test "latest symlink points to today's snapshot" {
do_backup
local today
today=$(date +%Y-%m-%d)
local target
target=$(readlink "${TEST_WORK}/backup/latest")
assert_equal "$target" "${TEST_WORK}/backup/snapshots/${today}"
}
@test "latest symlink is updated on second backup" {
# Simulate two days
BACKUP_DIR="${TEST_WORK}/backup"
mkdir -p "${BACKUP_DIR}/snapshots/2026-03-27/rustfs"
ln -sf "${BACKUP_DIR}/snapshots/2026-03-27" "${BACKUP_DIR}/latest"
do_backup
local target
target=$(readlink "${TEST_WORK}/backup/latest")
local today
today=$(date +%Y-%m-%d)
assert_equal "$target" "${TEST_WORK}/backup/snapshots/${today}"
}
# ─── Volume check ───
@test "backup skips when volumes not mounted" {
rm -f "${TEST_WORK}/backup/.mounted"
run check_volumes
assert_failure
}
@test "backup proceeds when volumes mounted" {
run check_volumes
assert_success
}
# ─── Logging ───
@test "backup writes completion to log" {
do_backup
run grep "DONE: backup complete" "${TEST_WORK}/backup.log"
assert_success
}
# ─── Rotation ───
@test "rotation removes old snapshots" {
# Create old snapshot
mkdir -p "${TEST_WORK}/backup/snapshots/2020-01-01"
touch -d "2020-01-01" "${TEST_WORK}/backup/snapshots/2020-01-01"
rotate
assert [ ! -d "${TEST_WORK}/backup/snapshots/2020-01-01" ]
}
@test "rotation keeps recent snapshots" {
# Create yesterday's snapshot
local yesterday
yesterday=$(date -d "yesterday" +%Y-%m-%d 2>/dev/null || date -v-1d +%Y-%m-%d 2>/dev/null || echo "2026-03-27")
mkdir -p "${TEST_WORK}/backup/snapshots/${yesterday}"
rotate
assert [ -d "${TEST_WORK}/backup/snapshots/${yesterday}" ]
}
@test "rotation with no snapshots does not error" {
rm -rf "${TEST_WORK}/backup/snapshots"
mkdir -p "${TEST_WORK}/backup/snapshots"
run rotate
assert_success
}

View File

@@ -1,197 +0,0 @@
#!/usr/bin/env bats
# Tests for build-iso.sh validation logic (pre-build checks)
load test_helper
setup() {
setup_test_work
source_nanny_functions
mkdir -p "${TEST_WORK}/ssh-keys"
}
teardown() {
teardown_test_work
}
# Helper: create a complete valid enrollment
create_full_enrollment() {
# drives.conf — 8 drives
cat > "$CONF" << 'DRIVES'
#
# Drive enrollment manifest
#
DRIVE_SATA_1=WD-S001|WD Blue SA510 2TB|0x5001|2000398934016|sata|enrolled_via=usb
DRIVE_SATA_2=WD-S002|WD Blue SA510 2TB|0x5002|2000398934016|sata|enrolled_via=usb
DRIVE_SATA_3=WD-S003|WD Blue SA510 2TB|0x5003|2000398934016|sata|enrolled_via=usb
DRIVE_SATA_4=WD-S004|WD Blue SA510 2TB|0x5004|2000398934016|sata|enrolled_via=usb
DRIVE_SATA_5=WD-S005|WD Blue SA510 2TB|0x5005|2000398934016|sata|enrolled_via=usb
DRIVE_SATA_6=WD-S006|WD Blue SA510 2TB|0x5006|2000398934016|sata|enrolled_via=usb
DRIVE_NVME_DOCKER=NVM-D001|WD SN770 2TB|none|2000398934016|nvme|enrolled_via=usb
DRIVE_HDD_BACKUP=HDD-B001|WD Red Plus 4TB|none|4000787030016|sata|enrolled_via=usb
DRIVES
# yubikeys.conf — 2 keys
create_enrolled_yubikeys
# SSH keys
echo "sk-ssh-ed25519 AAAA yubikey-1-storagenode-12345678" > "${TEST_WORK}/ssh-keys/yubikey1_storagenode.pub"
echo "sk-ssh-ed25519 BBBB yubikey-2-storagenode-87654321" > "${TEST_WORK}/ssh-keys/yubikey2_storagenode.pub"
# authorized_keys
cat "${TEST_WORK}"/ssh-keys/*.pub > "${TEST_WORK}/authorized_keys"
# drive-resolver.sh
echo "#!/bin/sh" > "${TEST_WORK}/drive-resolver.sh"
chmod +x "${TEST_WORK}/drive-resolver.sh"
# cloudflare.conf
cat > "$CFCONF" << 'CF'
CF_TUNNEL_TOKEN=eyJhIjoiNDhmMzA1ZjQ3YmY5N2I4OGFiNjg0YzY1NWIzMTVhNmUi
CF_ACCESS_CONFIGURED=true
CF_CERT_TYPE=none
CF_WARP_ENABLED=true
RUSTFS_ROOT_USER=admin
RUSTFS_ROOT_PASSWORD=autogenpass123456789
NET_STATIC_IP=192.168.1.100/24
NET_GATEWAY=192.168.1.1
NET_DNS=1.1.1.1
CF
}
# ─── Validation: all files present ───
@test "build validation passes with complete enrollment" {
create_full_enrollment
for f in drives.conf drive-resolver.sh yubikeys.conf authorized_keys cloudflare.conf; do
assert [ -f "${TEST_WORK}/${f}" ]
done
}
@test "build validation: drives.conf has 6 SATA" {
create_full_enrollment
local count
count=$(grep -c "^DRIVE_SATA_" "$CONF")
assert_equal "$count" "6"
}
@test "build validation: drives.conf has 1 NVMe" {
create_full_enrollment
local count
count=$(grep -c "^DRIVE_NVME_" "$CONF")
assert_equal "$count" "1"
}
@test "build validation: drives.conf has 1 HDD" {
create_full_enrollment
local count
count=$(grep -c "^DRIVE_HDD_" "$CONF")
assert_equal "$count" "1"
}
@test "build validation: yubikeys.conf has 2 keys" {
create_full_enrollment
local count
count=$(grep -c "^YUBIKEY_" "$YKCONF")
assert_equal "$count" "2"
}
@test "build validation: at least 2 SSH public keys" {
create_full_enrollment
local count
count=$(ls "${TEST_WORK}"/ssh-keys/*.pub 2>/dev/null | wc -l)
assert [ "$count" -ge 2 ]
}
@test "build validation: cloudflare.conf has tunnel token" {
create_full_enrollment
run grep "^CF_TUNNEL_TOKEN=" "$CFCONF"
assert_success
}
# ─── Validation: missing files ───
@test "build fails without drives.conf" {
create_full_enrollment
rm -f "$CONF"
assert [ ! -f "$CONF" ]
}
@test "build fails without yubikeys.conf" {
create_full_enrollment
rm -f "$YKCONF"
assert [ ! -f "$YKCONF" ]
}
@test "build fails without authorized_keys" {
create_full_enrollment
rm -f "${TEST_WORK}/authorized_keys"
assert [ ! -f "${TEST_WORK}/authorized_keys" ]
}
@test "build fails without cloudflare.conf" {
create_full_enrollment
rm -f "$CFCONF"
assert [ ! -f "$CFCONF" ]
}
# ─── Validation: incomplete enrollment ───
@test "build fails with only 5 SATA drives" {
create_full_enrollment
# Remove SATA_6
sed -i '/^DRIVE_SATA_6=/d' "$CONF"
local count
count=$(grep -c "^DRIVE_SATA_" "$CONF")
assert_equal "$count" "5"
}
@test "build fails with no NVMe drive" {
create_full_enrollment
sed -i '/^DRIVE_NVME/d' "$CONF"
local count
count=$(grep -c "^DRIVE_NVME_" "$CONF" 2>/dev/null || true)
assert_equal "${count:-0}" "0"
}
@test "build fails with no HDD backup" {
create_full_enrollment
sed -i '/^DRIVE_HDD/d' "$CONF"
local count
count=$(grep -c "^DRIVE_HDD_" "$CONF" 2>/dev/null || true)
assert_equal "${count:-0}" "0"
}
@test "build fails with only 1 YubiKey" {
create_full_enrollment
sed -i '/^YUBIKEY_2=/d' "$YKCONF"
local count
count=$(grep -c "^YUBIKEY_" "$YKCONF")
assert_equal "$count" "1"
}
# ─── Cross-validation ───
@test "all drive serials are unique" {
create_full_enrollment
local serials
serials=$(grep "^DRIVE_" "$CONF" | cut -d= -f2 | cut -d'|' -f1 | sort)
local unique_serials
unique_serials=$(echo "$serials" | sort -u)
assert_equal "$serials" "$unique_serials"
}
@test "yubikey serials are different from each other" {
create_full_enrollment
local s1 s2
s1=$(grep "^YUBIKEY_1=" "$YKCONF" | cut -d= -f2 | cut -d'|' -f1)
s2=$(grep "^YUBIKEY_2=" "$YKCONF" | cut -d= -f2 | cut -d'|' -f1)
assert [ "$s1" != "$s2" ]
}
@test "authorized_keys matches number of SSH key files" {
create_full_enrollment
local key_files key_lines
key_files=$(ls "${TEST_WORK}"/ssh-keys/*.pub | wc -l)
key_lines=$(grep -c "^sk-ssh-\|^ssh-ed25519\|^ecdsa-" "${TEST_WORK}/authorized_keys")
assert_equal "$key_files" "$key_lines"
}

View File

@@ -1,168 +0,0 @@
#!/usr/bin/env bats
# Tests for Cloudflare configuration enrollment
load test_helper
setup() {
setup_test_work
source_nanny_functions
}
teardown() {
teardown_test_work
}
# ─── cloudflare.conf format ───
@test "tunnel token is stored correctly" {
echo "CF_TUNNEL_TOKEN=eyJhIjoiNDhmMzA1ZjQ3YmY5N2I4OGFiNjg0YzY1NWIzMTVhNmUi" >> "$CFCONF"
run grep "^CF_TUNNEL_TOKEN=" "$CFCONF"
assert_success
assert_output --partial "eyJ"
}
@test "placeholder token is used when skipped" {
echo "CF_TUNNEL_TOKEN=PASTE_YOUR_TOKEN_HERE" >> "$CFCONF"
local token
token=$(grep "^CF_TUNNEL_TOKEN=" "$CFCONF" | cut -d= -f2-)
assert_equal "$token" "PASTE_YOUR_TOKEN_HERE"
}
@test "service token credentials stored separately" {
echo "CF_SVC_CLIENT_ID=abc123.access" >> "$CFCONF"
echo "CF_SVC_CLIENT_SECRET=secretvalue" >> "$CFCONF"
run grep "^CF_SVC_CLIENT_ID=" "$CFCONF"
assert_success
run grep "^CF_SVC_CLIENT_SECRET=" "$CFCONF"
assert_success
}
@test "access configured flag is set" {
echo "CF_ACCESS_CONFIGURED=true" >> "$CFCONF"
run grep "^CF_ACCESS_CONFIGURED=true" "$CFCONF"
assert_success
}
# ─── RustFS credentials ───
@test "rustfs credentials are stored" {
echo "RUSTFS_ROOT_USER=admin" >> "$CFCONF"
echo "RUSTFS_ROOT_PASSWORD=supersecretpass123" >> "$CFCONF"
local user pass
user=$(grep "^RUSTFS_ROOT_USER=" "$CFCONF" | cut -d= -f2)
pass=$(grep "^RUSTFS_ROOT_PASSWORD=" "$CFCONF" | cut -d= -f2)
assert_equal "$user" "admin"
assert_equal "$pass" "supersecretpass123"
}
@test "auto-generated password is at least 20 chars" {
local pass
pass=$(head -c 32 /dev/urandom | base64 | tr -d '/+=' | head -c 24)
assert [ ${#pass} -ge 20 ]
}
# ─── Network config ───
@test "static IP config stored with CIDR" {
echo "NET_STATIC_IP=192.168.1.100/24" >> "$CFCONF"
echo "NET_GATEWAY=192.168.1.1" >> "$CFCONF"
echo "NET_DNS=1.1.1.1" >> "$CFCONF"
local ip
ip=$(grep "^NET_STATIC_IP=" "$CFCONF" | cut -d= -f2)
assert_equal "$ip" "192.168.1.100/24"
}
@test "gateway is stored" {
echo "NET_GATEWAY=192.168.1.1" >> "$CFCONF"
local gw
gw=$(grep "^NET_GATEWAY=" "$CFCONF" | cut -d= -f2)
assert_equal "$gw" "192.168.1.1"
}
@test "dns defaults to 1.1.1.1 when empty" {
local dns=""
dns="${dns:-1.1.1.1}"
assert_equal "$dns" "1.1.1.1"
}
# ─── WARP config ───
@test "warp enabled flag stored" {
echo "CF_WARP_ENABLED=true" >> "$CFCONF"
run grep "^CF_WARP_ENABLED=true" "$CFCONF"
assert_success
}
@test "warp disabled flag stored" {
echo "CF_WARP_ENABLED=false" >> "$CFCONF"
run grep "^CF_WARP_ENABLED=false" "$CFCONF"
assert_success
}
# ─── Certificate config ───
@test "cert type none when skipped" {
echo "CF_CERT_TYPE=none" >> "$CFCONF"
local cert_type
cert_type=$(grep "^CF_CERT_TYPE=" "$CFCONF" | cut -d= -f2)
assert_equal "$cert_type" "none"
}
@test "cert type origin-ca when cloudflare origin cert" {
echo "CF_CERT_TYPE=origin-ca" >> "$CFCONF"
local cert_type
cert_type=$(grep "^CF_CERT_TYPE=" "$CFCONF" | cut -d= -f2)
assert_equal "$cert_type" "origin-ca"
}
@test "cert type letsencrypt when LE selected" {
echo "CF_CERT_TYPE=letsencrypt" >> "$CFCONF"
local cert_type
cert_type=$(grep "^CF_CERT_TYPE=" "$CFCONF" | cut -d= -f2)
assert_equal "$cert_type" "letsencrypt"
}
@test "API token stored for certbot" {
echo "CF_API_TOKEN=v1.0-abc123def456" >> "$CFCONF"
run grep "^CF_API_TOKEN=" "$CFCONF"
assert_success
}
@test "certbot credentials file has correct format" {
mkdir -p "${TEST_WORK}/certs"
echo "dns_cloudflare_api_token = testtoken123" > "${TEST_WORK}/certs/cf-credentials.ini"
run grep "dns_cloudflare_api_token" "${TEST_WORK}/certs/cf-credentials.ini"
assert_success
}
# ─── Cloudflare state tracking ───
@test "all cloudflare steps track independently" {
local steps=(cf_account cf_tunnel cf_routes cf_access cf_ssl cf_api_token cf_cert cf_warp cf_rustfs_creds cf_network)
for step in "${steps[@]}"; do
run state_done "$step"
assert_failure
done
state_mark "cf_account"
state_mark "cf_tunnel"
run state_done "cf_account"
assert_success
run state_done "cf_tunnel"
assert_success
run state_done "cf_routes"
assert_failure
}
@test "cloudflare state survives re-source" {
state_mark "cf_tunnel"
state_mark "cf_access"
source_nanny_functions
run state_done "cf_tunnel"
assert_success
run state_done "cf_access"
assert_success
run state_done "cf_network"
assert_failure
}

View File

@@ -1,138 +0,0 @@
#!/usr/bin/env bats
# Tests for drive enrollment logic
load test_helper
setup() {
setup_test_work
source_nanny_functions
# Init drives.conf
cat > "$CONF" << 'HEADER'
#
# Drive enrollment manifest — generated by nanny.sh
#
HEADER
}
teardown() {
teardown_test_work
}
# ─── is_serial_enrolled ───
@test "is_serial_enrolled returns false for empty conf" {
run is_serial_enrolled "WD-ABCDEF123456"
assert_failure
}
@test "is_serial_enrolled returns true for enrolled serial" {
echo "DRIVE_SATA_1=WD-ABCDEF123456|WD Blue|none|2000000000000|sata|enrolled_via=usb" >> "$CONF"
run is_serial_enrolled "WD-ABCDEF123456"
assert_success
}
@test "is_serial_enrolled partial serial does not match" {
echo "DRIVE_SATA_1=WD-ABCDEF123456|WD Blue|none|2000000000000|sata|enrolled_via=usb" >> "$CONF"
run is_serial_enrolled "WD-ABCDEF"
assert_failure
}
@test "is_serial_enrolled handles multiple drives" {
echo "DRIVE_SATA_1=SERIAL_AAA|WD Blue|none|2000000000000|sata|enrolled_via=usb" >> "$CONF"
echo "DRIVE_SATA_2=SERIAL_BBB|WD Blue|none|2000000000000|sata|enrolled_via=usb" >> "$CONF"
echo "DRIVE_SATA_3=SERIAL_CCC|WD Blue|none|2000000000000|sata|enrolled_via=usb" >> "$CONF"
run is_serial_enrolled "SERIAL_BBB"
assert_success
run is_serial_enrolled "SERIAL_DDD"
assert_failure
}
@test "is_serial_enrolled does not match model field" {
# The model "WD Blue SA510" appears between pipes, so the simple
# grep "|X|" pattern may match. This test documents the behavior.
# The real protection is that serials are alphanumeric with dashes,
# not multi-word strings with spaces.
echo "DRIVE_SATA_1=REAL_SERIAL|WDBlue|none|2000000000000|sata|enrolled_via=usb" >> "$CONF"
run is_serial_enrolled "WDBlue"
# WDBlue appears between pipes so it WILL match — this is a known
# limitation. In practice, serials and model names never collide.
assert_success
}
# ─── Drive manifest format ───
@test "drive manifest entry has correct pipe-delimited format" {
echo "DRIVE_SATA_1=WD-SER001|WD Blue SA510|0x5000cca|2000398934016|sata|enrolled_via=usb" >> "$CONF"
local line
line=$(grep "^DRIVE_SATA_1=" "$CONF")
# Verify 6 pipe-delimited fields after the =
local field_count
field_count=$(echo "$line" | cut -d= -f2 | tr '|' '\n' | wc -l)
assert_equal "$field_count" "6"
}
@test "drive manifest stores serial as first field" {
echo "DRIVE_SATA_1=WD-SER001|WD Blue SA510|0x5000cca|2000398934016|sata|enrolled_via=usb" >> "$CONF"
local serial
serial=$(grep "^DRIVE_SATA_1=" "$CONF" | cut -d= -f2 | cut -d'|' -f1)
assert_equal "$serial" "WD-SER001"
}
@test "drive manifest stores model as second field" {
echo "DRIVE_SATA_1=WD-SER001|WD Blue SA510|0x5000cca|2000398934016|sata|enrolled_via=usb" >> "$CONF"
local model
model=$(grep "^DRIVE_SATA_1=" "$CONF" | cut -d= -f2 | cut -d'|' -f2)
assert_equal "$model" "WD Blue SA510"
}
@test "drive manifest stores target transport (not enrollment transport)" {
echo "DRIVE_NVME_DOCKER=NVME-SER|SN770|none|2000000000000|nvme|enrolled_via=usb" >> "$CONF"
local tran
tran=$(grep "^DRIVE_NVME_DOCKER=" "$CONF" | cut -d= -f2 | cut -d'|' -f5)
assert_equal "$tran" "nvme"
}
@test "drive manifest records enrollment transport" {
echo "DRIVE_SATA_1=WD-SER001|WD Blue|none|2000000000000|sata|enrolled_via=usb" >> "$CONF"
local enrolled_via
# Field 6 contains "enrolled_via=usb" but cut -d= splits on both = signs
# so we use cut -d'|' to get the whole field
enrolled_via=$(grep "^DRIVE_SATA_1=" "$CONF" | sed 's/.*|//')
assert_equal "$enrolled_via" "enrolled_via=usb"
}
# ─── Duplicate detection ───
@test "duplicate serial detected across different roles" {
echo "DRIVE_SATA_1=DUPE-SERIAL|WD Blue|none|2000000000000|sata|enrolled_via=usb" >> "$CONF"
run is_serial_enrolled "DUPE-SERIAL"
assert_success
}
@test "same serial in SATA and NVMe roles is detected" {
echo "DRIVE_SATA_1=SHARED-SER|WD Blue|none|2000000000000|sata|enrolled_via=usb" >> "$CONF"
run is_serial_enrolled "SHARED-SER"
assert_success
}
# ─── Full enrollment count ───
@test "6 SATA + 1 NVMe + 1 HDD = 8 total drives" {
create_enrolled_drives 6
echo "DRIVE_NVME_DOCKER=NVM-SER|SN770|none|2000000000000|nvme|enrolled_via=usb" >> "$CONF"
echo "DRIVE_HDD_BACKUP=HDD-SER|WD Red|none|4000000000000|sata|enrolled_via=usb" >> "$CONF"
local count
count=$(grep -c "^DRIVE_" "$CONF")
assert_equal "$count" "8"
}
@test "partial enrollment counts correctly" {
create_enrolled_drives 3
local sata_count
sata_count=$(grep -c "^DRIVE_SATA_" "$CONF")
assert_equal "$sata_count" "3"
local nvme_count
nvme_count=$(grep -c "^DRIVE_NVME_" "$CONF" 2>/dev/null || true)
assert_equal "${nvme_count:-0}" "0"
}

View File

@@ -1,214 +0,0 @@
#!/usr/bin/env bats
# Integration tests — full enrollment flow simulation
load test_helper
setup() {
setup_test_work
source_nanny_functions
mkdir -p "${TEST_WORK}/ssh-keys"
}
teardown() {
teardown_test_work
}
# ─── Full state machine walkthrough ───
@test "complete enrollment produces all required state entries" {
local all_steps=(
"phase1_init"
"drive_sata_1" "drive_sata_2" "drive_sata_3"
"drive_sata_4" "drive_sata_5" "drive_sata_6"
"drive_nvme" "drive_hdd"
"phase1_resolver"
"yubikey_1" "yubikey_2"
"phase2_authkeys"
"cf_account" "cf_tunnel" "cf_routes" "cf_access"
"cf_ssl" "cf_api_token" "cf_cert" "cf_warp"
"cf_rustfs_creds" "cf_network"
)
for step in "${all_steps[@]}"; do
state_mark "$step"
done
local total
total=$(wc -l < "$STATE")
assert_equal "$total" "${#all_steps[@]}"
for step in "${all_steps[@]}"; do
run state_done "$step"
assert_success
done
}
@test "partial completion: drives done, yubikeys pending" {
state_mark "phase1_init"
for i in 1 2 3 4 5 6; do
state_mark "drive_sata_${i}"
done
state_mark "drive_nvme"
state_mark "drive_hdd"
state_mark "phase1_resolver"
# Drives complete
for i in 1 2 3 4 5 6; do
run state_done "drive_sata_${i}"
assert_success
done
# YubiKeys pending
run state_done "yubikey_1"
assert_failure
run state_done "yubikey_2"
assert_failure
# Cloudflare pending
run state_done "cf_account"
assert_failure
}
@test "partial completion: drives + keys done, cloudflare pending" {
state_mark "phase1_init"
for i in 1 2 3 4 5 6; do
state_mark "drive_sata_${i}"
done
state_mark "drive_nvme"
state_mark "drive_hdd"
state_mark "phase1_resolver"
state_mark "yubikey_1"
state_mark "yubikey_2"
state_mark "phase2_authkeys"
# Everything before CF done
run state_done "phase2_authkeys"
assert_success
# CF steps pending
run state_done "cf_account"
assert_failure
run state_done "cf_tunnel"
assert_failure
}
# ─── Full output file set ───
@test "complete enrollment creates all output files" {
# Simulate full enrollment outputs
cat > "$CONF" << 'DRIVES'
DRIVE_SATA_1=S001|WD Blue|none|2000000000000|sata|enrolled_via=usb
DRIVE_SATA_2=S002|WD Blue|none|2000000000000|sata|enrolled_via=usb
DRIVE_SATA_3=S003|WD Blue|none|2000000000000|sata|enrolled_via=usb
DRIVE_SATA_4=S004|WD Blue|none|2000000000000|sata|enrolled_via=usb
DRIVE_SATA_5=S005|WD Blue|none|2000000000000|sata|enrolled_via=usb
DRIVE_SATA_6=S006|WD Blue|none|2000000000000|sata|enrolled_via=usb
DRIVE_NVME_DOCKER=N001|SN770|none|2000000000000|nvme|enrolled_via=usb
DRIVE_HDD_BACKUP=H001|WD Red|none|4000000000000|sata|enrolled_via=usb
DRIVES
create_enrolled_yubikeys
echo "sk-ssh-ed25519 AAAA key1" > "${TEST_WORK}/ssh-keys/yubikey1_storagenode.pub"
echo "sk-ssh-ed25519 BBBB key2" > "${TEST_WORK}/ssh-keys/yubikey2_storagenode.pub"
cat "${TEST_WORK}"/ssh-keys/*.pub > "${TEST_WORK}/authorized_keys"
echo "#!/bin/sh" > "${TEST_WORK}/drive-resolver.sh"
chmod +x "${TEST_WORK}/drive-resolver.sh"
cat > "$CFCONF" << 'CF'
CF_TUNNEL_TOKEN=eyJ...
RUSTFS_ROOT_USER=admin
RUSTFS_ROOT_PASSWORD=testpass12345678
CF
# Verify all required files
local required_files=(
"drives.conf"
"drive-resolver.sh"
"yubikeys.conf"
"authorized_keys"
"cloudflare.conf"
)
for f in "${required_files[@]}"; do
assert [ -f "${TEST_WORK}/${f}" ]
done
}
# ─── Drive count validation ───
@test "exactly 8 DRIVE_ entries required" {
cat > "$CONF" << 'DRIVES'
DRIVE_SATA_1=S001|M|W|B|sata|e
DRIVE_SATA_2=S002|M|W|B|sata|e
DRIVE_SATA_3=S003|M|W|B|sata|e
DRIVE_SATA_4=S004|M|W|B|sata|e
DRIVE_SATA_5=S005|M|W|B|sata|e
DRIVE_SATA_6=S006|M|W|B|sata|e
DRIVE_NVME_DOCKER=N001|M|W|B|nvme|e
DRIVE_HDD_BACKUP=H001|M|W|B|sata|e
DRIVES
local total
total=$(grep -c "^DRIVE_" "$CONF")
assert_equal "$total" "8"
}
# ─── Log integrity ───
@test "log records all state transitions with timestamps" {
state_mark "phase1_init"
state_mark "drive_sata_1"
state_mark "cf_tunnel"
run grep -c "STATE:.*completed" "$LOG"
assert_output "3"
# Verify timestamp format
run grep -P "^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]" "$LOG"
assert_success
}
@test "log is append-only across resume" {
state_mark "drive_sata_1"
local lines_before
lines_before=$(wc -l < "$LOG")
# Simulate resume
source_nanny_functions
state_mark "drive_sata_2"
local lines_after
lines_after=$(wc -l < "$LOG")
assert [ "$lines_after" -gt "$lines_before" ]
# Original entry still present
run grep "drive_sata_1 completed" "$LOG"
assert_success
}
# ─── Edge cases ───
@test "state file with trailing newline works" {
printf "drive_sata_1\n\n" > "$STATE"
run state_done "drive_sata_1"
assert_success
}
@test "state file with windows line endings still works" {
printf "drive_sata_1\r\n" > "$STATE"
# grep -x won't match with \r, but our state should handle it
# This test documents the limitation
run grep "drive_sata_1" "$STATE"
assert_success
}
@test "concurrent state writes don't corrupt" {
# Simulate rapid sequential writes
for i in $(seq 1 20); do
state_mark "step_${i}"
done
local count
count=$(wc -l < "$STATE")
assert_equal "$count" "20"
}

View File

@@ -1,152 +0,0 @@
#!/usr/bin/env bats
# Tests for drive-resolver.sh generation and execution
load test_helper
setup() {
setup_test_work
source_nanny_functions
# Populate a full drives.conf
cat > "$CONF" << 'DRIVES'
#
# Drive enrollment manifest
#
DRIVE_SATA_1=WD-S001|WD Blue SA510 2TB|0x5001|2000398934016|sata|enrolled_via=usb
DRIVE_SATA_2=WD-S002|WD Blue SA510 2TB|0x5002|2000398934016|sata|enrolled_via=usb
DRIVE_SATA_3=WD-S003|WD Blue SA510 2TB|0x5003|2000398934016|sata|enrolled_via=usb
DRIVE_SATA_4=WD-S004|WD Blue SA510 2TB|0x5004|2000398934016|sata|enrolled_via=usb
DRIVE_SATA_5=WD-S005|WD Blue SA510 2TB|0x5005|2000398934016|sata|enrolled_via=usb
DRIVE_SATA_6=WD-S006|WD Blue SA510 2TB|0x5006|2000398934016|sata|enrolled_via=usb
DRIVE_NVME_DOCKER=NVM-D001|WD SN770 2TB|none|2000398934016|nvme|enrolled_via=usb
DRIVE_HDD_BACKUP=HDD-B001|WD Red Plus 4TB|none|4000787030016|sata|enrolled_via=usb
DRIVES
}
teardown() {
teardown_test_work
}
# ─── Resolver generation ───
generate_resolver() {
# Replicate the resolver generation logic from nanny.sh
cat > "${TEST_WORK}/drive-resolver.sh" << 'RESOLVER_HEAD'
#!/bin/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 [ "$serial" = "$target_serial" ]; then
result="$devpath"
break
fi
done
echo "$result"
}
RESOLVER_HEAD
{
echo "SATA_DRIVES=\"\""
for i in 1 2 3 4 5 6; do
line=$(grep "^DRIVE_SATA_${i}=" "$CONF")
serial=$(echo "$line" | cut -d= -f2 | cut -d'|' -f1)
echo "DEV_SATA_${i}=\$(resolve_drive \"${serial}\")"
echo "SATA_DRIVES=\"\${SATA_DRIVES} \${DEV_SATA_${i}}\""
done
echo "SATA_DRIVES=\$(echo \$SATA_DRIVES | sed 's/^ //')"
line=$(grep "^DRIVE_NVME_DOCKER=" "$CONF")
serial=$(echo "$line" | cut -d= -f2 | cut -d'|' -f1)
echo "DEV_NVME=\$(resolve_drive \"${serial}\")"
line=$(grep "^DRIVE_HDD_BACKUP=" "$CONF")
serial=$(echo "$line" | cut -d= -f2 | cut -d'|' -f1)
echo "DEV_HDD=\$(resolve_drive \"${serial}\")"
} >> "${TEST_WORK}/drive-resolver.sh"
chmod +x "${TEST_WORK}/drive-resolver.sh"
}
@test "resolver script is generated" {
generate_resolver
assert [ -f "${TEST_WORK}/drive-resolver.sh" ]
assert [ -x "${TEST_WORK}/drive-resolver.sh" ]
}
@test "resolver contains all 6 SATA serial lookups" {
generate_resolver
for i in 1 2 3 4 5 6; do
run grep "WD-S00${i}" "${TEST_WORK}/drive-resolver.sh"
assert_success
done
}
@test "resolver contains NVMe serial lookup" {
generate_resolver
run grep "NVM-D001" "${TEST_WORK}/drive-resolver.sh"
assert_success
}
@test "resolver contains HDD serial lookup" {
generate_resolver
run grep "HDD-B001" "${TEST_WORK}/drive-resolver.sh"
assert_success
}
@test "resolver defines SATA_DRIVES variable" {
generate_resolver
run grep "SATA_DRIVES=" "${TEST_WORK}/drive-resolver.sh"
assert_success
}
@test "resolver defines DEV_NVME variable" {
generate_resolver
run grep "DEV_NVME=" "${TEST_WORK}/drive-resolver.sh"
assert_success
}
@test "resolver defines DEV_HDD variable" {
generate_resolver
run grep "DEV_HDD=" "${TEST_WORK}/drive-resolver.sh"
assert_success
}
@test "resolver skips virtual devices in resolve_drive" {
generate_resolver
run grep "loop\*|ram\*|dm-\*|md\*|sr\*|zram\*" "${TEST_WORK}/drive-resolver.sh"
assert_success
}
@test "resolve_drive function tries lsblk then udevadm fallback" {
generate_resolver
run grep "ID_SERIAL_SHORT" "${TEST_WORK}/drive-resolver.sh"
assert_success
}
# ─── Resolver with mocked system ───
@test "resolve_drive returns empty for unknown serial" {
# Create a mock lsblk that returns known serials
create_smart_mock "lsblk" 'echo ""'
create_smart_mock "udevadm" 'echo ""'
# Source just the resolve_drive function
eval "$(head -30 <(generate_resolver; cat "${TEST_WORK}/drive-resolver.sh"))"
# This won't find anything since /sys/block is the real system
# but the mock ensures lsblk returns empty
result=$(resolve_drive "NONEXISTENT-SERIAL")
assert_equal "$result" ""
}

View File

@@ -1,147 +0,0 @@
#!/usr/bin/env bats
# Tests for state management (resume, checkpointing)
load test_helper
setup() {
setup_test_work
source_nanny_functions
}
teardown() {
teardown_test_work
}
# ─── state_done ───
@test "state_done returns false for unmarked state" {
run state_done "drive_sata_1"
assert_failure
}
@test "state_done returns true for marked state" {
echo "drive_sata_1" >> "$STATE"
run state_done "drive_sata_1"
assert_success
}
@test "state_done is exact match — partial names don't match" {
echo "drive_sata_1" >> "$STATE"
run state_done "drive_sata_10"
assert_failure
}
@test "state_done is exact match — substrings don't match" {
echo "drive_sata_1" >> "$STATE"
run state_done "drive_sata"
assert_failure
}
@test "state_done works with empty state file" {
> "$STATE"
run state_done "anything"
assert_failure
}
@test "state_done works when state file missing" {
rm -f "$STATE"
run state_done "anything"
assert_failure
}
# ─── state_mark ───
@test "state_mark writes step to state file" {
state_mark "drive_sata_1"
run grep -x "drive_sata_1" "$STATE"
assert_success
}
@test "state_mark writes timestamp to log" {
state_mark "drive_sata_1"
run grep "STATE: drive_sata_1 completed" "$LOG"
assert_success
}
@test "state_mark is idempotent — no duplicate entries" {
state_mark "drive_sata_1"
state_mark "drive_sata_1"
state_mark "drive_sata_1"
local count
count=$(grep -cx "drive_sata_1" "$STATE")
assert_equal "$count" "1"
}
@test "state_mark preserves existing state" {
state_mark "phase1_init"
state_mark "drive_sata_1"
state_mark "drive_sata_2"
run grep -c "." "$STATE"
assert_output "3"
}
# ─── Resume scenarios ───
@test "resume: all phase 1 drive states are checkpointed independently" {
local steps=(
"phase1_init"
"drive_sata_1" "drive_sata_2" "drive_sata_3"
"drive_sata_4" "drive_sata_5" "drive_sata_6"
"drive_nvme" "drive_hdd"
"phase1_resolver"
)
for step in "${steps[@]}"; do
state_mark "$step"
done
for step in "${steps[@]}"; do
run state_done "$step"
assert_success
done
}
@test "resume: partial state correctly identifies remaining work" {
state_mark "phase1_init"
state_mark "drive_sata_1"
state_mark "drive_sata_2"
# drives 3-6, nvme, hdd should be pending
run state_done "drive_sata_3"
assert_failure
run state_done "drive_nvme"
assert_failure
run state_done "drive_hdd"
assert_failure
}
@test "resume: cloudflare steps are independent of drive steps" {
state_mark "cf_account"
state_mark "cf_tunnel"
run state_done "cf_account"
assert_success
run state_done "cf_routes"
assert_failure
run state_done "drive_sata_1"
assert_failure
}
@test "resume: state file survives across source reloads" {
state_mark "drive_sata_1"
state_mark "yubikey_1"
# Re-source functions (simulates script restart)
source_nanny_functions
run state_done "drive_sata_1"
assert_success
run state_done "yubikey_1"
assert_success
}
@test "state file ordering is preserved" {
state_mark "phase1_init"
state_mark "drive_sata_1"
state_mark "drive_sata_2"
local first_line
first_line=$(head -1 "$STATE")
assert_equal "$first_line" "phase1_init"
local third_line
third_line=$(sed -n '3p' "$STATE")
assert_equal "$third_line" "drive_sata_2"
}

View File

@@ -1,153 +0,0 @@
#!/bin/bash
# test_helper.bash — Common setup for all nanny.sh tests
BATS_SUPPORT="${BATS_TEST_DIRNAME}/../node_modules/bats-support"
BATS_ASSERT="${BATS_TEST_DIRNAME}/../node_modules/bats-assert"
load "${BATS_SUPPORT}/load.bash"
load "${BATS_ASSERT}/load.bash"
# Test working directory — isolated per test
TEST_WORK=""
setup_test_work() {
TEST_WORK=$(mktemp -d)
export WORK="$TEST_WORK"
export CONF="${TEST_WORK}/drives.conf"
export YKCONF="${TEST_WORK}/yubikeys.conf"
export CFCONF="${TEST_WORK}/cloudflare.conf"
export STATE="${TEST_WORK}/nanny.state"
export LOG="${TEST_WORK}/nanny.log"
touch "$STATE" "$LOG"
# Mock bin directory — prepended to PATH
MOCK_BIN="${TEST_WORK}/mock-bin"
mkdir -p "$MOCK_BIN"
export PATH="${MOCK_BIN}:${PATH}"
}
teardown_test_work() {
[ -n "$TEST_WORK" ] && rm -rf "$TEST_WORK"
}
# Source just the functions from nanny.sh without running main logic.
# We extract functions by sourcing with a guard.
source_nanny_functions() {
# Create a version of nanny.sh that only defines functions
local func_file="${TEST_WORK}/nanny_functions.bash"
cat > "$func_file" << 'FUNCS'
# State management
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
}
# Drive helpers
snapshot_devices() {
lsblk -dno NAME,TYPE 2>/dev/null | awk '$2=="disk"{print $1}' | sort
}
is_serial_enrolled() {
grep -q "|${1}|" "$CONF" 2>/dev/null || grep -q "=${1}|" "$CONF" 2>/dev/null
}
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"
fi
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
}
FUNCS
source "$func_file"
}
# Create a mock command that records calls and returns preset output
create_mock() {
local cmd_name="$1"
local output="$2"
local exit_code="${3:-0}"
cat > "${MOCK_BIN}/${cmd_name}" << MOCK
#!/bin/bash
echo "\$0 \$@" >> "${TEST_WORK}/mock_calls.log"
echo "${output}"
exit ${exit_code}
MOCK
chmod +x "${MOCK_BIN}/${cmd_name}"
}
# Create a mock that behaves differently based on arguments
create_smart_mock() {
local cmd_name="$1"
local script_body="$2"
cat > "${MOCK_BIN}/${cmd_name}" << MOCK
#!/bin/bash
echo "\$0 \$@" >> "${TEST_WORK}/mock_calls.log"
${script_body}
MOCK
chmod +x "${MOCK_BIN}/${cmd_name}"
}
# Check if a mock was called with specific arguments
assert_mock_called_with() {
local pattern="$1"
grep -q "$pattern" "${TEST_WORK}/mock_calls.log" 2>/dev/null
}
# Count mock calls
mock_call_count() {
local cmd="$1"
grep -c "$cmd" "${TEST_WORK}/mock_calls.log" 2>/dev/null || echo 0
}
# Create a fake drives.conf with N enrolled drives
create_enrolled_drives() {
local count="${1:-6}"
cat > "$CONF" << 'HEADER'
#
# Drive enrollment manifest — generated by nanny.sh
#
HEADER
for i in $(seq 1 "$count"); do
echo "DRIVE_SATA_${i}=WD-SERIAL${i}|WD Blue SA510|0x5000|2000000000000|sata|enrolled_via=usb" >> "$CONF"
done
}
# Create a fake yubikeys.conf
create_enrolled_yubikeys() {
cat > "$YKCONF" << 'YK'
#
# YubiKey enrollment manifest
#
YUBIKEY_1=12345678|YubiKey 5 NFC|5.4.3|yubikey1_storagenode.pub
YUBIKEY_2=87654321|YubiKey 5C|5.4.3|yubikey2_storagenode.pub
YK
}

View File

@@ -1,154 +0,0 @@
#!/usr/bin/env bats
# Tests for YubiKey enrollment logic
load test_helper
setup() {
setup_test_work
source_nanny_functions
mkdir -p "${TEST_WORK}/ssh-keys"
}
teardown() {
teardown_test_work
}
# ─── YubiKey manifest format ───
@test "yubikey manifest entry has correct format" {
cat > "$YKCONF" << 'YK'
YUBIKEY_1=12345678|YubiKey 5 NFC|5.4.3|yubikey1_storagenode.pub
YK
local line
line=$(grep "^YUBIKEY_1=" "$YKCONF")
local field_count
field_count=$(echo "$line" | cut -d= -f2 | tr '|' '\n' | wc -l)
assert_equal "$field_count" "4"
}
@test "yubikey manifest stores serial as first field" {
cat > "$YKCONF" << 'YK'
YUBIKEY_1=12345678|YubiKey 5 NFC|5.4.3|yubikey1_storagenode.pub
YK
local serial
serial=$(grep "^YUBIKEY_1=" "$YKCONF" | cut -d= -f2 | cut -d'|' -f1)
assert_equal "$serial" "12345678"
}
@test "yubikey manifest stores type as second field" {
cat > "$YKCONF" << 'YK'
YUBIKEY_1=12345678|YubiKey 5 NFC|5.4.3|yubikey1_storagenode.pub
YK
local type
type=$(grep "^YUBIKEY_1=" "$YKCONF" | cut -d= -f2 | cut -d'|' -f2)
assert_equal "$type" "YubiKey 5 NFC"
}
@test "yubikey manifest stores firmware as third field" {
cat > "$YKCONF" << 'YK'
YUBIKEY_1=12345678|YubiKey 5 NFC|5.4.3|yubikey1_storagenode.pub
YK
local fw
fw=$(grep "^YUBIKEY_1=" "$YKCONF" | cut -d= -f2 | cut -d'|' -f3)
assert_equal "$fw" "5.4.3"
}
@test "yubikey manifest references SSH pubkey filename" {
cat > "$YKCONF" << 'YK'
YUBIKEY_1=12345678|YubiKey 5 NFC|5.4.3|yubikey1_storagenode.pub
YK
local pubkey
pubkey=$(grep "^YUBIKEY_1=" "$YKCONF" | cut -d= -f2 | cut -d'|' -f4)
assert_equal "$pubkey" "yubikey1_storagenode.pub"
}
# ─── Duplicate YubiKey detection ───
@test "duplicate yubikey serial is detected" {
cat > "$YKCONF" << 'YK'
YUBIKEY_1=12345678|YubiKey 5 NFC|5.4.3|yubikey1_storagenode.pub
YK
run grep -q "12345678" "$YKCONF"
assert_success
}
@test "different yubikey serial is not flagged" {
cat > "$YKCONF" << 'YK'
YUBIKEY_1=12345678|YubiKey 5 NFC|5.4.3|yubikey1_storagenode.pub
YK
run grep -q "99999999" "$YKCONF"
assert_failure
}
# ─── authorized_keys generation ───
@test "authorized_keys includes all pubkeys" {
echo "sk-ssh-ed25519 AAAA key1-comment" > "${TEST_WORK}/ssh-keys/yubikey1_storagenode.pub"
echo "sk-ssh-ed25519 BBBB key2-comment" > "${TEST_WORK}/ssh-keys/yubikey2_storagenode.pub"
{
echo "# Generated by nanny.sh"
for kf in "${TEST_WORK}"/ssh-keys/*.pub; do
[ -f "$kf" ] || continue
echo "# $(basename "$kf")"
cat "$kf"
done
} > "${TEST_WORK}/authorized_keys"
run grep -c "sk-ssh-ed25519" "${TEST_WORK}/authorized_keys"
assert_output "2"
}
@test "authorized_keys has comment headers per key" {
echo "sk-ssh-ed25519 AAAA key1" > "${TEST_WORK}/ssh-keys/yubikey1_storagenode.pub"
{
echo "# Generated by nanny.sh"
for kf in "${TEST_WORK}"/ssh-keys/*.pub; do
[ -f "$kf" ] || continue
echo "# $(basename "$kf")"
cat "$kf"
done
} > "${TEST_WORK}/authorized_keys"
run grep "# yubikey1_storagenode.pub" "${TEST_WORK}/authorized_keys"
assert_success
}
@test "authorized_keys with ed25519 fallback (non-FIDO key)" {
echo "ssh-ed25519 CCCC non-fido-key" > "${TEST_WORK}/ssh-keys/yubikey1_storagenode.pub"
{
for kf in "${TEST_WORK}"/ssh-keys/*.pub; do
cat "$kf"
done
} > "${TEST_WORK}/authorized_keys"
run grep "ssh-ed25519" "${TEST_WORK}/authorized_keys"
assert_success
}
# ─── YubiKey state tracking ───
@test "yubikey enrollment marks state for key 1" {
state_mark "yubikey_1"
run state_done "yubikey_1"
assert_success
run state_done "yubikey_2"
assert_failure
}
@test "yubikey enrollment marks state for both keys" {
state_mark "yubikey_1"
state_mark "yubikey_2"
run state_done "yubikey_1"
assert_success
run state_done "yubikey_2"
assert_success
}
@test "authorized_keys generation marks state" {
state_mark "phase2_authkeys"
run state_done "phase2_authkeys"
assert_success
}

View File

@@ -1,104 +0,0 @@
18:07:31.612412 [debug] [MainThread]: Sending event: {'category': 'dbt', 'action': 'invocation', 'label': 'start', 'context': [<snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x7b16f2085a90>, <snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x7b16f3961310>, <snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x7b16f17c7250>]}
============================== 18:07:31.631410 | 9eebcce4-53cc-4ffd-b0cd-fbc6cb715845 ==============================
18:07:31.631410 [info ] [MainThread]: Running with dbt=1.11.3
18:07:31.631651 [debug] [MainThread]: running dbt with arguments {'use_colors': 'True', 'invocation_command': 'dbt init', 'log_format': 'default', 'quiet': 'False', 'use_experimental_parser': 'False', 'introspect': 'True', 'empty': 'None', 'write_json': 'True', 'debug': 'False', 'profiles_dir': '/home/kert/.dbt', 'fail_fast': 'False', 'send_anonymous_usage_stats': 'True', 'target_path': 'None', 'warn_error': 'None', 'printer_width': '80', 'indirect_selection': 'eager', 'log_cache_events': 'False', 'static_parser': 'True', 'version_check': 'True', 'cache_selected_only': 'False', 'partial_parse': 'True', 'no_print': 'None', 'warn_error_options': 'WarnErrorOptionsV2(error=[], warn=[], silence=[])', 'log_path': 'logs'}
18:07:31.650335 [info ] [MainThread]: Creating dbt configuration folder at /home/kert/.dbt
18:07:48.715001 [debug] [MainThread]: Starter project path: /home/kert/.local/share/uv/tools/dbt-core/lib/python3.13/site-packages/dbt/include/starter_project
18:07:48.716267 [info ] [MainThread]:
Your new dbt project "stack" was created!
For more information on how to configure the profiles.yml file,
please consult the dbt documentation here:
https://docs.getdbt.com/docs/configure-your-profile
One more thing:
Need help? Don't hesitate to reach out to us via GitHub issues or on Slack:
https://community.getdbt.com/
Happy modeling!
18:07:48.716476 [info ] [MainThread]: Setting up your profile.
18:07:54.624362 [info ] [MainThread]: Profile stack written to /home/kert/.dbt/profiles.yml using target's sample configuration. Once updated, you'll be able to start developing with dbt.
18:07:54.624896 [debug] [MainThread]: Resource report: {"command_name": "init", "command_success": true, "command_wall_clock_time": 23.035336, "process_in_blocks": "88", "process_kernel_time": 0.082027, "process_mem_max_rss": "140632", "process_out_blocks": "37424", "process_user_time": 1.389458}
18:07:54.625133 [debug] [MainThread]: Command `dbt init` succeeded at 18:07:54.625078 after 23.04 seconds
18:07:54.625333 [debug] [MainThread]: Sending event: {'category': 'dbt', 'action': 'invocation', 'label': 'end', 'context': [<snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x7b16f0f8e3f0>, <snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x7b16f0f131d0>, <snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x7b16f0e805a0>]}
18:07:54.625552 [debug] [MainThread]: Flushing usage events
18:07:54.757751 [debug] [MainThread]: An error was encountered while trying to flush usage events
18:08:45.370155 [debug] [MainThread]: Sending event: {'category': 'dbt', 'action': 'invocation', 'label': 'start', 'context': [<snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x70035cb71010>, <snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x70035e489450>, <snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x70035c20f110>]}
============================== 18:08:45.384949 | 1ea2649e-5d81-45d4-8775-5a5911ff73ce ==============================
18:08:45.384949 [info ] [MainThread]: Running with dbt=1.11.3
18:08:45.385197 [debug] [MainThread]: running dbt with arguments {'quiet': 'False', 'log_cache_events': 'False', 'fail_fast': 'False', 'send_anonymous_usage_stats': 'True', 'log_format': 'default', 'cache_selected_only': 'False', 'write_json': 'True', 'indirect_selection': 'eager', 'target_path': 'None', 'use_experimental_parser': 'False', 'profiles_dir': '/home/kert/.dbt', 'partial_parse': 'True', 'introspect': 'True', 'no_print': 'None', 'warn_error': 'None', 'log_path': 'logs', 'printer_width': '80', 'use_colors': 'True', 'version_check': 'True', 'invocation_command': 'dbt test', 'debug': 'False', 'static_parser': 'True', 'empty': 'None', 'warn_error_options': 'WarnErrorOptionsV2(error=[], warn=[], silence=[])'}
18:08:45.385423 [error] [MainThread]: Encountered an error:
Runtime Error
No dbt_project.yml found at expected path /home/kert/stack/dbt_project.yml
Verify that each entry within packages.yml (and their transitive dependencies) contains a file named dbt_project.yml
18:08:45.385764 [debug] [MainThread]: Resource report: {"command_name": "test", "command_success": false, "command_wall_clock_time": 0.038533367, "process_in_blocks": "0", "process_kernel_time": 0.085946, "process_mem_max_rss": "126624", "process_out_blocks": "33104", "process_user_time": 1.210253}
18:08:45.385969 [debug] [MainThread]: Command `dbt test` failed at 18:08:45.385930 after 0.04 seconds
18:08:45.386122 [debug] [MainThread]: Sending event: {'category': 'dbt', 'action': 'invocation', 'label': 'end', 'context': [<snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x70035ba6a8b0>, <snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x70035ba95a30>, <snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x70035bb24490>]}
18:08:45.386272 [debug] [MainThread]: Flushing usage events
18:08:45.477717 [debug] [MainThread]: An error was encountered while trying to flush usage events
18:09:14.758962 [debug] [MainThread]: Sending event: {'category': 'dbt', 'action': 'invocation', 'label': 'start', 'context': [<snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x777d2417cad0>, <snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x777d25c34190>, <snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x777d232f3c50>]}
============================== 18:09:14.760556 | 9df2f850-5b3c-480a-a0a2-2dd62a703d22 ==============================
18:09:14.760556 [info ] [MainThread]: Running with dbt=1.11.3
18:09:14.760774 [debug] [MainThread]: running dbt with arguments {'invocation_command': 'dbt init', 'empty': 'None', 'write_json': 'True', 'printer_width': '80', 'fail_fast': 'False', 'log_cache_events': 'False', 'send_anonymous_usage_stats': 'True', 'indirect_selection': 'eager', 'static_parser': 'True', 'introspect': 'True', 'profiles_dir': '/home/kert/.dbt', 'partial_parse': 'True', 'log_path': 'logs', 'log_format': 'default', 'quiet': 'False', 'debug': 'False', 'cache_selected_only': 'False', 'use_experimental_parser': 'False', 'target_path': 'None', 'use_colors': 'True', 'warn_error': 'None', 'warn_error_options': 'WarnErrorOptionsV2(error=[], warn=[], silence=[])', 'version_check': 'True', 'no_print': 'None'}
18:09:20.184408 [info ] [MainThread]: A project called stack already exists here.
18:09:20.185308 [debug] [MainThread]: Resource report: {"command_name": "init", "command_success": true, "command_wall_clock_time": 5.4502306, "process_in_blocks": "0", "process_kernel_time": 0.042925, "process_mem_max_rss": "101404", "process_out_blocks": "16", "process_user_time": 0.560022}
18:09:20.185582 [debug] [MainThread]: Command `dbt init` succeeded at 18:09:20.185528 after 5.45 seconds
18:09:20.185761 [debug] [MainThread]: Sending event: {'category': 'dbt', 'action': 'invocation', 'label': 'end', 'context': [<snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x777d232fa2c0>, <snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x777d231aa690>, <snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x777d23180160>]}
18:09:20.185956 [debug] [MainThread]: Flushing usage events
18:09:20.357279 [debug] [MainThread]: An error was encountered while trying to flush usage events
18:09:37.181553 [debug] [MainThread]: Sending event: {'category': 'dbt', 'action': 'invocation', 'label': 'start', 'context': [<snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x710bf1ba8ad0>, <snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x710bf3638190>, <snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x710bf0d1bc50>]}
============================== 18:09:37.183472 | 45dac402-4334-4b87-b812-cf3e37691c85 ==============================
18:09:37.183472 [info ] [MainThread]: Running with dbt=1.11.3
18:09:37.183716 [debug] [MainThread]: running dbt with arguments {'write_json': 'True', 'send_anonymous_usage_stats': 'True', 'partial_parse': 'True', 'printer_width': '80', 'warn_error_options': 'WarnErrorOptionsV2(error=[], warn=[], silence=[])', 'invocation_command': 'dbt init', 'log_format': 'default', 'version_check': 'True', 'log_path': 'logs', 'fail_fast': 'False', 'target_path': 'None', 'empty': 'None', 'no_print': 'None', 'indirect_selection': 'eager', 'static_parser': 'True', 'use_colors': 'True', 'quiet': 'False', 'warn_error': 'None', 'log_cache_events': 'False', 'profiles_dir': '/home/kert/.dbt', 'use_experimental_parser': 'False', 'introspect': 'True', 'debug': 'False', 'cache_selected_only': 'False'}
18:09:39.039926 [debug] [MainThread]: Starter project path: /home/kert/.local/share/uv/tools/dbt-core/lib/python3.13/site-packages/dbt/include/starter_project
18:09:39.041119 [info ] [MainThread]:
Your new dbt project "tuva" was created!
For more information on how to configure the profiles.yml file,
please consult the dbt documentation here:
https://docs.getdbt.com/docs/configure-your-profile
One more thing:
Need help? Don't hesitate to reach out to us via GitHub issues or on Slack:
https://community.getdbt.com/
Happy modeling!
18:09:39.041308 [info ] [MainThread]: Setting up your profile.
18:09:41.375307 [debug] [MainThread]: Resource report: {"command_name": "init", "command_success": true, "command_wall_clock_time": 4.2184906, "process_in_blocks": "0", "process_kernel_time": 0.056915, "process_mem_max_rss": "130728", "process_out_blocks": "88", "process_user_time": 0.590128}
18:09:41.375666 [debug] [MainThread]: Command `dbt init` succeeded at 18:09:41.375610 after 4.22 seconds
18:09:41.375853 [debug] [MainThread]: Sending event: {'category': 'dbt', 'action': 'invocation', 'label': 'end', 'context': [<snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x710bf0d222c0>, <snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x710bf140b410>, <snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x710bf0ba79b0>]}
18:09:41.376056 [debug] [MainThread]: Flushing usage events
18:09:41.468832 [debug] [MainThread]: An error was encountered while trying to flush usage events
18:15:54.394174 [debug] [MainThread]: Sending event: {'category': 'dbt', 'action': 'invocation', 'label': 'start', 'context': [<snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x7c4966b75010>, <snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x7c4968489450>, <snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x7c496620f110>]}
============================== 18:15:54.409892 | 1243bf14-bd91-4cd7-9f36-1794f26f96ed ==============================
18:15:54.409892 [info ] [MainThread]: Running with dbt=1.11.3
18:15:54.410223 [debug] [MainThread]: running dbt with arguments {'use_experimental_parser': 'False', 'write_json': 'True', 'indirect_selection': 'eager', 'partial_parse': 'True', 'profiles_dir': '/home/kert/.dbt', 'log_path': 'logs', 'warn_error': 'None', 'cache_selected_only': 'False', 'use_colors': 'True', 'version_check': 'True', 'invocation_command': 'dbt deps', 'debug': 'False', 'target_path': 'None', 'quiet': 'False', 'log_cache_events': 'False', 'send_anonymous_usage_stats': 'True', 'introspect': 'True', 'static_parser': 'True', 'empty': 'None', 'fail_fast': 'False', 'warn_error_options': 'WarnErrorOptionsV2(error=[], warn=[], silence=[])', 'printer_width': '80', 'no_print': 'None', 'log_format': 'default'}
18:15:54.410673 [error] [MainThread]: Encountered an error:
Runtime Error
No dbt_project.yml found at expected path /home/kert/stack/dbt_project.yml
Verify that each entry within packages.yml (and their transitive dependencies) contains a file named dbt_project.yml
18:15:54.411040 [debug] [MainThread]: Resource report: {"command_name": "deps", "command_success": false, "command_wall_clock_time": 0.039776757, "process_in_blocks": "0", "process_kernel_time": 0.071869, "process_mem_max_rss": "126996", "process_out_blocks": "33096", "process_user_time": 1.22578}
18:15:54.411242 [debug] [MainThread]: Command `dbt deps` failed at 18:15:54.411205 after 0.04 seconds
18:15:54.411380 [debug] [MainThread]: Sending event: {'category': 'dbt', 'action': 'invocation', 'label': 'end', 'context': [<snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x7c4965a668b0>, <snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x7c4965a95b50>, <snowplow_tracker.self_describing_json.SelfDescribingJson object at 0x7c4965ac0490>]}
18:15:54.411522 [debug] [MainThread]: Flushing usage events
18:15:54.544400 [debug] [MainThread]: An error was encountered while trying to flush usage events