API and realtime development
Add REST, SSE, or WebSocket behavior while preserving authentication, scoping, audit, and agent-proxy invariants.
The API gateway (api/) exposes the Kubernetes control plane (CRDs, operator state) to the web dashboard and external integrations. New endpoints must preserve five invariants: authentication (who is the user?), session integrity (CSRF + rate limiting), cluster/namespace scoping (which resource?), safe error messages (never leak internal state), and audit trails (what changed?).
A route is incomplete until permission, cluster and namespace scope, safe errors, audit, tests, and docs are defined.
REST gateway conventions
Handlers stay close to Kubernetes resources while enforcing session, CSRF, permission, scope, and safe-error contracts. Every REST route follows these patterns:
Authentication and authorization
The API supports two authentication flows: local password (argon2id hashing) and OIDC (via coreos/go-oidc/v3). Sessions are stored in the database with CSRF tokens and configurable expiry. Every API request runs through the Session middleware, which validates the session cookie and populates the request context.
Authorization is role-based with three built-in roles (admin, operator, viewer) plus custom roles. The RBAC middleware (internal/rbac/) checks the user’s permission against a rule table (method + path → permission), and falls back to per-GameServer owner/collaborator checks for fine-grained access.
Permission discovery: The GET /roles endpoint lists all permissions and role assignments (read by the dashboard settings UI).
Cluster and namespace scope
Every request has a logical cluster and namespace. The API resolves these via:
- Cluster: extracted from
?cluster={name}query param (defaults tolocal— the install cluster), validated against the Cluster CRD registry. Collection reads under/fleet/*(v0.3.0) instead combine every authorized scope, with optional cluster and namespace filters; each item carries an explicit{cluster, namespace, name, uid}target, and unavailable or truncated scopes are reported rather than silently dropped. - Namespace: extracted from the URL path (
/servers/{name}→ namespace from server lookup) or defaulted to the user’s home namespace
Scope middleware (internal/scope/) extracts both before the handler runs. If a user tries to access a namespace they lack permission for, the handler returns 403 Forbidden with a safe message (no namespace-existence leakage).
Safe error messages
The httperr package classifies errors into safe HTTP responses. Internal errors (e.g., “database connection lost”) are logged server-side but returned to the client as generic 500 Internal Server Error. Permission errors return 403 Forbidden without revealing whether the resource exists. Not-found errors return 404, but the safe message never reveals whether the 404 is because the resource doesn’t exist or the user lacks permission to see it.
Audit logging
Every non-read request (POST, PATCH, DELETE) is logged to the audit table with:
- User ID and IP address
- Method, path, and request body (redacted for secrets)
- Response status
- Timestamp and hash-chain integrity ID
The audit log is queryable via GET /admin/audit with filters (user, date, status). Hash-chain integrity allows offline detection of tampering.
Realtime and proxy paths
Cluster events, console, logs, files, players, mods, status, and actions use different transports and upstreams:
Server-Sent Events (SSE) for status invalidation
The GET /events endpoint is an authenticated SSE stream that emits Kubernetes events in real-time:
GET /events?cluster=local HTTP/1.1
Accept: text/event-stream
Authorization: Bearer <session-cookie>
data: {"type":"GameServer","name":"my-server","action":"ADDED","timestamp":"2025-09-27T01:00:00Z"}
data: {"type":"GameServer","name":"my-server","action":"MODIFIED","status":"Running"}
The stream multiplexes events across all servers the user has permission to see (role + collaborator fallback). Reconnects are automatic on the client side (browser EventSource API); the API accepts unlimited concurrent SSE clients.
Use cases: status updates (server phase: Running → Stopped), quota changes, collaborator additions, backup completion.
WebSocket console and logs
Console (RCON/PTY execution) and pod logs are bidirectional WebSocket streams (/ws/servers/{name}/console, /ws/servers/{name}/console-pty, /ws/servers/{name}/logs, /ws/servers/{name}/logs/pod). The WebSocket handler:
- Validates session and permission (same middleware as REST)
- Dials the in-pod agent over mTLS
- Proxies client ↔ agent frames with heartbeat/keepalive
- Closes the connection when either end disconnects
Agent proxy: Commands sent over the WebSocket are executed in the game pod via the agent sidecar. RCON goes to the game’s remote console port; PTY goes to a shell session inside the pod.
Pod logs: GET /ws/servers/{name}/logs/pod streams live output from Kubernetes pod logs (stdout/stderr of the game container). Failure modes differ from agent logs (pod logs are captured by kubelet; agent logs come from the sidecar — if the agent crashes or the pod is evicted, pod logs may be incomplete or unavailable).
Heartbeat: WebSocket connections are protected against idle timeouts with keepalive frames; the connection closes when either endpoint disconnects.
File proxy and artifact downloads
File operations (/servers/{name}/files/*) are HTTP(S) requests that proxy to the agent’s internal file server. The agent handles listing, reading, writing, and deleting files from the game’s persistent volume. Uploads/downloads are streamed (no in-memory buffering).
Add an endpoint
Adding a new API endpoint is a four-step workflow: mount the route, define the handler and permission, test the right layer, and document the change.
Step 1: Define the permission
Open api/internal/rbac/catalog.go and add a permission to the Catalog slice:
{
Key: "servers:my-new-action",
Label: "...",
Namespaced: true,
}
Add this inside the relevant PermGroup to define the new permission key.
Step 2: Implement the handler
Create or edit a file in api/internal/handlers/ (e.g., lifecycle.go, resources.go, audit.go). Follow this pattern:
func (h *handlers) serversEdit(w http.ResponseWriter, r *http.Request) {
// 1. Extract scope (cluster, namespace from context)
cluster := scope.ClusterFromContext(r.Context())
namespace := scope.NamespaceFromContext(r.Context())
// 2. Parse input
var req UpdateServerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperr.BadRequest(w, "invalid request body")
return
}
// 3. Validate input
if req.Memory < 512 {
httperr.BadRequest(w, "memory must be >= 512Mi")
return
}
// 4. Fetch the resource (CRD)
server, err := h.kube.GetGameServer(r.Context(), cluster, namespace, chi.URLParam(r, "name"))
if err != nil {
if apierrors.IsNotFound(err) {
httperr.NotFound(w, "server not found")
} else {
httperr.InternalError(w, err)
}
return
}
// 5. Check ownership/collaborator permission (RBAC fallback)
user := auth.UserFromContext(r.Context())
if !user.IsAdmin && server.Spec.Owner != user.ID && !slices.Contains(server.Spec.Collaborators, user.ID) {
httperr.Forbidden(w, "you do not have access to this server")
return
}
// 6. Mutate and apply
server.Spec.Memory = req.Memory
_, err = h.kube.UpdateGameServer(r.Context(), server)
if err != nil {
httperr.InternalError(w, err)
return
}
// 7. Emit audit log (automatic via middleware if audit tags are set)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(server)
}
Step 3: Mount the route
Edit api/internal/rbac/rbac.go and add a rule entry to the rules table:
{method: "PATCH", path: "/servers/{name}", permission: "servers:my-new-action"}
Then mount the plain chi route in api/cmd/main.go:
r.Route("/servers/{name}", func(r chi.Router) {
r.Get("/", h.serversView)
r.Patch("/", h.serversEdit)
r.Delete("/", h.serversDelete)
})
The global rbac.Middleware (applied once in main.go) validates permission against the rules table. If the user lacks permission, the middleware returns 403 Forbidden.
Step 4: Test the route
Add an envtest (integration test) in api/internal/handlers/<name>_envtest_test.go:
func TestGameServerEdit_NotFound(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Create a test user, cluster, namespace
h := &harness{}
h.setUp(ctx, t)
defer h.tearDown()
// Call the API
req := httptest.NewRequest("PATCH", "/servers/nonexistent", bytes.NewBufferString(`{"memory": 1024}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handler.ServeHTTP(w, req)
// Assert response
assert.Equal(t, http.StatusNotFound, w.Code)
}
Run the test via make test-integration.
API CHANGE
Related reading
- REST API Reference — full endpoint catalog, request/response schemas
- WebSocket & Event Streams — protocol details, reconnect behavior
- API & CRDs — CRD reference and schema validation
- Local Development Setup — running the API locally (
make dev-up)
Next guide: Operator and CRD development