Skip to content

Publish the API at evocation.nerthus.pl

This guide puts the VM daemon on the public internet behind a Cloudflare Tunnel, so the browser dashboard and a future bot can reach it at https://evocation.nerthus.pl. The daemon does not change: it stays bound to 127.0.0.1, single-consumer, emitting no TLS and no CORS. Everything a public API needs — the certificate, rate-limiting, CORS, DDoS protection — is added at the Cloudflare edge, never in the daemon. The trust-boundary rationale is in Architecture; this page is procedure only.

flowchart LR
    U["Dashboard userscript<br/>nerthus.margonem.pl"]
    subgraph edge["Cloudflare edge"]
        E1["TLS termination"]
        E2["CORS Worker"]
        E3["Rate-limit / WAF"]
    end
    subgraph vm["The VM (one host)"]
        T["cloudflared"]
        D["nerthusd @ 127.0.0.1:8787"]
    end
    U -->|"https + Bearer"| edge
    edge -->|"outbound tunnel"| T
    T -->|"loopback HTTP"| D

Prerequisites

  • A working VM daemon — Deploy the VM daemon done first. This guide fronts that daemon; it does not replace it.
  • nerthus.pl on Cloudflare DNS. The domain's nameservers are delegated to Cloudflare (GoDaddy stays the registrar — .pl cannot move to Cloudflare Registrar, and does not need to). All records are edited in the Cloudflare dashboard, not at GoDaddy.
  • A Cloudflare account with Zero Trust (tunnels) and Workers enabled — both are free-tier.
  • cloudflared installed on the VM (Cloudflare's apt/rpm repository).

1. Pin the daemon port

A tunnel ingress needs a stable target, but the daemon's default port is OS-assigned and changes on every restart. Pin it host-locally through the boot flag — never through the committed config.json, which every clone shares. The shipped vm/nerthusd.service already carries -Port 8787:

ExecStart=/usr/bin/pwsh -NoProfile -File /srv/nerthus/module/daemon/Start-NerthusDaemon.ps1 -Repo /srv/nerthus/repo -Port 8787
sudo cp /srv/nerthus/module/vm/nerthusd.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl restart nerthusd
curl -s http://127.0.0.1:8787/health          # expect 200 JSON, on the VM itself

The daemon still binds loopback only — the pinned port is a fixed loopback target, not a public one. server.bind stays inert by design (Configuration).

2. Clear the stale record

If evocation.nerthus.pl already resolves (a placeholder or an old project), it will return a Cloudflare 530 — the edge is up with nothing behind it. Delete that record in Cloudflare → DNS first; the tunnel route in step 3 recreates it. Skipping this makes tunnel route dns fail on the existing record.

3. Create the tunnel

Run on the VM as the nerthus user. Browser login works headless — cloudflared prints a URL you authorize on any machine; the certificate lands back on the VM.

cloudflared tunnel login
cloudflared tunnel create nerthus-api                       # writes <UUID>.json under ~/.cloudflared
cloudflared tunnel route dns nerthus-api evocation.nerthus.pl     # creates the proxied CNAME

route dns writes a proxied (orange-cloud) CNAME evocation.nerthus.pl → <UUID>.cfargotunnel.com. Proxied is mandatory — it is what puts the edge (TLS, Worker, rate-limit) in front of the origin.

One command instead of this step

On a bare VM, dot-source and run the deploy cmdlet as root (after cloudflared tunnel login):

. /srv/nerthus/module/vm/Install-NerthusTunnel.ps1
Install-NerthusTunnel -Hostname evocation.nerthus.pl -Port 8787 -WhatIf   # preview the steps
Install-NerthusTunnel -Hostname evocation.nerthus.pl -Port 8787

It creates-or-reuses the tunnel, routes the DNS, renders the config from vm/cloudflared-config.yml with the real UUID, and installs and starts the unit — steps 1 and 3 in one idempotent pass. It can't touch your Cloudflare account, so steps 4–5 stay manual.

Install the ingress config from vm/cloudflared-config.yml and the unit from vm/cloudflared.service, filling in the UUID:

sudo mkdir -p /srv/nerthus/.cloudflared
sudo cp /srv/nerthus/module/vm/cloudflared-config.yml /srv/nerthus/.cloudflared/config.yml
sudo cp ~/.cloudflared/<UUID>.json /srv/nerthus/.cloudflared/
# edit config.yml: set credentials-file to the copied <UUID>.json
sudo cp /srv/nerthus/module/vm/cloudflared.service /etc/systemd/system/
sudo chown -R nerthus:nerthus /srv/nerthus/.cloudflared
sudo systemctl daemon-reload && sudo systemctl enable --now cloudflared
curl -s https://evocation.nerthus.pl/health          # expect 200 — the 530 is gone, origin wired

4. Add the CORS Worker

The dashboard is a userscript on https://nerthus.margonem.pl, so its calls are cross-origin. The daemon sends no CORS headers and has no OPTIONS route, so a browser preflight would 404. Grant access at the edge instead. Deploy vm/cloudflare-worker-cors.js as a Worker and bind it to the route evocation.nerthus.pl/* (Workers & Pages → Create → Deploy → Settings → Triggers → Add route).

Verify the preflight the daemon can't answer:

curl -sD - -o /dev/null -X OPTIONS https://evocation.nerthus.pl/v1/api/entities \
  -H 'Origin: https://nerthus.margonem.pl' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: authorization'
# expect 204 with access-control-allow-origin: https://nerthus.margonem.pl

The Worker allows exactly one origin. A second consumer (a hosted bot UI) is a one-line addition to ALLOWED_ORIGINS.

5. Harden the edge

  • Rate-limit — required, not optional. The daemon's serve loop is single-threaded; it answers one request at a time. A flood of even unauthenticated /health hits starves it, and Margonem login cannot protect availability. Add a Cloudflare Rate limiting rule on evocation.nerthus.pl/* — e.g. 100 requests / 10 s per IP → Managed Challenge.
  • Cache — bypass. The API is dynamic and authenticated. Add a Cache rule: Bypass for evocation.nerthus.pl/*. This matters most for /v1/api/events: SSE must not be buffered or cached (the daemon already sets Cache-Control: no-cache and sends a keepalive every 30 s, well inside Cloudflare's ~100 s idle window).
  • Access — optional. The control-plane routes are already gated to admin.all by the capability ACL, but you can add a Cloudflare Access policy over evocation.nerthus.pl/v1/api/admin/*, /v1/api/import, /v1/api/tokens*, and /v1/api/capabilities/* for defense-in-depth (email OTP for people, a service token for a bot). Do not put Access over the player-facing routes — it would collide with the Margonem login the dashboard already performs.
  • Bot Fight Mode on.

6. Verify end to end

  1. https://evocation.nerthus.pl/health200 (no auth).
  2. Preflight → 204 + access-control-allow-origin (step 4 curl).
  3. From the dashboard: Margonem login mints a session token (see the consumer contract below).
  4. An authenticated read (GET /v1/api/entities) succeeds from the page.
  5. An own-scoped write lands as a gracz:<margonem-id> line in the audit stream and syncs to GitLab.
  6. GET /v1/api/events connects and streams an event.
  7. A burst trips the rate-limit rule.
  8. The machine token (runtime/daemon.token) is confirmed unreachable from outside the VM — it is loopback-discovered only and never leaves the host.

The consumer contract

What a public consumer (the dashboard userscript, a bot) must honour — the dashboard lives in its own repository and implements this:

  • Base URL https://evocation.nerthus.pl/v1/api. GET /health also answers bare at the root.
  • Authenticate with POST /v1/api/auth/margonem (a public route) carrying the Margonem-signed payload; it returns a session token scoped to the caller's gracz role. Login is roster-gated — a Margonem id absent from the roster gets 404, never a token — and gracz-pinned, so no login can escalate. Elevated capabilities come only from out-of-band per-person grants (Permissions).
  • Call every other route with Authorization: Bearer <token>. Never ship a named or machine token to a browser; the Margonem flow needs no client secret because the daemon verifies the signature against Margonem's public key.
  • Live updates stream from GET /v1/api/events (SSE via fetch + ReadableStream, the token in the header).
  • Transport. nerthus.margonem.pl sets no CSP today, so native fetch works; keep GM_xmlhttpRequest + @connect evocation.nerthus.pl as a designed fallback, since that domain's headers are not ours to control — a future strict connect-src would force it, and SSE would then drop to polling.

Notes

  • No daemon code changes. The loopback bind, the absent CORS, and the single-thread loop are all unchanged — the whole public surface is edge configuration plus the four vm/ templates.
  • The unauthenticated surface is small: /health, /schema, /routes, /schema/version, and the two /auth/* login routes. Everything else needs a bearer token (API reference).
  • Independent of the pipelines. No CI/CD variables are involved; the public edge is orthogonal to the settlement and map-checkup jobs (Set up pipelines).