Artifact 53e82a47cf93b5594a8083ab32c07e820f0f0360598dac5885ef0247871610b5:
- File
docs/superpowers/specs/2026-03-15-nats-server-setup-design.md
— part of check-in
[3406e2ec48]
at
2026-03-15 16:15:53
on branch trunk
— Add NATS server setup design spec
Four-phase install (install.sh → bootstrap-creds.sh → start.sh → provision-streams.sh) covering NATS 2.10 on linux/arm64, TLS via acme.sh DNS-01, NKeys+JWT auth, JetStream. (user: dmestas, size: 15626) [annotate] [blame] [check-ins using]
# NATS Server Setup — Design Spec **Date:** 2026-03-15 **Status:** Approved **Context:** Infrastructure foundation for Fossil NATS Sync (Phase E — Leaf Agent) --- ## Overview Configure a NATS 2.10 server with JetStream on the Hetzner VPS as the messaging hub for the Fossil NATS Sync project. The server must be reachable by both Tailscale-connected leaf nodes (MacBook, Mac Mini) and external public clients. Configuration is version-controlled in a new `infra` Fossil repo for reproducible setup and future cluster expansion. --- ## Architecture ``` External clients / leaf agents │ │ tls://sync.craftdesign.group:4222 ▼ nats-server 2.10 (systemd, linux/arm64) listen: 0.0.0.0:4222 (TLS — external + Tailscale + local admin) │ ├── /var/nats/jetstream JetStream persistent storage ├── /var/nats/jwt JWT resolver store └── /var/nats/tls TLS cert + key (acme.sh managed) Local admin tools (nsc, nats CLI) connect to tls://localhost:4222 --tlsskipverify ``` **Single listener:** NATS 2.10 does not support two independent TCP listeners with different TLS policies in one server process. All connections — external, Tailscale, and local admin — use the single TLS listener on port 4222. Local admin tools (`nsc push`, `nats` CLI) connect to `tls://localhost:4222` with `--tlsskipverify` (acceptable for loopback connections where network interception is impossible). --- ## NATS Version **Target version: 2.10.x** (latest 2.10 stable at install time) `install.sh` pins a `NATS_VERSION` variable (e.g., `2.10.22`) and downloads the `linux-arm64` binary: ``` https://github.com/nats-io/nats-server/releases/download/v${NATS_VERSION}/nats-server-v${NATS_VERSION}-linux-arm64.tar.gz ``` The `Dockerfile` pins the same minor version: `FROM nats:2.10-alpine`. Both are updated together when upgrading. --- ## Config Repo (`infra`) New Fossil repo: `/var/fossil/repos/infra.fossil`, browsable at `fossil.craftdesign.group/infra/`. ``` infra/ ├── nats/ │ ├── nats-server.conf.template # Config template (contains $OPERATOR_JWT placeholder) │ ├── nats.service # Systemd unit │ ├── install.sh # Phase 1: binary, dirs, TLS, unit file (does NOT start service) │ ├── bootstrap-creds.sh # Phase 2: one-time nsc operator/account/user setup │ ├── start.sh # Phase 3: render config, enable and start service, nsc push │ ├── provision-streams.sh # Phase 4: idempotent JetStream stream creation │ ├── Dockerfile # Docker surface for future cluster nodes │ ├── creds/ │ │ └── README.md # How to regenerate operator/account/user creds with nsc │ └── README.md └── README.md ``` `creds/` is listed in `.fossil-ignore` — NKey seeds and JWT files are never committed. `/etc/nats/nats-server.conf` (the rendered live config) is also never committed. --- ## Install Phases (explicit ordering) **Phase 1 — `install.sh`**: installs binary, creates dirs, obtains TLS cert, copies config template and unit file. Does **not** start the service (operator JWT not yet available). **Phase 2 — `bootstrap-creds.sh`**: one-time `nsc` setup. Creates operator, accounts, users. Writes `/etc/nats/operator.env` containing `OPERATOR_JWT=<value>`. **Phase 3 — `start.sh`**: renders config template → live config, enables and starts the service, pushes JWTs to the running resolver. **Phase 4 — `provision-streams.sh`**: creates JetStream streams after the server is running. Idempotent and non-interactive. --- ## TLS Provisioning **Mechanism: `acme.sh` with Cloudflare DNS-01 challenge** Traefik already holds ports 80/443, making HTTP-01 challenges impossible for non-Coolify services. DNS-01 via the Cloudflare API sidesteps this completely. `install.sh` handles TLS setup: ```bash # Install acme.sh (if not present) curl https://get.acme.sh | sh -s [email protected] # cf-token.env must already exist at /etc/nats/cf-token.env (operator places it before running install.sh) # It exports: CF_Token="<cloudflare-api-token>" with Zone:DNS:Edit permission for craftdesign.group source /etc/nats/cf-token.env ~/.acme.sh/acme.sh --issue --dns dns_cf \ -d sync.craftdesign.group \ --key-file /var/nats/tls/sync.craftdesign.group.key \ --fullchain-file /var/nats/tls/sync.craftdesign.group.crt \ --reloadcmd "systemctl kill --signal=SIGHUP nats" # Set ownership so nats-server (running as nats user) can read the key chown nats:nats /var/nats/tls/sync.craftdesign.group.key /var/nats/tls/sync.craftdesign.group.crt chmod 0640 /var/nats/tls/sync.craftdesign.group.key /var/nats/tls/sync.craftdesign.group.crt ``` - `--fullchain-file` ensures the cert includes the Let's Encrypt intermediate - `--reloadcmd` sends `SIGHUP` to `nats-server` after renewal; NATS reloads TLS certs on SIGHUP without dropping connections - The Cloudflare API token requires `Zone:DNS:Edit` for `craftdesign.group`; stored at `/etc/nats/cf-token.env` (mode 0600, root-owned) — never committed to the repo - `/etc/nats/cf-token.env` is also used by `acme.sh`'s cron renewal job, so it must persist on disk --- ## `install.sh` Responsibilities (idempotent, Phase 1) 1. Verify `NATS_VERSION` is set; download and install `nats-server` binary to `/usr/local/bin/nats-server` 2. Create `nats` system user (no login shell) if not present 3. Create directories: - `/var/nats/jetstream` — owned by `nats:nats`, mode 0750 - `/var/nats/jwt` — owned by `nats:nats`, mode 0750 - `/var/nats/tls` — owned by `nats:nats`, mode 0750 - `/etc/nats/` — owned by root, mode 0755 4. Verify `/etc/nats/cf-token.env` exists and is mode 0600; fail with clear error if not 5. Copy `nats-server.conf.template` to `/etc/nats/nats-server.conf.template` 6. Install `acme.sh` if not present; issue TLS cert via DNS-01 (skips if cert already valid); set ownership `nats:nats` on cert and key files 7. Install `nats.service` to `/etc/systemd/system/`; `systemctl daemon-reload` 8. Print: "Phase 1 complete. Run bootstrap-creds.sh next." The service is **not** started by `install.sh`. --- ## `bootstrap-creds.sh` Responsibilities (Phase 2, one-time) Requires `nsc` CLI (install via `curl -fsSL https://install.nats.io/nsc | sh`). No server connection is needed at this stage — `nsc` works purely locally. ```bash nsc add operator --name fossil-sync # Use localhost TLS for all local nsc/nats CLI operations nsc edit operator --account-jwt-server-url tls://localhost:4222 nsc add account --name edgesync nsc add account --name go-libfossil # Admin user per account — full pub/sub for local stream provisioning only nsc add user --account edgesync --name admin --allow-pub '>' --allow-sub '>' nsc add user --account go-libfossil --name admin --allow-pub '>' --allow-sub '>' # Bridge user — scoped to project subjects nsc add user --account edgesync \ --name bridge \ --allow-pub 'fossil.edgesync.>' \ --allow-sub 'fossil.edgesync.>' nsc add user --account go-libfossil \ --name bridge \ --allow-pub 'fossil.go-libfossil.>' \ --allow-sub 'fossil.go-libfossil.>' # Generate .creds files nsc generate creds --account edgesync --name bridge > creds/edgesync-bridge.creds nsc generate creds --account go-libfossil --name bridge > creds/go-libfossil-bridge.creds nsc generate creds --account edgesync --name admin > creds/edgesync-admin.creds nsc generate creds --account go-libfossil --name admin > creds/go-libfossil-admin.creds # Extract operator JWT using nsc generate config (the supported extraction method) OPERATOR_JWT=$(nsc generate config --nats-resolver 2>/dev/null \ | grep '^operator:' \ | awk '{print $2}') echo "OPERATOR_JWT=${OPERATOR_JWT}" > /etc/nats/operator.env chmod 0600 /etc/nats/operator.env echo "Phase 2 complete. Run start.sh next." ``` `nsc` local store lives at `~/.local/share/nats/nsc/` (default). `creds/README.md` documents the full `nsc` command sequence for recreating the credential tree on a fresh machine. ### Adding a new leaf node (post-setup) ```bash nsc add user --account <project> \ --name leaf-<hostname> \ --allow-pub 'fossil.<project>.igot,fossil.<project>.file,fossil.<project>.delta' \ --allow-sub 'fossil.<project>.gimme,fossil.<project>.file,fossil.<project>.meta,fossil.<project>.events' nsc generate creds --account <project> --name leaf-<hostname> > leaf-<hostname>.creds nsc push --all --server tls://localhost:4222 --tlsskipverify # Distribute .creds to the leaf node via scp over Tailscale ``` --- ## `start.sh` Responsibilities (Phase 3) Renders config from template (preserving the template file), starts the service, pushes JWTs. ```bash source /etc/nats/operator.env # loads OPERATOR_JWT # Render template → live config (template file is never modified) envsubst < /etc/nats/nats-server.conf.template > /etc/nats/nats-server.conf systemctl enable --now nats # Wait for server to be ready sleep 2 # Push account JWTs to the running resolver nsc push --all --server tls://localhost:4222 --tlsskipverify echo "Phase 3 complete. Run provision-streams.sh next." ``` Re-running `start.sh` is safe: `envsubst` always reads from the `.template` file. --- ## `provision-streams.sh` Responsibilities (Phase 4, idempotent) Uses `tls://localhost:4222 --tlsskipverify` and admin creds. The `--defaults` flag suppresses all interactive prompts. ```bash NATS_SERVER="tls://localhost:4222" NATS_TLS_FLAGS="--tlsskipverify" for PROJECT in edgesync go-libfossil; do STREAM=$(echo "$PROJECT" | tr '[:lower:]' '[:upper:]' | tr '-' '_') CREDS="creds/${PROJECT}-admin.creds" if nats stream info "$STREAM" \ --server "$NATS_SERVER" $NATS_TLS_FLAGS \ --creds "$CREDS" &>/dev/null; then echo "Stream $STREAM already exists, skipping." continue fi nats stream add "$STREAM" \ --server "$NATS_SERVER" $NATS_TLS_FLAGS \ --creds "$CREDS" \ --subjects "fossil.${PROJECT}.>" \ --retention limits \ --max-age 168h \ --max-bytes 1073741824 \ --storage file \ --replicas 1 \ --defaults echo "Stream $STREAM created." done ``` --- ## NATS Server Config Template (`nats-server.conf.template`) ``` server_name: vps-nbg1 listen: 0.0.0.0:4222 tls { cert_file: "/var/nats/tls/sync.craftdesign.group.crt" key_file: "/var/nats/tls/sync.craftdesign.group.key" } jetstream { store_dir: "/var/nats/jetstream" } # JWT resolver — accounts pushed via `nsc push --all` resolver: { type: full dir: "/var/nats/jwt" allow_delete: false interval: "2m" } # Operator JWT — substituted by start.sh via envsubst operator: $OPERATOR_JWT # Cluster placeholder — populate when adding a second node # cluster { # name: fossil-sync # listen: 0.0.0.0:6222 # routes: [ nats://node2.internal:6222 ] # } ``` --- ## Systemd Unit (`nats.service`) ```ini [Unit] Description=NATS Server After=network-online.target Wants=network-online.target [Service] User=nats Group=nats ExecStart=/usr/local/bin/nats-server -c /etc/nats/nats-server.conf ExecReload=/bin/kill -HUP $MAINPID Restart=on-failure RestartSec=5s LimitNOFILE=1048576 [Install] WantedBy=multi-user.target ``` --- ## Auth & Security ### NKeys + JWT (three-tier) ``` Operator: fossil-sync ├── Account: edgesync │ ├── User: admin (pub/sub > — local provisioning on VPS only) │ ├── User: bridge (pub+sub fossil.edgesync.>) │ └── User: leaf-<hostname> └── Account: go-libfossil ├── User: admin ├── User: bridge (pub+sub fossil.go-libfossil.>) └── User: leaf-<hostname> ``` - **Operator** is the root of trust — NKey seed lives in `nsc` local store on the VPS - **Accounts** map 1:1 to Fossil projects; each is isolated — a user in one account cannot see another account's subjects - **Admin users** have full pub/sub (`>`); their `.creds` files never leave the VPS; they connect to `tls://localhost:4222 --tlsskipverify` - **Bridge and leaf users** have scoped permissions; their `.creds` files are distributed to the appropriate machines via `scp` over Tailscale - Adding a new project: `nsc add account <name>` + `nsc push --all` — no server restart - Revoking a leaf: delete its user JWT from the resolver; connection drops at next token refresh (default 2 min) ### Future migration path Account names (`edgesync`, `go-libfossil`) will be replaced with opaque IDs when multi-tenant scale requires it. The JWT resolver supports this without protocol changes — it is a config and tooling update only. ### Subject permissions ``` Leaf user: publish: fossil.<project>.igot, fossil.<project>.file, fossil.<project>.delta subscribe: fossil.<project>.gimme, fossil.<project>.file, fossil.<project>.meta, fossil.<project>.events Bridge user: publish: fossil.<project>.> subscribe: fossil.<project>.> Admin user: publish: > subscribe: > (used only on VPS via tls://localhost:4222 --tlsskipverify) ``` --- ## Dockerfile Same minor version as systemd install. The `$OPERATOR_JWT` placeholder must be rendered before building — the `Dockerfile` is intended for use with a pre-rendered `nats-server.conf` (not the `.template` file). Build workflow for Docker deployments: ```bash source /etc/nats/operator.env envsubst < nats-server.conf.template > nats-server.conf-rendered docker build --build-arg CONF=nats-server.conf-rendered -t fossil-nats:2.10 . ``` ```dockerfile FROM nats:2.10-alpine ARG CONF=nats-server.conf-rendered COPY ${CONF} /etc/nats/nats-server.conf # TLS certs and creds are mounted at runtime — never baked into the image CMD ["nats-server", "-c", "/etc/nats/nats-server.conf"] ``` --- ## DNS & Firewall ### DNS Add an A record in Cloudflare for `craftdesign.group`: | Name | Type | Value | Proxy | |------|------|-------|-------| | `sync` | A | `<hetzner-public-ip>` | **DNS only (grey cloud)** | Must be grey cloud — NATS is raw TCP; Cloudflare cannot proxy it. ### Hetzner Cloud Firewall Add an inbound rule to the VPS firewall: | Protocol | Port | Source | |----------|------|--------| | TCP | 4222 | 0.0.0.0/0, ::/0 | --- ## JetStream Streams | Stream | Subjects | Retention | Storage | |--------|----------|-----------|---------| | `EDGESYNC` | `fossil.edgesync.>` | Limits (7 days / 1 GB) | File | | `GO_LIBFOSSIL` | `fossil.go-libfossil.>` | Limits (7 days / 1 GB) | File | --- ## Operational Reference ### Service management ```bash sudo systemctl status nats sudo journalctl -u nats -f sudo systemctl kill --signal=SIGHUP nats # reload config/certs — no connection drop ``` ### Verify public connectivity ```bash nats server ping --server tls://sync.craftdesign.group:4222 nats account info --server tls://sync.craftdesign.group:4222 --creds creds/edgesync-bridge.creds ``` ### Verify local admin connectivity ```bash nats server ping --server tls://localhost:4222 --tlsskipverify nats stream ls --server tls://localhost:4222 --tlsskipverify --creds creds/edgesync-admin.creds ``` ### Re-push JWTs after credential changes ```bash nsc push --all --server tls://localhost:4222 --tlsskipverify ``` --- ## Out of Scope - NATS clustering — cluster config hook (commented placeholder) present; implementation deferred - NATS WebSocket endpoint — deferred until browser/WASM leaf is implemented - Monitoring / Prometheus scraping — deferred