feat(instances): apply template configuration before startup
CI / validate (pull_request) Successful in 21m5s
CI / validate (pull_request) Successful in 21m5s
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.") {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
Reference in New Issue
Block a user