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

After

Width:  |  Height:  |  Size: 22 KiB

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

After

Width:  |  Height:  |  Size: 1.3 MiB

+40 -7
View File
@@ -28,7 +28,7 @@ tests/integration/
## Initial application development
The initial main application requires Go 1.25. SQLite is provided by the pure-Go `modernc.org/sqlite` driver, so neither cgo nor a system SQLite development library is required. It reads bootstrap settings from `DOGAMA_LISTEN_ADDRESS` (default `:8080`), `DOGAMA_DATABASE_PATH` (default `dogama.db`), `DOGAMA_AGENT_URL` and `DOGAMA_AGENT_TOKEN_FILE`. The two agent settings must either both be present or both be absent; lifecycle routes remain disabled when developing without an agent. Run it with:
The initial main application requires Go 1.25. SQLite is provided by the pure-Go `modernc.org/sqlite` driver, so neither cgo nor a system SQLite development library is required. It reads bootstrap settings from `DOGAMA_LISTEN_ADDRESS` (default `:8080`), `DOGAMA_DATABASE_PATH` (default `dogama.db`), `DOGAMA_AGENT_URL`, `DOGAMA_AGENT_TOKEN_FILE` and `DOGAMA_MASTER_KEY_FILE`. The two agent settings must either both be present or both be absent; lifecycle routes remain disabled when developing without an agent. The master-key file must contain exactly 32 bytes and enables encrypted notification-channel configuration; audit remains available without it. Run it with:
```sh
go run ./cmd/dogama
@@ -59,12 +59,45 @@ below configured roots with symlinks refused. Docker containers always use the
fixed restricted baseline; callers cannot provide labels, capabilities, devices,
network modes or arbitrary Docker options.
Lifecycle API operations are administrator-only until the per-instance
authorization milestone. Install, start, stop, restart and container-only delete
are serialized per instance and recorded in `instance_operations`. Desired and
observed states are reconciled at startup and every minute. Container-only delete
removes neither the SQLite intent nor host paths; player data and backups remain
untouched and the missing container stays visible for reconciliation.
Lifecycle API operations are authorized in the backend against the authenticated
identity and the target instance. Global administrators retain implicit access;
assigned users can inspect, view metrics, start and stop, while managers also
receive the documented operational baseline. Explicit deny overrides take
precedence over role baselines and allows. Install and container-only delete
remain administrator operations. Operations are serialized per instance and
recorded in `instance_operations`; desired and observed states are reconciled at
startup and every minute. Container-only delete removes neither the SQLite
intent nor host paths, so player data and backups remain untouched.
The authorization foundation exposes JSON APIs for local user creation,
memberships, per-user overrides and installation requests. Mutations require the
session CSRF token. User creation, membership changes, override changes and
request review additionally require an administrator session authenticated in
the previous ten minutes. Approving a request only records the decision and the
requested values; it never creates a draft or contacts the restricted agent.
Game-data backups are written below `DOGAMA_BACKUPS_ROOT` (default
`/srv/game-backups`) and may only read instance mounts below
`DOGAMA_SERVERS_ROOT` (default `/srv/game-servers`). Untrusted uploads are
isolated below `DOGAMA_IMPORTS_ROOT` (default
`/var/lib/dogama/imports/staging`). These are bootstrap path boundaries, not
ordinary product settings. The same canonical server and backup roots are
mounted into the main application and restricted agent by `compose.yaml`.
The current backup engine conservatively stops a running instance before
archiving. Once the WebAssembly runtime is active, an enabled `online_save`
module can provide the documented flush-before-archive optimization without
moving traversal or archive ownership out of the main application. Archives
are finalized before SQLite marks them available; a metadata failure removes
the orphaned file. Scheduled retention considers only successful `scheduled`
backups. Restore verifies size, checksum, manifest and pinned template version,
creates a `pre_restore` backup, extracts into sibling staging and keeps the
instance stopped with `intervention_required` if readiness cannot be restored.
Validated imports are pinned to the selected template version. Import-backed
drafts require that opaque import ID, and installation atomically places the
normalized staged tree at the template-declared mount-relative destination
before the restricted agent creates the first container. Repeated installation
submission recognizes an already attached import instead of copying it twice.
At main-application startup, every embedded `catalog/*/template.yaml` is
validated against `specs/template.schema.json`, checked for cross-reference and
+19 -1
View File
@@ -86,7 +86,25 @@ Admin-only system permissions are not delegated per instance in V1.
Client-provided instance IDs, roles and permission lists are never trusted. Object lookup and permission evaluation occur in one application-layer call to prevent confused-deputy errors.
## Persistence and request workflow
Instance memberships and overrides are stored in SQLite with foreign keys to
the registered instance and local user. Removing a membership also removes its
overrides. A user can have at most one pending installation request for a given
template version; users only list their own requests, while administrators list
all requests.
Approving or refusing a request is an atomic state transition from `pending`.
Refusal requires a reason. Approval retains the requested template, optional
name, player estimate, schedule, mods flag and message for a later creation
workflow, but deliberately performs no deployment and creates no instance.
All API mutations validate the session-bound CSRF token. User creation,
membership and override changes, and request review require an administrator
authentication no older than ten minutes. Authorization failures return a
generic denial so an unassigned user cannot use object identifiers to discover
instances.
## Sensitive-action safeguards
Restore, destructive delete, membership changes, secret rotation and security configuration require recent authentication. Data removal requires separate checkboxes and typed instance-name confirmation. A manager never gains new abilities merely because a module exposes a capability.
+4 -1
View File
@@ -14,7 +14,7 @@ SQLite is authoritative for product state. Runtime Docker state is reconciled in
| `template_versions` | Immutable validated snapshots | template_id, version, schema_version, canonical_yaml, digest |
| `modules` | Module identity and trust | id, source, trust_status, active_version |
| `module_versions` | Immutable installed artifacts | module_id, version, manifest, wasm_digest, path, api_range |
| `instances` | Desired and observed instance state | id, slug, display_name, template snapshot, revision, lifecycle_state, public_host |
| `instances` | Desired and observed instance state | id, derived slug, display_name, template snapshot, revision, lifecycle_state, Docker-user mode/UID/GID, image-tag mode/tag, custom labels, container_config_pending |
| `instance_settings` | Typed non-secret template values | instance_id, field_id, value_json |
| `instance_secrets` | Encrypted secret values | instance_id, field_id, key_version, nonce, ciphertext |
| `instance_ports` | Published and private bindings | instance_id, port_id, host_ip, host_port, container_port, protocol |
@@ -36,6 +36,9 @@ SQLite is authoritative for product state. Runtime Docker state is reconciled in
## Invariants
- IDs are opaque and stable; slugs are unique but mutable only through a controlled rename.
- Instance slugs are derived from display names, transliterated to lowercase ASCII and recalculated on rename; they are never canonical identifiers.
- The Docker-user selection is immutable after creation. Custom UID/GID values exist only for `custom`; DoGaMa never recursively changes file ownership.
- Desired container configuration is stored separately from the applied `plan_digest`; `container_config_pending` covers any replacement-requiring change without feature-specific flags.
- Released template and module versions are immutable. Editing creates a new version or an independent local copy.
- An instance pins a template snapshot and module version; catalog changes do not mutate it silently.
- There is at most one active mutating operation per instance.
+4
View File
@@ -49,6 +49,10 @@ The configurable failure policy is `abort_stop`, `stop_without_backup` or `force
Maintenance mode blocks ordinary user starts and shows an administrator message while preserving manager/admin access. Settings specify `immediate` or `restart_required`; pending restart changes are applied together through a controlled container replacement. Keep a small redacted configuration history.
Docker label and image-tag changes use the same generic desired-versus-applied mechanism. `immediate` stops and replaces the container, restores its prior running/stopped intent and preserves every bind-mounted data path. `next_start` sets `container_config_pending`; the next explicit start pulls the desired image, replaces the container, clears the flag and starts it. A stopped instance remains stopped during immediate replacement.
The Docker user is selected at creation (`dogama`, `custom`, or image-defined) and is never editable afterward because changing it could invalidate persistent-file permissions. Administrators must use backup, new-instance creation and restore to change ownership deliberately; DoGaMa never performs automatic recursive `chown`.
## Crash-loop protection
Track unexpected exits. Default circuit breaker: five restarts within ten minutes disables automatic restart and moves the instance to error. Manual administrator action after diagnosis resets it. Scheduled jobs do not fight this state.
+21
View File
@@ -91,3 +91,24 @@ The preview reports detected game/type, file count, expanded size, world/player
The reference template recognizes dedicated-server world layouts with `Level.sav` and `Players/`. A local hosted-world import may require player identity conversion. V1 must preserve the upload, warn about this possibility and never perform undocumented silent conversion. A future game-specific data converter remains separate from the WebAssembly API adapter because modules have no filesystem access.
## Implemented foundation
The V1 backup foundation persists policies, archives and import-validation
records in SQLite. Manual backups, listing, integrity-checked export, restore
and five-field cron policy APIs are protected by the stable instance permission
identifiers. Restore and backup-policy mutation use the existing recent-session
safeguard.
Until an integration module is activated, the generic engine uses the safe
`stop_then_archive` behavior even when a template advertises `online_save`.
The later WebAssembly milestone supplies the capability call; modules will
still never traverse or archive files.
ZIP, tar, tar.gz and tar.zst imports are copied to a unique staging directory
before validation. Extraction uses create-new files and rejects absolute or
Windows paths, traversal, links, special files, excessive nesting, excessive
file counts and expanded-size overflow. A compatible validated import can be
selected in an administrator creation preview. Its normalized data is copied
through a create-new sibling directory into the template-declared destination
immediately before first container creation; validation itself never creates a
container or writes into live player data.
+26 -1
View File
@@ -31,18 +31,44 @@ The template states destination mount, ordering, restart requirement, dependency
DoGaMa clearly labels unofficial mod support and never assumes a server update is compatible with installed mods.
The V1 implementation accepts only Steam Workshop numeric item IDs when the pinned template explicitly declares that provider. It persists a normalized declarative list and rejects provider commands, scripts, arbitrary URLs and changes for templates without mod support.
## Configuration application
Each template field declares `apply: immediate` or `restart_required`. Secret fields are write-only. Validate types, ranges, patterns and conflicts on both client and server. A preview lists pending changes and whether container replacement or game restart is needed.
Keep the last 10 redacted configuration revisions by default. Rollback revalidates the old revision against the pinned template/module versions before applying it.
Every desired container or mod change creates a revision, listed newest first. Rollback never restores secrets, never changes the immutable Docker user and may be immediate or deferred until the next explicit start.
## Updates
Image updates are digest-aware. A mutable tag alone is never treated as proof that nothing changed. The UI shows current and candidate references, template release notes if available, mod warnings and whether a backup will run.
The full update sequence and rollback behavior are normative in `docs/domain/instance-lifecycle.md`. Managers may trigger only updates allowed by global/instance policy; administrators choose channels and may pin a digest. Automatic updates remain disabled by default.
The V1 API requires an explicit candidate tag and SHA-256 image digest plus confirmation. A required `pre_update` backup must finish before replacement. Readiness is bounded by the template timeout; failed replacement, start or readiness restores the prior image/configuration plan when rollback is enabled, without restoring player data.
## Game-container labels, users and tags
Administrators can define global labels for game-server containers and instance-specific overrides, one `key=value` per line. Empty lines are ignored and only the first `=` separates the key. Instance labels override global labels; DoGaMa's technical labels always win. Both `dogama.*` and `io.dogama.*` are reserved.
Values support only `{{game.name}}`, `{{game.id}}`, `{{game.icon_url}}`, `{{instance.name}}`, `{{instance.id}}`, `{{instance.slug}}` and `{{server.name}}`. Unknown or malformed variables fail validation; this is substitution, not a general template language. For example:
```text
glance.name={{instance.name}}
glance.icon={{game.icon_url}}
glance.parent=DoGaMa
```
`{{game.icon_url}}` resolves to the unauthenticated, read-only `/public/game-icons/{game-id}` route. The route serves only embedded reviewed raster content with an explicit MIME type and cache policy; it is not a public catalog or administration API.
At creation, the Docker user is either the DoGaMa process UID/GID (default), an explicitly validated numeric UID/GID, or omitted to use the image-defined user. An image without `USER` may therefore run as root. The selection is immutable after creation.
The template's declared tag is the `tracked` default. An administrator may instead select a syntactically validated `pinned` tag and later return to tracked mode. Pinned means an explicitly selected mutable tag, not a digest: publishers can republish the same tag. Manual SHA-256 digest management is outside this milestone.
Label and tag changes can apply immediately or at the next start. Immediate application disconnects players and recreates only the container; bind-mounted persistent data remains. Deferred application uses the generic `container_config_pending` state.
## Template and module updates
- Updating a catalog template creates a new immutable version; instances remain pinned.
@@ -50,4 +76,3 @@ The full update sequence and rollback behavior are normative in `docs/domain/ins
- Local copied templates are independent and are never overwritten by their origin.
- Module packages update independently and require compatibility plus connection tests before activation.
- Rollback keeps the prior template snapshot, module binary and container plan available until the new combination is verified.
+12 -1
View File
@@ -10,6 +10,14 @@ Administrators configure channels entirely in the UI:
Channel secrets are encrypted and write-only. A test action sends a clearly marked test message and reports a redacted result.
The main application reads the 32-byte authenticated-encryption key from
`DOGAMA_MASTER_KEY_FILE`. Without that external key, audit remains available
but channel configuration and delivery are disabled. Generic and Discord
webhooks require HTTPS; resolution, redirects and every resolved address reject
loopback, private, link-local, multicast and unspecified networks. Generic
webhooks carry `X-DoGaMa-Event-ID`, `X-DoGaMa-Timestamp` and, when a signing
secret is configured, an HMAC-SHA256 `X-DoGaMa-Signature`.
## Events and filtering
Suggested configurable events:
@@ -58,5 +66,8 @@ Entries are compact and use allow-listed structured summaries. Player IDs may be
Audit retention defaults to 30 days and is globally administrator-configurable. An optional maximum count prevents unbounded growth. Purge runs daily and records one aggregate audit event, not an event per deleted row. Unlimited retention requires an explicit warning and displays database usage. Administrators may manually purge by date with confirmation.
Technical application logs go to stdout/stderr and use Docker log rotation. Their level and retention are separate from SQLite audit policy.
The V1 bounds are 03650 retention days and 01,000,000 entries; zero means
unlimited. The viewer returns at most 200 entries per request and supports time,
actor, instance, action and outcome filters through the administration API.
Technical application logs go to stdout/stderr and use Docker log rotation. Their level and retention are separate from SQLite audit policy.
@@ -32,6 +32,7 @@
| CSRF/session theft | Secure HttpOnly SameSite cookies, CSRF token, TLS guidance, session rotation/revocation and idle/absolute expiry |
| Password attack | Modern password hashing, rate limits, backoff, generic errors, repeated-failure audit/notification |
| Supply-chain substitution | Immutable version snapshots, checksums, optional signatures/trust labels, digest-pinned images, controlled activation |
| Label/template injection | Structured key/value parsing, reserved namespaces, explicit substitution allowlist, no arbitrary template execution |
| Destructive mistake | Preview, recent authentication, typed-name confirmation, pre-restore/update backups and recoverable workflows |
| Resource exhaustion | Upload/extraction limits, job concurrency, per-instance locks, Docker limits, disk checks, notification/module bounds |
| Replay/race | Signed nonce/timestamp agent calls, idempotency keys, optimistic revisions and durable operation phases |
+4
View File
@@ -54,5 +54,9 @@ Restore shows overwritten data, safety-backup behavior and server downtime. It r
Global screens cover users, public address, approved storage roots (displayed, bootstrap-controlled where appropriate), catalog/modules, notification channels, audit retention/default 30 days, upload limits, disk thresholds and safety defaults.
The game-container label editor is a multiline `key=value` field with one label per line, the complete allowed-variable list, and explicit `apply immediately` versus `apply on next start` choices. Immediate application confirms that affected containers stop and are recreated, connected players disconnect, persistent data remains, and displays affected/running counts when known.
Instance creation includes Docker-user mode, conditional custom UID/GID, tracked/pinned image tag and optional labels. Existing-instance advanced configuration displays the Docker user read-only with the backup/new-instance/restore migration explanation, permits label and tag changes with the same application choices, and visibly reports `container_config_pending`.
The audit viewer is compact and filterable by time, actor, instance, action and outcome. It is not a raw log console.
+5 -2
View File
@@ -4,8 +4,13 @@ go 1.25.0
require (
github.com/dlclark/regexp2 v1.12.0
github.com/klauspost/compress v1.18.0
github.com/robfig/cron/v3 v3.0.1
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3
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
)
@@ -16,8 +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/sys v0.47.0 // 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
+6
View File
@@ -8,14 +8,20 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
+15 -3
View File
@@ -141,6 +141,7 @@ func (d *dockerRuntime) Create(ctx context.Context, plan agentwire.DeploymentPla
pidsLimit := int64(512)
payload := struct {
Image string `json:"Image"`
User string `json:"User,omitempty"`
Entrypoint []string `json:"Entrypoint,omitempty"`
Cmd []string `json:"Cmd,omitempty"`
Labels map[string]string `json:"Labels"`
@@ -157,12 +158,12 @@ func (d *dockerRuntime) Create(ctx context.Context, plan agentwire.DeploymentPla
RestartPolicy map[string]string `json:"RestartPolicy"`
} `json:"HostConfig"`
}{
Image: plan.Image, Entrypoint: plan.Entrypoint, Cmd: plan.Arguments,
Labels: map[string]string{
Image: plan.Image, User: plan.User, Entrypoint: plan.Entrypoint, Cmd: plan.Arguments,
Labels: mergeDockerLabels(plan.Labels, map[string]string{
"io.dogama.managed": "true", "io.dogama.instance-id": plan.InstanceID,
"io.dogama.template-id": plan.TemplateID, "io.dogama.template-version": plan.TemplateVersion,
"io.dogama.plan-digest": plan.PlanDigest,
},
}),
ExposedPorts: exposed,
}
payload.HostConfig.Binds = binds
@@ -190,6 +191,17 @@ func (d *dockerRuntime) Create(ctx context.Context, plan agentwire.DeploymentPla
return created.ID, nil
}
func mergeDockerLabels(custom, technical map[string]string) map[string]string {
result := make(map[string]string, len(custom)+len(technical))
for key, value := range custom {
result[key] = value
}
for key, value := range technical {
result[key] = value
}
return result
}
func (d *dockerRuntime) cleanupPartialCreate(ctx context.Context, name string, plan agentwire.DeploymentPlan) {
inspection, err := d.Inspect(ctx, name)
if err != nil || inspection.Labels["io.dogama.managed"] != "true" || inspection.Labels["io.dogama.instance-id"] != plan.InstanceID || inspection.Labels["io.dogama.plan-digest"] != plan.PlanDigest {
+5 -1
View File
@@ -69,7 +69,7 @@ func TestDockerRuntimeCreatesFixedSecurityBaseline(t *testing.T) {
plan := agentwire.DeploymentPlan{
InstanceID: "abcdefghijklmnopqrstuvwx", TemplateID: "palworld-official", TemplateVersion: "1.0.0", PlanDigest: strings.Repeat("a", 64), Image: "example.invalid/game:1",
Ports: []agentwire.PlanPort{{ID: "game", Protocol: "udp", ContainerPort: 8211, HostPort: 38211, Publish: true}},
Mounts: []agentwire.PlanMount{{ID: "saved", HostPath: "/srv/games/saved", ContainerPath: "/game/saved"}}, Resources: agentwire.PlanResource{CPUCores: 2, MemoryMB: 1024, StorageGB: 10},
Mounts: []agentwire.PlanMount{{ID: "saved", HostPath: "/srv/games/saved", ContainerPath: "/game/saved"}}, Resources: agentwire.PlanResource{CPUCores: 2, MemoryMB: 1024, StorageGB: 10}, Labels: map[string]string{"dashboard.name": "Summer"}, User: "1000:1001",
}
if err := runtime.CheckPorts(context.Background(), plan.Ports); err != nil {
t.Fatal(err)
@@ -82,6 +82,7 @@ func TestDockerRuntimeCreatesFixedSecurityBaseline(t *testing.T) {
t.Fatalf("container ID = %q", id)
}
var payload struct {
User string `json:"User"`
Labels map[string]string `json:"Labels"`
HostConfig struct {
NetworkMode string `json:"NetworkMode"`
@@ -100,6 +101,9 @@ func TestDockerRuntimeCreatesFixedSecurityBaseline(t *testing.T) {
if payload.Labels["io.dogama.instance-id"] != plan.InstanceID || payload.Labels["io.dogama.plan-digest"] != plan.PlanDigest {
t.Fatalf("binding labels = %#v", payload.Labels)
}
if payload.Labels["dashboard.name"] != "Summer" || payload.User != "1000:1001" {
t.Fatalf("custom Docker configuration = %#v user=%q", payload.Labels, payload.User)
}
}
func TestDockerPingerDoesNotExposeConnectionDetails(t *testing.T) {
+3 -1
View File
@@ -6,6 +6,7 @@ import (
"io/fs"
"path"
"reflect"
"strings"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
@@ -71,7 +72,8 @@ func (p *PlanPolicy) Validate(plan agentwire.DeploymentPlan) error {
return errors.New("unknown template snapshot")
}
template := snapshot.Template
if plan.Image != template.Container.Image+":"+template.Container.Tag || !reflect.DeepEqual(plan.Entrypoint, template.Container.Entrypoint) || !reflect.DeepEqual(plan.Arguments, template.Container.Arguments) || plan.StopTimeoutSeconds != template.Container.StopTimeoutSeconds {
imagePrefix := template.Container.Image + ":"
if !strings.HasPrefix(plan.Image, imagePrefix) || !reflect.DeepEqual(plan.Entrypoint, template.Container.Entrypoint) || !reflect.DeepEqual(plan.Arguments, template.Container.Arguments) || plan.StopTimeoutSeconds != template.Container.StopTimeoutSeconds {
return errors.New("container plan differs from template")
}
if plan.Resources.CPUCores < template.Requirements.Minimum.CPUCores || plan.Resources.MemoryMB < template.Requirements.Minimum.MemoryMB || plan.Resources.StorageGB < template.Requirements.Minimum.StorageGB {
+14
View File
@@ -115,6 +115,20 @@ func (r *Registry) Remove(instanceID string) error {
return r.saveLocked(instances)
}
// Replace atomically persists a new binding for an already registered instance.
func (r *Registry) Replace(entry RegisteredInstance) error {
r.mu.Lock()
defer r.mu.Unlock()
instances := append([]RegisteredInstance(nil), r.instances...)
for index := range instances {
if instances[index].InstanceID == entry.InstanceID {
instances[index] = entry
return r.saveLocked(instances)
}
}
return errors.New("instance registration not found")
}
func (r *Registry) load() error {
info, err := os.Lstat(r.path)
if err != nil {
+55
View File
@@ -52,6 +52,7 @@ func NewHandlerWithDiskChecker(authenticator *Authenticator, paths *PathPolicy,
mux.HandleFunc("GET /v1/instances", server.listInstances)
mux.HandleFunc("POST /v1/check-ports", server.checkPorts)
mux.HandleFunc("POST /v1/instances", server.createInstance)
mux.HandleFunc("PUT /v1/instances/{id}", server.replaceInstance)
mux.HandleFunc("GET /v1/instances/{id}", server.inspectInstance)
mux.HandleFunc("GET /v1/instances/{id}/stats", server.instanceStats)
mux.HandleFunc("POST /v1/instances/{id}/start", server.startInstance)
@@ -190,6 +191,60 @@ func (s *service) createInstance(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, agentwire.InstanceState{InstanceID: plan.InstanceID, ContainerID: containerID, PlanDigest: plan.PlanDigest, Health: "stopped"})
}
func (s *service) replaceInstance(w http.ResponseWriter, r *http.Request) {
var plan agentwire.DeploymentPlan
id := r.PathValue("id")
if decodeJSON(r.Body, &plan) != nil || plan.InstanceID != id || s.plans.Validate(plan) != nil {
writeProblem(w, http.StatusUnprocessableEntity, "invalid_plan", "The replacement plan is invalid.")
return
}
entry, ok := s.registry.Get(id)
if !ok {
writeProblem(w, http.StatusNotFound, "registration_not_found", "The instance is not registered.")
return
}
oldState, err := s.boundState(r.Context(), entry)
if err != nil {
writeProblem(w, http.StatusConflict, "registration_mismatch", "The registered container binding is invalid.")
return
}
for index := range plan.Mounts {
canonical, pathErr := s.paths.Resolve(plan.Mounts[index].HostPath)
if pathErr != nil {
writeProblem(w, http.StatusUnprocessableEntity, "path_not_allowed", "A deployment path is not allowed.")
return
}
plan.Mounts[index].HostPath = canonical
}
assets, err := s.prepareAssets(plan)
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "asset_prepare_failed", "Approved template assets could not be prepared.")
return
}
if oldState.Running {
if err := s.docker.Stop(r.Context(), entry.ContainerID, plan.StopTimeoutSeconds); err != nil {
writeProblem(w, http.StatusBadGateway, "container_stop_failed", "The previous container could not be stopped.")
return
}
}
if err := s.docker.Delete(r.Context(), entry.ContainerID); err != nil {
writeProblem(w, http.StatusBadGateway, "container_delete_failed", "The previous container could not be removed.")
return
}
containerID, err := s.docker.Create(r.Context(), plan, assets)
if err != nil {
writeProblem(w, http.StatusBadGateway, "container_replace_failed", "The replacement container could not be created; persistent data was preserved.")
return
}
replacement := RegisteredInstance{InstanceID: id, ContainerID: containerID, PlanDigest: plan.PlanDigest}
if err := s.registry.Replace(replacement); err != nil {
_ = s.docker.Delete(r.Context(), containerID)
writeProblem(w, http.StatusInternalServerError, "registration_failed", "The replacement registration could not be persisted.")
return
}
writeJSON(w, http.StatusOK, agentwire.InstanceState{InstanceID: id, ContainerID: containerID, PlanDigest: plan.PlanDigest, Health: "stopped"})
}
func (s *service) inspectInstance(w http.ResponseWriter, r *http.Request) {
entry, ok := s.registration(w, r.PathValue("id"))
if !ok {
+6
View File
@@ -121,6 +121,12 @@ func (c *Client) CreateInstance(ctx context.Context, plan agentwire.DeploymentPl
return state, err
}
func (c *Client) ReplaceInstance(ctx context.Context, plan agentwire.DeploymentPlan) (agentwire.InstanceState, error) {
var state agentwire.InstanceState
err := c.do(ctx, http.MethodPut, instancePath(plan.InstanceID), plan, &state)
return state, err
}
func (c *Client) InspectInstance(ctx context.Context, instanceID string) (agentwire.InstanceState, error) {
var state agentwire.InstanceState
err := c.do(ctx, http.MethodGet, instancePath(instanceID), nil, &state)
+36 -13
View File
@@ -9,6 +9,7 @@ import (
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
)
@@ -22,19 +23,21 @@ var (
)
type DeploymentPlan struct {
SchemaVersion int `json:"schema_version"`
InstanceID string `json:"instance_id"`
TemplateID string `json:"template_id"`
TemplateVersion string `json:"template_version"`
TemplateDigest string `json:"template_digest"`
Image string `json:"image"`
Entrypoint []string `json:"entrypoint,omitempty"`
Arguments []string `json:"arguments,omitempty"`
Ports []PlanPort `json:"ports"`
Mounts []PlanMount `json:"mounts"`
Resources PlanResource `json:"resources"`
StopTimeoutSeconds int `json:"stop_timeout_seconds"`
PlanDigest string `json:"plan_digest"`
SchemaVersion int `json:"schema_version"`
InstanceID string `json:"instance_id"`
TemplateID string `json:"template_id"`
TemplateVersion string `json:"template_version"`
TemplateDigest string `json:"template_digest"`
Image string `json:"image"`
Entrypoint []string `json:"entrypoint,omitempty"`
Arguments []string `json:"arguments,omitempty"`
Ports []PlanPort `json:"ports"`
Mounts []PlanMount `json:"mounts"`
Resources PlanResource `json:"resources"`
StopTimeoutSeconds int `json:"stop_timeout_seconds"`
Labels map[string]string `json:"labels,omitempty"`
User string `json:"user,omitempty"`
PlanDigest string `json:"plan_digest"`
}
type PlanPort struct {
@@ -88,6 +91,26 @@ func (p DeploymentPlan) Validate() error {
if p.StopTimeoutSeconds < 5 || p.StopTimeoutSeconds > 900 || len(p.Ports) > 32 || len(p.Mounts) == 0 || len(p.Mounts) > 16 {
return errors.New("invalid deployment plan limits")
}
if len(p.Labels) > 64 || len(p.User) > 32 {
return errors.New("invalid deployment plan container configuration")
}
for key, value := range p.Labels {
lower := strings.ToLower(key)
if key == "" || len(key) > 255 || len(value) > 4096 || strings.HasPrefix(lower, "dogama.") || strings.HasPrefix(lower, "io.dogama.") {
return errors.New("invalid deployment plan label")
}
}
if p.User != "" {
parts := strings.Split(p.User, ":")
if len(parts) != 2 {
return errors.New("invalid deployment plan user")
}
for _, part := range parts {
if _, err := strconv.ParseUint(part, 10, 32); err != nil {
return errors.New("invalid deployment plan user")
}
}
}
portIDs := make(map[string]struct{}, len(p.Ports))
hostPorts := make(map[string]struct{})
for _, port := range p.Ports {
+16
View File
@@ -27,3 +27,19 @@ func TestDeploymentPlanDigestRejectsPrivilegedFieldSubstitution(t *testing.T) {
t.Fatal("image substitution preserved a valid binding")
}
}
func TestDeploymentPlanRejectsReservedLabelsAndInvalidUser(t *testing.T) {
plan := DeploymentPlan{SchemaVersion: DeploymentPlanVersion, InstanceID: "abcdefghijklmnopqrstuvwx", TemplateID: "palworld-official", TemplateVersion: "1.0.0", TemplateDigest: strings.Repeat("a", 64), Image: "example.invalid/game:1", Ports: []PlanPort{{ID: "game", Protocol: "udp", ContainerPort: 8211, HostPort: 38211, Publish: true}}, Mounts: []PlanMount{{ID: "saved", HostPath: filepath.Join(string(filepath.Separator), "srv", "games", "saved"), ContainerPath: "/game/saved"}}, Resources: PlanResource{CPUCores: 2, MemoryMB: 1024, StorageGB: 10}, StopTimeoutSeconds: 30, Labels: map[string]string{"dogama.managed": "false"}, User: "1000:1000"}
digest, _ := plan.CanonicalDigest()
plan.PlanDigest = digest
if err := plan.Validate(); err == nil {
t.Fatal("reserved label accepted")
}
plan.Labels = map[string]string{"dashboard.name": "server"}
plan.User = "root"
digest, _ = plan.CanonicalDigest()
plan.PlanDigest = digest
if err := plan.Validate(); err == nil {
t.Fatal("non-numeric Docker user accepted")
}
}
+178
View File
@@ -0,0 +1,178 @@
// Package audit stores the deliberately small, redacted security audit trail.
package audit
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
type Event struct {
ID string `json:"id"`
ActorID string `json:"actor_id"`
ActorLabel string `json:"actor_label"`
InstanceID string `json:"instance_id"`
Action string `json:"action"`
Outcome string `json:"outcome"`
OccurredAt time.Time `json:"occurred_at"`
Summary map[string]string `json:"summary"`
}
type Filter struct {
ActorID, InstanceID, Action, Outcome string
Since, Until time.Time
Limit int
}
type Policy struct {
RetentionDays int `json:"retention_days"`
MaximumCount int `json:"maximum_count"`
}
type Service struct {
db *sql.DB
now func() time.Time
}
func New(db *sql.DB) *Service { return &Service{db: db, now: time.Now} }
var allowedSummaryKeys = map[string]bool{"target_id": true, "target_name": true, "channel_type": true, "event_type": true, "reason_code": true, "deleted_count": true, "before": true, "after": true}
func (s *Service) Record(ctx context.Context, event Event) error {
if event.Action == "" || (event.Outcome != "allowed" && event.Outcome != "denied" && event.Outcome != "failed") {
return errors.New("invalid audit event")
}
clean := map[string]string{}
for key, value := range event.Summary {
if allowedSummaryKeys[key] && len(value) <= 200 {
clean[key] = value
}
}
body, _ := json.Marshal(clean)
when := event.OccurredAt
if when.IsZero() {
when = s.now().UTC()
}
var actor, instance any
if event.ActorID != "" {
actor = event.ActorID
}
if event.InstanceID != "" {
instance = event.InstanceID
}
_, err := s.db.ExecContext(ctx, `INSERT INTO audit_events(id,occurred_at,actor_id,actor_label,instance_id,action,outcome,summary_json) VALUES(?,?,?,?,?,?,?,?)`, randomID(), when.Format(time.RFC3339Nano), actor, event.ActorLabel, instance, event.Action, event.Outcome, string(body))
if err != nil {
return fmt.Errorf("record audit event: %w", err)
}
return nil
}
func (s *Service) List(ctx context.Context, f Filter) ([]Event, error) {
limit := f.Limit
if limit <= 0 || limit > 200 {
limit = 100
}
clauses, args := []string{"1=1"}, []any{}
for _, item := range []struct{ column, value string }{{"actor_id", f.ActorID}, {"instance_id", f.InstanceID}, {"action", f.Action}, {"outcome", f.Outcome}} {
if item.value != "" {
clauses = append(clauses, item.column+"=?")
args = append(args, item.value)
}
}
if !f.Since.IsZero() {
clauses = append(clauses, "occurred_at>=?")
args = append(args, f.Since.UTC().Format(time.RFC3339Nano))
}
if !f.Until.IsZero() {
clauses = append(clauses, "occurred_at<?")
args = append(args, f.Until.UTC().Format(time.RFC3339Nano))
}
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, `SELECT id,occurred_at,COALESCE(actor_id,''),actor_label,COALESCE(instance_id,''),action,outcome,summary_json FROM audit_events WHERE `+strings.Join(clauses, " AND ")+` ORDER BY occurred_at DESC,id DESC LIMIT ?`, args...)
if err != nil {
return nil, fmt.Errorf("list audit events: %w", err)
}
defer rows.Close()
var events []Event
for rows.Next() {
var e Event
var occurred, body string
if err := rows.Scan(&e.ID, &occurred, &e.ActorID, &e.ActorLabel, &e.InstanceID, &e.Action, &e.Outcome, &body); err != nil {
return nil, err
}
e.OccurredAt, _ = time.Parse(time.RFC3339Nano, occurred)
_ = json.Unmarshal([]byte(body), &e.Summary)
events = append(events, e)
}
return events, rows.Err()
}
func (s *Service) Policy(ctx context.Context) (Policy, error) {
var body string
err := s.db.QueryRowContext(ctx, `SELECT value_json FROM system_settings WHERE key='audit_policy'`).Scan(&body)
if err != nil {
return Policy{}, err
}
var p Policy
var raw struct {
RetentionDays int `json:"retention_days"`
MaximumCount int `json:"maximum_count"`
}
if err = json.Unmarshal([]byte(body), &raw); err != nil {
return p, err
}
p.RetentionDays, p.MaximumCount = raw.RetentionDays, raw.MaximumCount
return p, nil
}
func (s *Service) SetPolicy(ctx context.Context, p Policy) error {
if p.RetentionDays < 0 || p.RetentionDays > 3650 || p.MaximumCount < 0 || p.MaximumCount > 1000000 {
return errors.New("audit policy is out of bounds")
}
body, _ := json.Marshal(map[string]int{"retention_days": p.RetentionDays, "maximum_count": p.MaximumCount})
_, err := s.db.ExecContext(ctx, `UPDATE system_settings SET value_json=?,revision=revision+1,updated_at=? WHERE key='audit_policy'`, body, s.now().UTC().Format(time.RFC3339Nano))
return err
}
func (s *Service) Purge(ctx context.Context, before time.Time) (int64, error) {
if before.IsZero() || before.After(s.now().UTC()) {
return 0, errors.New("invalid audit purge boundary")
}
result, err := s.db.ExecContext(ctx, `DELETE FROM audit_events WHERE occurred_at < ?`, before.UTC().Format(time.RFC3339Nano))
if err != nil {
return 0, err
}
return result.RowsAffected()
}
func (s *Service) RunRetention(ctx context.Context) (int64, error) {
p, err := s.Policy(ctx)
if err != nil {
return 0, err
}
var total int64
if p.RetentionDays > 0 {
n, e := s.Purge(ctx, s.now().UTC().AddDate(0, 0, -p.RetentionDays))
if e != nil {
return 0, e
}
total += n
}
if p.MaximumCount > 0 {
result, e := s.db.ExecContext(ctx, `DELETE FROM audit_events WHERE id IN (SELECT id FROM audit_events ORDER BY occurred_at DESC,id DESC LIMIT -1 OFFSET ?)`, p.MaximumCount)
if e != nil {
return total, e
}
n, _ := result.RowsAffected()
total += n
}
return total, nil
}
func randomID() string {
b := make([]byte, 18)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
+55
View File
@@ -0,0 +1,55 @@
package audit_test
import (
"context"
"path/filepath"
"testing"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
)
func TestRecordFiltersSummaryAndRetention(t *testing.T) {
ctx := context.Background()
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
service := audit.New(db)
old := time.Now().UTC().AddDate(0, 0, -40)
if err := service.Record(ctx, audit.Event{OccurredAt: old, ActorLabel: "admin", Action: "instance.update", Outcome: "allowed", Summary: map[string]string{"target_name": "Palworld", "secret": "must-not-persist"}}); err != nil {
t.Fatal(err)
}
events, err := service.List(ctx, audit.Filter{Action: "instance.update"})
if err != nil || len(events) != 1 {
t.Fatalf("events=%#v err=%v", events, err)
}
if events[0].Summary["target_name"] != "Palworld" || events[0].Summary["secret"] != "" {
t.Fatalf("summary was not allow-listed: %#v", events[0].Summary)
}
if err := service.SetPolicy(ctx, audit.Policy{RetentionDays: 30, MaximumCount: 100}); err != nil {
t.Fatal(err)
}
deleted, err := service.RunRetention(ctx)
if err != nil || deleted != 1 {
t.Fatalf("deleted=%d err=%v", deleted, err)
}
}
func TestPolicyAndPurgeBounds(t *testing.T) {
ctx := context.Background()
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
service := audit.New(db)
if err := service.SetPolicy(ctx, audit.Policy{RetentionDays: -1}); err == nil {
t.Fatal("negative retention accepted")
}
if _, err := service.Purge(ctx, time.Now().Add(time.Hour)); err == nil {
t.Fatal("future purge accepted")
}
}
+50 -7
View File
@@ -36,9 +36,11 @@ const (
// User is the authenticated principal exposed to application handlers.
type User struct {
ID string
Username string
Role string
ID string `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
Disabled bool `json:"disabled"`
AuthenticatedAt time.Time `json:"-"`
}
// Session contains a new opaque browser credential and CSRF token.
@@ -150,10 +152,10 @@ func (s *Service) Authenticate(ctx context.Context, token string) (User, error)
}
now := s.now().UTC()
var user User
var expiresAt, lastSeenAt string
err := s.db.QueryRowContext(ctx, `SELECT u.id, u.username, u.global_role, s.expires_at, s.last_seen_at
var expiresAt, lastSeenAt, createdAt string
err := s.db.QueryRowContext(ctx, `SELECT u.id, u.username, u.global_role, s.expires_at, s.last_seen_at, s.created_at
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.id_hash = ? AND u.disabled_at IS NULL`, digest(token)).Scan(&user.ID, &user.Username, &user.Role, &expiresAt, &lastSeenAt)
WHERE s.id_hash = ? AND u.disabled_at IS NULL`, digest(token)).Scan(&user.ID, &user.Username, &user.Role, &expiresAt, &lastSeenAt, &createdAt)
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrInvalidSession
}
@@ -162,16 +164,57 @@ func (s *Service) Authenticate(ctx context.Context, token string) (User, error)
}
expires, err1 := time.Parse(time.RFC3339Nano, expiresAt)
lastSeen, err2 := time.Parse(time.RFC3339Nano, lastSeenAt)
if err1 != nil || err2 != nil || !now.Before(expires) || now.Sub(lastSeen) > idleLifetime {
authenticatedAt, err3 := time.Parse(time.RFC3339Nano, createdAt)
if err1 != nil || err2 != nil || err3 != nil || !now.Before(expires) || now.Sub(lastSeen) > idleLifetime {
_ = s.Revoke(ctx, token)
return User{}, ErrInvalidSession
}
if _, err := s.db.ExecContext(ctx, "UPDATE sessions SET last_seen_at = ? WHERE id_hash = ?", now.Format(time.RFC3339Nano), digest(token)); err != nil {
return User{}, fmt.Errorf("refresh session: %w", err)
}
user.AuthenticatedAt = authenticatedAt
return user, nil
}
// CreateUser adds a local identity after the caller has enforced administrator
// authorization and recent authentication.
func (s *Service) CreateUser(ctx context.Context, username, password, role string) (User, error) {
username = strings.TrimSpace(username)
if role != "user" && role != "admin" {
return User{}, errors.New("invalid global role")
}
if err := validateCredentials(username, password); err != nil {
return User{}, err
}
hash, err := hashPassword(password)
if err != nil {
return User{}, err
}
user := User{ID: randomToken(18), Username: username, Role: role}
_, err = s.db.ExecContext(ctx, "INSERT INTO users(id, username, password_hash, global_role, created_at) VALUES (?, ?, ?, ?, ?)", user.ID, username, hash, role, s.now().UTC().Format(time.RFC3339Nano))
if err != nil {
return User{}, fmt.Errorf("create local user: %w", err)
}
return user, nil
}
func (s *Service) ListUsers(ctx context.Context) ([]User, error) {
rows, err := s.db.QueryContext(ctx, "SELECT id, username, global_role, disabled_at IS NOT NULL FROM users ORDER BY username COLLATE NOCASE")
if err != nil {
return nil, fmt.Errorf("list users: %w", err)
}
defer rows.Close()
var users []User
for rows.Next() {
var user User
if err := rows.Scan(&user.ID, &user.Username, &user.Role, &user.Disabled); err != nil {
return nil, fmt.Errorf("scan user: %w", err)
}
users = append(users, user)
}
return users, rows.Err()
}
// ValidateCSRF checks that a token belongs to the current session.
func (s *Service) ValidateCSRF(ctx context.Context, sessionToken, csrfToken string) bool {
if sessionToken == "" || csrfToken == "" {
+53 -4
View File
@@ -2,6 +2,7 @@ package auth
import (
"context"
"database/sql"
"errors"
"path/filepath"
"strings"
@@ -9,7 +10,8 @@ import (
"testing"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/migrations"
_ "modernc.org/sqlite"
)
func TestBootstrapLoginSessionAndRevocation(t *testing.T) {
@@ -178,12 +180,59 @@ func TestVerifyPasswordRejectsMalformedArgon2idEncodings(t *testing.T) {
}
}
func testService(t *testing.T) *Service {
t.Helper()
db, err := sqlite.Open(context.Background(), filepath.Join(t.TempDir(), "dogama.db"))
func TestCreateAndListUserTracksAuthenticationTime(t *testing.T) {
service := testService(t)
ctx := context.Background()
if err := service.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
t.Fatal(err)
}
created, err := service.CreateUser(ctx, "player", "another correct battery staple", "user")
if err != nil {
t.Fatal(err)
}
if created.ID == "" || created.Username != "player" || created.Role != "user" {
t.Fatalf("created user = %#v", created)
}
users, err := service.ListUsers(ctx)
if err != nil || len(users) != 2 {
t.Fatalf("users = %#v, error = %v", users, err)
}
session, err := service.Login(ctx, "player", "another correct battery staple", "192.0.2.10:1234")
if err != nil {
t.Fatal(err)
}
authenticated, err := service.Authenticate(ctx, session.Token)
if err != nil || authenticated.AuthenticatedAt.IsZero() {
t.Fatalf("authenticated user = %#v, error = %v", authenticated, err)
}
}
func testService(t *testing.T) *Service {
t.Helper()
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "dogama.db"))
if err != nil {
t.Fatal(err)
}
db.SetMaxOpenConns(1)
if _, err := db.Exec("PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000"); err != nil {
t.Fatal(err)
}
entries, err := migrations.Files.ReadDir(".")
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
continue
}
body, err := migrations.Files.ReadFile(entry.Name())
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(string(body)); err != nil {
t.Fatalf("apply %s: %v", entry.Name(), err)
}
}
t.Cleanup(func() { db.Close() })
return New(db)
}
+277
View File
@@ -0,0 +1,277 @@
// Package authorization enforces global and per-instance permissions.
package authorization
import (
"context"
"errors"
"sort"
"strings"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
)
var (
ErrDenied = errors.New("permission denied")
ErrInvalidInput = errors.New("invalid authorization input")
ErrNotFound = errors.New("authorization object not found")
ErrConflict = errors.New("authorization conflict")
ErrRecentAuth = errors.New("recent authentication required")
)
const RecentAuthenticationWindow = 10 * time.Minute
const (
PermissionInstanceView = "instance.view"
PermissionInstanceStart = "instance.start"
PermissionInstanceStop = "instance.stop"
PermissionInstanceRestart = "instance.restart"
PermissionInstanceUpdate = "instance.update"
PermissionInstanceConfigure = "instance.configure"
PermissionInstanceDelete = "instance.delete"
PermissionInstanceWelcomeEdit = "instance.welcome.edit"
PermissionMetricsView = "metrics.view"
PermissionPlayersView = "players.view"
PermissionPlayersKick = "players.kick"
PermissionPlayersBan = "players.ban"
PermissionPlayersUnban = "players.unban"
PermissionAnnouncementsSend = "announcements.send"
PermissionLogsView = "logs.view"
PermissionModsManage = "mods.manage"
PermissionBackupCreate = "backup.create"
PermissionBackupList = "backup.list"
PermissionBackupExport = "backup.export"
PermissionBackupRestore = "backup.restore"
PermissionBackupDelete = "backup.delete"
PermissionRequestCreate = "request.create"
)
var allPermissions = map[string]struct{}{
PermissionInstanceView: {}, PermissionInstanceStart: {}, PermissionInstanceStop: {}, PermissionInstanceRestart: {},
PermissionInstanceUpdate: {}, PermissionInstanceConfigure: {}, PermissionInstanceDelete: {}, PermissionInstanceWelcomeEdit: {},
PermissionMetricsView: {}, PermissionPlayersView: {}, PermissionPlayersKick: {}, PermissionPlayersBan: {}, PermissionPlayersUnban: {},
PermissionAnnouncementsSend: {}, PermissionLogsView: {}, PermissionModsManage: {}, PermissionBackupCreate: {}, PermissionBackupList: {},
PermissionBackupExport: {}, PermissionBackupRestore: {}, PermissionBackupDelete: {}, PermissionRequestCreate: {},
}
var userBaseline = permissionSet(
PermissionInstanceView, PermissionInstanceStart, PermissionInstanceStop,
PermissionMetricsView, PermissionPlayersView, PermissionRequestCreate,
)
var managerBaseline = permissionSet(
PermissionInstanceView, PermissionInstanceStart, PermissionInstanceStop, PermissionInstanceRestart,
PermissionInstanceUpdate, PermissionInstanceWelcomeEdit, PermissionMetricsView, PermissionPlayersView,
PermissionPlayersKick, PermissionPlayersBan, PermissionPlayersUnban, PermissionAnnouncementsSend,
PermissionLogsView, PermissionModsManage, PermissionBackupCreate, PermissionBackupList, PermissionRequestCreate,
)
type Access struct {
InstanceExists bool
MembershipRole string
Overrides map[string]string
}
type Membership struct {
InstanceID string `json:"instance_id"`
UserID string `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
Overrides map[string]string `json:"overrides"`
}
type InstallationRequest struct {
ID string `json:"id"`
RequestedBy string `json:"requested_by"`
RequesterName string `json:"requester_name"`
TemplateID string `json:"template_id"`
TemplateVersion string `json:"template_version"`
SuggestedName string `json:"suggested_name,omitempty"`
PlayerEstimate int `json:"player_estimate,omitempty"`
DesiredSchedule string `json:"desired_schedule,omitempty"`
ModsRequested bool `json:"mods_requested"`
Message string `json:"message,omitempty"`
Status string `json:"status"`
ReviewedBy string `json:"reviewed_by,omitempty"`
ReviewerName string `json:"reviewer_name,omitempty"`
ReviewReason string `json:"review_reason,omitempty"`
CreatedAt string `json:"created_at"`
ReviewedAt string `json:"reviewed_at,omitempty"`
}
type RequestInput struct {
ID string
TemplateID string
TemplateVersion string
SuggestedName string
PlayerEstimate int
DesiredSchedule string
ModsRequested bool
Message string
}
type Repository interface {
ResolveAccess(context.Context, string, string) (Access, error)
SetMembership(context.Context, string, string, string, string) error
DeleteMembership(context.Context, string, string) error
SetPermissionOverride(context.Context, string, string, string, string, string) error
DeletePermissionOverride(context.Context, string, string, string) error
ListMemberships(context.Context, string) ([]Membership, error)
CreateInstallationRequest(context.Context, string, RequestInput) (InstallationRequest, error)
ListInstallationRequests(context.Context, string, bool) ([]InstallationRequest, error)
ReviewInstallationRequest(context.Context, string, string, string, string) (InstallationRequest, error)
}
type Service struct {
repository Repository
now func() time.Time
}
func New(repository Repository) *Service { return &Service{repository: repository, now: time.Now} }
func Permissions() []string {
result := make([]string, 0, len(allPermissions))
for permission := range allPermissions {
result = append(result, permission)
}
sort.Strings(result)
return result
}
func (s *Service) Require(ctx context.Context, principal auth.User, instanceID, permission string) error {
if principal.ID == "" || principal.Disabled || !knownPermission(permission) {
return ErrDenied
}
if principal.Role == "admin" {
return s.requireRecentForPermission(principal, permission)
}
if principal.Role != "user" || instanceID == "" {
return ErrDenied
}
access, err := s.repository.ResolveAccess(ctx, principal.ID, instanceID)
if err != nil {
return err
}
if !access.InstanceExists || (access.MembershipRole != "user" && access.MembershipRole != "manager") {
return ErrDenied
}
if access.Overrides[permission] == "deny" {
return ErrDenied
}
baseline := userBaseline
if access.MembershipRole == "manager" {
baseline = managerBaseline
}
if baseline[permission] || access.Overrides[permission] == "allow" {
return s.requireRecentForPermission(principal, permission)
}
return ErrDenied
}
func (s *Service) SetMembership(ctx context.Context, actor auth.User, instanceID, userID, role string) error {
if err := s.requireRecentAdmin(actor); err != nil {
return err
}
if instanceID == "" || userID == "" || (role != "user" && role != "manager") {
return ErrInvalidInput
}
return s.repository.SetMembership(ctx, actor.ID, instanceID, userID, role)
}
func (s *Service) RequireRecentAdmin(actor auth.User) error { return s.requireRecentAdmin(actor) }
func (s *Service) DeleteMembership(ctx context.Context, actor auth.User, instanceID, userID string) error {
if err := s.requireRecentAdmin(actor); err != nil {
return err
}
if instanceID == "" || userID == "" {
return ErrInvalidInput
}
return s.repository.DeleteMembership(ctx, instanceID, userID)
}
func (s *Service) SetOverride(ctx context.Context, actor auth.User, instanceID, userID, permission, effect string) error {
if err := s.requireRecentAdmin(actor); err != nil {
return err
}
if instanceID == "" || userID == "" || !knownPermission(permission) || (effect != "allow" && effect != "deny") {
return ErrInvalidInput
}
return s.repository.SetPermissionOverride(ctx, actor.ID, instanceID, userID, permission, effect)
}
func (s *Service) DeleteOverride(ctx context.Context, actor auth.User, instanceID, userID, permission string) error {
if err := s.requireRecentAdmin(actor); err != nil {
return err
}
if instanceID == "" || userID == "" || !knownPermission(permission) {
return ErrInvalidInput
}
return s.repository.DeletePermissionOverride(ctx, instanceID, userID, permission)
}
func (s *Service) ListMemberships(ctx context.Context, actor auth.User, instanceID string) ([]Membership, error) {
if actor.Role != "admin" || actor.Disabled {
return nil, ErrDenied
}
return s.repository.ListMemberships(ctx, instanceID)
}
func (s *Service) CreateInstallationRequest(ctx context.Context, actor auth.User, input RequestInput) (InstallationRequest, error) {
if actor.ID == "" || actor.Disabled || (actor.Role != "user" && actor.Role != "admin") {
return InstallationRequest{}, ErrDenied
}
input.SuggestedName, input.DesiredSchedule, input.Message = strings.TrimSpace(input.SuggestedName), strings.TrimSpace(input.DesiredSchedule), strings.TrimSpace(input.Message)
if input.ID == "" || input.TemplateID == "" || input.TemplateVersion == "" || len(input.SuggestedName) > 100 || strings.ContainsAny(input.SuggestedName, `/\\`) || input.PlayerEstimate < 0 || input.PlayerEstimate > 10000 || len(input.DesiredSchedule) > 200 || len(input.Message) > 1000 {
return InstallationRequest{}, ErrInvalidInput
}
return s.repository.CreateInstallationRequest(ctx, actor.ID, input)
}
func (s *Service) ListInstallationRequests(ctx context.Context, actor auth.User) ([]InstallationRequest, error) {
if actor.ID == "" || actor.Disabled {
return nil, ErrDenied
}
return s.repository.ListInstallationRequests(ctx, actor.ID, actor.Role == "admin")
}
func (s *Service) ReviewInstallationRequest(ctx context.Context, actor auth.User, requestID, decision, reason string) (InstallationRequest, error) {
if err := s.requireRecentAdmin(actor); err != nil {
return InstallationRequest{}, err
}
reason = strings.TrimSpace(reason)
if requestID == "" || (decision != "approved" && decision != "refused") || len(reason) > 1000 || (decision == "refused" && reason == "") {
return InstallationRequest{}, ErrInvalidInput
}
return s.repository.ReviewInstallationRequest(ctx, actor.ID, requestID, decision, reason)
}
func (s *Service) requireRecentAdmin(actor auth.User) error {
if actor.ID == "" || actor.Disabled || actor.Role != "admin" {
return ErrDenied
}
if actor.AuthenticatedAt.IsZero() || s.now().Sub(actor.AuthenticatedAt) > RecentAuthenticationWindow {
return ErrRecentAuth
}
return nil
}
func (s *Service) requireRecentForPermission(actor auth.User, permission string) error {
if permission != PermissionInstanceConfigure && permission != PermissionInstanceDelete && permission != PermissionBackupRestore && permission != PermissionBackupDelete {
return nil
}
age := s.now().Sub(actor.AuthenticatedAt)
if actor.AuthenticatedAt.IsZero() || age < 0 || age > RecentAuthenticationWindow {
return ErrRecentAuth
}
return nil
}
func knownPermission(permission string) bool { _, ok := allPermissions[permission]; return ok }
func permissionSet(values ...string) map[string]bool {
result := make(map[string]bool, len(values))
for _, value := range values {
result[value] = true
}
return result
}
+154
View File
@@ -0,0 +1,154 @@
package authorization_test
import (
"context"
"database/sql"
"errors"
"path/filepath"
"testing"
"time"
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/authorization"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
)
func TestInstanceBaselinesOverridesAndIdentifierSubstitution(t *testing.T) {
ctx, _, repository, _, admin, user, instanceID, _ := authorizationFixture(t)
service := authorization.New(repository)
if err := service.Require(ctx, user, instanceID, authorization.PermissionInstanceView); !errors.Is(err, authorization.ErrDenied) {
t.Fatalf("unassigned access error = %v", err)
}
if err := service.SetMembership(ctx, admin, instanceID, user.ID, "user"); err != nil {
t.Fatal(err)
}
for _, permission := range []string{authorization.PermissionInstanceView, authorization.PermissionInstanceStart, authorization.PermissionInstanceStop} {
if err := service.Require(ctx, user, instanceID, permission); err != nil {
t.Fatalf("user permission %s: %v", permission, err)
}
}
if err := service.Require(ctx, user, instanceID, authorization.PermissionInstanceRestart); !errors.Is(err, authorization.ErrDenied) {
t.Fatalf("user restart error = %v", err)
}
if err := service.SetMembership(ctx, admin, instanceID, user.ID, "manager"); err != nil {
t.Fatal(err)
}
if err := service.Require(ctx, user, instanceID, authorization.PermissionInstanceRestart); err != nil {
t.Fatal(err)
}
if err := service.SetOverride(ctx, admin, instanceID, user.ID, authorization.PermissionInstanceRestart, "deny"); err != nil {
t.Fatal(err)
}
if err := service.Require(ctx, user, instanceID, authorization.PermissionInstanceRestart); !errors.Is(err, authorization.ErrDenied) {
t.Fatalf("explicit deny error = %v", err)
}
if err := service.SetOverride(ctx, admin, instanceID, user.ID, authorization.PermissionBackupRestore, "allow"); err != nil {
t.Fatal(err)
}
if err := service.Require(ctx, user, instanceID, authorization.PermissionBackupRestore); err != nil {
t.Fatal(err)
}
staleUser := user
staleUser.AuthenticatedAt = time.Now().Add(-authorization.RecentAuthenticationWindow - time.Minute)
if err := service.Require(ctx, staleUser, instanceID, authorization.PermissionBackupRestore); !errors.Is(err, authorization.ErrRecentAuth) {
t.Fatalf("stale user restore error = %v", err)
}
if err := service.Require(ctx, user, "substituted-instance-id", authorization.PermissionInstanceView); !errors.Is(err, authorization.ErrDenied) {
t.Fatalf("substituted ID error = %v", err)
}
if err := service.Require(ctx, admin, instanceID, authorization.PermissionInstanceDelete); err != nil {
t.Fatal(err)
}
stale := admin
stale.AuthenticatedAt = time.Now().Add(-authorization.RecentAuthenticationWindow - time.Minute)
if err := service.Require(ctx, stale, instanceID, authorization.PermissionInstanceDelete); !errors.Is(err, authorization.ErrRecentAuth) {
t.Fatalf("stale admin delete error = %v", err)
}
if err := service.SetMembership(ctx, stale, instanceID, user.ID, "user"); !errors.Is(err, authorization.ErrRecentAuth) {
t.Fatalf("stale admin error = %v", err)
}
}
func TestInstallationRequestReviewNeverDeploys(t *testing.T) {
ctx, db, repository, _, admin, user, _, snapshot := authorizationFixture(t)
service := authorization.New(repository)
request, err := service.CreateInstallationRequest(ctx, user, authorization.RequestInput{
ID: "request-one", TemplateID: snapshot.Template.ID, TemplateVersion: snapshot.Template.Version,
SuggestedName: "Friends server", PlayerEstimate: 8, DesiredSchedule: "Evenings", Message: "Please approve",
})
if err != nil || request.Status != "pending" {
t.Fatalf("request=%#v error=%v", request, err)
}
if _, err := service.CreateInstallationRequest(ctx, user, authorization.RequestInput{ID: "request-two", TemplateID: snapshot.Template.ID, TemplateVersion: snapshot.Template.Version}); !errors.Is(err, authorization.ErrConflict) {
t.Fatalf("duplicate request error = %v", err)
}
userRequests, err := service.ListInstallationRequests(ctx, user)
if err != nil || len(userRequests) != 1 {
t.Fatalf("user requests=%#v error=%v", userRequests, err)
}
reviewed, err := service.ReviewInstallationRequest(ctx, admin, request.ID, "approved", "Capacity available")
if err != nil || reviewed.Status != "approved" || reviewed.SuggestedName != "Friends server" {
t.Fatalf("reviewed=%#v error=%v", reviewed, err)
}
var instanceCount int
if err := db.QueryRow("SELECT COUNT(*) FROM instances").Scan(&instanceCount); err != nil {
t.Fatal(err)
}
if instanceCount != 1 {
t.Fatalf("approval deployed an instance; count=%d", instanceCount)
}
}
func authorizationFixture(t *testing.T) (context.Context, *sql.DB, *sqlite.Repository, *auth.Service, auth.User, auth.User, string, catalog.Snapshot) {
t.Helper()
ctx := context.Background()
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
repository := sqlite.NewRepository(db)
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
if err != nil {
t.Fatal(err)
}
if err := repository.Sync(ctx, snapshots); err != nil {
t.Fatal(err)
}
authService := auth.New(db)
if err := authService.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
t.Fatal(err)
}
adminSession, err := authService.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
if err != nil {
t.Fatal(err)
}
admin, err := authService.Authenticate(ctx, adminSession.Token)
if err != nil {
t.Fatal(err)
}
createdUser, err := authService.CreateUser(ctx, "player", "another correct battery staple", "user")
if err != nil {
t.Fatal(err)
}
userSession, err := authService.Login(ctx, createdUser.Username, "another correct battery staple", "192.0.2.2:1234")
if err != nil {
t.Fatal(err)
}
user, err := authService.Authenticate(ctx, userSession.Token)
if err != nil {
t.Fatal(err)
}
preview, err := instance.BuildPreview(snapshots[0], instance.PreviewRequest{DisplayName: "Family Palworld", Slug: "family-palworld", HostPorts: map[string]int{"game": 38211}, MountPaths: map[string]string{"saved": "/srv/game-servers/family-palworld/saved"}, DataOrigin: "new", BackupRetention: 7})
if err != nil {
t.Fatal(err)
}
instanceID := "abcdefghijklmnopqrstuvwx"
if err := repository.CreateDraft(ctx, instance.Draft{ID: instanceID, Preview: preview}); err != nil {
t.Fatal(err)
}
return ctx, db, repository, authService, admin, user, instanceID, snapshots[0]
}
+745
View File
@@ -0,0 +1,745 @@
// Package backup owns game-data archives, retention and recoverable restores.
package backup
import (
"archive/tar"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
"github.com/klauspost/compress/zstd"
"github.com/robfig/cron/v3"
"golang.org/x/sys/unix"
)
var (
ErrNotFound = errors.New("backup not found")
ErrInvalidInput = errors.New("invalid backup input")
ErrInvalidState = errors.New("invalid backup state")
ErrUnsafePath = errors.New("unsafe backup path")
ErrIntegrity = errors.New("backup integrity check failed")
ErrIncompatible = errors.New("backup is incompatible")
)
const manifestSchemaVersion = 1
type Backup struct {
ID string `json:"id"`
InstanceID string `json:"instance_id"`
Origin string `json:"origin"`
Status string `json:"status"`
SizeBytes int64 `json:"size_bytes,omitempty"`
SHA256 string `json:"sha256,omitempty"`
CreatedAt string `json:"created_at"`
CompletedAt string `json:"completed_at,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
RelativePath string `json:"-"`
}
type Policy struct {
InstanceID string `json:"instance_id"`
Enabled bool `json:"enabled"`
CronExpression string `json:"cron_expression,omitempty"`
Timezone string `json:"timezone"`
RetentionCount int `json:"retention_count"`
NextRunAt string `json:"next_run_at,omitempty"`
}
type Manifest struct {
SchemaVersion int `json:"schema_version"`
BackupID string `json:"backup_id"`
InstanceID string `json:"instance_id"`
TemplateID string `json:"template_id"`
TemplateVersion string `json:"template_version"`
Origin string `json:"origin"`
CreatedAt string `json:"created_at"`
MountIDs []string `json:"mount_ids"`
}
type Repository interface {
instance.LifecycleRepository
BeginBackup(context.Context, Backup, string, string) error
CompleteBackup(context.Context, string, string, int64, string, Manifest) error
FailBackup(context.Context, string, string) error
ListBackups(context.Context, string) ([]Backup, error)
GetBackup(context.Context, string, string) (Backup, Manifest, error)
RetentionCandidates(context.Context, string, int) ([]Backup, error)
MarkBackupDeleted(context.Context, string) error
GetBackupPolicy(context.Context, string) (Policy, error)
SetBackupPolicy(context.Context, Policy) error
ListDueBackupPolicies(context.Context, string) ([]Policy, error)
}
type Agent interface {
StartInstance(context.Context, string) (agentwire.InstanceState, error)
StopInstance(context.Context, string, int) (agentwire.InstanceState, error)
}
type Service struct {
repository Repository
agent Agent
serversRoot string
backupsRoot string
now func() time.Time
}
func New(repository Repository, agent Agent, serversRoot, backupsRoot string) (*Service, error) {
serversRoot, err := canonicalRoot(serversRoot)
if err != nil {
return nil, fmt.Errorf("server root: %w", err)
}
backupsRoot, err = canonicalRoot(backupsRoot)
if err != nil {
return nil, fmt.Errorf("backup root: %w", err)
}
return &Service{repository: repository, agent: agent, serversRoot: serversRoot, backupsRoot: backupsRoot, now: time.Now}, nil
}
func (s *Service) Create(ctx context.Context, actorID, instanceID, origin string) (Backup, error) {
if !validOrigin(origin) || instanceID == "" {
return Backup{}, ErrInvalidInput
}
current, err := s.repository.GetInstance(ctx, instanceID)
if err != nil {
return Backup{}, err
}
if current.ContainerID == "" {
return Backup{}, ErrInvalidState
}
operationID, backupID := token(), token()
if operationID == "" || backupID == "" {
return Backup{}, errors.New("generate backup identifiers")
}
current, err = s.repository.BeginOperation(ctx, operationID, instanceID, "backup", "backup")
if err != nil {
return Backup{}, err
}
backup := Backup{ID: backupID, InstanceID: instanceID, Origin: origin, Status: "creating", CreatedAt: s.now().UTC().Format(time.RFC3339Nano)}
if err := s.repository.BeginBackup(ctx, backup, operationID, actorID); err != nil {
_ = s.repository.FailOperation(ctx, operationID, "error", "backup_metadata_failed")
return Backup{}, err
}
wasRunning := current.DesiredRunning
if wasRunning {
if _, err := s.agent.StopInstance(ctx, instanceID, current.Preview.StopTimeoutSeconds); err != nil {
return Backup{}, s.fail(ctx, operationID, backupID, "backup_stop_failed", err)
}
}
completed, manifest, err := s.writeArchive(ctx, current, backup)
if err != nil {
if wasRunning {
if _, restartErr := s.agent.StartInstance(ctx, instanceID); restartErr != nil {
_ = s.repository.FailBackup(ctx, backupID, "backup_restart_failed")
_ = s.repository.FailOperation(ctx, operationID, "intervention_required", "backup_restart_failed")
return Backup{}, fmt.Errorf("backup failed and restart failed: %v: %w", err, restartErr)
}
}
return Backup{}, s.fail(ctx, operationID, backupID, "backup_archive_failed", err)
}
if err := s.repository.CompleteBackup(ctx, backupID, completed.RelativePath, completed.SizeBytes, completed.SHA256, manifest); err != nil {
if full, pathErr := s.backupPath(completed.RelativePath); pathErr == nil {
_ = os.Remove(full)
}
if wasRunning {
_, _ = s.agent.StartInstance(ctx, instanceID)
}
return Backup{}, s.fail(ctx, operationID, backupID, "backup_persist_failed", err)
}
state, lifecycle, observed := agentwire.InstanceState{InstanceID: instanceID, ContainerID: current.ContainerID, Health: "stopped"}, "stopped", "stopped"
if wasRunning {
state, err = s.agent.StartInstance(ctx, instanceID)
if err != nil {
_ = s.repository.FailOperation(ctx, operationID, "intervention_required", "backup_restart_failed")
return Backup{}, fmt.Errorf("restart after backup: %w", err)
}
lifecycle, observed = lifecycleFromAgent(state)
}
if err := s.repository.FinishOperation(ctx, operationID, lifecycle, observed, state.ContainerID, current.PlanDigest, wasRunning, ""); err != nil {
return Backup{}, err
}
completed.Status, completed.CompletedAt = "available", s.now().UTC().Format(time.RFC3339Nano)
if err := s.applyRetention(ctx, instanceID, current.Preview.Backup.RetentionCount); err != nil {
return Backup{}, fmt.Errorf("apply retention: %w", err)
}
return completed, nil
}
func (s *Service) List(ctx context.Context, instanceID string) ([]Backup, error) {
return s.repository.ListBackups(ctx, instanceID)
}
func (s *Service) GetPolicy(ctx context.Context, instanceID string) (Policy, error) {
return s.repository.GetBackupPolicy(ctx, instanceID)
}
func (s *Service) SetPolicy(ctx context.Context, value Policy) (Policy, error) {
if value.InstanceID == "" || value.RetentionCount < 1 || value.RetentionCount > 1000 {
return Policy{}, ErrInvalidInput
}
location, err := time.LoadLocation(value.Timezone)
if err != nil {
return Policy{}, ErrInvalidInput
}
if !value.Enabled {
value.CronExpression, value.NextRunAt = "", ""
} else {
schedule, err := cronParser().Parse(value.CronExpression)
if err != nil {
return Policy{}, ErrInvalidInput
}
value.NextRunAt = schedule.Next(s.now().In(location)).UTC().Format(time.RFC3339Nano)
}
if err := s.repository.SetBackupPolicy(ctx, value); err != nil {
return Policy{}, err
}
return value, nil
}
func (s *Service) RunDue(ctx context.Context) error {
now := s.now().UTC()
policies, err := s.repository.ListDueBackupPolicies(ctx, now.Format(time.RFC3339Nano))
if err != nil {
return err
}
for _, policy := range policies {
location, locationErr := time.LoadLocation(policy.Timezone)
schedule, parseErr := cronParser().Parse(policy.CronExpression)
if locationErr != nil || parseErr != nil {
continue
}
policy.NextRunAt = schedule.Next(now.In(location)).UTC().Format(time.RFC3339Nano)
if err := s.repository.SetBackupPolicy(ctx, policy); err != nil {
return err
}
if _, err := s.Create(ctx, "", policy.InstanceID, "scheduled"); err != nil && !errors.Is(err, instance.ErrOperationConflict) {
return err
}
}
return nil
}
func (s *Service) Export(ctx context.Context, instanceID, backupID string) (*os.File, Backup, error) {
backup, _, err := s.repository.GetBackup(ctx, instanceID, backupID)
if err != nil {
return nil, Backup{}, err
}
if backup.Status != "available" {
return nil, Backup{}, ErrInvalidState
}
full, err := s.backupPath(backup.RelativePath)
if err != nil {
return nil, Backup{}, err
}
if err := verifyFile(full, backup.SizeBytes, backup.SHA256); err != nil {
return nil, Backup{}, err
}
file, err := os.Open(full)
return file, backup, err
}
func (s *Service) Restore(ctx context.Context, actorID, instanceID, backupID string) error {
current, err := s.repository.GetInstance(ctx, instanceID)
if err != nil {
return err
}
backup, manifest, err := s.repository.GetBackup(ctx, instanceID, backupID)
if err != nil {
return err
}
if backup.Status != "available" {
return ErrInvalidState
}
if manifest.InstanceID != instanceID || manifest.TemplateID != current.Preview.Template.ID || manifest.TemplateVersion != current.Preview.Template.Version {
return ErrIncompatible
}
full, err := s.backupPath(backup.RelativePath)
if err != nil {
return err
}
if err := verifyFile(full, backup.SizeBytes, backup.SHA256); err != nil {
return err
}
operationID := token()
current, err = s.repository.BeginOperation(ctx, operationID, instanceID, "restore", "restore")
if err != nil {
return err
}
wasRunning := current.DesiredRunning
if wasRunning {
if _, err := s.agent.StopInstance(ctx, instanceID, current.Preview.StopTimeoutSeconds); err != nil {
return s.restoreFail(ctx, operationID, "restore_stop_failed", err)
}
}
safety := Backup{ID: token(), InstanceID: instanceID, Origin: "pre_restore", Status: "creating", CreatedAt: s.now().UTC().Format(time.RFC3339Nano)}
if err := s.repository.BeginBackup(ctx, safety, operationID, actorID); err != nil {
return s.restoreFail(ctx, operationID, "restore_safety_metadata_failed", err)
}
safety, safetyManifest, err := s.writeArchive(ctx, current, safety)
if err != nil {
return s.restoreFail(ctx, operationID, "restore_safety_backup_failed", err)
}
if err := s.repository.CompleteBackup(ctx, safety.ID, safety.RelativePath, safety.SizeBytes, safety.SHA256, safetyManifest); err != nil {
return s.restoreFail(ctx, operationID, "restore_safety_persist_failed", err)
}
if err := s.restoreArchive(full, current, manifest); err != nil {
return s.restoreFail(ctx, operationID, "restore_extract_failed", err)
}
state, lifecycle, observed := agentwire.InstanceState{InstanceID: instanceID, ContainerID: current.ContainerID, Health: "stopped"}, "stopped", "stopped"
if wasRunning {
state, err = s.agent.StartInstance(ctx, instanceID)
if err != nil {
return s.restoreFail(ctx, operationID, "restore_restart_failed", err)
}
lifecycle, observed = lifecycleFromAgent(state)
}
return s.repository.FinishOperation(ctx, operationID, lifecycle, observed, state.ContainerID, current.PlanDigest, wasRunning, "")
}
func (s *Service) writeArchive(ctx context.Context, current instance.StoredInstance, backup Backup) (Backup, Manifest, error) {
mounts := make(map[string]string)
for _, mount := range current.Preview.Mounts {
mounts[mount.ID] = mount.HostPath
}
manifest := Manifest{SchemaVersion: manifestSchemaVersion, BackupID: backup.ID, InstanceID: backup.InstanceID, TemplateID: current.Preview.Template.ID, TemplateVersion: current.Preview.Template.Version, Origin: backup.Origin, CreatedAt: backup.CreatedAt, MountIDs: append([]string(nil), current.Preview.Backup.SourceMounts...)}
sort.Strings(manifest.MountIDs)
directory, err := s.instanceBackupDirectory(backup.InstanceID)
if err != nil {
return Backup{}, Manifest{}, err
}
if err := os.MkdirAll(directory, 0o750); err != nil {
return Backup{}, Manifest{}, err
}
var estimated int64
for _, mountID := range manifest.MountIDs {
source := mounts[mountID]
if source == "" {
return Backup{}, Manifest{}, ErrUnsafePath
}
if _, err := s.serverPath(source); err != nil {
return Backup{}, Manifest{}, err
}
size, err := estimateTree(ctx, source)
if err != nil {
return Backup{}, Manifest{}, err
}
estimated += size
}
if err := ensureFreeSpace(directory, estimated+(64<<20)); err != nil {
return Backup{}, Manifest{}, err
}
temporary, err := os.CreateTemp(directory, ".creating-*.tar.zst")
if err != nil {
return Backup{}, Manifest{}, err
}
temporaryName := temporary.Name()
defer func() { _ = temporary.Close(); _ = os.Remove(temporaryName) }()
hasher := sha256.New()
zstdWriter, err := zstd.NewWriter(io.MultiWriter(temporary, hasher), zstd.WithEncoderConcurrency(1))
if err != nil {
return Backup{}, Manifest{}, err
}
tarWriter := tar.NewWriter(zstdWriter)
manifestJSON, _ := json.Marshal(manifest)
err = tarWriter.WriteHeader(&tar.Header{Name: "manifest.json", Mode: 0o600, Size: int64(len(manifestJSON)), ModTime: s.now().UTC(), Typeflag: tar.TypeReg})
if err == nil {
_, err = tarWriter.Write(manifestJSON)
}
if err == nil {
for _, mountID := range manifest.MountIDs {
source := mounts[mountID]
if source == "" {
err = ErrUnsafePath
break
}
if _, pathErr := s.serverPath(source); pathErr != nil {
err = pathErr
break
}
if walkErr := addTree(ctx, tarWriter, source, path.Join("data", mountID)); walkErr != nil {
err = walkErr
break
}
}
}
if closeErr := tarWriter.Close(); err == nil {
err = closeErr
}
if closeErr := zstdWriter.Close(); err == nil {
err = closeErr
}
if syncErr := temporary.Sync(); err == nil {
err = syncErr
}
if closeErr := temporary.Close(); err == nil {
err = closeErr
}
if err != nil {
return Backup{}, Manifest{}, err
}
info, err := os.Stat(temporaryName)
if err != nil {
return Backup{}, Manifest{}, err
}
finalName := backup.ID + ".tar.zst"
finalPath := filepath.Join(directory, finalName)
if err := os.Rename(temporaryName, finalPath); err != nil {
return Backup{}, Manifest{}, err
}
backup.RelativePath = filepath.ToSlash(filepath.Join(backup.InstanceID, finalName))
backup.SizeBytes, backup.SHA256 = info.Size(), hex.EncodeToString(hasher.Sum(nil))
return backup, manifest, nil
}
func addTree(ctx context.Context, writer *tar.Writer, source, prefix string) error {
return filepath.WalkDir(source, func(current string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
info, err := entry.Info()
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 || (!info.Mode().IsRegular() && !info.IsDir()) {
return ErrUnsafePath
}
relative, err := filepath.Rel(source, current)
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return ErrUnsafePath
}
name := prefix
if relative != "." {
name = path.Join(prefix, filepath.ToSlash(relative))
}
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
header.Name, header.Uid, header.Gid, header.Uname, header.Gname = name, 0, 0, "", ""
if err := writer.WriteHeader(header); err != nil {
return err
}
if !info.Mode().IsRegular() {
return nil
}
file, err := os.Open(current)
if err != nil {
return err
}
_, copyErr := io.Copy(writer, file)
closeErr := file.Close()
if copyErr != nil {
return copyErr
}
return closeErr
})
}
func estimateTree(ctx context.Context, source string) (int64, error) {
var total int64
err := filepath.WalkDir(source, func(_ string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
info, err := entry.Info()
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 || (!info.Mode().IsRegular() && !info.IsDir()) {
return ErrUnsafePath
}
if info.Mode().IsRegular() {
total += info.Size()
}
return nil
})
return total, err
}
func ensureFreeSpace(directory string, required int64) error {
var stats unix.Statfs_t
if err := unix.Statfs(directory, &stats); err != nil {
return err
}
available := int64(stats.Bavail) * int64(stats.Bsize)
if available < required {
return ErrInvalidState
}
return nil
}
func (s *Service) restoreArchive(archive string, current instance.StoredInstance, manifest Manifest) error {
stage, err := os.MkdirTemp(s.serversRoot, ".dogama-restore-")
if err != nil {
return err
}
defer os.RemoveAll(stage)
file, err := os.Open(archive)
if err != nil {
return err
}
defer file.Close()
decoder, err := zstd.NewReader(file, zstd.WithDecoderConcurrency(1), zstd.WithDecoderMaxMemory(2<<30))
if err != nil {
return err
}
defer decoder.Close()
reader := tar.NewReader(decoder)
var total int64
sawManifest := false
for {
header, err := reader.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return err
}
if header.Name == "manifest.json" {
if sawManifest || header.Size < 1 || header.Size > 64<<10 {
return ErrIntegrity
}
var archived Manifest
if err := json.NewDecoder(io.LimitReader(reader, header.Size)).Decode(&archived); err != nil {
return ErrIntegrity
}
if archived.SchemaVersion != manifestSchemaVersion || archived.BackupID != manifest.BackupID || archived.InstanceID != manifest.InstanceID || archived.TemplateID != manifest.TemplateID || archived.TemplateVersion != manifest.TemplateVersion || strings.Join(archived.MountIDs, "\x00") != strings.Join(manifest.MountIDs, "\x00") {
return ErrIntegrity
}
sawManifest = true
continue
}
if !fs.ValidPath(header.Name) || !strings.HasPrefix(header.Name, "data/") || header.Linkname != "" {
return ErrUnsafePath
}
if header.Typeflag != tar.TypeReg && header.Typeflag != tar.TypeDir {
return ErrUnsafePath
}
total += header.Size
if total > 1<<40 {
return ErrInvalidInput
}
target := filepath.Join(stage, filepath.FromSlash(header.Name))
if !within(stage, target) {
return ErrUnsafePath
}
if header.Typeflag == tar.TypeDir {
if err := os.MkdirAll(target, 0o750); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil {
return err
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o640)
if err != nil {
return err
}
_, copyErr := io.CopyN(out, reader, header.Size)
closeErr := out.Close()
if copyErr != nil {
return copyErr
}
if closeErr != nil {
return closeErr
}
}
if !sawManifest {
return ErrIntegrity
}
mountPaths := make(map[string]string)
for _, mount := range current.Preview.Mounts {
mountPaths[mount.ID] = mount.HostPath
}
var swapped []struct{ live, previous string }
rollback := func() {
for index := len(swapped) - 1; index >= 0; index-- {
_ = os.RemoveAll(swapped[index].live)
_ = os.Rename(swapped[index].previous, swapped[index].live)
}
}
for _, mountID := range manifest.MountIDs {
live := mountPaths[mountID]
if _, err := s.serverPath(live); err != nil {
rollback()
return err
}
staged := filepath.Join(stage, "data", mountID)
if _, err := os.Stat(staged); err != nil {
rollback()
return ErrIntegrity
}
previous := live + ".dogama-previous-" + manifest.BackupID
if err := os.Rename(live, previous); err != nil {
rollback()
return err
}
if err := os.Rename(staged, live); err != nil {
_ = os.Rename(previous, live)
rollback()
return err
}
swapped = append(swapped, struct{ live, previous string }{live, previous})
}
for _, item := range swapped {
if err := os.RemoveAll(item.previous); err != nil {
return err
}
}
return nil
}
func (s *Service) applyRetention(ctx context.Context, instanceID string, count int) error {
candidates, err := s.repository.RetentionCandidates(ctx, instanceID, count)
if err != nil {
return err
}
for _, candidate := range candidates {
full, err := s.backupPath(candidate.RelativePath)
if err != nil {
return err
}
if err := os.Remove(full); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
if err := s.repository.MarkBackupDeleted(ctx, candidate.ID); err != nil {
return err
}
}
return nil
}
func (s *Service) fail(ctx context.Context, operationID, backupID, code string, cause error) error {
_ = s.repository.FailBackup(ctx, backupID, code)
_ = s.repository.FailOperation(ctx, operationID, "error", code)
return fmt.Errorf("%s: %w", code, cause)
}
func (s *Service) restoreFail(ctx context.Context, operationID, code string, cause error) error {
_ = s.repository.FailOperation(ctx, operationID, "intervention_required", code)
return fmt.Errorf("%s: %w", code, cause)
}
func (s *Service) instanceBackupDirectory(instanceID string) (string, error) {
if instanceID == "" || strings.ContainsAny(instanceID, `/\\`) {
return "", ErrUnsafePath
}
result := filepath.Join(s.backupsRoot, instanceID)
if !within(s.backupsRoot, result) {
return "", ErrUnsafePath
}
return result, nil
}
func (s *Service) backupPath(relative string) (string, error) {
if !fs.ValidPath(relative) {
return "", ErrUnsafePath
}
result := filepath.Join(s.backupsRoot, filepath.FromSlash(relative))
if !within(s.backupsRoot, result) {
return "", ErrUnsafePath
}
return result, nil
}
func (s *Service) serverPath(value string) (string, error) {
clean := filepath.Clean(value)
if !filepath.IsAbs(clean) || !within(s.serversRoot, clean) {
return "", ErrUnsafePath
}
return clean, nil
}
func canonicalRoot(value string) (string, error) {
if value == "" {
return "", ErrUnsafePath
}
absolute, err := filepath.Abs(value)
if err != nil {
return "", err
}
if err := os.MkdirAll(absolute, 0o750); err != nil {
return "", err
}
return filepath.EvalSymlinks(absolute)
}
func within(root, candidate string) bool {
relative, err := filepath.Rel(root, candidate)
return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
}
func verifyFile(name string, expectedSize int64, expectedSHA string) error {
file, err := os.Open(name)
if err != nil {
return err
}
defer file.Close()
hasher := sha256.New()
size, err := io.Copy(hasher, file)
if err != nil {
return err
}
if size != expectedSize || !strings.EqualFold(hex.EncodeToString(hasher.Sum(nil)), expectedSHA) {
return ErrIntegrity
}
return nil
}
func validOrigin(value string) bool {
switch value {
case "manual", "scheduled", "pre_update", "pre_restore", "idle_shutdown", "imported", "system":
return true
}
return false
}
func token() string {
buffer := make([]byte, 24)
if _, err := rand.Read(buffer); err != nil {
return ""
}
return base64.RawURLEncoding.EncodeToString(buffer)
}
func lifecycleFromAgent(state agentwire.InstanceState) (string, string) {
if !state.Running {
return "stopped", "stopped"
}
if state.Ready {
return "online", "ready"
}
if state.Health == "unhealthy" || state.Health == "none" {
return "degraded", "degraded"
}
return "starting", "running"
}
func cronParser() cron.Parser {
return cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
}
+205
View File
@@ -0,0 +1,205 @@
package backup_test
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
"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/instance"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
)
type backupAgent struct{ running bool }
func (a *backupAgent) StartInstance(_ context.Context, id string) (agentwire.InstanceState, error) {
a.running = true
return agentwire.InstanceState{InstanceID: id, ContainerID: "container", Running: true, Ready: true, Health: "healthy"}, nil
}
func (a *backupAgent) StopInstance(_ context.Context, id string, _ int) (agentwire.InstanceState, error) {
a.running = false
return agentwire.InstanceState{InstanceID: id, ContainerID: "container", Health: "stopped"}, nil
}
func TestCreateRestoreExportAndRetention(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
serversRoot, backupsRoot := filepath.Join(root, "servers"), filepath.Join(root, "backups")
mount := filepath.Join(serversRoot, "instance", "saved")
if err := os.MkdirAll(mount, 0o750); err != nil {
t.Fatal(err)
}
world := filepath.Join(mount, "Level.sav")
if err := os.WriteFile(world, []byte("world-v1"), 0o640); err != nil {
t.Fatal(err)
}
db, err := sqlite.Open(ctx, filepath.Join(root, "dogama.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
repository := sqlite.NewRepository(db)
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
if err != nil {
t.Fatal(err)
}
if err := repository.Sync(ctx, snapshots); err != nil {
t.Fatal(err)
}
authService := auth.New(db)
if err := authService.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
t.Fatal(err)
}
adminSession, err := authService.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
if err != nil {
t.Fatal(err)
}
admin, err := authService.Authenticate(ctx, adminSession.Token)
if err != nil {
t.Fatal(err)
}
preview, err := instance.BuildPreview(snapshots[0], instance.PreviewRequest{
DisplayName: "Backup Test", Slug: "backup-test", HostPorts: map[string]int{"game": 38211},
MountPaths: map[string]string{"saved": mount}, DataOrigin: "new", BackupRetention: 2,
})
if err != nil {
t.Fatal(err)
}
const instanceID = "backup-instance"
if err := repository.CreateDraft(ctx, instance.Draft{ID: instanceID, Preview: preview}); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`UPDATE instances SET lifecycle_state='online', observed_state='ready', container_id='container', desired_running=1 WHERE id=?`, instanceID); err != nil {
t.Fatal(err)
}
agent := &backupAgent{running: true}
service, err := backup.New(repository, agent, serversRoot, backupsRoot)
if err != nil {
t.Fatal(err)
}
manual, err := service.Create(ctx, admin.ID, instanceID, "manual")
if err != nil {
t.Fatal(err)
}
if manual.Status != "available" || manual.SHA256 == "" || manual.SizeBytes == 0 || !agent.running {
t.Fatalf("backup = %#v, running=%v", manual, agent.running)
}
exported, metadata, err := service.Export(ctx, instanceID, manual.ID)
if err != nil {
t.Fatal(err)
}
if metadata.ID != manual.ID {
t.Fatalf("export metadata = %#v", metadata)
}
if err := exported.Close(); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(world, []byte("world-v2"), 0o640); err != nil {
t.Fatal(err)
}
if err := service.Restore(ctx, admin.ID, instanceID, manual.ID); err != nil {
t.Fatal(err)
}
restored, err := os.ReadFile(world)
if err != nil || string(restored) != "world-v1" {
t.Fatalf("restored world = %q, error = %v", restored, err)
}
for index := 0; index < 3; index++ {
if _, err := service.Create(ctx, admin.ID, instanceID, "scheduled"); err != nil {
t.Fatal(err)
}
}
backups, err := service.List(ctx, instanceID)
if err != nil {
t.Fatal(err)
}
var manualCount, scheduledCount, safetyCount int
for _, item := range backups {
switch item.Origin {
case "manual":
manualCount++
case "scheduled":
scheduledCount++
case "pre_restore":
safetyCount++
}
}
if manualCount != 1 || scheduledCount != 2 || safetyCount != 1 {
t.Fatalf("retained backups: manual=%d scheduled=%d safety=%d", manualCount, scheduledCount, safetyCount)
}
}
func TestBackupRejectsSymlinks(t *testing.T) {
ctx, service, actorID, instanceID, mount := backupFixture(t)
outside := filepath.Join(t.TempDir(), "outside")
if err := os.WriteFile(outside, []byte("secret"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(mount, "escape")); err != nil {
t.Fatal(err)
}
_, err := service.Create(ctx, actorID, instanceID, "manual")
if !errors.Is(err, backup.ErrUnsafePath) {
t.Fatalf("symlink backup error = %v", err)
}
}
func backupFixture(t *testing.T) (context.Context, *backup.Service, string, string, string) {
t.Helper()
ctx := context.Background()
root := t.TempDir()
serversRoot := filepath.Join(root, "servers")
backupsRoot := filepath.Join(root, "backups")
mount := filepath.Join(serversRoot, "instance", "saved")
if err := os.MkdirAll(mount, 0o750); err != nil {
t.Fatal(err)
}
db, err := sqlite.Open(ctx, filepath.Join(root, "dogama.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
repository := sqlite.NewRepository(db)
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
if err != nil {
t.Fatal(err)
}
if err := repository.Sync(ctx, snapshots); err != nil {
t.Fatal(err)
}
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)
}
actor, err := authService.Authenticate(ctx, session.Token)
if err != nil {
t.Fatal(err)
}
preview, err := instance.BuildPreview(snapshots[0], instance.PreviewRequest{DisplayName: "Backup Test", Slug: "backup-test", HostPorts: map[string]int{"game": 38211}, MountPaths: map[string]string{"saved": mount}, DataOrigin: "new", BackupRetention: 2})
if err != nil {
t.Fatal(err)
}
instanceID := "backup-instance"
if err := repository.CreateDraft(ctx, instance.Draft{ID: instanceID, Preview: preview}); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`UPDATE instances SET lifecycle_state='stopped', observed_state='stopped', container_id='container' WHERE id=?`, instanceID); err != nil {
t.Fatal(err)
}
service, err := backup.New(repository, &backupAgent{}, serversRoot, backupsRoot)
if err != nil {
t.Fatal(err)
}
return ctx, service, actor.ID, instanceID, mount
}
+19 -2
View File
@@ -88,9 +88,26 @@ type Template struct {
IntervalSeconds int `json:"interval_seconds"`
} `json:"healthcheck"`
Imports struct {
Supported bool `json:"supported"`
DestinationMount string `json:"destination_mount"`
Supported bool `json:"supported"`
AcceptedFormats []string `json:"accepted_formats"`
MaxExtractedSizeGB int `json:"max_extracted_size_gb"`
RequiredPaths []string `json:"required_paths"`
DestinationMount string `json:"destination_mount"`
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 {
+521
View File
@@ -0,0 +1,521 @@
// Package importexport validates untrusted game-data archives in isolated staging.
package importexport
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"context"
"crypto/rand"
"encoding/base64"
"errors"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/klauspost/compress/zstd"
)
var (
ErrInvalidInput = errors.New("invalid import input")
ErrUnsafeArchive = errors.New("unsafe import archive")
ErrLimitExceeded = errors.New("import limit exceeded")
ErrNotRecognized = errors.New("import layout not recognized")
)
const (
maxFiles = 100000
maxDepth = 20
)
type Policy struct {
TemplateID string
TemplateVersion string
AcceptedFormats []string
MaxExpandedBytes int64
RequiredPaths []string
}
type Import struct {
ID string `json:"id"`
Status string `json:"status"`
Format string `json:"format"`
DetectedType string `json:"detected_type,omitempty"`
Confidence string `json:"confidence,omitempty"`
FileCount int `json:"file_count"`
ExpandedSizeBytes int64 `json:"expanded_size_bytes"`
ExpiresAt string `json:"expires_at"`
RelativeStagePath string `json:"-"`
DataRoot string `json:"-"`
TemplateID string `json:"template_id"`
TemplateVersion string `json:"template_version"`
InstanceID string `json:"instance_id,omitempty"`
}
type Repository interface {
BeginImport(context.Context, Import, string) error
CompleteImport(context.Context, Import) error
FailImport(context.Context, string, string) error
ExpireImports(context.Context, string) ([]string, error)
GetImport(context.Context, string) (Import, error)
AttachImport(context.Context, string, string) error
}
func (s *Service) CleanupExpired(ctx context.Context) error {
paths, err := s.repository.ExpireImports(ctx, s.now().UTC().Format(time.RFC3339Nano))
if err != nil {
return err
}
for _, relative := range paths {
if !fs.ValidPath(relative) {
return ErrUnsafeArchive
}
target := filepath.Join(s.root, filepath.FromSlash(relative))
if !withinRoot(s.root, target) {
return ErrUnsafeArchive
}
if err := os.RemoveAll(target); err != nil {
return err
}
}
return nil
}
type Service struct {
repository Repository
root string
serversRoot string
now func() time.Time
}
func New(repository Repository, root, serversRoot string) (*Service, error) {
if root == "" || serversRoot == "" {
return nil, ErrInvalidInput
}
absolute, err := filepath.Abs(root)
if err != nil {
return nil, err
}
if err := os.MkdirAll(absolute, 0o750); err != nil {
return nil, err
}
canonical, err := filepath.EvalSymlinks(absolute)
if err != nil {
return nil, err
}
serverAbsolute, err := filepath.Abs(serversRoot)
if err != nil {
return nil, err
}
if err := os.MkdirAll(serverAbsolute, 0o750); err != nil {
return nil, err
}
serverCanonical, err := filepath.EvalSymlinks(serverAbsolute)
if err != nil {
return nil, err
}
return &Service{repository: repository, root: canonical, serversRoot: serverCanonical, now: time.Now}, nil
}
func (s *Service) Stage(ctx context.Context, actorID, format string, source io.Reader, policy Policy) (Import, error) {
if actorID == "" || policy.TemplateID == "" || policy.TemplateVersion == "" || !contains(policy.AcceptedFormats, format) || policy.MaxExpandedBytes < 1 {
return Import{}, ErrInvalidInput
}
id := importToken()
if id == "" {
return Import{}, errors.New("generate import identifier")
}
directory := filepath.Join(s.root, id)
if !withinRoot(s.root, directory) {
return Import{}, ErrUnsafeArchive
}
if err := os.Mkdir(directory, 0o750); err != nil {
return Import{}, err
}
value := Import{ID: id, Status: "staging", Format: format, RelativeStagePath: id, TemplateID: policy.TemplateID, TemplateVersion: policy.TemplateVersion, ExpiresAt: s.now().Add(24 * time.Hour).UTC().Format(time.RFC3339Nano)}
if err := s.repository.BeginImport(ctx, value, actorID); err != nil {
_ = os.RemoveAll(directory)
return Import{}, err
}
fail := func(code string, err error) (Import, error) {
_ = s.repository.FailImport(ctx, id, code)
_ = os.RemoveAll(directory)
return Import{}, err
}
upload := filepath.Join(directory, "upload")
file, err := os.OpenFile(upload, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return fail("import_stage_failed", err)
}
maxUpload := policy.MaxExpandedBytes
written, copyErr := io.Copy(file, io.LimitReader(source, maxUpload+1))
closeErr := file.Close()
if copyErr != nil || closeErr != nil {
if copyErr == nil {
copyErr = closeErr
}
return fail("import_upload_failed", copyErr)
}
if written > maxUpload {
return fail("import_upload_limit", ErrLimitExceeded)
}
extracted := filepath.Join(directory, "data")
if err := os.Mkdir(extracted, 0o750); err != nil {
return fail("import_stage_failed", err)
}
files, size, paths, err := extract(ctx, upload, extracted, format, policy.MaxExpandedBytes)
if err != nil {
return fail("import_validation_failed", err)
}
if !requiredPresent(paths, policy.RequiredPaths) {
return fail("import_layout_unrecognized", ErrNotRecognized)
}
dataRoot, err := detectedRoot(paths, policy.RequiredPaths)
if err != nil {
return fail("import_layout_ambiguous", err)
}
if err := os.Remove(upload); err != nil {
return fail("import_cleanup_failed", err)
}
value.Status, value.DetectedType, value.Confidence, value.FileCount, value.ExpandedSizeBytes, value.DataRoot = "validated", "game_save", "confirmed", files, size, dataRoot
if err := s.repository.CompleteImport(ctx, value); err != nil {
return fail("import_persist_failed", err)
}
return value, nil
}
func (s *Service) ValidateSelection(ctx context.Context, id, templateID, templateVersion string) error {
value, err := s.repository.GetImport(ctx, id)
if err != nil {
return err
}
if value.Status != "validated" || value.TemplateID != templateID || value.TemplateVersion != templateVersion {
return ErrInvalidInput
}
return nil
}
func (s *Service) ApplyToInstance(ctx context.Context, id, instanceID, templateID, templateVersion, mountPath, relativePath string) error {
value, err := s.repository.GetImport(ctx, id)
if err != nil {
return err
}
if value.InstanceID == instanceID && value.Status == "attached" {
return nil
}
if value.Status != "validated" || value.TemplateID != templateID || value.TemplateVersion != templateVersion {
return ErrInvalidInput
}
live := filepath.Join(mountPath, filepath.FromSlash(relativePath))
if !filepath.IsAbs(live) || !withinRoot(s.serversRoot, live) {
return ErrUnsafeArchive
}
source := filepath.Join(s.root, filepath.FromSlash(value.RelativeStagePath), "data", filepath.FromSlash(value.DataRoot))
if !withinRoot(s.root, source) {
return ErrUnsafeArchive
}
if info, err := os.Stat(source); err != nil || !info.IsDir() {
return ErrInvalidInput
}
if entries, err := os.ReadDir(live); err == nil && len(entries) != 0 {
return ErrInvalidInput
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
if err := os.MkdirAll(filepath.Dir(live), 0o750); err != nil {
return err
}
temporary, err := os.MkdirTemp(filepath.Dir(live), ".dogama-import-")
if err != nil {
return err
}
defer os.RemoveAll(temporary)
if err := copyValidatedTree(source, temporary); err != nil {
return err
}
if err := os.Remove(live); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
if err := os.Rename(temporary, live); err != nil {
return err
}
if err := s.repository.AttachImport(ctx, id, instanceID); err != nil {
_ = os.RemoveAll(live)
return err
}
return nil
}
func extract(ctx context.Context, archive, destination, format string, limit int64) (int, int64, []string, error) {
switch format {
case "zip":
return extractZIP(ctx, archive, destination, limit)
case "tar", "tar.gz", "tar.zst":
return extractTar(ctx, archive, destination, format, limit)
default:
return 0, 0, nil, ErrInvalidInput
}
}
func extractZIP(ctx context.Context, archive, destination string, limit int64) (int, int64, []string, error) {
reader, err := zip.OpenReader(archive)
if err != nil {
return 0, 0, nil, ErrUnsafeArchive
}
defer reader.Close()
var count int
var total int64
var paths []string
for _, entry := range reader.File {
select {
case <-ctx.Done():
return 0, 0, nil, ctx.Err()
default:
}
name, err := safeName(entry.Name)
if err != nil || entry.Mode()&os.ModeSymlink != 0 || (!entry.Mode().IsRegular() && !entry.FileInfo().IsDir()) {
return 0, 0, nil, ErrUnsafeArchive
}
if entry.FileInfo().IsDir() {
if err := os.MkdirAll(filepath.Join(destination, filepath.FromSlash(name)), 0o750); err != nil {
return 0, 0, nil, err
}
continue
}
count++
total += int64(entry.UncompressedSize64)
if count > maxFiles || total > limit {
return 0, 0, nil, ErrLimitExceeded
}
input, err := entry.Open()
if err != nil {
return 0, 0, nil, err
}
if err := writeExtracted(destination, name, input, int64(entry.UncompressedSize64)); err != nil {
input.Close()
return 0, 0, nil, err
}
if err := input.Close(); err != nil {
return 0, 0, nil, err
}
paths = append(paths, name)
}
return count, total, paths, nil
}
func extractTar(ctx context.Context, archive, destination, format string, limit int64) (int, int64, []string, error) {
file, err := os.Open(archive)
if err != nil {
return 0, 0, nil, err
}
defer file.Close()
var source io.Reader = file
var closer io.Closer
if format == "tar.gz" {
value, err := gzip.NewReader(file)
if err != nil {
return 0, 0, nil, ErrUnsafeArchive
}
source, closer = value, value
}
if format == "tar.zst" {
value, err := zstd.NewReader(file, zstd.WithDecoderConcurrency(1), zstd.WithDecoderMaxMemory(2<<30))
if err != nil {
return 0, 0, nil, ErrUnsafeArchive
}
source, closer = value, value.IOReadCloser()
}
if closer != nil {
defer closer.Close()
}
reader := tar.NewReader(source)
var count int
var total int64
var paths []string
for {
select {
case <-ctx.Done():
return 0, 0, nil, ctx.Err()
default:
}
header, err := reader.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return 0, 0, nil, ErrUnsafeArchive
}
name, err := safeName(header.Name)
if err != nil || header.Linkname != "" {
return 0, 0, nil, ErrUnsafeArchive
}
if header.Typeflag == tar.TypeDir {
if err := os.MkdirAll(filepath.Join(destination, filepath.FromSlash(name)), 0o750); err != nil {
return 0, 0, nil, err
}
continue
}
if !header.FileInfo().Mode().IsRegular() {
return 0, 0, nil, ErrUnsafeArchive
}
count++
total += header.Size
if count > maxFiles || total > limit {
return 0, 0, nil, ErrLimitExceeded
}
if err := writeExtracted(destination, name, reader, header.Size); err != nil {
return 0, 0, nil, err
}
paths = append(paths, name)
}
return count, total, paths, nil
}
func writeExtracted(root, name string, source io.Reader, size int64) error {
target := filepath.Join(root, filepath.FromSlash(name))
if !withinRoot(root, target) {
return ErrUnsafeArchive
}
if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil {
return err
}
file, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o640)
if err != nil {
return err
}
written, copyErr := io.CopyN(file, source, size)
closeErr := file.Close()
if copyErr != nil || written != size {
return ErrUnsafeArchive
}
return closeErr
}
func safeName(value string) (string, error) {
if strings.Contains(value, "\\") || strings.ContainsRune(value, 0) || strings.HasPrefix(value, "/") {
return "", ErrUnsafeArchive
}
clean := path.Clean(value)
if clean == "." || !fs.ValidPath(clean) || strings.Contains(strings.Split(clean, "/")[0], ":") || len(strings.Split(clean, "/")) > maxDepth {
return "", ErrUnsafeArchive
}
return clean, nil
}
func requiredPresent(paths, required []string) bool {
for _, expected := range required {
found := false
for _, candidate := range paths {
if candidate == expected || strings.HasSuffix(candidate, "/"+expected) || strings.HasPrefix(candidate, expected+"/") || strings.Contains(candidate, "/"+expected+"/") {
found = true
break
}
}
if !found {
return false
}
}
return true
}
func detectedRoot(paths, required []string) (string, error) {
root := ""
for _, expected := range required {
found := ""
for _, candidate := range paths {
marker := "/" + expected
if candidate == expected || strings.HasPrefix(candidate, expected+"/") {
found = "."
break
}
if index := strings.Index(candidate, marker); index >= 0 && (len(candidate) == index+len(marker) || candidate[index+len(marker)] == '/') {
found = candidate[:index]
break
}
}
if found == "" {
return "", ErrNotRecognized
}
if root == "" {
root = found
} else if root != found {
return "", ErrNotRecognized
}
}
if root == "" {
root = "."
}
return root, nil
}
func copyValidatedTree(source, destination string) error {
return filepath.WalkDir(source, func(current string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
info, err := entry.Info()
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 || (!info.Mode().IsRegular() && !info.IsDir()) {
return ErrUnsafeArchive
}
relative, err := filepath.Rel(source, current)
if err != nil {
return err
}
if relative == "." {
return nil
}
target := filepath.Join(destination, relative)
if !withinRoot(destination, target) {
return ErrUnsafeArchive
}
if info.IsDir() {
return os.MkdirAll(target, 0o750)
}
input, err := os.Open(current)
if err != nil {
return err
}
output, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o640)
if err != nil {
_ = input.Close()
return err
}
_, copyErr := io.Copy(output, input)
inputCloseErr := input.Close()
closeErr := output.Close()
if copyErr != nil {
return copyErr
}
if inputCloseErr != nil {
return inputCloseErr
}
return closeErr
})
}
func contains(values []string, expected string) bool {
for _, value := range values {
if value == expected {
return true
}
}
return false
}
func withinRoot(root, candidate string) bool {
relative, err := filepath.Rel(root, candidate)
return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
}
func importToken() string {
value := make([]byte, 24)
if _, err := rand.Read(value); err != nil {
return ""
}
return base64.RawURLEncoding.EncodeToString(value)
}
+105
View File
@@ -0,0 +1,105 @@
package importexport_test
import (
"archive/zip"
"bytes"
"context"
"io"
"os"
"path/filepath"
"testing"
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
"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/persistence/sqlite"
)
func TestStageValidZIPAndRejectTraversal(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
db, err := sqlite.Open(ctx, filepath.Join(root, "dogama.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
repository := sqlite.NewRepository(db)
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
if err != nil {
t.Fatal(err)
}
if err := repository.Sync(ctx, snapshots); err != nil {
t.Fatal(err)
}
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)
}
actor, err := authService.Authenticate(ctx, session.Token)
if err != nil {
t.Fatal(err)
}
serversRoot := filepath.Join(root, "servers")
service, err := importexport.New(repository, filepath.Join(root, "imports"), serversRoot)
if err != nil {
t.Fatal(err)
}
policy := importexport.Policy{TemplateID: "palworld-official", TemplateVersion: "1.0.0", AcceptedFormats: []string{"zip"}, MaxExpandedBytes: 1 << 20, RequiredPaths: []string{"Level.sav", "Players"}}
valid := zipBytes(t, map[string]string{"Save/Level.sav": "world", "Save/Players/player.sav": "player"})
result, err := service.Stage(ctx, actor.ID, "zip", bytes.NewReader(valid), policy)
if err != nil {
t.Fatal(err)
}
if result.Status != "validated" || result.Confidence != "confirmed" || result.FileCount != 2 {
t.Fatalf("import = %#v", result)
}
destination := filepath.Join(serversRoot, "instance", "saved")
preview, err := instance.BuildPreview(snapshots[0], instance.PreviewRequest{DisplayName: "Import Test", Slug: "import-test", HostPorts: map[string]int{"game": 38211}, MountPaths: map[string]string{"saved": destination}, DataOrigin: "new", BackupRetention: 2})
if err != nil {
t.Fatal(err)
}
if err := repository.CreateDraft(ctx, instance.Draft{ID: "instance-id", Preview: preview}); err != nil {
t.Fatal(err)
}
if err := service.ApplyToInstance(ctx, result.ID, "instance-id", policy.TemplateID, policy.TemplateVersion, destination, "SaveGames/0"); err != nil {
t.Fatal(err)
}
if body, err := os.ReadFile(filepath.Join(destination, "SaveGames", "0", "Level.sav")); err != nil || string(body) != "world" {
t.Fatalf("applied import=%q error=%v", body, err)
}
if err := service.ApplyToInstance(ctx, result.ID, "instance-id", policy.TemplateID, policy.TemplateVersion, destination, "SaveGames/0"); err != nil {
t.Fatalf("idempotent apply: %v", err)
}
unsafe := zipBytes(t, map[string]string{"../escape": "bad", "Level.sav": "world", "Players/player": "player"})
if _, err := service.Stage(ctx, actor.ID, "zip", bytes.NewReader(unsafe), policy); err == nil {
t.Fatal("traversal archive was accepted")
}
if _, err := os.Stat(filepath.Join(root, "escape")); !os.IsNotExist(err) {
t.Fatalf("escape path exists: %v", err)
}
}
func zipBytes(t *testing.T, files map[string]string) []byte {
t.Helper()
var buffer bytes.Buffer
writer := zip.NewWriter(&buffer)
for name, body := range files {
entry, err := writer.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := io.WriteString(entry, body); err != nil {
t.Fatal(err)
}
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
return buffer.Bytes()
}
+226
View File
@@ -0,0 +1,226 @@
package instance
import (
"context"
"errors"
"regexp"
"strings"
"time"
)
type ConfigurationRepository interface {
GetGlobalLabels(context.Context) (map[string]string, error)
SetGlobalLabels(context.Context, map[string]string, bool) (affected int, running int, err error)
SaveInstanceConfiguration(context.Context, string, Preview, bool, string, string) error
ClearContainerConfigPending(context.Context, string, string) error
ListConfigurationRevisions(context.Context, string) ([]ConfigurationRevision, error)
GetConfigurationRevision(context.Context, string, int) (ConfigurationRevision, error)
}
type ConfigurationRevision struct {
InstanceID string `json:"instance_id"`
Revision int `json:"revision"`
Snapshot Preview `json:"snapshot"`
Reason string `json:"reason"`
CreatedBy string `json:"created_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type ConfigurationStatus struct {
InstanceID string `json:"instance_id"`
ContainerConfigPending bool `json:"container_config_pending"`
Preview Preview `json:"configuration"`
}
func (s *LifecycleService) Configure(ctx context.Context, instanceID, labels string, tag ImageTag, immediate bool) (OperationResult, error) {
repository, ok := s.repository.(ConfigurationRepository)
if !ok {
return OperationResult{}, errors.New("container configuration is unavailable")
}
return s.exclusive(instanceID, func() (OperationResult, error) {
current, err := s.repository.GetInstance(ctx, instanceID)
if err != nil {
return OperationResult{}, err
}
parsed, err := ParseLabels(labels)
if err != nil {
return OperationResult{}, err
}
defaultTag := current.Preview.TemplateDefaultTag
if defaultTag == "" {
parts := strings.Split(current.Preview.Image, ":")
defaultTag = parts[len(parts)-1]
}
validated, err := ValidateImageTag(tag, defaultTag)
if err != nil {
return OperationResult{}, err
}
preview := current.Preview
preview.CustomLabels, preview.ImageTag = parsed, validated
base := imageRepository(preview.Image)
preview.Image = base + ":" + validated.Tag
if err := repository.SaveInstanceConfiguration(ctx, instanceID, preview, !immediate, "container_configuration", ""); err != nil {
return OperationResult{}, err
}
if !immediate || current.ContainerID == "" {
updated, _ := s.repository.GetInstance(ctx, instanceID)
return resultFrom(updated, ""), nil
}
agent, ok := s.agent.(replacementAgent)
if !ok {
return OperationResult{}, errors.New("container replacement is unavailable")
}
operationID, err := operationToken()
if err != nil {
return OperationResult{}, err
}
current, err = s.repository.BeginOperation(ctx, operationID, instanceID, "restart", "update")
if err != nil {
return OperationResult{}, err
}
plan, err := preview.DeploymentPlan(instanceID)
if err != nil {
return s.fail(ctx, operationID, instanceID, "invalid_plan", err)
}
state, err := agent.ReplaceInstance(ctx, plan)
if err != nil {
return s.fail(ctx, operationID, instanceID, "agent_replace_failed", err)
}
if current.DesiredRunning {
state, err = s.agent.StartInstance(ctx, instanceID)
if err != nil {
return s.fail(ctx, operationID, instanceID, "agent_start_failed", err)
}
}
lifecycle, observed := stateToLifecycle(state)
if err := s.repository.FinishOperation(ctx, operationID, lifecycle, observed, state.ContainerID, plan.PlanDigest, current.DesiredRunning, ""); err != nil {
return OperationResult{}, err
}
if err := repository.ClearContainerConfigPending(ctx, instanceID, plan.PlanDigest); err != nil {
return OperationResult{}, err
}
return OperationResult{OperationID: operationID, InstanceID: instanceID, State: lifecycle, Observed: observed, ContainerID: state.ContainerID, AgentState: state}, nil
})
}
func imageRepository(reference string) string {
if at := strings.Index(reference, "@"); at >= 0 {
reference = reference[:at]
}
if colon := strings.LastIndex(reference, ":"); colon > strings.LastIndex(reference, "/") {
return reference[:colon]
}
return reference
}
var workshopIDPattern = regexp.MustCompile(`^[1-9][0-9]{0,19}$`)
func (s *LifecycleService) ConfigureMods(ctx context.Context, instanceID string, items []string, immediate bool, actorID string) (OperationResult, error) {
return s.exclusive(instanceID, func() (OperationResult, error) {
current, err := s.repository.GetInstance(ctx, instanceID)
if err != nil {
return OperationResult{}, err
}
if !current.Preview.Mods.Supported {
return OperationResult{}, errors.New("template does not support mods")
}
if current.Preview.Mods.Provider != "steam_workshop" {
return OperationResult{}, errors.New("mod provider is not implemented safely")
}
if len(items) > 256 {
return OperationResult{}, errors.New("too many mods")
}
seen := map[string]struct{}{}
for _, id := range items {
if !workshopIDPattern.MatchString(id) {
return OperationResult{}, errors.New("invalid Steam Workshop item ID")
}
if _, exists := seen[id]; exists {
return OperationResult{}, errors.New("duplicate mod item ID")
}
seen[id] = struct{}{}
}
items = append([]string(nil), items...)
preview := current.Preview
preview.Mods.Items = items
repository := s.repository.(ConfigurationRepository)
if err := repository.SaveInstanceConfiguration(ctx, instanceID, preview, !immediate, "mods", actorID); err != nil {
return OperationResult{}, err
}
if !immediate || current.ContainerID == "" {
updated, _ := s.repository.GetInstance(ctx, instanceID)
return resultFrom(updated, ""), nil
}
return s.replaceConfigured(ctx, current, preview, "restart")
})
}
func (s *LifecycleService) RollbackConfiguration(ctx context.Context, instanceID string, revision int, immediate bool, actorID string) (OperationResult, error) {
return s.exclusive(instanceID, func() (OperationResult, error) {
repository := s.repository.(ConfigurationRepository)
current, err := s.repository.GetInstance(ctx, instanceID)
if err != nil {
return OperationResult{}, err
}
old, err := repository.GetConfigurationRevision(ctx, instanceID, revision)
if err != nil {
return OperationResult{}, err
}
if old.Snapshot.Template != current.Preview.Template {
return OperationResult{}, errors.New("revision template no longer matches pinned template")
}
old.Snapshot.DockerUser = current.Preview.DockerUser
old.Snapshot.DockerUserValue = current.Preview.DockerUserValue
if _, err := ValidateImageTag(old.Snapshot.ImageTag, old.Snapshot.TemplateDefaultTag); err != nil {
return OperationResult{}, err
}
if _, err := old.Snapshot.DeploymentPlan(instanceID); err != nil {
return OperationResult{}, err
}
if err := repository.SaveInstanceConfiguration(ctx, instanceID, old.Snapshot, !immediate, "rollback", actorID); err != nil {
return OperationResult{}, err
}
if !immediate || current.ContainerID == "" {
updated, _ := s.repository.GetInstance(ctx, instanceID)
return resultFrom(updated, ""), nil
}
return s.replaceConfigured(ctx, current, old.Snapshot, "restart")
})
}
func (s *LifecycleService) replaceConfigured(ctx context.Context, current StoredInstance, preview Preview, kind string) (OperationResult, error) {
agent, ok := s.agent.(replacementAgent)
if !ok {
return OperationResult{}, errors.New("container replacement is unavailable")
}
operationID, err := operationToken()
if err != nil {
return OperationResult{}, err
}
current, err = s.repository.BeginOperation(ctx, operationID, current.ID, kind, "update")
if err != nil {
return OperationResult{}, err
}
plan, err := preview.DeploymentPlan(current.ID)
if err != nil {
return s.fail(ctx, operationID, current.ID, "invalid_plan", err)
}
state, err := agent.ReplaceInstance(ctx, plan)
if err != nil {
return s.fail(ctx, operationID, current.ID, "agent_replace_failed", err)
}
if current.DesiredRunning {
state, err = s.agent.StartInstance(ctx, current.ID)
}
if err != nil {
return s.fail(ctx, operationID, current.ID, "agent_start_failed", err)
}
lifecycle, observed := stateToLifecycle(state)
if err := s.repository.FinishOperation(ctx, operationID, lifecycle, observed, state.ContainerID, plan.PlanDigest, current.DesiredRunning, ""); err != nil {
return OperationResult{}, err
}
if err := s.repository.(ConfigurationRepository).ClearContainerConfigPending(ctx, current.ID, plan.PlanDigest); err != nil {
return OperationResult{}, err
}
return OperationResult{OperationID: operationID, InstanceID: current.ID, State: lifecycle, Observed: observed, ContainerID: state.ContainerID, AgentState: state}, nil
}
+216
View File
@@ -0,0 +1,216 @@
package instance
import (
"errors"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"unicode"
"golang.org/x/text/unicode/norm"
)
const (
DockerUserDoGaMa = "dogama"
DockerUserCustom = "custom"
DockerUserImage = "image"
ImageTagTracked = "tracked"
ImageTagPinned = "pinned"
)
var (
labelKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*(?:/[A-Za-z0-9][A-Za-z0-9_.-]*)?$`)
tagPattern = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$`)
variablePattern = regexp.MustCompile(`\{\{([^{}]+)\}\}`)
)
var AllowedLabelVariables = []string{
"game.name", "game.id", "game.icon_url", "instance.name", "instance.id", "instance.slug", "server.name",
}
type DockerUser struct {
Mode string `json:"mode"`
UID *uint32 `json:"uid,omitempty"`
GID *uint32 `json:"gid,omitempty"`
}
type ImageTag struct {
Mode string `json:"mode"`
Tag string `json:"tag"`
}
type ContainerConfiguration struct {
Labels map[string]string `json:"labels"`
DockerUser DockerUser `json:"docker_user"`
ImageTag ImageTag `json:"image_tag"`
}
type LabelContext struct {
GameName, GameID, GameIconURL string
InstanceName, InstanceID, InstanceSlug string
ServerName string
}
func ParseLabels(input string) (map[string]string, error) {
labels := make(map[string]string)
for index, raw := range strings.Split(strings.ReplaceAll(input, "\r\n", "\n"), "\n") {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
separator := strings.IndexByte(line, '=')
if separator < 1 {
return nil, fmt.Errorf("label line %d must use key=value", index+1)
}
key := strings.TrimSpace(line[:separator])
if !labelKeyPattern.MatchString(key) {
return nil, fmt.Errorf("label line %d has an invalid key", index+1)
}
lower := strings.ToLower(key)
if strings.HasPrefix(lower, "dogama.") || strings.HasPrefix(lower, "io.dogama.") {
return nil, fmt.Errorf("label line %d uses a reserved DoGaMa key", index+1)
}
value := line[separator+1:]
if err := ValidateLabelTemplate(value); err != nil {
return nil, fmt.Errorf("label line %d: %w", index+1, err)
}
labels[key] = value
}
return labels, nil
}
func ValidateLabelTemplate(value string) error {
allowed := make(map[string]bool, len(AllowedLabelVariables))
for _, variable := range AllowedLabelVariables {
allowed[variable] = true
}
for _, match := range variablePattern.FindAllStringSubmatch(value, -1) {
if !allowed[match[1]] {
return fmt.Errorf("unknown label variable %q", match[1])
}
}
withoutKnown := variablePattern.ReplaceAllString(value, "")
if strings.Contains(withoutKnown, "{{") || strings.Contains(withoutKnown, "}}") {
return errors.New("invalid label variable syntax")
}
return nil
}
func ResolveLabels(labels map[string]string, context LabelContext) (map[string]string, error) {
values := map[string]string{
"game.name": context.GameName, "game.id": context.GameID, "game.icon_url": context.GameIconURL,
"instance.name": context.InstanceName, "instance.id": context.InstanceID, "instance.slug": context.InstanceSlug,
"server.name": context.ServerName,
}
result := make(map[string]string, len(labels))
for key, value := range labels {
if err := ValidateLabelTemplate(value); err != nil {
return nil, err
}
result[key] = variablePattern.ReplaceAllStringFunc(value, func(token string) string {
name := token[2 : len(token)-2]
return values[name]
})
}
return result, nil
}
func MergeLabels(technical, global, local map[string]string) map[string]string {
result := make(map[string]string, len(technical)+len(global)+len(local))
for key, value := range global {
result[key] = value
}
for key, value := range local {
result[key] = value
}
for key, value := range technical {
result[key] = value
}
return result
}
func FormatLabels(labels map[string]string) string {
keys := make([]string, 0, len(labels))
for key := range labels {
keys = append(keys, key)
}
sort.Strings(keys)
lines := make([]string, 0, len(keys))
for _, key := range keys {
lines = append(lines, key+"="+labels[key])
}
return strings.Join(lines, "\n")
}
func Slugify(value string) string {
decomposed := norm.NFD.String(strings.ToLower(strings.TrimSpace(value)))
var builder strings.Builder
separator := false
for _, r := range decomposed {
if unicode.Is(unicode.Mn, r) {
continue
}
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
if separator && builder.Len() > 0 {
builder.WriteByte('-')
}
builder.WriteRune(r)
separator = false
} else {
separator = true
}
}
return strings.Trim(builder.String(), "-")
}
func ValidateDockerUser(user DockerUser) error {
switch user.Mode {
case DockerUserDoGaMa, DockerUserImage:
if user.UID != nil || user.GID != nil {
return errors.New("UID and GID are only valid in custom mode")
}
case DockerUserCustom:
if user.UID == nil || user.GID == nil {
return errors.New("custom Docker user requires UID and GID")
}
default:
return errors.New("invalid Docker user mode")
}
return nil
}
func DockerUserValue(user DockerUser, processUID, processGID uint32) (string, error) {
if err := ValidateDockerUser(user); err != nil {
return "", err
}
switch user.Mode {
case DockerUserImage:
return "", nil
case DockerUserDoGaMa:
return strconv.FormatUint(uint64(processUID), 10) + ":" + strconv.FormatUint(uint64(processGID), 10), nil
default:
return strconv.FormatUint(uint64(*user.UID), 10) + ":" + strconv.FormatUint(uint64(*user.GID), 10), nil
}
}
func ValidateImageTag(value ImageTag, defaultTag string) (ImageTag, error) {
if value.Mode == "" {
value.Mode = ImageTagTracked
}
switch value.Mode {
case ImageTagTracked:
value.Tag = defaultTag
case ImageTagPinned:
if !tagPattern.MatchString(value.Tag) {
return ImageTag{}, errors.New("invalid pinned Docker image tag")
}
default:
return ImageTag{}, errors.New("invalid Docker image tag mode")
}
if !tagPattern.MatchString(value.Tag) {
return ImageTag{}, errors.New("invalid Docker image tag")
}
return value, nil
}
@@ -0,0 +1,62 @@
package instance
import "testing"
func TestLabelsAndVariables(t *testing.T) {
labels, err := ParseLabels("\n glance.name={{instance.name}}\nquery=a=b=c\n")
if err != nil || labels["query"] != "a=b=c" {
t.Fatalf("ParseLabels = %#v, %v", labels, err)
}
if _, err := ParseLabels("missing"); err == nil {
t.Fatal("line without separator accepted")
}
if _, err := ParseLabels("=empty"); err == nil {
t.Fatal("empty key accepted")
}
if _, err := ParseLabels("dogama.managed=false"); err == nil {
t.Fatal("reserved key accepted")
}
if _, err := ParseLabels("io.dogama.managed=false"); err == nil {
t.Fatal("technical key accepted")
}
if _, err := ParseLabels("x={{unknown}}"); err == nil {
t.Fatal("unknown variable accepted")
}
resolved, err := ResolveLabels(map[string]string{"x": "{{game.name}}/{{game.id}}/{{game.icon_url}}/{{instance.name}}/{{instance.id}}/{{instance.slug}}/{{server.name}}"}, LabelContext{GameName: "Palworld", GameID: "palworld", GameIconURL: "/public/game-icons/palworld", InstanceName: "Summer", InstanceID: "id", InstanceSlug: "summer", ServerName: "Server"})
if err != nil || resolved["x"] != "Palworld/palworld//public/game-icons/palworld/Summer/id/summer/Server" {
t.Fatalf("ResolveLabels = %#v, %v", resolved, err)
}
merged := MergeLabels(map[string]string{"io.dogama.managed": "true"}, map[string]string{"x": "global"}, map[string]string{"x": "local", "io.dogama.managed": "false"})
if merged["x"] != "local" || merged["io.dogama.managed"] != "true" {
t.Fatalf("MergeLabels = %#v", merged)
}
}
func TestSlugify(t *testing.T) {
cases := map[string]string{"Mon Serveur": "mon-serveur", "Été 2026": "ete-2026", "PvE / PvP": "pve-pvp", "Serveur !!! Test": "serveur-test", "---Été///PvE###1---": "ete-pve-1"}
for input, expected := range cases {
if actual := Slugify(input); actual != expected {
t.Errorf("Slugify(%q)=%q, want %q", input, actual, expected)
}
}
}
func TestDockerUserAndImageTag(t *testing.T) {
uid, gid := uint32(1000), uint32(1001)
if value, err := DockerUserValue(DockerUser{Mode: DockerUserDoGaMa}, 42, 43); err != nil || value != "42:43" {
t.Fatalf("dogama user = %q, %v", value, err)
}
if value, err := DockerUserValue(DockerUser{Mode: DockerUserCustom, UID: &uid, GID: &gid}, 0, 0); err != nil || value != "1000:1001" {
t.Fatalf("custom user = %q, %v", value, err)
}
if value, err := DockerUserValue(DockerUser{Mode: DockerUserImage}, 0, 0); err != nil || value != "" {
t.Fatalf("image user = %q, %v", value, err)
}
if _, err := ValidateImageTag(ImageTag{Mode: ImageTagPinned, Tag: "bad/tag"}, "stable"); err == nil {
t.Fatal("invalid tag accepted")
}
tracked, err := ValidateImageTag(ImageTag{Mode: ImageTagTracked}, "stable")
if err != nil || tracked.Tag != "stable" {
t.Fatalf("tracked tag = %#v, %v", tracked, err)
}
}
+15 -2
View File
@@ -25,6 +25,7 @@ type StoredInstance struct {
ContainerID string
PlanDigest string
DesiredRunning bool
ContainerConfigPending bool
}
type OperationResult struct {
@@ -33,6 +34,7 @@ type OperationResult struct {
State string `json:"state"`
Observed string `json:"observed_state"`
ContainerID string `json:"container_id,omitempty"`
ContainerConfigPending bool `json:"container_config_pending"`
AgentState agentwire.InstanceState `json:"agent_state,omitempty"`
}
@@ -56,6 +58,8 @@ type LifecycleAgent interface {
GetInstanceStats(context.Context, string) (agentwire.InstanceStats, error)
}
type replacementAgent interface { ReplaceInstance(context.Context, agentwire.DeploymentPlan) (agentwire.InstanceState, error) }
type LifecycleService struct {
repository LifecycleRepository
agent LifecycleAgent
@@ -118,7 +122,16 @@ func (s *LifecycleService) Start(ctx context.Context, instanceID string) (Operat
if err != nil {
return OperationResult{}, err
}
state, err := s.agent.StartInstance(ctx, instanceID)
var state agentwire.InstanceState
if current.ContainerConfigPending {
replacement, ok := s.agent.(replacementAgent)
if !ok { return s.fail(ctx, operationID, instanceID, "container_replace_unavailable", errors.New("container replacement is unavailable")) }
plan, planErr := current.Preview.DeploymentPlan(instanceID)
if planErr != nil { return s.fail(ctx, operationID, instanceID, "invalid_plan", planErr) }
state, err = replacement.ReplaceInstance(ctx, plan)
if err == nil { err = s.repository.(ConfigurationRepository).ClearContainerConfigPending(ctx, instanceID, plan.PlanDigest) }
}
if err == nil { state, err = s.agent.StartInstance(ctx, instanceID) }
if err != nil {
return s.fail(ctx, operationID, instanceID, "agent_start_failed", err)
}
@@ -319,7 +332,7 @@ func stateToLifecycle(state agentwire.InstanceState) (string, string) {
}
func resultFrom(current StoredInstance, operationID string) OperationResult {
return OperationResult{OperationID: operationID, InstanceID: current.ID, State: current.LifecycleState, Observed: current.ObservedState, ContainerID: current.ContainerID}
return OperationResult{OperationID: operationID, InstanceID: current.ID, State: current.LifecycleState, Observed: current.ObservedState, ContainerID: current.ContainerID, ContainerConfigPending: current.ContainerConfigPending}
}
func operationToken() (string, error) {
+23
View File
@@ -3,6 +3,7 @@ package instance_test
import (
"context"
"path/filepath"
"strings"
"sync"
"testing"
@@ -46,10 +47,32 @@ func (a *lifecycleAgent) RestartInstance(ctx context.Context, id string, _ int)
return a.StartInstance(ctx, id)
}
func (a *lifecycleAgent) DeleteContainer(context.Context, string) error { return nil }
func (a *lifecycleAgent) ReplaceInstance(_ context.Context, plan agentwire.DeploymentPlan) (agentwire.InstanceState, error) {
return agentwire.InstanceState{InstanceID: plan.InstanceID, ContainerID: "container-2", PlanDigest: plan.PlanDigest, Health: "stopped"}, nil
}
func (a *lifecycleAgent) GetInstanceStats(_ context.Context, id string) (agentwire.InstanceStats, error) {
return agentwire.InstanceStats{InstanceID: id, MemoryBytes: 42}, nil
}
func TestUpdatePreviewRequiresDigestAndWarnsAboutMods(t *testing.T) {
current := instance.StoredInstance{Preview: instance.Preview{
Image: "registry.example/game:old",
TemplateDefaultTag: "old",
Mods: instance.ModsConfiguration{Items: []string{"123"}},
UpdatePolicy: instance.UpdatePolicy{BackupBeforeUpdate: true, RollbackOnFailure: true},
}}
if _, err := instance.PreviewUpdate(current, instance.UpdateRequest{CandidateTag: "new", CandidateDigest: "latest"}); err == nil {
t.Fatal("mutable tag without digest was accepted")
}
value, err := instance.PreviewUpdate(current, instance.UpdateRequest{CandidateTag: "new", CandidateDigest: "sha256:" + strings.Repeat("a", 64)})
if err != nil {
t.Fatal(err)
}
if !value.BackupRequired || !value.ModWarning || !value.RollbackOnFailure || !strings.Contains(value.CandidateReference, "@sha256:") {
t.Fatalf("preview = %#v", value)
}
}
func TestLifecycleInstallStartStopAndSafeContainerDeletion(t *testing.T) {
ctx := context.Background()
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
+102 -3
View File
@@ -27,6 +27,11 @@ type PreviewRequest struct {
Resources catalog.Resources `json:"resources"`
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 {
@@ -44,8 +49,39 @@ type Preview struct {
Settings []SettingPreview `json:"settings"`
DataOrigin string `json:"data_origin"`
Backup BackupPreview `json:"backup"`
Import ImportPreview `json:"import,omitempty"`
CanonicalJSON string `json:"canonical_json"`
PlanDigest string `json:"plan_digest"`
CustomLabels map[string]string `json:"custom_labels"`
GlobalLabels map[string]string `json:"global_labels"`
DockerUser DockerUser `json:"docker_user"`
DockerUserValue string `json:"docker_user_value,omitempty"`
ImageTag ImageTag `json:"image_tag"`
TemplateDefaultTag string `json:"template_default_tag"`
Game GameReference `json:"game"`
Mods ModsConfiguration `json:"mods"`
UpdatePolicy UpdatePolicy `json:"update_policy"`
}
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 {
@@ -84,6 +120,12 @@ type BackupPreview struct {
RetentionCount int `json:"retention_count"`
}
type ImportPreview struct {
ID string `json:"id,omitempty"`
DestinationMount string `json:"destination_mount,omitempty"`
DestinationRelativePath string `json:"destination_relative_path,omitempty"`
}
type Draft struct {
ID string
Preview Preview
@@ -97,15 +139,44 @@ 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")
}
if request.DataOrigin == "import" && !snapshot.Template.Imports.Supported {
return Preview{}, errors.New("template does not support imports")
}
if request.DataOrigin == "import" && request.ImportID == "" {
return Preview{}, errors.New("validated import is required")
}
if request.DataOrigin == "new" && request.ImportID != "" {
return Preview{}, errors.New("import is only valid for imported data")
}
resources := request.Resources
if resources.CPUCores == 0 {
resources = snapshot.Template.Requirements.Recommended
@@ -171,14 +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},
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")
@@ -200,11 +276,34 @@ func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, e
// DeploymentPlan converts a persisted preview into the only container plan
// accepted by the restricted agent. The digest binds every privileged field.
func (p Preview) DeploymentPlan(instanceID string) (agentwire.DeploymentPlan, error) {
if p.DockerUser.Mode == "" {
p.DockerUser.Mode = DockerUserDoGaMa
}
if p.DockerUserValue == "" && p.DockerUser.Mode == DockerUserDoGaMa {
uid, gid, userErr := currentUIDGID()
if userErr != nil {
return agentwire.DeploymentPlan{}, userErr
}
p.DockerUserValue, userErr = DockerUserValue(p.DockerUser, uid, gid)
if userErr != nil {
return agentwire.DeploymentPlan{}, userErr
}
}
context := LabelContext{GameName: p.Game.Name, GameID: p.Game.ID, GameIconURL: p.Game.IconURL, InstanceName: p.DisplayName, InstanceID: instanceID, InstanceSlug: p.Slug, ServerName: p.DisplayName}
global, err := ResolveLabels(p.GlobalLabels, context)
if err != nil {
return agentwire.DeploymentPlan{}, err
}
local, err := ResolveLabels(p.CustomLabels, context)
if err != nil {
return agentwire.DeploymentPlan{}, err
}
plan := agentwire.DeploymentPlan{
SchemaVersion: agentwire.DeploymentPlanVersion,
InstanceID: instanceID,
TemplateID: p.Template.ID, TemplateVersion: p.Template.Version, TemplateDigest: p.Template.Digest,
Image: p.Image, Entrypoint: append([]string(nil), p.Entrypoint...), Arguments: append([]string(nil), p.Arguments...),
Labels: MergeLabels(nil, global, local), User: p.DockerUserValue,
Resources: agentwire.PlanResource{CPUCores: p.Resources.CPUCores, MemoryMB: p.Resources.MemoryMB, StorageGB: p.Resources.StorageGB},
StopTimeoutSeconds: p.StopTimeoutSeconds,
}
@@ -0,0 +1,9 @@
//go:build unix
package instance
import "golang.org/x/sys/unix"
func currentUIDGID() (uint32, uint32, error) {
return uint32(unix.Getuid()), uint32(unix.Getgid()), nil
}
@@ -0,0 +1,8 @@
//go:build windows
package instance
// Windows is a development/test host only; Linux deployment resolves the real process identity.
func currentUIDGID() (uint32, uint32, error) {
return 1000, 1000, nil
}
+135
View File
@@ -0,0 +1,135 @@
package instance
import (
"context"
"errors"
"regexp"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
)
var imageDigestPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
type UpdateRequest struct {
CandidateTag string `json:"candidate_tag"`
CandidateDigest string `json:"candidate_digest"`
Confirmed bool `json:"confirmed"`
}
type UpdatePreview struct {
CurrentReference string `json:"current_reference"`
CandidateReference string `json:"candidate_reference"`
BackupRequired bool `json:"backup_required"`
ModWarning bool `json:"mod_warning"`
RollbackOnFailure bool `json:"rollback_on_failure"`
}
func PreviewUpdate(current StoredInstance, request UpdateRequest) (UpdatePreview, error) {
tag, err := ValidateImageTag(ImageTag{Mode: ImageTagPinned, Tag: request.CandidateTag}, current.Preview.TemplateDefaultTag)
if err != nil {
return UpdatePreview{}, err
}
if !imageDigestPattern.MatchString(request.CandidateDigest) {
return UpdatePreview{}, errors.New("candidate image digest must be sha256")
}
base := imageRepository(current.Preview.Image)
return UpdatePreview{CurrentReference: current.Preview.Image, CandidateReference: base + ":" + tag.Tag + "@" + request.CandidateDigest, BackupRequired: current.Preview.UpdatePolicy.BackupBeforeUpdate, ModWarning: len(current.Preview.Mods.Items) != 0, RollbackOnFailure: current.Preview.UpdatePolicy.RollbackOnFailure}, nil
}
func (s *LifecycleService) Update(ctx context.Context, instanceID string, request UpdateRequest, actorID string) (OperationResult, error) {
return s.exclusive(instanceID, func() (OperationResult, error) {
current, err := s.repository.GetInstance(ctx, instanceID)
if err != nil {
return OperationResult{}, err
}
candidate, err := PreviewUpdate(current, request)
if err != nil {
return OperationResult{}, err
}
if !request.Confirmed {
return OperationResult{}, errors.New("update confirmation is required")
}
if current.ContainerID == "" {
return OperationResult{}, ErrInvalidState
}
replacement, ok := s.agent.(replacementAgent)
if !ok {
return OperationResult{}, errors.New("container replacement is unavailable")
}
previous := current.Preview
next := previous
next.Image = candidate.CandidateReference
next.ImageTag = ImageTag{Mode: ImageTagPinned, Tag: request.CandidateTag}
repository := s.repository.(ConfigurationRepository)
operationID, err := operationToken()
if err != nil {
return OperationResult{}, err
}
if err := repository.SaveInstanceConfiguration(ctx, instanceID, next, false, "update", actorID); err != nil {
return s.fail(ctx, operationID, instanceID, "update_configuration_failed", err)
}
current, err = s.repository.BeginOperation(ctx, operationID, instanceID, "restart", "update")
if err != nil {
return OperationResult{}, err
}
plan, err := next.DeploymentPlan(instanceID)
if err == nil {
_, err = replacement.ReplaceInstance(ctx, plan)
}
var stateErr error
if err == nil && current.DesiredRunning {
_, stateErr = s.agent.StartInstance(ctx, instanceID)
err = stateErr
}
rollback := func() {
if next.UpdatePolicy.RollbackOnFailure {
_ = repository.SaveInstanceConfiguration(ctx, instanceID, previous, false, "update_rollback", actorID)
if oldPlan, planErr := previous.DeploymentPlan(instanceID); planErr == nil {
if _, rollbackErr := replacement.ReplaceInstance(ctx, oldPlan); rollbackErr == nil && current.DesiredRunning {
_, _ = s.agent.StartInstance(ctx, instanceID)
}
}
}
}
if err != nil {
rollback()
return s.fail(ctx, operationID, instanceID, "update_failed", err)
}
state, err := s.waitForUpdateReadiness(ctx, instanceID, current.DesiredRunning, next.UpdatePolicy.HealthTimeoutSeconds)
if err != nil {
rollback()
return s.fail(ctx, operationID, instanceID, "update_health_failed", err)
}
lifecycle, observed := stateToLifecycle(state)
if err := s.repository.FinishOperation(ctx, operationID, lifecycle, observed, state.ContainerID, plan.PlanDigest, current.DesiredRunning, ""); err != nil {
return OperationResult{}, err
}
return OperationResult{OperationID: operationID, InstanceID: instanceID, State: lifecycle, Observed: observed, ContainerID: state.ContainerID, AgentState: state}, nil
})
}
func (s *LifecycleService) waitForUpdateReadiness(ctx context.Context, instanceID string, shouldRun bool, timeoutSeconds int) (agentwire.InstanceState, error) {
if timeoutSeconds < 10 {
timeoutSeconds = 10
}
deadline, cancel := context.WithTimeout(ctx, time.Duration(timeoutSeconds)*time.Second)
defer cancel()
for {
state, err := s.agent.InspectInstance(deadline, instanceID)
if err != nil {
return agentwire.InstanceState{}, err
}
if !shouldRun && !state.Running {
return state, nil
}
if shouldRun && state.Ready {
return state, nil
}
select {
case <-deadline.Done():
return agentwire.InstanceState{}, errors.New("updated instance did not become ready before timeout")
case <-time.After(time.Second):
}
}
}
+345
View File
@@ -0,0 +1,345 @@
// Package module executes game adapters in a capability-limited WebAssembly sandbox.
package module
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"path"
"strings"
"sync"
"time"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/api"
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
)
const (
ABI = "dogama:game-module@1.0.0"
maxRequestBytes = 64 << 10
maxHostCallsPerCall = 32
)
var capabilityExport = map[string]string{
"server_info": "get_server_info", "metrics": "get_metrics", "player_list": "list_players",
"online_save": "save_world", "graceful_shutdown": "shutdown", "announcement": "send_announcement",
"kick": "kick_player", "ban": "ban_player", "unban": "unban_player",
}
type Limits struct {
MemoryMB uint32
Timeout time.Duration
MaxResponseBytes int
MaxConcurrentCall int
}
type Binding struct {
InstanceID string
ContainerPort int
AllowedMethods map[string]bool
Configuration map[string]string
Secrets map[string]string
}
type HTTPRequest struct {
Method string `json:"method"`
Path string `json:"path"`
Header map[string]string `json:"header,omitempty"`
Body []byte `json:"body,omitempty"`
}
type HTTPResponse struct {
Status int `json:"status"`
Header map[string][]string `json:"header,omitempty"`
Body []byte `json:"body,omitempty"`
}
type Runtime struct {
wasm []byte
capabilities []string
limits Limits
binding Binding
client *http.Client
sem chan struct{}
mu sync.Mutex
failures int
openUntil time.Time
}
func New(ctx context.Context, wasm []byte, checksum string, capabilities []string, limits Limits, binding Binding) (*Runtime, error) {
transport, err := pinnedTransport(ctx, binding)
if err != nil {
return nil, err
}
return newWithTransport(wasm, checksum, capabilities, limits, binding, transport)
}
func newWithTransport(wasm []byte, checksum string, capabilities []string, limits Limits, binding Binding, transport http.RoundTripper) (*Runtime, error) {
if len(wasm) == 0 || limits.MemoryMB == 0 || limits.MemoryMB > 256 || limits.Timeout <= 0 || limits.MaxResponseBytes < 1 || limits.MaxResponseBytes > 8<<20 || limits.MaxConcurrentCall < 1 || limits.MaxConcurrentCall > 16 {
return nil, errors.New("invalid module runtime configuration")
}
digest := sha256.Sum256(wasm)
if hex.EncodeToString(digest[:]) != checksum {
return nil, errors.New("module checksum mismatch")
}
if binding.InstanceID == "" || binding.ContainerPort < 1 || binding.ContainerPort > 65535 || transport == nil {
return nil, errors.New("invalid instance API binding")
}
seen := map[string]bool{}
for _, capability := range capabilities {
if _, ok := capabilityExport[capability]; !ok || seen[capability] {
return nil, errors.New("invalid module capability")
}
seen[capability] = true
}
r := &Runtime{wasm: append([]byte(nil), wasm...), capabilities: append([]string(nil), capabilities...), limits: limits, binding: binding, sem: make(chan struct{}, limits.MaxConcurrentCall)}
r.client = &http.Client{Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
return r, nil
}
func pinnedTransport(ctx context.Context, binding Binding) (http.RoundTripper, error) {
hostname := "dogama-" + strings.ToLower(binding.InstanceID)
lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
addresses, err := net.DefaultResolver.LookupIPAddr(lookupCtx, hostname)
if err != nil || len(addresses) == 0 {
return nil, errors.New("resolve bound instance API")
}
ip := addresses[0].IP
if ip == nil || ip.IsUnspecified() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() {
return nil, errors.New("unsafe instance API address")
}
pinned := net.JoinHostPort(ip.String(), fmt.Sprint(binding.ContainerPort))
dialer := &net.Dialer{Timeout: 2 * time.Second, KeepAlive: 30 * time.Second}
return &http.Transport{
DisableCompression: true,
Proxy: nil,
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
if network != "tcp" && network != "tcp4" && network != "tcp6" {
return nil, errors.New("unsupported instance API network")
}
return dialer.DialContext(ctx, "tcp", pinned)
},
}, nil
}
func (r *Runtime) Call(ctx context.Context, operation string, request any, response any) error {
if !r.allowedOperation(operation) {
return errors.New("unsupported")
}
r.mu.Lock()
open := time.Now().Before(r.openUntil)
r.mu.Unlock()
if open {
return errors.New("module circuit breaker is open")
}
select {
case r.sem <- struct{}{}:
defer func() { <-r.sem }()
case <-ctx.Done():
return ctx.Err()
}
callCtx, cancel := context.WithTimeout(ctx, r.limits.Timeout)
defer cancel()
input, err := json.Marshal(request)
if err != nil || len(input) > maxRequestBytes {
return errors.New("invalid module request")
}
result, err := r.invoke(callCtx, operation, input)
if err != nil {
r.recordFailure()
return err
}
r.recordSuccess()
decoder := json.NewDecoder(bytes.NewReader(result))
decoder.DisallowUnknownFields()
if err := decoder.Decode(response); err != nil || decoder.Decode(&struct{}{}) != io.EOF {
return errors.New("invalid module response")
}
return nil
}
func (r *Runtime) allowedOperation(operation string) bool {
if operation == "initialize" || operation == "test_connection" || operation == "get_server_status" {
return true
}
for _, capability := range r.capabilities {
if capabilityExport[capability] == operation {
return true
}
}
return false
}
type callState struct{ hostCalls int }
type stateKey struct{}
func (r *Runtime) invoke(ctx context.Context, operation string, input []byte) ([]byte, error) {
ctx = context.WithValue(ctx, stateKey{}, &callState{})
pages := (r.limits.MemoryMB*1024*1024 + 65535) / 65536
config := wazero.NewRuntimeConfigInterpreter().WithMemoryLimitPages(pages).WithCloseOnContextDone(true).WithDebugInfoEnabled(false)
runtime := wazero.NewRuntimeWithConfig(ctx, config)
defer runtime.Close(ctx)
if _, err := wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil {
return nil, fmt.Errorf("instantiate restricted WASI: %w", err)
}
host := runtime.NewHostModuleBuilder("dogama_host")
host.NewFunctionBuilder().WithFunc(r.httpRequest).Export("http_request")
host.NewFunctionBuilder().WithFunc(r.getSecret).Export("get_secret")
host.NewFunctionBuilder().WithFunc(r.getConfig).Export("get_config")
if _, err := host.Instantiate(ctx); err != nil {
return nil, fmt.Errorf("instantiate module host: %w", err)
}
compiled, err := runtime.CompileModule(ctx, r.wasm)
if err != nil {
return nil, errors.New("compile module")
}
if err := validateABI(compiled, r.capabilities); err != nil {
return nil, err
}
instance, err := runtime.InstantiateModule(ctx, compiled, wazero.NewModuleConfig().WithName("").WithStartFunctions("_initialize").WithStdin(bytes.NewReader(nil)).WithStdout(io.Discard).WithStderr(io.Discard))
if err != nil {
return nil, errors.New("instantiate module")
}
alloc := instance.ExportedFunction("dogama_alloc")
fn := instance.ExportedFunction(operation)
if alloc == nil || fn == nil {
return nil, errors.New("missing module export")
}
allocated, err := alloc.Call(ctx, uint64(len(input)))
if err != nil || len(allocated) != 1 || allocated[0] > 1<<32-1 || !instance.Memory().Write(uint32(allocated[0]), input) {
return nil, errors.New("write module request")
}
out, err := alloc.Call(ctx, uint64(r.limits.MaxResponseBytes))
if err != nil || len(out) != 1 || out[0] > 1<<32-1 {
return nil, errors.New("allocate module response")
}
result, err := fn.Call(ctx, allocated[0], uint64(len(input)), out[0], uint64(r.limits.MaxResponseBytes))
if err != nil || len(result) != 1 || int32(result[0]) < 0 || result[0] > uint64(r.limits.MaxResponseBytes) {
return nil, errors.New("module execution failed")
}
body, ok := instance.Memory().Read(uint32(out[0]), uint32(result[0]))
if !ok {
return nil, errors.New("read module response")
}
return append([]byte(nil), body...), nil
}
func validateABI(compiled wazero.CompiledModule, capabilities []string) error {
exports := compiled.ExportedFunctions()
for _, name := range []string{"dogama_alloc", "initialize", "test_connection", "get_server_status"} {
if exports[name] == nil {
return fmt.Errorf("required module export %s is missing", name)
}
}
for _, capability := range capabilities {
if exports[capabilityExport[capability]] == nil {
return fmt.Errorf("capability export %s is missing", capability)
}
}
for _, imported := range compiled.ImportedFunctions() {
moduleName, name, importedFunction := imported.Import()
if importedFunction && moduleName != "dogama_host" && moduleName != wasi_snapshot_preview1.ModuleName {
return fmt.Errorf("forbidden import %s.%s", moduleName, name)
}
}
return nil
}
func (r *Runtime) httpRequest(ctx context.Context, mod api.Module, requestPtr, requestLen, responsePtr, responseCap uint32) int32 {
state, _ := ctx.Value(stateKey{}).(*callState)
if state == nil || state.hostCalls >= maxHostCallsPerCall || requestLen > maxRequestBytes || responseCap > uint32(r.limits.MaxResponseBytes) {
return -1
}
state.hostCalls++
raw, ok := mod.Memory().Read(requestPtr, requestLen)
if !ok {
return -1
}
var request HTTPRequest
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if decoder.Decode(&request) != nil || !r.binding.AllowedMethods[request.Method] || len(request.Body) > maxRequestBytes || !validRelativeAPIPath(request.Path) {
return -2
}
endpoint := fmt.Sprintf("http://dogama-%s:%d%s", strings.ToLower(r.binding.InstanceID), r.binding.ContainerPort, request.Path)
httpRequest, err := http.NewRequestWithContext(ctx, request.Method, endpoint, bytes.NewReader(request.Body))
if err != nil {
return -2
}
for name, value := range request.Header {
canonical := http.CanonicalHeaderKey(name)
if canonical != "Accept" && canonical != "Content-Type" && canonical != "Authorization" {
return -2
}
httpRequest.Header.Set(canonical, value)
}
response, err := r.client.Do(httpRequest)
if err != nil {
return -3
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, int64(r.limits.MaxResponseBytes)+1))
if err != nil || len(body) > r.limits.MaxResponseBytes {
return -4
}
encoded, err := json.Marshal(HTTPResponse{Status: response.StatusCode, Header: map[string][]string{"Content-Type": response.Header.Values("Content-Type")}, Body: body})
if err != nil || len(encoded) > int(responseCap) || !mod.Memory().Write(responsePtr, encoded) {
return -4
}
return int32(len(encoded))
}
func (r *Runtime) getSecret(ctx context.Context, mod api.Module, keyPtr, keyLen, valuePtr, valueCap uint32) int32 {
return r.readBoundValue(ctx, mod, keyPtr, keyLen, valuePtr, valueCap, r.binding.Secrets)
}
func (r *Runtime) getConfig(ctx context.Context, mod api.Module, keyPtr, keyLen, valuePtr, valueCap uint32) int32 {
return r.readBoundValue(ctx, mod, keyPtr, keyLen, valuePtr, valueCap, r.binding.Configuration)
}
func (r *Runtime) readBoundValue(ctx context.Context, mod api.Module, keyPtr, keyLen, valuePtr, valueCap uint32, values map[string]string) int32 {
state, _ := ctx.Value(stateKey{}).(*callState)
if state == nil || state.hostCalls >= maxHostCallsPerCall || keyLen > 128 {
return -1
}
state.hostCalls++
raw, ok := mod.Memory().Read(keyPtr, keyLen)
if !ok {
return -1
}
value, exists := values[string(raw)]
if !exists || len(value) > int(valueCap) || !mod.Memory().Write(valuePtr, []byte(value)) {
return -2
}
return int32(len(value))
}
func validRelativeAPIPath(value string) bool {
parsed, err := url.Parse(value)
return err == nil && !parsed.IsAbs() && parsed.Host == "" && parsed.RawQuery == "" && parsed.Fragment == "" && strings.HasPrefix(value, "/v1/api/") && path.Clean(value) == value && !strings.Contains(value, "\\")
}
func (r *Runtime) recordFailure() {
r.mu.Lock()
defer r.mu.Unlock()
r.failures++
if r.failures >= 3 {
r.openUntil = time.Now().Add(30 * time.Second)
}
}
func (r *Runtime) recordSuccess() {
r.mu.Lock()
r.failures, r.openUntil = 0, time.Time{}
r.mu.Unlock()
}
+147
View File
@@ -0,0 +1,147 @@
package module
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"net/http"
"os"
"strings"
"testing"
"time"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) }
func TestValidRelativeAPIPath(t *testing.T) {
for _, value := range []string{"http://metadata/v1/api/info", "//other/v1/api/info", "/v1/api/../secret", "/v1/api/info?q=1", "/etc/passwd"} {
if validRelativeAPIPath(value) {
t.Fatalf("unsafe path accepted: %q", value)
}
}
if !validRelativeAPIPath("/v1/api/info") {
t.Fatal("documented Palworld endpoint was rejected")
}
}
func TestPalworldAdapterReportsBoundedFailures(t *testing.T) {
wasm, err := os.ReadFile("../../modules/palworld-rest/module.wasm")
if err != nil {
t.Fatal(err)
}
digest := sha256.Sum256(wasm)
tests := []struct {
name string
transport roundTripFunc
code string
}{
{name: "unauthorized", code: "unauthorized", transport: func(request *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusUnauthorized, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("unauthorized")), Request: request}, nil
}},
{name: "malformed", code: "invalid_response", transport: func(request *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("not-json")), Request: request}, nil
}},
{name: "offline", code: "unreachable", transport: func(*http.Request) (*http.Response, error) {
return nil, errors.New("offline")
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
runtime, err := newWithTransport(wasm, hex.EncodeToString(digest[:]), []string{"server_info"}, Limits{MemoryMB: 32, Timeout: 10 * time.Second, MaxResponseBytes: 1 << 20, MaxConcurrentCall: 2}, Binding{InstanceID: strings.Repeat("a", 20), ContainerPort: 8212, AllowedMethods: map[string]bool{"GET": true}, Configuration: map[string]string{"username": "admin"}, Secrets: map[string]string{"admin_password": "secret"}}, test.transport)
if err != nil {
t.Fatal(err)
}
var response struct {
OK bool `json:"ok"`
Data map[string]any `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
Retryable bool `json:"retryable"`
} `json:"error"`
}
if err := runtime.Call(context.Background(), "get_server_info", struct{}{}, &response); err != nil {
t.Fatal(err)
}
if response.OK || response.Error == nil || response.Error.Code != test.code || strings.Contains(response.Error.Message, "secret") {
t.Fatalf("unexpected safe failure: %+v", response)
}
})
}
}
func TestPalworldAdapterExecutesInSandbox(t *testing.T) {
wasm, err := os.ReadFile("../../modules/palworld-rest/module.wasm")
if err != nil {
t.Fatal(err)
}
digest := sha256.Sum256(wasm)
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
if request.URL.Path != "/v1/api/info" || request.Header.Get("Authorization") == "" {
t.Fatalf("unexpected adapter request: %s", request.URL)
}
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"version":"v1","servername":"test","description":"fixture","worldguid":"world"}`)), Request: request}, nil
})
runtime, err := newWithTransport(wasm, hex.EncodeToString(digest[:]), []string{"server_info"}, Limits{MemoryMB: 32, Timeout: 10 * time.Second, MaxResponseBytes: 1 << 20, MaxConcurrentCall: 2}, Binding{InstanceID: strings.Repeat("a", 20), ContainerPort: 8212, AllowedMethods: map[string]bool{"GET": true, "POST": true}, Configuration: map[string]string{"username": "admin"}, Secrets: map[string]string{"admin_password": "secret"}}, transport)
if err != nil {
t.Fatal(err)
}
var response struct {
OK bool `json:"ok"`
Error any `json:"error"`
Data struct {
Name string `json:"name"`
GameVersion string `json:"game_version"`
Description string `json:"description"`
WorldID string `json:"world_id"`
} `json:"data"`
}
if err := runtime.Call(context.Background(), "get_server_info", struct{}{}, &response); err != nil {
t.Fatal(err)
}
if !response.OK || response.Data.Name != "test" {
t.Fatalf("unexpected normalized response: %+v", response)
}
}
func TestNewRejectsChecksumAndUnknownCapability(t *testing.T) {
limits := Limits{MemoryMB: 32, Timeout: time.Second, MaxResponseBytes: 1024, MaxConcurrentCall: 1}
binding := Binding{InstanceID: strings.Repeat("a", 20), ContainerPort: 8212}
if _, err := newWithTransport([]byte("wasm"), strings.Repeat("0", 64), nil, limits, binding, roundTripFunc(nil)); err == nil {
t.Fatal("checksum mismatch accepted")
}
digest := sha256.Sum256([]byte("wasm"))
if _, err := newWithTransport([]byte("wasm"), hex.EncodeToString(digest[:]), []string{"shell"}, limits, binding, roundTripFunc(nil)); err == nil {
t.Fatal("unknown capability accepted")
}
}
func TestHTTPRequestPinsInstanceOriginAndDisablesRedirects(t *testing.T) {
instanceID := strings.Repeat("A", 20)
seen := false
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
seen = true
if request.URL.String() != "http://dogama-aaaaaaaaaaaaaaaaaaaa:8212/v1/api/info" {
t.Fatalf("unexpected destination %q", request.URL)
}
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"ok":true}`)), Request: request}, nil
})
digest := sha256.Sum256([]byte("wasm"))
runtime, err := newWithTransport([]byte("wasm"), hex.EncodeToString(digest[:]), nil, Limits{MemoryMB: 32, Timeout: time.Second, MaxResponseBytes: 1024, MaxConcurrentCall: 1}, Binding{InstanceID: instanceID, ContainerPort: 8212, AllowedMethods: map[string]bool{"GET": true}}, transport)
if err != nil {
t.Fatal(err)
}
request, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://dogama-aaaaaaaaaaaaaaaaaaaa:8212/v1/api/info", nil)
response, err := runtime.client.Do(request)
if err != nil {
t.Fatal(err)
}
response.Body.Close()
if !seen {
t.Fatal("bound transport was not used")
}
}
+425
View File
@@ -0,0 +1,425 @@
// Package notification manages encrypted channels and bounded asynchronous delivery.
package notification
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"net/smtp"
"net/url"
"strconv"
"strings"
"time"
)
type Channel struct {
ID, Name, Type string
Enabled bool
Events []string
Configured bool
}
type Input struct {
Name, Type string
Enabled bool
Events []string
Config map[string]string
}
type Event struct{ Type, Title, Message, InstanceName, OperationID string }
type Service struct {
db *sql.DB
aead cipher.AEAD
client *http.Client
resolver *net.Resolver
now func() time.Time
}
func New(db *sql.DB, key []byte) (*Service, error) {
if len(key) != 32 {
return nil, errors.New("notification encryption key must be exactly 32 bytes")
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
s := &Service{db: db, aead: aead, resolver: net.DefaultResolver, now: time.Now}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.DialContext = s.dialSafe
s.client = &http.Client{Transport: transport, Timeout: 10 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return errors.New("too many redirects")
}
return s.validateURL(req.Context(), req.URL)
}}
return s, nil
}
func (s *Service) Upsert(ctx context.Context, id string, in Input) (Channel, error) {
if strings.TrimSpace(in.Name) == "" || !validType(in.Type) {
return Channel{}, errors.New("invalid notification channel")
}
if err := validateConfigShape(in.Type, in.Config); err != nil {
return Channel{}, err
}
encrypted, err := s.seal(in.Config)
if err != nil {
return Channel{}, err
}
events, _ := json.Marshal(normalizeEvents(in.Events))
now := s.now().UTC().Format(time.RFC3339Nano)
if id == "" {
id = randomID()
}
_, err = s.db.ExecContext(ctx, `INSERT INTO notification_channels(id,name,type,enabled,encrypted_config,event_filter_json,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name,type=excluded.type,enabled=excluded.enabled,encrypted_config=excluded.encrypted_config,event_filter_json=excluded.event_filter_json,updated_at=excluded.updated_at`, id, strings.TrimSpace(in.Name), in.Type, in.Enabled, encrypted, string(events), now, now)
if err != nil {
return Channel{}, fmt.Errorf("save notification channel: %w", err)
}
return Channel{ID: id, Name: strings.TrimSpace(in.Name), Type: in.Type, Enabled: in.Enabled, Events: normalizeEvents(in.Events), Configured: true}, nil
}
func (s *Service) List(ctx context.Context) ([]Channel, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id,name,type,enabled,event_filter_json,length(encrypted_config)>0 FROM notification_channels ORDER BY name COLLATE NOCASE`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Channel
for rows.Next() {
var c Channel
var body string
if err := rows.Scan(&c.ID, &c.Name, &c.Type, &c.Enabled, &body, &c.Configured); err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(body), &c.Events)
out = append(out, c)
}
return out, rows.Err()
}
func (s *Service) Delete(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM notification_channels WHERE id=?`, id)
return err
}
func (s *Service) Queue(ctx context.Context, event Event) error {
if !validEvent(event.Type) || len(event.Message) > 1000 {
return errors.New("invalid notification event")
}
payload, _ := json.Marshal(event)
rows, err := s.db.QueryContext(ctx, `SELECT id,event_filter_json FROM notification_channels WHERE enabled=1`)
if err != nil {
return err
}
var channelIDs []string
for rows.Next() {
var id, filter string
if err := rows.Scan(&id, &filter); err != nil {
rows.Close()
return err
}
var events []string
_ = json.Unmarshal([]byte(filter), &events)
if matches(events, event.Type) {
channelIDs = append(channelIDs, id)
}
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
now := s.now().UTC().Format(time.RFC3339Nano)
for _, id := range channelIDs {
if _, err := s.db.ExecContext(ctx, `INSERT INTO notification_deliveries(id,channel_id,event_type,payload_redacted,next_attempt_at,created_at) VALUES(?,?,?,?,?,?)`, randomID(), id, event.Type, string(payload), now, now); err != nil {
return err
}
}
return nil
}
func (s *Service) Test(ctx context.Context, id string) error {
return s.queueForChannel(ctx, id, Event{Type: "notification.test", Title: "DoGaMa test notification", Message: "This is a test notification from DoGaMa."})
}
func (s *Service) queueForChannel(ctx context.Context, id string, event Event) error {
var enabled bool
if err := s.db.QueryRowContext(ctx, `SELECT enabled FROM notification_channels WHERE id=?`, id).Scan(&enabled); err != nil {
return err
}
payload, _ := json.Marshal(event)
now := s.now().UTC().Format(time.RFC3339Nano)
_, err := s.db.ExecContext(ctx, `INSERT INTO notification_deliveries(id,channel_id,event_type,payload_redacted,next_attempt_at,created_at) VALUES(?,?,?,?,?,?)`, randomID(), id, event.Type, string(payload), now, now)
return err
}
func (s *Service) RunDue(ctx context.Context) error {
rows, err := s.db.QueryContext(ctx, `SELECT d.id,d.attempt,d.payload_redacted,c.type,c.encrypted_config FROM notification_deliveries d JOIN notification_channels c ON c.id=d.channel_id WHERE d.status IN ('queued','retrying') AND d.next_attempt_at<=? ORDER BY d.next_attempt_at LIMIT 20`, s.now().UTC().Format(time.RFC3339Nano))
if err != nil {
return err
}
type job struct {
id, typ, payload string
attempt int
encrypted []byte
}
var jobs []job
for rows.Next() {
var j job
if err := rows.Scan(&j.id, &j.attempt, &j.payload, &j.typ, &j.encrypted); err != nil {
rows.Close()
return err
}
jobs = append(jobs, j)
}
rows.Close()
for _, j := range jobs {
config, e := s.open(j.encrypted)
if e == nil {
e = s.deliver(ctx, j.id, j.typ, config, []byte(j.payload))
}
attempt := j.attempt + 1
if e == nil {
if _, updateErr := s.db.ExecContext(ctx, `UPDATE notification_deliveries SET status='succeeded',attempt=?,completed_at=?,last_error_code='' WHERE id=?`, attempt, s.now().UTC().Format(time.RFC3339Nano), j.id); updateErr != nil {
return updateErr
}
} else {
status := "retrying"
if attempt >= 5 {
status = "failed"
}
delay := time.Duration(1<<min(attempt, 6)) * time.Minute
_, _ = s.db.ExecContext(ctx, `UPDATE notification_deliveries SET status=?,attempt=?,next_attempt_at=?,last_error_code=? WHERE id=?`, status, attempt, s.now().UTC().Add(delay).Format(time.RFC3339Nano), errorCode(e), j.id)
}
}
return nil
}
func (s *Service) deliver(ctx context.Context, id, typ string, c map[string]string, payload []byte) error {
if typ == "email" {
host := c["host"]
port := c["port"]
if port == "" {
port = "587"
}
addr := net.JoinHostPort(host, port)
var auth smtp.Auth
if c["username"] != "" {
auth = smtp.PlainAuth("", c["username"], c["password"], host)
}
msg := []byte("To: " + c["to"] + "\r\nSubject: DoGaMa notification\r\nContent-Type: application/json\r\n\r\n" + string(payload))
return sendSMTP(ctx, addr, host, auth, c["from"], strings.Split(c["to"], ","), msg)
}
u, err := url.Parse(c["url"])
if err != nil {
return err
}
if err = s.validateURL(ctx, u); err != nil {
return err
}
body := payload
if typ == "discord" {
var event Event
_ = json.Unmarshal(payload, &event)
body, _ = json.Marshal(map[string]string{"content": event.Title + "\n" + event.Message})
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-DoGaMa-Event-ID", id)
timestamp := strconv.FormatInt(s.now().Unix(), 10)
req.Header.Set("X-DoGaMa-Timestamp", timestamp)
if secret := c["signing_secret"]; secret != "" {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(timestamp + "." + string(body)))
req.Header.Set("X-DoGaMa-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
}
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("remote_status_%d", resp.StatusCode)
}
return nil
}
func (s *Service) validateURL(ctx context.Context, u *url.URL) error {
if u.Scheme != "https" || u.User != nil || u.Hostname() == "" {
return errors.New("unsafe_destination")
}
ips, err := s.resolver.LookupNetIP(ctx, "ip", u.Hostname())
if err != nil {
return errors.New("destination_resolution_failed")
}
for _, ip := range ips {
if unsafeIP(ip) {
return errors.New("unsafe_destination")
}
}
return nil
}
func (s *Service) dialSafe(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, errors.New("unsafe_destination")
}
ips, err := s.resolver.LookupNetIP(ctx, "ip", host)
if err != nil || len(ips) == 0 {
return nil, errors.New("destination_resolution_failed")
}
dialer := net.Dialer{Timeout: 10 * time.Second}
for _, ip := range ips {
if unsafeIP(ip) {
return nil, errors.New("unsafe_destination")
}
connection, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if dialErr == nil {
return connection, nil
}
}
return nil, errors.New("delivery_failed")
}
func sendSMTP(ctx context.Context, address, host string, auth smtp.Auth, from string, recipients []string, message []byte) error {
dialer := net.Dialer{Timeout: 10 * time.Second}
connection, err := dialer.DialContext(ctx, "tcp", address)
if err != nil {
return err
}
client, err := smtp.NewClient(connection, host)
if err != nil {
_ = connection.Close()
return err
}
defer client.Close()
if ok, _ := client.Extension("STARTTLS"); !ok {
return errors.New("smtp_tls_required")
}
if err := client.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil {
return err
}
if auth != nil {
if err := client.Auth(auth); err != nil {
return err
}
}
if err := client.Mail(from); err != nil {
return err
}
for _, recipient := range recipients {
if err := client.Rcpt(strings.TrimSpace(recipient)); err != nil {
return err
}
}
w, err := client.Data()
if err != nil {
return err
}
if _, err = w.Write(message); err != nil {
_ = w.Close()
return err
}
if err = w.Close(); err != nil {
return err
}
return client.Quit()
}
func unsafeIP(ip netip.Addr) bool {
return !ip.IsValid() || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified()
}
func (s *Service) seal(config map[string]string) ([]byte, error) {
body, _ := json.Marshal(config)
nonce := make([]byte, s.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
return s.aead.Seal(nonce, nonce, body, nil), nil
}
func (s *Service) open(body []byte) (map[string]string, error) {
n := s.aead.NonceSize()
if len(body) < n {
return nil, errors.New("invalid encrypted channel")
}
plain, err := s.aead.Open(nil, body[:n], body[n:], nil)
if err != nil {
return nil, err
}
var out map[string]string
err = json.Unmarshal(plain, &out)
return out, err
}
func validType(v string) bool { return v == "email" || v == "webhook" || v == "discord" }
func validEvent(v string) bool {
return v == "notification.test" || strings.HasSuffix(v, ".failed") || strings.HasSuffix(v, ".completed") || strings.HasSuffix(v, ".required")
}
func validateConfigShape(typ string, c map[string]string) error {
if typ == "email" {
if c["host"] == "" || c["from"] == "" || c["to"] == "" {
return errors.New("email host, from and to are required")
}
return nil
}
u, err := url.Parse(c["url"])
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil {
return errors.New("an HTTPS webhook URL is required")
}
return nil
}
func normalizeEvents(in []string) []string {
seen := map[string]bool{}
out := []string{}
for _, v := range in {
v = strings.TrimSpace(v)
if validEvent(v) && !seen[v] {
seen[v] = true
out = append(out, v)
}
}
return out
}
func matches(filter []string, event string) bool {
for _, v := range filter {
if v == event || v == "*" {
return true
}
}
return false
}
func errorCode(err error) string {
v := err.Error()
if strings.HasPrefix(v, "remote_status_") {
return v
}
switch v {
case "unsafe_destination", "destination_resolution_failed":
return v
}
return "delivery_failed"
}
func randomID() string {
b := make([]byte, 18)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+84
View File
@@ -0,0 +1,84 @@
package notification_test
import (
"bytes"
"context"
"path/filepath"
"strings"
"testing"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
)
func TestChannelSecretsAreEncryptedAndWriteOnly(t *testing.T) {
ctx := context.Background()
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
service, err := notification.New(db, bytes.Repeat([]byte{7}, 32))
if err != nil {
t.Fatal(err)
}
channel, err := service.Upsert(ctx, "", notification.Input{Name: "ops", Type: "webhook", Enabled: true, Events: []string{"backup.failed"}, Config: map[string]string{"url": "https://example.com/hook", "signing_secret": "highly-sensitive"}})
if err != nil {
t.Fatal(err)
}
var encrypted []byte
if err := db.QueryRowContext(ctx, `SELECT encrypted_config FROM notification_channels WHERE id=?`, channel.ID).Scan(&encrypted); err != nil {
t.Fatal(err)
}
if strings.Contains(string(encrypted), "highly-sensitive") {
t.Fatal("secret stored in plaintext")
}
channels, err := service.List(ctx)
if err != nil || len(channels) != 1 || !channels[0].Configured {
t.Fatalf("channels=%#v err=%v", channels, err)
}
}
func TestDeliveryBlocksPrivateWebhookAndRetriesWithRedactedError(t *testing.T) {
ctx := context.Background()
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
service, _ := notification.New(db, bytes.Repeat([]byte{8}, 32))
channel, err := service.Upsert(ctx, "", notification.Input{Name: "unsafe", Type: "webhook", Enabled: true, Events: []string{"backup.failed"}, Config: map[string]string{"url": "https://127.0.0.1/hook", "signing_secret": "never-leak"}})
if err != nil {
t.Fatal(err)
}
if err := service.Queue(ctx, notification.Event{Type: "backup.failed", Title: "Backup failed", Message: "Operation failed", OperationID: "op-1"}); err != nil {
t.Fatal(err)
}
if err := service.RunDue(ctx); err != nil {
t.Fatal(err)
}
var status, code string
var attempt int
if err := db.QueryRowContext(ctx, `SELECT status,attempt,last_error_code FROM notification_deliveries WHERE channel_id=?`, channel.ID).Scan(&status, &attempt, &code); err != nil {
t.Fatal(err)
}
if status != "retrying" || attempt != 1 || code != "unsafe_destination" || strings.Contains(code, "never-leak") {
t.Fatalf("status=%s attempt=%d code=%q", status, attempt, code)
}
}
func TestRejectsMissingKeyAndInsecureURL(t *testing.T) {
if _, err := notification.New(nil, []byte("short")); err == nil {
t.Fatal("short key accepted")
}
ctx := context.Background()
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
service, _ := notification.New(db, bytes.Repeat([]byte{9}, 32))
if _, err := service.Upsert(ctx, "", notification.Input{Name: "bad", Type: "discord", Enabled: true, Config: map[string]string{"url": "http://example.com"}}); err == nil {
t.Fatal("insecure URL accepted")
}
}
@@ -0,0 +1,228 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/authorization"
modernsqlite "modernc.org/sqlite"
sqlite3 "modernc.org/sqlite/lib"
)
func (r *Repository) ResolveAccess(ctx context.Context, userID, instanceID string) (authorization.Access, error) {
access := authorization.Access{Overrides: make(map[string]string)}
err := r.db.QueryRowContext(ctx, `SELECT COALESCE(m.role, '') FROM instances i
LEFT JOIN instance_memberships m ON m.instance_id=i.id AND m.user_id=?
WHERE i.id=? AND i.deleted_at IS NULL`, userID, instanceID).Scan(&access.MembershipRole)
if errors.Is(err, sql.ErrNoRows) {
return access, nil
}
if err != nil {
return authorization.Access{}, fmt.Errorf("resolve instance access: %w", err)
}
access.InstanceExists = true
if access.MembershipRole == "" {
return access, nil
}
rows, err := r.db.QueryContext(ctx, `SELECT permission, effect FROM permission_overrides WHERE instance_id=? AND user_id=?`, instanceID, userID)
if err != nil {
return authorization.Access{}, fmt.Errorf("load permission overrides: %w", err)
}
defer rows.Close()
for rows.Next() {
var permission, effect string
if err := rows.Scan(&permission, &effect); err != nil {
return authorization.Access{}, fmt.Errorf("scan permission override: %w", err)
}
access.Overrides[permission] = effect
}
return access, rows.Err()
}
func (r *Repository) SetMembership(ctx context.Context, actorID, instanceID, userID, role string) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin membership update: %w", err)
}
defer func() { _ = tx.Rollback() }()
var globalRole string
err = tx.QueryRowContext(ctx, `SELECT global_role FROM users WHERE id=? AND disabled_at IS NULL`, userID).Scan(&globalRole)
if errors.Is(err, sql.ErrNoRows) {
return authorization.ErrNotFound
}
if err != nil {
return fmt.Errorf("load membership user: %w", err)
}
if globalRole != "user" {
return authorization.ErrInvalidInput
}
var exists int
if err := tx.QueryRowContext(ctx, `SELECT 1 FROM instances WHERE id=? AND deleted_at IS NULL`, instanceID).Scan(&exists); errors.Is(err, sql.ErrNoRows) {
return authorization.ErrNotFound
} else if err != nil {
return fmt.Errorf("load membership instance: %w", err)
}
now := r.now().UTC().Format(time.RFC3339Nano)
_, err = tx.ExecContext(ctx, `INSERT INTO instance_memberships(instance_id, user_id, role, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(instance_id, user_id) DO UPDATE SET role=excluded.role, updated_at=excluded.updated_at`, instanceID, userID, role, actorID, now, now)
if err != nil {
return fmt.Errorf("store instance membership: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit membership update: %w", err)
}
return nil
}
func (r *Repository) DeleteMembership(ctx context.Context, instanceID, userID string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM instance_memberships WHERE instance_id=? AND user_id=?`, instanceID, userID)
if err != nil {
return fmt.Errorf("delete membership: %w", err)
}
changed, _ := result.RowsAffected()
if changed != 1 {
return authorization.ErrNotFound
}
return nil
}
func (r *Repository) SetPermissionOverride(ctx context.Context, actorID, instanceID, userID, permission, effect string) error {
now := r.now().UTC().Format(time.RFC3339Nano)
_, err := r.db.ExecContext(ctx, `INSERT INTO permission_overrides(instance_id, user_id, permission, effect, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(instance_id, user_id, permission) DO UPDATE SET effect=excluded.effect, created_by=excluded.created_by, updated_at=excluded.updated_at`, instanceID, userID, permission, effect, actorID, now, now)
if err != nil {
if isConstraintError(err) {
return authorization.ErrNotFound
}
return fmt.Errorf("store permission override: %w", err)
}
return nil
}
func (r *Repository) DeletePermissionOverride(ctx context.Context, instanceID, userID, permission string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM permission_overrides WHERE instance_id=? AND user_id=? AND permission=?`, instanceID, userID, permission)
if err != nil {
return fmt.Errorf("delete permission override: %w", err)
}
changed, _ := result.RowsAffected()
if changed != 1 {
return authorization.ErrNotFound
}
return nil
}
func (r *Repository) ListMemberships(ctx context.Context, instanceID string) ([]authorization.Membership, error) {
rows, err := r.db.QueryContext(ctx, `SELECT m.instance_id, m.user_id, u.username, m.role, COALESCE(o.permission, ''), COALESCE(o.effect, '')
FROM instance_memberships m JOIN users u ON u.id=m.user_id
LEFT JOIN permission_overrides o ON o.instance_id=m.instance_id AND o.user_id=m.user_id
WHERE m.instance_id=? ORDER BY u.username COLLATE NOCASE, o.permission`, instanceID)
if err != nil {
return nil, fmt.Errorf("list instance memberships: %w", err)
}
defer rows.Close()
var result []authorization.Membership
index := make(map[string]int)
for rows.Next() {
var instanceID, userID, username, role, permission, effect string
if err := rows.Scan(&instanceID, &userID, &username, &role, &permission, &effect); err != nil {
return nil, fmt.Errorf("scan membership: %w", err)
}
position, exists := index[userID]
if !exists {
position = len(result)
index[userID] = position
result = append(result, authorization.Membership{InstanceID: instanceID, UserID: userID, Username: username, Role: role, Overrides: make(map[string]string)})
}
if permission != "" {
result[position].Overrides[permission] = effect
}
}
return result, rows.Err()
}
func (r *Repository) CreateInstallationRequest(ctx context.Context, requesterID string, input authorization.RequestInput) (authorization.InstallationRequest, error) {
now := r.now().UTC().Format(time.RFC3339Nano)
var players any
if input.PlayerEstimate > 0 {
players = input.PlayerEstimate
}
mods := 0
if input.ModsRequested {
mods = 1
}
_, err := r.db.ExecContext(ctx, `INSERT INTO installation_requests(id, requested_by, template_id, template_version, suggested_name, player_estimate, desired_schedule, mods_requested, message, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`, input.ID, requesterID, input.TemplateID, input.TemplateVersion, nullable(input.SuggestedName), players, nullable(input.DesiredSchedule), mods, nullable(input.Message), now, now)
if err != nil {
if isConstraintError(err) {
return authorization.InstallationRequest{}, authorization.ErrConflict
}
return authorization.InstallationRequest{}, fmt.Errorf("create installation request: %w", err)
}
requests, err := r.ListInstallationRequests(ctx, requesterID, false)
if err != nil {
return authorization.InstallationRequest{}, err
}
for _, request := range requests {
if request.ID == input.ID {
return request, nil
}
}
return authorization.InstallationRequest{}, authorization.ErrNotFound
}
func (r *Repository) ListInstallationRequests(ctx context.Context, userID string, all bool) ([]authorization.InstallationRequest, error) {
query := `SELECT q.id, q.requested_by, requester.username, q.template_id, q.template_version, COALESCE(q.suggested_name, ''), COALESCE(q.player_estimate, 0), COALESCE(q.desired_schedule, ''), q.mods_requested, COALESCE(q.message, ''), q.status, COALESCE(q.reviewed_by, ''), COALESCE(reviewer.username, ''), COALESCE(q.review_reason, ''), q.created_at, COALESCE(q.reviewed_at, '')
FROM installation_requests q JOIN users requester ON requester.id=q.requested_by LEFT JOIN users reviewer ON reviewer.id=q.reviewed_by`
args := []any{}
if !all {
query += ` WHERE q.requested_by=?`
args = append(args, userID)
}
query += ` ORDER BY q.created_at DESC, q.id`
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list installation requests: %w", err)
}
defer rows.Close()
var result []authorization.InstallationRequest
for rows.Next() {
var request authorization.InstallationRequest
var mods int
if err := rows.Scan(&request.ID, &request.RequestedBy, &request.RequesterName, &request.TemplateID, &request.TemplateVersion, &request.SuggestedName, &request.PlayerEstimate, &request.DesiredSchedule, &mods, &request.Message, &request.Status, &request.ReviewedBy, &request.ReviewerName, &request.ReviewReason, &request.CreatedAt, &request.ReviewedAt); err != nil {
return nil, fmt.Errorf("scan installation request: %w", err)
}
request.ModsRequested = mods != 0
result = append(result, request)
}
return result, rows.Err()
}
func (r *Repository) ReviewInstallationRequest(ctx context.Context, reviewerID, requestID, decision, reason string) (authorization.InstallationRequest, error) {
now := r.now().UTC().Format(time.RFC3339Nano)
result, err := r.db.ExecContext(ctx, `UPDATE installation_requests SET status=?, reviewed_by=?, review_reason=?, reviewed_at=?, updated_at=? WHERE id=? AND status='pending'`, decision, reviewerID, nullable(reason), now, now, requestID)
if err != nil {
return authorization.InstallationRequest{}, fmt.Errorf("review installation request: %w", err)
}
changed, _ := result.RowsAffected()
if changed != 1 {
return authorization.InstallationRequest{}, authorization.ErrConflict
}
requests, err := r.ListInstallationRequests(ctx, reviewerID, true)
if err != nil {
return authorization.InstallationRequest{}, err
}
for _, request := range requests {
if request.ID == requestID {
return request, nil
}
}
return authorization.InstallationRequest{}, authorization.ErrNotFound
}
func isConstraintError(err error) bool {
var sqliteError *modernsqlite.Error
return errors.As(err, &sqliteError) && sqliteError.Code()&0xff == sqlite3.SQLITE_CONSTRAINT
}
+178
View File
@@ -0,0 +1,178 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/backup"
)
func (r *Repository) BeginBackup(ctx context.Context, value backup.Backup, operationID, actorID string) error {
var operation, actor any
if operationID != "" {
operation = operationID
}
if actorID != "" {
actor = actorID
}
_, err := r.db.ExecContext(ctx, `INSERT INTO backups(id, instance_id, operation_id, origin, status, created_by, created_at)
VALUES (?, ?, ?, ?, 'creating', ?, ?)`, value.ID, value.InstanceID, operation, value.Origin, actor, value.CreatedAt)
if err != nil {
return fmt.Errorf("begin backup metadata: %w", err)
}
return nil
}
func (r *Repository) CompleteBackup(ctx context.Context, id, relativePath string, size int64, checksum string, manifest backup.Manifest) error {
encoded, err := json.Marshal(manifest)
if err != nil {
return fmt.Errorf("encode backup manifest: %w", err)
}
now := r.now().UTC().Format(time.RFC3339Nano)
result, err := r.db.ExecContext(ctx, `UPDATE backups SET status='available', relative_path=?, size_bytes=?, sha256=?, manifest_json=?, completed_at=?, error_code=NULL
WHERE id=? AND status='creating'`, relativePath, size, checksum, string(encoded), now, id)
if err != nil {
return fmt.Errorf("complete backup metadata: %w", err)
}
changed, _ := result.RowsAffected()
if changed != 1 {
return backup.ErrInvalidState
}
return nil
}
func (r *Repository) FailBackup(ctx context.Context, id, code string) error {
result, err := r.db.ExecContext(ctx, `UPDATE backups SET status='failed', error_code=?, completed_at=? WHERE id=? AND status='creating'`, code, r.now().UTC().Format(time.RFC3339Nano), id)
if err != nil {
return fmt.Errorf("fail backup metadata: %w", err)
}
changed, _ := result.RowsAffected()
if changed != 1 {
return backup.ErrInvalidState
}
return nil
}
func (r *Repository) ListBackups(ctx context.Context, instanceID string) ([]backup.Backup, error) {
rows, err := r.db.QueryContext(ctx, `SELECT id, instance_id, origin, status, COALESCE(relative_path, ''), COALESCE(size_bytes, 0), COALESCE(sha256, ''), created_at, COALESCE(completed_at, ''), COALESCE(error_code, '')
FROM backups WHERE instance_id=? AND status!='deleted' ORDER BY created_at DESC, id`, instanceID)
if err != nil {
return nil, fmt.Errorf("list backups: %w", err)
}
defer rows.Close()
var result []backup.Backup
for rows.Next() {
var value backup.Backup
if err := rows.Scan(&value.ID, &value.InstanceID, &value.Origin, &value.Status, &value.RelativePath, &value.SizeBytes, &value.SHA256, &value.CreatedAt, &value.CompletedAt, &value.ErrorCode); err != nil {
return nil, fmt.Errorf("scan backup: %w", err)
}
result = append(result, value)
}
return result, rows.Err()
}
func (r *Repository) GetBackup(ctx context.Context, instanceID, id string) (backup.Backup, backup.Manifest, error) {
var value backup.Backup
var manifestJSON string
err := r.db.QueryRowContext(ctx, `SELECT id, instance_id, origin, status, COALESCE(relative_path, ''), COALESCE(size_bytes, 0), COALESCE(sha256, ''), created_at, COALESCE(completed_at, ''), COALESCE(error_code, ''), COALESCE(manifest_json, '')
FROM backups WHERE id=? AND instance_id=? AND status!='deleted'`, id, instanceID).Scan(&value.ID, &value.InstanceID, &value.Origin, &value.Status, &value.RelativePath, &value.SizeBytes, &value.SHA256, &value.CreatedAt, &value.CompletedAt, &value.ErrorCode, &manifestJSON)
if errors.Is(err, sql.ErrNoRows) {
return backup.Backup{}, backup.Manifest{}, backup.ErrNotFound
}
if err != nil {
return backup.Backup{}, backup.Manifest{}, fmt.Errorf("get backup: %w", err)
}
var manifest backup.Manifest
if manifestJSON != "" {
if err := json.Unmarshal([]byte(manifestJSON), &manifest); err != nil {
return backup.Backup{}, backup.Manifest{}, backup.ErrIntegrity
}
}
return value, manifest, nil
}
func (r *Repository) RetentionCandidates(ctx context.Context, instanceID string, keep int) ([]backup.Backup, error) {
rows, err := r.db.QueryContext(ctx, `SELECT id, instance_id, origin, status, relative_path, size_bytes, sha256, created_at, completed_at, ''
FROM backups WHERE instance_id=? AND origin='scheduled' AND status='available'
ORDER BY created_at DESC, id DESC LIMIT -1 OFFSET ?`, instanceID, keep)
if err != nil {
return nil, fmt.Errorf("list retention candidates: %w", err)
}
defer rows.Close()
var result []backup.Backup
for rows.Next() {
var value backup.Backup
if err := rows.Scan(&value.ID, &value.InstanceID, &value.Origin, &value.Status, &value.RelativePath, &value.SizeBytes, &value.SHA256, &value.CreatedAt, &value.CompletedAt, &value.ErrorCode); err != nil {
return nil, err
}
result = append(result, value)
}
return result, rows.Err()
}
func (r *Repository) MarkBackupDeleted(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, `UPDATE backups SET status='deleted', relative_path=NULL, deleted_at=? WHERE id=? AND status='available'`, r.now().UTC().Format(time.RFC3339Nano), id)
if err != nil {
return fmt.Errorf("mark backup deleted: %w", err)
}
changed, _ := result.RowsAffected()
if changed != 1 {
return backup.ErrInvalidState
}
return nil
}
func (r *Repository) GetBackupPolicy(ctx context.Context, instanceID string) (backup.Policy, error) {
var value backup.Policy
var enabled int
err := r.db.QueryRowContext(ctx, `SELECT instance_id, enabled, COALESCE(cron_expression, ''), timezone, retention_count, COALESCE(next_run_at, '') FROM backup_policies WHERE instance_id=?`, instanceID).Scan(&value.InstanceID, &enabled, &value.CronExpression, &value.Timezone, &value.RetentionCount, &value.NextRunAt)
if errors.Is(err, sql.ErrNoRows) {
instanceValue, instanceErr := r.GetInstance(ctx, instanceID)
if instanceErr != nil {
return backup.Policy{}, instanceErr
}
return backup.Policy{InstanceID: instanceID, Timezone: "UTC", RetentionCount: instanceValue.Preview.Backup.RetentionCount}, nil
}
if err != nil {
return backup.Policy{}, fmt.Errorf("get backup policy: %w", err)
}
value.Enabled = enabled != 0
return value, nil
}
func (r *Repository) SetBackupPolicy(ctx context.Context, value backup.Policy) error {
enabled := 0
if value.Enabled {
enabled = 1
}
now := r.now().UTC().Format(time.RFC3339Nano)
_, err := r.db.ExecContext(ctx, `INSERT INTO backup_policies(instance_id, enabled, cron_expression, timezone, retention_count, next_run_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(instance_id) DO UPDATE SET enabled=excluded.enabled, cron_expression=excluded.cron_expression, timezone=excluded.timezone, retention_count=excluded.retention_count, next_run_at=excluded.next_run_at, updated_at=excluded.updated_at`, value.InstanceID, enabled, nullable(value.CronExpression), value.Timezone, value.RetentionCount, nullable(value.NextRunAt), now, now)
if err != nil {
return fmt.Errorf("set backup policy: %w", err)
}
return nil
}
func (r *Repository) ListDueBackupPolicies(ctx context.Context, now string) ([]backup.Policy, error) {
rows, err := r.db.QueryContext(ctx, `SELECT instance_id, enabled, cron_expression, timezone, retention_count, next_run_at FROM backup_policies WHERE enabled=1 AND next_run_at<=? ORDER BY next_run_at`, now)
if err != nil {
return nil, fmt.Errorf("list due backup policies: %w", err)
}
defer rows.Close()
var result []backup.Policy
for rows.Next() {
var value backup.Policy
var enabled int
if err := rows.Scan(&value.InstanceID, &enabled, &value.CronExpression, &value.Timezone, &value.RetentionCount, &value.NextRunAt); err != nil {
return nil, err
}
value.Enabled = enabled != 0
result = append(result, value)
}
return result, rows.Err()
}
+17 -7
View File
@@ -103,20 +103,28 @@ func (r *Repository) CreateDraft(ctx context.Context, draft instance.Draft) erro
if err != nil {
return fmt.Errorf("encode draft preview: %w", err)
}
_, err = r.db.ExecContext(ctx, `INSERT INTO instances(id, slug, display_name, template_id, template_version, template_digest, revision, lifecycle_state, preview_json, plan_digest, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 1, 'draft', ?, ?, ?, ?)`, draft.ID, draft.Preview.Slug, draft.Preview.DisplayName, draft.Preview.Template.ID, draft.Preview.Template.Version, draft.Preview.Template.Digest, string(previewJSON), draft.Preview.PlanDigest, now, now)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
_, err = tx.ExecContext(ctx, `INSERT INTO instances(id, slug, display_name, template_id, template_version, template_digest, revision, lifecycle_state, preview_json, plan_digest, custom_labels_json, docker_user_mode, docker_uid, docker_gid, image_tag_mode, image_tag, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 1, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, draft.ID, draft.Preview.Slug, draft.Preview.DisplayName, draft.Preview.Template.ID, draft.Preview.Template.Version, draft.Preview.Template.Digest, string(previewJSON), draft.Preview.PlanDigest, string(mustJSON(draft.Preview.CustomLabels)), draft.Preview.DockerUser.Mode, draft.Preview.DockerUser.UID, draft.Preview.DockerUser.GID, draft.Preview.ImageTag.Mode, draft.Preview.ImageTag.Tag, now, now)
if err != nil {
return fmt.Errorf("create draft instance: %w", err)
}
return nil
if _, err := tx.ExecContext(ctx, `INSERT INTO configuration_revisions(instance_id, revision, redacted_snapshot, reason, created_at) VALUES(?,1,?,'creation',?)`, draft.ID, string(previewJSON), now); err != nil {
return fmt.Errorf("create initial configuration revision: %w", err)
}
return tx.Commit()
}
func (r *Repository) GetInstance(ctx context.Context, id string) (instance.StoredInstance, error) {
return scanInstance(r.db.QueryRowContext(ctx, `SELECT id, preview_json, lifecycle_state, observed_state, COALESCE(container_id, ''), plan_digest, desired_running FROM instances WHERE id=? AND deleted_at IS NULL`, id))
return scanInstance(r.db.QueryRowContext(ctx, `SELECT id, preview_json, lifecycle_state, observed_state, COALESCE(container_id, ''), plan_digest, desired_running, container_config_pending FROM instances WHERE id=? AND deleted_at IS NULL`, id))
}
func (r *Repository) ListLifecycleInstances(ctx context.Context) ([]instance.StoredInstance, error) {
rows, err := r.db.QueryContext(ctx, `SELECT id, preview_json, lifecycle_state, observed_state, COALESCE(container_id, ''), plan_digest, desired_running FROM instances WHERE deleted_at IS NULL AND lifecycle_state != 'draft' ORDER BY id`)
rows, err := r.db.QueryContext(ctx, `SELECT id, preview_json, lifecycle_state, observed_state, COALESCE(container_id, ''), plan_digest, desired_running, container_config_pending FROM instances WHERE deleted_at IS NULL AND lifecycle_state != 'draft' ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("list lifecycle instances: %w", err)
}
@@ -138,7 +146,8 @@ func scanInstance(row rowScanner) (instance.StoredInstance, error) {
var value instance.StoredInstance
var previewJSON string
var desired int
err := row.Scan(&value.ID, &previewJSON, &value.LifecycleState, &value.ObservedState, &value.ContainerID, &value.PlanDigest, &desired)
var pending int
err := row.Scan(&value.ID, &previewJSON, &value.LifecycleState, &value.ObservedState, &value.ContainerID, &value.PlanDigest, &desired, &pending)
if errors.Is(err, sql.ErrNoRows) {
return instance.StoredInstance{}, instance.ErrInstanceNotFound
}
@@ -149,6 +158,7 @@ func scanInstance(row rowScanner) (instance.StoredInstance, error) {
return instance.StoredInstance{}, fmt.Errorf("decode instance preview: %w", err)
}
value.DesiredRunning = desired != 0
value.ContainerConfigPending = pending != 0
return value, nil
}
@@ -158,7 +168,7 @@ func (r *Repository) BeginOperation(ctx context.Context, operationID, instanceID
return instance.StoredInstance{}, fmt.Errorf("begin instance operation: %w", err)
}
defer func() { _ = tx.Rollback() }()
current, err := scanInstance(tx.QueryRowContext(ctx, `SELECT id, preview_json, lifecycle_state, observed_state, COALESCE(container_id, ''), plan_digest, desired_running FROM instances WHERE id=? AND deleted_at IS NULL`, instanceID))
current, err := scanInstance(tx.QueryRowContext(ctx, `SELECT id, preview_json, lifecycle_state, observed_state, COALESCE(container_id, ''), plan_digest, desired_running, container_config_pending FROM instances WHERE id=? AND deleted_at IS NULL`, instanceID))
if err != nil {
return instance.StoredInstance{}, err
}
@@ -0,0 +1,174 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
)
const globalLabelsKey = "game_container_labels"
func (r *Repository) GetGlobalLabels(ctx context.Context) (map[string]string, error) {
var body string
err := r.db.QueryRowContext(ctx, `SELECT value_json FROM system_settings WHERE key=?`, globalLabelsKey).Scan(&body)
if errors.Is(err, sql.ErrNoRows) {
return map[string]string{}, nil
}
if err != nil {
return nil, fmt.Errorf("load global game-container labels: %w", err)
}
var labels map[string]string
if err := json.Unmarshal([]byte(body), &labels); err != nil {
return nil, fmt.Errorf("decode global game-container labels: %w", err)
}
return labels, nil
}
func (r *Repository) SetGlobalLabels(ctx context.Context, labels map[string]string, pending bool) (int, int, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return 0, 0, err
}
defer func() { _ = tx.Rollback() }()
now := r.now().UTC().Format(time.RFC3339Nano)
body := string(mustJSON(labels))
if _, err := tx.ExecContext(ctx, `INSERT INTO system_settings(key,value_json,revision,updated_at) VALUES(?,?,1,?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json, revision=system_settings.revision+1, updated_at=excluded.updated_at`, globalLabelsKey, body, now); err != nil {
return 0, 0, err
}
rows, err := tx.QueryContext(ctx, `SELECT id, preview_json, desired_running FROM instances WHERE deleted_at IS NULL AND lifecycle_state!='draft'`)
if err != nil {
return 0, 0, err
}
type update struct {
id, body string
running bool
}
var updates []update
for rows.Next() {
var id, previewBody string
var running int
if err := rows.Scan(&id, &previewBody, &running); err != nil {
rows.Close()
return 0, 0, err
}
var preview instance.Preview
if json.Unmarshal([]byte(previewBody), &preview) != nil {
rows.Close()
return 0, 0, errors.New("decode instance configuration")
}
preview.GlobalLabels = labels
updates = append(updates, update{id: id, body: string(mustJSON(preview)), running: running != 0})
}
rows.Close()
running := 0
pendingValue := 0
if pending {
pendingValue = 1
}
for _, update := range updates {
if update.running {
running++
}
if _, err := tx.ExecContext(ctx, `UPDATE instances SET preview_json=?, container_config_pending=?, revision=revision+1, updated_at=? WHERE id=?`, update.body, pendingValue, now, update.id); err != nil {
return 0, 0, err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO configuration_revisions(instance_id, revision, redacted_snapshot, reason, created_at) SELECT id, revision, preview_json, 'global_labels', ? FROM instances WHERE id=?`, now, update.id); err != nil {
return 0, 0, err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM configuration_revisions WHERE instance_id=? AND revision NOT IN (SELECT revision FROM configuration_revisions WHERE instance_id=? ORDER BY revision DESC LIMIT 10)`, update.id, update.id); err != nil {
return 0, 0, err
}
}
if err := tx.Commit(); err != nil {
return 0, 0, err
}
return len(updates), running, nil
}
func (r *Repository) SaveInstanceConfiguration(ctx context.Context, id string, preview instance.Preview, pending bool, reason, actorID string) error {
body := string(mustJSON(preview))
labels := string(mustJSON(preview.CustomLabels))
pendingValue := 0
if pending {
pendingValue = 1
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
now := r.now().UTC().Format(time.RFC3339Nano)
result, err := tx.ExecContext(ctx, `UPDATE instances SET preview_json=?, custom_labels_json=?, image_tag_mode=?, image_tag=?, container_config_pending=?, revision=revision+1, updated_at=? WHERE id=? AND deleted_at IS NULL`, body, labels, preview.ImageTag.Mode, preview.ImageTag.Tag, pendingValue, now, id)
if err != nil {
return fmt.Errorf("save instance container configuration: %w", err)
}
changed, _ := result.RowsAffected()
if changed != 1 {
return instance.ErrInstanceNotFound
}
var revision int
if err := tx.QueryRowContext(ctx, `SELECT revision FROM instances WHERE id=?`, id).Scan(&revision); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO configuration_revisions(instance_id, revision, redacted_snapshot, reason, created_by, created_at) VALUES(?,?,?,?,?,?)`, id, revision, body, reason, nullable(actorID), now); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM configuration_revisions WHERE instance_id=? AND revision NOT IN (SELECT revision FROM configuration_revisions WHERE instance_id=? ORDER BY revision DESC LIMIT 10)`, id, id); err != nil {
return err
}
return tx.Commit()
}
func (r *Repository) ListConfigurationRevisions(ctx context.Context, id string) ([]instance.ConfigurationRevision, error) {
rows, err := r.db.QueryContext(ctx, `SELECT revision, redacted_snapshot, reason, COALESCE(created_by,''), created_at FROM configuration_revisions WHERE instance_id=? ORDER BY revision DESC`, id)
if err != nil {
return nil, err
}
defer rows.Close()
var result []instance.ConfigurationRevision
for rows.Next() {
var value instance.ConfigurationRevision
var body, created string
value.InstanceID = id
if err := rows.Scan(&value.Revision, &body, &value.Reason, &value.CreatedBy, &created); err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(body), &value.Snapshot); err != nil {
return nil, err
}
value.CreatedAt, _ = time.Parse(time.RFC3339Nano, created)
result = append(result, value)
}
return result, rows.Err()
}
func (r *Repository) GetConfigurationRevision(ctx context.Context, id string, revision int) (instance.ConfigurationRevision, error) {
values, err := r.ListConfigurationRevisions(ctx, id)
if err != nil {
return instance.ConfigurationRevision{}, err
}
for _, value := range values {
if value.Revision == revision {
return value, nil
}
}
return instance.ConfigurationRevision{}, instance.ErrInstanceNotFound
}
func (r *Repository) ClearContainerConfigPending(ctx context.Context, id, planDigest string) error {
_, err := r.db.ExecContext(ctx, `UPDATE instances SET container_config_pending=0, plan_digest=?, updated_at=? WHERE id=?`, planDigest, r.now().UTC().Format(time.RFC3339Nano), id)
return err
}
func mustJSON(value any) []byte {
body, err := json.Marshal(value)
if err != nil {
panic(err)
}
return body
}
+94
View File
@@ -0,0 +1,94 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/importexport"
)
func (r *Repository) BeginImport(ctx context.Context, value importexport.Import, actorID string) error {
_, err := r.db.ExecContext(ctx, `INSERT INTO imports(id, requested_by, template_id, template_version, status, format, relative_stage_path, created_at, expires_at) VALUES (?, ?, ?, ?, 'staging', ?, ?, ?, ?)`, value.ID, actorID, value.TemplateID, value.TemplateVersion, value.Format, value.RelativeStagePath, r.now().UTC().Format(time.RFC3339Nano), value.ExpiresAt)
if err != nil {
return fmt.Errorf("begin import: %w", err)
}
return nil
}
func (r *Repository) CompleteImport(ctx context.Context, value importexport.Import) error {
result, err := r.db.ExecContext(ctx, `UPDATE imports SET status='validated', data_root=?, detected_type=?, confidence=?, file_count=?, expanded_size_bytes=?, completed_at=? WHERE id=? AND status='staging'`, value.DataRoot, value.DetectedType, value.Confidence, value.FileCount, value.ExpandedSizeBytes, r.now().UTC().Format(time.RFC3339Nano), value.ID)
if err != nil {
return fmt.Errorf("complete import: %w", err)
}
changed, _ := result.RowsAffected()
if changed != 1 {
return importexport.ErrInvalidInput
}
return nil
}
func (r *Repository) GetImport(ctx context.Context, id string) (importexport.Import, error) {
var value importexport.Import
err := r.db.QueryRowContext(ctx, `SELECT id, status, format, template_id, template_version, COALESCE(instance_id, ''), relative_stage_path, COALESCE(data_root, ''), COALESCE(detected_type, ''), COALESCE(confidence, ''), file_count, expanded_size_bytes, expires_at FROM imports WHERE id=?`, id).Scan(&value.ID, &value.Status, &value.Format, &value.TemplateID, &value.TemplateVersion, &value.InstanceID, &value.RelativeStagePath, &value.DataRoot, &value.DetectedType, &value.Confidence, &value.FileCount, &value.ExpandedSizeBytes, &value.ExpiresAt)
if errors.Is(err, sql.ErrNoRows) {
return importexport.Import{}, importexport.ErrInvalidInput
}
if err != nil {
return importexport.Import{}, fmt.Errorf("get import: %w", err)
}
return value, nil
}
func (r *Repository) AttachImport(ctx context.Context, id, instanceID string) error {
result, err := r.db.ExecContext(ctx, `UPDATE imports SET status='attached', instance_id=?, completed_at=? WHERE id=? AND status='validated'`, instanceID, r.now().UTC().Format(time.RFC3339Nano), id)
if err != nil {
return fmt.Errorf("attach import: %w", err)
}
changed, _ := result.RowsAffected()
if changed != 1 {
return importexport.ErrInvalidInput
}
return nil
}
func (r *Repository) FailImport(ctx context.Context, id, code string) error {
_, err := r.db.ExecContext(ctx, `UPDATE imports SET status='failed', error_code=?, completed_at=? WHERE id=? AND status='staging'`, code, r.now().UTC().Format(time.RFC3339Nano), id)
if err != nil {
return fmt.Errorf("fail import: %w", err)
}
return nil
}
func (r *Repository) ExpireImports(ctx context.Context, now string) ([]string, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin import expiry: %w", err)
}
defer func() { _ = tx.Rollback() }()
rows, err := tx.QueryContext(ctx, `SELECT relative_stage_path FROM imports WHERE status IN ('staging', 'validated') AND expires_at<=?`, now)
if err != nil {
return nil, fmt.Errorf("list expired imports: %w", err)
}
var paths []string
for rows.Next() {
var value string
if err := rows.Scan(&value); err != nil {
rows.Close()
return nil, err
}
paths = append(paths, value)
}
if err := rows.Close(); err != nil {
return nil, err
}
if _, err := tx.ExecContext(ctx, `UPDATE imports SET status='expired', completed_at=? WHERE status IN ('staging', 'validated') AND expires_at<=?`, now, now); err != nil {
return nil, fmt.Errorf("expire imports: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit import expiry: %w", err)
}
return paths, nil
}
+13 -4
View File
@@ -24,8 +24,17 @@ func TestOpenAppliesMigrationsAndConfiguration(t *testing.T) {
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 3 {
t.Fatalf("got %d migrations, want 3", 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", "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)
}
if found != 1 {
t.Fatalf("required table %q is missing", table)
}
}
var foreignKeys, busyTimeout int
var journalMode string
@@ -53,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 != 3 {
t.Fatalf("reopened database has %d migrations, want 3", count)
if count != 9 {
t.Fatalf("reopened database has %d migrations, want 9", count)
}
}
+257
View File
@@ -0,0 +1,257 @@
package web
import (
"net/http"
"strconv"
"strings"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification"
)
func (s *server) requireRecentAdmin(w http.ResponseWriter, r *http.Request, api bool) (auth.User, bool) {
var user auth.User
var ok bool
if api {
user, ok = s.requireAPIUser(w, r, true)
} else {
var err error
user, err = s.currentUser(r)
ok = err == nil && user.Role == "admin"
if ok {
session, e := r.Cookie(sessionCookie)
ok = e == nil && s.parseForm(w, r) && s.auth.ValidateCSRF(r.Context(), session.Value, r.FormValue("csrf_token"))
}
}
if !ok {
return auth.User{}, false
}
if time.Since(user.AuthenticatedAt) > 10*time.Minute {
if api {
s.apiProblem(w, http.StatusForbidden, "reauthentication_required", "Recent authentication is required.")
} else {
s.problem(w, http.StatusForbidden, "Recent authentication is required.")
}
return auth.User{}, false
}
return user, true
}
func (s *server) recordAudit(r *http.Request, actor auth.User, action, outcome string, summary map[string]string) {
if s.audit == nil {
return
}
if err := s.audit.Record(r.Context(), audit.Event{ActorID: actor.ID, ActorLabel: actor.Username, Action: action, Outcome: outcome, Summary: summary}); err != nil {
s.logger.Warn("audit recording failed", "event", "audit.record.failed")
}
}
func (s *server) auditList(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAPIUser(w, r, true); !ok {
return
}
f := audit.Filter{ActorID: r.URL.Query().Get("actor_id"), InstanceID: r.URL.Query().Get("instance_id"), Action: r.URL.Query().Get("action"), Outcome: r.URL.Query().Get("outcome")}
events, err := s.audit.List(r.Context(), f)
if err != nil {
s.apiProblem(w, 500, "audit_unavailable", "Audit events are unavailable.")
return
}
s.apiJSON(w, 200, map[string]any{"events": events})
}
func (s *server) auditPolicyGet(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAPIUser(w, r, true); !ok {
return
}
p, err := s.audit.Policy(r.Context())
if err != nil {
s.apiProblem(w, 500, "audit_unavailable", "Audit policy is unavailable.")
return
}
s.apiJSON(w, 200, p)
}
func (s *server) auditPolicyPut(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, true)
if !ok {
return
}
var p audit.Policy
if !s.decodeAPIJSON(w, r, &p) {
return
}
if err := s.audit.SetPolicy(r.Context(), p); err != nil {
s.recordAudit(r, actor, "audit.policy.update", "failed", map[string]string{"reason_code": "invalid_policy"})
s.apiProblem(w, 422, "invalid_policy", "The audit policy is invalid.")
return
}
s.recordAudit(r, actor, "audit.policy.update", "allowed", nil)
s.apiJSON(w, 200, p)
}
func (s *server) auditPurge(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, true)
if !ok {
return
}
var in struct {
Before string `json:"before"`
Confirm bool `json:"confirm"`
}
if !s.decodeAPIJSON(w, r, &in) || !in.Confirm {
s.apiProblem(w, 422, "confirmation_required", "Purge confirmation is required.")
return
}
before, err := time.Parse(time.RFC3339, in.Before)
if err != nil {
s.apiProblem(w, 422, "invalid_boundary", "The purge boundary is invalid.")
return
}
n, err := s.audit.Purge(r.Context(), before)
if err != nil {
s.apiProblem(w, 422, "purge_failed", "The audit purge failed.")
return
}
s.recordAudit(r, actor, "audit.purge", "allowed", map[string]string{"deleted_count": strconv.FormatInt(n, 10)})
s.apiJSON(w, 200, map[string]int64{"deleted_count": n})
}
func (s *server) notificationList(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAPIUser(w, r, true); !ok {
return
}
values, err := s.notifications.List(r.Context())
if err != nil {
s.apiProblem(w, 500, "channels_unavailable", "Notification channels are unavailable.")
return
}
s.apiJSON(w, 200, map[string]any{"channels": values})
}
func (s *server) notificationCreate(w http.ResponseWriter, r *http.Request) {
s.notificationUpsert(w, r, "")
}
func (s *server) notificationUpdate(w http.ResponseWriter, r *http.Request) {
s.notificationUpsert(w, r, r.PathValue("id"))
}
func (s *server) notificationUpsert(w http.ResponseWriter, r *http.Request, id string) {
actor, ok := s.requireRecentAdmin(w, r, true)
if !ok {
return
}
var in notification.Input
if !s.decodeAPIJSON(w, r, &in) {
return
}
value, err := s.notifications.Upsert(r.Context(), id, in)
if err != nil {
s.recordAudit(r, actor, "notification.channel.update", "failed", map[string]string{"channel_type": in.Type, "reason_code": "invalid_channel"})
s.apiProblem(w, 422, "invalid_channel", "The notification channel is invalid.")
return
}
s.recordAudit(r, actor, "notification.channel.update", "allowed", map[string]string{"target_id": value.ID, "channel_type": value.Type})
status := http.StatusOK
if id == "" {
status = http.StatusCreated
}
s.apiJSON(w, status, value)
}
func (s *server) notificationDelete(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, true)
if !ok {
return
}
if err := s.notifications.Delete(r.Context(), r.PathValue("id")); err != nil {
s.apiProblem(w, 500, "delete_failed", "The channel could not be deleted.")
return
}
s.recordAudit(r, actor, "notification.channel.delete", "allowed", map[string]string{"target_id": r.PathValue("id")})
w.WriteHeader(http.StatusNoContent)
}
func (s *server) notificationTest(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, true)
if !ok {
return
}
if !s.requireEmptyBody(w, r) {
return
}
if err := s.notifications.Test(r.Context(), r.PathValue("id")); err != nil {
s.apiProblem(w, 404, "channel_not_found", "The channel was not found.")
return
}
s.recordAudit(r, actor, "notification.channel.test", "allowed", map[string]string{"target_id": r.PathValue("id"), "event_type": "notification.test"})
s.apiJSON(w, 202, map[string]string{"status": "queued"})
}
func (s *server) notificationForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
if !ok {
return
}
config := map[string]string{"url": r.FormValue("url"), "signing_secret": r.FormValue("signing_secret"), "host": r.FormValue("host"), "port": r.FormValue("port"), "username": r.FormValue("smtp_username"), "password": r.FormValue("smtp_password"), "from": r.FormValue("from"), "to": r.FormValue("to")}
value, err := s.notifications.Upsert(r.Context(), "", notification.Input{Name: r.FormValue("name"), Type: r.FormValue("type"), Enabled: true, Events: strings.Fields(r.FormValue("events")), Config: config})
if err != nil {
s.problem(w, 422, "Invalid notification channel.")
return
}
s.recordAudit(r, actor, "notification.channel.update", "allowed", map[string]string{"target_id": value.ID, "channel_type": value.Type})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *server) notificationTestForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
if !ok {
return
}
if err := s.notifications.Test(r.Context(), r.PathValue("id")); err != nil {
s.problem(w, 404, "Notification channel not found.")
return
}
s.recordAudit(r, actor, "notification.channel.test", "allowed", map[string]string{"target_id": r.PathValue("id"), "event_type": "notification.test"})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *server) notificationDeleteForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
if !ok {
return
}
if err := s.notifications.Delete(r.Context(), r.PathValue("id")); err != nil {
s.problem(w, 500, "Notification channel could not be deleted.")
return
}
s.recordAudit(r, actor, "notification.channel.delete", "allowed", map[string]string{"target_id": r.PathValue("id")})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *server) auditPolicyForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
if !ok {
return
}
days, _ := strconv.Atoi(r.FormValue("retention_days"))
maximum, _ := strconv.Atoi(r.FormValue("maximum_count"))
if err := s.audit.SetPolicy(r.Context(), audit.Policy{RetentionDays: days, MaximumCount: maximum}); err != nil {
s.problem(w, 422, "Invalid audit policy.")
return
}
s.recordAudit(r, actor, "audit.policy.update", "allowed", nil)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *server) auditPurgeForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
if !ok {
return
}
if r.FormValue("confirm") != "yes" {
s.problem(w, 422, "Purge confirmation is required.")
return
}
before, err := time.Parse("2006-01-02", r.FormValue("before"))
if err != nil {
s.problem(w, 422, "Invalid purge date.")
return
}
n, err := s.audit.Purge(r.Context(), before)
if err != nil {
s.problem(w, 422, "Audit purge failed.")
return
}
s.recordAudit(r, actor, "audit.purge", "allowed", map[string]string{"deleted_count": strconv.FormatInt(n, 10)})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
+1026 -35
View File
File diff suppressed because it is too large Load Diff
+321 -1
View File
@@ -1,6 +1,7 @@
package web
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
@@ -9,14 +10,20 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
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"
)
@@ -69,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}
@@ -120,6 +136,306 @@ 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()
serversRoot := filepath.Join(root, "servers")
backupsRoot := filepath.Join(root, "backups")
mount := filepath.Join(serversRoot, "instance", "saved")
if err := os.MkdirAll(mount, 0o750); err != nil {
t.Fatal(err)
}
world := filepath.Join(mount, "Level.sav")
if err := os.WriteFile(world, []byte("world-v1"), 0o640); err != nil {
t.Fatal(err)
}
db, err := sqlite.Open(ctx, filepath.Join(root, "dogama.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
repository := sqlite.NewRepository(db)
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
if err != nil {
t.Fatal(err)
}
if err := repository.Sync(ctx, snapshots); err != nil {
t.Fatal(err)
}
authService := auth.New(db)
if err := authService.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
t.Fatal(err)
}
adminSession, err := authService.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
if err != nil {
t.Fatal(err)
}
admin, err := authService.Authenticate(ctx, adminSession.Token)
if err != nil {
t.Fatal(err)
}
player, err := authService.CreateUser(ctx, "player", "another correct battery staple", "user")
if err != nil {
t.Fatal(err)
}
playerSession, err := authService.Login(ctx, "player", "another correct battery staple", "192.0.2.2:1234")
if err != nil {
t.Fatal(err)
}
preview, err := instance.BuildPreview(snapshots[0], instance.PreviewRequest{DisplayName: "Backup API", Slug: "backup-api", HostPorts: map[string]int{"game": 38211}, MountPaths: map[string]string{"saved": mount}, DataOrigin: "new", BackupRetention: 2})
if err != nil {
t.Fatal(err)
}
const instanceID = "backup-api-instance"
if err := repository.CreateDraft(ctx, instance.Draft{ID: instanceID, Preview: preview}); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`UPDATE instances SET lifecycle_state='online', observed_state='ready', container_id='container', desired_running=1 WHERE id=?`, instanceID); err != nil {
t.Fatal(err)
}
backupService, err := backup.New(repository, webLifecycleAgent{}, serversRoot, backupsRoot)
if err != nil {
t.Fatal(err)
}
importService, err := importexport.New(repository, filepath.Join(root, "imports"), serversRoot)
if err != nil {
t.Fatal(err)
}
handler, err := NewHandlerWithLifecycleAndBackup(authService, repository, webLifecycleAgent{}, backupService, importService, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
adminCookie := &http.Cookie{Name: sessionCookie, Value: adminSession.Token}
playerCookie := &http.Cookie{Name: sessionCookie, Value: playerSession.Token}
denied := request(t, handler, http.MethodGet, "/api/v1/instances/"+instanceID+"/backups", []*http.Cookie{playerCookie})
assertStatus(t, denied, http.StatusForbidden)
membership := jsonMethodRequest(t, handler, http.MethodPut, "/api/v1/instances/"+instanceID+"/memberships/"+player.ID, []byte(`{"role":"manager"}`), adminCookie, adminSession.CSRFToken)
assertStatus(t, membership, http.StatusNoContent)
created := jsonRequest(t, handler, "/api/v1/instances/"+instanceID+"/backups", nil, playerCookie, playerSession.CSRFToken)
assertStatus(t, created, http.StatusCreated)
var value backup.Backup
if err := json.Unmarshal(created.Body.Bytes(), &value); err != nil {
t.Fatal(err)
}
listed := request(t, handler, http.MethodGet, "/api/v1/instances/"+instanceID+"/backups", []*http.Cookie{playerCookie})
assertStatus(t, listed, http.StatusOK)
exportDenied := request(t, handler, http.MethodGet, "/api/v1/instances/"+instanceID+"/backups/"+value.ID+"/export", []*http.Cookie{playerCookie})
assertStatus(t, exportDenied, http.StatusForbidden)
exported := request(t, handler, http.MethodGet, "/api/v1/instances/"+instanceID+"/backups/"+value.ID+"/export", []*http.Cookie{adminCookie})
assertStatus(t, exported, http.StatusOK)
if exported.Header().Get("X-Content-SHA256") == "" {
t.Fatal("export checksum header missing")
}
if err := os.WriteFile(world, []byte("world-v2"), 0o640); err != nil {
t.Fatal(err)
}
restored := jsonRequest(t, handler, "/api/v1/instances/"+instanceID+"/backups/"+value.ID+"/restore", nil, adminCookie, adminSession.CSRFToken)
assertStatus(t, restored, http.StatusOK)
body, err := os.ReadFile(world)
if err != nil || string(body) != "world-v1" {
t.Fatalf("restored world=%q error=%v", body, err)
}
policy := jsonMethodRequest(t, handler, http.MethodPut, "/api/v1/instances/"+instanceID+"/backup-policy", []byte(`{"enabled":true,"cron_expression":"0 3 * * *","timezone":"Europe/Paris","retention_count":5}`), adminCookie, adminSession.CSRFToken)
assertStatus(t, policy, http.StatusOK)
imported, err := importService.Stage(ctx, admin.ID, "zip", bytes.NewReader(palworldImportZIP(t)), importexport.Policy{TemplateID: snapshots[0].Template.ID, TemplateVersion: snapshots[0].Template.Version, AcceptedFormats: snapshots[0].Template.Imports.AcceptedFormats, MaxExpandedBytes: int64(snapshots[0].Template.Imports.MaxExtractedSizeGB) << 30, RequiredPaths: snapshots[0].Template.Imports.RequiredPaths})
if err != nil {
t.Fatal(err)
}
importMount := filepath.Join(serversRoot, "imported", "saved")
importPreview, err := instance.BuildPreview(snapshots[0], instance.PreviewRequest{DisplayName: "Imported API", Slug: "imported-api", HostPorts: map[string]int{"game": 38212}, MountPaths: map[string]string{"saved": importMount}, DataOrigin: "import", ImportID: imported.ID, BackupRetention: 2})
if err != nil {
t.Fatal(err)
}
const importedInstanceID = "imported-api-instance"
if err := repository.CreateDraft(ctx, instance.Draft{ID: importedInstanceID, Preview: importPreview}); err != nil {
t.Fatal(err)
}
installed := jsonRequest(t, handler, "/api/v1/instances/"+importedInstanceID+"/install", nil, adminCookie, adminSession.CSRFToken)
assertStatus(t, installed, http.StatusOK)
importedWorld := filepath.Join(importMount, "SaveGames", "0", "Level.sav")
if body, err := os.ReadFile(importedWorld); err != nil || string(body) != "imported-world" {
t.Fatalf("imported world=%q error=%v", body, err)
}
}
func palworldImportZIP(t *testing.T) []byte {
t.Helper()
var buffer bytes.Buffer
writer := zip.NewWriter(&buffer)
for name, body := range map[string]string{"Save/Level.sav": "imported-world", "Save/Players/player.sav": "player"} {
entry, err := writer.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := io.WriteString(entry, body); err != nil {
t.Fatal(err)
}
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
return buffer.Bytes()
}
func TestInstanceAuthorizationAndInstallationRequestWorkflow(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)
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
if err != nil {
t.Fatal(err)
}
if err := repository.Sync(ctx, snapshots); err != nil {
t.Fatal(err)
}
authService := auth.New(db)
if err := authService.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
t.Fatal(err)
}
player, err := authService.CreateUser(ctx, "player", "another correct battery staple", "user")
if err != nil {
t.Fatal(err)
}
adminSession, err := authService.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
if err != nil {
t.Fatal(err)
}
playerSession, err := authService.Login(ctx, "player", "another correct battery staple", "192.0.2.2:1234")
if err != nil {
t.Fatal(err)
}
handler, err := NewHandlerWithLifecycle(authService, repository, webLifecycleAgent{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
adminCookie := &http.Cookie{Name: sessionCookie, Value: adminSession.Token}
playerCookie := &http.Cookie{Name: sessionCookie, Value: playerSession.Token}
draftPayload, _ := json.Marshal(map[string]any{
"template_id": "palworld-official", "template_version": "1.0.0",
"display_name": "Authorization Test", "slug": "authorization-test",
"host_ports": map[string]int{"game": 8211},
"mount_paths": map[string]string{"saved": "/srv/game-servers/authorization-test/saved"},
"data_origin": "new", "backup_retention": 7,
})
draft := jsonRequest(t, handler, "/api/v1/instances/drafts", draftPayload, adminCookie, adminSession.CSRFToken)
assertStatus(t, draft, http.StatusCreated)
var draftResult map[string]string
if err := json.Unmarshal(draft.Body.Bytes(), &draftResult); err != nil {
t.Fatal(err)
}
instanceID := draftResult["id"]
install := jsonRequest(t, handler, "/api/v1/instances/"+instanceID+"/install", nil, adminCookie, adminSession.CSRFToken)
assertStatus(t, install, http.StatusOK)
denied := request(t, handler, http.MethodGet, "/api/v1/instances/"+instanceID, []*http.Cookie{playerCookie})
assertStatus(t, denied, http.StatusForbidden)
membershipPath := "/api/v1/instances/" + instanceID + "/memberships/" + player.ID
membership := jsonMethodRequest(t, handler, http.MethodPut, membershipPath, []byte(`{"role":"user"}`), adminCookie, adminSession.CSRFToken)
assertStatus(t, membership, http.StatusNoContent)
inspect := request(t, handler, http.MethodGet, "/api/v1/instances/"+instanceID, []*http.Cookie{playerCookie})
assertStatus(t, inspect, http.StatusOK)
start := jsonRequest(t, handler, "/api/v1/instances/"+instanceID+"/start", nil, playerCookie, playerSession.CSRFToken)
assertStatus(t, start, http.StatusOK)
restart := jsonRequest(t, handler, "/api/v1/instances/"+instanceID+"/restart", nil, playerCookie, playerSession.CSRFToken)
assertStatus(t, restart, http.StatusForbidden)
membership = jsonMethodRequest(t, handler, http.MethodPut, membershipPath, []byte(`{"role":"manager"}`), adminCookie, adminSession.CSRFToken)
assertStatus(t, membership, http.StatusNoContent)
restart = jsonRequest(t, handler, "/api/v1/instances/"+instanceID+"/restart", nil, playerCookie, playerSession.CSRFToken)
assertStatus(t, restart, http.StatusOK)
overridePath := membershipPath + "/permissions/instance.restart"
override := jsonMethodRequest(t, handler, http.MethodPut, overridePath, []byte(`{"effect":"deny"}`), adminCookie, adminSession.CSRFToken)
assertStatus(t, override, http.StatusNoContent)
restart = jsonRequest(t, handler, "/api/v1/instances/"+instanceID+"/restart", nil, playerCookie, playerSession.CSRFToken)
assertStatus(t, restart, http.StatusForbidden)
substitution := request(t, handler, http.MethodGet, "/api/v1/instances/not-the-member-instance", []*http.Cookie{playerCookie})
assertStatus(t, substitution, http.StatusForbidden)
requestPayload := []byte(`{"template_id":"palworld-official","template_version":"1.0.0","suggested_name":"Friends","player_estimate":8,"desired_schedule":"evenings","mods_requested":true,"message":"Private group"}`)
installationRequest := jsonRequest(t, handler, "/api/v1/installation-requests", requestPayload, playerCookie, playerSession.CSRFToken)
assertStatus(t, installationRequest, http.StatusCreated)
var createdRequest struct {
ID string `json:"id"`
}
if err := json.Unmarshal(installationRequest.Body.Bytes(), &createdRequest); err != nil {
t.Fatal(err)
}
duplicate := jsonRequest(t, handler, "/api/v1/installation-requests", requestPayload, playerCookie, playerSession.CSRFToken)
assertStatus(t, duplicate, http.StatusConflict)
playerList := request(t, handler, http.MethodGet, "/api/v1/installation-requests", []*http.Cookie{playerCookie})
assertStatus(t, playerList, http.StatusOK)
if !strings.Contains(playerList.Body.String(), createdRequest.ID) {
t.Fatalf("request list = %s", playerList.Body.String())
}
var instancesBefore int
if err := db.QueryRow("SELECT COUNT(*) FROM instances").Scan(&instancesBefore); err != nil {
t.Fatal(err)
}
review := jsonRequest(t, handler, "/api/v1/installation-requests/"+createdRequest.ID+"/review", []byte(`{"decision":"approved","reason":"capacity available"}`), adminCookie, adminSession.CSRFToken)
assertStatus(t, review, http.StatusOK)
var instancesAfter int
if err := db.QueryRow("SELECT COUNT(*) FROM instances").Scan(&instancesAfter); err != nil {
t.Fatal(err)
}
if instancesAfter != instancesBefore {
t.Fatalf("approval deployed an instance: before=%d after=%d", instancesBefore, instancesAfter)
}
}
func TestBootstrapAuthenticationAndLogoutFlow(t *testing.T) {
handler := testHandler(t)
@@ -269,8 +585,12 @@ func formRequest(t *testing.T, handler http.Handler, target string, values url.V
}
func jsonRequest(t *testing.T, handler http.Handler, target string, body []byte, session *http.Cookie, csrf string) *httptest.ResponseRecorder {
return jsonMethodRequest(t, handler, http.MethodPost, target, body, session, csrf)
}
func jsonMethodRequest(t *testing.T, handler http.Handler, method, target string, body []byte, session *http.Cookie, csrf string) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(http.MethodPost, target, bytes.NewReader(body))
request := httptest.NewRequest(method, target, bytes.NewReader(body))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-CSRF-Token", csrf)
request.AddCookie(session)
+11 -3
View File
@@ -1,11 +1,19 @@
:root { color-scheme: light dark; font-family: system-ui, sans-serif; line-height: 1.5; }
body { margin: 0; background: #eef2f7; color: #172033; }
main { width: min(32rem, calc(100% - 2rem)); margin: 8vh auto; padding: 2rem; background: white; border-radius: .75rem; box-shadow: 0 .5rem 2rem #17203318; }
main { width: min(64rem, calc(100% - 2rem)); margin: 4vh auto; padding: 2rem; background: white; border-radius: .75rem; box-shadow: 0 .5rem 2rem #17203318; }
section { margin-top: 2.5rem; padding-top: 1rem; border-top: 1px solid #d0d5dd; }
header { display: flex; justify-content: space-between; align-items: center; padding: 1rem 2rem; background: white; }
form { display: grid; gap: 1rem; }
header form { display: block; }
.inline { display: inline; margin-left: .5rem; }
.inline button { padding: .35rem .55rem; }
label { display: grid; gap: .35rem; font-weight: 600; }
input, button { box-sizing: border-box; padding: .7rem; font: inherit; border: 1px solid #8a94a6; border-radius: .35rem; }
input, textarea, select, button { box-sizing: border-box; padding: .7rem; font: inherit; border: 1px solid #8a94a6; border-radius: .35rem; }
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
.table-wrap { overflow-x: auto; } table { width: 100%; border-collapse: collapse; } th, td { padding: .6rem; text-align: left; border-bottom: 1px solid #d0d5dd; white-space: nowrap; }
.danger { background: #b42318; }
.warning { padding: .75rem; border-left: .25rem solid #b54708; background: #fffaeb; color: #7a2e0e; }
button { border: 0; background: #3157d5; color: white; font-weight: 700; cursor: pointer; }
.error { padding: .75rem; border-left: .25rem solid #b42318; background: #fee4e2; color: #7a271a; }
@media (prefers-color-scheme: dark) { body { background: #111827; color: #e5e7eb; } main, header { background: #1f2937; } input { background: #111827; color: #e5e7eb; } }
@media (max-width: 40rem) { main { padding: 1rem; margin: 1rem; width: auto; } header { padding: 1rem; } .grid { grid-template-columns: 1fr; } }
@media (prefers-color-scheme: dark) { body { background: #111827; color: #e5e7eb; } main, header { background: #1f2937; } input, textarea, select { background: #111827; color: #e5e7eb; } }
+14 -1
View File
@@ -1,3 +1,16 @@
{{define "home.html"}}<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · DoGaMa</title><link rel="stylesheet" href="/static/app.v1.css"></head>
<body><header><strong>{{msg "brand"}}</strong><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">{{msg "logout.submit"}}</button></form></header><main><h1>{{msg "dashboard.title"}}</h1><p>{{msg "dashboard.signed_in"}} <strong>{{.User.Username}}</strong>.</p><p>{{msg "dashboard.ready"}}</p></main></body></html>{{end}}
<body><header><strong>{{msg "brand"}}</strong><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">{{msg "logout.submit"}}</button></form></header>
<main><h1>{{msg "dashboard.title"}}</h1><p>{{msg "dashboard.signed_in"}} <strong>{{.User.Username}}</strong>.</p><p>{{msg "dashboard.ready"}}</p>
{{if .IsAdmin}}
<section><h2>Notification channels</h2><p>Secrets are encrypted and never displayed after saving. Delivery is queued and retried without blocking operations.</p>
<ul>{{range .Channels}}<li><strong>{{.Name}}</strong> — {{.Type}} · {{if .Enabled}}enabled{{else}}disabled{{end}}
<form class="inline" method="post" action="/admin/notification-channels/{{.ID}}/test"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><button type="submit">Send test</button></form>
<form class="inline" method="post" action="/admin/notification-channels/{{.ID}}/delete"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><button class="danger" type="submit">Delete</button></form></li>{{else}}<li>No channel configured.</li>{{end}}</ul>
<form method="post" action="/admin/notification-channels"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Name<input name="name" required></label><label>Type<select name="type"><option value="webhook">HTTPS webhook</option><option value="discord">Discord webhook</option><option value="email">SMTP email</option></select></label><label>HTTPS URL<input name="url" type="url" placeholder="https://…"></label><label>Signing secret<input name="signing_secret" type="password" autocomplete="new-password"></label><div class="grid"><label>SMTP host<input name="host"></label><label>Port<input name="port" inputmode="numeric" placeholder="587"></label><label>Username<input name="smtp_username"></label><label>Password<input name="smtp_password" type="password" autocomplete="new-password"></label><label>From<input name="from" type="email"></label><label>Recipients<input name="to"></label></div><label>Events (space separated)<input name="events" value="backup.failed update.failed restore.failed security.required"></label><button type="submit">Add channel</button></form></section>
<section><h2>Audit retention</h2><form method="post" action="/admin/audit-policy"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><div class="grid"><label>Retention days<input name="retention_days" type="number" min="0" max="3650" value="{{.AuditPolicy.RetentionDays}}"></label><label>Maximum entries<input name="maximum_count" type="number" min="0" max="1000000" value="{{.AuditPolicy.MaximumCount}}"></label></div><p>Zero means unlimited and may grow the database indefinitely.</p><button type="submit">Save retention</button></form>
<form method="post" action="/admin/audit-purge"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Delete events before<input name="before" type="date" required></label><label><input name="confirm" type="checkbox" value="yes"> Confirm bounded audit purge</label><button class="danger" type="submit">Purge audit events</button></form></section>
<section><h2>Recent audit events</h2><form method="get" action="/"><div class="grid"><label>Actor ID<input name="actor_id" value="{{.AuditActor}}"></label><label>Instance ID<input name="instance_id" value="{{.AuditInstance}}"></label><label>Action<input name="action" value="{{.AuditAction}}"></label><label>Outcome<select name="outcome"><option value="">Any</option><option value="allowed">Allowed</option><option value="denied">Denied</option><option value="failed">Failed</option></select></label></div><button type="submit">Filter audit</button></form>
<div class="table-wrap"><table><thead><tr><th>Time</th><th>Actor</th><th>Action</th><th>Outcome</th><th>Instance</th></tr></thead><tbody>{{range .AuditEvents}}<tr><td>{{.OccurredAt.Format "2006-01-02 15:04:05Z"}}</td><td>{{.ActorLabel}}</td><td><code>{{.Action}}</code></td><td>{{.Outcome}}</td><td>{{.InstanceID}}</td></tr>{{else}}<tr><td colspan="5">No audit event.</td></tr>{{end}}</tbody></table></div></section>
<section><h2>Game-container labels</h2><form method="post" action="/admin/game-container-labels"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Global labels<textarea name="labels" rows="8" placeholder="key=value">{{.GlobalLabels}}</textarea></label><p>One label per line. Available variables: <code>{{`{{game.name}}`}}</code>, <code>{{`{{game.id}}`}}</code>, <code>{{`{{game.icon_url}}`}}</code>, <code>{{`{{instance.name}}`}}</code>, <code>{{`{{instance.id}}`}}</code>, <code>{{`{{instance.slug}}`}}</code>, <code>{{`{{server.name}}`}}</code>.</p><label><input type="radio" name="apply" value="next_start" checked> Apply on next start</label><label><input type="radio" name="apply" value="immediate"> Apply immediately</label><aside class="warning"><strong>Immediate application stops and recreates affected containers.</strong> Connected players are disconnected immediately. Persistent data is preserved and each instance returns to its previous running or stopped state.</aside><label><input type="checkbox" name="confirm_disconnection" value="yes"> I understand the immediate-disconnection warning</label><button type="submit">Save game-container labels</button></form></section>
{{end}}</main></body></html>{{end}}
@@ -0,0 +1,46 @@
CREATE TABLE instance_memberships (
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('user', 'manager')),
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (instance_id, user_id)
);
CREATE INDEX instance_memberships_user_idx ON instance_memberships(user_id, instance_id);
CREATE TABLE permission_overrides (
instance_id TEXT NOT NULL,
user_id TEXT NOT NULL,
permission TEXT NOT NULL CHECK (permission IN ('instance.view', 'instance.start', 'instance.stop', 'instance.restart', 'instance.update', 'instance.configure', 'instance.delete', 'instance.welcome.edit', 'metrics.view', 'players.view', 'players.kick', 'players.ban', 'players.unban', 'announcements.send', 'logs.view', 'mods.manage', 'backup.create', 'backup.list', 'backup.export', 'backup.restore', 'backup.delete', 'request.create')),
effect TEXT NOT NULL CHECK (effect IN ('allow', 'deny')),
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (instance_id, user_id, permission),
FOREIGN KEY (instance_id, user_id) REFERENCES instance_memberships(instance_id, user_id) ON DELETE CASCADE
);
CREATE TABLE installation_requests (
id TEXT PRIMARY KEY,
requested_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
template_id TEXT NOT NULL,
template_version TEXT NOT NULL,
suggested_name TEXT,
player_estimate INTEGER CHECK (player_estimate IS NULL OR (player_estimate >= 1 AND player_estimate <= 10000)),
desired_schedule TEXT,
mods_requested INTEGER NOT NULL DEFAULT 0 CHECK (mods_requested IN (0, 1)),
message TEXT,
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'refused', 'cancelled')),
reviewed_by TEXT REFERENCES users(id) ON DELETE RESTRICT,
review_reason TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
reviewed_at TEXT,
FOREIGN KEY (template_id, template_version) REFERENCES template_versions(template_id, version) ON DELETE RESTRICT
);
CREATE UNIQUE INDEX installation_requests_pending_idx ON installation_requests(requested_by, template_id, template_version) WHERE status = 'pending';
CREATE INDEX installation_requests_status_idx ON installation_requests(status, created_at);
CREATE INDEX installation_requests_requester_idx ON installation_requests(requested_by, created_at DESC);
+76
View File
@@ -0,0 +1,76 @@
ALTER TABLE instance_operations RENAME TO instance_operations_v4;
CREATE TABLE instance_operations (
id TEXT PRIMARY KEY,
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE RESTRICT,
kind TEXT NOT NULL CHECK (kind IN ('install', 'start', 'stop', 'restart', 'delete_container', 'reconcile', 'backup', 'restore', 'import')),
state TEXT NOT NULL CHECK (state IN ('running', 'succeeded', 'failed', 'intervention_required')),
phase TEXT NOT NULL,
error_code TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
completed_at TEXT
);
INSERT INTO instance_operations(id, instance_id, kind, state, phase, error_code, created_at, updated_at, completed_at)
SELECT id, instance_id, kind, state, phase, error_code, created_at, updated_at, completed_at
FROM instance_operations_v4;
DROP TABLE instance_operations_v4;
CREATE UNIQUE INDEX instance_operation_active_idx ON instance_operations(instance_id) WHERE state = 'running';
CREATE INDEX instance_operation_history_idx ON instance_operations(instance_id, created_at DESC);
CREATE TABLE backup_policies (
instance_id TEXT PRIMARY KEY REFERENCES instances(id) ON DELETE CASCADE,
enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)),
cron_expression TEXT,
timezone TEXT NOT NULL DEFAULT 'UTC',
retention_count INTEGER NOT NULL DEFAULT 7 CHECK (retention_count BETWEEN 1 AND 1000),
next_run_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE backups (
id TEXT PRIMARY KEY,
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE RESTRICT,
operation_id TEXT REFERENCES instance_operations(id) ON DELETE SET NULL,
origin TEXT NOT NULL CHECK (origin IN ('manual', 'scheduled', 'pre_update', 'pre_restore', 'idle_shutdown', 'imported', 'system')),
status TEXT NOT NULL CHECK (status IN ('creating', 'available', 'failed', 'deleted')),
relative_path TEXT,
size_bytes INTEGER CHECK (size_bytes IS NULL OR size_bytes >= 0),
sha256 TEXT CHECK (sha256 IS NULL OR length(sha256) = 64),
manifest_json TEXT,
error_code TEXT,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
completed_at TEXT,
deleted_at TEXT
);
CREATE INDEX backups_instance_created_idx ON backups(instance_id, created_at DESC);
CREATE INDEX backups_retention_idx ON backups(instance_id, origin, status, created_at);
CREATE TABLE imports (
id TEXT PRIMARY KEY,
requested_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
instance_id TEXT REFERENCES instances(id) ON DELETE CASCADE,
template_id TEXT NOT NULL,
template_version TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('staging', 'validated', 'attached', 'failed', 'expired')),
format TEXT NOT NULL,
relative_stage_path TEXT NOT NULL,
data_root TEXT,
detected_type TEXT,
confidence TEXT CHECK (confidence IS NULL OR confidence IN ('confirmed', 'probable', 'recognized_unknown_version', 'unrecognized')),
file_count INTEGER NOT NULL DEFAULT 0,
expanded_size_bytes INTEGER NOT NULL DEFAULT 0,
error_code TEXT,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
completed_at TEXT,
FOREIGN KEY (template_id, template_version) REFERENCES template_versions(template_id, version) ON DELETE RESTRICT
);
CREATE INDEX imports_expiry_idx ON imports(status, expires_at);
+29
View File
@@ -0,0 +1,29 @@
CREATE TABLE module_versions (
module_id TEXT NOT NULL,
version TEXT NOT NULL,
game_id TEXT NOT NULL,
abi TEXT NOT NULL,
manifest_json TEXT NOT NULL,
wasm_sha256 TEXT NOT NULL CHECK (length(wasm_sha256) = 64),
wasm_size_bytes INTEGER NOT NULL CHECK (wasm_size_bytes > 0),
source TEXT NOT NULL CHECK (source IN ('bundled', 'uploaded')),
installed_at TEXT NOT NULL,
PRIMARY KEY (module_id, version)
);
CREATE TABLE instance_module_bindings (
instance_id TEXT PRIMARY KEY REFERENCES instances(id) ON DELETE CASCADE,
module_id TEXT NOT NULL,
module_version TEXT NOT NULL,
port_id TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)),
configuration_json TEXT NOT NULL DEFAULT '{}',
secret_references_json TEXT NOT NULL DEFAULT '{}',
last_health TEXT NOT NULL DEFAULT 'unknown' CHECK (last_health IN ('ready', 'degraded', 'offline', 'unknown')),
last_error_code TEXT,
activated_at TEXT,
updated_at TEXT NOT NULL,
FOREIGN KEY (module_id, module_version) REFERENCES module_versions(module_id, version) ON DELETE RESTRICT
);
CREATE INDEX instance_module_health_idx ON instance_module_bindings(enabled, last_health);
@@ -0,0 +1,14 @@
ALTER TABLE instances ADD COLUMN custom_labels_json TEXT NOT NULL DEFAULT '{}';
ALTER TABLE instances ADD COLUMN docker_user_mode TEXT NOT NULL DEFAULT 'dogama' CHECK (docker_user_mode IN ('dogama', 'custom', 'image'));
ALTER TABLE instances ADD COLUMN docker_uid INTEGER CHECK (docker_uid BETWEEN 0 AND 4294967295);
ALTER TABLE instances ADD COLUMN docker_gid INTEGER CHECK (docker_gid BETWEEN 0 AND 4294967295);
ALTER TABLE instances ADD COLUMN image_tag_mode TEXT NOT NULL DEFAULT 'tracked' CHECK (image_tag_mode IN ('tracked', 'pinned'));
ALTER TABLE instances ADD COLUMN image_tag TEXT NOT NULL DEFAULT '';
ALTER TABLE instances ADD COLUMN container_config_pending INTEGER NOT NULL DEFAULT 0 CHECK (container_config_pending IN (0, 1));
CREATE TABLE system_settings (
key TEXT PRIMARY KEY,
value_json TEXT NOT NULL,
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1),
updated_at TEXT NOT NULL
);
+11
View File
@@ -0,0 +1,11 @@
CREATE TABLE configuration_revisions (
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
revision INTEGER NOT NULL CHECK (revision >= 1),
redacted_snapshot TEXT NOT NULL,
reason TEXT NOT NULL CHECK (length(reason) BETWEEN 1 AND 100),
created_by TEXT,
created_at TEXT NOT NULL,
PRIMARY KEY (instance_id, revision)
);
CREATE INDEX configuration_revision_history_idx ON configuration_revisions(instance_id, revision DESC);
+40
View File
@@ -0,0 +1,40 @@
CREATE TABLE notification_channels (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('email', 'webhook', 'discord')),
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
encrypted_config BLOB NOT NULL,
event_filter_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE notification_deliveries (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
payload_redacted TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'retrying', 'succeeded', 'failed')),
attempt INTEGER NOT NULL DEFAULT 0 CHECK (attempt >= 0),
next_attempt_at TEXT NOT NULL,
last_error_code TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
completed_at TEXT
);
CREATE INDEX notification_deliveries_due_idx ON notification_deliveries(status, next_attempt_at);
CREATE TABLE audit_events (
id TEXT PRIMARY KEY,
occurred_at TEXT NOT NULL,
actor_id TEXT REFERENCES users(id) ON DELETE SET NULL,
actor_label TEXT NOT NULL,
instance_id TEXT REFERENCES instances(id) ON DELETE SET NULL,
action TEXT NOT NULL,
outcome TEXT NOT NULL CHECK (outcome IN ('allowed', 'denied', 'failed')),
summary_json TEXT NOT NULL DEFAULT '{}'
);
CREATE INDEX audit_events_time_idx ON audit_events(occurred_at DESC, id DESC);
CREATE INDEX audit_events_filters_idx ON audit_events(actor_id, instance_id, action, outcome);
INSERT INTO system_settings(key, value_json, revision, updated_at)
VALUES ('audit_policy', '{"retention_days":30,"maximum_count":10000}', 1, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
+17
View File
@@ -0,0 +1,17 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Copyright 2026 DoGaMa contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+24 -26
View File
@@ -1,40 +1,38 @@
# Palworld REST reference adapter
This is the source specification for DoGaMa's reference WebAssembly adapter. It is intentionally a translator only. Container lifecycle and archive handling stay in generic DoGaMa workflows.
This package contains DoGaMa's reference WebAssembly translator for the private Palworld REST API. Container lifecycle and archive handling remain in generic DoGaMa workflows.
## Endpoint mapping
| Normalized operation | Palworld REST operation |
| Normalized operation | Palworld REST endpoint |
|---|---|
| `test_connection`, `get_server_info` | server info |
| `get_server_status` | info plus metrics readiness |
| `get_metrics` | metrics |
| `list_players` | players |
| `save_world` | save |
| `shutdown` | shutdown with bounded wait/message |
| `send_announcement` | announce |
| `kick_player` | kick by stable player ID |
| `ban_player` | ban by stable player ID |
| `unban_player` | unban by stable player ID |
| `test_connection`, `get_server_info`, `get_server_status` | `GET /v1/api/info` |
| `get_metrics` | `GET /v1/api/metrics` |
| `list_players` | `GET /v1/api/players` |
| `save_world` | `POST /v1/api/save` |
| `shutdown` | `POST /v1/api/shutdown` |
| `send_announcement` | `POST /v1/api/announce` |
| `kick_player` | `POST /v1/api/kick` |
| `ban_player` | `POST /v1/api/ban` |
| `unban_player` | `POST /v1/api/unban` |
The official API category is [Palworld REST API](https://docs.palworldgame.com/category/rest-api/). Implementation must verify exact current paths, methods and response fields against the pinned game/API version and use fixtures for that version.
The mappings follow the [official Palworld REST API](https://docs.palworldgame.com/category/rest-api/).
## Network and credentials
## Isolation
The host binds logical handle `instance_api` to template port `rest_api`. The module never constructs a host or accepts a URL. It requests only relative `/v1/api/...` paths. Authentication values come from declared configuration and are placed in the request by module logic; diagnostics must never contain the header or password.
The host binds the logical `instance_api` destination to the instance container and its private `rest_api` port. The adapter receives no destination URL and can import only restricted WASI plus `dogama_host` functions. The runtime provides no filesystem, environment, process, raw socket, DNS or host clock capability.
## Save and shutdown semantics
Credentials come only from the declared `username` configuration and `admin_password` secret. Responses, requests, host-call count, memory, execution time and concurrency are bounded by the runtime.
`save_world` returns success only after the API acknowledges the save operation. DoGaMa then archives the `saved` mount. `shutdown` sends the in-game shutdown request; generic lifecycle code observes container exit and asks the restricted agent for a bounded stop only when necessary.
## Reproducible build
## Build status
From the repository root, using Go 1.25 or newer:
No WebAssembly binary is included in this specification baseline. The zero checksum in `manifest.yaml` is a visible placeholder. The implementation task must:
1. define the WIT/typed bindings for normalized API v1;
2. implement and test all declared capabilities;
3. compile `module.wasm` without ambient WASI capabilities;
4. replace the placeholder with the real SHA-256;
5. verify runtime limits and offline/unauthorized/malformed-response cases;
6. package the manifest, binary, README and license.
```sh
CGO_ENABLED=0 GOOS=wasip1 GOARCH=wasm go build \
-trimpath -buildmode=c-shared \
-o modules/palworld-rest/module.wasm ./modules/palworld-rest/src
sha256sum modules/palworld-rest/module.wasm
```
The checksum must exactly match `manifest.yaml`. Repository tests instantiate the real artifact under wazero and exercise it against a bounded fake Palworld transport.
+1 -1
View File
@@ -58,4 +58,4 @@ configuration:
artifacts:
wasm: module.wasm
sha256: "0000000000000000000000000000000000000000000000000000000000000000"
sha256: "e4b19ecfe66f4d9fb2c6451b82e6989c6eaf8887ec12cef2e97811527b1ff4ea"
Binary file not shown.
+294
View File
@@ -0,0 +1,294 @@
//go:build wasip1
package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"unsafe"
)
//go:wasmimport dogama_host http_request
func hostHTTPRequest(requestPtr, requestLen, responsePtr, responseCap uint32) int32
//go:wasmimport dogama_host get_secret
func hostGetSecret(keyPtr, keyLen, valuePtr, valueCap uint32) int32
//go:wasmimport dogama_host get_config
func hostGetConfig(keyPtr, keyLen, valuePtr, valueCap uint32) int32
var allocations [][]byte
//go:wasmexport dogama_alloc
func dogamaAlloc(size uint32) uint32 {
if size == 0 {
size = 1
}
value := make([]byte, size)
allocations = append(allocations, value)
return uint32(uintptr(unsafe.Pointer(&value[0])))
}
type hostRequest struct {
Method string `json:"method"`
Path string `json:"path"`
Header map[string]string `json:"header,omitempty"`
Body []byte `json:"body,omitempty"`
}
type hostResponse struct {
Status int `json:"status"`
Body []byte `json:"body,omitempty"`
}
type moduleError struct {
Code string `json:"code"`
Message string `json:"message"`
Retryable bool `json:"retryable"`
}
type envelope struct {
OK bool `json:"ok"`
Data any `json:"data,omitempty"`
Error *moduleError `json:"error,omitempty"`
}
func bytesAt(ptr, size uint32) []byte {
if size == 0 {
return nil
}
return unsafe.Slice((*byte)(unsafe.Pointer(uintptr(ptr))), size)
}
func output(outPtr, outCap uint32, value any) int32 {
encoded, err := json.Marshal(value)
if err != nil || len(encoded) > int(outCap) {
return -1
}
copy(bytesAt(outPtr, uint32(len(encoded))), encoded)
return int32(len(encoded))
}
func boundValue(secret bool, key string) (string, bool) {
keyBytes := []byte(key)
buffer := make([]byte, 4096)
var size int32
if secret {
size = hostGetSecret(uint32(uintptr(unsafe.Pointer(&keyBytes[0]))), uint32(len(keyBytes)), uint32(uintptr(unsafe.Pointer(&buffer[0]))), uint32(len(buffer)))
} else {
size = hostGetConfig(uint32(uintptr(unsafe.Pointer(&keyBytes[0]))), uint32(len(keyBytes)), uint32(uintptr(unsafe.Pointer(&buffer[0]))), uint32(len(buffer)))
}
if size < 0 {
return "", false
}
return string(buffer[:size]), true
}
func call(method, endpoint string, body any, result any) *moduleError {
username, usernameOK := boundValue(false, "username")
password, passwordOK := boundValue(true, "admin_password")
if !usernameOK || !passwordOK || username == "" || password == "" {
return &moduleError{Code: "invalid_configuration", Message: "Palworld REST credentials are incomplete."}
}
var encodedBody []byte
if body != nil {
encodedBody, _ = json.Marshal(body)
}
request := hostRequest{Method: method, Path: "/v1/api/" + endpoint, Header: map[string]string{"Accept": "application/json", "Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password))}, Body: encodedBody}
if body != nil {
request.Header["Content-Type"] = "application/json"
}
encoded, _ := json.Marshal(request)
responseBuffer := make([]byte, 1<<20)
size := hostHTTPRequest(uint32(uintptr(unsafe.Pointer(&encoded[0]))), uint32(len(encoded)), uint32(uintptr(unsafe.Pointer(&responseBuffer[0]))), uint32(len(responseBuffer)))
if size < 0 {
return &moduleError{Code: "unreachable", Message: "The Palworld REST API could not be reached.", Retryable: true}
}
var response hostResponse
if json.Unmarshal(responseBuffer[:size], &response) != nil {
return &moduleError{Code: "invalid_response", Message: "Palworld returned an invalid response."}
}
if response.Status == 401 {
return &moduleError{Code: "unauthorized", Message: "Palworld rejected the configured credentials."}
}
if response.Status < 200 || response.Status >= 300 {
return &moduleError{Code: "game_error", Message: "Palworld rejected the operation.", Retryable: response.Status >= 500}
}
if result != nil && len(response.Body) > 0 && json.Unmarshal(response.Body, result) != nil {
return &moduleError{Code: "invalid_response", Message: "Palworld returned an invalid response."}
}
return nil
}
func writeResult(outPtr, outCap uint32, data any, failure *moduleError) int32 {
return output(outPtr, outCap, envelope{OK: failure == nil, Data: data, Error: failure})
}
var capabilities = []string{"server_info", "metrics", "player_list", "online_save", "graceful_shutdown", "announcement", "kick", "ban", "unban"}
//go:wasmexport initialize
func initialize(_, _ uint32, outPtr, outCap uint32) int32 {
username, usernameOK := boundValue(false, "username")
_, passwordOK := boundValue(true, "admin_password")
if !usernameOK || !passwordOK || username == "" {
return writeResult(outPtr, outCap, nil, &moduleError{Code: "invalid_configuration", Message: "Palworld REST credentials are incomplete."})
}
return writeResult(outPtr, outCap, map[string]any{"module_id": "palworld-rest", "module_version": "1.0.0", "api_version": "1.0.0", "capabilities": capabilities}, nil)
}
type serverInfo struct {
Version string `json:"version"`
ServerName string `json:"servername"`
Description string `json:"description"`
WorldGUID string `json:"worldguid"`
}
//go:wasmexport test_connection
func testConnection(_, _ uint32, outPtr, outCap uint32) int32 {
var info serverInfo
failure := call("GET", "info", nil, &info)
return writeResult(outPtr, outCap, map[string]any{"connected": failure == nil, "game_version": info.Version}, failure)
}
//go:wasmexport get_server_status
func getServerStatus(_, _ uint32, outPtr, outCap uint32) int32 {
var info serverInfo
failure := call("GET", "info", nil, &info)
status := "ready"
if failure != nil {
status = "offline"
}
return writeResult(outPtr, outCap, map[string]any{"status": status}, failure)
}
//go:wasmexport get_server_info
func getServerInfo(_, _ uint32, outPtr, outCap uint32) int32 {
var info serverInfo
failure := call("GET", "info", nil, &info)
data := map[string]any{"name": info.ServerName, "game_version": info.Version, "description": info.Description, "world_id": info.WorldGUID}
return writeResult(outPtr, outCap, data, failure)
}
type palMetrics struct {
ServerFPS int `json:"serverfps"`
CurrentPlayers int `json:"currentplayernum"`
ServerFrameTime float64 `json:"serverframetime"`
MaxPlayers int `json:"maxplayernum"`
UptimeSeconds int64 `json:"uptime"`
BaseCampCount int `json:"basecampnum"`
InGameDay int `json:"days"`
}
//go:wasmexport get_metrics
func getMetrics(_, _ uint32, outPtr, outCap uint32) int32 {
var metrics palMetrics
failure := call("GET", "metrics", nil, &metrics)
return writeResult(outPtr, outCap, map[string]any{"server_fps": metrics.ServerFPS, "current_players": metrics.CurrentPlayers, "frame_time_ms": metrics.ServerFrameTime, "max_players": metrics.MaxPlayers, "uptime_seconds": metrics.UptimeSeconds, "base_camps": metrics.BaseCampCount, "game_day": metrics.InGameDay}, failure)
}
type palPlayer struct {
Name string `json:"name"`
PlayerID string `json:"playerId"`
UserID string `json:"userId"`
Ping float64 `json:"ping"`
}
//go:wasmexport list_players
func listPlayers(_, _ uint32, outPtr, outCap uint32) int32 {
var response struct {
Players []palPlayer `json:"players"`
}
failure := call("GET", "players", nil, &response)
players := make([]map[string]any, 0, len(response.Players))
for _, player := range response.Players {
players = append(players, map[string]any{"player_id": player.PlayerID, "user_id": player.UserID, "display_name": player.Name, "ping_ms": player.Ping})
}
return writeResult(outPtr, outCap, map[string]any{"players": players}, failure)
}
//go:wasmexport save_world
func saveWorld(_, _ uint32, outPtr, outCap uint32) int32 {
failure := call("POST", "save", nil, nil)
return writeResult(outPtr, outCap, map[string]any{"completed": failure == nil}, failure)
}
func action(outPtr, outCap uint32, endpoint string, body any) int32 {
failure := call("POST", endpoint, body, nil)
return writeResult(outPtr, outCap, map[string]any{"accepted": failure == nil}, failure)
}
func decodeRequest(inPtr, inLen uint32, value any) bool {
decoder := json.NewDecoder(bytes.NewReader(bytesAt(inPtr, inLen)))
decoder.DisallowUnknownFields()
return decoder.Decode(value) == nil
}
func invalidAction(outPtr, outCap uint32) int32 {
return writeResult(outPtr, outCap, nil, &moduleError{Code: "invalid_configuration", Message: "The operation request is invalid."})
}
type shutdownRequest struct {
WaitSeconds int `json:"wait_seconds"`
Message string `json:"message,omitempty"`
}
type messageRequest struct {
Message string `json:"message"`
}
type playerActionRequest struct {
PlayerID string `json:"player_id"`
Reason string `json:"reason,omitempty"`
}
type unbanRequest struct {
PlayerID string `json:"player_id"`
}
//go:wasmexport shutdown
func shutdown(inPtr, inLen, outPtr, outCap uint32) int32 {
var request shutdownRequest
if !decodeRequest(inPtr, inLen, &request) || request.WaitSeconds < 0 || request.WaitSeconds > 900 {
return invalidAction(outPtr, outCap)
}
return action(outPtr, outCap, "shutdown", map[string]any{"waittime": request.WaitSeconds, "message": request.Message})
}
//go:wasmexport send_announcement
func sendAnnouncement(inPtr, inLen, outPtr, outCap uint32) int32 {
var request messageRequest
if !decodeRequest(inPtr, inLen, &request) || request.Message == "" || len(request.Message) > 1000 {
return invalidAction(outPtr, outCap)
}
return action(outPtr, outCap, "announce", request)
}
func playerAction(inPtr, inLen, outPtr, outCap uint32, endpoint string) int32 {
var request playerActionRequest
if !decodeRequest(inPtr, inLen, &request) || request.PlayerID == "" || len(request.PlayerID) > 256 || len(request.Reason) > 1000 {
return invalidAction(outPtr, outCap)
}
return action(outPtr, outCap, endpoint, map[string]any{"userid": request.PlayerID, "message": request.Reason})
}
//go:wasmexport kick_player
func kickPlayer(inPtr, inLen, outPtr, outCap uint32) int32 {
return playerAction(inPtr, inLen, outPtr, outCap, "kick")
}
//go:wasmexport ban_player
func banPlayer(inPtr, inLen, outPtr, outCap uint32) int32 {
return playerAction(inPtr, inLen, outPtr, outCap, "ban")
}
//go:wasmexport unban_player
func unbanPlayer(inPtr, inLen, outPtr, outCap uint32) int32 {
var request unbanRequest
if !decodeRequest(inPtr, inLen, &request) || request.PlayerID == "" || len(request.PlayerID) > 256 {
return invalidAction(outPtr, outCap)
}
return action(outPtr, outCap, "unban", map[string]any{"userid": request.PlayerID})
}
func main() {}
+7
View File
@@ -0,0 +1,7 @@
//go:build !wasip1
package main
// The adapter is built only for GOOS=wasip1. This stub keeps repository-wide
// host-platform validation deterministic without pretending to run the guest.
func main() {}