feat(runtime): add configurable game server uid gid
CI / validate (pull_request) Successful in 26m27s
CI / validate (pull_request) Successful in 26m27s
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" {
|
||||||
|
|||||||
@@ -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"}}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
Reference in New Issue
Block a user