MTG lobby (phase.rs)
A self-hosted phase.rs card-game server so a small
group of trusted users can play Commander in the browser: solo against the AI
(the game engine runs entirely as client-side WebAssembly) or multiplayer through
a lightweight matchmaking lobby. It is served at mtg.example.com behind the same
Authelia login as the other supporting apps.
How it works
It is a same-origin design. One Cloudflare tunnel hostname routes to Caddy, which does three things behind a single Authelia forward-auth gate:
- serves the static single-page app and the card database JSON,
- reverse-proxies the
/wslobby socket (and/health) to aphase-servercontainer on the internalproxynetwork, - gates every request, the app and the socket upgrade alike, with Authelia.
Because the app and the socket share one origin, the .example.com Authelia
session cookie rides the WebSocket upgrade, so the lobby needs no separate auth.
phase-server runs in Full mode: it hosts server-run multiplayer games
directly over the same /ws connection Caddy already gates with Authelia. The
client's "Host P2P" path still exists as a fallback (players connect directly to
each other instead), but the default is a server-hosted game (see below).
What is protected
Authelia forward-auth (two-factor) gates the app shell, the card data, and the
/ws lobby upgrade. An unauthenticated request to any of them is redirected to
the login portal. The session cookie is stripped before Caddy proxies to
phase-server, so a compromised lobby container cannot replay a user's SSO
session.
Trust boundaries
- Server-hosted games (the default) are gated by Authelia end to end, since
the same
/wsconnection that carries lobby/matchmaking traffic also carries gameplay: joining still needs a valid SSO session, not just a leaked room code. - The P2P fallback ("Host P2P") is not gated by Authelia and is not fully self-hosted. Once the lobby matches two P2P players, they connect directly to each other, and a room is protected only by its short random join code, fine for private play but not login-grade. It also depends on two upstream signalling services outside this deployment's control: a public PeerJS broker for connection setup, and a TURN relay for players behind strict NAT. If the TURN service is unavailable the client silently falls back to a direct connection, which can fail for some remote peers (observed 2026-07-21: a P2P game dropped mid-session after negotiating a TURN-relayed candidate pair). Server-hosted mode sidesteps both dependencies entirely, which is why it is now the default.
- The login gate is enforced at the network, not in the cached client. The
app is a Progressive Web App: once a browser has loaded it, its service worker
serves the cached app shell (and can play solo against the AI offline) without a
network round-trip, so on a shared machine the shell can still open after
logout. This is not a bypass. Every request that actually reaches the server,
the lobby
/wsand any fresh data fetch, is redirected to Authelia when unauthenticated. To confirm the gate, open the site in a private window (no session, no service worker) and you will get the login page. To make a logout take effect in a normal window, unregister the service worker or hard-reload. - Direct LAN access fails closed. Caddy publishes ports 80 and 443 on the
host, so the vhost is reachable directly on the LAN, bypassing Cloudflare.
Forward-auth still runs, and the Authelia session cookie is marked
Secure, so it is never sent over plain HTTP; an unauthenticated visitor is simply bounced to the HTTPS login.
Monitoring
Three complementary signals cover it, matching the rest of the fleet:
- an external blackbox probe of the public URL (generic
ProbeDownandCertExpiringSoonalerts) watches the tunnel and Caddy ingress path, ContainerUnhealthywatches the container's own healthcheck,PhaseServerAbsent(anabsent()guard) catches the container going away entirely, the one case the other two miss.