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.plon Cloudflare DNS. The domain's nameservers are delegated to Cloudflare (GoDaddy stays the registrar —.plcannot 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.
cloudflaredinstalled 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
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
/healthhits starves it, and Margonem login cannot protect availability. Add a Cloudflare Rate limiting rule onevocation.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 setsCache-Control: no-cacheand 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.allby the capability ACL, but you can add a Cloudflare Access policy overevocation.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¶
https://evocation.nerthus.pl/health→200(no auth).- Preflight →
204+access-control-allow-origin(step 4 curl). - From the dashboard: Margonem login mints a session token (see the consumer contract below).
- An authenticated read (
GET /v1/api/entities) succeeds from the page. - An own-scoped write lands as a
gracz:<margonem-id>line in the audit stream and syncs to GitLab. GET /v1/api/eventsconnects and streams an event.- A burst trips the rate-limit rule.
- 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 /healthalso 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'sgraczrole. Login is roster-gated — a Margonem id absent from the roster gets404, never a token — andgracz-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 viafetch+ReadableStream, the token in the header). - Transport.
nerthus.margonem.plsets no CSP today, so nativefetchworks; keepGM_xmlhttpRequest+@connect evocation.nerthus.plas a designed fallback, since that domain's headers are not ours to control — a future strictconnect-srcwould 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).