The Go service behind layforge.org: a small, standalone, self-hostable
public directory of currently-joinable Layforge campaigns, plus the
project's own homepage (plain HTML/JS at web/, served off the same
binary). Its own Go module (go.mod), deliberately separate from
master/'s — this is a genuinely independent service, not a Master
subsystem, matching the same "single static binary, no shared runtime
dependency" posture CLAUDE.md already states for
Master.
No central Layforge server your game depends on. A self-hosted
Master never needs a registry to function — this is purely opt-in
discovery, layered on top. master/internal/registry's own heartbeat
client is what Master uses to publish to one of these; see that
package's doc comment for the exact opt-in gate (a registry URL alone
lists nothing — each campaign additionally needs its own
RegistryListed/JoinAddress set via the admin panel's Campaign tab).
Nothing durable. internal/lobby.Store is a plain in-memory,
mutex-guarded map — the same ephemeral, TTL-based pattern Master itself
already uses for its own short-lived in-memory state (turn order, audio
stream buffers, character-creation sessions). A listing that stops
being heartbeated within -ttl (default 90s) is excluded from reads and
eventually swept; a registry restart loses every listing, and every
Master heartbeating it transparently re-registers on its very next tick
(a 404 on heartbeat is treated as "register again," not a fatal error —
see master/internal/registry's own HeartbeatLoop).
POST /api/v1/listings— create a listing. Body:adventure_name,min_level,max_level,players_joined,player_slots,password_protected,join_url,campaign_id. Response:{"id", "token"}—tokenis never retrievable again after this call and authorizes every later call against this listing.PUT /api/v1/listings/{id}— heartbeat/update. Body: same fields plustoken.404if the registry no longer knowsid(expired or restarted — the caller should justPOSTagain);403iftokendoesn't match.DELETE /api/v1/listings/{id}— deregister. Body:{"token"}.GET /api/v1/listings— public, no auth, notokenin the response ever. Returns{"listings": [...]}.
There is deliberately no same-origin/CORS-style check on the write
endpoints the way master/internal/admin's local operator panel has
one — this is a genuinely public, cross-host, server-to-server API any
self-hosted Master may call; authorization is the per-listing token
alone, the same trust model a lot of small public directory services
use (low-stakes data, real ownership check, no accounts).
Since this is a genuinely public, unauthenticated-write, internet-facing API, a real security pass (prompted by an actual review of this exact service) added several independent layers, each covering a different attack the previous ones don't:
- Request-size caps, two independent layers:
internal/lobby's own handlers wrap every write body inhttp.MaxBytesReader(16KB — seemaxRequestBodyBytes), so this binary itself never allocates unbounded memory decoding a request regardless of what's in front of it; the production Apache vhost additionally setsLimitRequestBody 32768, so an oversized upload is rejected before Apache spends effort relaying it to the backend at all. (A truly enormous body — multiple megabytes — can still surface as a502rather than a clean413through the full proxy chain, a known rough edge in howmod_proxyreacts to the backend closing the connection mid-upload; the actual protection — neither layer ever processes or buffers the oversized data — holds either way.) - Field length/shape validation (
internal/lobby/handlers.go'svalidateFields):adventure_name/campaign_idcapped at 200 characters,join_urlat 500 and required to parse as an absolute URL with a scheme and host — rejects obvious garbage before it ever reaches the public listings page. - Per-IP rate limiting on writes (
internal/lobby.IPRateLimiter, a hand-rolled token bucket — no dependency, matching this module's own zero-dependencygo.mod): 2 req/s sustained, burst 20, applied only to POST/PUT/DELETE —GET /api/v1/listings(players' own browsers polling every 30s) is never throttled. Generous enough that a single operator running several campaigns off one IP never trips it in normal heartbeat traffic. - A hard cap on total listings (
lobby.ErrStoreFull, 10,000): the per-IP limiter above only bounds one source; this bounds a distributed attempt from many different IPs from exhausting memory one listing at a time. Existing listings still expire/sweep normally, so this self-heals the moment abusive traffic stops. - HTTP server timeouts (
main.go's*http.Server):ReadHeaderTimeout/ReadTimeout/WriteTimeout/IdleTimeoutare all set — Go's default*http.Serverhas none at all, which is a real Slowloris/connection- exhaustion exposure for an internet-facing listener. - Security response headers, set twice — once in this binary
(
securityHeadersmiddleware, so they apply even to a direct request bypassing the reverse proxy) and again at the Apache vhost (mod_headers, matching in production):Strict-Transport-Security,X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy, and aContent-Security-Policy: default-src 'self'(the site never loads anything but its own same-originstyle.css/app.js, so this costs nothing functionally). - No stored-XSS surface:
web/app.jsbuilds every listing row withtextContent, neverinnerHTML— attacker-controlled listing data (which anyone can submit, by this API's own open-write design) can never execute as script on the public page. - Apache's global
ServerTokens Prod/ServerSignature Off(set onironclad, the box fronting this and its sibling domains) suppress theServerresponse header's version string, a minor but free reduction in what a would-be attacker can fingerprint.
What this deliberately does not defend against: a self-hoster is
always free to publish a fake/spam listing (fabricated adventure_name,
a join_url pointing anywhere) — there's no way to cryptographically
prove a listing corresponds to a real, reachable Master, by this API's
own open-registration design (see "API" above). The mitigations above
bound the scale of abuse (rate/size/count), not its existence; the
lobby page's own copy already sets that expectation ("it does not create
an account or connect you to anything automatically").
go build ./...
./registry -addr :8091 -web-dir ./web -ttl 90s
Flags: -addr (default :8091), -web-dir (default a web directory
next to the binary; empty disables serving the site, leaving only the
JSON API), -ttl (default 90s — keep this comfortably above whatever
heartbeat interval self-hosted Masters use), -sweep-interval (default
30s — pure memory hygiene, GET /api/v1/listings already excludes
expired entries regardless of whether a sweep has run).
Live: https://layforge.org is this exact deployment, running on
this project's own home network, mirroring an already-working pattern
(promptdna.org) on the same infrastructure — documented here so it's
reproducible, not tribal knowledge:
-
The
registrybinary +web/run on one LAN machine (videogen), bound to a LAN-reachable port (8091), as a systemd service:[Unit] Description=Layforge Registry After=network.target [Service] Type=simple User=jamesp WorkingDirectory=/home/jamesp/projects/layforge-registry ExecStart=/home/jamesp/projects/layforge-registry/registry -addr :8091 -web-dir /home/jamesp/projects/layforge-registry/web Restart=on-failure RestartSec=5 [Install] WantedBy=multi-user.target
-
A separate machine (
ironclad) already runs Apache as this home network's real public front door — real TLS viacertbot, already reverse-proxying several other domains straight through to specific ports onvideogenover the LAN.layforge.org's vhost pair mirrors those exactly:<VirtualHost *:80> ServerName layforge.org ServerAlias www.layforge.org LimitRequestBody 32768 ProxyPreserveHost On ProxyPass / http://192.168.1.56:8091/ ProxyPassReverse / http://192.168.1.56:8091/ ErrorLog ${APACHE_LOG_DIR}/layforge-error.log CustomLog ${APACHE_LOG_DIR}/layforge-access.log combined RewriteEngine on RewriteCond %{SERVER_NAME} =www.layforge.org [OR] RewriteCond %{SERVER_NAME} =layforge.org RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent] </VirtualHost>
(
192.168.1.56isvideogen's LAN address; adjust for a different network.)certbot --apache -d layforge.org -d www.layforge.orgissues the matching-le-ssl.confhalf automatically, into which the sameLimitRequestBodyline and the followingmod_headersblock were also added (see "Security" above for why):<IfModule mod_headers.c> Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains" Header always set X-Content-Type-Options "nosniff" Header always set X-Frame-Options "DENY" Header always set Referrer-Policy "strict-origin-when-cross-origin" </IfModule>
ironclad's global/etc/apache2/conf-available/security.confalso setsServerTokens Prod/ServerSignature Off— box-wide, not specific to this vhost. -
DNS (Porkbun): an A record for
layforge.organdwww.layforge.orgpointing at the home network's public IP — the same one every other domain on this Apache instance already resolves to. -
certbot --apache -d layforge.org -d www.layforge.orgissued the real cert and deployedlayforge.org-le-ssl.confautomatically.
go build ./... && go vet ./... && go test -race ./...— coveringinternal/lobby's create/heartbeat/remove/token-mismatch/not-found/ TTL-expiry/sweep logic and its HTTP handlers (including confirmingtokennever appears in aGET /api/v1/listingsresponse), plus the Security section's own additions: field-length/URL-shape validation, the 413 request-size cap,ErrStoreFull's capacity cap,IPRateLimiter's token-bucket behavior, andmain.go's security- headers/rate-limit middleware.- Security fixes re-verified against the real production deployment
(not just local
httptest): confirmed all 5 security response headers present onhttps://layforge.orgafter the Apache reload; confirmed a 40KB write returns a clean413through the full proxy chain (Apache'sLimitRequestBodycatching it before the backend); confirmed a badjoin_urlstill returns400; confirmedcurl -Ino longer leaks an Apache version string; hammeringPOST /api/v1/listingspast the burst locally returned429as expected. - Live-verified, real separate processes (not
httptestfakes): a realregistrybinary and a realmasterbinary,master's admin API used to opt a real test campaign in viaPUT /api/campaigns/{id}/policy(registry_listed: true,join_address). Confirmed the listing appeared inGET /api/v1/listingsand in the real browser lobby page within one heartbeat interval; confirmed opting the same campaign back out produced a real deregister (gone within one more interval); confirmed that killing Master ungracefully (no deregister call at all) still made the listing disappear on its own once the registry's TTL passed with no further heartbeats. - Re-verified against the real production deployment at
https://layforge.org(not just the local pair of processes above): a real Master pointed at-registry-url https://layforge.orgopted a test campaign in via the admin API, and the listing appeared inhttps://layforge.org/api/v1/listingsand the live homepage over real HTTPS within one heartbeat interval; opting back out produced a real deregister against production.