Author SHA1 Message Date
tony d68d97d00a Merge pull request 'Codex/milestone 9' (#11) from codex/milestone-9 into main
Reviewed-on: #11
2026-08-07 22:58:39 +02:00
codex 349d34ce20 feat(web): expose milestone 9 administration 2026-08-07 20:53:01 +00:00
codex 3fbaa65eea feat(observability): add notification and audit services 2026-08-07 20:50:26 +00:00
tony 1e226d3ada Merge pull request 'Codex/milestone 8' (#10) from codex/milestone-8 into main
Reviewed-on: #10
2026-08-07 22:13:00 +02:00
codex 92c5e11bbc docs: require milestone push and web UI validation 2026-08-07 20:09:14 +00:00
codex 9eb4fe2cd8 docs: record milestone 8 delivery 2026-08-07 20:01:05 +00:00
codex 7f5fa30d0f feat(instances): add controlled updates mods and rollback history 2026-08-07 20:00:44 +00:00
tony f9676ef9cd Merge pull request 'docs: add incremental milestone workflow' (#9) from codex/docs-incremental-workflow into main
Reviewed-on: #9
2026-08-07 21:23:34 +02:00
tony b30e6c5122 docs: add incremental milestone workflow 2026-08-07 20:59:25 +02:00
tony c820c9c0e0 feat(instances): add Docker labels, user IDs, image tags and deferred recreation 2026-08-07 20:54:19 +02:00
tony 5783474955 Merge pull request 'Add DoGaMa brand banner' (#8) from codex/readme-brand-banner into main
Reviewed-on: #8
Reviewed-by: tony <1+tony@noreply.localhost>
2026-08-07 18:50:25 +02:00
codex 1e0628974c docs: add DoGaMa brand banner 2026-08-07 16:47:28 +00:00
tony e65aef8315 Merge pull request 'feat: establish WebAssembly module runtime' (#7) from feat/wasm-runtime-foundation into main
Reviewed-on: #7
2026-08-07 18:41:07 +02:00
48 changed files with 3099 additions and 171 deletions
+68 -99
View File
@@ -1,97 +1,79 @@
# Instructions for Codex and automated contributors
Read `README.md` and the relevant documents under `docs/` before changing implementation or contracts.
Work incrementally. Do not perform a repository-wide audit unless the user explicitly requests one.
## Non-negotiable rules
## Start every task
1. Preserve the product invariants in `README.md`.
2. Do not give the main application direct Docker-socket access.
3. Do not implement integrations as native plugins, host executables, scripts, or sidecar containers. Integrations are WebAssembly adapters only.
4. Do not add arbitrary command execution, arbitrary Docker API proxying, arbitrary host paths, or unrestricted network access.
5. Treat templates and module manifests as untrusted input. Validate them against `specs/*.schema.json` before persistence or execution.
6. Keep secrets out of API responses, logs, audit payloads, exports, error messages, and test fixtures.
7. Preserve player data and backups by default in every deletion, update, restore, and migration workflow.
8. Keep `compose.yaml` minimal. Product settings belong in the database and web interface unless they are bootstrap secrets, bind roots, or network/listen settings required before startup.
9. SQLite is the V1 database. Do not introduce an external database, message broker, Kubernetes, or distributed-node design without an accepted architecture decision.
10. Palworld is the reference integration. Any contract change affecting templates, modules, backups, permissions, or instance lifecycles must be checked against both Palworld examples.
1. Run `git status --short --branch` and `git log --oneline -5`.
2. Read this file and `docs/PROJECT-STATE.md`.
3. Read only the current milestone/request and the domain documents it directly affects.
4. Use `git show`, `git diff`, `rg` and file-specific reads to locate the relevant implementation and tests.
5. Treat existing working-tree changes as user-owned. Never overwrite, discard, stage or reformat unrelated work.
## Change workflow
Do not reread all documentation, list every source file, concatenate large files, or emit thousands of log lines when a targeted query is sufficient. Start with targeted tests and concise output; expand diagnostics only after a failure.
- Locate the normative document first.
- State assumptions when requirements are ambiguous; do not silently invent security-sensitive behavior.
- Update documentation, schema, example, implementation, and tests together when a contract changes.
- Prefer small Go packages with explicit interfaces and dependency direction.
- Add migrations for persisted data changes. Never edit an already released migration.
- Use deterministic serialization and stable identifiers.
- Validate JSON Schemas and YAML examples in automated checks.
- Add negative tests for authorization, path validation, module capabilities, archive extraction, and agent operation scope.
- Report what was validated and what still needs physical or integration testing.
## Product invariants
## Codex operating workflow
- The main application never accesses the Docker socket. Only the private restricted agent does.
- Integrations are WebAssembly adapters only: no native plugins, host scripts, executables or sidecars.
- Never add arbitrary commands, Docker API proxying, host paths or unrestricted network access.
- Validate untrusted templates and manifests against `specs/*.schema.json`.
- Never expose secrets in APIs, logs, audit events, exports, errors or fixtures.
- Preserve player data and backups by default in deletion, update, restore and migration workflows.
- Keep `compose.yaml` minimal; ordinary product settings belong in SQLite and the web UI.
- SQLite is the V1 database. Keep the single-host architecture unless an accepted decision changes it.
- Palworld is the reference integration. Check both Palworld examples when a contract affects templates, modules, backups, permissions or instance lifecycle.
These rules are permanent repository policy. A `codex exec` prompt should name
the objective and constraints specific to the task, then rely on this file
instead of repeating the repository workflow.
Stop and report any request that would weaken these boundaries.
### Git, branches and releases
## Change rules
- Start by reading the current branch and working-tree state. Treat existing
changes as user-owned and do not overwrite, discard, stage or reformat
unrelated work.
- Git commands required by the current task are allowed on working branches.
Before any Git write, verify the active branch. Never switch to, modify,
commit on, merge into, rebase, reset, delete or push `main`; if work starts
on `main`, stop before writing and use or request a working branch.
- Commits and pushes on non-`main` branches are allowed only when they are
necessary for the stated task. Do not infer that implementation alone
requires publication, and always report the operations performed.
- Codex may create merge/pull requests from working branches when delivery
requires review. Codex must never approve or merge them; leave approval and
fusion into `main` to an authorized user in Gitea.
- Do not alter remotes, credentials or repository-wide Git configuration
unless the task explicitly requires it.
- Never use destructive recovery commands such as `git reset --hard`,
`git clean`, or checkout-based file restoration without explicit approval
and a verified target list.
- Work only inside this repository unless the task explicitly names another location. Never send repository contents or local data to external services.
- Treat network access, dependency installation, host configuration and persistent services as opt-in; request approval when required.
- State assumptions instead of inventing security-sensitive behavior.
- Make the smallest coherent change; avoid unrelated redesigns.
- Enforce authorization and validation in the backend, not only the UI.
- Update documentation, schema, examples, implementation and tests together when a contract changes.
- Prefer small Go packages, explicit interfaces, deterministic serialization and stable identifiers.
- Add a new migration for persisted changes; never edit a released migration.
- Add negative tests for authorization, paths, archives, module capabilities and agent operation scope when relevant.
- Open detailed documents under `docs/` only when their domain is affected. `README.md` is required only when product invariants, the documentation map or top-level status changes.
### Security and scope
## Git workflow
- Work only inside this repository unless the task explicitly names another
location. Do not expose credentials, tokens, cookies, database contents or
private keys in commands, logs, fixtures or reports.
- Preserve every trust boundary and non-negotiable product rule above. Stop and
report a conflict instead of weakening authentication, authorization,
validation, isolation, redaction or data-preservation behavior.
- Inspect before editing, make the smallest coherent change, and preserve
user-owned changes. Do not modify generated or synchronized files unless the
repository documents that workflow.
- Do not install system packages, start persistent services, modify host
configuration or use elevated privileges without explicit approval.
- Work only on a non-`main` feature branch. If the task starts on `main`, create or request a working branch before editing.
- Never modify, commit on, merge into, rebase, reset, delete or push `main`.
- Develop each milestone on its own dedicated working branch.
- After a milestone's validations and commits, always push its working branch to Gitea using the `codex` account. The milestone is not complete until the remote branch exists.
- After pushing, create a pull request from the working branch to `main` when the available tools permit it. If automatic creation is unavailable, provide the URL or exact information needed to open it immediately.
- Never approve or merge a Gitea pull request.
- Do not alter remotes, credentials or repository-wide Git configuration unless explicitly requested.
- Never use destructive recovery commands such as `git reset --hard`, `git clean`, or checkout-based restoration without explicit approval and a verified target list.
- Before committing, review `git status`, `git diff --stat`, the complete relevant diff and `git diff --check`.
### Network and dependencies
## Standard milestone procedure
- Treat network access as opt-in. Use it only when the task requires current
primary documentation or dependency retrieval.
- Prefer existing pinned dependencies and repository tools. Review changes to
`go.mod` and `go.sum`; do not add an unrelated dependency or execute code
fetched from an untrusted source.
- Never send repository contents, secrets or local data to external services.
Record any validation skipped because the network was unavailable.
1. Read `AGENTS.md` and `docs/PROJECT-STATE.md`.
2. Read only the current milestone specification.
3. Inspect recent commits and the diff from the relevant baseline.
4. Locate affected files with targeted searches.
5. Implement the smallest complete change.
6. Run targeted tests first.
7. Run the applicable global validations.
8. Update `docs/PROJECT-STATE.md` with the new baseline, delivered behavior, durable decisions, limitations and next work.
9. Review the final diff and validation status, then commit the completed milestone.
10. Push the working branch to Gitea with the `codex` account and create, or provide the exact link to create, a pull request to `main`.
### Caches and temporary files
## Validation
- Keep Codex-created caches and temporary artifacts outside tracked source
paths, preferably under `.cache/codex/` or an OS temporary directory.
- Reuse caches when safe. Do not delete or purge shared Go, Python, linter,
container or user caches unless explicitly requested.
- Do not leave binaries, databases, coverage files, logs or temporary patches
in the repository. Before finishing, remove only artifacts created by the
current task and confirmed safe to remove.
For documentation-only changes:
### Required validation
```sh
python tools/validate_spec.py
git diff --check
```
For Go implementation changes, run the applicable complete validation set from
the repository root:
For Go changes, run the applicable full set from the repository root after targeted tests:
```sh
gofmt -w <changed-go-files>
@@ -102,33 +84,20 @@ go test -race ./...
go vet ./...
staticcheck ./...
golangci-lint run
python3 tools/validate_spec.py
python tools/validate_spec.py
git diff --check
```
Use already installed tools when possible. If a required tool or Python module
is unavailable, do not silently install it or omit the check: report the exact
blocker and request approval when installation or network access is needed.
Run narrower tests during development, but run the full applicable set before
declaring completion. A passing build does not replace tests, race detection,
static analysis or specification validation.
Use installed tools and pinned dependencies. Do not silently install missing tools; report the exact blocker. Keep caches under `.cache/codex/` or an OS temporary directory and remove only artifacts created by the current task.
### Completion report
For changes that affect the web interface, exercise the relevant screens and states in a real browser when the environment permits it. Capture screenshots and use them to check at least the overall rendering, alignment, overflow, labels, primary states, relevant responsive behavior and obvious visual regressions. Screenshots are local validation artifacts and must not be committed unless explicitly requested or another project rule requires it. If browser validation or screenshots are technically unavailable, state that explicitly in the completion report.
- Summarize behavior changed and list every modified, created or removed file.
- List each validation command with pass, fail or not-run status and the exact
blocker for anything incomplete.
- Report the final branch and working-tree state, while distinguishing changes
made by Codex from changes that were already present.
- State explicitly whether commits, tags, pushes, branch changes, external
writes or persistent host changes occurred. Never claim success for a check
that was not run to completion.
## Completion report
## Definition of done for a change
- Summarize behavior and contract changes.
- List modified, created and removed files.
- Report each validation as pass, fail or not run with the exact blocker.
- Report the final branch and working-tree state, distinguishing prior changes from yours.
- Report commits, pushes, branch changes, external writes and persistent host changes explicitly.
- Relevant requirements and acceptance criteria are satisfied.
- Authorization is enforced in the backend, not only hidden in the UI.
- Audit and notification behavior is deliberate.
- Failure and rollback behavior is covered.
- Documentation and machine-readable examples agree.
- Tests cover success, denial, and interruption paths.
A change is complete only when success, denial and interruption behavior relevant to its scope are deliberate, documentation and machine-readable contracts agree, and unfinished integration or physical validation is reported.
+3
View File
@@ -1,5 +1,7 @@
# DoGaMa
![DoGaMa synthwave banner](docs/assets/dogama-brand-banner.png)
DoGaMa is a lightweight, self-hosted manager for private game servers running as Docker containers. It is designed for families and small groups of friends, not for commercial hosting or general Docker administration.
This repository currently contains the normative product and engineering specification. Implementation must follow the documents and machine-readable contracts linked below.
@@ -44,6 +46,7 @@ This repository currently contains the normative product and engineering specifi
### Contributor contracts
- [Current operational project state](docs/PROJECT-STATE.md)
- [Development conventions](docs/contributing/development.md)
- [AI and Codex contributor guide](docs/contributing/ai-codex-guide.md)
- [Template schema](specs/template.schema.json)
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+47 -2
View File
@@ -9,16 +9,19 @@ import (
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentclient"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/backup"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/importexport"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/web"
)
@@ -55,6 +58,21 @@ func run(logger *slog.Logger) error {
var handler http.Handler
var lifecycle *instance.LifecycleService
var backupService *backup.Service
auditService := audit.New(db)
var notificationService *notification.Service
if keyFile := os.Getenv("DOGAMA_MASTER_KEY_FILE"); keyFile != "" {
key, keyErr := os.ReadFile(keyFile)
if keyErr != nil {
return errors.New("read encryption key file")
}
key = bytes.TrimSpace(key)
notificationService, keyErr = notification.New(db, key)
if keyErr != nil {
return keyErr
}
} else {
logger.Warn("notification channel configuration disabled: DOGAMA_MASTER_KEY_FILE is unset", "event", "notification.disabled")
}
importService, err := importexport.New(repository, environment("DOGAMA_IMPORTS_ROOT", "/var/lib/dogama/imports/staging"), serversRoot)
if err != nil {
return err
@@ -62,7 +80,6 @@ func run(logger *slog.Logger) error {
agentURL, tokenFile := os.Getenv("DOGAMA_AGENT_URL"), os.Getenv("DOGAMA_AGENT_TOKEN_FILE")
if agentURL == "" && tokenFile == "" {
logger.Warn("instance lifecycle disabled", "event", "lifecycle.disabled")
handler, err = web.NewHandlerWithRepositoryAndImports(auth.New(db), repository, importService, logger)
} else {
if agentURL == "" || tokenFile == "" {
return errors.New("DOGAMA_AGENT_URL and DOGAMA_AGENT_TOKEN_FILE must be configured together")
@@ -90,8 +107,8 @@ func run(logger *slog.Logger) error {
logger.Warn("instance reconciliation incomplete", "event", "lifecycle.reconcile.failed")
}
cancel()
handler, err = web.NewHandlerWithLifecycleAndBackup(auth.New(db), repository, agent, backupService, importService, logger)
}
handler, err = web.NewHandlerComplete(auth.New(db), repository, lifecycle, backupService, importService, auditService, notificationService, logger)
if err != nil {
return err
}
@@ -102,6 +119,7 @@ func run(logger *slog.Logger) error {
go runBackupScheduler(ctx, backupService, logger)
}
go runImportCleanup(ctx, importService, logger)
go runObservabilityScheduler(ctx, auditService, notificationService, logger)
server := &http.Server{
Addr: listenAddress,
Handler: handler,
@@ -128,6 +146,33 @@ func run(logger *slog.Logger) error {
}
}
func runObservabilityScheduler(ctx context.Context, auditService *audit.Service, notificationService *notification.Service, logger *slog.Logger) {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
lastPurgeDay := ""
for {
select {
case <-ctx.Done():
return
case now := <-ticker.C:
if notificationService != nil {
if err := notificationService.RunDue(ctx); err != nil {
logger.Warn("notification delivery run incomplete", "event", "notification.scheduler.failed")
}
}
day := now.UTC().Format("2006-01-02")
if day != lastPurgeDay {
if deleted, err := auditService.RunRetention(ctx); err != nil {
logger.Warn("audit retention incomplete", "event", "audit.retention.failed")
} else if deleted > 0 {
_ = auditService.Record(ctx, audit.Event{ActorLabel: "system", Action: "audit.retention.purge", Outcome: "allowed", Summary: map[string]string{"deleted_count": strconv.FormatInt(deleted, 10)}})
}
lastPurgeDay = day
}
}
}
}
func runImportCleanup(ctx context.Context, service *importexport.Service, logger *slog.Logger) {
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
+72
View File
@@ -0,0 +1,72 @@
# DoGaMa project state
Read this compact operational baseline before starting a milestone. Open detailed domain documents only when the current work affects them.
## Baseline
- Current reference: milestone 9 working branch after merged milestone 8 baseline `1e226d3`.
- Released SQLite migrations: `0001` through `0009`; never rewrite them.
- Roadmap milestones 1-9 are implemented.
## Architecture
- Go main application: HTTP API, embedded server-rendered UI, authentication/authorization, SQLite, workflows, backups and WASM runtime.
- Private restricted Go agent: sole Docker-socket owner; authenticated typed API; registered-instance and plan-digest binding; no generic Docker proxy.
- Integrations: capability-scoped WebAssembly adapters only. Palworld REST is the reference module.
- Data: SQLite plus canonical allowed server, import and backup roots. Persistent game data lives outside containers.
- Contracts: declarative YAML templates and module manifests validated against JSON Schemas; released snapshots are immutable.
## Implemented capabilities
- Bootstrap administrator, local authentication, secure sessions and CSRF protection.
- Validated embedded catalog, deterministic deployment previews and instance registry.
- Restricted instance create/inspect/start/stop/restart/delete and reconciliation.
- Per-instance memberships, overrides and installation requests with backend authorization.
- Backup scheduling/retention, safe imports, export and restore with safety backups.
- Sandboxed WASM runtime and normalized module API with Palworld reference adapter.
- Game-container configuration: global and per-instance labels, safe label variables, derived instance slug, immutable Docker-user selection, tracked/pinned image tags, immediate or deferred container recreation, and public game-icon route.
- Controlled digest-aware game updates with confirmation, policy-driven pre-update backups, readiness verification, mod warnings and automatic container-plan rollback.
- Redacted configuration history retained to the latest 10 revisions, with pinned-template revalidation and immediate or deferred rollback.
- Declarative Steam Workshop item configuration with numeric-ID validation, stable ordering and backend `mods.manage` enforcement.
- Encrypted write-only SMTP, generic HTTPS webhook and Discord channels with event filters, queued test delivery, bounded retry and redacted terminal errors.
- SSRF-resistant HTTPS webhook delivery with redirect/address revalidation, event IDs, timestamps and optional HMAC-SHA256 signatures.
- Compact allow-listed audit events for authentication and significant mutations, administrator filtering, bounded manual purge, daily retention and maximum-count enforcement.
## Durable decisions
- Editable Docker labels apply only to game-server instance containers.
- Labels on the DoGaMa application container remain Compose configuration and are never read, copied or edited by DoGaMa.
- Merge order is global labels, then instance labels; instance values win. Internal technical labels are applied last and cannot be overridden.
- `dogama.*` and `io.dogama.*` are reserved label namespaces.
- Label values support only the explicit allowlist in `internal/instance/container_config.go`; unknown variables are errors, not arbitrary templates.
- `{{game.icon_url}}` is the public icon for the game. `{{instance.slug}}` remains supported.
- Instance slugs are derived from the display name, not canonical IDs. Accents are normalized to ASCII; whitespace, `/`, punctuation and special characters become safe hyphen separators; repeated and edge hyphens are removed.
- Docker user mode is fixed at creation to DoGaMa UID/GID, custom numeric UID/GID, or image-defined user. Never perform automatic recursive ownership changes.
- A pinned image tag is an explicit mutable tag, not an immutable digest. Tracked mode follows the template's declared default tag.
- Replacement-requiring changes use the generic `container_config_pending` desired-versus-applied state. Replacements preserve bind-mounted data and prior running/stopped intent.
- The main app never gains Docker-socket access; the agent remains deny-by-default and independently validates privileged plan fields.
- Update candidates are explicit `tag@sha256:digest` references. Mutable tags alone are rejected; automatic updates remain disabled.
- Mod configuration is data-only. Provider commands, scripts and arbitrary download URLs are forbidden.
- Notification configuration is unavailable unless `DOGAMA_MASTER_KEY_FILE` contains exactly 32 bytes; ciphertext is authenticated AES-GCM and secrets are never returned by list APIs.
- Notification delivery attempts are capped at five with exponential minute-scale backoff and never determine the originating operation result.
- Audit retention defaults to 30 days and 10,000 entries; zero explicitly selects unlimited retention/count within documented bounds.
## Known limitations and debt
- Release hardening remains roadmap work.
- Scheduled backup outcomes and repeated authentication blocks are audited/logged, but broader scheduler-origin notification coverage remains intentionally limited to events emitted by implemented workflows.
- The web interface is intentionally modest; several advanced workflows are API-first.
- Linux is the deployment target. Native Windows execution of the full Go suite is blocked by Unix `Statfs` code; use Linux/WSL/CI for complete execution.
- `staticcheck`, `golangci-lint` and Python specification dependencies may not be installed on every development host; report missing tooling rather than silently skipping or installing it.
## Validation and CI
- No repository-hosted Gitea/GitHub workflow files are currently present.
- Normal completion gate for Go changes is the validation set in `AGENTS.md` on Linux.
- Specification validation is `python tools/validate_spec.py` with `tools/requirements-validation.txt` available.
- Start with package/file-specific tests, then run global tests, build, race detection, vet, static analysis and schema validation as applicable.
## Next known work
- Roadmap milestone 10: security hardening, end-to-end tests, contributor documentation and release packaging.
- Update this file at the end of every merged milestone or durable architectural change; keep it compact and remove stale statements.
+2
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+1 -1
View File
@@ -28,7 +28,7 @@ tests/integration/
## Initial application development
The initial main application requires Go 1.25. SQLite is provided by the pure-Go `modernc.org/sqlite` driver, so neither cgo nor a system SQLite development library is required. It reads bootstrap settings from `DOGAMA_LISTEN_ADDRESS` (default `:8080`), `DOGAMA_DATABASE_PATH` (default `dogama.db`), `DOGAMA_AGENT_URL` and `DOGAMA_AGENT_TOKEN_FILE`. The two agent settings must either both be present or both be absent; lifecycle routes remain disabled when developing without an agent. Run it with:
The initial main application requires Go 1.25. SQLite is provided by the pure-Go `modernc.org/sqlite` driver, so neither cgo nor a system SQLite development library is required. It reads bootstrap settings from `DOGAMA_LISTEN_ADDRESS` (default `:8080`), `DOGAMA_DATABASE_PATH` (default `dogama.db`), `DOGAMA_AGENT_URL`, `DOGAMA_AGENT_TOKEN_FILE` and `DOGAMA_MASTER_KEY_FILE`. The two agent settings must either both be present or both be absent; lifecycle routes remain disabled when developing without an agent. The master-key file must contain exactly 32 bytes and enables encrypted notification-channel configuration; audit remains available without it. Run it with:
```sh
go run ./cmd/dogama
+4 -1
View File
@@ -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.
+4
View File
@@ -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.
+26 -1
View File
@@ -31,18 +31,44 @@ The template states destination mount, ordering, restart requirement, dependency
DoGaMa clearly labels unofficial mod support and never assumes a server update is compatible with installed mods.
The V1 implementation accepts only Steam Workshop numeric item IDs when the pinned template explicitly declares that provider. It persists a normalized declarative list and rejects provider commands, scripts, arbitrary URLs and changes for templates without mod support.
## Configuration application
Each template field declares `apply: immediate` or `restart_required`. Secret fields are write-only. Validate types, ranges, patterns and conflicts on both client and server. A preview lists pending changes and whether container replacement or game restart is needed.
Keep the last 10 redacted configuration revisions by default. Rollback revalidates the old revision against the pinned template/module versions before applying it.
Every desired container or mod change creates a revision, listed newest first. Rollback never restores secrets, never changes the immutable Docker user and may be immediate or deferred until the next explicit start.
## Updates
Image updates are digest-aware. A mutable tag alone is never treated as proof that nothing changed. The UI shows current and candidate references, template release notes if available, mod warnings and whether a backup will run.
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.
The V1 API requires an explicit candidate tag and SHA-256 image digest plus confirmation. A required `pre_update` backup must finish before replacement. Readiness is bounded by the template timeout; failed replacement, start or readiness restores the prior image/configuration plan when rollback is enabled, without restoring player data.
## 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.
@@ -50,4 +76,3 @@ The full update sequence and rollback behavior are normative in `docs/domain/ins
- Local copied templates are independent and are never overwritten by their origin.
- Module packages update independently and require compatibility plus connection tests before activation.
- Rollback keeps the prior template snapshot, module binary and container plan available until the new combination is verified.
+12 -1
View File
@@ -10,6 +10,14 @@ Administrators configure channels entirely in the UI:
Channel secrets are encrypted and write-only. A test action sends a clearly marked test message and reports a redacted result.
The main application reads the 32-byte authenticated-encryption key from
`DOGAMA_MASTER_KEY_FILE`. Without that external key, audit remains available
but channel configuration and delivery are disabled. Generic and Discord
webhooks require HTTPS; resolution, redirects and every resolved address reject
loopback, private, link-local, multicast and unspecified networks. Generic
webhooks carry `X-DoGaMa-Event-ID`, `X-DoGaMa-Timestamp` and, when a signing
secret is configured, an HMAC-SHA256 `X-DoGaMa-Signature`.
## Events and filtering
Suggested configurable events:
@@ -58,5 +66,8 @@ Entries are compact and use allow-listed structured summaries. Player IDs may be
Audit retention defaults to 30 days and is globally administrator-configurable. An optional maximum count prevents unbounded growth. Purge runs daily and records one aggregate audit event, not an event per deleted row. Unlimited retention requires an explicit warning and displays database usage. Administrators may manually purge by date with confirmation.
Technical application logs go to stdout/stderr and use Docker log rotation. Their level and retention are separate from SQLite audit policy.
The V1 bounds are 03650 retention days and 01,000,000 entries; zero means
unlimited. The viewer returns at most 200 entries per request and supports time,
actor, instance, action and outcome filters through the administration API.
Technical application logs go to stdout/stderr and use Docker log rotation. Their level and retention are separate from SQLite audit policy.
@@ -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 |
+4
View File
@@ -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.
+1 -1
View File
@@ -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
+15 -3
View File
@@ -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 {
+5 -1
View File
@@ -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) {
+3 -1
View File
@@ -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 {
+14
View File
@@ -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 {
+55
View File
@@ -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 {
+6
View File
@@ -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)
+36 -13
View File
@@ -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 {
+16
View File
@@ -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")
}
}
+178
View File
@@ -0,0 +1,178 @@
// Package audit stores the deliberately small, redacted security audit trail.
package audit
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
type Event struct {
ID string `json:"id"`
ActorID string `json:"actor_id"`
ActorLabel string `json:"actor_label"`
InstanceID string `json:"instance_id"`
Action string `json:"action"`
Outcome string `json:"outcome"`
OccurredAt time.Time `json:"occurred_at"`
Summary map[string]string `json:"summary"`
}
type Filter struct {
ActorID, InstanceID, Action, Outcome string
Since, Until time.Time
Limit int
}
type Policy struct {
RetentionDays int `json:"retention_days"`
MaximumCount int `json:"maximum_count"`
}
type Service struct {
db *sql.DB
now func() time.Time
}
func New(db *sql.DB) *Service { return &Service{db: db, now: time.Now} }
var allowedSummaryKeys = map[string]bool{"target_id": true, "target_name": true, "channel_type": true, "event_type": true, "reason_code": true, "deleted_count": true, "before": true, "after": true}
func (s *Service) Record(ctx context.Context, event Event) error {
if event.Action == "" || (event.Outcome != "allowed" && event.Outcome != "denied" && event.Outcome != "failed") {
return errors.New("invalid audit event")
}
clean := map[string]string{}
for key, value := range event.Summary {
if allowedSummaryKeys[key] && len(value) <= 200 {
clean[key] = value
}
}
body, _ := json.Marshal(clean)
when := event.OccurredAt
if when.IsZero() {
when = s.now().UTC()
}
var actor, instance any
if event.ActorID != "" {
actor = event.ActorID
}
if event.InstanceID != "" {
instance = event.InstanceID
}
_, err := s.db.ExecContext(ctx, `INSERT INTO audit_events(id,occurred_at,actor_id,actor_label,instance_id,action,outcome,summary_json) VALUES(?,?,?,?,?,?,?,?)`, randomID(), when.Format(time.RFC3339Nano), actor, event.ActorLabel, instance, event.Action, event.Outcome, string(body))
if err != nil {
return fmt.Errorf("record audit event: %w", err)
}
return nil
}
func (s *Service) List(ctx context.Context, f Filter) ([]Event, error) {
limit := f.Limit
if limit <= 0 || limit > 200 {
limit = 100
}
clauses, args := []string{"1=1"}, []any{}
for _, item := range []struct{ column, value string }{{"actor_id", f.ActorID}, {"instance_id", f.InstanceID}, {"action", f.Action}, {"outcome", f.Outcome}} {
if item.value != "" {
clauses = append(clauses, item.column+"=?")
args = append(args, item.value)
}
}
if !f.Since.IsZero() {
clauses = append(clauses, "occurred_at>=?")
args = append(args, f.Since.UTC().Format(time.RFC3339Nano))
}
if !f.Until.IsZero() {
clauses = append(clauses, "occurred_at<?")
args = append(args, f.Until.UTC().Format(time.RFC3339Nano))
}
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, `SELECT id,occurred_at,COALESCE(actor_id,''),actor_label,COALESCE(instance_id,''),action,outcome,summary_json FROM audit_events WHERE `+strings.Join(clauses, " AND ")+` ORDER BY occurred_at DESC,id DESC LIMIT ?`, args...)
if err != nil {
return nil, fmt.Errorf("list audit events: %w", err)
}
defer rows.Close()
var events []Event
for rows.Next() {
var e Event
var occurred, body string
if err := rows.Scan(&e.ID, &occurred, &e.ActorID, &e.ActorLabel, &e.InstanceID, &e.Action, &e.Outcome, &body); err != nil {
return nil, err
}
e.OccurredAt, _ = time.Parse(time.RFC3339Nano, occurred)
_ = json.Unmarshal([]byte(body), &e.Summary)
events = append(events, e)
}
return events, rows.Err()
}
func (s *Service) Policy(ctx context.Context) (Policy, error) {
var body string
err := s.db.QueryRowContext(ctx, `SELECT value_json FROM system_settings WHERE key='audit_policy'`).Scan(&body)
if err != nil {
return Policy{}, err
}
var p Policy
var raw struct {
RetentionDays int `json:"retention_days"`
MaximumCount int `json:"maximum_count"`
}
if err = json.Unmarshal([]byte(body), &raw); err != nil {
return p, err
}
p.RetentionDays, p.MaximumCount = raw.RetentionDays, raw.MaximumCount
return p, nil
}
func (s *Service) SetPolicy(ctx context.Context, p Policy) error {
if p.RetentionDays < 0 || p.RetentionDays > 3650 || p.MaximumCount < 0 || p.MaximumCount > 1000000 {
return errors.New("audit policy is out of bounds")
}
body, _ := json.Marshal(map[string]int{"retention_days": p.RetentionDays, "maximum_count": p.MaximumCount})
_, err := s.db.ExecContext(ctx, `UPDATE system_settings SET value_json=?,revision=revision+1,updated_at=? WHERE key='audit_policy'`, body, s.now().UTC().Format(time.RFC3339Nano))
return err
}
func (s *Service) Purge(ctx context.Context, before time.Time) (int64, error) {
if before.IsZero() || before.After(s.now().UTC()) {
return 0, errors.New("invalid audit purge boundary")
}
result, err := s.db.ExecContext(ctx, `DELETE FROM audit_events WHERE occurred_at < ?`, before.UTC().Format(time.RFC3339Nano))
if err != nil {
return 0, err
}
return result.RowsAffected()
}
func (s *Service) RunRetention(ctx context.Context) (int64, error) {
p, err := s.Policy(ctx)
if err != nil {
return 0, err
}
var total int64
if p.RetentionDays > 0 {
n, e := s.Purge(ctx, s.now().UTC().AddDate(0, 0, -p.RetentionDays))
if e != nil {
return 0, e
}
total += n
}
if p.MaximumCount > 0 {
result, e := s.db.ExecContext(ctx, `DELETE FROM audit_events WHERE id IN (SELECT id FROM audit_events ORDER BY occurred_at DESC,id DESC LIMIT -1 OFFSET ?)`, p.MaximumCount)
if e != nil {
return total, e
}
n, _ := result.RowsAffected()
total += n
}
return total, nil
}
func randomID() string {
b := make([]byte, 18)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
+55
View File
@@ -0,0 +1,55 @@
package audit_test
import (
"context"
"path/filepath"
"testing"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
)
func TestRecordFiltersSummaryAndRetention(t *testing.T) {
ctx := context.Background()
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
service := audit.New(db)
old := time.Now().UTC().AddDate(0, 0, -40)
if err := service.Record(ctx, audit.Event{OccurredAt: old, ActorLabel: "admin", Action: "instance.update", Outcome: "allowed", Summary: map[string]string{"target_name": "Palworld", "secret": "must-not-persist"}}); err != nil {
t.Fatal(err)
}
events, err := service.List(ctx, audit.Filter{Action: "instance.update"})
if err != nil || len(events) != 1 {
t.Fatalf("events=%#v err=%v", events, err)
}
if events[0].Summary["target_name"] != "Palworld" || events[0].Summary["secret"] != "" {
t.Fatalf("summary was not allow-listed: %#v", events[0].Summary)
}
if err := service.SetPolicy(ctx, audit.Policy{RetentionDays: 30, MaximumCount: 100}); err != nil {
t.Fatal(err)
}
deleted, err := service.RunRetention(ctx)
if err != nil || deleted != 1 {
t.Fatalf("deleted=%d err=%v", deleted, err)
}
}
func TestPolicyAndPurgeBounds(t *testing.T) {
ctx := context.Background()
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
service := audit.New(db)
if err := service.SetPolicy(ctx, audit.Policy{RetentionDays: -1}); err == nil {
t.Fatal("negative retention accepted")
}
if _, err := service.Purge(ctx, time.Now().Add(time.Hour)); err == nil {
t.Fatal("future purge accepted")
}
}
+12
View File
@@ -96,6 +96,18 @@ type Template struct {
DestinationRelativePath string `json:"destination_relative_path"`
RequiresStoppedServer bool `json:"requires_stopped_server"`
} `json:"imports"`
Mods struct {
Supported bool `json:"supported"`
Provider string `json:"provider,omitempty"`
DestinationMount string `json:"destination_mount,omitempty"`
RestartRequired bool `json:"restart_required"`
} `json:"mods"`
Updates struct {
BackupBeforeUpdate bool `json:"backup_before_update"`
AutomaticDefault bool `json:"automatic_default"`
RollbackOnFailure bool `json:"rollback_on_failure"`
HealthTimeoutSeconds int `json:"health_timeout_seconds"`
} `json:"updates"`
}
type Resources struct {
+226
View File
@@ -0,0 +1,226 @@
package instance
import (
"context"
"errors"
"regexp"
"strings"
"time"
)
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, string, string) error
ClearContainerConfigPending(context.Context, string, string) error
ListConfigurationRevisions(context.Context, string) ([]ConfigurationRevision, error)
GetConfigurationRevision(context.Context, string, int) (ConfigurationRevision, error)
}
type ConfigurationRevision struct {
InstanceID string `json:"instance_id"`
Revision int `json:"revision"`
Snapshot Preview `json:"snapshot"`
Reason string `json:"reason"`
CreatedBy string `json:"created_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
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 := imageRepository(preview.Image)
preview.Image = base + ":" + validated.Tag
if err := repository.SaveInstanceConfiguration(ctx, instanceID, preview, !immediate, "container_configuration", ""); 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
})
}
func imageRepository(reference string) string {
if at := strings.Index(reference, "@"); at >= 0 {
reference = reference[:at]
}
if colon := strings.LastIndex(reference, ":"); colon > strings.LastIndex(reference, "/") {
return reference[:colon]
}
return reference
}
var workshopIDPattern = regexp.MustCompile(`^[1-9][0-9]{0,19}$`)
func (s *LifecycleService) ConfigureMods(ctx context.Context, instanceID string, items []string, immediate bool, actorID string) (OperationResult, error) {
return s.exclusive(instanceID, func() (OperationResult, error) {
current, err := s.repository.GetInstance(ctx, instanceID)
if err != nil {
return OperationResult{}, err
}
if !current.Preview.Mods.Supported {
return OperationResult{}, errors.New("template does not support mods")
}
if current.Preview.Mods.Provider != "steam_workshop" {
return OperationResult{}, errors.New("mod provider is not implemented safely")
}
if len(items) > 256 {
return OperationResult{}, errors.New("too many mods")
}
seen := map[string]struct{}{}
for _, id := range items {
if !workshopIDPattern.MatchString(id) {
return OperationResult{}, errors.New("invalid Steam Workshop item ID")
}
if _, exists := seen[id]; exists {
return OperationResult{}, errors.New("duplicate mod item ID")
}
seen[id] = struct{}{}
}
items = append([]string(nil), items...)
preview := current.Preview
preview.Mods.Items = items
repository := s.repository.(ConfigurationRepository)
if err := repository.SaveInstanceConfiguration(ctx, instanceID, preview, !immediate, "mods", actorID); err != nil {
return OperationResult{}, err
}
if !immediate || current.ContainerID == "" {
updated, _ := s.repository.GetInstance(ctx, instanceID)
return resultFrom(updated, ""), nil
}
return s.replaceConfigured(ctx, current, preview, "restart")
})
}
func (s *LifecycleService) RollbackConfiguration(ctx context.Context, instanceID string, revision int, immediate bool, actorID string) (OperationResult, error) {
return s.exclusive(instanceID, func() (OperationResult, error) {
repository := s.repository.(ConfigurationRepository)
current, err := s.repository.GetInstance(ctx, instanceID)
if err != nil {
return OperationResult{}, err
}
old, err := repository.GetConfigurationRevision(ctx, instanceID, revision)
if err != nil {
return OperationResult{}, err
}
if old.Snapshot.Template != current.Preview.Template {
return OperationResult{}, errors.New("revision template no longer matches pinned template")
}
old.Snapshot.DockerUser = current.Preview.DockerUser
old.Snapshot.DockerUserValue = current.Preview.DockerUserValue
if _, err := ValidateImageTag(old.Snapshot.ImageTag, old.Snapshot.TemplateDefaultTag); err != nil {
return OperationResult{}, err
}
if _, err := old.Snapshot.DeploymentPlan(instanceID); err != nil {
return OperationResult{}, err
}
if err := repository.SaveInstanceConfiguration(ctx, instanceID, old.Snapshot, !immediate, "rollback", actorID); err != nil {
return OperationResult{}, err
}
if !immediate || current.ContainerID == "" {
updated, _ := s.repository.GetInstance(ctx, instanceID)
return resultFrom(updated, ""), nil
}
return s.replaceConfigured(ctx, current, old.Snapshot, "restart")
})
}
func (s *LifecycleService) replaceConfigured(ctx context.Context, current StoredInstance, preview Preview, kind string) (OperationResult, error) {
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, current.ID, kind, "update")
if err != nil {
return OperationResult{}, err
}
plan, err := preview.DeploymentPlan(current.ID)
if err != nil {
return s.fail(ctx, operationID, current.ID, "invalid_plan", err)
}
state, err := agent.ReplaceInstance(ctx, plan)
if err != nil {
return s.fail(ctx, operationID, current.ID, "agent_replace_failed", err)
}
if current.DesiredRunning {
state, err = s.agent.StartInstance(ctx, current.ID)
}
if err != nil {
return s.fail(ctx, operationID, current.ID, "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 := s.repository.(ConfigurationRepository).ClearContainerConfigPending(ctx, current.ID, plan.PlanDigest); err != nil {
return OperationResult{}, err
}
return OperationResult{OperationID: operationID, InstanceID: current.ID, State: lifecycle, Observed: observed, ContainerID: state.ContainerID, AgentState: state}, nil
}
+216
View File
@@ -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
}
@@ -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)
}
}
+15 -2
View File
@@ -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) {
+23
View File
@@ -3,6 +3,7 @@ package instance_test
import (
"context"
"path/filepath"
"strings"
"sync"
"testing"
@@ -46,10 +47,32 @@ func (a *lifecycleAgent) RestartInstance(ctx context.Context, id string, _ int)
return a.StartInstance(ctx, id)
}
func (a *lifecycleAgent) DeleteContainer(context.Context, string) error { return nil }
func (a *lifecycleAgent) ReplaceInstance(_ context.Context, plan agentwire.DeploymentPlan) (agentwire.InstanceState, error) {
return agentwire.InstanceState{InstanceID: plan.InstanceID, ContainerID: "container-2", PlanDigest: plan.PlanDigest, Health: "stopped"}, nil
}
func (a *lifecycleAgent) GetInstanceStats(_ context.Context, id string) (agentwire.InstanceStats, error) {
return agentwire.InstanceStats{InstanceID: id, MemoryBytes: 42}, nil
}
func TestUpdatePreviewRequiresDigestAndWarnsAboutMods(t *testing.T) {
current := instance.StoredInstance{Preview: instance.Preview{
Image: "registry.example/game:old",
TemplateDefaultTag: "old",
Mods: instance.ModsConfiguration{Items: []string{"123"}},
UpdatePolicy: instance.UpdatePolicy{BackupBeforeUpdate: true, RollbackOnFailure: true},
}}
if _, err := instance.PreviewUpdate(current, instance.UpdateRequest{CandidateTag: "new", CandidateDigest: "latest"}); err == nil {
t.Fatal("mutable tag without digest was accepted")
}
value, err := instance.PreviewUpdate(current, instance.UpdateRequest{CandidateTag: "new", CandidateDigest: "sha256:" + strings.Repeat("a", 64)})
if err != nil {
t.Fatal(err)
}
if !value.BackupRequired || !value.ModWarning || !value.RollbackOnFailure || !strings.Contains(value.CandidateReference, "@sha256:") {
t.Fatalf("preview = %#v", value)
}
}
func TestLifecycleInstallStartStopAndSafeContainerDeletion(t *testing.T) {
ctx := context.Background()
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
+88 -4
View File
@@ -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,36 @@ 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"`
Mods ModsConfiguration `json:"mods"`
UpdatePolicy UpdatePolicy `json:"update_policy"`
}
type ModsConfiguration struct {
Supported bool `json:"supported"`
Provider string `json:"provider,omitempty"`
DestinationMount string `json:"destination_mount,omitempty"`
RestartRequired bool `json:"restart_required"`
Items []string `json:"items"`
}
type UpdatePolicy struct {
BackupBeforeUpdate bool `json:"backup_before_update"`
RollbackOnFailure bool `json:"rollback_on_failure"`
Automatic bool `json:"automatic"`
HealthTimeoutSeconds int `json:"health_timeout_seconds"`
}
type GameReference struct {
ID string `json:"id"`
Name string `json:"name"`
IconURL string `json:"icon_url"`
}
type TemplateReference struct {
@@ -105,9 +139,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 +242,19 @@ 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},
Mods: ModsConfiguration{Supported: snapshot.Template.Mods.Supported, Provider: snapshot.Template.Mods.Provider, DestinationMount: snapshot.Template.Mods.DestinationMount, RestartRequired: snapshot.Template.Mods.RestartRequired, Items: []string{}},
UpdatePolicy: UpdatePolicy{BackupBeforeUpdate: snapshot.Template.Updates.BackupBeforeUpdate, RollbackOnFailure: snapshot.Template.Updates.RollbackOnFailure, Automatic: false, HealthTimeoutSeconds: snapshot.Template.Updates.HealthTimeoutSeconds},
}
if preview.Backup.RetentionCount < 1 || preview.Backup.RetentionCount > 1000 {
return Preview{}, errors.New("backup retention must be between 1 and 1000")
@@ -215,11 +276,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,
}
@@ -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
}
@@ -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
}
+135
View File
@@ -0,0 +1,135 @@
package instance
import (
"context"
"errors"
"regexp"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
)
var imageDigestPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
type UpdateRequest struct {
CandidateTag string `json:"candidate_tag"`
CandidateDigest string `json:"candidate_digest"`
Confirmed bool `json:"confirmed"`
}
type UpdatePreview struct {
CurrentReference string `json:"current_reference"`
CandidateReference string `json:"candidate_reference"`
BackupRequired bool `json:"backup_required"`
ModWarning bool `json:"mod_warning"`
RollbackOnFailure bool `json:"rollback_on_failure"`
}
func PreviewUpdate(current StoredInstance, request UpdateRequest) (UpdatePreview, error) {
tag, err := ValidateImageTag(ImageTag{Mode: ImageTagPinned, Tag: request.CandidateTag}, current.Preview.TemplateDefaultTag)
if err != nil {
return UpdatePreview{}, err
}
if !imageDigestPattern.MatchString(request.CandidateDigest) {
return UpdatePreview{}, errors.New("candidate image digest must be sha256")
}
base := imageRepository(current.Preview.Image)
return UpdatePreview{CurrentReference: current.Preview.Image, CandidateReference: base + ":" + tag.Tag + "@" + request.CandidateDigest, BackupRequired: current.Preview.UpdatePolicy.BackupBeforeUpdate, ModWarning: len(current.Preview.Mods.Items) != 0, RollbackOnFailure: current.Preview.UpdatePolicy.RollbackOnFailure}, nil
}
func (s *LifecycleService) Update(ctx context.Context, instanceID string, request UpdateRequest, actorID string) (OperationResult, error) {
return s.exclusive(instanceID, func() (OperationResult, error) {
current, err := s.repository.GetInstance(ctx, instanceID)
if err != nil {
return OperationResult{}, err
}
candidate, err := PreviewUpdate(current, request)
if err != nil {
return OperationResult{}, err
}
if !request.Confirmed {
return OperationResult{}, errors.New("update confirmation is required")
}
if current.ContainerID == "" {
return OperationResult{}, ErrInvalidState
}
replacement, ok := s.agent.(replacementAgent)
if !ok {
return OperationResult{}, errors.New("container replacement is unavailable")
}
previous := current.Preview
next := previous
next.Image = candidate.CandidateReference
next.ImageTag = ImageTag{Mode: ImageTagPinned, Tag: request.CandidateTag}
repository := s.repository.(ConfigurationRepository)
operationID, err := operationToken()
if err != nil {
return OperationResult{}, err
}
if err := repository.SaveInstanceConfiguration(ctx, instanceID, next, false, "update", actorID); err != nil {
return s.fail(ctx, operationID, instanceID, "update_configuration_failed", err)
}
current, err = s.repository.BeginOperation(ctx, operationID, instanceID, "restart", "update")
if err != nil {
return OperationResult{}, err
}
plan, err := next.DeploymentPlan(instanceID)
if err == nil {
_, err = replacement.ReplaceInstance(ctx, plan)
}
var stateErr error
if err == nil && current.DesiredRunning {
_, stateErr = s.agent.StartInstance(ctx, instanceID)
err = stateErr
}
rollback := func() {
if next.UpdatePolicy.RollbackOnFailure {
_ = repository.SaveInstanceConfiguration(ctx, instanceID, previous, false, "update_rollback", actorID)
if oldPlan, planErr := previous.DeploymentPlan(instanceID); planErr == nil {
if _, rollbackErr := replacement.ReplaceInstance(ctx, oldPlan); rollbackErr == nil && current.DesiredRunning {
_, _ = s.agent.StartInstance(ctx, instanceID)
}
}
}
}
if err != nil {
rollback()
return s.fail(ctx, operationID, instanceID, "update_failed", err)
}
state, err := s.waitForUpdateReadiness(ctx, instanceID, current.DesiredRunning, next.UpdatePolicy.HealthTimeoutSeconds)
if err != nil {
rollback()
return s.fail(ctx, operationID, instanceID, "update_health_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
}
return OperationResult{OperationID: operationID, InstanceID: instanceID, State: lifecycle, Observed: observed, ContainerID: state.ContainerID, AgentState: state}, nil
})
}
func (s *LifecycleService) waitForUpdateReadiness(ctx context.Context, instanceID string, shouldRun bool, timeoutSeconds int) (agentwire.InstanceState, error) {
if timeoutSeconds < 10 {
timeoutSeconds = 10
}
deadline, cancel := context.WithTimeout(ctx, time.Duration(timeoutSeconds)*time.Second)
defer cancel()
for {
state, err := s.agent.InspectInstance(deadline, instanceID)
if err != nil {
return agentwire.InstanceState{}, err
}
if !shouldRun && !state.Running {
return state, nil
}
if shouldRun && state.Ready {
return state, nil
}
select {
case <-deadline.Done():
return agentwire.InstanceState{}, errors.New("updated instance did not become ready before timeout")
case <-time.After(time.Second):
}
}
}
+425
View File
@@ -0,0 +1,425 @@
// Package notification manages encrypted channels and bounded asynchronous delivery.
package notification
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"net/smtp"
"net/url"
"strconv"
"strings"
"time"
)
type Channel struct {
ID, Name, Type string
Enabled bool
Events []string
Configured bool
}
type Input struct {
Name, Type string
Enabled bool
Events []string
Config map[string]string
}
type Event struct{ Type, Title, Message, InstanceName, OperationID string }
type Service struct {
db *sql.DB
aead cipher.AEAD
client *http.Client
resolver *net.Resolver
now func() time.Time
}
func New(db *sql.DB, key []byte) (*Service, error) {
if len(key) != 32 {
return nil, errors.New("notification encryption key must be exactly 32 bytes")
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
s := &Service{db: db, aead: aead, resolver: net.DefaultResolver, now: time.Now}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.DialContext = s.dialSafe
s.client = &http.Client{Transport: transport, Timeout: 10 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return errors.New("too many redirects")
}
return s.validateURL(req.Context(), req.URL)
}}
return s, nil
}
func (s *Service) Upsert(ctx context.Context, id string, in Input) (Channel, error) {
if strings.TrimSpace(in.Name) == "" || !validType(in.Type) {
return Channel{}, errors.New("invalid notification channel")
}
if err := validateConfigShape(in.Type, in.Config); err != nil {
return Channel{}, err
}
encrypted, err := s.seal(in.Config)
if err != nil {
return Channel{}, err
}
events, _ := json.Marshal(normalizeEvents(in.Events))
now := s.now().UTC().Format(time.RFC3339Nano)
if id == "" {
id = randomID()
}
_, err = s.db.ExecContext(ctx, `INSERT INTO notification_channels(id,name,type,enabled,encrypted_config,event_filter_json,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name,type=excluded.type,enabled=excluded.enabled,encrypted_config=excluded.encrypted_config,event_filter_json=excluded.event_filter_json,updated_at=excluded.updated_at`, id, strings.TrimSpace(in.Name), in.Type, in.Enabled, encrypted, string(events), now, now)
if err != nil {
return Channel{}, fmt.Errorf("save notification channel: %w", err)
}
return Channel{ID: id, Name: strings.TrimSpace(in.Name), Type: in.Type, Enabled: in.Enabled, Events: normalizeEvents(in.Events), Configured: true}, nil
}
func (s *Service) List(ctx context.Context) ([]Channel, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id,name,type,enabled,event_filter_json,length(encrypted_config)>0 FROM notification_channels ORDER BY name COLLATE NOCASE`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Channel
for rows.Next() {
var c Channel
var body string
if err := rows.Scan(&c.ID, &c.Name, &c.Type, &c.Enabled, &body, &c.Configured); err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(body), &c.Events)
out = append(out, c)
}
return out, rows.Err()
}
func (s *Service) Delete(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM notification_channels WHERE id=?`, id)
return err
}
func (s *Service) Queue(ctx context.Context, event Event) error {
if !validEvent(event.Type) || len(event.Message) > 1000 {
return errors.New("invalid notification event")
}
payload, _ := json.Marshal(event)
rows, err := s.db.QueryContext(ctx, `SELECT id,event_filter_json FROM notification_channels WHERE enabled=1`)
if err != nil {
return err
}
var channelIDs []string
for rows.Next() {
var id, filter string
if err := rows.Scan(&id, &filter); err != nil {
rows.Close()
return err
}
var events []string
_ = json.Unmarshal([]byte(filter), &events)
if matches(events, event.Type) {
channelIDs = append(channelIDs, id)
}
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
now := s.now().UTC().Format(time.RFC3339Nano)
for _, id := range channelIDs {
if _, err := s.db.ExecContext(ctx, `INSERT INTO notification_deliveries(id,channel_id,event_type,payload_redacted,next_attempt_at,created_at) VALUES(?,?,?,?,?,?)`, randomID(), id, event.Type, string(payload), now, now); err != nil {
return err
}
}
return nil
}
func (s *Service) Test(ctx context.Context, id string) error {
return s.queueForChannel(ctx, id, Event{Type: "notification.test", Title: "DoGaMa test notification", Message: "This is a test notification from DoGaMa."})
}
func (s *Service) queueForChannel(ctx context.Context, id string, event Event) error {
var enabled bool
if err := s.db.QueryRowContext(ctx, `SELECT enabled FROM notification_channels WHERE id=?`, id).Scan(&enabled); err != nil {
return err
}
payload, _ := json.Marshal(event)
now := s.now().UTC().Format(time.RFC3339Nano)
_, err := s.db.ExecContext(ctx, `INSERT INTO notification_deliveries(id,channel_id,event_type,payload_redacted,next_attempt_at,created_at) VALUES(?,?,?,?,?,?)`, randomID(), id, event.Type, string(payload), now, now)
return err
}
func (s *Service) RunDue(ctx context.Context) error {
rows, err := s.db.QueryContext(ctx, `SELECT d.id,d.attempt,d.payload_redacted,c.type,c.encrypted_config FROM notification_deliveries d JOIN notification_channels c ON c.id=d.channel_id WHERE d.status IN ('queued','retrying') AND d.next_attempt_at<=? ORDER BY d.next_attempt_at LIMIT 20`, s.now().UTC().Format(time.RFC3339Nano))
if err != nil {
return err
}
type job struct {
id, typ, payload string
attempt int
encrypted []byte
}
var jobs []job
for rows.Next() {
var j job
if err := rows.Scan(&j.id, &j.attempt, &j.payload, &j.typ, &j.encrypted); err != nil {
rows.Close()
return err
}
jobs = append(jobs, j)
}
rows.Close()
for _, j := range jobs {
config, e := s.open(j.encrypted)
if e == nil {
e = s.deliver(ctx, j.id, j.typ, config, []byte(j.payload))
}
attempt := j.attempt + 1
if e == nil {
if _, updateErr := s.db.ExecContext(ctx, `UPDATE notification_deliveries SET status='succeeded',attempt=?,completed_at=?,last_error_code='' WHERE id=?`, attempt, s.now().UTC().Format(time.RFC3339Nano), j.id); updateErr != nil {
return updateErr
}
} else {
status := "retrying"
if attempt >= 5 {
status = "failed"
}
delay := time.Duration(1<<min(attempt, 6)) * time.Minute
_, _ = s.db.ExecContext(ctx, `UPDATE notification_deliveries SET status=?,attempt=?,next_attempt_at=?,last_error_code=? WHERE id=?`, status, attempt, s.now().UTC().Add(delay).Format(time.RFC3339Nano), errorCode(e), j.id)
}
}
return nil
}
func (s *Service) deliver(ctx context.Context, id, typ string, c map[string]string, payload []byte) error {
if typ == "email" {
host := c["host"]
port := c["port"]
if port == "" {
port = "587"
}
addr := net.JoinHostPort(host, port)
var auth smtp.Auth
if c["username"] != "" {
auth = smtp.PlainAuth("", c["username"], c["password"], host)
}
msg := []byte("To: " + c["to"] + "\r\nSubject: DoGaMa notification\r\nContent-Type: application/json\r\n\r\n" + string(payload))
return sendSMTP(ctx, addr, host, auth, c["from"], strings.Split(c["to"], ","), msg)
}
u, err := url.Parse(c["url"])
if err != nil {
return err
}
if err = s.validateURL(ctx, u); err != nil {
return err
}
body := payload
if typ == "discord" {
var event Event
_ = json.Unmarshal(payload, &event)
body, _ = json.Marshal(map[string]string{"content": event.Title + "\n" + event.Message})
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-DoGaMa-Event-ID", id)
timestamp := strconv.FormatInt(s.now().Unix(), 10)
req.Header.Set("X-DoGaMa-Timestamp", timestamp)
if secret := c["signing_secret"]; secret != "" {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(timestamp + "." + string(body)))
req.Header.Set("X-DoGaMa-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
}
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("remote_status_%d", resp.StatusCode)
}
return nil
}
func (s *Service) validateURL(ctx context.Context, u *url.URL) error {
if u.Scheme != "https" || u.User != nil || u.Hostname() == "" {
return errors.New("unsafe_destination")
}
ips, err := s.resolver.LookupNetIP(ctx, "ip", u.Hostname())
if err != nil {
return errors.New("destination_resolution_failed")
}
for _, ip := range ips {
if unsafeIP(ip) {
return errors.New("unsafe_destination")
}
}
return nil
}
func (s *Service) dialSafe(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, errors.New("unsafe_destination")
}
ips, err := s.resolver.LookupNetIP(ctx, "ip", host)
if err != nil || len(ips) == 0 {
return nil, errors.New("destination_resolution_failed")
}
dialer := net.Dialer{Timeout: 10 * time.Second}
for _, ip := range ips {
if unsafeIP(ip) {
return nil, errors.New("unsafe_destination")
}
connection, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if dialErr == nil {
return connection, nil
}
}
return nil, errors.New("delivery_failed")
}
func sendSMTP(ctx context.Context, address, host string, auth smtp.Auth, from string, recipients []string, message []byte) error {
dialer := net.Dialer{Timeout: 10 * time.Second}
connection, err := dialer.DialContext(ctx, "tcp", address)
if err != nil {
return err
}
client, err := smtp.NewClient(connection, host)
if err != nil {
_ = connection.Close()
return err
}
defer client.Close()
if ok, _ := client.Extension("STARTTLS"); !ok {
return errors.New("smtp_tls_required")
}
if err := client.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil {
return err
}
if auth != nil {
if err := client.Auth(auth); err != nil {
return err
}
}
if err := client.Mail(from); err != nil {
return err
}
for _, recipient := range recipients {
if err := client.Rcpt(strings.TrimSpace(recipient)); err != nil {
return err
}
}
w, err := client.Data()
if err != nil {
return err
}
if _, err = w.Write(message); err != nil {
_ = w.Close()
return err
}
if err = w.Close(); err != nil {
return err
}
return client.Quit()
}
func unsafeIP(ip netip.Addr) bool {
return !ip.IsValid() || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified()
}
func (s *Service) seal(config map[string]string) ([]byte, error) {
body, _ := json.Marshal(config)
nonce := make([]byte, s.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
return s.aead.Seal(nonce, nonce, body, nil), nil
}
func (s *Service) open(body []byte) (map[string]string, error) {
n := s.aead.NonceSize()
if len(body) < n {
return nil, errors.New("invalid encrypted channel")
}
plain, err := s.aead.Open(nil, body[:n], body[n:], nil)
if err != nil {
return nil, err
}
var out map[string]string
err = json.Unmarshal(plain, &out)
return out, err
}
func validType(v string) bool { return v == "email" || v == "webhook" || v == "discord" }
func validEvent(v string) bool {
return v == "notification.test" || strings.HasSuffix(v, ".failed") || strings.HasSuffix(v, ".completed") || strings.HasSuffix(v, ".required")
}
func validateConfigShape(typ string, c map[string]string) error {
if typ == "email" {
if c["host"] == "" || c["from"] == "" || c["to"] == "" {
return errors.New("email host, from and to are required")
}
return nil
}
u, err := url.Parse(c["url"])
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil {
return errors.New("an HTTPS webhook URL is required")
}
return nil
}
func normalizeEvents(in []string) []string {
seen := map[string]bool{}
out := []string{}
for _, v := range in {
v = strings.TrimSpace(v)
if validEvent(v) && !seen[v] {
seen[v] = true
out = append(out, v)
}
}
return out
}
func matches(filter []string, event string) bool {
for _, v := range filter {
if v == event || v == "*" {
return true
}
}
return false
}
func errorCode(err error) string {
v := err.Error()
if strings.HasPrefix(v, "remote_status_") {
return v
}
switch v {
case "unsafe_destination", "destination_resolution_failed":
return v
}
return "delivery_failed"
}
func randomID() string {
b := make([]byte, 18)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+84
View File
@@ -0,0 +1,84 @@
package notification_test
import (
"bytes"
"context"
"path/filepath"
"strings"
"testing"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
)
func TestChannelSecretsAreEncryptedAndWriteOnly(t *testing.T) {
ctx := context.Background()
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
service, err := notification.New(db, bytes.Repeat([]byte{7}, 32))
if err != nil {
t.Fatal(err)
}
channel, err := service.Upsert(ctx, "", notification.Input{Name: "ops", Type: "webhook", Enabled: true, Events: []string{"backup.failed"}, Config: map[string]string{"url": "https://example.com/hook", "signing_secret": "highly-sensitive"}})
if err != nil {
t.Fatal(err)
}
var encrypted []byte
if err := db.QueryRowContext(ctx, `SELECT encrypted_config FROM notification_channels WHERE id=?`, channel.ID).Scan(&encrypted); err != nil {
t.Fatal(err)
}
if strings.Contains(string(encrypted), "highly-sensitive") {
t.Fatal("secret stored in plaintext")
}
channels, err := service.List(ctx)
if err != nil || len(channels) != 1 || !channels[0].Configured {
t.Fatalf("channels=%#v err=%v", channels, err)
}
}
func TestDeliveryBlocksPrivateWebhookAndRetriesWithRedactedError(t *testing.T) {
ctx := context.Background()
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
service, _ := notification.New(db, bytes.Repeat([]byte{8}, 32))
channel, err := service.Upsert(ctx, "", notification.Input{Name: "unsafe", Type: "webhook", Enabled: true, Events: []string{"backup.failed"}, Config: map[string]string{"url": "https://127.0.0.1/hook", "signing_secret": "never-leak"}})
if err != nil {
t.Fatal(err)
}
if err := service.Queue(ctx, notification.Event{Type: "backup.failed", Title: "Backup failed", Message: "Operation failed", OperationID: "op-1"}); err != nil {
t.Fatal(err)
}
if err := service.RunDue(ctx); err != nil {
t.Fatal(err)
}
var status, code string
var attempt int
if err := db.QueryRowContext(ctx, `SELECT status,attempt,last_error_code FROM notification_deliveries WHERE channel_id=?`, channel.ID).Scan(&status, &attempt, &code); err != nil {
t.Fatal(err)
}
if status != "retrying" || attempt != 1 || code != "unsafe_destination" || strings.Contains(code, "never-leak") {
t.Fatalf("status=%s attempt=%d code=%q", status, attempt, code)
}
}
func TestRejectsMissingKeyAndInsecureURL(t *testing.T) {
if _, err := notification.New(nil, []byte("short")); err == nil {
t.Fatal("short key accepted")
}
ctx := context.Background()
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
service, _ := notification.New(db, bytes.Repeat([]byte{9}, 32))
if _, err := service.Upsert(ctx, "", notification.Input{Name: "bad", Type: "discord", Enabled: true, Config: map[string]string{"url": "http://example.com"}}); err == nil {
t.Fatal("insecure URL accepted")
}
}
+17 -7
View File
@@ -103,20 +103,28 @@ 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)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
_, err = tx.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)
}
return nil
if _, err := tx.ExecContext(ctx, `INSERT INTO configuration_revisions(instance_id, revision, redacted_snapshot, reason, created_at) VALUES(?,1,?,'creation',?)`, draft.ID, string(previewJSON), now); err != nil {
return fmt.Errorf("create initial configuration revision: %w", err)
}
return tx.Commit()
}
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 +146,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 +158,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 +168,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
}
@@ -0,0 +1,174 @@
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.ExecContext(ctx, `INSERT INTO configuration_revisions(instance_id, revision, redacted_snapshot, reason, created_at) SELECT id, revision, preview_json, 'global_labels', ? FROM instances WHERE id=?`, now, update.id); err != nil {
return 0, 0, err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM configuration_revisions WHERE instance_id=? AND revision NOT IN (SELECT revision FROM configuration_revisions WHERE instance_id=? ORDER BY revision DESC LIMIT 10)`, update.id, 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, reason, actorID string) error {
body := string(mustJSON(preview))
labels := string(mustJSON(preview.CustomLabels))
pendingValue := 0
if pending {
pendingValue = 1
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
now := r.now().UTC().Format(time.RFC3339Nano)
result, err := tx.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, now, id)
if err != nil {
return fmt.Errorf("save instance container configuration: %w", err)
}
changed, _ := result.RowsAffected()
if changed != 1 {
return instance.ErrInstanceNotFound
}
var revision int
if err := tx.QueryRowContext(ctx, `SELECT revision FROM instances WHERE id=?`, id).Scan(&revision); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO configuration_revisions(instance_id, revision, redacted_snapshot, reason, created_by, created_at) VALUES(?,?,?,?,?,?)`, id, revision, body, reason, nullable(actorID), now); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM configuration_revisions WHERE instance_id=? AND revision NOT IN (SELECT revision FROM configuration_revisions WHERE instance_id=? ORDER BY revision DESC LIMIT 10)`, id, id); err != nil {
return err
}
return tx.Commit()
}
func (r *Repository) ListConfigurationRevisions(ctx context.Context, id string) ([]instance.ConfigurationRevision, error) {
rows, err := r.db.QueryContext(ctx, `SELECT revision, redacted_snapshot, reason, COALESCE(created_by,''), created_at FROM configuration_revisions WHERE instance_id=? ORDER BY revision DESC`, id)
if err != nil {
return nil, err
}
defer rows.Close()
var result []instance.ConfigurationRevision
for rows.Next() {
var value instance.ConfigurationRevision
var body, created string
value.InstanceID = id
if err := rows.Scan(&value.Revision, &body, &value.Reason, &value.CreatedBy, &created); err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(body), &value.Snapshot); err != nil {
return nil, err
}
value.CreatedAt, _ = time.Parse(time.RFC3339Nano, created)
result = append(result, value)
}
return result, rows.Err()
}
func (r *Repository) GetConfigurationRevision(ctx context.Context, id string, revision int) (instance.ConfigurationRevision, error) {
values, err := r.ListConfigurationRevisions(ctx, id)
if err != nil {
return instance.ConfigurationRevision{}, err
}
for _, value := range values {
if value.Revision == revision {
return value, nil
}
}
return instance.ConfigurationRevision{}, instance.ErrInstanceNotFound
}
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
}
+5 -5
View File
@@ -24,10 +24,10 @@ func TestOpenAppliesMigrationsAndConfiguration(t *testing.T) {
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 6 {
t.Fatalf("got %d migrations, want 6", count)
if count != 9 {
t.Fatalf("got %d migrations, want 9", count)
}
for _, table := range []string{"instance_memberships", "permission_overrides", "installation_requests", "backup_policies", "backups", "imports"} {
for _, table := range []string{"instance_memberships", "permission_overrides", "installation_requests", "backup_policies", "backups", "imports", "notification_channels", "notification_deliveries", "audit_events"} {
var found int
if err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&found); err != nil {
t.Fatal(err)
@@ -62,8 +62,8 @@ func TestOpenAppliesMigrationsAndConfiguration(t *testing.T) {
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 6 {
t.Fatalf("reopened database has %d migrations, want 6", count)
if count != 9 {
t.Fatalf("reopened database has %d migrations, want 9", count)
}
}
+257
View File
@@ -0,0 +1,257 @@
package web
import (
"net/http"
"strconv"
"strings"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification"
)
func (s *server) requireRecentAdmin(w http.ResponseWriter, r *http.Request, api bool) (auth.User, bool) {
var user auth.User
var ok bool
if api {
user, ok = s.requireAPIUser(w, r, true)
} else {
var err error
user, err = s.currentUser(r)
ok = err == nil && user.Role == "admin"
if ok {
session, e := r.Cookie(sessionCookie)
ok = e == nil && s.parseForm(w, r) && s.auth.ValidateCSRF(r.Context(), session.Value, r.FormValue("csrf_token"))
}
}
if !ok {
return auth.User{}, false
}
if time.Since(user.AuthenticatedAt) > 10*time.Minute {
if api {
s.apiProblem(w, http.StatusForbidden, "reauthentication_required", "Recent authentication is required.")
} else {
s.problem(w, http.StatusForbidden, "Recent authentication is required.")
}
return auth.User{}, false
}
return user, true
}
func (s *server) recordAudit(r *http.Request, actor auth.User, action, outcome string, summary map[string]string) {
if s.audit == nil {
return
}
if err := s.audit.Record(r.Context(), audit.Event{ActorID: actor.ID, ActorLabel: actor.Username, Action: action, Outcome: outcome, Summary: summary}); err != nil {
s.logger.Warn("audit recording failed", "event", "audit.record.failed")
}
}
func (s *server) auditList(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAPIUser(w, r, true); !ok {
return
}
f := audit.Filter{ActorID: r.URL.Query().Get("actor_id"), InstanceID: r.URL.Query().Get("instance_id"), Action: r.URL.Query().Get("action"), Outcome: r.URL.Query().Get("outcome")}
events, err := s.audit.List(r.Context(), f)
if err != nil {
s.apiProblem(w, 500, "audit_unavailable", "Audit events are unavailable.")
return
}
s.apiJSON(w, 200, map[string]any{"events": events})
}
func (s *server) auditPolicyGet(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAPIUser(w, r, true); !ok {
return
}
p, err := s.audit.Policy(r.Context())
if err != nil {
s.apiProblem(w, 500, "audit_unavailable", "Audit policy is unavailable.")
return
}
s.apiJSON(w, 200, p)
}
func (s *server) auditPolicyPut(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, true)
if !ok {
return
}
var p audit.Policy
if !s.decodeAPIJSON(w, r, &p) {
return
}
if err := s.audit.SetPolicy(r.Context(), p); err != nil {
s.recordAudit(r, actor, "audit.policy.update", "failed", map[string]string{"reason_code": "invalid_policy"})
s.apiProblem(w, 422, "invalid_policy", "The audit policy is invalid.")
return
}
s.recordAudit(r, actor, "audit.policy.update", "allowed", nil)
s.apiJSON(w, 200, p)
}
func (s *server) auditPurge(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, true)
if !ok {
return
}
var in struct {
Before string `json:"before"`
Confirm bool `json:"confirm"`
}
if !s.decodeAPIJSON(w, r, &in) || !in.Confirm {
s.apiProblem(w, 422, "confirmation_required", "Purge confirmation is required.")
return
}
before, err := time.Parse(time.RFC3339, in.Before)
if err != nil {
s.apiProblem(w, 422, "invalid_boundary", "The purge boundary is invalid.")
return
}
n, err := s.audit.Purge(r.Context(), before)
if err != nil {
s.apiProblem(w, 422, "purge_failed", "The audit purge failed.")
return
}
s.recordAudit(r, actor, "audit.purge", "allowed", map[string]string{"deleted_count": strconv.FormatInt(n, 10)})
s.apiJSON(w, 200, map[string]int64{"deleted_count": n})
}
func (s *server) notificationList(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAPIUser(w, r, true); !ok {
return
}
values, err := s.notifications.List(r.Context())
if err != nil {
s.apiProblem(w, 500, "channels_unavailable", "Notification channels are unavailable.")
return
}
s.apiJSON(w, 200, map[string]any{"channels": values})
}
func (s *server) notificationCreate(w http.ResponseWriter, r *http.Request) {
s.notificationUpsert(w, r, "")
}
func (s *server) notificationUpdate(w http.ResponseWriter, r *http.Request) {
s.notificationUpsert(w, r, r.PathValue("id"))
}
func (s *server) notificationUpsert(w http.ResponseWriter, r *http.Request, id string) {
actor, ok := s.requireRecentAdmin(w, r, true)
if !ok {
return
}
var in notification.Input
if !s.decodeAPIJSON(w, r, &in) {
return
}
value, err := s.notifications.Upsert(r.Context(), id, in)
if err != nil {
s.recordAudit(r, actor, "notification.channel.update", "failed", map[string]string{"channel_type": in.Type, "reason_code": "invalid_channel"})
s.apiProblem(w, 422, "invalid_channel", "The notification channel is invalid.")
return
}
s.recordAudit(r, actor, "notification.channel.update", "allowed", map[string]string{"target_id": value.ID, "channel_type": value.Type})
status := http.StatusOK
if id == "" {
status = http.StatusCreated
}
s.apiJSON(w, status, value)
}
func (s *server) notificationDelete(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, true)
if !ok {
return
}
if err := s.notifications.Delete(r.Context(), r.PathValue("id")); err != nil {
s.apiProblem(w, 500, "delete_failed", "The channel could not be deleted.")
return
}
s.recordAudit(r, actor, "notification.channel.delete", "allowed", map[string]string{"target_id": r.PathValue("id")})
w.WriteHeader(http.StatusNoContent)
}
func (s *server) notificationTest(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, true)
if !ok {
return
}
if !s.requireEmptyBody(w, r) {
return
}
if err := s.notifications.Test(r.Context(), r.PathValue("id")); err != nil {
s.apiProblem(w, 404, "channel_not_found", "The channel was not found.")
return
}
s.recordAudit(r, actor, "notification.channel.test", "allowed", map[string]string{"target_id": r.PathValue("id"), "event_type": "notification.test"})
s.apiJSON(w, 202, map[string]string{"status": "queued"})
}
func (s *server) notificationForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
if !ok {
return
}
config := map[string]string{"url": r.FormValue("url"), "signing_secret": r.FormValue("signing_secret"), "host": r.FormValue("host"), "port": r.FormValue("port"), "username": r.FormValue("smtp_username"), "password": r.FormValue("smtp_password"), "from": r.FormValue("from"), "to": r.FormValue("to")}
value, err := s.notifications.Upsert(r.Context(), "", notification.Input{Name: r.FormValue("name"), Type: r.FormValue("type"), Enabled: true, Events: strings.Fields(r.FormValue("events")), Config: config})
if err != nil {
s.problem(w, 422, "Invalid notification channel.")
return
}
s.recordAudit(r, actor, "notification.channel.update", "allowed", map[string]string{"target_id": value.ID, "channel_type": value.Type})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *server) notificationTestForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
if !ok {
return
}
if err := s.notifications.Test(r.Context(), r.PathValue("id")); err != nil {
s.problem(w, 404, "Notification channel not found.")
return
}
s.recordAudit(r, actor, "notification.channel.test", "allowed", map[string]string{"target_id": r.PathValue("id"), "event_type": "notification.test"})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *server) notificationDeleteForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
if !ok {
return
}
if err := s.notifications.Delete(r.Context(), r.PathValue("id")); err != nil {
s.problem(w, 500, "Notification channel could not be deleted.")
return
}
s.recordAudit(r, actor, "notification.channel.delete", "allowed", map[string]string{"target_id": r.PathValue("id")})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *server) auditPolicyForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
if !ok {
return
}
days, _ := strconv.Atoi(r.FormValue("retention_days"))
maximum, _ := strconv.Atoi(r.FormValue("maximum_count"))
if err := s.audit.SetPolicy(r.Context(), audit.Policy{RetentionDays: days, MaximumCount: maximum}); err != nil {
s.problem(w, 422, "Invalid audit policy.")
return
}
s.recordAudit(r, actor, "audit.policy.update", "allowed", nil)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *server) auditPurgeForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
if !ok {
return
}
if r.FormValue("confirm") != "yes" {
s.problem(w, 422, "Purge confirmation is required.")
return
}
before, err := time.Parse("2006-01-02", r.FormValue("before"))
if err != nil {
s.problem(w, 422, "Invalid purge date.")
return
}
n, err := s.audit.Purge(r.Context(), before)
if err != nil {
s.problem(w, 422, "Audit purge failed.")
return
}
s.recordAudit(r, actor, "audit.purge", "allowed", map[string]string{"deleted_count": strconv.FormatInt(n, 10)})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
+565 -25
View File
@@ -14,14 +14,20 @@ import (
"io"
"log/slog"
"net/http"
"regexp"
"strconv"
"strings"
"time"
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/authorization"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/backup"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/importexport"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification"
)
const (
@@ -55,14 +61,16 @@ var englishMessages = map[string]string{
}
type server struct {
auth *auth.Service
templates *template.Template
logger *slog.Logger
repository repository
lifecycle *instance.LifecycleService
permissions *authorization.Service
backups *backup.Service
imports *importexport.Service
auth *auth.Service
templates *template.Template
logger *slog.Logger
repository repository
lifecycle *instance.LifecycleService
permissions *authorization.Service
backups *backup.Service
imports *importexport.Service
audit *audit.Service
notifications *notification.Service
}
type repository interface {
@@ -73,10 +81,19 @@ 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
Channels []notification.Channel
AuditEvents []audit.Event
AuditPolicy audit.Policy
AuditActor string
AuditInstance string
AuditAction string
AuditOutcome string
}
// NewHandler constructs the complete HTTP application.
@@ -104,21 +121,31 @@ func NewHandlerWithLifecycleAndBackup(authService *auth.Service, repository repo
return newHandlerWithImports(authService, repository, instance.NewLifecycleService(repository, agent), backupService, importService, logger)
}
// NewHandlerComplete enables the milestone-nine administration services.
func NewHandlerComplete(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, backupService *backup.Service, importService *importexport.Service, auditService *audit.Service, notificationService *notification.Service, logger *slog.Logger) (http.Handler, error) {
return newHandlerServices(authService, repository, lifecycle, backupService, importService, auditService, notificationService, logger)
}
func newHandler(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, backupService *backup.Service, logger *slog.Logger) (http.Handler, error) {
return newHandlerWithImports(authService, repository, lifecycle, backupService, nil, logger)
}
func newHandlerWithImports(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, backupService *backup.Service, importService *importexport.Service, logger *slog.Logger) (http.Handler, error) {
return newHandlerServices(authService, repository, lifecycle, backupService, importService, nil, nil, logger)
}
func newHandlerServices(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, backupService *backup.Service, importService *importexport.Service, auditService *audit.Service, notificationService *notification.Service, logger *slog.Logger) (http.Handler, error) {
templates, err := template.New("views").Funcs(template.FuncMap{"msg": message}).ParseFS(assets, "templates/*.html")
if err != nil {
return nil, err
}
s := &server{auth: authService, templates: templates, logger: logger, repository: repository, lifecycle: lifecycle, backups: backupService, imports: importService}
s := &server{auth: authService, templates: templates, logger: logger, repository: repository, lifecycle: lifecycle, backups: backupService, imports: importService, audit: auditService, notifications: notificationService}
if repository != nil {
s.permissions = authorization.New(repository)
}
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 +154,21 @@ 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)
if auditService != nil {
mux.HandleFunc("GET /api/v1/admin/audit", s.auditList)
mux.HandleFunc("GET /api/v1/admin/audit-policy", s.auditPolicyGet)
mux.HandleFunc("PUT /api/v1/admin/audit-policy", s.auditPolicyPut)
mux.HandleFunc("POST /api/v1/admin/audit/purge", s.auditPurge)
}
if notificationService != nil {
mux.HandleFunc("GET /api/v1/admin/notification-channels", s.notificationList)
mux.HandleFunc("POST /api/v1/admin/notification-channels", s.notificationCreate)
mux.HandleFunc("PUT /api/v1/admin/notification-channels/{id}", s.notificationUpdate)
mux.HandleFunc("DELETE /api/v1/admin/notification-channels/{id}", s.notificationDelete)
mux.HandleFunc("POST /api/v1/admin/notification-channels/{id}/test", s.notificationTest)
}
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 +182,14 @@ 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)
mux.HandleFunc("GET /api/v1/instances/{id}/configuration-revisions", s.configurationRevisionList)
mux.HandleFunc("POST /api/v1/instances/{id}/configuration-revisions/{revision}/rollback", s.configurationRevisionRollback)
mux.HandleFunc("GET /api/v1/instances/{id}/mods", s.instanceModsGet)
mux.HandleFunc("PUT /api/v1/instances/{id}/mods", s.instanceModsPut)
mux.HandleFunc("POST /api/v1/instances/{id}/update/preview", s.instanceUpdatePreview)
mux.HandleFunc("POST /api/v1/instances/{id}/update", s.instanceUpdate)
}
if backupService != nil {
mux.HandleFunc("GET /api/v1/instances/{id}/backups", s.backupList)
@@ -159,21 +209,461 @@ 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("POST /admin/notification-channels", s.notificationForm)
mux.HandleFunc("POST /admin/notification-channels/{id}/test", s.notificationTestForm)
mux.HandleFunc("POST /admin/notification-channels/{id}/delete", s.notificationDeleteForm)
mux.HandleFunc("POST /admin/audit-policy", s.auditPolicyForm)
mux.HandleFunc("POST /admin/audit-purge", s.auditPurgeForm)
mux.HandleFunc("GET /", s.home)
return s.securityHeaders(mux), nil
return s.securityHeaders(s.auditRequests(mux)), nil
}
type auditResponseWriter struct {
http.ResponseWriter
status int
}
func (w *auditResponseWriter) WriteHeader(status int) {
if w.status == 0 {
w.status = status
}
w.ResponseWriter.WriteHeader(status)
}
func (w *auditResponseWriter) Write(body []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
return w.ResponseWriter.Write(body)
}
func (s *server) auditRequests(next http.Handler) http.Handler {
if s.audit == nil {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
action := auditAction(r.Method, r.URL.Path)
if action == "" {
next.ServeHTTP(w, r)
return
}
actor, _ := s.currentUser(r)
wrapped := &auditResponseWriter{ResponseWriter: w}
next.ServeHTTP(wrapped, r)
status := wrapped.status
if status == 0 {
status = http.StatusOK
}
outcome := "allowed"
if status == http.StatusUnauthorized || status == http.StatusForbidden {
outcome = "denied"
} else if status >= 400 {
outcome = "failed"
}
summary := map[string]string{}
if id := r.PathValue("id"); id != "" {
summary["target_id"] = id
}
instanceID := ""
if status < 400 && strings.HasPrefix(r.URL.Path, "/api/v1/instances/") {
instanceID = r.PathValue("id")
}
_ = s.audit.Record(r.Context(), audit.Event{ActorID: actor.ID, ActorLabel: actor.Username, InstanceID: instanceID, Action: action, Outcome: outcome, Summary: summary})
})
}
func auditAction(method, path string) string {
if method == http.MethodGet || strings.HasPrefix(path, "/api/v1/admin/audit") || strings.HasPrefix(path, "/api/v1/admin/notification") || strings.HasPrefix(path, "/admin/audit") || strings.HasPrefix(path, "/admin/notification") {
return ""
}
switch {
case path == "/api/v1/admin/users":
return "user.create"
case strings.Contains(path, "/memberships/") && strings.Contains(path, "/permissions/"):
return "permission.override.change"
case strings.Contains(path, "/memberships/"):
return "membership.change"
case strings.Contains(path, "installation-requests") && strings.HasSuffix(path, "/review"):
return "installation_request.review"
case strings.Contains(path, "/backups/") && strings.HasSuffix(path, "/restore"):
return "backup.restore"
case strings.HasSuffix(path, "/backups"):
return "backup.create"
case path == "/api/v1/imports":
return "import.create"
case strings.HasSuffix(path, "/update"):
return "instance.update"
case strings.Contains(path, "configuration-revisions") && strings.HasSuffix(path, "/rollback"):
return "configuration.rollback"
case strings.HasSuffix(path, "/start"):
return "instance.start"
case strings.HasSuffix(path, "/stop"):
return "instance.stop"
case strings.HasSuffix(path, "/restart"):
return "instance.restart"
case strings.HasSuffix(path, "/install"):
return "instance.create"
case strings.HasPrefix(path, "/api/v1/instances/") && method == http.MethodDelete:
return "instance.delete"
case strings.Contains(path, "container-configuration") || strings.HasSuffix(path, "/mods"):
return "instance.configuration.change"
case strings.Contains(path, "game-container-labels"):
return "security.configuration.change"
}
return ""
}
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) configurationRevisionList(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceView); !ok {
return
}
values, err := s.repository.(instance.ConfigurationRepository).ListConfigurationRevisions(r.Context(), r.PathValue("id"))
if err != nil {
s.lifecycleProblem(w, err)
return
}
s.apiJSON(w, 200, map[string]any{"revisions": values})
}
func (s *server) configurationRevisionRollback(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceConfigure)
if !ok {
return
}
revision, err := strconv.Atoi(r.PathValue("revision"))
if err != nil || revision < 1 {
s.apiProblem(w, 422, "invalid_revision", "Revision must be positive.")
return
}
var request applicationModeRequest
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.RollbackConfiguration(r.Context(), r.PathValue("id"), revision, request.Apply == "immediate", actor.ID)
if err != nil {
s.apiProblem(w, 422, "configuration_rollback_failed", err.Error())
return
}
s.apiJSON(w, 200, result)
}
func (s *server) instanceModsGet(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, current.Preview.Mods)
}
func (s *server) instanceModsPut(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireInstancePermission(w, r, authorization.PermissionModsManage)
if !ok {
return
}
var request struct {
Items []string `json:"items"`
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.ConfigureMods(r.Context(), r.PathValue("id"), request.Items, request.Apply == "immediate", actor.ID)
if err != nil {
s.apiProblem(w, 422, "invalid_mod_configuration", err.Error())
return
}
s.apiJSON(w, 200, result)
}
func (s *server) instanceUpdatePreview(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceUpdate); !ok {
return
}
var request instance.UpdateRequest
if !s.decodeStrictJSON(w, r, &request) {
return
}
current, err := s.repository.GetInstance(r.Context(), r.PathValue("id"))
if err != nil {
s.lifecycleProblem(w, err)
return
}
value, err := instance.PreviewUpdate(current, request)
if err != nil {
s.apiProblem(w, 422, "invalid_update", err.Error())
return
}
s.apiJSON(w, 200, value)
}
func (s *server) instanceUpdate(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceUpdate)
if !ok {
return
}
var request instance.UpdateRequest
if !s.decodeStrictJSON(w, r, &request) {
return
}
if !request.Confirmed {
s.apiProblem(w, 422, "update_confirmation_required", "Update confirmation is required.")
return
}
current, err := s.repository.GetInstance(r.Context(), r.PathValue("id"))
if err != nil {
s.lifecycleProblem(w, err)
return
}
if current.Preview.UpdatePolicy.BackupBeforeUpdate {
if s.backups == nil {
s.apiProblem(w, 409, "backup_unavailable", "The required safety backup service is unavailable.")
return
}
if _, err := s.backups.Create(r.Context(), actor.ID, current.ID, "pre_update"); err != nil {
s.apiProblem(w, 422, "pre_update_backup_failed", "The safety backup failed; the update was not started.")
return
}
}
result, err := s.lifecycle.Update(r.Context(), current.ID, request, actor.ID)
if err != nil {
s.queueNotification(r, notification.Event{Type: "update.failed", Title: "Update failed", Message: "The instance update failed.", InstanceName: current.Preview.DisplayName})
s.apiProblem(w, 422, "update_failed", err.Error())
return
}
s.queueNotification(r, notification.Event{Type: "update.completed", Title: "Update completed", Message: "The instance update completed.", InstanceName: current.Preview.DisplayName})
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 +866,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
}
@@ -403,9 +903,11 @@ func (s *server) backupCreate(w http.ResponseWriter, r *http.Request) {
}
value, err := s.backups.Create(r.Context(), actor.ID, r.PathValue("id"), "manual")
if err != nil {
s.queueNotification(r, notification.Event{Type: "backup.failed", Title: "Backup failed", Message: "The manual backup failed."})
s.backupProblem(w, err)
return
}
s.queueNotification(r, notification.Event{Type: "backup.completed", Title: "Backup completed", Message: "The manual backup completed.", OperationID: value.ID})
s.apiJSON(w, http.StatusCreated, value)
}
@@ -431,9 +933,11 @@ func (s *server) backupRestore(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.backups.Restore(r.Context(), actor.ID, r.PathValue("id"), r.PathValue("backupID")); err != nil {
s.queueNotification(r, notification.Event{Type: "restore.failed", Title: "Restore failed", Message: "The backup restore failed."})
s.backupProblem(w, err)
return
}
s.queueNotification(r, notification.Event{Type: "restore.completed", Title: "Restore completed", Message: "The backup restore completed."})
s.apiJSON(w, http.StatusOK, map[string]string{"state": "restored"})
}
@@ -668,6 +1172,7 @@ func (s *server) installationRequestCreate(w http.ResponseWriter, r *http.Reques
s.authorizationProblem(w, err)
return
}
s.queueNotification(r, notification.Event{Type: "installation_request.required", Title: "Installation request submitted", Message: "An installation request requires administrator review."})
s.apiJSON(w, http.StatusCreated, created)
}
@@ -701,9 +1206,19 @@ func (s *server) installationRequestReview(w http.ResponseWriter, r *http.Reques
s.authorizationProblem(w, err)
return
}
s.queueNotification(r, notification.Event{Type: "installation_request.completed", Title: "Installation request reviewed", Message: "An installation request was reviewed."})
s.apiJSON(w, http.StatusOK, reviewed)
}
func (s *server) queueNotification(r *http.Request, event notification.Event) {
if s.notifications == nil {
return
}
if err := s.notifications.Queue(r.Context(), event); err != nil {
s.logger.Warn("notification queue failed", "event", "notification.queue.failed")
}
}
func (s *server) decodeAPIJSON(w http.ResponseWriter, r *http.Request, target any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
decoder := json.NewDecoder(r.Body)
@@ -833,11 +1348,19 @@ func (s *server) loginSubmit(w http.ResponseWriter, r *http.Request) {
if errors.Is(err, auth.ErrRateLimited) {
status = http.StatusTooManyRequests
w.Header().Set("Retry-After", "60")
if s.audit != nil {
_ = s.audit.Record(r.Context(), audit.Event{ActorLabel: "anonymous", Action: "auth.login.blocked", Outcome: "denied", Summary: map[string]string{"reason_code": "rate_limited"}})
}
}
token := s.anonymousCSRF(w, r)
s.render(w, status, "login.html", pageData{Title: message("login.title"), CSRFToken: token, Error: message("error.credentials")})
return
}
if s.audit != nil {
if actor, actorErr := s.auth.Authenticate(r.Context(), session.Token); actorErr == nil {
_ = s.audit.Record(r.Context(), audit.Event{ActorID: actor.ID, ActorLabel: actor.Username, Action: "auth.login", Outcome: "allowed"})
}
}
if cookie, cookieErr := r.Cookie(sessionCookie); cookieErr == nil {
if revokeErr := s.auth.Revoke(r.Context(), cookie.Value); revokeErr != nil {
_ = s.auth.Revoke(r.Context(), session.Token)
@@ -888,7 +1411,24 @@ 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)
}
}
if s.notifications != nil {
data.Channels, _ = s.notifications.List(r.Context())
}
if s.audit != nil {
data.AuditActor, data.AuditInstance = r.URL.Query().Get("actor_id"), r.URL.Query().Get("instance_id")
data.AuditAction, data.AuditOutcome = r.URL.Query().Get("action"), r.URL.Query().Get("outcome")
data.AuditEvents, _ = s.audit.List(r.Context(), audit.Filter{ActorID: data.AuditActor, InstanceID: data.AuditInstance, Action: data.AuditAction, Outcome: data.AuditOutcome, Limit: 50})
data.AuditPolicy, _ = s.audit.Policy(r.Context())
}
}
s.render(w, http.StatusOK, "home.html", data)
}
func (s *server) currentUser(r *http.Request) (auth.User, error) {
+60
View File
@@ -17,11 +17,13 @@ import (
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/backup"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/importexport"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
)
@@ -74,6 +76,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}
@@ -125,6 +136,55 @@ func TestCatalogPreviewAndDraftAPIAuthorization(t *testing.T) {
assertStatus(t, unsafeResponse, http.StatusUnprocessableEntity)
}
func TestNotificationAndAuditAdministration(t *testing.T) {
ctx := context.Background()
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
repository := sqlite.NewRepository(db)
authService := auth.New(db)
if err := authService.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
t.Fatal(err)
}
session, err := authService.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
if err != nil {
t.Fatal(err)
}
auditService := audit.New(db)
notificationService, err := notification.New(db, bytes.Repeat([]byte{3}, 32))
if err != nil {
t.Fatal(err)
}
handler, err := NewHandlerComplete(authService, repository, nil, nil, nil, auditService, notificationService, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
cookie := &http.Cookie{Name: sessionCookie, Value: session.Token}
payload, _ := json.Marshal(map[string]any{"name": "operations", "type": "webhook", "enabled": true, "events": []string{"backup.failed"}, "config": map[string]string{"url": "https://example.com/hook", "signing_secret": "do-not-return"}})
created := jsonMethodRequest(t, handler, http.MethodPost, "/api/v1/admin/notification-channels", payload, cookie, session.CSRFToken)
assertStatus(t, created, http.StatusCreated)
if strings.Contains(created.Body.String(), "do-not-return") {
t.Fatal("channel secret returned")
}
listed := request(t, handler, http.MethodGet, "/api/v1/admin/notification-channels", []*http.Cookie{cookie})
assertStatus(t, listed, http.StatusOK)
if strings.Contains(listed.Body.String(), "do-not-return") || !strings.Contains(listed.Body.String(), "operations") {
t.Fatalf("unsafe channel response: %s", listed.Body.String())
}
auditResponse := request(t, handler, http.MethodGet, "/api/v1/admin/audit", []*http.Cookie{cookie})
assertStatus(t, auditResponse, http.StatusOK)
if !strings.Contains(auditResponse.Body.String(), "notification.channel.update") {
t.Fatalf("missing audit event: %s", auditResponse.Body.String())
}
home := request(t, handler, http.MethodGet, "/", []*http.Cookie{cookie, &http.Cookie{Name: csrfCookie, Value: session.CSRFToken}})
assertStatus(t, home, http.StatusOK)
if !strings.Contains(home.Body.String(), "Notification channels") || !strings.Contains(home.Body.String(), "Recent audit events") {
t.Fatal("administration UI sections missing")
}
}
func TestBackupAPIEnforcesPermissionsAndRestores(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
+11 -3
View File
@@ -1,11 +1,19 @@
:root { color-scheme: light dark; font-family: system-ui, sans-serif; line-height: 1.5; }
body { margin: 0; background: #eef2f7; color: #172033; }
main { width: min(32rem, calc(100% - 2rem)); margin: 8vh auto; padding: 2rem; background: white; border-radius: .75rem; box-shadow: 0 .5rem 2rem #17203318; }
main { width: min(64rem, calc(100% - 2rem)); margin: 4vh auto; padding: 2rem; background: white; border-radius: .75rem; box-shadow: 0 .5rem 2rem #17203318; }
section { margin-top: 2.5rem; padding-top: 1rem; border-top: 1px solid #d0d5dd; }
header { display: flex; justify-content: space-between; align-items: center; padding: 1rem 2rem; background: white; }
form { display: grid; gap: 1rem; }
header form { display: block; }
.inline { display: inline; margin-left: .5rem; }
.inline button { padding: .35rem .55rem; }
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, select, button { box-sizing: border-box; padding: .7rem; font: inherit; border: 1px solid #8a94a6; border-radius: .35rem; }
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
.table-wrap { overflow-x: auto; } table { width: 100%; border-collapse: collapse; } th, td { padding: .6rem; text-align: left; border-bottom: 1px solid #d0d5dd; white-space: nowrap; }
.danger { background: #b42318; }
.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; } }
@media (max-width: 40rem) { main { padding: 1rem; margin: 1rem; width: auto; } header { padding: 1rem; } .grid { grid-template-columns: 1fr; } }
@media (prefers-color-scheme: dark) { body { background: #111827; color: #e5e7eb; } main, header { background: #1f2937; } input, textarea, select { background: #111827; color: #e5e7eb; } }
+14 -1
View File
@@ -1,3 +1,16 @@
{{define "home.html"}}<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · DoGaMa</title><link rel="stylesheet" href="/static/app.v1.css"></head>
<body><header><strong>{{msg "brand"}}</strong><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">{{msg "logout.submit"}}</button></form></header><main><h1>{{msg "dashboard.title"}}</h1><p>{{msg "dashboard.signed_in"}} <strong>{{.User.Username}}</strong>.</p><p>{{msg "dashboard.ready"}}</p></main></body></html>{{end}}
<body><header><strong>{{msg "brand"}}</strong><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">{{msg "logout.submit"}}</button></form></header>
<main><h1>{{msg "dashboard.title"}}</h1><p>{{msg "dashboard.signed_in"}} <strong>{{.User.Username}}</strong>.</p><p>{{msg "dashboard.ready"}}</p>
{{if .IsAdmin}}
<section><h2>Notification channels</h2><p>Secrets are encrypted and never displayed after saving. Delivery is queued and retried without blocking operations.</p>
<ul>{{range .Channels}}<li><strong>{{.Name}}</strong> — {{.Type}} · {{if .Enabled}}enabled{{else}}disabled{{end}}
<form class="inline" method="post" action="/admin/notification-channels/{{.ID}}/test"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><button type="submit">Send test</button></form>
<form class="inline" method="post" action="/admin/notification-channels/{{.ID}}/delete"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><button class="danger" type="submit">Delete</button></form></li>{{else}}<li>No channel configured.</li>{{end}}</ul>
<form method="post" action="/admin/notification-channels"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Name<input name="name" required></label><label>Type<select name="type"><option value="webhook">HTTPS webhook</option><option value="discord">Discord webhook</option><option value="email">SMTP email</option></select></label><label>HTTPS URL<input name="url" type="url" placeholder="https://…"></label><label>Signing secret<input name="signing_secret" type="password" autocomplete="new-password"></label><div class="grid"><label>SMTP host<input name="host"></label><label>Port<input name="port" inputmode="numeric" placeholder="587"></label><label>Username<input name="smtp_username"></label><label>Password<input name="smtp_password" type="password" autocomplete="new-password"></label><label>From<input name="from" type="email"></label><label>Recipients<input name="to"></label></div><label>Events (space separated)<input name="events" value="backup.failed update.failed restore.failed security.required"></label><button type="submit">Add channel</button></form></section>
<section><h2>Audit retention</h2><form method="post" action="/admin/audit-policy"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><div class="grid"><label>Retention days<input name="retention_days" type="number" min="0" max="3650" value="{{.AuditPolicy.RetentionDays}}"></label><label>Maximum entries<input name="maximum_count" type="number" min="0" max="1000000" value="{{.AuditPolicy.MaximumCount}}"></label></div><p>Zero means unlimited and may grow the database indefinitely.</p><button type="submit">Save retention</button></form>
<form method="post" action="/admin/audit-purge"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Delete events before<input name="before" type="date" required></label><label><input name="confirm" type="checkbox" value="yes"> Confirm bounded audit purge</label><button class="danger" type="submit">Purge audit events</button></form></section>
<section><h2>Recent audit events</h2><form method="get" action="/"><div class="grid"><label>Actor ID<input name="actor_id" value="{{.AuditActor}}"></label><label>Instance ID<input name="instance_id" value="{{.AuditInstance}}"></label><label>Action<input name="action" value="{{.AuditAction}}"></label><label>Outcome<select name="outcome"><option value="">Any</option><option value="allowed">Allowed</option><option value="denied">Denied</option><option value="failed">Failed</option></select></label></div><button type="submit">Filter audit</button></form>
<div class="table-wrap"><table><thead><tr><th>Time</th><th>Actor</th><th>Action</th><th>Outcome</th><th>Instance</th></tr></thead><tbody>{{range .AuditEvents}}<tr><td>{{.OccurredAt.Format "2006-01-02 15:04:05Z"}}</td><td>{{.ActorLabel}}</td><td><code>{{.Action}}</code></td><td>{{.Outcome}}</td><td>{{.InstanceID}}</td></tr>{{else}}<tr><td colspan="5">No audit event.</td></tr>{{end}}</tbody></table></div></section>
<section><h2>Game-container labels</h2><form method="post" action="/admin/game-container-labels"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Global labels<textarea name="labels" rows="8" placeholder="key=value">{{.GlobalLabels}}</textarea></label><p>One label per line. Available variables: <code>{{`{{game.name}}`}}</code>, <code>{{`{{game.id}}`}}</code>, <code>{{`{{game.icon_url}}`}}</code>, <code>{{`{{instance.name}}`}}</code>, <code>{{`{{instance.id}}`}}</code>, <code>{{`{{instance.slug}}`}}</code>, <code>{{`{{server.name}}`}}</code>.</p><label><input type="radio" name="apply" value="next_start" checked> Apply on next start</label><label><input type="radio" name="apply" value="immediate"> Apply immediately</label><aside class="warning"><strong>Immediate application stops and recreates affected containers.</strong> Connected players are disconnected immediately. Persistent data is preserved and each instance returns to its previous running or stopped state.</aside><label><input type="checkbox" name="confirm_disconnection" value="yes"> I understand the immediate-disconnection warning</label><button type="submit">Save game-container labels</button></form></section>
{{end}}</main></body></html>{{end}}
@@ -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
);
+11
View File
@@ -0,0 +1,11 @@
CREATE TABLE configuration_revisions (
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
revision INTEGER NOT NULL CHECK (revision >= 1),
redacted_snapshot TEXT NOT NULL,
reason TEXT NOT NULL CHECK (length(reason) BETWEEN 1 AND 100),
created_by TEXT,
created_at TEXT NOT NULL,
PRIMARY KEY (instance_id, revision)
);
CREATE INDEX configuration_revision_history_idx ON configuration_revisions(instance_id, revision DESC);
+40
View File
@@ -0,0 +1,40 @@
CREATE TABLE notification_channels (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('email', 'webhook', 'discord')),
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
encrypted_config BLOB NOT NULL,
event_filter_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE notification_deliveries (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
payload_redacted TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'retrying', 'succeeded', 'failed')),
attempt INTEGER NOT NULL DEFAULT 0 CHECK (attempt >= 0),
next_attempt_at TEXT NOT NULL,
last_error_code TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
completed_at TEXT
);
CREATE INDEX notification_deliveries_due_idx ON notification_deliveries(status, next_attempt_at);
CREATE TABLE audit_events (
id TEXT PRIMARY KEY,
occurred_at TEXT NOT NULL,
actor_id TEXT REFERENCES users(id) ON DELETE SET NULL,
actor_label TEXT NOT NULL,
instance_id TEXT REFERENCES instances(id) ON DELETE SET NULL,
action TEXT NOT NULL,
outcome TEXT NOT NULL CHECK (outcome IN ('allowed', 'denied', 'failed')),
summary_json TEXT NOT NULL DEFAULT '{}'
);
CREATE INDEX audit_events_time_idx ON audit_events(occurred_at DESC, id DESC);
CREATE INDEX audit_events_filters_idx ON audit_events(actor_id, instance_id, action, outcome);
INSERT INTO system_settings(key, value_json, revision, updated_at)
VALUES ('audit_policy', '{"retention_days":30,"maximum_count":10000}', 1, strftime('%Y-%m-%dT%H:%M:%fZ','now'));