No description
  • Go 82.9%
  • HTML 10%
  • CSS 3.4%
  • JavaScript 2.5%
  • Shell 0.8%
  • Other 0.4%
Find a file
James Dyke ac281dc3a0
All checks were successful
Test / test (push) Successful in 46s
Revert "Install Node.js in the build job before using artifact actions"
This reverts commit 18eb03dba9.
2026-07-24 16:09:00 +02:00
.claude/skills Migrate frontend to Material Web Components with flexbox layout 2026-07-14 17:23:24 +02:00
.forgejo/workflows Revert "Install Node.js in the build job before using artifact actions" 2026-07-24 16:09:00 +02:00
cmd/server start of proxy rework 2026-07-20 13:26:58 +02:00
features Support proxy connections and automatically starting containers 2026-07-24 14:06:20 +02:00
internal Support proxy connections and automatically starting containers 2026-07-24 14:06:20 +02:00
scripts Add Postgres as an alternate database provider alongside SQLite 2026-07-15 13:48:56 +02:00
tools/frontend Add log auto-scroll and a download-logs dialog with a line-count slider 2026-07-14 18:04:27 +02:00
.gitignore starting to look like a real proxy 2026-07-24 00:29:50 +02:00
config.example.yaml first pass at proxy connections 2026-07-23 11:41:28 +02:00
Dockerfile Fix dockerfile to include version in binary when building 2026-07-14 18:21:15 +02:00
Dockerfile.release Updating CI steps 2026-07-24 15:42:24 +02:00
go.mod Add Postgres as an alternate database provider alongside SQLite 2026-07-15 13:48:56 +02:00
go.sum Add Postgres as an alternate database provider alongside SQLite 2026-07-15 13:48:56 +02:00
Makefile Updating CI steps 2026-07-24 15:42:24 +02:00
README.md Add Postgres as an alternate database provider alongside SQLite 2026-07-15 13:48:56 +02:00

game-control-panel

A small, self-hosted web application for starting and stopping Docker containers (game servers) from a browser. Server-rendered with Go's standard library and htmx; no SPA framework. The UI is built with Material Web Components, vendored as a single static bundle (see tools/frontend/) — there's no build step for the Go server itself, but the frontend's component bundle is produced by a small, separately-run Node/esbuild step and committed like any other vendored static asset.

Features

  • Lists and controls only the Docker containers you opt in via a label, not every container on the host.
  • Two roles: user (read-only, can see status) and admin (can start and stop).
  • Login sessions are JWTs carried in an HttpOnly/Secure cookie.
  • Passwords are hashed with argon2id.
  • Single self-contained binary: templates and static assets are embedded.

Requirements

  • Go 1.26.5 or later to build.
  • Access to a Docker daemon (local unix socket by default).
  • No CGO required; both the SQLite and Postgres drivers are pure Go.

Configuration

Copy config.example.yaml and adjust it:

server:
  listen_addr: ":8080"
  read_timeout: 15s
  write_timeout: 15s
  shutdown_timeout: 10s

docker:
  host: "unix:///var/run/docker.sock"
  request_timeout: 5s
  stop_timeout: 30s
  label_key: "gcp.managed"
  label_value: "true"
  name_label: "gcp.name"

database:
  # driver defaults to "sqlite" if omitted, and uses "path" below.
  path: "/var/lib/game-control-panel/data.db"

  # To use Postgres instead, set driver to "postgres" and drop "path" in
  # favor of the connection fields below. "password_file" reads the
  # password from a file (e.g. a Docker secret) and takes precedence over
  # an inline "password" if both are set.
  # driver: "postgres"
  # host: "db.internal"
  # port: 5432
  # name: "gcp"
  # user: "gcp"
  # password: "hunter2"
  # password_file: "/run/secrets/db-password"
  # sslmode: "disable"

auth:
  jwt_secret_env: "GCP_JWT_SECRET"
  token_ttl: 12h
  cookie_name: "gcp_session"
  cookie_secure: true

admin:
  username: "admin"
  password_env: "GCP_ADMIN_PASSWORD"
  force_reset: false

Notes:

  • docker.label_key / docker.label_value control which containers the panel can see and control. Only containers carrying this label are listed, and every start/stop request re-checks the label on the target container before acting, even if a request is crafted with an arbitrary container ID.
  • docker.name_label is optional; if set, its value is used as the container's display name in the UI instead of the raw Docker container name.
  • auth.jwt_secret / admin.password accept inline values for local development. auth.jwt_secret_env / admin.password_env name an environment variable to read the value from instead, and take precedence if set. One of the two must resolve to a non-empty value or the server refuses to start.
  • admin.username / admin.password seed the initial admin account on first startup. If the account already exists, it is left untouched on subsequent restarts unless admin.force_reset: true.
  • database.driver selects sqlite (default) or postgres. database.password accepts an inline value; database.password_file reads it from a file instead (e.g. a Docker/Kubernetes secret) and takes precedence if both are set. database.sslmode has no app-level default — leave it unset to fall back to the Postgres driver's own default, or set it explicitly (disable, require, etc.).
  • Config parsing is strict: unknown fields in the YAML file are a startup error, not a silently ignored typo.

Label a container so the panel manages it:

docker run -d --name my-game-server \
  --label gcp.managed=true \
  --label gcp.name="My Game Server" \
  my-game-image

Running

export GCP_JWT_SECRET=$(openssl rand -hex 32)
export GCP_ADMIN_PASSWORD=some-strong-password
go run ./cmd/server -config config.yaml

The -config flag defaults to config.yaml in the working directory.

On first startup the admin account named in admin.username is created using the resolved password. Log in at /login with those credentials.

Building

go build -o bin/server ./cmd/server

A statically linked binary (no CGO) can be built the same way, since both the SQLite and Postgres drivers are pure Go:

CGO_ENABLED=0 go build -o bin/server ./cmd/server

Migrating from SQLite to Postgres

scripts/migrate-sqlite-to-postgres.sh copies the users table from an existing SQLite database into a fresh Postgres database, for operators switching database.driver from sqlite to postgres on an existing install. It's a one-time, env-var driven script (not part of the Go binary) — see the header comment in the script for the full list of required/optional env vars and an example invocation. It is not idempotent: run it once against an empty users table.

Testing

go vet ./...
go test ./...

Project layout

cmd/server/            entrypoint: config, DB, Docker client, HTTP server wiring
internal/config/       YAML config struct, defaults, env-var secret resolution
internal/store/        SQLite/Postgres persistence for users, admin bootstrap
internal/auth/         argon2id hashing, JWT issuing/parsing, auth middleware
internal/dockerctl/    Docker SDK wrapper: label-filtered list, guarded start/stop
internal/web/          HTTP routes, handlers, embedded templates and static assets
tools/frontend/        Vendoring tool: bundles Material Web Components into
                        internal/web/static/material-web.bundle.js. Not part
                        of the Go build — run by hand to regenerate on upgrade.
scripts/               Standalone ops scripts (e.g. SQLite→Postgres migration)

Routes

Method Path Access
GET, POST /login public
GET /static/ public
GET /healthz public
POST /logout authenticated
GET / authenticated
GET /containers authenticated
POST /containers/{id}/start admin
POST /containers/{id}/stop admin

Unauthenticated requests to any authenticated route are redirected to /login (a 302 for normal page loads, or a 401 with HX-Redirect for htmx requests) rather than reaching handler code. Role enforcement for start/stop happens server-side; hiding the buttons in the UI for non-admin users is a display convenience only, not the security boundary.

Troubleshooting

Login form just reloads with no error, even with the correct password. This almost always means the session cookie isn't being stored by the browser, not that authentication is failing. By default auth.cookie_secure: true, which marks the cookie Secure — browsers silently refuse to store Secure cookies over a plain HTTP connection. POST /login succeeds and redirects to /, but with no cookie the Authenticate middleware immediately bounces the browser back to /login, which looks identical to a failed login. Check your browser's network tab: if POST /login returns a 302 with a Set-Cookie header but no cookie shows up afterward, this is the cause. Fix it by either serving over HTTPS (directly or via a reverse proxy that terminates TLS in front of this server) or, for plain-HTTP/local deployments, setting auth.cookie_secure: false in config.yaml.

Security notes

  • Sessions are stateless JWTs (HS256). No per-request database lookup is needed to authenticate a request. Every issued token carries a jti, so a revocation/blacklist table can be added later without changing the token format or middleware signature; none exists yet.
  • Password hashes use argon2id with OWASP-recommended parameters (19 MiB memory, 2 iterations, 1 degree of parallelism), stored in a self-describing format so cost parameters can change later without invalidating existing hashes.
  • Login responses are uniform for a nonexistent username and a wrong password, and a dummy hash is verified when the username doesn't exist, so response timing doesn't reveal whether an account exists.