From c820c9c0e0223157720cac35b7909b603db8f3cc Mon Sep 17 00:00:00 2001 From: Tony Date: Fri, 7 Aug 2026 20:54:19 +0200 Subject: [PATCH] feat(instances): add Docker labels, user IDs, image tags and deferred recreation --- catalog/palworld/assets/icon.png | Bin 0 -> 22738 bytes docs/architecture/docker-agent.md | 2 + docs/domain/data-model.md | 5 +- docs/domain/instance-lifecycle.md | 4 + docs/operations/instance-operations.md | 20 ++ docs/security/security-and-threat-model.md | 1 + docs/ux/interfaces.md | 4 + go.mod | 2 +- internal/agent/agent_docker.go | 18 +- internal/agent/agent_docker_test.go | 6 +- internal/agent/agent_plan.go | 4 +- internal/agent/agent_registry.go | 14 + internal/agent/agent_server.go | 55 ++++ internal/agentclient/client.go | 6 + internal/agentwire/plan.go | 49 +++- internal/agentwire/plan_test.go | 16 ++ internal/instance/configuration.go | 91 ++++++ internal/instance/container_config.go | 216 ++++++++++++++ internal/instance/container_config_test.go | 62 ++++ internal/instance/lifecycle.go | 17 +- internal/instance/preview.go | 73 ++++- internal/instance/process_identity_unix.go | 9 + internal/instance/process_identity_windows.go | 8 + internal/persistence/sqlite/catalog.go | 14 +- internal/persistence/sqlite/configuration.go | 116 ++++++++ internal/web/server.go | 265 +++++++++++++++++- internal/web/server_test.go | 9 + internal/web/static/app.v1.css | 3 +- internal/web/templates/home.html | 2 +- migrations/0007_container_configuration.sql | 14 + 30 files changed, 1056 insertions(+), 49 deletions(-) create mode 100644 catalog/palworld/assets/icon.png create mode 100644 internal/instance/configuration.go create mode 100644 internal/instance/container_config.go create mode 100644 internal/instance/container_config_test.go create mode 100644 internal/instance/process_identity_unix.go create mode 100644 internal/instance/process_identity_windows.go create mode 100644 internal/persistence/sqlite/configuration.go create mode 100644 migrations/0007_container_configuration.sql diff --git a/catalog/palworld/assets/icon.png b/catalog/palworld/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..083935fb0967d06784747345dfe5ba4d9ee4c782 GIT binary patch literal 22738 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 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 { + return errors.New("invalid deployment plan container configuration") + } + 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.") { + return errors.New("invalid deployment plan label") + } + } + if p.User != "" { + parts := strings.Split(p.User, ":") + if len(parts) != 2 { + return errors.New("invalid deployment plan user") + } + for _, part := range parts { + if _, err := strconv.ParseUint(part, 10, 32); err != nil { + return errors.New("invalid deployment plan user") + } + } + } portIDs := make(map[string]struct{}, len(p.Ports)) hostPorts := make(map[string]struct{}) for _, port := range p.Ports { diff --git a/internal/agentwire/plan_test.go b/internal/agentwire/plan_test.go index e84cca9..7ade718 100644 --- a/internal/agentwire/plan_test.go +++ b/internal/agentwire/plan_test.go @@ -27,3 +27,19 @@ func TestDeploymentPlanDigestRejectsPrivilegedFieldSubstitution(t *testing.T) { t.Fatal("image substitution preserved a valid binding") } } + +func TestDeploymentPlanRejectsReservedLabelsAndInvalidUser(t *testing.T) { + plan := DeploymentPlan{SchemaVersion: DeploymentPlanVersion, InstanceID: "abcdefghijklmnopqrstuvwx", TemplateID: "palworld-official", TemplateVersion: "1.0.0", TemplateDigest: strings.Repeat("a", 64), Image: "example.invalid/game:1", Ports: []PlanPort{{ID: "game", Protocol: "udp", ContainerPort: 8211, HostPort: 38211, Publish: true}}, Mounts: []PlanMount{{ID: "saved", HostPath: filepath.Join(string(filepath.Separator), "srv", "games", "saved"), ContainerPath: "/game/saved"}}, Resources: PlanResource{CPUCores: 2, MemoryMB: 1024, StorageGB: 10}, StopTimeoutSeconds: 30, Labels: map[string]string{"dogama.managed": "false"}, User: "1000:1000"} + digest, _ := plan.CanonicalDigest() + plan.PlanDigest = digest + if err := plan.Validate(); err == nil { + t.Fatal("reserved label accepted") + } + plan.Labels = map[string]string{"dashboard.name": "server"} + plan.User = "root" + digest, _ = plan.CanonicalDigest() + plan.PlanDigest = digest + if err := plan.Validate(); err == nil { + t.Fatal("non-numeric Docker user accepted") + } +} diff --git a/internal/instance/configuration.go b/internal/instance/configuration.go new file mode 100644 index 0000000..85a953d --- /dev/null +++ b/internal/instance/configuration.go @@ -0,0 +1,91 @@ +package instance + +import ( + "context" + "errors" + "strings" +) + +type ConfigurationRepository interface { + GetGlobalLabels(context.Context) (map[string]string, error) + SetGlobalLabels(context.Context, map[string]string, bool) (affected int, running int, err error) + SaveInstanceConfiguration(context.Context, string, Preview, bool) error + ClearContainerConfigPending(context.Context, string, string) error +} + +type ConfigurationStatus struct { + InstanceID string `json:"instance_id"` + ContainerConfigPending bool `json:"container_config_pending"` + Preview Preview `json:"configuration"` +} + +func (s *LifecycleService) Configure(ctx context.Context, instanceID, labels string, tag ImageTag, immediate bool) (OperationResult, error) { + repository, ok := s.repository.(ConfigurationRepository) + if !ok { + return OperationResult{}, errors.New("container configuration is unavailable") + } + return s.exclusive(instanceID, func() (OperationResult, error) { + current, err := s.repository.GetInstance(ctx, instanceID) + if err != nil { + return OperationResult{}, err + } + parsed, err := ParseLabels(labels) + if err != nil { + return OperationResult{}, err + } + defaultTag := current.Preview.TemplateDefaultTag + if defaultTag == "" { + parts := strings.Split(current.Preview.Image, ":") + defaultTag = parts[len(parts)-1] + } + validated, err := ValidateImageTag(tag, defaultTag) + if err != nil { + return OperationResult{}, err + } + preview := current.Preview + preview.CustomLabels, preview.ImageTag = parsed, validated + base := preview.Image[:strings.LastIndex(preview.Image, ":")] + preview.Image = base + ":" + validated.Tag + if err := repository.SaveInstanceConfiguration(ctx, instanceID, preview, !immediate); err != nil { + return OperationResult{}, err + } + if !immediate || current.ContainerID == "" { + updated, _ := s.repository.GetInstance(ctx, instanceID) + return resultFrom(updated, ""), nil + } + agent, ok := s.agent.(replacementAgent) + if !ok { + return OperationResult{}, errors.New("container replacement is unavailable") + } + operationID, err := operationToken() + if err != nil { + return OperationResult{}, err + } + current, err = s.repository.BeginOperation(ctx, operationID, instanceID, "restart", "update") + if err != nil { + return OperationResult{}, err + } + plan, err := preview.DeploymentPlan(instanceID) + if err != nil { + return s.fail(ctx, operationID, instanceID, "invalid_plan", err) + } + state, err := agent.ReplaceInstance(ctx, plan) + if err != nil { + return s.fail(ctx, operationID, instanceID, "agent_replace_failed", err) + } + if current.DesiredRunning { + state, err = s.agent.StartInstance(ctx, instanceID) + if err != nil { + return s.fail(ctx, operationID, instanceID, "agent_start_failed", err) + } + } + lifecycle, observed := stateToLifecycle(state) + if err := s.repository.FinishOperation(ctx, operationID, lifecycle, observed, state.ContainerID, plan.PlanDigest, current.DesiredRunning, ""); err != nil { + return OperationResult{}, err + } + if err := repository.ClearContainerConfigPending(ctx, instanceID, plan.PlanDigest); err != nil { + return OperationResult{}, err + } + return OperationResult{OperationID: operationID, InstanceID: instanceID, State: lifecycle, Observed: observed, ContainerID: state.ContainerID, AgentState: state}, nil + }) +} diff --git a/internal/instance/container_config.go b/internal/instance/container_config.go new file mode 100644 index 0000000..3418705 --- /dev/null +++ b/internal/instance/container_config.go @@ -0,0 +1,216 @@ +package instance + +import ( + "errors" + "fmt" + "regexp" + "sort" + "strconv" + "strings" + "unicode" + + "golang.org/x/text/unicode/norm" +) + +const ( + DockerUserDoGaMa = "dogama" + DockerUserCustom = "custom" + DockerUserImage = "image" + ImageTagTracked = "tracked" + ImageTagPinned = "pinned" +) + +var ( + labelKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*(?:/[A-Za-z0-9][A-Za-z0-9_.-]*)?$`) + tagPattern = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$`) + variablePattern = regexp.MustCompile(`\{\{([^{}]+)\}\}`) +) + +var AllowedLabelVariables = []string{ + "game.name", "game.id", "game.icon_url", "instance.name", "instance.id", "instance.slug", "server.name", +} + +type DockerUser struct { + Mode string `json:"mode"` + UID *uint32 `json:"uid,omitempty"` + GID *uint32 `json:"gid,omitempty"` +} + +type ImageTag struct { + Mode string `json:"mode"` + Tag string `json:"tag"` +} + +type ContainerConfiguration struct { + Labels map[string]string `json:"labels"` + DockerUser DockerUser `json:"docker_user"` + ImageTag ImageTag `json:"image_tag"` +} + +type LabelContext struct { + GameName, GameID, GameIconURL string + InstanceName, InstanceID, InstanceSlug string + ServerName string +} + +func ParseLabels(input string) (map[string]string, error) { + labels := make(map[string]string) + for index, raw := range strings.Split(strings.ReplaceAll(input, "\r\n", "\n"), "\n") { + line := strings.TrimSpace(raw) + if line == "" { + continue + } + separator := strings.IndexByte(line, '=') + if separator < 1 { + return nil, fmt.Errorf("label line %d must use key=value", index+1) + } + key := strings.TrimSpace(line[:separator]) + if !labelKeyPattern.MatchString(key) { + return nil, fmt.Errorf("label line %d has an invalid key", index+1) + } + lower := strings.ToLower(key) + if strings.HasPrefix(lower, "dogama.") || strings.HasPrefix(lower, "io.dogama.") { + return nil, fmt.Errorf("label line %d uses a reserved DoGaMa key", index+1) + } + value := line[separator+1:] + if err := ValidateLabelTemplate(value); err != nil { + return nil, fmt.Errorf("label line %d: %w", index+1, err) + } + labels[key] = value + } + return labels, nil +} + +func ValidateLabelTemplate(value string) error { + allowed := make(map[string]bool, len(AllowedLabelVariables)) + for _, variable := range AllowedLabelVariables { + allowed[variable] = true + } + for _, match := range variablePattern.FindAllStringSubmatch(value, -1) { + if !allowed[match[1]] { + return fmt.Errorf("unknown label variable %q", match[1]) + } + } + withoutKnown := variablePattern.ReplaceAllString(value, "") + if strings.Contains(withoutKnown, "{{") || strings.Contains(withoutKnown, "}}") { + return errors.New("invalid label variable syntax") + } + return nil +} + +func ResolveLabels(labels map[string]string, context LabelContext) (map[string]string, error) { + values := map[string]string{ + "game.name": context.GameName, "game.id": context.GameID, "game.icon_url": context.GameIconURL, + "instance.name": context.InstanceName, "instance.id": context.InstanceID, "instance.slug": context.InstanceSlug, + "server.name": context.ServerName, + } + result := make(map[string]string, len(labels)) + for key, value := range labels { + if err := ValidateLabelTemplate(value); err != nil { + return nil, err + } + result[key] = variablePattern.ReplaceAllStringFunc(value, func(token string) string { + name := token[2 : len(token)-2] + return values[name] + }) + } + return result, nil +} + +func MergeLabels(technical, global, local map[string]string) map[string]string { + result := make(map[string]string, len(technical)+len(global)+len(local)) + for key, value := range global { + result[key] = value + } + for key, value := range local { + result[key] = value + } + for key, value := range technical { + result[key] = value + } + return result +} + +func FormatLabels(labels map[string]string) string { + keys := make([]string, 0, len(labels)) + for key := range labels { + keys = append(keys, key) + } + sort.Strings(keys) + lines := make([]string, 0, len(keys)) + for _, key := range keys { + lines = append(lines, key+"="+labels[key]) + } + return strings.Join(lines, "\n") +} + +func Slugify(value string) string { + decomposed := norm.NFD.String(strings.ToLower(strings.TrimSpace(value))) + var builder strings.Builder + separator := false + for _, r := range decomposed { + if unicode.Is(unicode.Mn, r) { + continue + } + if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' { + if separator && builder.Len() > 0 { + builder.WriteByte('-') + } + builder.WriteRune(r) + separator = false + } else { + separator = true + } + } + return strings.Trim(builder.String(), "-") +} + +func ValidateDockerUser(user DockerUser) error { + switch user.Mode { + case DockerUserDoGaMa, DockerUserImage: + if user.UID != nil || user.GID != nil { + return errors.New("UID and GID are only valid in custom mode") + } + case DockerUserCustom: + if user.UID == nil || user.GID == nil { + return errors.New("custom Docker user requires UID and GID") + } + default: + return errors.New("invalid Docker user mode") + } + return nil +} + +func DockerUserValue(user DockerUser, processUID, processGID uint32) (string, error) { + if err := ValidateDockerUser(user); err != nil { + return "", err + } + switch user.Mode { + case DockerUserImage: + return "", nil + case DockerUserDoGaMa: + return strconv.FormatUint(uint64(processUID), 10) + ":" + strconv.FormatUint(uint64(processGID), 10), nil + default: + return strconv.FormatUint(uint64(*user.UID), 10) + ":" + strconv.FormatUint(uint64(*user.GID), 10), nil + } +} + +func ValidateImageTag(value ImageTag, defaultTag string) (ImageTag, error) { + if value.Mode == "" { + value.Mode = ImageTagTracked + } + switch value.Mode { + case ImageTagTracked: + value.Tag = defaultTag + case ImageTagPinned: + if !tagPattern.MatchString(value.Tag) { + return ImageTag{}, errors.New("invalid pinned Docker image tag") + } + default: + return ImageTag{}, errors.New("invalid Docker image tag mode") + } + if !tagPattern.MatchString(value.Tag) { + return ImageTag{}, errors.New("invalid Docker image tag") + } + return value, nil +} diff --git a/internal/instance/container_config_test.go b/internal/instance/container_config_test.go new file mode 100644 index 0000000..6b586bf --- /dev/null +++ b/internal/instance/container_config_test.go @@ -0,0 +1,62 @@ +package instance + +import "testing" + +func TestLabelsAndVariables(t *testing.T) { + labels, err := ParseLabels("\n glance.name={{instance.name}}\nquery=a=b=c\n") + if err != nil || labels["query"] != "a=b=c" { + t.Fatalf("ParseLabels = %#v, %v", labels, err) + } + if _, err := ParseLabels("missing"); err == nil { + t.Fatal("line without separator accepted") + } + if _, err := ParseLabels("=empty"); err == nil { + t.Fatal("empty key accepted") + } + if _, err := ParseLabels("dogama.managed=false"); err == nil { + t.Fatal("reserved key accepted") + } + if _, err := ParseLabels("io.dogama.managed=false"); err == nil { + t.Fatal("technical key accepted") + } + if _, err := ParseLabels("x={{unknown}}"); err == nil { + t.Fatal("unknown variable accepted") + } + resolved, err := ResolveLabels(map[string]string{"x": "{{game.name}}/{{game.id}}/{{game.icon_url}}/{{instance.name}}/{{instance.id}}/{{instance.slug}}/{{server.name}}"}, LabelContext{GameName: "Palworld", GameID: "palworld", GameIconURL: "/public/game-icons/palworld", InstanceName: "Summer", InstanceID: "id", InstanceSlug: "summer", ServerName: "Server"}) + if err != nil || resolved["x"] != "Palworld/palworld//public/game-icons/palworld/Summer/id/summer/Server" { + t.Fatalf("ResolveLabels = %#v, %v", resolved, err) + } + merged := MergeLabels(map[string]string{"io.dogama.managed": "true"}, map[string]string{"x": "global"}, map[string]string{"x": "local", "io.dogama.managed": "false"}) + if merged["x"] != "local" || merged["io.dogama.managed"] != "true" { + t.Fatalf("MergeLabels = %#v", merged) + } +} + +func TestSlugify(t *testing.T) { + cases := map[string]string{"Mon Serveur": "mon-serveur", "Été 2026": "ete-2026", "PvE / PvP": "pve-pvp", "Serveur !!! Test": "serveur-test", "---Été///PvE###1---": "ete-pve-1"} + for input, expected := range cases { + if actual := Slugify(input); actual != expected { + t.Errorf("Slugify(%q)=%q, want %q", input, actual, expected) + } + } +} + +func TestDockerUserAndImageTag(t *testing.T) { + uid, gid := uint32(1000), uint32(1001) + if value, err := DockerUserValue(DockerUser{Mode: DockerUserDoGaMa}, 42, 43); err != nil || value != "42:43" { + t.Fatalf("dogama user = %q, %v", value, err) + } + if value, err := DockerUserValue(DockerUser{Mode: DockerUserCustom, UID: &uid, GID: &gid}, 0, 0); err != nil || value != "1000:1001" { + t.Fatalf("custom user = %q, %v", value, err) + } + if value, err := DockerUserValue(DockerUser{Mode: DockerUserImage}, 0, 0); err != nil || value != "" { + t.Fatalf("image user = %q, %v", value, err) + } + if _, err := ValidateImageTag(ImageTag{Mode: ImageTagPinned, Tag: "bad/tag"}, "stable"); err == nil { + t.Fatal("invalid tag accepted") + } + tracked, err := ValidateImageTag(ImageTag{Mode: ImageTagTracked}, "stable") + if err != nil || tracked.Tag != "stable" { + t.Fatalf("tracked tag = %#v, %v", tracked, err) + } +} diff --git a/internal/instance/lifecycle.go b/internal/instance/lifecycle.go index 6ac50fc..971b543 100644 --- a/internal/instance/lifecycle.go +++ b/internal/instance/lifecycle.go @@ -25,6 +25,7 @@ type StoredInstance struct { ContainerID string PlanDigest string DesiredRunning bool + ContainerConfigPending bool } type OperationResult struct { @@ -33,6 +34,7 @@ type OperationResult struct { State string `json:"state"` Observed string `json:"observed_state"` ContainerID string `json:"container_id,omitempty"` + ContainerConfigPending bool `json:"container_config_pending"` AgentState agentwire.InstanceState `json:"agent_state,omitempty"` } @@ -56,6 +58,8 @@ type LifecycleAgent interface { GetInstanceStats(context.Context, string) (agentwire.InstanceStats, error) } +type replacementAgent interface { ReplaceInstance(context.Context, agentwire.DeploymentPlan) (agentwire.InstanceState, error) } + type LifecycleService struct { repository LifecycleRepository agent LifecycleAgent @@ -118,7 +122,16 @@ func (s *LifecycleService) Start(ctx context.Context, instanceID string) (Operat if err != nil { return OperationResult{}, err } - state, err := s.agent.StartInstance(ctx, instanceID) + var state agentwire.InstanceState + if current.ContainerConfigPending { + replacement, ok := s.agent.(replacementAgent) + if !ok { return s.fail(ctx, operationID, instanceID, "container_replace_unavailable", errors.New("container replacement is unavailable")) } + plan, planErr := current.Preview.DeploymentPlan(instanceID) + if planErr != nil { return s.fail(ctx, operationID, instanceID, "invalid_plan", planErr) } + state, err = replacement.ReplaceInstance(ctx, plan) + if err == nil { err = s.repository.(ConfigurationRepository).ClearContainerConfigPending(ctx, instanceID, plan.PlanDigest) } + } + if err == nil { state, err = s.agent.StartInstance(ctx, instanceID) } if err != nil { return s.fail(ctx, operationID, instanceID, "agent_start_failed", err) } @@ -319,7 +332,7 @@ func stateToLifecycle(state agentwire.InstanceState) (string, string) { } func resultFrom(current StoredInstance, operationID string) OperationResult { - return OperationResult{OperationID: operationID, InstanceID: current.ID, State: current.LifecycleState, Observed: current.ObservedState, ContainerID: current.ContainerID} + return OperationResult{OperationID: operationID, InstanceID: current.ID, State: current.LifecycleState, Observed: current.ObservedState, ContainerID: current.ContainerID, ContainerConfigPending: current.ContainerConfigPending} } func operationToken() (string, error) { diff --git a/internal/instance/preview.go b/internal/instance/preview.go index e336dac..3f5b7ad 100644 --- a/internal/instance/preview.go +++ b/internal/instance/preview.go @@ -28,6 +28,10 @@ type PreviewRequest struct { DataOrigin string `json:"data_origin"` BackupRetention int `json:"backup_retention"` ImportID string `json:"import_id,omitempty"` + CustomLabels string `json:"custom_labels,omitempty"` + DockerUser DockerUser `json:"docker_user"` + ImageTag ImageTag `json:"image_tag"` + PublicBaseURL string `json:"-"` } type Preview struct { @@ -48,6 +52,19 @@ type Preview struct { 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"` +} + +type GameReference struct { + ID string `json:"id"` + Name string `json:"name"` + IconURL string `json:"icon_url"` } type TemplateReference struct { @@ -105,9 +122,32 @@ type Repository interface { // BuildPreview validates administrator choices and produces deterministic JSON. func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, error) { request.DisplayName = strings.TrimSpace(request.DisplayName) + request.Slug = Slugify(request.DisplayName) if request.DisplayName == "" || len(request.DisplayName) > 100 || !slugPattern.MatchString(request.Slug) { return Preview{}, errors.New("invalid display name or slug") } + customLabels, err := ParseLabels(request.CustomLabels) + if err != nil { + return Preview{}, err + } + if request.DockerUser.Mode == "" { + request.DockerUser.Mode = DockerUserDoGaMa + } + if err := ValidateDockerUser(request.DockerUser); err != nil { + return Preview{}, err + } + tag, err := ValidateImageTag(request.ImageTag, snapshot.Template.Container.Tag) + if err != nil { + return Preview{}, err + } + uid, gid, err := currentUIDGID() + if err != nil && request.DockerUser.Mode == DockerUserDoGaMa { + return Preview{}, err + } + userValue, err := DockerUserValue(request.DockerUser, uid, gid) + if err != nil { + return Preview{}, err + } if request.DataOrigin != "new" && request.DataOrigin != "import" { return Preview{}, errors.New("data origin must be new or import") } @@ -185,15 +225,17 @@ func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, e Template: TemplateReference{ID: snapshot.Template.ID, Version: snapshot.Template.Version, Digest: snapshot.Digest}, DisplayName: request.DisplayName, Slug: request.Slug, - Image: snapshot.Template.Container.Image + ":" + snapshot.Template.Container.Tag, + Image: snapshot.Template.Container.Image + ":" + tag.Tag, Entrypoint: append([]string(nil), snapshot.Template.Container.Entrypoint...), Arguments: append([]string(nil), snapshot.Template.Container.Arguments...), StopTimeoutSeconds: snapshot.Template.Container.StopTimeoutSeconds, StartupTimeoutSeconds: snapshot.Template.Healthcheck.StartupTimeoutSeconds, Ports: ports, Mounts: mounts, Resources: resources, Settings: settings, - DataOrigin: request.DataOrigin, - 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}, + DataOrigin: request.DataOrigin, + 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{ID: snapshot.Template.Game.ID, Name: snapshot.Template.Game.Name, IconURL: strings.TrimRight(request.PublicBaseURL, "/") + "/public/game-icons/" + snapshot.Template.Game.ID}, } if preview.Backup.RetentionCount < 1 || preview.Backup.RetentionCount > 1000 { return Preview{}, errors.New("backup retention must be between 1 and 1000") @@ -215,11 +257,34 @@ func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, e // 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) { + if p.DockerUser.Mode == "" { + p.DockerUser.Mode = DockerUserDoGaMa + } + if p.DockerUserValue == "" && p.DockerUser.Mode == DockerUserDoGaMa { + uid, gid, userErr := currentUIDGID() + if userErr != nil { + return agentwire.DeploymentPlan{}, userErr + } + p.DockerUserValue, userErr = DockerUserValue(p.DockerUser, uid, gid) + if userErr != nil { + return agentwire.DeploymentPlan{}, userErr + } + } + context := LabelContext{GameName: p.Game.Name, GameID: p.Game.ID, GameIconURL: p.Game.IconURL, InstanceName: p.DisplayName, InstanceID: instanceID, InstanceSlug: p.Slug, ServerName: p.DisplayName} + global, err := ResolveLabels(p.GlobalLabels, context) + if err != nil { + return agentwire.DeploymentPlan{}, err + } + local, err := ResolveLabels(p.CustomLabels, context) + if err != nil { + return agentwire.DeploymentPlan{}, err + } 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...), + 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, } diff --git a/internal/instance/process_identity_unix.go b/internal/instance/process_identity_unix.go new file mode 100644 index 0000000..4c18f73 --- /dev/null +++ b/internal/instance/process_identity_unix.go @@ -0,0 +1,9 @@ +//go:build unix + +package instance + +import "golang.org/x/sys/unix" + +func currentUIDGID() (uint32, uint32, error) { + return uint32(unix.Getuid()), uint32(unix.Getgid()), nil +} diff --git a/internal/instance/process_identity_windows.go b/internal/instance/process_identity_windows.go new file mode 100644 index 0000000..9ce6315 --- /dev/null +++ b/internal/instance/process_identity_windows.go @@ -0,0 +1,8 @@ +//go:build windows + +package instance + +// Windows is a development/test host only; Linux deployment resolves the real process identity. +func currentUIDGID() (uint32, uint32, error) { + return 1000, 1000, nil +} diff --git a/internal/persistence/sqlite/catalog.go b/internal/persistence/sqlite/catalog.go index 3ddd83c..3799d4f 100644 --- a/internal/persistence/sqlite/catalog.go +++ b/internal/persistence/sqlite/catalog.go @@ -103,8 +103,8 @@ func (r *Repository) CreateDraft(ctx context.Context, draft instance.Draft) erro if err != nil { return fmt.Errorf("encode draft preview: %w", err) } - _, err = r.db.ExecContext(ctx, `INSERT INTO instances(id, slug, display_name, template_id, template_version, template_digest, revision, lifecycle_state, preview_json, plan_digest, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, 1, 'draft', ?, ?, ?, ?)`, draft.ID, draft.Preview.Slug, draft.Preview.DisplayName, draft.Preview.Template.ID, draft.Preview.Template.Version, draft.Preview.Template.Digest, string(previewJSON), draft.Preview.PlanDigest, now, now) + _, err = r.db.ExecContext(ctx, `INSERT INTO instances(id, slug, display_name, template_id, template_version, template_digest, revision, lifecycle_state, preview_json, plan_digest, custom_labels_json, docker_user_mode, docker_uid, docker_gid, image_tag_mode, image_tag, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 1, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, draft.ID, draft.Preview.Slug, draft.Preview.DisplayName, draft.Preview.Template.ID, draft.Preview.Template.Version, draft.Preview.Template.Digest, string(previewJSON), draft.Preview.PlanDigest, string(mustJSON(draft.Preview.CustomLabels)), draft.Preview.DockerUser.Mode, draft.Preview.DockerUser.UID, draft.Preview.DockerUser.GID, draft.Preview.ImageTag.Mode, draft.Preview.ImageTag.Tag, now, now) if err != nil { return fmt.Errorf("create draft instance: %w", err) } @@ -112,11 +112,11 @@ func (r *Repository) CreateDraft(ctx context.Context, draft instance.Draft) erro } func (r *Repository) GetInstance(ctx context.Context, id string) (instance.StoredInstance, error) { - return scanInstance(r.db.QueryRowContext(ctx, `SELECT id, preview_json, lifecycle_state, observed_state, COALESCE(container_id, ''), plan_digest, desired_running FROM instances WHERE id=? AND deleted_at IS NULL`, id)) + return scanInstance(r.db.QueryRowContext(ctx, `SELECT id, preview_json, lifecycle_state, observed_state, COALESCE(container_id, ''), plan_digest, desired_running, container_config_pending FROM instances WHERE id=? AND deleted_at IS NULL`, id)) } func (r *Repository) ListLifecycleInstances(ctx context.Context) ([]instance.StoredInstance, error) { - rows, err := r.db.QueryContext(ctx, `SELECT id, preview_json, lifecycle_state, observed_state, COALESCE(container_id, ''), plan_digest, desired_running FROM instances WHERE deleted_at IS NULL AND lifecycle_state != 'draft' ORDER BY id`) + rows, err := r.db.QueryContext(ctx, `SELECT id, preview_json, lifecycle_state, observed_state, COALESCE(container_id, ''), plan_digest, desired_running, container_config_pending FROM instances WHERE deleted_at IS NULL AND lifecycle_state != 'draft' ORDER BY id`) if err != nil { return nil, fmt.Errorf("list lifecycle instances: %w", err) } @@ -138,7 +138,8 @@ func scanInstance(row rowScanner) (instance.StoredInstance, error) { var value instance.StoredInstance var previewJSON string var desired int - err := row.Scan(&value.ID, &previewJSON, &value.LifecycleState, &value.ObservedState, &value.ContainerID, &value.PlanDigest, &desired) + var pending int + err := row.Scan(&value.ID, &previewJSON, &value.LifecycleState, &value.ObservedState, &value.ContainerID, &value.PlanDigest, &desired, &pending) if errors.Is(err, sql.ErrNoRows) { return instance.StoredInstance{}, instance.ErrInstanceNotFound } @@ -149,6 +150,7 @@ func scanInstance(row rowScanner) (instance.StoredInstance, error) { return instance.StoredInstance{}, fmt.Errorf("decode instance preview: %w", err) } value.DesiredRunning = desired != 0 + value.ContainerConfigPending = pending != 0 return value, nil } @@ -158,7 +160,7 @@ func (r *Repository) BeginOperation(ctx context.Context, operationID, instanceID return instance.StoredInstance{}, fmt.Errorf("begin instance operation: %w", err) } defer func() { _ = tx.Rollback() }() - current, err := scanInstance(tx.QueryRowContext(ctx, `SELECT id, preview_json, lifecycle_state, observed_state, COALESCE(container_id, ''), plan_digest, desired_running FROM instances WHERE id=? AND deleted_at IS NULL`, instanceID)) + current, err := scanInstance(tx.QueryRowContext(ctx, `SELECT id, preview_json, lifecycle_state, observed_state, COALESCE(container_id, ''), plan_digest, desired_running, container_config_pending FROM instances WHERE id=? AND deleted_at IS NULL`, instanceID)) if err != nil { return instance.StoredInstance{}, err } diff --git a/internal/persistence/sqlite/configuration.go b/internal/persistence/sqlite/configuration.go new file mode 100644 index 0000000..0702504 --- /dev/null +++ b/internal/persistence/sqlite/configuration.go @@ -0,0 +1,116 @@ +package sqlite + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance" +) + +const globalLabelsKey = "game_container_labels" + +func (r *Repository) GetGlobalLabels(ctx context.Context) (map[string]string, error) { + var body string + err := r.db.QueryRowContext(ctx, `SELECT value_json FROM system_settings WHERE key=?`, globalLabelsKey).Scan(&body) + if errors.Is(err, sql.ErrNoRows) { + return map[string]string{}, nil + } + if err != nil { + return nil, fmt.Errorf("load global game-container labels: %w", err) + } + var labels map[string]string + if err := json.Unmarshal([]byte(body), &labels); err != nil { + return nil, fmt.Errorf("decode global game-container labels: %w", err) + } + return labels, nil +} + +func (r *Repository) SetGlobalLabels(ctx context.Context, labels map[string]string, pending bool) (int, int, error) { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return 0, 0, err + } + defer func() { _ = tx.Rollback() }() + now := r.now().UTC().Format(time.RFC3339Nano) + body := string(mustJSON(labels)) + if _, err := tx.ExecContext(ctx, `INSERT INTO system_settings(key,value_json,revision,updated_at) VALUES(?,?,1,?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json, revision=system_settings.revision+1, updated_at=excluded.updated_at`, globalLabelsKey, body, now); err != nil { + return 0, 0, err + } + rows, err := tx.QueryContext(ctx, `SELECT id, preview_json, desired_running FROM instances WHERE deleted_at IS NULL AND lifecycle_state!='draft'`) + if err != nil { + return 0, 0, err + } + type update struct { + id, body string + running bool + } + var updates []update + for rows.Next() { + var id, previewBody string + var running int + if err := rows.Scan(&id, &previewBody, &running); err != nil { + rows.Close() + return 0, 0, err + } + var preview instance.Preview + if json.Unmarshal([]byte(previewBody), &preview) != nil { + rows.Close() + return 0, 0, errors.New("decode instance configuration") + } + preview.GlobalLabels = labels + updates = append(updates, update{id: id, body: string(mustJSON(preview)), running: running != 0}) + } + rows.Close() + running := 0 + pendingValue := 0 + if pending { + pendingValue = 1 + } + for _, update := range updates { + if update.running { + running++ + } + if _, err := tx.ExecContext(ctx, `UPDATE instances SET preview_json=?, container_config_pending=?, revision=revision+1, updated_at=? WHERE id=?`, update.body, pendingValue, now, update.id); err != nil { + return 0, 0, err + } + } + if err := tx.Commit(); err != nil { + return 0, 0, err + } + return len(updates), running, nil +} + +func (r *Repository) SaveInstanceConfiguration(ctx context.Context, id string, preview instance.Preview, pending bool) error { + body := string(mustJSON(preview)) + labels := string(mustJSON(preview.CustomLabels)) + pendingValue := 0 + if pending { + pendingValue = 1 + } + result, err := r.db.ExecContext(ctx, `UPDATE instances SET preview_json=?, custom_labels_json=?, image_tag_mode=?, image_tag=?, container_config_pending=?, revision=revision+1, updated_at=? WHERE id=? AND deleted_at IS NULL`, body, labels, preview.ImageTag.Mode, preview.ImageTag.Tag, pendingValue, r.now().UTC().Format(time.RFC3339Nano), id) + if err != nil { + return fmt.Errorf("save instance container configuration: %w", err) + } + changed, _ := result.RowsAffected() + if changed != 1 { + return instance.ErrInstanceNotFound + } + return nil +} + +func (r *Repository) ClearContainerConfigPending(ctx context.Context, id, planDigest string) error { + _, err := r.db.ExecContext(ctx, `UPDATE instances SET container_config_pending=0, plan_digest=?, updated_at=? WHERE id=?`, planDigest, r.now().UTC().Format(time.RFC3339Nano), id) + return err +} + +func mustJSON(value any) []byte { + body, err := json.Marshal(value) + if err != nil { + panic(err) + } + return body +} diff --git a/internal/web/server.go b/internal/web/server.go index b3c8691..9a73900 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -14,8 +14,10 @@ import ( "io" "log/slog" "net/http" + "regexp" "time" + catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog" "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth" "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/authorization" "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/backup" @@ -73,10 +75,12 @@ type repository interface { } type pageData struct { - Title string - CSRFToken string - Error string - User auth.User + Title string + CSRFToken string + Error string + User auth.User + GlobalLabels string + IsAdmin bool } // NewHandler constructs the complete HTTP application. @@ -119,6 +123,7 @@ func newHandlerWithImports(authService *auth.Service, repository repository, lif } mux := http.NewServeMux() if repository != nil { + mux.HandleFunc("GET /public/game-icons/{gameID}", s.publicGameIcon) mux.HandleFunc("GET /api/v1/catalog", s.catalogList) mux.HandleFunc("POST /api/v1/instances/preview", s.instancePreview) mux.HandleFunc("POST /api/v1/instances/drafts", s.instanceDraft) @@ -127,6 +132,8 @@ func newHandlerWithImports(authService *auth.Service, repository repository, lif mux.HandleFunc("POST /api/v1/installation-requests/{id}/review", s.installationRequestReview) mux.HandleFunc("GET /api/v1/admin/users", s.userList) mux.HandleFunc("POST /api/v1/admin/users", s.userCreate) + mux.HandleFunc("GET /api/v1/admin/game-container-labels", s.globalLabelsGet) + mux.HandleFunc("PUT /api/v1/admin/game-container-labels", s.globalLabelsPut) mux.HandleFunc("GET /api/v1/instances/{id}/memberships", s.membershipList) mux.HandleFunc("PUT /api/v1/instances/{id}/memberships/{userID}", s.membershipSet) mux.HandleFunc("DELETE /api/v1/instances/{id}/memberships/{userID}", s.membershipDelete) @@ -140,6 +147,8 @@ func newHandlerWithImports(authService *auth.Service, repository repository, lif mux.HandleFunc("POST /api/v1/instances/{id}/stop", s.instanceStop) mux.HandleFunc("POST /api/v1/instances/{id}/restart", s.instanceRestart) mux.HandleFunc("DELETE /api/v1/instances/{id}", s.instanceDeleteContainer) + mux.HandleFunc("GET /api/v1/instances/{id}/container-configuration", s.instanceConfigurationGet) + mux.HandleFunc("PUT /api/v1/instances/{id}/container-configuration", s.instanceConfigurationPut) } if backupService != nil { mux.HandleFunc("GET /api/v1/instances/{id}/backups", s.backupList) @@ -159,21 +168,229 @@ func newHandlerWithImports(authService *auth.Service, repository repository, lif mux.HandleFunc("GET /login", s.loginForm) mux.HandleFunc("POST /login", s.loginSubmit) mux.HandleFunc("POST /logout", s.logout) + mux.HandleFunc("POST /admin/game-container-labels", s.globalLabelsForm) mux.HandleFunc("GET /", s.home) return s.securityHeaders(mux), nil } +var publicGameIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) + +func (s *server) publicGameIcon(w http.ResponseWriter, r *http.Request) { + gameID := r.PathValue("gameID") + if !publicGameIDPattern.MatchString(gameID) || gameID != "palworld" { + http.NotFound(w, r) + return + } + body, err := catalogdata.Files.ReadFile("palworld/assets/icon.png") + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Cache-Control", "public, max-age=86400") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) +} + +func requestBaseURL(r *http.Request) string { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + if forwarded := r.Header.Get("X-Forwarded-Proto"); forwarded == "http" || forwarded == "https" { + scheme = forwarded + } + return scheme + "://" + r.Host +} + +type applicationModeRequest struct { + Apply string `json:"apply"` +} + +func (s *server) globalLabelsGet(w http.ResponseWriter, r *http.Request) { + actor, ok := s.requireAPIUser(w, r, false) + if !ok { + return + } + if actor.Role != "admin" { + s.apiProblem(w, http.StatusForbidden, "forbidden", "Administrator access is required.") + return + } + repository := s.repository.(instance.ConfigurationRepository) + labels, err := repository.GetGlobalLabels(r.Context()) + if err != nil { + s.apiProblem(w, 500, "settings_unavailable", "The settings are unavailable.") + return + } + s.apiJSON(w, http.StatusOK, map[string]any{"labels": instance.FormatLabels(labels), "variables": instance.AllowedLabelVariables}) +} + +func (s *server) globalLabelsPut(w http.ResponseWriter, r *http.Request) { + actor, ok := s.requireAPIUser(w, r, true) + if !ok { + return + } + if err := s.permissions.RequireRecentAdmin(actor); err != nil { + s.authorizationProblem(w, err) + return + } + var request struct { + Labels string `json:"labels"` + Apply string `json:"apply"` + } + if !s.decodeStrictJSON(w, r, &request) { + return + } + labels, err := instance.ParseLabels(request.Labels) + if err != nil { + s.apiProblem(w, 422, "invalid_labels", err.Error()) + return + } + if request.Apply != "immediate" && request.Apply != "next_start" { + s.apiProblem(w, 422, "invalid_apply_mode", "Apply must be immediate or next_start.") + return + } + if request.Apply == "immediate" && s.lifecycle == nil { + s.apiProblem(w, http.StatusConflict, "lifecycle_unavailable", "Immediate application requires the Docker agent.") + return + } + repository := s.repository.(instance.ConfigurationRepository) + affected, running, err := repository.SetGlobalLabels(r.Context(), labels, true) + if err != nil { + s.apiProblem(w, 500, "settings_update_failed", "The settings could not be updated.") + return + } + if request.Apply == "immediate" && s.lifecycle != nil { + for _, current := range mustLifecycleInstances(r.Context(), s.repository) { + if _, err := s.lifecycle.Configure(r.Context(), current.ID, instance.FormatLabels(current.Preview.CustomLabels), current.Preview.ImageTag, true); err != nil { + s.apiProblem(w, 502, "container_replace_failed", "Global labels were saved, but one or more containers could not be recreated.") + return + } + } + } + s.apiJSON(w, http.StatusOK, map[string]any{"labels": instance.FormatLabels(labels), "affected_instances": affected, "running_instances": running, "container_config_pending": request.Apply == "next_start"}) +} + +func (s *server) globalLabelsForm(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes) + if r.ParseForm() != nil { + s.problem(w, 400, message("error.form")) + return + } + actor, err := s.currentUser(r) + session, sessionErr := r.Cookie(sessionCookie) + if err != nil || sessionErr != nil || actor.Role != "admin" || !s.auth.ValidateCSRF(r.Context(), session.Value, r.FormValue("csrf_token")) { + s.problem(w, http.StatusForbidden, message("error.csrf")) + return + } + if err := s.permissions.RequireRecentAdmin(actor); err != nil { + s.problem(w, http.StatusForbidden, "Recent administrator authentication is required.") + return + } + labels, err := instance.ParseLabels(r.FormValue("labels")) + if err != nil { + s.problem(w, 422, err.Error()) + return + } + apply := r.FormValue("apply") + if apply != "immediate" && apply != "next_start" { + s.problem(w, 422, "Invalid application mode.") + return + } + if apply == "immediate" && r.FormValue("confirm_disconnection") != "yes" { + s.problem(w, 422, "Confirm that players will be disconnected.") + return + } + repository := s.repository.(instance.ConfigurationRepository) + if _, _, err := repository.SetGlobalLabels(r.Context(), labels, true); err != nil { + s.problem(w, 500, message("error.internal")) + return + } + if apply == "immediate" { + if s.lifecycle == nil { + s.problem(w, 409, "The Docker agent is unavailable.") + return + } + for _, current := range mustLifecycleInstances(r.Context(), s.repository) { + if _, err := s.lifecycle.Configure(r.Context(), current.ID, instance.FormatLabels(current.Preview.CustomLabels), current.Preview.ImageTag, true); err != nil { + s.problem(w, 502, "The settings were saved, but a container could not be recreated.") + return + } + } + } + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func mustLifecycleInstances(ctx context.Context, repository repository) []instance.StoredInstance { + values, err := repository.ListLifecycleInstances(ctx) + if err != nil { + return nil + } + return values +} + +func (s *server) instanceConfigurationGet(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceView); !ok { + return + } + current, err := s.repository.GetInstance(r.Context(), r.PathValue("id")) + if err != nil { + s.lifecycleProblem(w, err) + return + } + s.apiJSON(w, 200, map[string]any{"custom_labels": instance.FormatLabels(current.Preview.CustomLabels), "docker_user": current.Preview.DockerUser, "image_tag": current.Preview.ImageTag, "container_config_pending": current.ContainerConfigPending, "docker_user_immutable": true}) +} + +func (s *server) instanceConfigurationPut(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceConfigure); !ok { + return + } + var request struct { + Labels string `json:"labels"` + ImageTag instance.ImageTag `json:"image_tag"` + Apply string `json:"apply"` + } + if !s.decodeStrictJSON(w, r, &request) { + return + } + if request.Apply != "immediate" && request.Apply != "next_start" { + s.apiProblem(w, 422, "invalid_apply_mode", "Apply must be immediate or next_start.") + return + } + result, err := s.lifecycle.Configure(r.Context(), r.PathValue("id"), request.Labels, request.ImageTag, request.Apply == "immediate") + if err != nil { + s.apiProblem(w, 422, "invalid_container_configuration", err.Error()) + return + } + s.apiJSON(w, 200, result) +} + +func (s *server) decodeStrictJSON(w http.ResponseWriter, r *http.Request, value any) bool { + r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if decoder.Decode(value) != nil || decoder.Decode(&struct{}{}) != io.EOF { + s.apiProblem(w, 400, "invalid_request", "The request is invalid.") + return false + } + return true +} + type previewAPIRequest struct { - TemplateID string `json:"template_id"` - TemplateVersion string `json:"template_version"` - DisplayName string `json:"display_name"` - Slug string `json:"slug"` - HostPorts map[string]int `json:"host_ports"` - MountPaths map[string]string `json:"mount_paths"` - Resources catalog.Resources `json:"resources"` - DataOrigin string `json:"data_origin"` - BackupRetention int `json:"backup_retention"` - ImportID string `json:"import_id"` + TemplateID string `json:"template_id"` + TemplateVersion string `json:"template_version"` + DisplayName string `json:"display_name"` + Slug string `json:"slug"` + HostPorts map[string]int `json:"host_ports"` + MountPaths map[string]string `json:"mount_paths"` + Resources catalog.Resources `json:"resources"` + DataOrigin string `json:"data_origin"` + BackupRetention int `json:"backup_retention"` + ImportID string `json:"import_id"` + CustomLabels string `json:"custom_labels"` + DockerUser instance.DockerUser `json:"docker_user"` + ImageTag instance.ImageTag `json:"image_tag"` } func (s *server) catalogList(w http.ResponseWriter, r *http.Request) { @@ -376,11 +593,21 @@ func (s *server) buildAPIPreview(w http.ResponseWriter, r *http.Request) (previe DisplayName: request.DisplayName, Slug: request.Slug, HostPorts: request.HostPorts, MountPaths: request.MountPaths, Resources: request.Resources, DataOrigin: request.DataOrigin, BackupRetention: request.BackupRetention, ImportID: request.ImportID, + CustomLabels: request.CustomLabels, DockerUser: request.DockerUser, ImageTag: request.ImageTag, + PublicBaseURL: requestBaseURL(r), }) if err != nil { s.apiProblem(w, http.StatusUnprocessableEntity, "invalid_preview", "The deployment preview is invalid.") return request, instance.Preview{}, false } + if configured, ok := s.repository.(instance.ConfigurationRepository); ok { + labels, labelErr := configured.GetGlobalLabels(r.Context()) + if labelErr != nil { + s.apiProblem(w, http.StatusInternalServerError, "settings_unavailable", "The global settings are unavailable.") + return request, instance.Preview{}, false + } + preview.GlobalLabels = labels + } return request, preview, true } @@ -888,7 +1115,15 @@ func (s *server) home(w http.ResponseWriter, r *http.Request) { s.problem(w, http.StatusForbidden, message("error.csrf")) return } - s.render(w, http.StatusOK, "home.html", pageData{Title: message("dashboard.title"), User: user, CSRFToken: csrf.Value}) + data := pageData{Title: message("dashboard.title"), User: user, CSRFToken: csrf.Value, IsAdmin: user.Role == "admin"} + if data.IsAdmin && s.repository != nil { + if configured, ok := s.repository.(instance.ConfigurationRepository); ok { + if labels, labelErr := configured.GetGlobalLabels(r.Context()); labelErr == nil { + data.GlobalLabels = instance.FormatLabels(labels) + } + } + } + s.render(w, http.StatusOK, "home.html", data) } func (s *server) currentUser(r *http.Request) (auth.User, error) { diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 157defe..549536a 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -74,6 +74,15 @@ func TestCatalogPreviewAndDraftAPIAuthorization(t *testing.T) { if err != nil { t.Fatal(err) } + icon := request(t, handler, http.MethodGet, "/public/game-icons/palworld", nil) + assertStatus(t, icon, http.StatusOK) + if icon.Header().Get("Content-Type") != "image/png" { + t.Fatalf("icon content type = %q", icon.Header().Get("Content-Type")) + } + traversal := request(t, handler, http.MethodGet, "/public/game-icons/..%2Fprivate", nil) + if traversal.Code == http.StatusOK { + t.Fatal("icon traversal accepted") + } unauthenticated := request(t, handler, http.MethodGet, "/api/v1/catalog", nil) assertStatus(t, unauthenticated, http.StatusUnauthorized) sessionCookieValue := &http.Cookie{Name: sessionCookie, Value: session.Token} diff --git a/internal/web/static/app.v1.css b/internal/web/static/app.v1.css index bcf038a..77fd0cf 100644 --- a/internal/web/static/app.v1.css +++ b/internal/web/static/app.v1.css @@ -5,7 +5,8 @@ header { display: flex; justify-content: space-between; align-items: center; pad form { display: grid; gap: 1rem; } header form { display: block; } label { display: grid; gap: .35rem; font-weight: 600; } -input, button { box-sizing: border-box; padding: .7rem; font: inherit; border: 1px solid #8a94a6; border-radius: .35rem; } +input, textarea, button { box-sizing: border-box; padding: .7rem; font: inherit; border: 1px solid #8a94a6; border-radius: .35rem; } +.warning { padding: .75rem; border-left: .25rem solid #b54708; background: #fffaeb; color: #7a2e0e; } button { border: 0; background: #3157d5; color: white; font-weight: 700; cursor: pointer; } .error { padding: .75rem; border-left: .25rem solid #b42318; background: #fee4e2; color: #7a271a; } @media (prefers-color-scheme: dark) { body { background: #111827; color: #e5e7eb; } main, header { background: #1f2937; } input { background: #111827; color: #e5e7eb; } } diff --git a/internal/web/templates/home.html b/internal/web/templates/home.html index 4d0335e..21c57c1 100644 --- a/internal/web/templates/home.html +++ b/internal/web/templates/home.html @@ -1,3 +1,3 @@ {{define "home.html"}} {{.Title}} · DoGaMa -
{{msg "brand"}}

{{msg "dashboard.title"}}

{{msg "dashboard.signed_in"}} {{.User.Username}}.

{{msg "dashboard.ready"}}

{{end}} +
{{msg "brand"}}

{{msg "dashboard.title"}}

{{msg "dashboard.signed_in"}} {{.User.Username}}.

{{msg "dashboard.ready"}}

{{if .IsAdmin}}

Game-container labels

One label per line. Available variables: {{`{{game.name}}`}}, {{`{{game.id}}`}}, {{`{{game.icon_url}}`}}, {{`{{instance.name}}`}}, {{`{{instance.id}}`}}, {{`{{instance.slug}}`}}, {{`{{server.name}}`}}.

{{end}}
{{end}} diff --git a/migrations/0007_container_configuration.sql b/migrations/0007_container_configuration.sql new file mode 100644 index 0000000..52be01c --- /dev/null +++ b/migrations/0007_container_configuration.sql @@ -0,0 +1,14 @@ +ALTER TABLE instances ADD COLUMN custom_labels_json TEXT NOT NULL DEFAULT '{}'; +ALTER TABLE instances ADD COLUMN docker_user_mode TEXT NOT NULL DEFAULT 'dogama' CHECK (docker_user_mode IN ('dogama', 'custom', 'image')); +ALTER TABLE instances ADD COLUMN docker_uid INTEGER CHECK (docker_uid BETWEEN 0 AND 4294967295); +ALTER TABLE instances ADD COLUMN docker_gid INTEGER CHECK (docker_gid BETWEEN 0 AND 4294967295); +ALTER TABLE instances ADD COLUMN image_tag_mode TEXT NOT NULL DEFAULT 'tracked' CHECK (image_tag_mode IN ('tracked', 'pinned')); +ALTER TABLE instances ADD COLUMN image_tag TEXT NOT NULL DEFAULT ''; +ALTER TABLE instances ADD COLUMN container_config_pending INTEGER NOT NULL DEFAULT 0 CHECK (container_config_pending IN (0, 1)); + +CREATE TABLE system_settings ( + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1), + updated_at TEXT NOT NULL +);