Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d68d97d00a | ||
|
|
349d34ce20 | ||
|
|
3fbaa65eea | ||
|
|
1e226d3ada | ||
|
|
92c5e11bbc | ||
|
|
9eb4fe2cd8 | ||
|
|
7f5fa30d0f | ||
|
|
f9676ef9cd | ||
|
|
b30e6c5122 | ||
|
|
c820c9c0e0 | ||
|
|
5783474955 |
@@ -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.
|
||||
|
||||
@@ -46,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
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 0–3650 retention days and 0–1,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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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);
|
||||
@@ -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'));
|
||||
Reference in New Issue
Block a user