Ga naar inhoud

Running the netnl demo

The demo is an anonymous, single-domain front door onto the netnl facade (/demo/*, opt-in, see openspec/changes/archive/2026-09-08-add-demo-run/design.md): a first-time visitor types a bare domain into the dark-launched demo page and sees a real result, without an account. It is the smallest possible slice — one domain, strictly bounded — not a way to issue tenant credentials; that flow (turning a demo visitor into a supporter/tenant) is a separate, later change and does not exist yet (see openspec/changes/archive/2026-09-08-add-demo-run/tasks.md, owner input O4).

Enabling it

Add these to the facade's environment (deploy/.env in the co-located topology, the homelab netnl-config/netnl-secrets equivalent in the K8s topology — see deploy-facade.md):

NETNL_DEMO_ENABLED=1
NETNL_DEMO_ALLOWED_ORIGIN=https://demo.example.org   # the demo page's own origin, exactly
NETNL_DEMO_TENANT=netnl-demo

NETNL_DEMO_ALLOWED_ORIGIN must be the bare https://host[:port] origin the demo page is actually served from — no path, no trailing slash (see design.md's D6 for the exact shape validated at startup). Everything else (NETNL_DEMO_MAX_PER_HOUR, NETNL_DEMO_MAX_CONCURRENT, NETNL_DEMO_PER_IP_PER_HOUR, NETNL_DEMO_CLIENT_IP_HEADER, NETNL_DEMO_DOMAIN_COOLDOWN_SECONDS, NETNL_DEMO_RETENTION_HOURS, NETNL_DEMO_POLLS_PER_IP_PER_HOUR) has a sensible default from design.md's configuration table; leave them unset to start. NETNL_DEMO_POLLS_PER_IP_PER_HOUR (default 120) bounds anonymous polling (GET status/results) per client-IP bucket, separately from NETNL_DEMO_PER_IP_PER_HOUR's bound on accepted submissions — see reference/demo-api.md for the poll cadence it is sized against.

Leaving NETNL_DEMO_ENABLED unset (the default) means the three /demo/* paths (six routes, counting each path's own OPTIONS preflight) do not exist as far as any client can tell — the ordinary 501 not-implemented catch-all, same as any other unmapped path.

Issuing the borrowed credential

The demo runs under one ordinary credential row — issued exactly like a tenant's, then never used to authenticate as anyone:

netnl-admin user add netnl-demo

This prints a generated password once, to stdout. Throw it away — write it down nowhere, do not put it in a secret store. Nobody ever authenticates as netnl-demo; the demo routes never read an Authorization header at all (design.md, D9). The row's only job is to exist, unrevoked, as the identity every accepted demo submission is recorded under in the audit trail (credential = netnl-demo, event = submit, domain_count = 1) — indistinguishable in shape from an ordinary tenant submission.

Use a dedicated, never-issued-to-anyone username for NETNL_DEMO_TENANT (netnl-demo above is the convention; anything not already, or ever, handed out to a real tenant works). If this username were ever also a real tenant's own credential, the demo would become an anonymous read path into that tenant's runs: GET /demo/requests/{id} and .../{id}/results are scoped to whichever credential row NETNL_DEMO_TENANT names, with no further check — the same row, the same rows in requests.

Header trust and the client IP

The per-IP bucket (NETNL_DEMO_PER_IP_PER_HOUR, and the poll budget NETNL_DEMO_POLLS_PER_IP_PER_HOUR) trusts whatever value arrives in NETNL_DEMO_CLIENT_IP_HEADER (CF-Connecting-IP by default) — a value the edge sets, not something this facade can itself verify. Reachable only through Cloudflare (topology 1's primary path, or any edge that always overwrites this header before proxying), that is a safe assumption: Cloudflare sets CF-Connecting-IP itself and strips any client-supplied copy. Reachable also through a bypass that does not set (or does not overwrite) this header — most concretely, topology 1's Tailscale Funnel fallback (see deploy-facade.md) — a caller going in through that path can put anything in CF-Connecting-IP, spoofing its way into a fresh per-IP bucket on every request.

That per-IP bucket is a dampener against casual repeat use from the same visitor, not the hard rate limit: the demo tenant's own aggregate rate/concurrency cap (NETNL_DEMO_MAX_PER_HOUR/NETNL_DEMO_MAX_CONCURRENT, via the same atomic reservation transaction the tenant surface uses) is the one bound that holds regardless of what any header claims, because it does not key on the header at all. Three options if the Funnel-bypass gap matters for a given deployment: add a Cloudflare rule scoped to /demo/* that only forwards traffic that actually came through Cloudflare (defeats the header-spoof from the Funnel path, since a request that skipped Cloudflare never gets that rule's stamp); turn the Funnel off outright outside of an active fallback need; or accept the gap and rely on the aggregate cap while the Funnel stays up. See deploy-facade.md's own /demo/* section for the same trade-off from the edge-configuration side.

One more reviewer-noted limit worth stating plainly: the per-IP bucket, the per-domain cooldown and the poll budget are all in-process, per running netnl-serve instance — running more than one replica behind a load balancer means each replica keeps its own independent counters, so the effective per-IP/per-domain/poll limits multiply by the number of replicas. The demo tenant's own aggregate cap (limits.reserve_submission, backed by the shared SQLite file) is the only one of these bounds that still holds cluster-wide regardless of replica count.

The kill switch

Revoke the row and every demo request answers 503 demo-unavailable from that moment on — no restart, no configuration change:

netnl-admin user revoke netnl-demo

Re-enable it with:

netnl-admin user reissue netnl-demo

reissue (not add) is the correct lever here: it works on the existing row whether it is currently revoked or not — a fresh password/salt, with revoked_at cleared — where netnl-admin user add netnl-demo would refuse with "user already exists" the moment the row exists at all, revoked or active. This is the kill switch's other half: revoke turns the demo off, reissue turns it back on, both without a restart or a configuration change. reissue also prints a generated password once, to stdout — throw it away exactly like user add's: the demo never authenticates as this credential either way (design.md, D9).

This is the operator's entire abuse-response lever for the demo surface. When to pull it is below.

Abuse response: who pulls it, and on what signal

Decided 2026-09-05 (owner Mark): one human revoker, one written threshold. No automatic revocation — a false positive would take the public demo offline unattended, and this surface is not load-bearing enough to justify that. Mark revokes; nobody else has netnl-admin on the pod.

Pull the switch when any of these holds:

  • Sustained saturation. The demo hits NETNL_DEMO_MAX_PER_HOUR for three consecutive hours and tenant submissions are visibly queueing behind it. One busy hour is the cap doing its job; three in a row with tenant impact is the demo crowding out the people it was meant to advertise to.
  • Single-source flooding. One IP burns the per-IP cap every hour for three consecutive hours — the per-IP bound is holding, but somebody is clearly automating against it rather than trying the demo.
  • Targeting. Submitted domains are plainly not the submitter's own (a run of unrelated third-party domains, or anything on a well-known target list). The terms permit measuring hosts you operate; the demo cannot verify that, so this is the one signal that is judged by eye.

What to look at, on the pod:

netnl-admin user list          # is the demo row still active?

# demo submissions per hour over the last day
sqlite3 "$NETNL_DB" \
  "SELECT substr(at,1,13) AS hour, count(*), sum(domain_count)
     FROM audit WHERE credential='netnl-demo' AND event='submit'
       AND at > datetime('now','-1 day')
   GROUP BY hour ORDER BY hour;"

The audit table is append-only and submit rows carry the credential and the domain count, so this is the whole picture for the demo surface — there is no separate metrics stack to consult.

After revoking: say so on the demo page (it already renders demo-unavailable as a plain message), and note the reason and the hour range in the change's tasks.md or a handover note, so the eventual decision to raise the caps has evidence under it rather than a memory.

Smoke check

The demo page itself probes a fixed, all-zero id on load to tell whether the demo is live, without needing a real submission first:

curl -s -o /dev/null -w '%{http_code}\n' \
  https://your-facade-host/demo/requests/00000000000000000000000000000000
  • 404 — the demo is enabled and reachable; an all-zero id is simply not a request that exists (the same "foreign or unknown id" 404 the authenticated surface gives).
  • 501 — the demo is not enabled at all.
  • 503 demo-unavailable — enabled, but the borrowed credential is missing or revoked (the kill switch is engaged).

A full end-to-end check submits a real domain and polls it through to done — see reference/demo-api.md for the exact contract the demo page itself relies on (poll cadence, error shapes, CORS).

CORS

NETNL_DEMO_ALLOWED_ORIGIN is the only origin the demo answers to — never echoed, never combined with Access-Control-Allow-Credentials. If the demo page moves to a new hostname (or gains a staging copy), update this variable to match — the facade will otherwise answer real requests from the old/other origin with 403 forbidden-origin and preflight requests with a CORS-header-free 204 a browser will not let through. See design.md's D6/D8 and openspec/changes/archive/2026-09-08-add-demo-run/tasks.md's owner input O6 (a single origin is a deliberate constraint, not yet revisited).

Retention

A demo-owned request stays retrievable for NETNL_DEMO_RETENTION_HOURS (default 24 hours) — much shorter than the tenant surface's NETNL_RESULT_RETENTION_DAYS (default 7 days), applied on the same netnl-admin prune pass (see deploy-facade.md for the cron cadence that actually bounds this in practice). netnl-admin prune's output gains a demo requests pruned: N line once the demo is configured; without NETNL_DEMO_ENABLED, the output is unchanged.

Not an SLA

Same caveat as the rest of the deployment docs (deploy-facade.md): homelab-grade, no managed- service guarantee. The demo's own bounds (per-tenant hourly cap, per-IP cap, per-domain cooldown) exist to keep one anonymous surface from overwhelming the same finite upstream instance every tenant shares — not to promise availability.