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:
15
hw/README.md
15
hw/README.md
@@ -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 `/`) |
|
||||
|
||||
270
hw/RUNBOOK.md
270
hw/RUNBOOK.md
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
1246
hw/build-iso.sh
1246
hw/build-iso.sh
File diff suppressed because it is too large
Load Diff
378
hw/build.sh
378
hw/build.sh
@@ -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 ""
|
||||
105
hw/config.yaml
105
hw/config.yaml
@@ -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
|
||||
|
||||
@@ -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"
|
||||
936
hw/nanny.sh
936
hw/nanny.sh
@@ -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
49
hw/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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" ""
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
104
logs/dbt.log
104
logs/dbt.log
@@ -1,104 +0,0 @@
|
||||
[0m18: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 ==============================
|
||||
[0m18:07:31.631410 [info ] [MainThread]: Running with dbt=1.11.3
|
||||
[0m18: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'}
|
||||
[0m18:07:31.650335 [info ] [MainThread]: Creating dbt configuration folder at /home/kert/.dbt
|
||||
[0m18: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
|
||||
[0m18: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!
|
||||
|
||||
[0m18:07:48.716476 [info ] [MainThread]: Setting up your profile.
|
||||
[0m18: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.
|
||||
[0m18: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}
|
||||
[0m18:07:54.625133 [debug] [MainThread]: Command `dbt init` succeeded at 18:07:54.625078 after 23.04 seconds
|
||||
[0m18: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>]}
|
||||
[0m18:07:54.625552 [debug] [MainThread]: Flushing usage events
|
||||
[0m18:07:54.757751 [debug] [MainThread]: An error was encountered while trying to flush usage events
|
||||
[0m18: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 ==============================
|
||||
[0m18:08:45.384949 [info ] [MainThread]: Running with dbt=1.11.3
|
||||
[0m18: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=[])'}
|
||||
[0m18: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
|
||||
|
||||
[0m18: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}
|
||||
[0m18:08:45.385969 [debug] [MainThread]: Command `dbt test` failed at 18:08:45.385930 after 0.04 seconds
|
||||
[0m18: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>]}
|
||||
[0m18:08:45.386272 [debug] [MainThread]: Flushing usage events
|
||||
[0m18:08:45.477717 [debug] [MainThread]: An error was encountered while trying to flush usage events
|
||||
[0m18: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 ==============================
|
||||
[0m18:09:14.760556 [info ] [MainThread]: Running with dbt=1.11.3
|
||||
[0m18: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'}
|
||||
[0m18:09:20.184408 [info ] [MainThread]: A project called stack already exists here.
|
||||
[0m18: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}
|
||||
[0m18:09:20.185582 [debug] [MainThread]: Command `dbt init` succeeded at 18:09:20.185528 after 5.45 seconds
|
||||
[0m18: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>]}
|
||||
[0m18:09:20.185956 [debug] [MainThread]: Flushing usage events
|
||||
[0m18:09:20.357279 [debug] [MainThread]: An error was encountered while trying to flush usage events
|
||||
[0m18: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 ==============================
|
||||
[0m18:09:37.183472 [info ] [MainThread]: Running with dbt=1.11.3
|
||||
[0m18: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'}
|
||||
[0m18: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
|
||||
[0m18: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!
|
||||
|
||||
[0m18:09:39.041308 [info ] [MainThread]: Setting up your profile.
|
||||
[0m18: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}
|
||||
[0m18:09:41.375666 [debug] [MainThread]: Command `dbt init` succeeded at 18:09:41.375610 after 4.22 seconds
|
||||
[0m18: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>]}
|
||||
[0m18:09:41.376056 [debug] [MainThread]: Flushing usage events
|
||||
[0m18:09:41.468832 [debug] [MainThread]: An error was encountered while trying to flush usage events
|
||||
[0m18: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 ==============================
|
||||
[0m18:15:54.409892 [info ] [MainThread]: Running with dbt=1.11.3
|
||||
[0m18: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'}
|
||||
[0m18: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
|
||||
|
||||
[0m18: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}
|
||||
[0m18:15:54.411242 [debug] [MainThread]: Command `dbt deps` failed at 18:15:54.411205 after 0.04 seconds
|
||||
[0m18: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>]}
|
||||
[0m18:15:54.411522 [debug] [MainThread]: Flushing usage events
|
||||
[0m18:15:54.544400 [debug] [MainThread]: An error was encountered while trying to flush usage events
|
||||
Reference in New Issue
Block a user