{{msg "dashboard.title"}}
{{msg "dashboard.signed_in"}} {{.User.Username}}.
{{msg "dashboard.ready"}}
diff --git a/catalog/palworld/assets/icon.png b/catalog/palworld/assets/icon.png new file mode 100644 index 0000000..083935f Binary files /dev/null and b/catalog/palworld/assets/icon.png differ diff --git a/docs/architecture/docker-agent.md b/docs/architecture/docker-agent.md index 25d1279..1aeea51 100644 --- a/docs/architecture/docker-agent.md +++ b/docs/architecture/docker-agent.md @@ -69,6 +69,8 @@ Before create or replace, the agent verifies: - port protocols and container ports match the template and host ports do not conflict; - resource limits are present and within administrator limits; - labels use the reserved namespace and cannot be overridden; +- custom labels are bounded, may not use either `dogama.*` or the internal `io.dogama.*` namespace, and are merged before immutable technical labels; +- the optional Docker `User` is either an already validated numeric `UID:GID` value or omitted so the image `USER` applies; - only approved DoGaMa networks are attached. The canonical plan digest alone is not treated as approval. The agent embeds and diff --git a/docs/domain/data-model.md b/docs/domain/data-model.md index 1a7b154..6be9cdc 100644 --- a/docs/domain/data-model.md +++ b/docs/domain/data-model.md @@ -14,7 +14,7 @@ SQLite is authoritative for product state. Runtime Docker state is reconciled in | `template_versions` | Immutable validated snapshots | template_id, version, schema_version, canonical_yaml, digest | | `modules` | Module identity and trust | id, source, trust_status, active_version | | `module_versions` | Immutable installed artifacts | module_id, version, manifest, wasm_digest, path, api_range | -| `instances` | Desired and observed instance state | id, slug, display_name, template snapshot, revision, lifecycle_state, public_host | +| `instances` | Desired and observed instance state | id, derived slug, display_name, template snapshot, revision, lifecycle_state, Docker-user mode/UID/GID, image-tag mode/tag, custom labels, container_config_pending | | `instance_settings` | Typed non-secret template values | instance_id, field_id, value_json | | `instance_secrets` | Encrypted secret values | instance_id, field_id, key_version, nonce, ciphertext | | `instance_ports` | Published and private bindings | instance_id, port_id, host_ip, host_port, container_port, protocol | @@ -36,6 +36,9 @@ SQLite is authoritative for product state. Runtime Docker state is reconciled in ## Invariants - IDs are opaque and stable; slugs are unique but mutable only through a controlled rename. +- Instance slugs are derived from display names, transliterated to lowercase ASCII and recalculated on rename; they are never canonical identifiers. +- The Docker-user selection is immutable after creation. Custom UID/GID values exist only for `custom`; DoGaMa never recursively changes file ownership. +- Desired container configuration is stored separately from the applied `plan_digest`; `container_config_pending` covers any replacement-requiring change without feature-specific flags. - Released template and module versions are immutable. Editing creates a new version or an independent local copy. - An instance pins a template snapshot and module version; catalog changes do not mutate it silently. - There is at most one active mutating operation per instance. diff --git a/docs/domain/instance-lifecycle.md b/docs/domain/instance-lifecycle.md index 2edd844..0731910 100644 --- a/docs/domain/instance-lifecycle.md +++ b/docs/domain/instance-lifecycle.md @@ -49,6 +49,10 @@ The configurable failure policy is `abort_stop`, `stop_without_backup` or `force Maintenance mode blocks ordinary user starts and shows an administrator message while preserving manager/admin access. Settings specify `immediate` or `restart_required`; pending restart changes are applied together through a controlled container replacement. Keep a small redacted configuration history. +Docker label and image-tag changes use the same generic desired-versus-applied mechanism. `immediate` stops and replaces the container, restores its prior running/stopped intent and preserves every bind-mounted data path. `next_start` sets `container_config_pending`; the next explicit start pulls the desired image, replaces the container, clears the flag and starts it. A stopped instance remains stopped during immediate replacement. + +The Docker user is selected at creation (`dogama`, `custom`, or image-defined) and is never editable afterward because changing it could invalidate persistent-file permissions. Administrators must use backup, new-instance creation and restore to change ownership deliberately; DoGaMa never performs automatic recursive `chown`. + ## Crash-loop protection Track unexpected exits. Default circuit breaker: five restarts within ten minutes disables automatic restart and moves the instance to error. Manual administrator action after diagnosis resets it. Scheduled jobs do not fight this state. diff --git a/docs/operations/instance-operations.md b/docs/operations/instance-operations.md index d089650..2993bfc 100644 --- a/docs/operations/instance-operations.md +++ b/docs/operations/instance-operations.md @@ -43,6 +43,26 @@ Image updates are digest-aware. A mutable tag alone is never treated as proof th The full update sequence and rollback behavior are normative in `docs/domain/instance-lifecycle.md`. Managers may trigger only updates allowed by global/instance policy; administrators choose channels and may pin a digest. Automatic updates remain disabled by default. +## Game-container labels, users and tags + +Administrators can define global labels for game-server containers and instance-specific overrides, one `key=value` per line. Empty lines are ignored and only the first `=` separates the key. Instance labels override global labels; DoGaMa's technical labels always win. Both `dogama.*` and `io.dogama.*` are reserved. + +Values support only `{{game.name}}`, `{{game.id}}`, `{{game.icon_url}}`, `{{instance.name}}`, `{{instance.id}}`, `{{instance.slug}}` and `{{server.name}}`. Unknown or malformed variables fail validation; this is substitution, not a general template language. For example: + +```text +glance.name={{instance.name}} +glance.icon={{game.icon_url}} +glance.parent=DoGaMa +``` + +`{{game.icon_url}}` resolves to the unauthenticated, read-only `/public/game-icons/{game-id}` route. The route serves only embedded reviewed raster content with an explicit MIME type and cache policy; it is not a public catalog or administration API. + +At creation, the Docker user is either the DoGaMa process UID/GID (default), an explicitly validated numeric UID/GID, or omitted to use the image-defined user. An image without `USER` may therefore run as root. The selection is immutable after creation. + +The template's declared tag is the `tracked` default. An administrator may instead select a syntactically validated `pinned` tag and later return to tracked mode. Pinned means an explicitly selected mutable tag, not a digest: publishers can republish the same tag. Manual SHA-256 digest management is outside this milestone. + +Label and tag changes can apply immediately or at the next start. Immediate application disconnects players and recreates only the container; bind-mounted persistent data remains. Deferred application uses the generic `container_config_pending` state. + ## Template and module updates - Updating a catalog template creates a new immutable version; instances remain pinned. diff --git a/docs/security/security-and-threat-model.md b/docs/security/security-and-threat-model.md index db4ae9f..92ca56e 100644 --- a/docs/security/security-and-threat-model.md +++ b/docs/security/security-and-threat-model.md @@ -32,6 +32,7 @@ | CSRF/session theft | Secure HttpOnly SameSite cookies, CSRF token, TLS guidance, session rotation/revocation and idle/absolute expiry | | Password attack | Modern password hashing, rate limits, backoff, generic errors, repeated-failure audit/notification | | Supply-chain substitution | Immutable version snapshots, checksums, optional signatures/trust labels, digest-pinned images, controlled activation | +| Label/template injection | Structured key/value parsing, reserved namespaces, explicit substitution allowlist, no arbitrary template execution | | Destructive mistake | Preview, recent authentication, typed-name confirmation, pre-restore/update backups and recoverable workflows | | Resource exhaustion | Upload/extraction limits, job concurrency, per-instance locks, Docker limits, disk checks, notification/module bounds | | Replay/race | Signed nonce/timestamp agent calls, idempotency keys, optimistic revisions and durable operation phases | diff --git a/docs/ux/interfaces.md b/docs/ux/interfaces.md index e2b0461..909479d 100644 --- a/docs/ux/interfaces.md +++ b/docs/ux/interfaces.md @@ -54,5 +54,9 @@ Restore shows overwritten data, safety-backup behavior and server downtime. It r Global screens cover users, public address, approved storage roots (displayed, bootstrap-controlled where appropriate), catalog/modules, notification channels, audit retention/default 30 days, upload limits, disk thresholds and safety defaults. +The game-container label editor is a multiline `key=value` field with one label per line, the complete allowed-variable list, and explicit `apply immediately` versus `apply on next start` choices. Immediate application confirms that affected containers stop and are recreated, connected players disconnect, persistent data remains, and displays affected/running counts when known. + +Instance creation includes Docker-user mode, conditional custom UID/GID, tracked/pinned image tag and optional labels. Existing-instance advanced configuration displays the Docker user read-only with the backup/new-instance/restore migration explanation, permits label and tag changes with the same application choices, and visibly reports `container_config_pending`. + The audit viewer is compact and filterable by time, actor, instance, action and outcome. It is not a raw log console. diff --git a/go.mod b/go.mod index c46cd80..bb04650 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/tetratelabs/wazero v1.11.0 golang.org/x/crypto v0.53.0 golang.org/x/sys v0.47.0 + golang.org/x/text v0.38.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.56.0 ) @@ -20,7 +21,6 @@ require ( github.com/mattn/go-isatty v0.0.24 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/text v0.38.0 // indirect modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/internal/agent/agent_docker.go b/internal/agent/agent_docker.go index 8daaec4..90477f3 100644 --- a/internal/agent/agent_docker.go +++ b/internal/agent/agent_docker.go @@ -141,6 +141,7 @@ func (d *dockerRuntime) Create(ctx context.Context, plan agentwire.DeploymentPla pidsLimit := int64(512) payload := struct { Image string `json:"Image"` + User string `json:"User,omitempty"` Entrypoint []string `json:"Entrypoint,omitempty"` Cmd []string `json:"Cmd,omitempty"` Labels map[string]string `json:"Labels"` @@ -157,12 +158,12 @@ func (d *dockerRuntime) Create(ctx context.Context, plan agentwire.DeploymentPla RestartPolicy map[string]string `json:"RestartPolicy"` } `json:"HostConfig"` }{ - Image: plan.Image, Entrypoint: plan.Entrypoint, Cmd: plan.Arguments, - Labels: map[string]string{ + Image: plan.Image, User: plan.User, Entrypoint: plan.Entrypoint, Cmd: plan.Arguments, + Labels: mergeDockerLabels(plan.Labels, map[string]string{ "io.dogama.managed": "true", "io.dogama.instance-id": plan.InstanceID, "io.dogama.template-id": plan.TemplateID, "io.dogama.template-version": plan.TemplateVersion, "io.dogama.plan-digest": plan.PlanDigest, - }, + }), ExposedPorts: exposed, } payload.HostConfig.Binds = binds @@ -190,6 +191,17 @@ func (d *dockerRuntime) Create(ctx context.Context, plan agentwire.DeploymentPla return created.ID, nil } +func mergeDockerLabels(custom, technical map[string]string) map[string]string { + result := make(map[string]string, len(custom)+len(technical)) + for key, value := range custom { + result[key] = value + } + for key, value := range technical { + result[key] = value + } + return result +} + func (d *dockerRuntime) cleanupPartialCreate(ctx context.Context, name string, plan agentwire.DeploymentPlan) { inspection, err := d.Inspect(ctx, name) if err != nil || inspection.Labels["io.dogama.managed"] != "true" || inspection.Labels["io.dogama.instance-id"] != plan.InstanceID || inspection.Labels["io.dogama.plan-digest"] != plan.PlanDigest { diff --git a/internal/agent/agent_docker_test.go b/internal/agent/agent_docker_test.go index 70f9510..608e2cc 100644 --- a/internal/agent/agent_docker_test.go +++ b/internal/agent/agent_docker_test.go @@ -69,7 +69,7 @@ func TestDockerRuntimeCreatesFixedSecurityBaseline(t *testing.T) { plan := agentwire.DeploymentPlan{ InstanceID: "abcdefghijklmnopqrstuvwx", TemplateID: "palworld-official", TemplateVersion: "1.0.0", PlanDigest: strings.Repeat("a", 64), Image: "example.invalid/game:1", Ports: []agentwire.PlanPort{{ID: "game", Protocol: "udp", ContainerPort: 8211, HostPort: 38211, Publish: true}}, - Mounts: []agentwire.PlanMount{{ID: "saved", HostPath: "/srv/games/saved", ContainerPath: "/game/saved"}}, Resources: agentwire.PlanResource{CPUCores: 2, MemoryMB: 1024, StorageGB: 10}, + Mounts: []agentwire.PlanMount{{ID: "saved", HostPath: "/srv/games/saved", ContainerPath: "/game/saved"}}, Resources: agentwire.PlanResource{CPUCores: 2, MemoryMB: 1024, StorageGB: 10}, Labels: map[string]string{"dashboard.name": "Summer"}, User: "1000:1001", } if err := runtime.CheckPorts(context.Background(), plan.Ports); err != nil { t.Fatal(err) @@ -82,6 +82,7 @@ func TestDockerRuntimeCreatesFixedSecurityBaseline(t *testing.T) { t.Fatalf("container ID = %q", id) } var payload struct { + User string `json:"User"` Labels map[string]string `json:"Labels"` HostConfig struct { NetworkMode string `json:"NetworkMode"` @@ -100,6 +101,9 @@ func TestDockerRuntimeCreatesFixedSecurityBaseline(t *testing.T) { if payload.Labels["io.dogama.instance-id"] != plan.InstanceID || payload.Labels["io.dogama.plan-digest"] != plan.PlanDigest { t.Fatalf("binding labels = %#v", payload.Labels) } + if payload.Labels["dashboard.name"] != "Summer" || payload.User != "1000:1001" { + t.Fatalf("custom Docker configuration = %#v user=%q", payload.Labels, payload.User) + } } func TestDockerPingerDoesNotExposeConnectionDetails(t *testing.T) { diff --git a/internal/agent/agent_plan.go b/internal/agent/agent_plan.go index 556b937..9b942d0 100644 --- a/internal/agent/agent_plan.go +++ b/internal/agent/agent_plan.go @@ -6,6 +6,7 @@ import ( "io/fs" "path" "reflect" + "strings" "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire" "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog" @@ -71,7 +72,8 @@ func (p *PlanPolicy) Validate(plan agentwire.DeploymentPlan) error { return errors.New("unknown template snapshot") } template := snapshot.Template - if plan.Image != template.Container.Image+":"+template.Container.Tag || !reflect.DeepEqual(plan.Entrypoint, template.Container.Entrypoint) || !reflect.DeepEqual(plan.Arguments, template.Container.Arguments) || plan.StopTimeoutSeconds != template.Container.StopTimeoutSeconds { + 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 { return errors.New("container plan 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 { diff --git a/internal/agent/agent_registry.go b/internal/agent/agent_registry.go index e7ce49b..ed58452 100644 --- a/internal/agent/agent_registry.go +++ b/internal/agent/agent_registry.go @@ -115,6 +115,20 @@ func (r *Registry) Remove(instanceID string) error { return r.saveLocked(instances) } +// Replace atomically persists a new binding for an already registered instance. +func (r *Registry) Replace(entry RegisteredInstance) error { + r.mu.Lock() + defer r.mu.Unlock() + instances := append([]RegisteredInstance(nil), r.instances...) + for index := range instances { + if instances[index].InstanceID == entry.InstanceID { + instances[index] = entry + return r.saveLocked(instances) + } + } + return errors.New("instance registration not found") +} + func (r *Registry) load() error { info, err := os.Lstat(r.path) if err != nil { diff --git a/internal/agent/agent_server.go b/internal/agent/agent_server.go index f7977ae..06f4968 100644 --- a/internal/agent/agent_server.go +++ b/internal/agent/agent_server.go @@ -52,6 +52,7 @@ func NewHandlerWithDiskChecker(authenticator *Authenticator, paths *PathPolicy, mux.HandleFunc("GET /v1/instances", server.listInstances) mux.HandleFunc("POST /v1/check-ports", server.checkPorts) mux.HandleFunc("POST /v1/instances", server.createInstance) + mux.HandleFunc("PUT /v1/instances/{id}", server.replaceInstance) mux.HandleFunc("GET /v1/instances/{id}", server.inspectInstance) mux.HandleFunc("GET /v1/instances/{id}/stats", server.instanceStats) mux.HandleFunc("POST /v1/instances/{id}/start", server.startInstance) @@ -190,6 +191,60 @@ func (s *service) createInstance(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, agentwire.InstanceState{InstanceID: plan.InstanceID, ContainerID: containerID, PlanDigest: plan.PlanDigest, Health: "stopped"}) } +func (s *service) replaceInstance(w http.ResponseWriter, r *http.Request) { + var plan agentwire.DeploymentPlan + id := r.PathValue("id") + if decodeJSON(r.Body, &plan) != nil || plan.InstanceID != id || s.plans.Validate(plan) != nil { + writeProblem(w, http.StatusUnprocessableEntity, "invalid_plan", "The replacement plan is invalid.") + return + } + entry, ok := s.registry.Get(id) + if !ok { + writeProblem(w, http.StatusNotFound, "registration_not_found", "The instance is not registered.") + return + } + oldState, err := s.boundState(r.Context(), entry) + if err != nil { + writeProblem(w, http.StatusConflict, "registration_mismatch", "The registered container binding is invalid.") + return + } + for index := range plan.Mounts { + canonical, pathErr := s.paths.Resolve(plan.Mounts[index].HostPath) + if pathErr != nil { + writeProblem(w, http.StatusUnprocessableEntity, "path_not_allowed", "A deployment path is not allowed.") + return + } + plan.Mounts[index].HostPath = canonical + } + assets, err := s.prepareAssets(plan) + if err != nil { + writeProblem(w, http.StatusUnprocessableEntity, "asset_prepare_failed", "Approved template assets could not be prepared.") + return + } + if oldState.Running { + if err := s.docker.Stop(r.Context(), entry.ContainerID, plan.StopTimeoutSeconds); err != nil { + writeProblem(w, http.StatusBadGateway, "container_stop_failed", "The previous container could not be stopped.") + return + } + } + if err := s.docker.Delete(r.Context(), entry.ContainerID); err != nil { + writeProblem(w, http.StatusBadGateway, "container_delete_failed", "The previous container could not be removed.") + return + } + containerID, err := s.docker.Create(r.Context(), plan, assets) + if err != nil { + writeProblem(w, http.StatusBadGateway, "container_replace_failed", "The replacement container could not be created; persistent data was preserved.") + return + } + replacement := RegisteredInstance{InstanceID: id, ContainerID: containerID, PlanDigest: plan.PlanDigest} + if err := s.registry.Replace(replacement); err != nil { + _ = s.docker.Delete(r.Context(), containerID) + writeProblem(w, http.StatusInternalServerError, "registration_failed", "The replacement registration could not be persisted.") + return + } + writeJSON(w, http.StatusOK, agentwire.InstanceState{InstanceID: id, ContainerID: containerID, PlanDigest: plan.PlanDigest, Health: "stopped"}) +} + func (s *service) inspectInstance(w http.ResponseWriter, r *http.Request) { entry, ok := s.registration(w, r.PathValue("id")) if !ok { diff --git a/internal/agentclient/client.go b/internal/agentclient/client.go index 5c43a26..b168145 100644 --- a/internal/agentclient/client.go +++ b/internal/agentclient/client.go @@ -121,6 +121,12 @@ func (c *Client) CreateInstance(ctx context.Context, plan agentwire.DeploymentPl return state, err } +func (c *Client) ReplaceInstance(ctx context.Context, plan agentwire.DeploymentPlan) (agentwire.InstanceState, error) { + var state agentwire.InstanceState + err := c.do(ctx, http.MethodPut, instancePath(plan.InstanceID), plan, &state) + return state, err +} + func (c *Client) InspectInstance(ctx context.Context, instanceID string) (agentwire.InstanceState, error) { var state agentwire.InstanceState err := c.do(ctx, http.MethodGet, instancePath(instanceID), nil, &state) diff --git a/internal/agentwire/plan.go b/internal/agentwire/plan.go index 059e93a..a7b8e8e 100644 --- a/internal/agentwire/plan.go +++ b/internal/agentwire/plan.go @@ -9,6 +9,7 @@ import ( "path/filepath" "regexp" "sort" + "strconv" "strings" ) @@ -22,19 +23,21 @@ var ( ) type DeploymentPlan struct { - SchemaVersion int `json:"schema_version"` - InstanceID string `json:"instance_id"` - TemplateID string `json:"template_id"` - TemplateVersion string `json:"template_version"` - TemplateDigest string `json:"template_digest"` - Image string `json:"image"` - Entrypoint []string `json:"entrypoint,omitempty"` - Arguments []string `json:"arguments,omitempty"` - Ports []PlanPort `json:"ports"` - Mounts []PlanMount `json:"mounts"` - Resources PlanResource `json:"resources"` - StopTimeoutSeconds int `json:"stop_timeout_seconds"` - PlanDigest string `json:"plan_digest"` + SchemaVersion int `json:"schema_version"` + InstanceID string `json:"instance_id"` + TemplateID string `json:"template_id"` + TemplateVersion string `json:"template_version"` + TemplateDigest string `json:"template_digest"` + Image string `json:"image"` + Entrypoint []string `json:"entrypoint,omitempty"` + Arguments []string `json:"arguments,omitempty"` + Ports []PlanPort `json:"ports"` + Mounts []PlanMount `json:"mounts"` + Resources PlanResource `json:"resources"` + StopTimeoutSeconds int `json:"stop_timeout_seconds"` + Labels map[string]string `json:"labels,omitempty"` + User string `json:"user,omitempty"` + PlanDigest string `json:"plan_digest"` } type PlanPort struct { @@ -88,6 +91,26 @@ 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 { + 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"}}
{{msg "dashboard.signed_in"}} {{.User.Username}}.
{{msg "dashboard.ready"}}
{{msg "dashboard.signed_in"}} {{.User.Username}}.
{{msg "dashboard.ready"}}
{{if .IsAdmin}}