- Go 82.9%
- HTML 10%
- CSS 3.4%
- JavaScript 2.5%
- Shell 0.8%
- Other 0.4%
|
All checks were successful
Test / test (push) Successful in 46s
This reverts commit
|
||
|---|---|---|
| .claude/skills | ||
| .forgejo/workflows | ||
| cmd/server | ||
| features | ||
| internal | ||
| scripts | ||
| tools/frontend | ||
| .gitignore | ||
| config.example.yaml | ||
| Dockerfile | ||
| Dockerfile.release | ||
| go.mod | ||
| go.sum | ||
| Makefile | ||
| README.md | ||
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) andadmin(can start and stop). - Login sessions are JWTs carried in an
HttpOnly/Securecookie. - 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_valuecontrol 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_labelis 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.passwordaccept inline values for local development.auth.jwt_secret_env/admin.password_envname 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.passwordseed the initial admin account on first startup. If the account already exists, it is left untouched on subsequent restarts unlessadmin.force_reset: true.database.driverselectssqlite(default) orpostgres.database.passwordaccepts an inline value;database.password_filereads it from a file instead (e.g. a Docker/Kubernetes secret) and takes precedence if both are set.database.sslmodehas 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.