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 image: didstopia/vrising-server
tag: latest tag: latest
user_mode: image user_mode: image
runtime_user:
mode: environment
uid_env: PUID
gid_env: PGID
stop_timeout_seconds: 120 stop_timeout_seconds: 120
capabilities: capabilities:
add: 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. - Backup scheduling/retention, safe imports, export and restore with safety backups.
- Sandboxed WASM runtime and normalized module API with Palworld reference adapter. - 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. - 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. - 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. - 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. - 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. - 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. - 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. - 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. - Gitea CI for pull requests and `main`, plus tag-only multi-architecture image publication and Gitea Release creation.
## Durable decisions ## 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. - 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. - 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. - 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. - 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. - 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. - 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; - resource limits are present and within administrator limits;
- labels use the reserved namespace and cannot be overridden; - 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; - 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 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 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_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 | | `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 | | `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 ## 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. 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 ## 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. 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. 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. `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. `{{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. 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 { 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") 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) { 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") 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 { for key := range template.Container.Environment {
allowed[key] = true 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 { for _, field := range template.Configuration.Fields {
if field.Target.Kind == "environment" { if field.Target.Kind == "environment" {
allowed[field.Target.Name] = true allowed[field.Target.Name] = true
+28 -4
View File
@@ -63,10 +63,15 @@ type Template struct {
Recommended Resources `json:"recommended"` Recommended Resources `json:"recommended"`
} `json:"requirements"` } `json:"requirements"`
Container struct { Container struct {
Image string `json:"image"` Image string `json:"image"`
Tag string `json:"tag"` Tag string `json:"tag"`
Entrypoint []string `json:"entrypoint,omitempty"` Entrypoint []string `json:"entrypoint,omitempty"`
UserMode string `json:"user_mode,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"` Arguments []string `json:"arguments,omitempty"`
Environment map[string]string `json:"environment,omitempty"` Environment map[string]string `json:"environment,omitempty"`
Capabilities struct { 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"}) 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 { if template.Integration != nil && template.Module == nil {
issues = append(issues, ValidationIssue{Path: "/module", Message: "integration requires a template-local module"}) 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 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) { func collectModuleFiles(template Template, assetRoot string, source fs.FS) (map[string][]byte, []ValidationIssue) {
if template.Module == nil { if template.Module == nil {
return nil, nil return nil, nil
+2
View File
@@ -9,6 +9,8 @@ import (
) )
type ConfigurationRepository interface { type ConfigurationRepository interface {
GetGameContainerRuntimeIdentity(context.Context) (RuntimeIdentity, error)
SetGameContainerRuntimeIdentity(context.Context, RuntimeIdentity) error
GetGlobalLabels(context.Context) (map[string]string, error) GetGlobalLabels(context.Context) (map[string]string, error)
SetGlobalLabels(context.Context, map[string]string, bool) (affected int, running int, err error) SetGlobalLabels(context.Context, map[string]string, bool) (affected int, running int, err error)
SaveInstanceConfiguration(context.Context, string, Preview, bool, string, string) 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 // configuration. It is persisted with the preview so a recreated container is
// built identically. Secret mutations retain only their field identifier. // built identically. Secret mutations retain only their field identifier.
type ResolvedConfiguration struct { type ResolvedConfiguration struct {
Environment map[string]string `json:"environment,omitempty"` Environment map[string]string `json:"environment,omitempty"`
Arguments []string `json:"arguments,omitempty"` RuntimeEnvironment map[string]string `json:"runtime_environment,omitempty"`
INI []INIMutation `json:"ini,omitempty"` Arguments []string `json:"arguments,omitempty"`
SecretEnvironment map[string]string `json:"secret_environment,omitempty"` INI []INIMutation `json:"ini,omitempty"`
SecretArguments []SecretArgument `json:"secret_arguments,omitempty"` SecretEnvironment map[string]string `json:"secret_environment,omitempty"`
SecretArguments []SecretArgument `json:"secret_arguments,omitempty"`
} }
type SecretArgument struct { 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} 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) { 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 { for key, value := range template.Container.Environment {
result.Environment[key] = value result.Environment[key] = value
} }
+26
View File
@@ -20,6 +20,32 @@ const (
ImageTagPinned = "pinned" 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 ( var (
labelKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*(?:/[A-Za-z0-9][A-Za-z0-9_.-]*)?$`) 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}$`) tagPattern = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$`)
@@ -2,6 +2,19 @@ package instance
import "testing" 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) { func TestLabelsAndVariables(t *testing.T) {
labels, err := ParseLabels("\n glance.name={{instance.name}}\nquery=a=b=c\n") labels, err := ParseLabels("\n glance.name={{instance.name}}\nquery=a=b=c\n")
if err != nil || labels["query"] != "a=b=c" { if err != nil || labels["query"] != "a=b=c" {
+23 -15
View File
@@ -35,6 +35,7 @@ type PreviewRequest struct {
PublicBaseURL string `json:"-"` PublicBaseURL string `json:"-"`
Configuration map[string]string `json:"configuration,omitempty"` Configuration map[string]string `json:"configuration,omitempty"`
Secrets map[string]string `json:"-"` Secrets map[string]string `json:"-"`
RuntimeIdentity *RuntimeIdentity `json:"-"`
} }
type Preview struct { type Preview struct {
@@ -159,11 +160,12 @@ func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, e
if err != nil { if err != nil {
return Preview{}, err 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 request.DockerUser.Mode = DockerUserDoGaMa
if snapshot.Template.Container.UserMode == DockerUserImage {
request.DockerUser.Mode = DockerUserImage
}
} }
if err := ValidateDockerUser(request.DockerUser); err != nil { if err := ValidateDockerUser(request.DockerUser); err != nil {
return Preview{}, err return Preview{}, err
@@ -172,11 +174,14 @@ func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, e
if err != nil { if err != nil {
return Preview{}, err return Preview{}, err
} }
uid, gid, err := currentUIDGID() identity := RuntimeIdentity{UID: DefaultGameContainerUID, GID: DefaultGameContainerGID}
if err != nil && request.DockerUser.Mode == DockerUserDoGaMa { if request.RuntimeIdentity != nil {
identity = *request.RuntimeIdentity
}
if err := identity.Validate(); err != nil {
return Preview{}, err return Preview{}, err
} }
userValue, err := DockerUserValue(request.DockerUser, uid, gid) userValue, err := DockerUserValue(request.DockerUser, identity.UID, identity.GID)
if err != nil { if err != nil {
return Preview{}, err return Preview{}, err
} }
@@ -215,6 +220,13 @@ func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, e
if err != nil { if err != nil {
return Preview{}, err 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 resources := request.Resources
if resources.CPUCores == 0 { if resources.CPUCores == 0 {
resources = snapshot.Template.Requirements.Recommended resources = snapshot.Template.Requirements.Recommended
@@ -338,14 +350,7 @@ func (p Preview) deploymentPlan(instanceID string, secrets map[string]string) (a
p.DockerUser.Mode = DockerUserDoGaMa p.DockerUser.Mode = DockerUserDoGaMa
} }
if p.DockerUserValue == "" && p.DockerUser.Mode == DockerUserDoGaMa { if p.DockerUserValue == "" && p.DockerUser.Mode == DockerUserDoGaMa {
uid, gid, userErr := currentUIDGID() return agentwire.DeploymentPlan{}, errors.New("managed Docker user is missing")
if userErr != nil {
return agentwire.DeploymentPlan{}, userErr
}
p.DockerUserValue, userErr = DockerUserValue(p.DockerUser, uid, gid)
if userErr != nil {
return agentwire.DeploymentPlan{}, userErr
}
} }
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} 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) 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 { for key, value := range p.ResolvedConfiguration.Environment {
environment[key] = value environment[key] = value
} }
for key, value := range p.ResolvedConfiguration.RuntimeEnvironment {
environment[key] = value
}
arguments := append([]string(nil), p.Arguments...) arguments := append([]string(nil), p.Arguments...)
arguments = append(arguments, p.ResolvedConfiguration.Arguments...) arguments = append(arguments, p.ResolvedConfiguration.Arguments...)
for key, id := range p.ResolvedConfiguration.SecretEnvironment { 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) t.Fatalf("image-mode plan user=%q error=%v", plan.User, err)
} }
request.DockerUser.Mode = instance.DockerUserDoGaMa request.DockerUser.Mode = instance.DockerUserDoGaMa
request.RuntimeIdentity = &instance.RuntimeIdentity{UID: 1234, GID: 5678}
explicit, err := instance.BuildPreview(snapshots[0], request) 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) 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) { func TestBuildPreviewRejectsPrivatePortPublicationAndLowResources(t *testing.T) {
snapshots, err := catalog.LoadFS(catalogdata.Files, ".") snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
if err != nil { 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 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) { func (r *Repository) GetGlobalLabels(ctx context.Context) (map[string]string, error) {
var body string 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_state (singleton, bootstrap_completed_at) VALUES (1, NULL);
INSERT INTO system_settings(key, value_json, revision, updated_at) 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')); 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", "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.", "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", "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.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", "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.", "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 Error string
User auth.User User auth.User
GlobalLabels string GlobalLabels string
GameContainerUID uint32
GameContainerGID uint32
IsAdmin bool IsAdmin bool
Channels []notification.Channel Channels []notification.Channel
NotificationPreferences map[string]bool NotificationPreferences map[string]bool
@@ -362,6 +364,7 @@ func newHandlerServicesWithCatalog(authService *auth.Service, repository reposit
mux.HandleFunc("POST /logout", s.logout) mux.HandleFunc("POST /logout", s.logout)
mux.HandleFunc("POST /admin/game-container-labels", s.globalLabelsForm) mux.HandleFunc("POST /admin/game-container-labels", s.globalLabelsForm)
mux.HandleFunc("POST /admin/web-access", s.webAccessForm) 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-channels", s.notificationForm)
mux.HandleFunc("POST /admin/notification-language", s.notificationLanguageForm) mux.HandleFunc("POST /admin/notification-language", s.notificationLanguageForm)
mux.HandleFunc("POST /admin/notification-channels/{id}/test", s.notificationTestForm) 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 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{ preview, err := instance.BuildPreview(snapshot, instance.PreviewRequest{
DisplayName: request.DisplayName, Slug: request.Slug, HostPorts: request.HostPorts, DisplayName: request.DisplayName, Slug: request.Slug, HostPorts: request.HostPorts,
MountPaths: request.MountPaths, Resources: request.Resources, DataOrigin: request.DataOrigin, MountPaths: request.MountPaths, Resources: request.Resources, DataOrigin: request.DataOrigin,
BackupRetention: request.BackupRetention, ImportID: request.ImportID, BackupRetention: request.BackupRetention, ImportID: request.ImportID,
CustomLabels: request.CustomLabels, DockerUser: request.DockerUser, ImageTag: request.ImageTag, CustomLabels: request.CustomLabels, DockerUser: request.DockerUser, ImageTag: request.ImageTag,
PublicBaseURL: requestBaseURL(r), PublicBaseURL: requestBaseURL(r), RuntimeIdentity: &identity,
}) })
if err != nil { if err != nil {
s.apiProblem(w, http.StatusUnprocessableEntity, "invalid_preview", "The deployment preview is invalid.") 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) s.render(w, 422, "deployment.html", data)
return 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) preview, buildErr := instance.BuildPreview(snapshot, request)
if buildErr != nil { if buildErr != nil {
data.Deployment.Error = "Please correct the deployment settings." 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 s.repository != nil {
if configured, ok := s.repository.(instance.ConfigurationRepository); ok { 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 { if labels, labelErr := configured.GetGlobalLabels(r.Context()); labelErr == nil {
data.GlobalLabels = instance.FormatLabels(labels) data.GlobalLabels = instance.FormatLabels(labels)
} }
+9 -1
View File
@@ -386,12 +386,20 @@ func TestNotificationAndAuditAdministration(t *testing.T) {
settingsBody := settings.Body.String() settingsBody := settings.Body.String()
for _, expected := range []string{ for _, expected := range []string{
"Notification channels", "Web access", "href=\"#audit\"", "href=\"/audit\"", "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) { if !strings.Contains(settingsBody, expected) {
t.Fatalf("settings UI section %q missing", 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}) 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) assertStatus(t, notificationLanguage, http.StatusSeeOther)
if got := notificationLanguage.Result().Header.Get("Location"); got != "/administration#notifications" { if got := notificationLanguage.Result().Header.Get("Location"); got != "/administration#notifications" {
+14
View File
@@ -46,6 +46,9 @@
<a href="#containers"> <a href="#containers">
{{.Msg "settings.containers"}} {{.Msg "settings.containers"}}
</a> </a>
<a href="#game-runtime">
{{.Msg "settings.game_runtime"}}
</a>
</nav> </nav>
<section class="panel settings-section" id="web-access"> <section class="panel settings-section" id="web-access">
<h2> <h2>
@@ -313,6 +316,17 @@
</button> </button>
</form> </form>
</section> </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"> <section class="panel settings-section" id="containers">
<h2> <h2>
{{.Msg "settings.container_labels"}} {{.Msg "settings.container_labels"}}
+48
View File
@@ -1,11 +1,59 @@
package web package web
import ( import (
"context"
"fmt"
"net/http" "net/http"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/webaccess" "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) { func (s *server) webAccessForm(w http.ResponseWriter, r *http.Request) {
if !s.parseForm(w, r) { if !s.parseForm(w, r) {
return return
+10
View File
@@ -58,6 +58,16 @@
"tag": { "type": "string", "pattern": "^[a-zA-Z0-9._-]+$", "maxLength": 128 }, "tag": { "type": "string", "pattern": "^[a-zA-Z0-9._-]+$", "maxLength": 128 },
"entrypoint": { "type": "array", "items": { "type": "string", "maxLength": 500 }, "maxItems": 8 }, "entrypoint": { "type": "array", "items": { "type": "string", "maxLength": 500 }, "maxItems": 8 },
"user_mode": { "type": "string", "enum": ["dogama", "image"] }, "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 }, "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_]*$" } }, "environment": { "type": "object", "maxProperties": 64, "additionalProperties": { "type": "string", "maxLength": 4096 }, "propertyNames": { "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" } },
"capabilities": { "capabilities": {