feat(instances): apply template configuration before startup
CI / validate (pull_request) Successful in 21m5s

This commit is contained in:
2026-08-14 12:19:49 +02:00
parent 76f390bae1
commit a24d2bac2c
18 changed files with 750 additions and 64 deletions
+28
View File
@@ -75,6 +75,10 @@ configuration:
target:
kind: ini
name: ServerName
mount: saved
file: Config/LinuxServer/PalWorldSettings.ini
section: /Script/Pal.PalGameWorldSettings
key: ServerName
apply: restart_required
visibility: members
required: true
@@ -85,6 +89,10 @@ configuration:
target:
kind: ini
name: ServerDescription
mount: saved
file: Config/LinuxServer/PalWorldSettings.ini
section: /Script/Pal.PalGameWorldSettings
key: ServerDescription
apply: restart_required
visibility: members
required: false
@@ -95,6 +103,10 @@ configuration:
target:
kind: ini
name: ServerPlayerMaxNum
mount: saved
file: Config/LinuxServer/PalWorldSettings.ini
section: /Script/Pal.PalGameWorldSettings
key: ServerPlayerMaxNum
apply: restart_required
visibility: members
required: true
@@ -107,6 +119,10 @@ configuration:
target:
kind: ini
name: ServerPassword
mount: saved
file: Config/LinuxServer/PalWorldSettings.ini
section: /Script/Pal.PalGameWorldSettings
key: ServerPassword
apply: restart_required
visibility: secret
required: false
@@ -116,6 +132,10 @@ configuration:
target:
kind: ini
name: AdminPassword
mount: saved
file: Config/LinuxServer/PalWorldSettings.ini
section: /Script/Pal.PalGameWorldSettings
key: AdminPassword
apply: restart_required
visibility: secret
required: true
@@ -125,6 +145,10 @@ configuration:
target:
kind: ini
name: RESTAPIEnabled
mount: saved
file: Config/LinuxServer/PalWorldSettings.ini
section: /Script/Pal.PalGameWorldSettings
key: RESTAPIEnabled
apply: restart_required
visibility: admin
required: true
@@ -135,6 +159,10 @@ configuration:
target:
kind: ini
name: RESTAPIPort
mount: saved
file: Config/LinuxServer/PalWorldSettings.ini
section: /Script/Pal.PalGameWorldSettings
key: RESTAPIPort
apply: restart_required
visibility: admin
required: true
+8 -5
View File
@@ -62,6 +62,14 @@ func run(logger *slog.Logger) error {
return err
}
repository := sqlite.NewRepository(db)
keyFile := environment("DOGAMA_MASTER_KEY_FILE", "secrets/master_key")
key, keyErr := loadMasterKey(keyFile)
if keyErr != nil {
return keyErr
}
if keyErr = repository.SetSecretKey(key); keyErr != nil {
return keyErr
}
if err := repository.Replace(ctx, scan.Valid); err != nil {
return err
}
@@ -71,12 +79,7 @@ func run(logger *slog.Logger) error {
var backupService *backup.Service
auditService := audit.New(db)
var notificationService *notification.Service
keyFile := environment("DOGAMA_MASTER_KEY_FILE", "secrets/master_key")
if keyFile != "" {
key, keyErr := loadMasterKey(keyFile)
if keyErr != nil {
return keyErr
}
notificationService, keyErr = notification.New(db, key)
if keyErr != nil {
return keyErr
+1
View File
@@ -76,6 +76,7 @@ Read this compact operational baseline before starting a milestone. Open detaile
- Scheduled backup outcomes and repeated authentication blocks are audited/logged, but broader scheduler-origin notification coverage remains intentionally limited to events emitted by implemented workflows.
- Instance detail and backup management remain API-first. The Catalog includes an administrator deployment form which resolves a safe preview, optionally validates an external save import, then installs and starts via the restricted agent; the detailed instance page remains future work.
- Template configuration targets are applied during deployment: container environment and argv are included in the signed agent plan; INI changes are applied atomically after an optional restore and before start. Instance secrets are encrypted outside preview JSON.
- Linux is the deployment target. Native Windows execution of the full Go suite is blocked by Unix `Statfs` code; use Linux/WSL/CI for complete execution.
- The two DoGaMa services run as root inside their container namespaces for bind-mount portability. Risk is bounded with read-only image filesystems, all capabilities dropped, `no-new-privileges`, no Docker socket in the main application and a private typed agent API; rootless Docker and user-namespace remapping remain host-level deployment choices.
- In-place migration of a successfully initialized v0.1.0 data directory has not been validated because that release wrote the application key under a different container identity. Preserve all stores and test a copied deployment rather than assuming compatibility.
+18 -2
View File
@@ -44,8 +44,24 @@ requirements:
`game.image` is the vertical cover displayed by Catalog and the game page. Its URL must be HTTPS, but validation never fetches it: an unavailable remote cover does not block a scan and the interface has a graphical fallback. `requirements.minimum` and `requirements.recommended` contain generic CPU, memory and storage values plus optional `other` lines. Recommended resources cannot be below minimum resources.
`configuration.fields` drives the deployment form. Supported types are `string`, `integer`, `number`, `boolean`, `enum` and `secret`; defaults, required flags, numeric bounds, regular expressions and enum values are checked again by the server. Secret inputs are write-only and are never included in a persisted preview or error.
`configuration.fields` drives the deployment form and the effective server configuration. Supported types are `string`, `integer`, `number`, `boolean`, `enum` and `secret`; defaults, required flags, numeric bounds, regular expressions and enum values are checked again by the server. Secret inputs are write-only, encrypted in the instance secret store when retained for runtime use, and never included in a persisted preview, API response, audit event or error.
Each field has one explicit `target`. `environment` uses `name` as a container environment-variable name; static `container.environment` values are retained and a field may only set its own declared target. `DOGAMA_*` and process/internal variables are protected. `argument` uses `name` as an argv prefix: ordinary values become `name=value`; boolean fields emit a flag only when true, unless `name` contains `{{value}}`, in which case it is substituted for both boolean values. Arguments are passed as Docker argv entries, never through a shell.
An `ini` target is constrained to a writable declared storage mount and needs `mount`, a relative `file`, `section` and `key` (with `name` retained as its display identifier):
```yaml
target:
kind: ini
name: ServerName
mount: saved
file: Config/LinuxServer/PalWorldSettings.ini
section: /Script/Pal.PalGameWorldSettings
key: ServerName
```
Absolute paths, traversal and symlinked parent directories are refused. INI changes preserve unrelated sections and keys and use a same-directory temporary file plus atomic rename.
Administrators deploy from **Catalog → game → Deploy**. The visible instance name is converted deterministically to the safe technical slug used for mount paths (for example `Été / Serveur #2` becomes `ete-serveur-2`). DoGaMa derives every mount below its configured server root, validates a canonical preview and asks only the restricted agent to create and start the container.
When the template supports imports, the form accepts ZIP, TAR, TAR.GZ and TAR.ZST save archives. Uploads are size-bounded, staged under the configured import root with generated names, checked for traversal and expected save layout, then copied before the first start. Invalid staging data is removed.
When the template supports imports, the form accepts ZIP, TAR, TAR.GZ and TAR.ZST save archives. Uploads are size-bounded, staged under the configured import root with generated names, checked for traversal and expected save layout. Deployment order is **install (stopped) → import → apply resolved configuration → start**, so DoGaMa form values intentionally take priority over configuration files present in an imported save. Invalid staging data is removed and a configuration failure prevents startup.
+6
View File
@@ -11,6 +11,7 @@ import (
"net/http"
"net/url"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@@ -171,6 +172,7 @@ func (d *dockerRuntime) Create(ctx context.Context, plan agentwire.DeploymentPla
User string `json:"User,omitempty"`
Entrypoint []string `json:"Entrypoint,omitempty"`
Cmd []string `json:"Cmd,omitempty"`
Env []string `json:"Env,omitempty"`
Labels map[string]string `json:"Labels"`
ExposedPorts map[string]struct{} `json:"ExposedPorts"`
HostConfig struct {
@@ -193,6 +195,10 @@ func (d *dockerRuntime) Create(ctx context.Context, plan agentwire.DeploymentPla
}),
ExposedPorts: exposed,
}
for key, value := range plan.Environment {
payload.Env = append(payload.Env, key+"="+value)
}
sort.Strings(payload.Env)
payload.HostConfig.Binds = binds
payload.HostConfig.PortBindings = bindings
payload.HostConfig.Memory = int64(plan.Resources.MemoryMB) * 1024 * 1024
+44 -1
View File
@@ -73,9 +73,12 @@ func (p *PlanPolicy) Validate(plan agentwire.DeploymentPlan) error {
}
template := snapshot.Template
imagePrefix := template.Container.Image + ":"
if !strings.HasPrefix(plan.Image, imagePrefix) || !reflect.DeepEqual(plan.Entrypoint, template.Container.Entrypoint) || !reflect.DeepEqual(plan.Arguments, template.Container.Arguments) || plan.StopTimeoutSeconds != template.Container.StopTimeoutSeconds {
if !strings.HasPrefix(plan.Image, imagePrefix) || !reflect.DeepEqual(plan.Entrypoint, template.Container.Entrypoint) || plan.StopTimeoutSeconds != template.Container.StopTimeoutSeconds {
return errors.New("container plan differs from template")
}
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")
}
if plan.Resources.CPUCores < template.Requirements.Minimum.CPUCores || plan.Resources.MemoryMB < template.Requirements.Minimum.MemoryMB || plan.Resources.StorageGB < template.Requirements.Minimum.StorageGB {
return errors.New("container resources are below template minimum")
}
@@ -104,3 +107,43 @@ func (p *PlanPolicy) Validate(plan agentwire.DeploymentPlan) error {
}
return nil
}
func allowedEnvironment(template catalog.Template, environment map[string]string) bool {
allowed := map[string]bool{}
for key := range template.Container.Environment {
allowed[key] = true
}
for _, field := range template.Configuration.Fields {
if field.Target.Kind == "environment" {
allowed[field.Target.Name] = true
}
}
for key := range environment {
if !allowed[key] {
return false
}
}
return true
}
func allowedArguments(template catalog.Template, arguments []string) bool {
allowed := make([]string, 0)
for _, field := range template.Configuration.Fields {
if field.Target.Kind == "argument" {
allowed = append(allowed, strings.ReplaceAll(field.Target.Name, "{{value}}", ""))
}
}
for _, argument := range arguments {
ok := false
for _, prefix := range allowed {
if argument == prefix || strings.HasPrefix(argument, prefix+"=") || (strings.HasSuffix(prefix, "=") && strings.HasPrefix(argument, prefix)) {
ok = true
break
}
}
if !ok {
return false
}
}
return true
}
+11 -1
View File
@@ -31,6 +31,7 @@ type DeploymentPlan struct {
Image string `json:"image"`
Entrypoint []string `json:"entrypoint,omitempty"`
Arguments []string `json:"arguments,omitempty"`
Environment map[string]string `json:"environment,omitempty"`
Ports []PlanPort `json:"ports"`
Mounts []PlanMount `json:"mounts"`
Resources PlanResource `json:"resources"`
@@ -66,6 +67,10 @@ func (p DeploymentPlan) CanonicalDigest() (string, error) {
copyPlan.PlanDigest = ""
copyPlan.Entrypoint = append([]string(nil), p.Entrypoint...)
copyPlan.Arguments = append([]string(nil), p.Arguments...)
copyPlan.Environment = make(map[string]string, len(p.Environment))
for key, value := range p.Environment {
copyPlan.Environment[key] = value
}
copyPlan.Ports = append([]PlanPort(nil), p.Ports...)
copyPlan.Mounts = append([]PlanMount(nil), p.Mounts...)
sort.Slice(copyPlan.Ports, func(i, j int) bool { return copyPlan.Ports[i].ID < copyPlan.Ports[j].ID })
@@ -91,9 +96,14 @@ func (p DeploymentPlan) Validate() error {
if p.StopTimeoutSeconds < 5 || p.StopTimeoutSeconds > 900 || len(p.Ports) > 32 || len(p.Mounts) == 0 || len(p.Mounts) > 16 {
return errors.New("invalid deployment plan limits")
}
if len(p.Labels) > 64 || len(p.User) > 32 {
if len(p.Labels) > 64 || len(p.Environment) > 64 || len(p.User) > 32 {
return errors.New("invalid deployment plan container configuration")
}
for key, value := range p.Environment {
if !regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`).MatchString(key) || len(value) > 4096 || strings.HasPrefix(key, "DOGAMA_") {
return errors.New("invalid deployment plan environment")
}
}
for key, value := range p.Labels {
lower := strings.ToLower(key)
if key == "" || len(key) > 255 || len(value) > 4096 || strings.HasPrefix(lower, "dogama.") || strings.HasPrefix(lower, "io.dogama.") {
+36 -17
View File
@@ -63,12 +63,13 @@ type Template struct {
Recommended Resources `json:"recommended"`
} `json:"requirements"`
Container struct {
Image string `json:"image"`
Tag string `json:"tag"`
Entrypoint []string `json:"entrypoint,omitempty"`
Arguments []string `json:"arguments,omitempty"`
StopTimeoutSeconds int `json:"stop_timeout_seconds"`
Ports []Port `json:"ports"`
Image string `json:"image"`
Tag string `json:"tag"`
Entrypoint []string `json:"entrypoint,omitempty"`
Arguments []string `json:"arguments,omitempty"`
Environment map[string]string `json:"environment,omitempty"`
StopTimeoutSeconds int `json:"stop_timeout_seconds"`
Ports []Port `json:"ports"`
Assets []struct {
Source string `json:"source"`
Destination string `json:"destination"`
@@ -144,17 +145,29 @@ type Mount struct {
}
type ConfigField struct {
ID string `json:"id"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
Type string `json:"type"`
Visibility string `json:"visibility"`
Required bool `json:"required"`
Default any `json:"default,omitempty"`
Minimum *float64 `json:"minimum,omitempty"`
Maximum *float64 `json:"maximum,omitempty"`
Pattern string `json:"pattern,omitempty"`
Values []any `json:"values,omitempty"`
ID string `json:"id"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
Type string `json:"type"`
Visibility string `json:"visibility"`
Required bool `json:"required"`
Default any `json:"default,omitempty"`
Minimum *float64 `json:"minimum,omitempty"`
Maximum *float64 `json:"maximum,omitempty"`
Pattern string `json:"pattern,omitempty"`
Values []any `json:"values,omitempty"`
Target ConfigTarget `json:"target"`
}
// ConfigTarget is deliberately data-only. INI targets are relative to an
// explicitly declared writable mount, never to a host path.
type ConfigTarget struct {
Kind string `json:"kind"`
Name string `json:"name"`
Mount string `json:"mount,omitempty"`
File string `json:"file,omitempty"`
Section string `json:"section,omitempty"`
Key string `json:"key,omitempty"`
}
// Snapshot is an immutable validated template version.
@@ -414,6 +427,12 @@ func crossValidate(template Template, assetRoot string, source fs.FS) []Validati
if field.Type == "secret" && (field.Visibility != "secret" || field.Default != nil) {
issues = append(issues, ValidationIssue{Path: "/configuration/fields/" + field.ID, Message: "secret fields require secret visibility and no default"})
}
if field.Target.Kind == "ini" {
mount, ok := mounts[field.Target.Mount]
if !ok || mount.ReadOnly || field.Target.File == "" || field.Target.Key == "" || strings.HasPrefix(field.Target.File, "/") || strings.Contains(field.Target.File, "..") {
issues = append(issues, ValidationIssue{Path: "/configuration/fields/" + field.ID + "/target", Message: "INI target must use a writable declared mount and relative file"})
}
}
}
for _, mountID := range template.Backup.SourceMounts {
mount, exists := mounts[mountID]
+119
View File
@@ -0,0 +1,119 @@
package instance
import (
"errors"
"fmt"
"path/filepath"
"sort"
"strings"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
)
// ResolvedConfiguration is the safe, non-secret part of a template's runtime
// configuration. It is persisted with the preview so a recreated container is
// built identically. Secret mutations retain only their field identifier.
type ResolvedConfiguration struct {
Environment map[string]string `json:"environment,omitempty"`
Arguments []string `json:"arguments,omitempty"`
INI []INIMutation `json:"ini,omitempty"`
SecretEnvironment map[string]string `json:"secret_environment,omitempty"`
SecretArguments []SecretArgument `json:"secret_arguments,omitempty"`
}
type SecretArgument struct {
Name string `json:"name"`
ID string `json:"id"`
Type string `json:"type"`
}
type INIMutation struct {
Mount string `json:"mount"`
File string `json:"file"`
Section string `json:"section"`
Key string `json:"key"`
Value string `json:"value,omitempty"`
SecretID string `json:"secret_id,omitempty"`
}
var protectedEnvironment = map[string]bool{"PATH": true, "HOME": true, "HOSTNAME": true, "DOCKER_HOST": true, "DOGAMA_INSTANCE_ID": true}
func ResolveConfiguration(template catalog.Template, values map[string]string) (ResolvedConfiguration, error) {
result := ResolvedConfiguration{Environment: make(map[string]string), SecretEnvironment: make(map[string]string)}
for key, value := range template.Container.Environment {
result.Environment[key] = value
}
for _, field := range template.Configuration.Fields {
value, configured := values[field.ID]
secret := field.Type == "secret"
if !configured {
continue
}
target := field.Target
switch target.Kind {
case "environment":
if protectedEnvironment[target.Name] || strings.HasPrefix(target.Name, "DOGAMA_") {
return ResolvedConfiguration{}, fmt.Errorf("%s targets a protected environment variable", field.ID)
}
if secret {
result.SecretEnvironment[target.Name] = field.ID
continue
}
// A field may replace only the explicitly named template variable.
result.Environment[target.Name] = value
case "argument":
if secret {
result.SecretArguments = append(result.SecretArguments, SecretArgument{Name: target.Name, ID: field.ID, Type: field.Type})
continue
}
argument, include, err := resolveArgument(target.Name, field.Type, value)
if err != nil {
return ResolvedConfiguration{}, fmt.Errorf("%s has an invalid argument target", field.ID)
}
if include {
result.Arguments = append(result.Arguments, argument)
}
case "ini":
mutation := INIMutation{Mount: target.Mount, File: target.File, Section: target.Section, Key: target.Key, Value: value}
if secret {
mutation.SecretID = field.ID
mutation.Value = ""
}
if err := validateINIMutation(mutation); err != nil {
return ResolvedConfiguration{}, fmt.Errorf("%s has an invalid INI target", field.ID)
}
result.INI = append(result.INI, mutation)
default:
return ResolvedConfiguration{}, fmt.Errorf("%s has an unknown configuration target", field.ID)
}
}
sort.Slice(result.INI, func(i, j int) bool {
return result.INI[i].Mount+result.INI[i].File+result.INI[i].Section+result.INI[i].Key < result.INI[j].Mount+result.INI[j].File+result.INI[j].Section+result.INI[j].Key
})
return result, nil
}
func resolveArgument(name, typ, value string) (string, bool, error) {
if strings.TrimSpace(name) == "" || strings.ContainsAny(name, "\x00\n\r") {
return "", false, errors.New("invalid")
}
if typ == "boolean" {
if value == "false" && !strings.Contains(name, "{{value}}") {
return "", false, nil
}
}
if strings.Contains(name, "{{value}}") {
return strings.ReplaceAll(name, "{{value}}", value), true, nil
}
if typ == "boolean" {
return name, true, nil
}
return name + "=" + value, true, nil
}
func validateINIMutation(m INIMutation) error {
if m.Mount == "" || m.File == "" || m.Key == "" || filepath.IsAbs(m.File) || filepath.Clean(m.File) != m.File || strings.HasPrefix(m.File, ".."+string(filepath.Separator)) || strings.ContainsAny(m.File+m.Section+m.Key, "\x00\r\n") {
return errors.New("invalid ini path")
}
return nil
}
@@ -0,0 +1,94 @@
package instance_test
import (
"os"
"path/filepath"
"strings"
"testing"
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
)
func TestResolveConfigurationTargets(t *testing.T) {
template := catalog.Template{}
template.Container.Environment = map[string]string{"STATIC": "kept"}
template.Configuration.Fields = []catalog.ConfigField{
{ID: "name", Type: "string", Target: catalog.ConfigTarget{Kind: "environment", Name: "SERVER_NAME"}},
{ID: "count", Type: "integer", Target: catalog.ConfigTarget{Kind: "argument", Name: "--count"}},
{ID: "public", Type: "boolean", Target: catalog.ConfigTarget{Kind: "argument", Name: "--public"}},
{ID: "private", Type: "boolean", Target: catalog.ConfigTarget{Kind: "argument", Name: "--private={{value}}"}},
{ID: "password", Type: "secret", Target: catalog.ConfigTarget{Kind: "ini", Mount: "saved", File: "Config/server.ini", Section: "server", Key: "password"}},
}
resolved, err := instance.ResolveConfiguration(template, map[string]string{"name": "My server", "count": "4", "public": "true", "private": "false", "password": "secret"})
if err != nil {
t.Fatal(err)
}
if resolved.Environment["STATIC"] != "kept" || resolved.Environment["SERVER_NAME"] != "My server" {
t.Fatalf("environment = %#v", resolved.Environment)
}
if got := strings.Join(resolved.Arguments, " "); got != "--count=4 --public --private=false" {
t.Fatalf("arguments = %q", got)
}
if len(resolved.INI) != 1 || resolved.INI[0].Value != "" || resolved.INI[0].SecretID != "password" {
t.Fatalf("secret leaked in resolved config: %#v", resolved.INI)
}
template.Configuration.Fields[0].Target.Kind = "unknown"
if _, err := instance.ResolveConfiguration(template, map[string]string{"name": "x"}); err == nil {
t.Fatal("unknown target accepted")
}
}
func TestPalworldServerNameIsWrittenBeforeStart(t *testing.T) {
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
if err != nil {
t.Fatal(err)
}
values, err := instance.ValidateConfiguration(snapshots[0].Template.Configuration.Fields, map[string]string{"server_name": "Mon serveur", "server_description": "x", "max_players": "8", "rest_api_enabled": "true", "rest_api_port": "8212"}, map[string]string{"admin_password": "secret"})
if err != nil {
t.Fatal(err)
}
values["admin_password"] = "secret" // marker only; resolver does not retain it.
resolved, err := instance.ResolveConfiguration(snapshots[0].Template, values)
if err != nil {
t.Fatal(err)
}
root := t.TempDir()
if err := instance.ApplyINI([]instance.MountBinding{{ID: "saved", HostPath: root}}, resolved.INI, map[string]string{"admin_password": "secret"}); err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(filepath.Join(root, "Config", "LinuxServer", "PalWorldSettings.ini"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(body), "ServerName=Mon serveur") {
t.Fatalf("Palworld configuration not effective: %s", body)
}
}
func TestApplyINIPreservesContentAndRejectsTraversal(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "Config", "server.ini")
if err := os.MkdirAll(filepath.Dir(path), 0o770); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("[other]\nkeep=yes\n\n[server]\nold=value\n"), 0o660); err != nil {
t.Fatal(err)
}
mounts := []instance.MountBinding{{ID: "saved", HostPath: root}}
mutations := []instance.INIMutation{{Mount: "saved", File: "Config/server.ini", Section: "server", Key: "name", Value: "Mon serveur"}, {Mount: "saved", File: "Config/server.ini", Section: "server", Key: "old", Value: "new"}}
if err := instance.ApplyINI(mounts, mutations, nil); err != nil {
t.Fatal(err)
}
body, _ := os.ReadFile(path)
got := string(body)
for _, want := range []string{"[other]", "keep=yes", "old=new", "name=Mon serveur"} {
if !strings.Contains(got, want) {
t.Fatalf("INI missing %q: %s", want, got)
}
}
if err := instance.ApplyINI(mounts, []instance.INIMutation{{Mount: "saved", File: "../escape.ini", Section: "x", Key: "x", Value: "x"}}, nil); err == nil {
t.Fatal("path traversal accepted")
}
}
+139
View File
@@ -0,0 +1,139 @@
package instance
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// ApplyINI applies mutations below declared instance mounts. It refuses links
// in the parent path and installs a complete replacement with rename.
func ApplyINI(mounts []MountBinding, mutations []INIMutation, secrets map[string]string) error {
roots := map[string]string{}
for _, mount := range mounts {
if !mount.ReadOnly {
roots[mount.ID] = mount.HostPath
}
}
for _, mutation := range mutations {
root, ok := roots[mutation.Mount]
if !ok {
return errors.New("INI target mount is not writable")
}
if err := validateINIMutation(mutation); err != nil {
return err
}
path, err := safeINIPath(root, mutation.File)
if err != nil {
return err
}
value := mutation.Value
if mutation.SecretID != "" {
var found bool
value, found = secrets[mutation.SecretID]
if !found {
return errors.New("required configuration secret is unavailable")
}
}
if err := updateINI(path, mutation.Section, mutation.Key, value); err != nil {
return fmt.Errorf("write INI configuration: %w", err)
}
}
return nil
}
func safeINIPath(root, relative string) (string, error) {
root = filepath.Clean(root)
target := filepath.Join(root, relative)
rel, err := filepath.Rel(root, target)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", errors.New("INI path is outside its mount")
}
current := root
for _, part := range strings.Split(filepath.Dir(relative), string(filepath.Separator)) {
if part == "." || part == "" {
continue
}
current = filepath.Join(current, part)
if info, err := os.Lstat(current); err == nil && info.Mode()&os.ModeSymlink != 0 {
return "", errors.New("INI path contains symbolic link")
} else if err != nil && !os.IsNotExist(err) {
return "", err
}
}
return target, nil
}
func updateINI(path, section, key, value string) error {
body, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return err
}
mode := os.FileMode(0o660)
if err == nil {
if info, statErr := os.Stat(path); statErr == nil {
mode = info.Mode().Perm()
}
}
lines := []string{}
if len(body) > 0 {
lines = strings.Split(strings.TrimSuffix(string(body), "\n"), "\n")
}
sectionAt, keyAt := -1, -1
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
if strings.TrimSpace(trimmed[1:len(trimmed)-1]) == section {
sectionAt = i
} else if sectionAt >= 0 {
break
}
continue
}
if sectionAt >= 0 && strings.HasPrefix(trimmed, key+"=") {
keyAt = i
break
}
}
entry := key + "=" + value
if keyAt >= 0 {
lines[keyAt] = entry
} else if sectionAt >= 0 {
insert := sectionAt + 1
for insert < len(lines) && !(strings.HasPrefix(strings.TrimSpace(lines[insert]), "[") && strings.HasSuffix(strings.TrimSpace(lines[insert]), "]")) {
insert++
}
lines = append(lines, "")
copy(lines[insert+1:], lines[insert:])
lines[insert] = entry
} else {
if len(lines) > 0 && lines[len(lines)-1] != "" {
lines = append(lines, "")
}
lines = append(lines, "["+section+"]", entry)
}
if err := os.MkdirAll(filepath.Dir(path), 0o770); err != nil {
return err
}
temporary, err := os.CreateTemp(filepath.Dir(path), ".dogama-ini-*")
if err != nil {
return err
}
name := temporary.Name()
defer os.Remove(name)
if err = temporary.Chmod(mode); err == nil {
_, err = temporary.WriteString(strings.Join(lines, "\n") + "\n")
}
if err == nil {
err = temporary.Sync()
}
if closeErr := temporary.Close(); err == nil {
err = closeErr
}
if err != nil {
return err
}
return os.Rename(name, path)
}
+12 -1
View File
@@ -99,7 +99,7 @@ func (s *LifecycleService) Install(ctx context.Context, instanceID string) (Oper
if err != nil {
return OperationResult{}, err
}
plan, err := current.Preview.DeploymentPlan(instanceID)
plan, err := deploymentPlan(ctx, s.repository, current)
if err != nil {
return s.fail(ctx, operationID, instanceID, "invalid_plan", err)
}
@@ -114,6 +114,17 @@ func (s *LifecycleService) Install(ctx context.Context, instanceID string) (Oper
})
}
func deploymentPlan(ctx context.Context, repository LifecycleRepository, current StoredInstance) (agentwire.DeploymentPlan, error) {
if secrets, ok := repository.(SecretRepository); ok && (len(current.Preview.ResolvedConfiguration.SecretEnvironment) != 0 || len(current.Preview.ResolvedConfiguration.SecretArguments) != 0) {
values, err := secrets.LoadInstanceSecrets(ctx, current.ID)
if err != nil {
return agentwire.DeploymentPlan{}, err
}
return current.Preview.DeploymentPlanWithSecrets(current.ID, values)
}
return current.Preview.DeploymentPlan(current.ID)
}
func (s *LifecycleService) Start(ctx context.Context, instanceID string) (OperationResult, error) {
return s.exclusive(instanceID, func() (OperationResult, error) {
current, err := s.repository.GetInstance(ctx, instanceID)
+84 -33
View File
@@ -38,34 +38,35 @@ type PreviewRequest struct {
}
type Preview struct {
Template TemplateReference `json:"template"`
DisplayName string `json:"display_name"`
Description string `json:"description,omitempty"`
Slug string `json:"slug"`
Image string `json:"image"`
Entrypoint []string `json:"entrypoint,omitempty"`
Arguments []string `json:"arguments,omitempty"`
StopTimeoutSeconds int `json:"stop_timeout_seconds"`
StartupTimeoutSeconds int `json:"startup_timeout_seconds"`
Ports []PortBinding `json:"ports"`
Mounts []MountBinding `json:"mounts"`
Resources catalog.Resources `json:"resources"`
Settings []SettingPreview `json:"settings"`
DataOrigin string `json:"data_origin"`
Backup BackupPreview `json:"backup"`
Import ImportPreview `json:"import,omitempty"`
CanonicalJSON string `json:"canonical_json"`
PlanDigest string `json:"plan_digest"`
CustomLabels map[string]string `json:"custom_labels"`
GlobalLabels map[string]string `json:"global_labels"`
DockerUser DockerUser `json:"docker_user"`
DockerUserValue string `json:"docker_user_value,omitempty"`
ImageTag ImageTag `json:"image_tag"`
TemplateDefaultTag string `json:"template_default_tag"`
Game GameReference `json:"game"`
Mods ModsConfiguration `json:"mods"`
UpdatePolicy UpdatePolicy `json:"update_policy"`
Configuration map[string]string `json:"configuration,omitempty"`
Template TemplateReference `json:"template"`
DisplayName string `json:"display_name"`
Description string `json:"description,omitempty"`
Slug string `json:"slug"`
Image string `json:"image"`
Entrypoint []string `json:"entrypoint,omitempty"`
Arguments []string `json:"arguments,omitempty"`
StopTimeoutSeconds int `json:"stop_timeout_seconds"`
StartupTimeoutSeconds int `json:"startup_timeout_seconds"`
Ports []PortBinding `json:"ports"`
Mounts []MountBinding `json:"mounts"`
Resources catalog.Resources `json:"resources"`
Settings []SettingPreview `json:"settings"`
DataOrigin string `json:"data_origin"`
Backup BackupPreview `json:"backup"`
Import ImportPreview `json:"import,omitempty"`
CanonicalJSON string `json:"canonical_json"`
PlanDigest string `json:"plan_digest"`
CustomLabels map[string]string `json:"custom_labels"`
GlobalLabels map[string]string `json:"global_labels"`
DockerUser DockerUser `json:"docker_user"`
DockerUserValue string `json:"docker_user_value,omitempty"`
ImageTag ImageTag `json:"image_tag"`
TemplateDefaultTag string `json:"template_default_tag"`
Game GameReference `json:"game"`
Mods ModsConfiguration `json:"mods"`
UpdatePolicy UpdatePolicy `json:"update_policy"`
Configuration map[string]string `json:"configuration,omitempty"`
ResolvedConfiguration ResolvedConfiguration `json:"resolved_configuration,omitempty"`
}
type ModsConfiguration struct {
@@ -195,6 +196,19 @@ func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, e
return Preview{}, err
}
}
resolutionValues := make(map[string]string, len(configuration)+len(request.Secrets))
for id, value := range configuration {
resolutionValues[id] = value
}
for id, value := range request.Secrets {
if value != "" {
resolutionValues[id] = value
}
}
resolved, err := ResolveConfiguration(snapshot.Template, resolutionValues)
if err != nil {
return Preview{}, err
}
resources := request.Resources
if resources.CPUCores == 0 {
resources = snapshot.Template.Requirements.Recommended
@@ -271,10 +285,11 @@ func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, e
Backup: BackupPreview{Strategy: snapshot.Template.Backup.Strategy, SourceMounts: append([]string(nil), snapshot.Template.Backup.SourceMounts...), RetentionCount: request.BackupRetention},
Import: ImportPreview{ID: request.ImportID, DestinationMount: snapshot.Template.Imports.DestinationMount, DestinationRelativePath: snapshot.Template.Imports.DestinationRelativePath},
CustomLabels: customLabels, DockerUser: request.DockerUser, DockerUserValue: userValue, ImageTag: tag, TemplateDefaultTag: snapshot.Template.Container.Tag,
Game: gameReference(snapshot.Template.Game.ID, snapshot.Template.Game.Name, request.PublicBaseURL),
Mods: ModsConfiguration{Supported: snapshot.Template.Mods.Supported, Provider: snapshot.Template.Mods.Provider, DestinationMount: snapshot.Template.Mods.DestinationMount, RestartRequired: snapshot.Template.Mods.RestartRequired, Items: []string{}},
UpdatePolicy: UpdatePolicy{BackupBeforeUpdate: snapshot.Template.Updates.BackupBeforeUpdate, RollbackOnFailure: snapshot.Template.Updates.RollbackOnFailure, Automatic: false, HealthTimeoutSeconds: snapshot.Template.Updates.HealthTimeoutSeconds},
Configuration: configuration,
Game: gameReference(snapshot.Template.Game.ID, snapshot.Template.Game.Name, request.PublicBaseURL),
Mods: ModsConfiguration{Supported: snapshot.Template.Mods.Supported, Provider: snapshot.Template.Mods.Provider, DestinationMount: snapshot.Template.Mods.DestinationMount, RestartRequired: snapshot.Template.Mods.RestartRequired, Items: []string{}},
UpdatePolicy: UpdatePolicy{BackupBeforeUpdate: snapshot.Template.Updates.BackupBeforeUpdate, RollbackOnFailure: snapshot.Template.Updates.RollbackOnFailure, Automatic: false, HealthTimeoutSeconds: snapshot.Template.Updates.HealthTimeoutSeconds},
Configuration: configuration,
ResolvedConfiguration: resolved,
}
if preview.Backup.RetentionCount < 1 || preview.Backup.RetentionCount > 1000 {
return Preview{}, errors.New("backup retention must be between 1 and 1000")
@@ -302,6 +317,16 @@ func gameReference(id, name, publicBaseURL string) GameReference {
// DeploymentPlan converts a persisted preview into the only container plan
// accepted by the restricted agent. The digest binds every privileged field.
func (p Preview) DeploymentPlan(instanceID string) (agentwire.DeploymentPlan, error) {
return p.deploymentPlan(instanceID, nil)
}
// DeploymentPlanWithSecrets resolves write-only runtime values only while the
// signed agent request is built; they are never copied into Preview JSON.
func (p Preview) DeploymentPlanWithSecrets(instanceID string, secrets map[string]string) (agentwire.DeploymentPlan, error) {
return p.deploymentPlan(instanceID, secrets)
}
func (p Preview) deploymentPlan(instanceID string, secrets map[string]string) (agentwire.DeploymentPlan, error) {
if p.DockerUser.Mode == "" {
p.DockerUser.Mode = DockerUserDoGaMa
}
@@ -324,11 +349,37 @@ func (p Preview) DeploymentPlan(instanceID string) (agentwire.DeploymentPlan, er
if err != nil {
return agentwire.DeploymentPlan{}, err
}
environment := make(map[string]string, len(p.ResolvedConfiguration.Environment)+len(p.ResolvedConfiguration.SecretEnvironment))
for key, value := range p.ResolvedConfiguration.Environment {
environment[key] = value
}
arguments := append([]string(nil), p.Arguments...)
arguments = append(arguments, p.ResolvedConfiguration.Arguments...)
for key, id := range p.ResolvedConfiguration.SecretEnvironment {
value, ok := secrets[id]
if !ok {
return agentwire.DeploymentPlan{}, errors.New("required configuration secret is unavailable")
}
environment[key] = value
}
for _, secret := range p.ResolvedConfiguration.SecretArguments {
value, ok := secrets[secret.ID]
if !ok {
return agentwire.DeploymentPlan{}, errors.New("required configuration secret is unavailable")
}
argument, include, err := resolveArgument(secret.Name, secret.Type, value)
if err != nil {
return agentwire.DeploymentPlan{}, err
}
if include {
arguments = append(arguments, argument)
}
}
plan := agentwire.DeploymentPlan{
SchemaVersion: agentwire.DeploymentPlanVersion,
InstanceID: instanceID,
TemplateID: p.Template.ID, TemplateVersion: p.Template.Version, TemplateDigest: p.Template.Digest,
Image: p.Image, Entrypoint: append([]string(nil), p.Entrypoint...), Arguments: append([]string(nil), p.Arguments...),
Image: p.Image, Entrypoint: append([]string(nil), p.Entrypoint...), Arguments: arguments, Environment: environment,
Labels: MergeLabels(nil, global, local), User: p.DockerUserValue,
Resources: agentwire.PlanResource{CPUCores: p.Resources.CPUCores, MemoryMB: p.Resources.MemoryMB, StorageGB: p.Resources.StorageGB},
StopTimeoutSeconds: p.StopTimeoutSeconds,
+10
View File
@@ -0,0 +1,10 @@
package instance
import "context"
// SecretRepository keeps runtime values outside preview JSON. Implementations
// must encrypt values at rest and never return them through normal APIs.
type SecretRepository interface {
SaveInstanceSecrets(context.Context, string, map[string]string) error
LoadInstanceSecrets(context.Context, string) (map[string]string, error)
}
+73 -2
View File
@@ -2,6 +2,9 @@ package sqlite
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"database/sql"
"encoding/json"
"errors"
@@ -15,8 +18,9 @@ import (
// Repository persists catalog snapshots and the desired instance registry.
type Repository struct {
db *sql.DB
now func() time.Time
db *sql.DB
now func() time.Time
aead cipher.AEAD
}
// Ping verifies that the SQLite connection backing the current request remains usable.
@@ -26,6 +30,73 @@ func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db, now: time.Now}
}
// SetSecretKey configures encryption for instance runtime secrets. It is set
// from the service-owned master key and deliberately has no read API for web.
func (r *Repository) SetSecretKey(key []byte) error {
if len(key) != 32 {
return errors.New("instance encryption key must be exactly 32 bytes")
}
block, err := aes.NewCipher(key)
if err != nil {
return err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return err
}
r.aead = aead
return nil
}
func (r *Repository) SaveInstanceSecrets(ctx context.Context, instanceID string, values map[string]string) error {
if len(values) == 0 {
return nil
}
if r.aead == nil {
return errors.New("instance secret storage unavailable")
}
body, err := json.Marshal(values)
if err != nil {
return err
}
nonce := make([]byte, r.aead.NonceSize())
if _, err = rand.Read(nonce); err != nil {
return err
}
encrypted := r.aead.Seal(nonce, nonce, body, []byte(instanceID))
_, err = r.db.ExecContext(ctx, `INSERT INTO instance_secrets(instance_id, encrypted_values, updated_at) VALUES(?,?,?) ON CONFLICT(instance_id) DO UPDATE SET encrypted_values=excluded.encrypted_values, updated_at=excluded.updated_at`, instanceID, encrypted, r.now().UTC().Format(time.RFC3339Nano))
if err != nil {
return fmt.Errorf("save instance secrets: %w", err)
}
return nil
}
func (r *Repository) LoadInstanceSecrets(ctx context.Context, instanceID string) (map[string]string, error) {
if r.aead == nil {
return nil, errors.New("instance secret storage unavailable")
}
var encrypted []byte
if err := r.db.QueryRowContext(ctx, `SELECT encrypted_values FROM instance_secrets WHERE instance_id=?`, instanceID).Scan(&encrypted); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return map[string]string{}, nil
}
return nil, err
}
n := r.aead.NonceSize()
if len(encrypted) < n {
return nil, errors.New("invalid encrypted instance secrets")
}
body, err := r.aead.Open(nil, encrypted[:n], encrypted[n:], []byte(instanceID))
if err != nil {
return nil, errors.New("invalid encrypted instance secrets")
}
out := map[string]string{}
if err := json.Unmarshal(body, &out); err != nil {
return nil, errors.New("invalid encrypted instance secrets")
}
return out, nil
}
func (r *Repository) Sync(ctx context.Context, snapshots []catalog.Snapshot) error {
for _, snapshot := range snapshots {
var digest string
+5
View File
@@ -72,6 +72,11 @@ CREATE TABLE instances (
);
CREATE INDEX instances_template_idx ON instances(template_id, template_version);
CREATE INDEX instances_lifecycle_idx ON instances(lifecycle_state);
CREATE TABLE instance_secrets (
instance_id TEXT PRIMARY KEY REFERENCES instances(id) ON DELETE CASCADE,
encrypted_values BLOB NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE instance_memberships (
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+56 -1
View File
@@ -828,6 +828,12 @@ func (s *server) instanceInstall(w http.ResponseWriter, r *http.Request) {
s.lifecycleProblem(w, err)
return
}
// Create a stopped container first. Imports are restored before configuration
// so form values deterministically win over imported INI files.
if _, err := s.lifecycle.Install(r.Context(), current.ID); err != nil {
s.lifecycleProblem(w, err)
return
}
if current.Preview.DataOrigin == "import" {
if s.imports == nil {
s.apiProblem(w, http.StatusConflict, "import_unavailable", "The validated import is unavailable.")
@@ -849,7 +855,11 @@ func (s *server) instanceInstall(w http.ResponseWriter, r *http.Request) {
return
}
}
s.runLifecycleAction(w, r, s.lifecycle.Install)
if err := s.applyDeploymentConfiguration(r.Context(), current.ID, current.Preview); err != nil {
s.apiProblem(w, http.StatusUnprocessableEntity, "configuration_apply_failed", "The configuration could not be applied.")
return
}
s.runLifecycleAction(w, r, s.lifecycle.Start)
}
func (s *server) instanceStart(w http.ResponseWriter, r *http.Request) {
@@ -1682,11 +1692,38 @@ func (s *server) deploymentSubmit(w http.ResponseWriter, r *http.Request) {
s.render(w, 409, "deployment.html", data)
return
}
if len(secrets) != 0 {
store, ok := s.repository.(instance.SecretRepository)
if !ok || store.SaveInstanceSecrets(r.Context(), id, secrets) != nil {
data.Deployment.Error = "The secure configuration store is unavailable."
s.render(w, 502, "deployment.html", data)
return
}
}
if _, err := s.lifecycle.Install(r.Context(), id); err != nil {
data.Deployment.Error = "Installation failed. The instance is retained in an error state."
s.render(w, 502, "deployment.html", data)
return
}
if preview.DataOrigin == "import" {
mountPath := ""
for _, mount := range preview.Mounts {
if mount.ID == preview.Import.DestinationMount {
mountPath = mount.HostPath
break
}
}
if mountPath == "" || s.imports == nil || s.imports.ApplyToInstance(r.Context(), preview.Import.ID, id, preview.Template.ID, preview.Template.Version, mountPath, preview.Import.DestinationRelativePath) != nil {
data.Deployment.Error = "Installation succeeded but the backup could not be restored."
s.render(w, 422, "deployment.html", data)
return
}
}
if err := s.applyDeploymentConfiguration(r.Context(), id, preview); err != nil {
data.Deployment.Error = "Installation succeeded but configuration could not be applied."
s.render(w, 502, "deployment.html", data)
return
}
if _, err := s.lifecycle.Start(r.Context(), id); err != nil {
data.Deployment.Error = "Installation succeeded but the server could not start."
s.render(w, 502, "deployment.html", data)
@@ -1699,6 +1736,24 @@ func (s *server) deploymentSubmit(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// applyDeploymentConfiguration intentionally runs after restore: values chosen
// in DoGaMa are authoritative over any configuration contained in an import.
func (s *server) applyDeploymentConfiguration(ctx context.Context, instanceID string, preview instance.Preview) error {
secrets := map[string]string{}
if len(preview.ResolvedConfiguration.INI) != 0 {
store, ok := s.repository.(instance.SecretRepository)
if !ok {
return errors.New("secure configuration store is unavailable")
}
var err error
secrets, err = store.LoadInstanceSecrets(ctx, instanceID)
if err != nil {
return errors.New("configuration secret is unavailable")
}
}
return instance.ApplyINI(preview.Mounts, preview.ResolvedConfiguration.INI, secrets)
}
func (s *server) deploymentData(w http.ResponseWriter, r *http.Request) (pageData, catalog.Snapshot, bool) {
data, ok := s.catalogPageData(w, r)
if !ok {
+6 -1
View File
@@ -60,6 +60,7 @@
"tag": { "type": "string", "pattern": "^[a-zA-Z0-9._-]+$", "maxLength": 128 },
"entrypoint": { "type": "array", "items": { "type": "string", "maxLength": 500 }, "maxItems": 8 },
"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_]*$" } },
"assets": {
"type": "array",
"maxItems": 16,
@@ -247,7 +248,11 @@
"required": ["kind", "name"],
"properties": {
"kind": { "enum": ["environment", "argument", "ini"] },
"name": { "type": "string", "minLength": 1, "maxLength": 200 }
"name": { "type": "string", "minLength": 1, "maxLength": 200 },
"mount": { "$ref": "#/$defs/id" },
"file": { "type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[^\\u0000]+$", "maxLength": 300 },
"section": { "type": "string", "maxLength": 200 },
"key": { "type": "string", "maxLength": 200 }
}
},
"apply": { "enum": ["immediate", "restart_required"] },