feat(runtime): add configurable game server uid gid
CI / validate (pull_request) Successful in 26m27s

This commit is contained in:
2026-08-25 22:57:23 +02:00
parent 51b6e5da34
commit 368ae918a8
27 changed files with 382 additions and 53 deletions
+4
View File
@@ -31,6 +31,10 @@ container:
image: didstopia/vrising-server
tag: latest
user_mode: image
runtime_user:
mode: environment
uid_env: PUID
gid_env: PGID
stop_timeout_seconds: 120
capabilities:
add:
+3 -2
View File
@@ -26,6 +26,7 @@ Read this compact operational baseline before starting a milestone. Open detaile
- Backup scheduling/retention, safe imports, export and restore with safety backups.
- Sandboxed WASM runtime and normalized module API with Palworld reference adapter.
- Game-container configuration: global and per-instance labels, safe label variables, derived instance slug, immutable Docker-user selection, tracked/pinned image tags, immediate or deferred container recreation, and public game-logo/artwork routes.
- Administration stores persistent game-container UID/GID defaults (1000:1000) with decimal uint32 validation. Managed templates use them as Docker `User`; `user_mode: image` is authoritative and omits Docker `User`. Templates can map the values to declared runtime environment variables; V Rising uses `PUID`/`PGID` while retaining its root entrypoint and capabilities.
- Controlled digest-aware game updates with confirmation, policy-driven pre-update backups, readiness verification, mod warnings and automatic container-plan rollback.
- Redacted configuration history retained to the latest 10 revisions, with pinned-template revalidation and immediate or deferred rollback.
- Declarative Steam Workshop item configuration with numeric-ID validation, stable ordering and backend `mods.manage` enforcement.
@@ -46,7 +47,7 @@ Read this compact operational baseline before starting a milestone. Open detaile
- Service-owned persistent secrets: the agent atomically creates and validates its mode-`0640` shared token in the internal `agent_auth` volume; the application mounts only that secret directory read-only and independently creates and validates its mode-`0600` master key below the application data path. The application tolerates concurrent first start by waiting up to 60 seconds for the token and authenticated agent health.
- The agent token is exactly 32 opaque random bytes. Readers preserve terminal carriage-return and newline byte values instead of treating the secret as text.
- Two service networks: an administrator-named application/reverse-proxy network plus a private Compose control network. The agent safely ensures the fixed `DOGAMA_GAMES_NETWORK` exists and applies it to every game-container create or replacement; it is not caller-selectable through the lifecycle API.
- Portable fresh bind-mount startup uses root identities inside the read-only, capability-free container namespaces and a private process umask. No recursive ownership change is performed; game-container UID/GID remains per-instance configuration.
- Portable fresh bind-mount startup uses root identities inside the read-only, capability-free container namespaces and a private process umask. No recursive ownership change is performed; game-container UID/GID is controlled by Administration only for managed templates.
- Gitea CI for pull requests and `main`, plus tag-only multi-architecture image publication and Gitea Release creation.
## Durable decisions
@@ -60,7 +61,7 @@ Read this compact operational baseline before starting a milestone. Open detaile
- Template game artwork contains separate required local `logo` and horizontal `image` assets; template validation rejects missing files. Deployment previews expose distinct logo and artwork URLs while retaining `icon_url` as a compatible logo alias. Palworld template `1.1.0` is the first snapshot with this contract.
- Embedded catalog validation is collection-based: every discovered template is schema- and cross-field-validated, including referenced assets, declared template-local integration modules and ports/configuration. A module bundle lives at `<template>/module/`, is declared by `module.path`, is path-confined and is retained with the selected snapshot; no game-specific internal registry exists. Template asset contents are not SHA-256-pinned, allowing administrator-maintained local assets while preserving required-path validation. A template `version` identifies a DoGaMa snapshot, whereas `container.tag` selects the game-server image; the official Palworld template follows Pocketpair's `latest` tag for new deployments and ordinary pulls.
- Instance slugs are derived from the display name, not canonical IDs. Accents are normalized to ASCII; whitespace, `/`, punctuation and special characters become safe hyphen separators; repeated and edge hyphens are removed.
- Docker user mode is fixed at creation to DoGaMa UID/GID, custom numeric UID/GID, or image-defined user. Never perform automatic recursive ownership changes.
- Docker user mode is fixed at creation to the administrator's managed game-container UID/GID or image-defined user. Image-defined templates cannot be overridden. Never perform automatic recursive ownership changes.
- A pinned image tag is an explicit mutable tag, not an immutable digest. Tracked mode follows the template's declared default tag.
- Replacement-requiring changes use the generic `container_config_pending` desired-versus-applied state. Replacements preserve bind-mounted data and prior running/stopped intent.
- The main app never gains Docker-socket access; the agent remains deny-by-default and independently validates privileged plan fields.
+2 -1
View File
@@ -70,7 +70,8 @@ Before create or replace, the agent verifies:
- resource limits are present and within administrator limits;
- labels use the reserved namespace and cannot be overridden;
- custom labels are bounded, may not use either `dogama.*` or the internal `io.dogama.*` namespace, and are merged before immutable technical labels;
- the optional Docker `User` is either an already validated numeric `UID:GID` value or omitted so the image `USER` applies;
- the optional Docker `User` is either an already validated numeric `UID:GID` value for a managed template or omitted when `user_mode: image` applies; image mode is enforced by the agent and cannot be overridden;
- template-declared runtime identity environment mappings are allow-listed and may carry only the administrator's validated game-container UID/GID;
- the configured deployment-wide game network is attached; the request schema has no network field and unknown fields are rejected.
The canonical plan digest alone is not treated as approval. The agent embeds and
+1 -1
View File
@@ -31,7 +31,7 @@ SQLite is authoritative for product state. Runtime Docker state is reconciled in
| `notification_channels` | Global delivery configuration | id, type, enabled, encrypted_config, event_filter |
| `notification_deliveries` | Bounded retry queue | id, channel_id, event_type, payload_redacted, attempt, next_attempt_at |
| `audit_events` | Compact significant actions | id, occurred_at, actor_id, instance_id, action, outcome, summary_json |
| `system_settings` | Admin-configured global values | key, value_json, revision |
| `system_settings` | Admin-configured global values | key, value_json, revision; includes separate game-container UID/GID defaults |
## Invariants
+1 -1
View File
@@ -51,7 +51,7 @@ Maintenance mode blocks ordinary user starts and shows an administrator message
Docker label and image-tag changes use the same generic desired-versus-applied mechanism. `immediate` stops and replaces the container, restores its prior running/stopped intent and preserves every bind-mounted data path. `next_start` sets `container_config_pending`; the next explicit start pulls the desired image, replaces the container, clears the flag and starts it. A stopped instance remains stopped during immediate replacement.
The Docker user is selected at creation (`dogama`, `custom`, or image-defined) and is never editable afterward because changing it could invalidate persistent-file permissions. Administrators must use backup, new-instance creation and restore to change ownership deliberately; DoGaMa never performs automatic recursive `chown`.
The Docker user is selected at creation from the template policy. Managed (`dogama`) templates use the persistent Administration game-container UID/GID defaults; image-defined templates omit Docker `User` and cannot be overridden. The selection is never editable afterward because changing it could invalidate persistent-file permissions. Administrators must use backup, new-instance creation and restore to change ownership deliberately; DoGaMa never performs automatic recursive `chown`.
## Crash-loop protection
+2 -2
View File
@@ -19,11 +19,11 @@ The application data path contains SQLite, import staging and the application-on
Only host-side storage locations, image version, web port, timezone and the two Docker network names are public Compose settings. `DOGAMA_NETWORK` names the application-facing network used by a reverse proxy. `DOGAMA_GAMES_NETWORK` names the sole network that the restricted agent attaches to created and recreated game containers. API plans contain no caller-selectable network. Container paths and allowed agent roots remain fixed internal contracts. Back up the application data, game servers, backups, `agent_state` and `agent_auth` together.
Both services use root inside their container namespaces so Docker-created bind directories and ordinary administrator-selected paths work without knowledge of an image-specific UID/GID. They keep read-only root filesystems, `no-new-privileges`, an empty Linux capability set and a `0077` process umask for new files. The main application never receives the Docker socket. DoGaMa creates only directories it needs below the configured roots and never performs an automatic recursive `chown` of application, server or backup data. Game-container UID/GID selection remains a separate per-instance setting.
Both services use root inside their container namespaces so Docker-created bind directories and ordinary administrator-selected paths work without knowledge of an image-specific UID/GID. They keep read-only root filesystems, `no-new-privileges`, an empty Linux capability set and a `0077` process umask for new files. The main application never receives the Docker socket. DoGaMa creates only directories it needs below the configured roots and never performs an automatic recursive `chown` of application, server or backup data. Game-container UID/GID defaults are Administration settings and apply only to templates that opt into managed identity; image-defined templates retain their native user.
For NAS or server-style paths, set ordinary writable locations in `.env`, for example `/srv/apps/dogama/data`, `/srv/games` and `/srv/backups`, then run `docker compose up -d`. `/var/lib/dogama/templates` is inside `DOGAMA_DATA_PATH`, so it persists as `${DOGAMA_DATA_PATH}/templates` and stays available to Catalog Scan. No `/etc` or host `/var/lib` setup, system user, systemd unit or bootstrap script is required.
The containers intentionally run as root only inside their own namespaces so fresh bind mounts work without host-specific UID/GID settings. Their root filesystems are read-only, all capabilities are dropped, and their private `0077` umask makes newly created data private by default. Host paths must be writable by the Docker daemon; do not recursively change ownership, because game-container UID/GID remains a per-instance choice. Set `TZ` once to a valid IANA timezone (the example uses `Europe/Paris`); it is used by both services and by Audit rendering.
The containers intentionally run as root only inside their own namespaces so fresh bind mounts work without host-specific UID/GID settings. Their root filesystems are read-only, all capabilities are dropped, and their private `0077` umask makes newly created data private by default. Host paths must be writable by the Docker daemon; do not recursively change ownership. Set `TZ` once to a valid IANA timezone (the example uses `Europe/Paris`); it is used by both services and by Audit rendering.
`docker compose down` removes containers and Compose networks while preserving bind mounts and named volumes. `docker compose down -v` also destroys the `agent_state` and `agent_auth` named volumes; it can make existing managed containers impossible to manage safely and must not be used for a retained installation.
+1 -1
View File
@@ -106,7 +106,7 @@ glance.parent=DoGaMa
`{{game.icon_url}}` resolves to the unauthenticated, read-only `/public/game-icons/{game-id}` route. The route serves only embedded reviewed raster content with an explicit MIME type and cache policy; it is not a public catalog or administration API.
At creation, the Docker user is either the DoGaMa process UID/GID (default), an explicitly validated numeric UID/GID, or omitted to use the image-defined user. An image without `USER` may therefore run as root. The selection is immutable after creation.
Administration stores separate decimal game-container defaults for UID and GID (1000:1000 initially, bounded to Linux's uint32 range). A template with the managed `user_mode: dogama` policy receives those values as Docker `User`; DoGaMa and its agent are never affected. A template with `user_mode: image` always omits Docker `User`, even if an API caller requests an override, so the image's native `USER` and entrypoint remain authoritative. Templates may instead declare a generic `runtime_user` environment mapping; V Rising uses this to receive its administrator defaults as `PUID`/`PGID` while retaining its root entrypoint and declared capabilities.
The template's declared tag is the `tracked` default. An administrator may instead select a syntactically validated `pinned` tag and later return to tracked mode. Pinned means an explicitly selected mutable tag, not a digest: publishers can republish the same tag. Manual SHA-256 digest management is outside this milestone.
+10
View File
@@ -81,6 +81,12 @@ func (p *PlanPolicy) Validate(plan agentwire.DeploymentPlan) error {
if !strings.HasPrefix(plan.Image, imagePrefix) || !reflect.DeepEqual(plan.Entrypoint, template.Container.Entrypoint) || !reflect.DeepEqual(plan.CapAdd, template.Container.Capabilities.Add) || plan.StopTimeoutSeconds != template.Container.StopTimeoutSeconds {
return errors.New("container plan differs from template")
}
if template.Container.UserMode == "image" && plan.User != "" {
return errors.New("image user template cannot receive a Docker user")
}
if template.Container.UserMode == "dogama" && plan.User == "" {
return errors.New("managed user template requires a Docker user")
}
if len(plan.Arguments) < len(template.Container.Arguments) || !reflect.DeepEqual(plan.Arguments[:len(template.Container.Arguments)], template.Container.Arguments) || !allowedArguments(template, plan.Arguments[len(template.Container.Arguments):]) || !allowedEnvironment(template, plan.Environment) {
return errors.New("container configuration differs from template")
}
@@ -118,6 +124,10 @@ func allowedEnvironment(template catalog.Template, environment map[string]string
for key := range template.Container.Environment {
allowed[key] = true
}
if template.Container.RuntimeUser.Mode == "environment" {
allowed[template.Container.RuntimeUser.UIDEnv] = true
allowed[template.Container.RuntimeUser.GIDEnv] = true
}
for _, field := range template.Configuration.Fields {
if field.Target.Kind == "environment" {
allowed[field.Target.Name] = true
+28 -4
View File
@@ -63,10 +63,15 @@ type Template struct {
Recommended Resources `json:"recommended"`
} `json:"requirements"`
Container struct {
Image string `json:"image"`
Tag string `json:"tag"`
Entrypoint []string `json:"entrypoint,omitempty"`
UserMode string `json:"user_mode,omitempty"`
Image string `json:"image"`
Tag string `json:"tag"`
Entrypoint []string `json:"entrypoint,omitempty"`
UserMode string `json:"user_mode,omitempty"`
RuntimeUser struct {
Mode string `json:"mode,omitempty"`
UIDEnv string `json:"uid_env,omitempty"`
GIDEnv string `json:"gid_env,omitempty"`
} `json:"runtime_user,omitempty"`
Arguments []string `json:"arguments,omitempty"`
Environment map[string]string `json:"environment,omitempty"`
Capabilities struct {
@@ -497,6 +502,13 @@ func crossValidate(template Template, assetRoot string, source fs.FS) []Validati
issues = append(issues, ValidationIssue{Path: "/integration/port_id", Message: "integration must reference an integration port"})
}
}
if template.Container.RuntimeUser.Mode == "environment" {
if template.Container.UserMode != "image" || !validRuntimeEnvironmentName(template.Container.RuntimeUser.UIDEnv) || !validRuntimeEnvironmentName(template.Container.RuntimeUser.GIDEnv) || template.Container.RuntimeUser.UIDEnv == template.Container.RuntimeUser.GIDEnv {
issues = append(issues, ValidationIssue{Path: "/container/runtime_user", Message: "environment runtime user requires distinct safe UID/GID variables and image user mode"})
}
} else if template.Container.RuntimeUser.Mode != "" && template.Container.RuntimeUser.Mode != "docker" {
issues = append(issues, ValidationIssue{Path: "/container/runtime_user/mode", Message: "unknown runtime user mode"})
}
if template.Integration != nil && template.Module == nil {
issues = append(issues, ValidationIssue{Path: "/module", Message: "integration requires a template-local module"})
}
@@ -521,6 +533,18 @@ func crossValidate(template Template, assetRoot string, source fs.FS) []Validati
return issues
}
func validRuntimeEnvironmentName(value string) bool {
if value == "" || strings.HasPrefix(value, "DOGAMA_") || value == "PATH" || value == "HOME" || value == "HOSTNAME" || value == "DOCKER_HOST" {
return false
}
for index, character := range value {
if (character < 'A' || character > 'Z') && (character < 'a' || character > 'z') && (index == 0 || character < '0' || character > '9') && character != '_' {
return false
}
}
return true
}
func collectModuleFiles(template Template, assetRoot string, source fs.FS) (map[string][]byte, []ValidationIssue) {
if template.Module == nil {
return nil, nil
+2
View File
@@ -9,6 +9,8 @@ import (
)
type ConfigurationRepository interface {
GetGameContainerRuntimeIdentity(context.Context) (RuntimeIdentity, error)
SetGameContainerRuntimeIdentity(context.Context, RuntimeIdentity) error
GetGlobalLabels(context.Context) (map[string]string, error)
SetGlobalLabels(context.Context, map[string]string, bool) (affected int, running int, err error)
SaveInstanceConfiguration(context.Context, string, Preview, bool, string, string) error
+7 -6
View File
@@ -14,11 +14,12 @@ import (
// configuration. It is persisted with the preview so a recreated container is
// built identically. Secret mutations retain only their field identifier.
type ResolvedConfiguration struct {
Environment map[string]string `json:"environment,omitempty"`
Arguments []string `json:"arguments,omitempty"`
INI []INIMutation `json:"ini,omitempty"`
SecretEnvironment map[string]string `json:"secret_environment,omitempty"`
SecretArguments []SecretArgument `json:"secret_arguments,omitempty"`
Environment map[string]string `json:"environment,omitempty"`
RuntimeEnvironment map[string]string `json:"runtime_environment,omitempty"`
Arguments []string `json:"arguments,omitempty"`
INI []INIMutation `json:"ini,omitempty"`
SecretEnvironment map[string]string `json:"secret_environment,omitempty"`
SecretArguments []SecretArgument `json:"secret_arguments,omitempty"`
}
type SecretArgument struct {
@@ -39,7 +40,7 @@ type INIMutation struct {
var protectedEnvironment = map[string]bool{"PATH": true, "HOME": true, "HOSTNAME": true, "DOCKER_HOST": true, "DOGAMA_INSTANCE_ID": true}
func ResolveConfiguration(template catalog.Template, values map[string]string) (ResolvedConfiguration, error) {
result := ResolvedConfiguration{Environment: make(map[string]string), SecretEnvironment: make(map[string]string)}
result := ResolvedConfiguration{Environment: make(map[string]string), RuntimeEnvironment: make(map[string]string), SecretEnvironment: make(map[string]string)}
for key, value := range template.Container.Environment {
result.Environment[key] = value
}
+26
View File
@@ -20,6 +20,32 @@ const (
ImageTagPinned = "pinned"
)
const DefaultGameContainerUID uint32 = 1000
const DefaultGameContainerGID uint32 = 1000
// RuntimeIdentity is the administrator-controlled identity for managed game
// containers. It never applies to DoGaMa's own containers.
type RuntimeIdentity struct {
UID uint32 `json:"uid"`
GID uint32 `json:"gid"`
}
func (identity RuntimeIdentity) Validate() error {
// uint32 is the Linux kernel's numeric UID/GID range.
return nil
}
func ParseRuntimeID(value string) (uint32, error) {
if value == "" {
return 0, errors.New("UID/GID is required")
}
parsed, err := strconv.ParseUint(value, 10, 32)
if err != nil {
return 0, errors.New("UID/GID must be a decimal Linux identifier between 0 and 4294967295")
}
return uint32(parsed), nil
}
var (
labelKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*(?:/[A-Za-z0-9][A-Za-z0-9_.-]*)?$`)
tagPattern = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$`)
@@ -2,6 +2,19 @@ package instance
import "testing"
func TestParseRuntimeID(t *testing.T) {
for _, value := range []string{"0", "1000", "65534", "4294967295"} {
if _, err := ParseRuntimeID(value); err != nil {
t.Fatalf("%q rejected: %v", value, err)
}
}
for _, value := range []string{"", "-1", "abc", "1000:1000", "1.5", "4294967296"} {
if _, err := ParseRuntimeID(value); err == nil {
t.Fatalf("%q unexpectedly accepted", value)
}
}
}
func TestLabelsAndVariables(t *testing.T) {
labels, err := ParseLabels("\n glance.name={{instance.name}}\nquery=a=b=c\n")
if err != nil || labels["query"] != "a=b=c" {
+23 -15
View File
@@ -35,6 +35,7 @@ type PreviewRequest struct {
PublicBaseURL string `json:"-"`
Configuration map[string]string `json:"configuration,omitempty"`
Secrets map[string]string `json:"-"`
RuntimeIdentity *RuntimeIdentity `json:"-"`
}
type Preview struct {
@@ -159,11 +160,12 @@ func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, e
if err != nil {
return Preview{}, err
}
if request.DockerUser.Mode == "" {
// The template policy is authoritative. In particular, an image-defined
// USER can never be replaced by an administrator or API caller.
if snapshot.Template.Container.UserMode == DockerUserImage {
request.DockerUser = DockerUser{Mode: DockerUserImage}
} else if request.DockerUser.Mode == "" {
request.DockerUser.Mode = DockerUserDoGaMa
if snapshot.Template.Container.UserMode == DockerUserImage {
request.DockerUser.Mode = DockerUserImage
}
}
if err := ValidateDockerUser(request.DockerUser); err != nil {
return Preview{}, err
@@ -172,11 +174,14 @@ func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, e
if err != nil {
return Preview{}, err
}
uid, gid, err := currentUIDGID()
if err != nil && request.DockerUser.Mode == DockerUserDoGaMa {
identity := RuntimeIdentity{UID: DefaultGameContainerUID, GID: DefaultGameContainerGID}
if request.RuntimeIdentity != nil {
identity = *request.RuntimeIdentity
}
if err := identity.Validate(); err != nil {
return Preview{}, err
}
userValue, err := DockerUserValue(request.DockerUser, uid, gid)
userValue, err := DockerUserValue(request.DockerUser, identity.UID, identity.GID)
if err != nil {
return Preview{}, err
}
@@ -215,6 +220,13 @@ func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, e
if err != nil {
return Preview{}, err
}
runtimeEnvironment, err := RuntimeUserEnvironment(snapshot.Template, identity)
if err != nil {
return Preview{}, err
}
for key, value := range runtimeEnvironment {
resolved.RuntimeEnvironment[key] = value
}
resources := request.Resources
if resources.CPUCores == 0 {
resources = snapshot.Template.Requirements.Recommended
@@ -338,14 +350,7 @@ func (p Preview) deploymentPlan(instanceID string, secrets map[string]string) (a
p.DockerUser.Mode = DockerUserDoGaMa
}
if p.DockerUserValue == "" && p.DockerUser.Mode == DockerUserDoGaMa {
uid, gid, userErr := currentUIDGID()
if userErr != nil {
return agentwire.DeploymentPlan{}, userErr
}
p.DockerUserValue, userErr = DockerUserValue(p.DockerUser, uid, gid)
if userErr != nil {
return agentwire.DeploymentPlan{}, userErr
}
return agentwire.DeploymentPlan{}, errors.New("managed Docker user is missing")
}
context := LabelContext{GameName: p.Game.Name, GameID: p.Game.ID, GameIconURL: p.Game.IconURL, InstanceName: p.DisplayName, InstanceID: instanceID, InstanceSlug: p.Slug, ServerName: p.DisplayName}
global, err := ResolveLabels(p.GlobalLabels, context)
@@ -360,6 +365,9 @@ func (p Preview) deploymentPlan(instanceID string, secrets map[string]string) (a
for key, value := range p.ResolvedConfiguration.Environment {
environment[key] = value
}
for key, value := range p.ResolvedConfiguration.RuntimeEnvironment {
environment[key] = value
}
arguments := append([]string(nil), p.Arguments...)
arguments = append(arguments, p.ResolvedConfiguration.Arguments...)
for key, id := range p.ResolvedConfiguration.SecretEnvironment {
+36 -1
View File
@@ -64,12 +64,47 @@ func TestBuildPreviewUsesTemplateImageUserUnlessAdministratorSelectsOne(t *testi
t.Fatalf("image-mode plan user=%q error=%v", plan.User, err)
}
request.DockerUser.Mode = instance.DockerUserDoGaMa
request.RuntimeIdentity = &instance.RuntimeIdentity{UID: 1234, GID: 5678}
explicit, err := instance.BuildPreview(snapshots[0], request)
if err != nil || explicit.DockerUser.Mode != instance.DockerUserDoGaMa || explicit.DockerUserValue == "" {
if err != nil || explicit.DockerUser.Mode != instance.DockerUserImage || explicit.DockerUserValue != "" {
t.Fatalf("explicit user preview=%#v error=%v", explicit.DockerUser, err)
}
}
func TestBuildPreviewUsesManagedIdentityAndVRisingEnvironmentMapping(t *testing.T) {
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
if err != nil {
t.Fatal(err)
}
vrising := snapshotByID(t, snapshots, "vrising-didstopia")
identity := instance.RuntimeIdentity{UID: 1234, GID: 5678}
preview, err := instance.BuildPreview(vrising, instance.PreviewRequest{DisplayName: "V Rising", HostPorts: map[string]int{"game": 38000, "query": 38001}, MountPaths: map[string]string{"persistent": filepath.Join(t.TempDir(), "persistent"), "server": filepath.Join(t.TempDir(), "server")}, DataOrigin: "new", BackupRetention: 7, RuntimeIdentity: &identity})
if err != nil {
t.Fatal(err)
}
plan, err := preview.DeploymentPlan("abcdefghijklmnopqrstuvwx")
if err != nil {
t.Fatal(err)
}
if plan.User != "" || plan.Environment["PUID"] != "1234" || plan.Environment["PGID"] != "5678" {
t.Fatalf("V Rising runtime identity = user %q env %#v", plan.User, plan.Environment)
}
if len(plan.CapAdd) != 5 {
t.Fatalf("V Rising capabilities changed: %#v", plan.CapAdd)
}
managed := vrising
managed.Template.Container.UserMode = instance.DockerUserDoGaMa
managed.Template.Container.RuntimeUser.Mode = "docker"
managedPreview, err := instance.BuildPreview(managed, instance.PreviewRequest{DisplayName: "Managed", HostPorts: map[string]int{"game": 38002, "query": 38003}, MountPaths: map[string]string{"persistent": filepath.Join(t.TempDir(), "persistent"), "server": filepath.Join(t.TempDir(), "server")}, DataOrigin: "new", BackupRetention: 7, RuntimeIdentity: &identity})
if err != nil {
t.Fatal(err)
}
managedPlan, err := managedPreview.DeploymentPlan("abcdefghijklmnopqrstuvwx")
if err != nil || managedPlan.User != "1234:5678" {
t.Fatalf("managed runtime user = %q error=%v", managedPlan.User, err)
}
}
func TestBuildPreviewRejectsPrivatePortPublicationAndLowResources(t *testing.T) {
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
if err != nil {
@@ -1,9 +0,0 @@
//go:build unix
package instance
import "golang.org/x/sys/unix"
func currentUIDGID() (uint32, uint32, error) {
return uint32(unix.Getuid()), uint32(unix.Getgid()), nil
}
@@ -1,8 +0,0 @@
//go:build windows
package instance
// Windows is a development/test host only; Linux deployment resolves the real process identity.
func currentUIDGID() (uint32, uint32, error) {
return 1000, 1000, nil
}
+28
View File
@@ -0,0 +1,28 @@
package instance
import (
"fmt"
"strconv"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
)
// RuntimeUserEnvironment maps the global managed identity through the
// template-declared environment contract. It deliberately does not know any
// game-specific variable names.
func RuntimeUserEnvironment(template catalog.Template, identity RuntimeIdentity) (map[string]string, error) {
result := map[string]string{}
runtimeUser := template.Container.RuntimeUser
if runtimeUser.Mode == "" || runtimeUser.Mode == "docker" {
return result, nil
}
if runtimeUser.Mode != "environment" || template.Container.UserMode != DockerUserImage {
return nil, fmt.Errorf("invalid runtime user policy")
}
if runtimeUser.UIDEnv == "" || runtimeUser.GIDEnv == "" || runtimeUser.UIDEnv == runtimeUser.GIDEnv {
return nil, fmt.Errorf("invalid runtime user environment mapping")
}
result[runtimeUser.UIDEnv] = strconv.FormatUint(uint64(identity.UID), 10)
result[runtimeUser.GIDEnv] = strconv.FormatUint(uint64(identity.GID), 10)
return result, nil
}
@@ -12,6 +12,53 @@ import (
)
const globalLabelsKey = "game_container_labels"
const gameContainerUIDKey = "game_container_uid"
const gameContainerGIDKey = "game_container_gid"
func (r *Repository) GetGameContainerRuntimeIdentity(ctx context.Context) (instance.RuntimeIdentity, error) {
identity := instance.RuntimeIdentity{UID: instance.DefaultGameContainerUID, GID: instance.DefaultGameContainerGID}
for _, setting := range []struct {
key string
value *uint32
}{{gameContainerUIDKey, &identity.UID}, {gameContainerGIDKey, &identity.GID}} {
var body string
err := r.db.QueryRowContext(ctx, `SELECT value_json FROM system_settings WHERE key=?`, setting.key).Scan(&body)
if errors.Is(err, sql.ErrNoRows) {
continue
}
if err != nil {
return instance.RuntimeIdentity{}, fmt.Errorf("load game-container runtime identity: %w", err)
}
var value uint64
if err := json.Unmarshal([]byte(body), &value); err != nil || value > ^uint64(0)>>32 {
return instance.RuntimeIdentity{}, errors.New("stored game-container runtime identity is invalid")
}
*setting.value = uint32(value)
}
return identity, nil
}
func (r *Repository) SetGameContainerRuntimeIdentity(ctx context.Context, identity instance.RuntimeIdentity) error {
if err := identity.Validate(); err != nil {
return err
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
now := r.now().UTC().Format(time.RFC3339Nano)
for _, setting := range []struct {
key string
value uint32
}{{gameContainerUIDKey, identity.UID}, {gameContainerGIDKey, identity.GID}} {
body := string(mustJSON(setting.value))
if _, err := tx.ExecContext(ctx, `INSERT INTO system_settings(key,value_json,revision,updated_at) VALUES(?,?,1,?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json, revision=system_settings.revision+1, updated_at=excluded.updated_at`, setting.key, body, now); err != nil {
return err
}
}
return tx.Commit()
}
func (r *Repository) GetGlobalLabels(ctx context.Context) (map[string]string, error) {
var body string
@@ -0,0 +1,37 @@
package sqlite_test
import (
"context"
"path/filepath"
"testing"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
)
func TestGameContainerRuntimeIdentityDefaultsAndPersists(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "dogama.db")
db, err := sqlite.Open(ctx, path)
if err != nil {
t.Fatal(err)
}
repository := sqlite.NewRepository(db)
identity, err := repository.GetGameContainerRuntimeIdentity(ctx)
if err != nil || identity != (instance.RuntimeIdentity{UID: 1000, GID: 1000}) {
t.Fatalf("defaults = %#v error=%v", identity, err)
}
if err := repository.SetGameContainerRuntimeIdentity(ctx, instance.RuntimeIdentity{UID: 1234, GID: 5678}); err != nil {
t.Fatal(err)
}
_ = db.Close()
db, err = sqlite.Open(ctx, path)
if err != nil {
t.Fatal(err)
}
defer db.Close()
identity, err = sqlite.NewRepository(db).GetGameContainerRuntimeIdentity(ctx)
if err != nil || identity != (instance.RuntimeIdentity{UID: 1234, GID: 5678}) {
t.Fatalf("persisted = %#v error=%v", identity, err)
}
}
+4
View File
@@ -285,3 +285,7 @@ CREATE INDEX audit_events_action_time_idx ON audit_events(action, occurred_at DE
INSERT INTO system_state (singleton, bootstrap_completed_at) VALUES (1, NULL);
INSERT INTO system_settings(key, value_json, revision, updated_at)
VALUES ('audit_policy', '{"retention_days":30,"maximum_count":10000}', 1, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
INSERT INTO system_settings(key, value_json, revision, updated_at)
VALUES ('game_container_uid', '1000', 1, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
INSERT INTO system_settings(key, value_json, revision, updated_at)
VALUES ('game_container_gid', '1000', 1, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
+1
View File
@@ -16,6 +16,7 @@ var messages = map[string]map[string]string{
"dashboard.title": "Dashboard", "dashboard.eyebrow": "Dashboard", "dashboard.heading": "Game servers", "dashboard.introduction": "Overview of all your game server instances.", "dashboard.search": "Search instances", "dashboard.search_placeholder": "Search instance", "dashboard.summary": "Instance summary", "dashboard.total": "Total instances", "dashboard.running": "Running", "dashboard.stopped": "Stopped", "dashboard.updating": "Updating", "dashboard.error": "Error", "dashboard.no_match": "No matching instance", "dashboard.empty_heading": "No instances deployed", "dashboard.empty_copy": "Your game servers will appear here when they are added from the Catalog.", "dashboard.instances_eyebrow": "Servers", "dashboard.instances": "Your instances", "dashboard.activity_eyebrow": "Operations", "dashboard.recent_activity": "Recent activity", "dashboard.no_activity": "No recent activity", "dashboard.system_eyebrow": "Health", "dashboard.system_status": "System status", "dashboard.agent": "Docker agent", "dashboard.database": "Database", "dashboard.storage": "Storage", "dashboard.backups": "Backups", "dashboard.audit_log": "Audit log", "dashboard.online": "Online", "dashboard.offline": "Offline", "dashboard.healthy": "Healthy", "dashboard.unavailable": "Unavailable", "dashboard.last_backup": "Last backup", "dashboard.no_backup": "No backup", "dashboard.retention_days": "days retention", "dashboard.unlimited": "Unlimited retention", "dashboard.by": "by",
"catalog.title": "Catalog", "catalog.eyebrow": "Game library", "catalog.heading": "Catalog", "catalog.search": "Search games", "catalog.search_placeholder": "Search a game", "catalog.scan": "Scan", "catalog.found": "templates found", "catalog.valid": "valid", "catalog.invalid": "invalid", "catalog.no_match": "No matching game", "catalog.empty": "No game available", "catalog.empty_admin": "Add templates to /var/lib/dogama/templates, then use Scan.", "catalog.back": "Back to catalog", "catalog.minimum": "Minimum", "catalog.recommended": "Recommended", "catalog.memory": "Memory", "catalog.storage": "Storage", "catalog.other": "Other", "catalog.deploy": "Deploy", "catalog.deploy_unavailable": "Deployment will be available in the next milestone.",
"deployment.title": "Deploy", "deployment.eyebrow": "New instance", "deployment.heading": "Configure your server", "deployment.name": "Instance name", "deployment.description": "Instance description", "deployment.parameters": "Game parameters", "deployment.backup": "Import an external save", "deployment.backup_help": "ZIP, TAR, TAR.GZ or TAR.ZST archives are validated before use.", "deployment.go": "Go",
"settings.game_runtime": "Game server execution", "settings.game_runtime_help": "These defaults apply only to game-server containers.", "settings.game_runtime_uid": "Default UID", "settings.game_runtime_gid": "Default GID", "settings.game_runtime_policy_help": "Templates that allow an administered identity use these values. Templates using the image's native user ignore them.", "settings.save_game_runtime": "Save game server identity",
"settings.title": "Settings", "settings.eyebrow": "Administration", "settings.introduction": "Configure product services without crowding the server overview.", "settings.tabs": "Settings sections", "settings.notifications": "Notifications", "settings.audit": "Audit", "settings.containers": "Game containers", "settings.web_access": "Web access", "settings.require_https": "Require HTTPS", "settings.canonical_url": "Canonical base URL", "settings.web_help": "Configure HTTPS enforcement and the public origin. Configure and verify your HTTPS reverse proxy before enabling this lock.", "settings.save_web": "Save web access", "settings.canonical_help": "Verify the canonical URL works before saving it.", "settings.https_help": "When enabled, unsafe HTTP requests are refused and safe navigation is redirected to HTTPS.",
"settings.channels": "Notification channels", "settings.channels_help": "Secrets remain encrypted and are never displayed after saving.", "settings.no_channels": "No channel configured.", "settings.send_test": "Send test", "settings.delete": "Delete", "settings.add_channel": "Add channel", "settings.name": "Name", "settings.type": "Type", "settings.enabled": "enabled", "settings.disabled": "disabled", "settings.events": "Events (space separated)", "settings.audit_retention": "Audit retention", "settings.audit_help": "Control history size and perform explicit bounded purges.", "settings.view_audit": "View audit events", "settings.retention_days": "Retention days", "settings.maximum_entries": "Maximum entries", "settings.zero_unlimited": "Zero means unlimited and may grow the database indefinitely.", "settings.save_retention": "Save retention", "settings.delete_before": "Delete events before", "settings.confirm_purge": "Confirm bounded audit purge", "settings.purge": "Purge audit events", "settings.container_labels": "Game-container labels", "settings.container_labels_help": "These labels apply only to game-server containers.", "settings.global_labels": "Global labels", "settings.apply": "Application", "settings.next_start": "Apply on next start", "settings.immediate": "Apply immediately", "settings.disconnection": "Immediate application stops and recreates affected containers.", "settings.confirm_disconnection": "I understand the immediate-disconnection warning", "settings.save_labels": "Save game-container labels",
"audit.title": "Audit", "audit.eyebrow": "Administration", "audit.introduction": "Review significant authentication and mutation events.", "audit.actor": "Actor ID", "audit.instance": "Instance ID", "audit.action": "Action", "audit.outcome": "Outcome", "audit.any": "Any", "audit.allowed": "Allowed", "audit.denied": "Denied", "audit.failed": "Failed", "audit.filter": "Filter audit", "audit.time": "Time", "audit.actor_column": "Actor", "audit.instance_column": "Instance", "audit.empty": "No audit event matches these filters.",
+25 -1
View File
@@ -93,6 +93,8 @@ type pageData struct {
Error string
User auth.User
GlobalLabels string
GameContainerUID uint32
GameContainerGID uint32
IsAdmin bool
Channels []notification.Channel
NotificationPreferences map[string]bool
@@ -362,6 +364,7 @@ func newHandlerServicesWithCatalog(authService *auth.Service, repository reposit
mux.HandleFunc("POST /logout", s.logout)
mux.HandleFunc("POST /admin/game-container-labels", s.globalLabelsForm)
mux.HandleFunc("POST /admin/web-access", s.webAccessForm)
mux.HandleFunc("POST /admin/game-container-runtime", s.gameContainerRuntimeForm)
mux.HandleFunc("POST /admin/notification-channels", s.notificationForm)
mux.HandleFunc("POST /admin/notification-language", s.notificationLanguageForm)
mux.HandleFunc("POST /admin/notification-channels/{id}/test", s.notificationTestForm)
@@ -1184,12 +1187,20 @@ func (s *server) buildAPIPreview(w http.ResponseWriter, r *http.Request) (previe
return request, instance.Preview{}, false
}
}
identity := instance.RuntimeIdentity{UID: instance.DefaultGameContainerUID, GID: instance.DefaultGameContainerGID}
if configured, ok := s.repository.(instance.ConfigurationRepository); ok {
identity, err = configured.GetGameContainerRuntimeIdentity(r.Context())
if err != nil {
s.apiProblem(w, http.StatusInternalServerError, "settings_unavailable", "The game-container runtime settings are unavailable.")
return request, instance.Preview{}, false
}
}
preview, err := instance.BuildPreview(snapshot, instance.PreviewRequest{
DisplayName: request.DisplayName, Slug: request.Slug, HostPorts: request.HostPorts,
MountPaths: request.MountPaths, Resources: request.Resources, DataOrigin: request.DataOrigin,
BackupRetention: request.BackupRetention, ImportID: request.ImportID,
CustomLabels: request.CustomLabels, DockerUser: request.DockerUser, ImageTag: request.ImageTag,
PublicBaseURL: requestBaseURL(r),
PublicBaseURL: requestBaseURL(r), RuntimeIdentity: &identity,
})
if err != nil {
s.apiProblem(w, http.StatusUnprocessableEntity, "invalid_preview", "The deployment preview is invalid.")
@@ -1913,6 +1924,15 @@ func (s *server) deploymentSubmit(w http.ResponseWriter, r *http.Request) {
s.render(w, 422, "deployment.html", data)
return
}
if configured, ok := s.repository.(instance.ConfigurationRepository); ok {
identity, identityErr := configured.GetGameContainerRuntimeIdentity(r.Context())
if identityErr != nil {
data.Deployment.Error = "The game-container runtime settings are unavailable."
s.render(w, 500, "deployment.html", data)
return
}
request.RuntimeIdentity = &identity
}
preview, buildErr := instance.BuildPreview(snapshot, request)
if buildErr != nil {
data.Deployment.Error = "Please correct the deployment settings."
@@ -2200,6 +2220,10 @@ func (s *server) settingsPage(w http.ResponseWriter, r *http.Request) {
}
if s.repository != nil {
if configured, ok := s.repository.(instance.ConfigurationRepository); ok {
identity, identityErr := configured.GetGameContainerRuntimeIdentity(r.Context())
if identityErr == nil {
data.GameContainerUID, data.GameContainerGID = identity.UID, identity.GID
}
if labels, labelErr := configured.GetGlobalLabels(r.Context()); labelErr == nil {
data.GlobalLabels = instance.FormatLabels(labels)
}
+9 -1
View File
@@ -386,12 +386,20 @@ func TestNotificationAndAuditAdministration(t *testing.T) {
settingsBody := settings.Body.String()
for _, expected := range []string{
"Notification channels", "Web access", "href=\"#audit\"", "href=\"/audit\"",
"id=\"notifications\"", "id=\"notification-general\"", "id=\"email\"", "id=\"discord\"", "id=\"gotify\"",
"id=\"notifications\"", "id=\"notification-general\"", "id=\"email\"", "id=\"discord\"", "id=\"gotify\"", "id=\"game-runtime\"", "name=\"uid\"", "name=\"gid\"",
} {
if !strings.Contains(settingsBody, expected) {
t.Fatalf("settings UI section %q missing", expected)
}
}
runtimeSave := formRequest(t, handler, "/admin/game-container-runtime", url.Values{"csrf_token": {session.CSRFToken}, "uid": {"1234"}, "gid": {"5678"}}, cookie, &http.Cookie{Name: csrfCookie, Value: session.CSRFToken})
assertStatus(t, runtimeSave, http.StatusSeeOther)
identity, err := repository.GetGameContainerRuntimeIdentity(ctx)
if err != nil || identity != (instance.RuntimeIdentity{UID: 1234, GID: 5678}) {
t.Fatalf("runtime identity = %#v error=%v", identity, err)
}
invalidRuntime := formRequest(t, handler, "/admin/game-container-runtime", url.Values{"csrf_token": {session.CSRFToken}, "uid": {"1000:1000"}, "gid": {"5678"}}, cookie, &http.Cookie{Name: csrfCookie, Value: session.CSRFToken})
assertStatus(t, invalidRuntime, http.StatusUnprocessableEntity)
notificationLanguage := formRequest(t, handler, "/admin/notification-language", url.Values{"csrf_token": {session.CSRFToken}, "language": {"fr"}}, cookie, &http.Cookie{Name: csrfCookie, Value: session.CSRFToken})
assertStatus(t, notificationLanguage, http.StatusSeeOther)
if got := notificationLanguage.Result().Header.Get("Location"); got != "/administration#notifications" {
+14
View File
@@ -46,6 +46,9 @@
<a href="#containers">
{{.Msg "settings.containers"}}
</a>
<a href="#game-runtime">
{{.Msg "settings.game_runtime"}}
</a>
</nav>
<section class="panel settings-section" id="web-access">
<h2>
@@ -313,6 +316,17 @@
</button>
</form>
</section>
<section class="panel settings-section" id="game-runtime">
<h2>{{.Msg "settings.game_runtime"}}</h2>
<p>{{.Msg "settings.game_runtime_help"}}</p>
<form method="post" action="/admin/game-container-runtime">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label>{{.Msg "settings.game_runtime_uid"}}<input required name="uid" type="number" min="0" max="4294967295" step="1" value="{{.GameContainerUID}}"></label>
<label>{{.Msg "settings.game_runtime_gid"}}<input required name="gid" type="number" min="0" max="4294967295" step="1" value="{{.GameContainerGID}}"></label>
<p class="help">{{.Msg "settings.game_runtime_policy_help"}}</p>
<button type="submit">{{.Msg "settings.save_game_runtime"}}</button>
</form>
</section>
<section class="panel settings-section" id="containers">
<h2>
{{.Msg "settings.container_labels"}}
+48
View File
@@ -1,11 +1,59 @@
package web
import (
"context"
"fmt"
"net/http"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/webaccess"
)
func (s *server) gameContainerRuntimeForm(w http.ResponseWriter, r *http.Request) {
if !s.parseForm(w, r) {
return
}
user, err := s.currentUser(r)
session, cookieErr := r.Cookie(sessionCookie)
if err != nil || cookieErr != nil || user.Role != "admin" || !s.auth.ValidateCSRF(r.Context(), session.Value, r.FormValue("csrf_token")) {
s.problem(w, http.StatusForbidden, localized(s.language(r, ""), "error.csrf"))
return
}
if err := s.permissions.RequireRecentAdmin(user); err != nil {
s.problem(w, http.StatusForbidden, "Recent administrator authentication is required.")
return
}
parse := func(name string) (uint32, error) {
value, err := instance.ParseRuntimeID(r.FormValue(name))
if err != nil {
return 0, fmt.Errorf("%s: %w", name, err)
}
return value, nil
}
uid, err := parse("uid")
if err != nil {
s.problem(w, http.StatusUnprocessableEntity, err.Error())
return
}
gid, err := parse("gid")
if err != nil {
s.problem(w, http.StatusUnprocessableEntity, err.Error())
return
}
repository, ok := s.repository.(interface {
SetGameContainerRuntimeIdentity(context.Context, instance.RuntimeIdentity) error
})
if !ok {
s.problem(w, http.StatusServiceUnavailable, localized(s.language(r, user.Language), "error.internal"))
return
}
if err := repository.SetGameContainerRuntimeIdentity(r.Context(), instance.RuntimeIdentity{UID: uid, GID: gid}); err != nil {
s.problem(w, http.StatusInternalServerError, localized(s.language(r, user.Language), "error.internal"))
return
}
http.Redirect(w, r, "/administration#game-runtime", http.StatusSeeOther)
}
func (s *server) webAccessForm(w http.ResponseWriter, r *http.Request) {
if !s.parseForm(w, r) {
return
+10
View File
@@ -58,6 +58,16 @@
"tag": { "type": "string", "pattern": "^[a-zA-Z0-9._-]+$", "maxLength": 128 },
"entrypoint": { "type": "array", "items": { "type": "string", "maxLength": 500 }, "maxItems": 8 },
"user_mode": { "type": "string", "enum": ["dogama", "image"] },
"runtime_user": {
"type": "object",
"additionalProperties": false,
"required": ["mode"],
"properties": {
"mode": { "enum": ["docker", "environment"] },
"uid_env": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", "maxLength": 128 },
"gid_env": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", "maxLength": 128 }
}
},
"arguments": { "type": "array", "items": { "type": "string", "maxLength": 500 }, "maxItems": 64 },
"environment": { "type": "object", "maxProperties": 64, "additionalProperties": { "type": "string", "maxLength": 4096 }, "propertyNames": { "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" } },
"capabilities": {