Skip to content

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 /ws lobby socket (and /health) to a phase-server container on the internal proxy network,
  • 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 /ws connection 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 /ws and 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 ProbeDown and CertExpiringSoon alerts) watches the tunnel and Caddy ingress path,
  • ContainerUnhealthy watches the container's own healthcheck,
  • PhaseServerAbsent (an absent() guard) catches the container going away entirely, the one case the other two miss.