# DAMM Bring-Up Notebook

A step-by-step walkthrough for bringing DAMM up on `hub2` (Contabo VPS, public IP), from scratch to a working `/get/` wizard with a first device enrolled. Every cell is idempotent — re-running it on a healthy system either no-ops or cleanly re-applies. Each cell has a clear postcondition so you know when to advance.

Read each cell top-to-bottom. Run the **Action** block. Run the **Verify** block. If it doesn't pass, read **If wrong** and iterate within the same cell until verify passes. Don't skip ahead.

> **Where to run from**: your laptop, with SSH access to `hub2` (alias for the Contabo box at `149.102.137.139`). All "ssh hub2" commands assume `~/.ssh/config` already has the alias.

---

## Cell 0 — Sanity check the host

**What**: confirm hub2 is reachable, has the basics, and is roughly the right size.

**Why**: every cell after assumes hub2 is online, you can SSH in non-interactively, and the box has node, wireguard-tools, and Caddy.

**Run**:
```
ssh -o RemoteCommand=none hub2 'hostname; uname -m; whoami; \
  command -v node wg wg-quick caddy jq; \
  free -m | head -2; df -h / | head -2; date -u'
```

**Verify**: you see a hostname, `x86_64`, your user, paths for `node` `wg` `wg-quick` `caddy` `jq`, ≥1GB free RAM, ≥10% free disk, current UTC time within seconds of yours.

**If wrong**:
- `command -v` missing a tool: `sudo apt-get install -y wireguard-tools jq`. Caddy: `sudo apt install -y caddy`. Node: see Cell 1.
- Time skew: `sudo apt-get install -y chrony && sudo systemctl enable --now chrony && sudo chronyc -a makestep`.
- SSH alias missing: edit `~/.ssh/config` to add `Host hub2 / HostName <ip> / User <you>`.

**Done when**: the verify command prints clean output with no missing tools or red flags.

---

## Cell 1 — Project on hub2

**What**: have the DAMM project at `/home/uprootiny/damm/` with `node_modules` installed and tests passing.

**Why**: everything depends on the project being there. We deploy from `hyle:~/damm` (source-of-truth) but run from `hub2:~/damm`.

**Run** (from your laptop):
```
ssh hyle 'cd ~/damm && rsync -az --delete \
  --exclude=node_modules --exclude=.git --exclude=site-dist --exclude=asideafar --exclude=orccu \
  ~/damm/ hub2:/home/uprootiny/damm/'
ssh hyle 'cd ~/damm && rsync -av \
  --include="*.json" --include="*.txt" --exclude="*" \
  data/ hub2:/home/uprootiny/damm/data/'
ssh hub2 'cd ~/damm && npm install --no-audit --no-fund 2>&1 | tail -3 && npm test 2>&1 | tail -5'
```

**Verify**: tests print `pass 43, fail 0, skipped 1` (postgres test skips without `DATABASE_URL`).

**If wrong**:
- `npm: command not found`: install Node via the system package or with `nvm install --lts`. Re-run.
- 1 test fails on `build-site emits index and manifest`: missing input data files. Re-run the second rsync (the `data/` one).
- A unit test fails: investigate before continuing — the bring-up must start from green tests.

**Done when**: 43/43 green. `~/damm/` exists on hub2 with `node_modules/` populated.

---

## Cell 2 — State directory and gateway keypair

**What**: a per-host state dir at `/home/uprootiny/damm-state/` with a freshly-generated WireGuard gateway keypair.

**Why**: the gateway's private key must live exactly here, never in the repo, never in env files outside this dir. State files (`state.json`, `env`, logs) all colocate.

**Run**:
```
ssh hub2 'mkdir -p ~/damm-state && chmod 700 ~/damm-state'
ssh hub2 'cd ~/damm && \
  if [[ ! -s ~/damm-state/gateway-keypair.json ]]; then
    node -e "const k=require(\"./control-plane/lib/wireguard\").generateWireGuardKeypair(); \
      require(\"fs\").writeFileSync(process.env.HOME+\"/damm-state/gateway-keypair.json\", JSON.stringify(k,null,2)); \
      require(\"fs\").chmodSync(process.env.HOME+\"/damm-state/gateway-keypair.json\", 0o600); \
      console.log(\"public:\", k.publicKey)";
  else
    echo "keypair already exists; reusing public:" && jq -r .publicKey ~/damm-state/gateway-keypair.json;
  fi'
```

**Verify**: prints `public: <44-char base64>` ending in `=`.

**If wrong**:
- Permission denied on `~/damm-state`: `chmod 700 ~/damm-state` and retry.
- Keypair generation fails: confirm Cell 1's tests passed (the `wireguard.test.js` tests this code).

**Done when**: `~/damm-state/gateway-keypair.json` exists, mode 600, two base64 strings inside.

---

## Cell 3 — The env file (control-plane secrets)

**What**: `/home/uprootiny/damm-state/env` containing all `VPN_*` secrets, properly quoted so both systemd `EnvironmentFile=` and shell `source` parse it.

**Why**: tokens are the only thing standing between us and arbitrary client enrollment / admin actions. The wrong format here breaks heartbeat (shell-source) or the control plane (systemd-source).

**Run**:
```
ssh hub2 'cd ~/damm-state && \
  if [[ -s env ]]; then echo "env already exists ($(wc -l < env) lines); skipping"; \
  else \
    GW_PUB=$(jq -r .publicKey gateway-keypair.json); \
    R() { node -e "console.log(require(\"crypto\").randomBytes($1).toString(\"hex\"))"; }; \
    cat > env <<EOF
HOST="127.0.0.1"
PORT="8080"
VPN_STATE_FILE="/home/uprootiny/damm-state/state.json"
VPN_NETWORK_CIDR="10.44.0.0/24"
VPN_CLIENT_DNS="1.1.1.1, 1.0.0.1"
VPN_ALLOWED_IPS="0.0.0.0/0, ::/0"
VPN_SERVER_PORT="51820"
VPN_GATEWAY_ID="gw-eu-hub2"
VPN_GATEWAY_NAME="Hub2 Edge"
VPN_GATEWAY_REGION="eu-central"
VPN_GATEWAY_PROVIDER="contabo"
VPN_GATEWAY_ENDPOINT="vpn.damm.raindesk.dev:51820"
VPN_GATEWAY_PUBLIC_KEY="${GW_PUB}"
VPN_GATEWAY_TUNNEL_IP="10.44.0.1/24"
VPN_GATEWAY_API_TOKEN="$(R 24)"
VPN_GATEWAY_HEARTBEAT_TTL_MS="180000"
VPN_ENROLLMENT_TOKEN="$(R 24)"
VPN_ADMIN_TOKEN="$(R 24)"
VPN_ADMIN_BOOTSTRAP_TOKEN="$(R 24)"
VPN_ADMIN_SIGNING_SECRET="$(R 32)"
VPN_EGRESS_POOL_ID="eg-eu-hub2"
VPN_EGRESS_POOL_NAME="EU Exit Hub2"
VPN_EGRESS_REGION="eu-central"
VPN_EGRESS_PROVIDER="contabo"
VPN_EGRESS_COUNTRIES="DE"
VPN_EGRESS_IPS="$(curl -fsS ifconfig.me)"
EOF
    chmod 600 env;
    echo "env written ($(wc -l < env) lines)";
  fi'
```

**Verify**:
```
ssh hub2 'bash -nc "set -a; source ~/damm-state/env; set +a; echo OK GW=\$VPN_GATEWAY_ID DNS=[\$VPN_CLIENT_DNS]"'
```
Should print `OK GW=gw-eu-hub2 DNS=[1.1.1.1, 1.0.0.1]` with no errors.

**If wrong**:
- Source command errors with `command not found` on a value: an unquoted value with `,` `:` `(` `/` slipped in. Open `env`, ensure every value is double-quoted. Re-verify.
- `VPN_EGRESS_IPS` is empty: `ifconfig.me` blocked. Replace with the actual public IP from `ip addr show eth0`.

**Done when**: shell-source verify prints `OK ...`. Mode is 600.

---

## Cell 4 — WireGuard interface (wg0)

**What**: a persistent `wg-quick@wg0.service` listening on UDP 51820, with NAT to eth0 and `10.44.0.0/24` overlay.

**Why**: this is the actual data plane. Without wg0, no client can connect.

**Run**:
```
ssh hub2 'GW_PRIV=$(jq -r .privateKey ~/damm-state/gateway-keypair.json); \
  sudo -n bash -c "if [[ ! -s /etc/wireguard/wg0.conf ]]; then \
    cat > /etc/wireguard/wg0.conf <<EOF
[Interface]
PrivateKey = $GW_PRIV
ListenPort = 51820
Address    = 10.44.0.1/24
SaveConfig = false

PostUp   = iptables -t nat -A POSTROUTING -s 10.44.0.0/24 -o eth0 -j MASQUERADE
PostUp   = iptables -A FORWARD -i wg0 -j ACCEPT
PostUp   = iptables -A FORWARD -o wg0 -j ACCEPT
PostDown = iptables -t nat -D POSTROUTING -s 10.44.0.0/24 -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i wg0 -j ACCEPT
PostDown = iptables -D FORWARD -o wg0 -j ACCEPT
EOF
    chmod 600 /etc/wireguard/wg0.conf;
  else echo wg0.conf exists; fi; \
  sysctl -w net.ipv4.ip_forward=1 >/dev/null; \
  systemctl enable --now wg-quick@wg0"'
```

**Verify**:
```
ssh hub2 'systemctl is-active wg-quick@wg0 && sudo -n wg show wg0 | head -4 && ss -lunp | grep :51820'
```
Should print `active`, then `interface: wg0 / public key: .../ private key: (hidden) / listening port: 51820`, then a UDP socket listening.

**If wrong**:
- `wg-quick@wg0` failed: `sudo journalctl -u wg-quick@wg0 -n 40`. Usually a stray `iptables` rule from a prior bring-down. Manually: `sudo wg-quick down wg0 || true; sudo wg-quick up wg0`.
- `Cannot find device "wg0"`: kernel module missing. `sudo modprobe wireguard`. If that fails: `sudo apt-get install -y wireguard-dkms`.
- Port 51820 in use: `sudo ss -lunp | grep 51820` to find the offender.

**Done when**: `wg-quick@wg0` is `active`, `wg show wg0` lists the interface with the matching public key from cell 2, port 51820 is listening.

---

## Cell 5 — Control plane (damm-control-plane.service)

**What**: a systemd unit running `node control-plane/server.js` on `127.0.0.1:8080`, with `EnvironmentFile=/home/uprootiny/damm-state/env`.

**Why**: the API surface for everything — enrollment, catalogs, admin, whoami, install-pass.

**Run**:
```
ssh hub2 'sudo -n bash -c "if [[ ! -s /etc/systemd/system/damm-control-plane.service ]]; then \
    cat > /etc/systemd/system/damm-control-plane.service <<EOF
[Unit]
Description=DAMM Control Plane
After=network-online.target wg-quick@wg0.service
Wants=network-online.target

[Service]
Type=simple
User=uprootiny
Group=uprootiny
WorkingDirectory=/home/uprootiny/damm
EnvironmentFile=/home/uprootiny/damm-state/env
ExecStart=/usr/bin/node /home/uprootiny/damm/control-plane/server.js
Restart=on-failure
RestartSec=2
StandardOutput=append:/home/uprootiny/damm-state/control-plane.log
StandardError=append:/home/uprootiny/damm-state/control-plane.log
ProtectSystem=strict
ReadWritePaths=/home/uprootiny/damm-state /home/uprootiny/damm/data
PrivateTmp=true
NoNewPrivileges=true

[Install]
WantedBy=multi-user.target
EOF
    systemctl daemon-reload;
  fi; \
  systemctl enable --now damm-control-plane"'
```

**Verify**:
```
ssh hub2 'sleep 1 && systemctl is-active damm-control-plane && curl -fsS http://127.0.0.1:8080/healthz'
```
Should print `active` then `{"ok":true,"stateBackend":"json"}`.

**If wrong**:
- `inactive (failed)`: `sudo journalctl -u damm-control-plane -n 30 --no-pager`. Usual failures: env file parse errors (cell 3), `EADDRINUSE` (`ss -lntp | grep :8080`), missing node_modules (cell 1).
- `refusing insecure runtime configuration`: env still has `dev-*-token` placeholders. Cell 3 should have replaced them; if not, regenerate the env.

**Done when**: `systemctl is-active damm-control-plane` → `active`. Health endpoint returns ok. `~/damm-state/state.json` was created.

---

## Cell 6 — Caddy public reverse proxy

**What**: Caddy serving `cp.damm.raindesk.dev` over HTTPS, reverse-proxying to `127.0.0.1:8080`. Site `damm.raindesk.dev` already serves the static site; we add the API hostname.

**Why**: browsers won't talk plaintext HTTP to the control plane. We need a public TLS surface; Caddy auto-handles ACME via the wildcard DNS already pointing at hub2.

**Run**:
```
ssh hub2 'sudo -n bash -c "if ! grep -q \"^cp.damm.raindesk.dev\" /etc/caddy/Caddyfile; then \
    cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.bak.\$(date +%Y%m%d%H%M%S); \
    cat >> /etc/caddy/Caddyfile <<EOF

cp.damm.raindesk.dev {
\treverse_proxy 127.0.0.1:8080
\tencode gzip
}
EOF
  fi; \
  caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy"'
```

**Verify**:
```
sleep 8 && curl -fsS https://cp.damm.raindesk.dev/healthz
```
Should print `{"ok":true,"stateBackend":"json"}`. The `sleep 8` gives Caddy time to issue the cert if it's a fresh hostname.

**If wrong**:
- TLS error / connection refused on first try: wait ~30 more seconds for ACME. If still failing, `sudo journalctl -u caddy --since '1 minute ago' | grep -i "cp.damm"`.
- `caddy validate` errors: a previous Caddyfile entry is malformed, unrelated to our block. Inspect, fix the unrelated block.
- DNS doesn't resolve: confirm `dig +short cp.damm.raindesk.dev` returns the hub2 IP. Wildcard `*.raindesk.dev` should already cover this.

**Done when**: `https://cp.damm.raindesk.dev/healthz` returns ok over a valid TLS cert.

---

## Cell 7 — Register the gateway with the control plane

**What**: post the gateway's identity to its own control plane so the gateway record's `registration.status === "registered"` and clients can enroll.

**Why**: until registration + heartbeat, `gatewayIsHealthy` is false → enrollment 503s with `no_active_gateways`.

**Run**:
```
ssh hub2 'set -a; source ~/damm-state/env; set +a; \
  CP=https://cp.damm.raindesk.dev; \
  curl -fsS -X POST -H "content-type: application/json" -H "x-gateway-token: $VPN_GATEWAY_API_TOKEN" \
    -d "{\"endpoint\":\"$VPN_GATEWAY_ENDPOINT\",\"publicKey\":\"$VPN_GATEWAY_PUBLIC_KEY\",\"buildVersion\":\"phase0\"}" \
    "$CP/v1/gateways/$VPN_GATEWAY_ID/register" | jq .; \
  curl -fsS -X POST -H "content-type: application/json" -H "x-gateway-token: $VPN_GATEWAY_API_TOKEN" \
    -d "{\"metrics\":{\"loadScore\":1},\"buildVersion\":\"phase0\"}" \
    "$CP/v1/gateways/$VPN_GATEWAY_ID/heartbeat" | jq .'
```

**Verify**:
```
ssh hub2 'jq ".gateways[0].registration" ~/damm-state/state.json'
```
Should show `status: "registered"` and `lastHeartbeatAt` within the last few seconds.

**If wrong**:
- `401 invalid_gateway_token`: env's token doesn't match state's. Regenerate cleanly: see operational-runbook §11.
- `404 gateway_not_found`: `VPN_GATEWAY_ID` in env doesn't match the bootstrapped state. Either fix env or `jq` the state.
- Curl times out: control plane isn't running or Caddy isn't proxying. Re-run cells 5/6.

**Done when**: state shows the gateway registered with a fresh heartbeat.

---

## Cell 8 — Heartbeat timer (keep the gateway fresh)

**What**: a systemd timer firing every 30s that re-runs heartbeat. Without it, gateway goes stale in ~3 minutes (`VPN_GATEWAY_HEARTBEAT_TTL_MS=180000`).

**Run**:
```
ssh hub2 'cat > ~/damm-state/heartbeat.sh <<"SH"
#!/usr/bin/env bash
set -euo pipefail
set -a; source /home/uprootiny/damm-state/env; set +a
load=$(awk "{print int(\$1*100)}" /proc/loadavg)
curl -sS --max-time 8 -X POST \
  -H "content-type: application/json" \
  -H "x-gateway-token: $VPN_GATEWAY_API_TOKEN" \
  -d "{\"metrics\":{\"loadScore\":$load},\"buildVersion\":\"phase0\"}" \
  "https://cp.damm.raindesk.dev/v1/gateways/$VPN_GATEWAY_ID/heartbeat" > /dev/null
SH
chmod +x ~/damm-state/heartbeat.sh'

ssh hub2 'sudo -n bash -c "if [[ ! -s /etc/systemd/system/damm-heartbeat.service ]]; then \
    cat > /etc/systemd/system/damm-heartbeat.service <<EOF
[Unit]
Description=DAMM gateway heartbeat
After=damm-control-plane.service

[Service]
Type=oneshot
User=uprootiny
ExecStart=/home/uprootiny/damm-state/heartbeat.sh
EOF
    cat > /etc/systemd/system/damm-heartbeat.timer <<EOF
[Unit]
Description=DAMM gateway heartbeat every 30s

[Timer]
OnBootSec=30s
OnUnitActiveSec=30s
AccuracySec=5s

[Install]
WantedBy=timers.target
EOF
    systemctl daemon-reload;
  fi; \
  systemctl enable --now damm-heartbeat.timer"'
```

**Verify**:
```
ssh hub2 'sudo -n systemctl list-timers damm-heartbeat.timer --no-pager | head -3 && \
  /home/uprootiny/damm-state/heartbeat.sh && \
  jq ".gateways[0].registration.lastHeartbeatAt" ~/damm-state/state.json'
```
Timer should be queued; manual heartbeat should succeed; lastHeartbeatAt should now.

**If wrong**:
- Heartbeat script errors with `command not found` on env line: env file has unquoted special chars. Cell 3 again.
- Timer is enabled but service is failing: `journalctl -u damm-heartbeat.service -n 10`.

**Done when**: timer is `active`, heartbeat fires successfully every 30s.

---

## Cell 9 — Peer reconciler (state.json → wg0)

**What**: a script + systemd path-watcher that adds/removes wg0 peers whenever state.json changes (i.e., a new device enrolls, gets revoked, etc.).

**Why**: enrollment writes to state.json but doesn't touch wg0 directly. Without this, an enrolled device handshakes with the gateway and gets rejected.

**Run**:

The canonical reconciler is `scripts/damm-sync-peers.sh` in this repo. Copy it into the per-host state directory on hub2:

```
scp scripts/damm-sync-peers.sh hub2:/home/uprootiny/damm-state/sync-peers.sh
ssh hub2 'chmod +x ~/damm-state/sync-peers.sh'
```

Then install the path watcher + service:
```
ssh hub2 'sudo -n bash -c "if [[ ! -s /etc/systemd/system/damm-sync-peers.service ]]; then \
    cat > /etc/systemd/system/damm-sync-peers.service <<EOF
[Unit]
Description=DAMM peer reconciler (state.json -> wg0)
After=wg-quick@wg0.service damm-control-plane.service

[Service]
Type=oneshot
User=uprootiny
EnvironmentFile=/home/uprootiny/damm-state/env
ExecStart=/home/uprootiny/damm-state/sync-peers.sh
EOF
    cat > /etc/systemd/system/damm-sync-peers.path <<EOF
[Unit]
Description=Watch DAMM state.json and reconcile wg0 peers

[Path]
PathChanged=/home/uprootiny/damm-state/state.json
Unit=damm-sync-peers.service

[Install]
WantedBy=multi-user.target
EOF
    systemctl daemon-reload;
  fi; \
  systemctl enable --now damm-sync-peers.path; \
  cat > /etc/sudoers.d/damm-wg-sync <<EOF
uprootiny ALL=(root) NOPASSWD: /usr/bin/wg show wg0 peers
uprootiny ALL=(root) NOPASSWD: /usr/bin/wg show wg0 allowed-ips
uprootiny ALL=(root) NOPASSWD: /usr/bin/wg set wg0 peer *
EOF
chmod 440 /etc/sudoers.d/damm-wg-sync; \
visudo -cf /etc/sudoers.d/damm-wg-sync"'
```

**Verify**:
```
ssh hub2 'systemctl is-active damm-sync-peers.path && \
  ~/damm-state/sync-peers.sh && \
  sudo -n wg show wg0'
```

**If wrong**: see operational-runbook §7 (full reconciler troubleshooting).

**Done when**: path is active, manual sync runs clean, wg0 has the right peer count.

---

## Cell 10 — Build and publish the static site (with /get/ wizard)

**What**: build `site-dist/` from the project, including the `/get/` wizard, and rsync it to `/home/uprootiny/damm-site/` where Caddy already serves `damm.raindesk.dev`.

**Run**:
```
ssh hub2 'cd ~/damm && node scripts/build-site.js 2>&1 | tail -3 && \
  rsync -a --delete site-dist/ ~/damm-site/ 2>&1 | tail -3'
```

**Verify**:
```
curl -fsS -o /dev/null -w "%{http_code} %{time_total}s\n" https://damm.raindesk.dev/
curl -fsS -o /dev/null -w "%{http_code} %{time_total}s\n" https://damm.raindesk.dev/get/
curl -fsS https://damm.raindesk.dev/get/ | grep -E "<title>"
```

**If wrong**:
- `/get/` returns the home page: Caddy's `try_files` is too aggressive. Patch the block to `try_files {path} {path}/index.html /index.html`. (See operational-runbook §4.)
- Build fails on missing data file: `data/permeability-sample.json` etc. — re-run Cell 1's `data/` rsync.

**Done when**: `https://damm.raindesk.dev/get/` returns the wizard's title `Get connected — DAMM`.

---

## Cell 11 — End-to-end smoke (auto-issue + enroll + reconcile)

**What**: drive the full `/get/` flow from a shell, verify each step lands. This is the same path a real browser-side wizard takes.

**Run**:
```
CP=https://cp.damm.raindesk.dev
echo "1. POST /v1/public/install-pass"
PASS=$(curl -fsS -X POST -H "content-type: application/json" -d '{"region":"eu-central"}' $CP/v1/public/install-pass)
echo "$PASS" | jq -r '{token: (.token[0:10]+"…"), expiresAt}'
TOKEN=$(echo "$PASS" | jq -r .token)

echo "2. generate keypair (locally — sim of in-browser)"
KP=$(node -e 'const c=require("crypto");const{publicKey,privateKey}=c.generateKeyPairSync("x25519");process.stdout.write(JSON.stringify({pub:publicKey.export({type:"spki",format:"der"}).slice(-32).toString("base64"),priv:privateKey.export({type:"pkcs8",format:"der"}).slice(-32).toString("base64")}))')
PUB=$(echo "$KP" | jq -r .pub)

echo "3. POST /v1/devices/enroll"
curl -fsS -X POST -H "content-type: application/json" \
  -d "{\"publicKey\":\"$PUB\",\"enrollmentToken\":\"$TOKEN\",\"region\":\"eu-central\",\"deviceName\":\"smoke-$(date +%s)\"}" \
  $CP/v1/devices/enroll | jq '{deviceId, assignedIp, gateway: .gateway.id, peerPub: (.peer.publicKey[0:12]+"…")}'

echo "4. /v1/whoami (from your laptop, NOT through tunnel)"
curl -fsS $CP/v1/whoami | jq .

echo "5. wg0 peers on hub2"
ssh hub2 'sleep 2; sudo -n wg show wg0 | tail -10'
```

**Verify**: each step prints clean JSON; `wg show wg0` lists a fresh peer matching the smoke-test's public key with the assigned `10.44.0.X/32` from step 3.

**If wrong**:
- Step 1 returns 429: rate limit hit. Wait an hour, or raise `VPN_PUBLIC_PASS_MAX` in env (cell 3) and restart CP.
- Step 3 returns `no_active_gateways`: heartbeat went stale; cell 8 isn't firing. `journalctl -u damm-heartbeat.service -n 20`.
- Step 5 doesn't show the new peer: reconciler didn't fire. `journalctl -u damm-sync-peers.service -n 20`. Manual fix: `~/damm-state/sync-peers.sh`.

**Done when**: all five steps print expected output, peer appears in wg0.

---

## Cell 12 — Real-device enrollment (the actual proof)

**What**: enroll a real device (your phone or laptop), import the .conf into WireGuard, watch handshakes flow.

**Run**:
1. On your phone or laptop, install WireGuard from the relevant app store.
2. Open `https://damm.raindesk.dev/get/` in a modern browser.
3. Tap **Let's get you connected →** → pick your device → tap **I have it installed** → wait for the three statuslines to turn green.
4. On the **Move it into WireGuard** screen, tap **Download .conf** (desktop) or **Copy the config** + paste into WireGuard manually (mobile, until QR ships).
5. Import into WireGuard, toggle the tunnel on.
6. Back on the wizard page, tap **Test my connection**.

**Verify**: the wizard shows `✓ connected — we see you coming from <your-egress-IP> via gw-eu-hub2`. On hub2:
```
ssh hub2 'sudo -n wg show wg0 latest-handshakes | tail -5; sudo -n wg show wg0 transfer | tail -5'
```
Should show a recent handshake (timestamp within seconds) and non-zero RX/TX bytes for the new peer.

**If wrong**:
- Wizard's debug log shows step-1 failure: cell 5/6/7 isn't right. Re-verify them.
- Handshake never lands: client's UDP is blocked. We don't have T1 (AmneziaWG) shipped yet — note it for Phase 2.
- Handshake lands but bytes don't flow: NAT / routing — see operational-runbook §9.
- Whoami says `gatewayMatch: false` even when connected: client traffic isn't going through the tunnel (split-tunnel rule on the client). Confirm `curl ifconfig.me` from the client returns hub2's IP.

**Done when**: from a real device, you see the green ✓ and your IP becomes hub2's egress.

---

## Cell 13 — Sanity sweep (closing checklist)

**What**: a single command that says "everything's fine" or names what isn't.

**Run**:
```
ssh hub2 '
set +e
echo "=== units ==="
systemctl is-active damm-control-plane wg-quick@wg0 damm-heartbeat.timer damm-sync-peers.path caddy
echo "=== health ==="
curl -fsS https://cp.damm.raindesk.dev/healthz
echo
echo "=== gateway freshness ==="
jq ".gateways[0].registration.lastHeartbeatAt" ~/damm-state/state.json
echo "=== peer parity ==="
WG=$(sudo -n wg show wg0 peers | wc -l)
ST=$(jq ".devices | map(select(.status == \"active\")) | length" ~/damm-state/state.json)
echo "wg0 peers: $WG  state active devices: $ST  $([ "$WG" = "$ST" ] && echo OK || echo MISMATCH)"
echo "=== state size ==="
wc -c ~/damm-state/state.json
echo "=== disk free ==="
df -h / /home | head -3
echo "=== tests ==="
cd ~/damm && npm test 2>&1 | tail -3
'
```

**Verify**: 5x `active`, healthz ok, fresh heartbeat, peer parity OK, state under 1MB, plenty of disk free, tests pass.

**If wrong**: jump to the relevant cell (the failing line tells you which one).

**Done when**: everything green. The system is running and stable; the next visitor through `/get/` will succeed.

---

## When the notebook drifts

This notebook is a snapshot of how to bring up the system as it stands today. When the code changes — when we add Phase 2 obfuscation tiers, Phase 3 sequester pools, a Postgres backend — cells 7+ may need updating. Treat this file like a recipe; revise it in lockstep with the code.

If a cell's verify command stops working but the system is healthy by every other check, **the cell is wrong, not the system**. Update the cell.

---

## What's NOT in this notebook

- Phase 2 (obfuscation tiers — AmneziaWG, etc.) — not deployed yet.
- Phase 3 (egress sequestering with separate pools) — not deployed yet.
- Postgres durable state — supported by code, not enabled here.
- Multi-gateway fleet — the code handles it, but bring-up of additional gateways needs its own cells.
- iOS WireGuard deep-link import — the wizard ships download + copy; deep-link follows.

When any of those ships, add a numbered cell after 12.
