Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d68d97d00a | ||
|
|
349d34ce20 | ||
|
|
3fbaa65eea | ||
|
|
1e226d3ada | ||
|
|
92c5e11bbc | ||
|
|
9eb4fe2cd8 | ||
|
|
7f5fa30d0f | ||
|
|
f9676ef9cd | ||
|
|
b30e6c5122 | ||
|
|
c820c9c0e0 | ||
|
|
5783474955 | ||
|
|
1e0628974c | ||
|
|
e65aef8315 | ||
|
|
6187178444 | ||
|
|
455ad36f83 | ||
|
|
83165cc4ad | ||
|
|
71d542a60f | ||
|
|
9fdf7a0b09 |
@@ -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.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# DoGaMa
|
||||
|
||||

|
||||
|
||||
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 five roadmap foundations are implemented: the main application and authentication, the restricted agent boundary, the validated embedded catalog, the registered instance lifecycle and backend-enforced per-instance authorization. Administrators can manage local users, assign user or manager memberships, apply explicit permission overrides and review installation requests without approval causing deployment. Assigned users receive the documented safe baseline; managers receive the operational baseline, while restore remains an explicit grant. SQLite records identities, access policy, requests, serialized operation phases and desired/observed instance state. Backups, 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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# DoGaMa project state
|
||||
|
||||
Read this compact operational baseline before starting a milestone. Open detailed domain documents only when the current work affects them.
|
||||
|
||||
## Baseline
|
||||
|
||||
- Current reference: milestone 9 working branch after merged milestone 8 baseline `1e226d3`.
|
||||
- Released SQLite migrations: `0001` through `0009`; never rewrite them.
|
||||
- Roadmap milestones 1-9 are implemented.
|
||||
|
||||
## Architecture
|
||||
|
||||
- Go main application: HTTP API, embedded server-rendered UI, authentication/authorization, SQLite, workflows, backups and WASM runtime.
|
||||
- Private restricted Go agent: sole Docker-socket owner; authenticated typed API; registered-instance and plan-digest binding; no generic Docker proxy.
|
||||
- Integrations: capability-scoped WebAssembly adapters only. Palworld REST is the reference module.
|
||||
- Data: SQLite plus canonical allowed server, import and backup roots. Persistent game data lives outside containers.
|
||||
- Contracts: declarative YAML templates and module manifests validated against JSON Schemas; released snapshots are immutable.
|
||||
|
||||
## Implemented capabilities
|
||||
|
||||
- Bootstrap administrator, local authentication, secure sessions and CSRF protection.
|
||||
- Validated embedded catalog, deterministic deployment previews and instance registry.
|
||||
- Restricted instance create/inspect/start/stop/restart/delete and reconciliation.
|
||||
- Per-instance memberships, overrides and installation requests with backend authorization.
|
||||
- Backup scheduling/retention, safe imports, export and restore with safety backups.
|
||||
- Sandboxed WASM runtime and normalized module API with Palworld reference adapter.
|
||||
- Game-container configuration: global and per-instance labels, safe label variables, derived instance slug, immutable Docker-user selection, tracked/pinned image tags, immediate or deferred container recreation, and public game-icon route.
|
||||
- Controlled digest-aware game updates with confirmation, policy-driven pre-update backups, readiness verification, mod warnings and automatic container-plan rollback.
|
||||
- Redacted configuration history retained to the latest 10 revisions, with pinned-template revalidation and immediate or deferred rollback.
|
||||
- Declarative Steam Workshop item configuration with numeric-ID validation, stable ordering and backend `mods.manage` enforcement.
|
||||
- Encrypted write-only SMTP, generic HTTPS webhook and Discord channels with event filters, queued test delivery, bounded retry and redacted terminal errors.
|
||||
- SSRF-resistant HTTPS webhook delivery with redirect/address revalidation, event IDs, timestamps and optional HMAC-SHA256 signatures.
|
||||
- Compact allow-listed audit events for authentication and significant mutations, administrator filtering, bounded manual purge, daily retention and maximum-count enforcement.
|
||||
|
||||
## Durable decisions
|
||||
|
||||
- Editable Docker labels apply only to game-server instance containers.
|
||||
- Labels on the DoGaMa application container remain Compose configuration and are never read, copied or edited by DoGaMa.
|
||||
- Merge order is global labels, then instance labels; instance values win. Internal technical labels are applied last and cannot be overridden.
|
||||
- `dogama.*` and `io.dogama.*` are reserved label namespaces.
|
||||
- Label values support only the explicit allowlist in `internal/instance/container_config.go`; unknown variables are errors, not arbitrary templates.
|
||||
- `{{game.icon_url}}` is the public icon for the game. `{{instance.slug}}` remains supported.
|
||||
- Instance slugs are derived from the display name, not canonical IDs. Accents are normalized to ASCII; whitespace, `/`, punctuation and special characters become safe hyphen separators; repeated and edge hyphens are removed.
|
||||
- Docker user mode is fixed at creation to DoGaMa UID/GID, custom numeric UID/GID, or image-defined user. Never perform automatic recursive ownership changes.
|
||||
- A pinned image tag is an explicit mutable tag, not an immutable digest. Tracked mode follows the template's declared default tag.
|
||||
- Replacement-requiring changes use the generic `container_config_pending` desired-versus-applied state. Replacements preserve bind-mounted data and prior running/stopped intent.
|
||||
- The main app never gains Docker-socket access; the agent remains deny-by-default and independently validates privileged plan fields.
|
||||
- Update candidates are explicit `tag@sha256:digest` references. Mutable tags alone are rejected; automatic updates remain disabled.
|
||||
- Mod configuration is data-only. Provider commands, scripts and arbitrary download URLs are forbidden.
|
||||
- Notification configuration is unavailable unless `DOGAMA_MASTER_KEY_FILE` contains exactly 32 bytes; ciphertext is authenticated AES-GCM and secrets are never returned by list APIs.
|
||||
- Notification delivery attempts are capped at five with exponential minute-scale backoff and never determine the originating operation result.
|
||||
- Audit retention defaults to 30 days and 10,000 entries; zero explicitly selects unlimited retention/count within documented bounds.
|
||||
|
||||
## Known limitations and debt
|
||||
|
||||
- Release hardening remains roadmap work.
|
||||
- Scheduled backup outcomes and repeated authentication blocks are audited/logged, but broader scheduler-origin notification coverage remains intentionally limited to events emitted by implemented workflows.
|
||||
- The web interface is intentionally modest; several advanced workflows are API-first.
|
||||
- Linux is the deployment target. Native Windows execution of the full Go suite is blocked by Unix `Statfs` code; use Linux/WSL/CI for complete execution.
|
||||
- `staticcheck`, `golangci-lint` and Python specification dependencies may not be installed on every development host; report missing tooling rather than silently skipping or installing it.
|
||||
|
||||
## Validation and CI
|
||||
|
||||
- No repository-hosted Gitea/GitHub workflow files are currently present.
|
||||
- Normal completion gate for Go changes is the validation set in `AGENTS.md` on Linux.
|
||||
- Specification validation is `python tools/validate_spec.py` with `tools/requirements-validation.txt` available.
|
||||
- Start with package/file-specific tests, then run global tests, build, race detection, vet, static analysis and schema validation as applicable.
|
||||
|
||||
## Next known work
|
||||
|
||||
- Roadmap milestone 10: security hardening, end-to-end tests, contributor documentation and release packaging.
|
||||
- Update this file at the end of every merged milestone or durable architectural change; keep it compact and remove stale statements.
|
||||
@@ -69,6 +69,8 @@ Before create or replace, the agent verifies:
|
||||
- port protocols and container ports match the template and host ports do not conflict;
|
||||
- resource limits are present and within administrator limits;
|
||||
- labels use the reserved namespace and cannot be overridden;
|
||||
- custom labels are bounded, may not use either `dogama.*` or the internal `io.dogama.*` namespace, and are merged before immutable technical labels;
|
||||
- the optional Docker `User` is either an already validated numeric `UID:GID` value or omitted so the image `USER` applies;
|
||||
- only approved DoGaMa networks are attached.
|
||||
|
||||
The canonical plan digest alone is not treated as approval. The agent embeds and
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -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
|
||||
@@ -76,6 +76,29 @@ 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
|
||||
asset integrity, canonicalized deterministically and synchronized into SQLite.
|
||||
|
||||
@@ -14,7 +14,7 @@ SQLite is authoritative for product state. Runtime Docker state is reconciled in
|
||||
| `template_versions` | Immutable validated snapshots | template_id, version, schema_version, canonical_yaml, digest |
|
||||
| `modules` | Module identity and trust | id, source, trust_status, active_version |
|
||||
| `module_versions` | Immutable installed artifacts | module_id, version, manifest, wasm_digest, path, api_range |
|
||||
| `instances` | Desired and observed instance state | id, slug, display_name, template snapshot, revision, lifecycle_state, public_host |
|
||||
| `instances` | Desired and observed instance state | id, derived slug, display_name, template snapshot, revision, lifecycle_state, Docker-user mode/UID/GID, image-tag mode/tag, custom labels, container_config_pending |
|
||||
| `instance_settings` | Typed non-secret template values | instance_id, field_id, value_json |
|
||||
| `instance_secrets` | Encrypted secret values | instance_id, field_id, key_version, nonce, ciphertext |
|
||||
| `instance_ports` | Published and private bindings | instance_id, port_id, host_ip, host_port, container_port, protocol |
|
||||
@@ -36,6 +36,9 @@ SQLite is authoritative for product state. Runtime Docker state is reconciled in
|
||||
## Invariants
|
||||
|
||||
- IDs are opaque and stable; slugs are unique but mutable only through a controlled rename.
|
||||
- Instance slugs are derived from display names, transliterated to lowercase ASCII and recalculated on rename; they are never canonical identifiers.
|
||||
- The Docker-user selection is immutable after creation. Custom UID/GID values exist only for `custom`; DoGaMa never recursively changes file ownership.
|
||||
- Desired container configuration is stored separately from the applied `plan_digest`; `container_config_pending` covers any replacement-requiring change without feature-specific flags.
|
||||
- Released template and module versions are immutable. Editing creates a new version or an independent local copy.
|
||||
- An instance pins a template snapshot and module version; catalog changes do not mutate it silently.
|
||||
- There is at most one active mutating operation per instance.
|
||||
|
||||
@@ -49,6 +49,10 @@ The configurable failure policy is `abort_stop`, `stop_without_backup` or `force
|
||||
|
||||
Maintenance mode blocks ordinary user starts and shows an administrator message while preserving manager/admin access. Settings specify `immediate` or `restart_required`; pending restart changes are applied together through a controlled container replacement. Keep a small redacted configuration history.
|
||||
|
||||
Docker label and image-tag changes use the same generic desired-versus-applied mechanism. `immediate` stops and replaces the container, restores its prior running/stopped intent and preserves every bind-mounted data path. `next_start` sets `container_config_pending`; the next explicit start pulls the desired image, replaces the container, clears the flag and starts it. A stopped instance remains stopped during immediate replacement.
|
||||
|
||||
The Docker user is selected at creation (`dogama`, `custom`, or image-defined) and is never editable afterward because changing it could invalidate persistent-file permissions. Administrators must use backup, new-instance creation and restore to change ownership deliberately; DoGaMa never performs automatic recursive `chown`.
|
||||
|
||||
## Crash-loop protection
|
||||
|
||||
Track unexpected exits. Default circuit breaker: five restarts within ten minutes disables automatic restart and moves the instance to error. Manual administrator action after diagnosis resets it. Scheduled jobs do not fight this state.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -31,18 +31,44 @@ The template states destination mount, ordering, restart requirement, dependency
|
||||
|
||||
DoGaMa clearly labels unofficial mod support and never assumes a server update is compatible with installed mods.
|
||||
|
||||
The V1 implementation accepts only Steam Workshop numeric item IDs when the pinned template explicitly declares that provider. It persists a normalized declarative list and rejects provider commands, scripts, arbitrary URLs and changes for templates without mod support.
|
||||
|
||||
## Configuration application
|
||||
|
||||
Each template field declares `apply: immediate` or `restart_required`. Secret fields are write-only. Validate types, ranges, patterns and conflicts on both client and server. A preview lists pending changes and whether container replacement or game restart is needed.
|
||||
|
||||
Keep the last 10 redacted configuration revisions by default. Rollback revalidates the old revision against the pinned template/module versions before applying it.
|
||||
|
||||
Every desired container or mod change creates a revision, listed newest first. Rollback never restores secrets, never changes the immutable Docker user and may be immediate or deferred until the next explicit start.
|
||||
|
||||
## Updates
|
||||
|
||||
Image updates are digest-aware. A mutable tag alone is never treated as proof that nothing changed. The UI shows current and candidate references, template release notes if available, mod warnings and whether a backup will run.
|
||||
|
||||
The full update sequence and rollback behavior are normative in `docs/domain/instance-lifecycle.md`. Managers may trigger only updates allowed by global/instance policy; administrators choose channels and may pin a digest. Automatic updates remain disabled by default.
|
||||
|
||||
The V1 API requires an explicit candidate tag and SHA-256 image digest plus confirmation. A required `pre_update` backup must finish before replacement. Readiness is bounded by the template timeout; failed replacement, start or readiness restores the prior image/configuration plan when rollback is enabled, without restoring player data.
|
||||
|
||||
## Game-container labels, users and tags
|
||||
|
||||
Administrators can define global labels for game-server containers and instance-specific overrides, one `key=value` per line. Empty lines are ignored and only the first `=` separates the key. Instance labels override global labels; DoGaMa's technical labels always win. Both `dogama.*` and `io.dogama.*` are reserved.
|
||||
|
||||
Values support only `{{game.name}}`, `{{game.id}}`, `{{game.icon_url}}`, `{{instance.name}}`, `{{instance.id}}`, `{{instance.slug}}` and `{{server.name}}`. Unknown or malformed variables fail validation; this is substitution, not a general template language. For example:
|
||||
|
||||
```text
|
||||
glance.name={{instance.name}}
|
||||
glance.icon={{game.icon_url}}
|
||||
glance.parent=DoGaMa
|
||||
```
|
||||
|
||||
`{{game.icon_url}}` resolves to the unauthenticated, read-only `/public/game-icons/{game-id}` route. The route serves only embedded reviewed raster content with an explicit MIME type and cache policy; it is not a public catalog or administration API.
|
||||
|
||||
At creation, the Docker user is either the DoGaMa process UID/GID (default), an explicitly validated numeric UID/GID, or omitted to use the image-defined user. An image without `USER` may therefore run as root. The selection is immutable after creation.
|
||||
|
||||
The template's declared tag is the `tracked` default. An administrator may instead select a syntactically validated `pinned` tag and later return to tracked mode. Pinned means an explicitly selected mutable tag, not a digest: publishers can republish the same tag. Manual SHA-256 digest management is outside this milestone.
|
||||
|
||||
Label and tag changes can apply immediately or at the next start. Immediate application disconnects players and recreates only the container; bind-mounted persistent data remains. Deferred application uses the generic `container_config_pending` state.
|
||||
|
||||
## Template and module updates
|
||||
|
||||
- Updating a catalog template creates a new immutable version; instances remain pinned.
|
||||
@@ -50,4 +76,3 @@ The full update sequence and rollback behavior are normative in `docs/domain/ins
|
||||
- Local copied templates are independent and are never overwritten by their origin.
|
||||
- Module packages update independently and require compatibility plus connection tests before activation.
|
||||
- Rollback keeps the prior template snapshot, module binary and container plan available until the new combination is verified.
|
||||
|
||||
|
||||
@@ -10,6 +10,14 @@ Administrators configure channels entirely in the UI:
|
||||
|
||||
Channel secrets are encrypted and write-only. A test action sends a clearly marked test message and reports a redacted result.
|
||||
|
||||
The main application reads the 32-byte authenticated-encryption key from
|
||||
`DOGAMA_MASTER_KEY_FILE`. Without that external key, audit remains available
|
||||
but channel configuration and delivery are disabled. Generic and Discord
|
||||
webhooks require HTTPS; resolution, redirects and every resolved address reject
|
||||
loopback, private, link-local, multicast and unspecified networks. Generic
|
||||
webhooks carry `X-DoGaMa-Event-ID`, `X-DoGaMa-Timestamp` and, when a signing
|
||||
secret is configured, an HMAC-SHA256 `X-DoGaMa-Signature`.
|
||||
|
||||
## Events and filtering
|
||||
|
||||
Suggested configurable events:
|
||||
@@ -58,5 +66,8 @@ Entries are compact and use allow-listed structured summaries. Player IDs may be
|
||||
|
||||
Audit retention defaults to 30 days and is globally administrator-configurable. An optional maximum count prevents unbounded growth. Purge runs daily and records one aggregate audit event, not an event per deleted row. Unlimited retention requires an explicit warning and displays database usage. Administrators may manually purge by date with confirmation.
|
||||
|
||||
Technical application logs go to stdout/stderr and use Docker log rotation. Their level and retention are separate from SQLite audit policy.
|
||||
The V1 bounds are 0–3650 retention days and 0–1,000,000 entries; zero means
|
||||
unlimited. The viewer returns at most 200 entries per request and supports time,
|
||||
actor, instance, action and outcome filters through the administration API.
|
||||
|
||||
Technical application logs go to stdout/stderr and use Docker log rotation. Their level and retention are separate from SQLite audit policy.
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
| CSRF/session theft | Secure HttpOnly SameSite cookies, CSRF token, TLS guidance, session rotation/revocation and idle/absolute expiry |
|
||||
| Password attack | Modern password hashing, rate limits, backoff, generic errors, repeated-failure audit/notification |
|
||||
| Supply-chain substitution | Immutable version snapshots, checksums, optional signatures/trust labels, digest-pinned images, controlled activation |
|
||||
| Label/template injection | Structured key/value parsing, reserved namespaces, explicit substitution allowlist, no arbitrary template execution |
|
||||
| Destructive mistake | Preview, recent authentication, typed-name confirmation, pre-restore/update backups and recoverable workflows |
|
||||
| Resource exhaustion | Upload/extraction limits, job concurrency, per-instance locks, Docker limits, disk checks, notification/module bounds |
|
||||
| Replay/race | Signed nonce/timestamp agent calls, idempotency keys, optimistic revisions and durable operation phases |
|
||||
|
||||
@@ -54,5 +54,9 @@ Restore shows overwritten data, safety-backup behavior and server downtime. It r
|
||||
|
||||
Global screens cover users, public address, approved storage roots (displayed, bootstrap-controlled where appropriate), catalog/modules, notification channels, audit retention/default 30 days, upload limits, disk thresholds and safety defaults.
|
||||
|
||||
The game-container label editor is a multiline `key=value` field with one label per line, the complete allowed-variable list, and explicit `apply immediately` versus `apply on next start` choices. Immediate application confirms that affected containers stop and are recreated, connected players disconnect, persistent data remains, and displays affected/running counts when known.
|
||||
|
||||
Instance creation includes Docker-user mode, conditional custom UID/GID, tracked/pinned image tag and optional labels. Existing-instance advanced configuration displays the Docker user read-only with the backup/new-instance/restore migration explanation, permits label and tag changes with the same application choices, and visibly reports `container_config_pending`.
|
||||
|
||||
The audit viewer is compact and filterable by time, actor, instance, action and outcome. It is not a raw log console.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -141,6 +141,7 @@ func (d *dockerRuntime) Create(ctx context.Context, plan agentwire.DeploymentPla
|
||||
pidsLimit := int64(512)
|
||||
payload := struct {
|
||||
Image string `json:"Image"`
|
||||
User string `json:"User,omitempty"`
|
||||
Entrypoint []string `json:"Entrypoint,omitempty"`
|
||||
Cmd []string `json:"Cmd,omitempty"`
|
||||
Labels map[string]string `json:"Labels"`
|
||||
@@ -157,12 +158,12 @@ func (d *dockerRuntime) Create(ctx context.Context, plan agentwire.DeploymentPla
|
||||
RestartPolicy map[string]string `json:"RestartPolicy"`
|
||||
} `json:"HostConfig"`
|
||||
}{
|
||||
Image: plan.Image, Entrypoint: plan.Entrypoint, Cmd: plan.Arguments,
|
||||
Labels: map[string]string{
|
||||
Image: plan.Image, User: plan.User, Entrypoint: plan.Entrypoint, Cmd: plan.Arguments,
|
||||
Labels: mergeDockerLabels(plan.Labels, map[string]string{
|
||||
"io.dogama.managed": "true", "io.dogama.instance-id": plan.InstanceID,
|
||||
"io.dogama.template-id": plan.TemplateID, "io.dogama.template-version": plan.TemplateVersion,
|
||||
"io.dogama.plan-digest": plan.PlanDigest,
|
||||
},
|
||||
}),
|
||||
ExposedPorts: exposed,
|
||||
}
|
||||
payload.HostConfig.Binds = binds
|
||||
@@ -190,6 +191,17 @@ func (d *dockerRuntime) Create(ctx context.Context, plan agentwire.DeploymentPla
|
||||
return created.ID, nil
|
||||
}
|
||||
|
||||
func mergeDockerLabels(custom, technical map[string]string) map[string]string {
|
||||
result := make(map[string]string, len(custom)+len(technical))
|
||||
for key, value := range custom {
|
||||
result[key] = value
|
||||
}
|
||||
for key, value := range technical {
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (d *dockerRuntime) cleanupPartialCreate(ctx context.Context, name string, plan agentwire.DeploymentPlan) {
|
||||
inspection, err := d.Inspect(ctx, name)
|
||||
if err != nil || inspection.Labels["io.dogama.managed"] != "true" || inspection.Labels["io.dogama.instance-id"] != plan.InstanceID || inspection.Labels["io.dogama.plan-digest"] != plan.PlanDigest {
|
||||
|
||||
@@ -69,7 +69,7 @@ func TestDockerRuntimeCreatesFixedSecurityBaseline(t *testing.T) {
|
||||
plan := agentwire.DeploymentPlan{
|
||||
InstanceID: "abcdefghijklmnopqrstuvwx", TemplateID: "palworld-official", TemplateVersion: "1.0.0", PlanDigest: strings.Repeat("a", 64), Image: "example.invalid/game:1",
|
||||
Ports: []agentwire.PlanPort{{ID: "game", Protocol: "udp", ContainerPort: 8211, HostPort: 38211, Publish: true}},
|
||||
Mounts: []agentwire.PlanMount{{ID: "saved", HostPath: "/srv/games/saved", ContainerPath: "/game/saved"}}, Resources: agentwire.PlanResource{CPUCores: 2, MemoryMB: 1024, StorageGB: 10},
|
||||
Mounts: []agentwire.PlanMount{{ID: "saved", HostPath: "/srv/games/saved", ContainerPath: "/game/saved"}}, Resources: agentwire.PlanResource{CPUCores: 2, MemoryMB: 1024, StorageGB: 10}, Labels: map[string]string{"dashboard.name": "Summer"}, User: "1000:1001",
|
||||
}
|
||||
if err := runtime.CheckPorts(context.Background(), plan.Ports); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -82,6 +82,7 @@ func TestDockerRuntimeCreatesFixedSecurityBaseline(t *testing.T) {
|
||||
t.Fatalf("container ID = %q", id)
|
||||
}
|
||||
var payload struct {
|
||||
User string `json:"User"`
|
||||
Labels map[string]string `json:"Labels"`
|
||||
HostConfig struct {
|
||||
NetworkMode string `json:"NetworkMode"`
|
||||
@@ -100,6 +101,9 @@ func TestDockerRuntimeCreatesFixedSecurityBaseline(t *testing.T) {
|
||||
if payload.Labels["io.dogama.instance-id"] != plan.InstanceID || payload.Labels["io.dogama.plan-digest"] != plan.PlanDigest {
|
||||
t.Fatalf("binding labels = %#v", payload.Labels)
|
||||
}
|
||||
if payload.Labels["dashboard.name"] != "Summer" || payload.User != "1000:1001" {
|
||||
t.Fatalf("custom Docker configuration = %#v user=%q", payload.Labels, payload.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerPingerDoesNotExposeConnectionDetails(t *testing.T) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io/fs"
|
||||
"path"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||
@@ -71,7 +72,8 @@ func (p *PlanPolicy) Validate(plan agentwire.DeploymentPlan) error {
|
||||
return errors.New("unknown template snapshot")
|
||||
}
|
||||
template := snapshot.Template
|
||||
if plan.Image != template.Container.Image+":"+template.Container.Tag || !reflect.DeepEqual(plan.Entrypoint, template.Container.Entrypoint) || !reflect.DeepEqual(plan.Arguments, template.Container.Arguments) || plan.StopTimeoutSeconds != template.Container.StopTimeoutSeconds {
|
||||
imagePrefix := template.Container.Image + ":"
|
||||
if !strings.HasPrefix(plan.Image, imagePrefix) || !reflect.DeepEqual(plan.Entrypoint, template.Container.Entrypoint) || !reflect.DeepEqual(plan.Arguments, template.Container.Arguments) || plan.StopTimeoutSeconds != template.Container.StopTimeoutSeconds {
|
||||
return errors.New("container plan differs from template")
|
||||
}
|
||||
if plan.Resources.CPUCores < template.Requirements.Minimum.CPUCores || plan.Resources.MemoryMB < template.Requirements.Minimum.MemoryMB || plan.Resources.StorageGB < template.Requirements.Minimum.StorageGB {
|
||||
|
||||
@@ -115,6 +115,20 @@ func (r *Registry) Remove(instanceID string) error {
|
||||
return r.saveLocked(instances)
|
||||
}
|
||||
|
||||
// Replace atomically persists a new binding for an already registered instance.
|
||||
func (r *Registry) Replace(entry RegisteredInstance) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
instances := append([]RegisteredInstance(nil), r.instances...)
|
||||
for index := range instances {
|
||||
if instances[index].InstanceID == entry.InstanceID {
|
||||
instances[index] = entry
|
||||
return r.saveLocked(instances)
|
||||
}
|
||||
}
|
||||
return errors.New("instance registration not found")
|
||||
}
|
||||
|
||||
func (r *Registry) load() error {
|
||||
info, err := os.Lstat(r.path)
|
||||
if err != nil {
|
||||
|
||||
@@ -52,6 +52,7 @@ func NewHandlerWithDiskChecker(authenticator *Authenticator, paths *PathPolicy,
|
||||
mux.HandleFunc("GET /v1/instances", server.listInstances)
|
||||
mux.HandleFunc("POST /v1/check-ports", server.checkPorts)
|
||||
mux.HandleFunc("POST /v1/instances", server.createInstance)
|
||||
mux.HandleFunc("PUT /v1/instances/{id}", server.replaceInstance)
|
||||
mux.HandleFunc("GET /v1/instances/{id}", server.inspectInstance)
|
||||
mux.HandleFunc("GET /v1/instances/{id}/stats", server.instanceStats)
|
||||
mux.HandleFunc("POST /v1/instances/{id}/start", server.startInstance)
|
||||
@@ -190,6 +191,60 @@ func (s *service) createInstance(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, agentwire.InstanceState{InstanceID: plan.InstanceID, ContainerID: containerID, PlanDigest: plan.PlanDigest, Health: "stopped"})
|
||||
}
|
||||
|
||||
func (s *service) replaceInstance(w http.ResponseWriter, r *http.Request) {
|
||||
var plan agentwire.DeploymentPlan
|
||||
id := r.PathValue("id")
|
||||
if decodeJSON(r.Body, &plan) != nil || plan.InstanceID != id || s.plans.Validate(plan) != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "invalid_plan", "The replacement plan is invalid.")
|
||||
return
|
||||
}
|
||||
entry, ok := s.registry.Get(id)
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusNotFound, "registration_not_found", "The instance is not registered.")
|
||||
return
|
||||
}
|
||||
oldState, err := s.boundState(r.Context(), entry)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusConflict, "registration_mismatch", "The registered container binding is invalid.")
|
||||
return
|
||||
}
|
||||
for index := range plan.Mounts {
|
||||
canonical, pathErr := s.paths.Resolve(plan.Mounts[index].HostPath)
|
||||
if pathErr != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "path_not_allowed", "A deployment path is not allowed.")
|
||||
return
|
||||
}
|
||||
plan.Mounts[index].HostPath = canonical
|
||||
}
|
||||
assets, err := s.prepareAssets(plan)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "asset_prepare_failed", "Approved template assets could not be prepared.")
|
||||
return
|
||||
}
|
||||
if oldState.Running {
|
||||
if err := s.docker.Stop(r.Context(), entry.ContainerID, plan.StopTimeoutSeconds); err != nil {
|
||||
writeProblem(w, http.StatusBadGateway, "container_stop_failed", "The previous container could not be stopped.")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.docker.Delete(r.Context(), entry.ContainerID); err != nil {
|
||||
writeProblem(w, http.StatusBadGateway, "container_delete_failed", "The previous container could not be removed.")
|
||||
return
|
||||
}
|
||||
containerID, err := s.docker.Create(r.Context(), plan, assets)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusBadGateway, "container_replace_failed", "The replacement container could not be created; persistent data was preserved.")
|
||||
return
|
||||
}
|
||||
replacement := RegisteredInstance{InstanceID: id, ContainerID: containerID, PlanDigest: plan.PlanDigest}
|
||||
if err := s.registry.Replace(replacement); err != nil {
|
||||
_ = s.docker.Delete(r.Context(), containerID)
|
||||
writeProblem(w, http.StatusInternalServerError, "registration_failed", "The replacement registration could not be persisted.")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, agentwire.InstanceState{InstanceID: id, ContainerID: containerID, PlanDigest: plan.PlanDigest, Health: "stopped"})
|
||||
}
|
||||
|
||||
func (s *service) inspectInstance(w http.ResponseWriter, r *http.Request) {
|
||||
entry, ok := s.registration(w, r.PathValue("id"))
|
||||
if !ok {
|
||||
|
||||
@@ -121,6 +121,12 @@ func (c *Client) CreateInstance(ctx context.Context, plan agentwire.DeploymentPl
|
||||
return state, err
|
||||
}
|
||||
|
||||
func (c *Client) ReplaceInstance(ctx context.Context, plan agentwire.DeploymentPlan) (agentwire.InstanceState, error) {
|
||||
var state agentwire.InstanceState
|
||||
err := c.do(ctx, http.MethodPut, instancePath(plan.InstanceID), plan, &state)
|
||||
return state, err
|
||||
}
|
||||
|
||||
func (c *Client) InspectInstance(ctx context.Context, instanceID string) (agentwire.InstanceState, error) {
|
||||
var state agentwire.InstanceState
|
||||
err := c.do(ctx, http.MethodGet, instancePath(instanceID), nil, &state)
|
||||
|
||||
+36
-13
@@ -9,6 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -22,19 +23,21 @@ var (
|
||||
)
|
||||
|
||||
type DeploymentPlan struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
TemplateID string `json:"template_id"`
|
||||
TemplateVersion string `json:"template_version"`
|
||||
TemplateDigest string `json:"template_digest"`
|
||||
Image string `json:"image"`
|
||||
Entrypoint []string `json:"entrypoint,omitempty"`
|
||||
Arguments []string `json:"arguments,omitempty"`
|
||||
Ports []PlanPort `json:"ports"`
|
||||
Mounts []PlanMount `json:"mounts"`
|
||||
Resources PlanResource `json:"resources"`
|
||||
StopTimeoutSeconds int `json:"stop_timeout_seconds"`
|
||||
PlanDigest string `json:"plan_digest"`
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
TemplateID string `json:"template_id"`
|
||||
TemplateVersion string `json:"template_version"`
|
||||
TemplateDigest string `json:"template_digest"`
|
||||
Image string `json:"image"`
|
||||
Entrypoint []string `json:"entrypoint,omitempty"`
|
||||
Arguments []string `json:"arguments,omitempty"`
|
||||
Ports []PlanPort `json:"ports"`
|
||||
Mounts []PlanMount `json:"mounts"`
|
||||
Resources PlanResource `json:"resources"`
|
||||
StopTimeoutSeconds int `json:"stop_timeout_seconds"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
PlanDigest string `json:"plan_digest"`
|
||||
}
|
||||
|
||||
type PlanPort struct {
|
||||
@@ -88,6 +91,26 @@ func (p DeploymentPlan) Validate() error {
|
||||
if p.StopTimeoutSeconds < 5 || p.StopTimeoutSeconds > 900 || len(p.Ports) > 32 || len(p.Mounts) == 0 || len(p.Mounts) > 16 {
|
||||
return errors.New("invalid deployment plan limits")
|
||||
}
|
||||
if len(p.Labels) > 64 || len(p.User) > 32 {
|
||||
return errors.New("invalid deployment plan container configuration")
|
||||
}
|
||||
for key, value := range p.Labels {
|
||||
lower := strings.ToLower(key)
|
||||
if key == "" || len(key) > 255 || len(value) > 4096 || strings.HasPrefix(lower, "dogama.") || strings.HasPrefix(lower, "io.dogama.") {
|
||||
return errors.New("invalid deployment plan label")
|
||||
}
|
||||
}
|
||||
if p.User != "" {
|
||||
parts := strings.Split(p.User, ":")
|
||||
if len(parts) != 2 {
|
||||
return errors.New("invalid deployment plan user")
|
||||
}
|
||||
for _, part := range parts {
|
||||
if _, err := strconv.ParseUint(part, 10, 32); err != nil {
|
||||
return errors.New("invalid deployment plan user")
|
||||
}
|
||||
}
|
||||
}
|
||||
portIDs := make(map[string]struct{}, len(p.Ports))
|
||||
hostPorts := make(map[string]struct{})
|
||||
for _, port := range p.Ports {
|
||||
|
||||
@@ -27,3 +27,19 @@ func TestDeploymentPlanDigestRejectsPrivilegedFieldSubstitution(t *testing.T) {
|
||||
t.Fatal("image substitution preserved a valid binding")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeploymentPlanRejectsReservedLabelsAndInvalidUser(t *testing.T) {
|
||||
plan := DeploymentPlan{SchemaVersion: DeploymentPlanVersion, InstanceID: "abcdefghijklmnopqrstuvwx", TemplateID: "palworld-official", TemplateVersion: "1.0.0", TemplateDigest: strings.Repeat("a", 64), Image: "example.invalid/game:1", Ports: []PlanPort{{ID: "game", Protocol: "udp", ContainerPort: 8211, HostPort: 38211, Publish: true}}, Mounts: []PlanMount{{ID: "saved", HostPath: filepath.Join(string(filepath.Separator), "srv", "games", "saved"), ContainerPath: "/game/saved"}}, Resources: PlanResource{CPUCores: 2, MemoryMB: 1024, StorageGB: 10}, StopTimeoutSeconds: 30, Labels: map[string]string{"dogama.managed": "false"}, User: "1000:1000"}
|
||||
digest, _ := plan.CanonicalDigest()
|
||||
plan.PlanDigest = digest
|
||||
if err := plan.Validate(); err == nil {
|
||||
t.Fatal("reserved label accepted")
|
||||
}
|
||||
plan.Labels = map[string]string{"dashboard.name": "server"}
|
||||
plan.User = "root"
|
||||
digest, _ = plan.CanonicalDigest()
|
||||
plan.PlanDigest = digest
|
||||
if err := plan.Validate(); err == nil {
|
||||
t.Fatal("non-numeric Docker user accepted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// Package audit stores the deliberately small, redacted security audit trail.
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
ActorID string `json:"actor_id"`
|
||||
ActorLabel string `json:"actor_label"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
Action string `json:"action"`
|
||||
Outcome string `json:"outcome"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
Summary map[string]string `json:"summary"`
|
||||
}
|
||||
|
||||
type Filter struct {
|
||||
ActorID, InstanceID, Action, Outcome string
|
||||
Since, Until time.Time
|
||||
Limit int
|
||||
}
|
||||
|
||||
type Policy struct {
|
||||
RetentionDays int `json:"retention_days"`
|
||||
MaximumCount int `json:"maximum_count"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
db *sql.DB
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func New(db *sql.DB) *Service { return &Service{db: db, now: time.Now} }
|
||||
|
||||
var allowedSummaryKeys = map[string]bool{"target_id": true, "target_name": true, "channel_type": true, "event_type": true, "reason_code": true, "deleted_count": true, "before": true, "after": true}
|
||||
|
||||
func (s *Service) Record(ctx context.Context, event Event) error {
|
||||
if event.Action == "" || (event.Outcome != "allowed" && event.Outcome != "denied" && event.Outcome != "failed") {
|
||||
return errors.New("invalid audit event")
|
||||
}
|
||||
clean := map[string]string{}
|
||||
for key, value := range event.Summary {
|
||||
if allowedSummaryKeys[key] && len(value) <= 200 {
|
||||
clean[key] = value
|
||||
}
|
||||
}
|
||||
body, _ := json.Marshal(clean)
|
||||
when := event.OccurredAt
|
||||
if when.IsZero() {
|
||||
when = s.now().UTC()
|
||||
}
|
||||
var actor, instance any
|
||||
if event.ActorID != "" {
|
||||
actor = event.ActorID
|
||||
}
|
||||
if event.InstanceID != "" {
|
||||
instance = event.InstanceID
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO audit_events(id,occurred_at,actor_id,actor_label,instance_id,action,outcome,summary_json) VALUES(?,?,?,?,?,?,?,?)`, randomID(), when.Format(time.RFC3339Nano), actor, event.ActorLabel, instance, event.Action, event.Outcome, string(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("record audit event: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, f Filter) ([]Event, error) {
|
||||
limit := f.Limit
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 100
|
||||
}
|
||||
clauses, args := []string{"1=1"}, []any{}
|
||||
for _, item := range []struct{ column, value string }{{"actor_id", f.ActorID}, {"instance_id", f.InstanceID}, {"action", f.Action}, {"outcome", f.Outcome}} {
|
||||
if item.value != "" {
|
||||
clauses = append(clauses, item.column+"=?")
|
||||
args = append(args, item.value)
|
||||
}
|
||||
}
|
||||
if !f.Since.IsZero() {
|
||||
clauses = append(clauses, "occurred_at>=?")
|
||||
args = append(args, f.Since.UTC().Format(time.RFC3339Nano))
|
||||
}
|
||||
if !f.Until.IsZero() {
|
||||
clauses = append(clauses, "occurred_at<?")
|
||||
args = append(args, f.Until.UTC().Format(time.RFC3339Nano))
|
||||
}
|
||||
args = append(args, limit)
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id,occurred_at,COALESCE(actor_id,''),actor_label,COALESCE(instance_id,''),action,outcome,summary_json FROM audit_events WHERE `+strings.Join(clauses, " AND ")+` ORDER BY occurred_at DESC,id DESC LIMIT ?`, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list audit events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var events []Event
|
||||
for rows.Next() {
|
||||
var e Event
|
||||
var occurred, body string
|
||||
if err := rows.Scan(&e.ID, &occurred, &e.ActorID, &e.ActorLabel, &e.InstanceID, &e.Action, &e.Outcome, &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.OccurredAt, _ = time.Parse(time.RFC3339Nano, occurred)
|
||||
_ = json.Unmarshal([]byte(body), &e.Summary)
|
||||
events = append(events, e)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) Policy(ctx context.Context) (Policy, error) {
|
||||
var body string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT value_json FROM system_settings WHERE key='audit_policy'`).Scan(&body)
|
||||
if err != nil {
|
||||
return Policy{}, err
|
||||
}
|
||||
var p Policy
|
||||
var raw struct {
|
||||
RetentionDays int `json:"retention_days"`
|
||||
MaximumCount int `json:"maximum_count"`
|
||||
}
|
||||
if err = json.Unmarshal([]byte(body), &raw); err != nil {
|
||||
return p, err
|
||||
}
|
||||
p.RetentionDays, p.MaximumCount = raw.RetentionDays, raw.MaximumCount
|
||||
return p, nil
|
||||
}
|
||||
func (s *Service) SetPolicy(ctx context.Context, p Policy) error {
|
||||
if p.RetentionDays < 0 || p.RetentionDays > 3650 || p.MaximumCount < 0 || p.MaximumCount > 1000000 {
|
||||
return errors.New("audit policy is out of bounds")
|
||||
}
|
||||
body, _ := json.Marshal(map[string]int{"retention_days": p.RetentionDays, "maximum_count": p.MaximumCount})
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE system_settings SET value_json=?,revision=revision+1,updated_at=? WHERE key='audit_policy'`, body, s.now().UTC().Format(time.RFC3339Nano))
|
||||
return err
|
||||
}
|
||||
func (s *Service) Purge(ctx context.Context, before time.Time) (int64, error) {
|
||||
if before.IsZero() || before.After(s.now().UTC()) {
|
||||
return 0, errors.New("invalid audit purge boundary")
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, `DELETE FROM audit_events WHERE occurred_at < ?`, before.UTC().Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
func (s *Service) RunRetention(ctx context.Context) (int64, error) {
|
||||
p, err := s.Policy(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var total int64
|
||||
if p.RetentionDays > 0 {
|
||||
n, e := s.Purge(ctx, s.now().UTC().AddDate(0, 0, -p.RetentionDays))
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
total += n
|
||||
}
|
||||
if p.MaximumCount > 0 {
|
||||
result, e := s.db.ExecContext(ctx, `DELETE FROM audit_events WHERE id IN (SELECT id FROM audit_events ORDER BY occurred_at DESC,id DESC LIMIT -1 OFFSET ?)`, p.MaximumCount)
|
||||
if e != nil {
|
||||
return total, e
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
total += n
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
func randomID() string {
|
||||
b := make([]byte, 18)
|
||||
_, _ = rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package audit_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
|
||||
)
|
||||
|
||||
func TestRecordFiltersSummaryAndRetention(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
service := audit.New(db)
|
||||
old := time.Now().UTC().AddDate(0, 0, -40)
|
||||
if err := service.Record(ctx, audit.Event{OccurredAt: old, ActorLabel: "admin", Action: "instance.update", Outcome: "allowed", Summary: map[string]string{"target_name": "Palworld", "secret": "must-not-persist"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
events, err := service.List(ctx, audit.Filter{Action: "instance.update"})
|
||||
if err != nil || len(events) != 1 {
|
||||
t.Fatalf("events=%#v err=%v", events, err)
|
||||
}
|
||||
if events[0].Summary["target_name"] != "Palworld" || events[0].Summary["secret"] != "" {
|
||||
t.Fatalf("summary was not allow-listed: %#v", events[0].Summary)
|
||||
}
|
||||
if err := service.SetPolicy(ctx, audit.Policy{RetentionDays: 30, MaximumCount: 100}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deleted, err := service.RunRetention(ctx)
|
||||
if err != nil || deleted != 1 {
|
||||
t.Fatalf("deleted=%d err=%v", deleted, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyAndPurgeBounds(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
service := audit.New(db)
|
||||
if err := service.SetPolicy(ctx, audit.Policy{RetentionDays: -1}); err == nil {
|
||||
t.Fatal("negative retention accepted")
|
||||
}
|
||||
if _, err := service.Purge(ctx, time.Now().Add(time.Hour)); err == nil {
|
||||
t.Fatal("future purge accepted")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ConfigurationRepository interface {
|
||||
GetGlobalLabels(context.Context) (map[string]string, error)
|
||||
SetGlobalLabels(context.Context, map[string]string, bool) (affected int, running int, err error)
|
||||
SaveInstanceConfiguration(context.Context, string, Preview, bool, string, string) error
|
||||
ClearContainerConfigPending(context.Context, string, string) error
|
||||
ListConfigurationRevisions(context.Context, string) ([]ConfigurationRevision, error)
|
||||
GetConfigurationRevision(context.Context, string, int) (ConfigurationRevision, error)
|
||||
}
|
||||
|
||||
type ConfigurationRevision struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
Revision int `json:"revision"`
|
||||
Snapshot Preview `json:"snapshot"`
|
||||
Reason string `json:"reason"`
|
||||
CreatedBy string `json:"created_by,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type ConfigurationStatus struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
ContainerConfigPending bool `json:"container_config_pending"`
|
||||
Preview Preview `json:"configuration"`
|
||||
}
|
||||
|
||||
func (s *LifecycleService) Configure(ctx context.Context, instanceID, labels string, tag ImageTag, immediate bool) (OperationResult, error) {
|
||||
repository, ok := s.repository.(ConfigurationRepository)
|
||||
if !ok {
|
||||
return OperationResult{}, errors.New("container configuration is unavailable")
|
||||
}
|
||||
return s.exclusive(instanceID, func() (OperationResult, error) {
|
||||
current, err := s.repository.GetInstance(ctx, instanceID)
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
parsed, err := ParseLabels(labels)
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
defaultTag := current.Preview.TemplateDefaultTag
|
||||
if defaultTag == "" {
|
||||
parts := strings.Split(current.Preview.Image, ":")
|
||||
defaultTag = parts[len(parts)-1]
|
||||
}
|
||||
validated, err := ValidateImageTag(tag, defaultTag)
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
preview := current.Preview
|
||||
preview.CustomLabels, preview.ImageTag = parsed, validated
|
||||
base := imageRepository(preview.Image)
|
||||
preview.Image = base + ":" + validated.Tag
|
||||
if err := repository.SaveInstanceConfiguration(ctx, instanceID, preview, !immediate, "container_configuration", ""); err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
if !immediate || current.ContainerID == "" {
|
||||
updated, _ := s.repository.GetInstance(ctx, instanceID)
|
||||
return resultFrom(updated, ""), nil
|
||||
}
|
||||
agent, ok := s.agent.(replacementAgent)
|
||||
if !ok {
|
||||
return OperationResult{}, errors.New("container replacement is unavailable")
|
||||
}
|
||||
operationID, err := operationToken()
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
current, err = s.repository.BeginOperation(ctx, operationID, instanceID, "restart", "update")
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
plan, err := preview.DeploymentPlan(instanceID)
|
||||
if err != nil {
|
||||
return s.fail(ctx, operationID, instanceID, "invalid_plan", err)
|
||||
}
|
||||
state, err := agent.ReplaceInstance(ctx, plan)
|
||||
if err != nil {
|
||||
return s.fail(ctx, operationID, instanceID, "agent_replace_failed", err)
|
||||
}
|
||||
if current.DesiredRunning {
|
||||
state, err = s.agent.StartInstance(ctx, instanceID)
|
||||
if err != nil {
|
||||
return s.fail(ctx, operationID, instanceID, "agent_start_failed", err)
|
||||
}
|
||||
}
|
||||
lifecycle, observed := stateToLifecycle(state)
|
||||
if err := s.repository.FinishOperation(ctx, operationID, lifecycle, observed, state.ContainerID, plan.PlanDigest, current.DesiredRunning, ""); err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
if err := repository.ClearContainerConfigPending(ctx, instanceID, plan.PlanDigest); err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
return OperationResult{OperationID: operationID, InstanceID: instanceID, State: lifecycle, Observed: observed, ContainerID: state.ContainerID, AgentState: state}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func imageRepository(reference string) string {
|
||||
if at := strings.Index(reference, "@"); at >= 0 {
|
||||
reference = reference[:at]
|
||||
}
|
||||
if colon := strings.LastIndex(reference, ":"); colon > strings.LastIndex(reference, "/") {
|
||||
return reference[:colon]
|
||||
}
|
||||
return reference
|
||||
}
|
||||
|
||||
var workshopIDPattern = regexp.MustCompile(`^[1-9][0-9]{0,19}$`)
|
||||
|
||||
func (s *LifecycleService) ConfigureMods(ctx context.Context, instanceID string, items []string, immediate bool, actorID string) (OperationResult, error) {
|
||||
return s.exclusive(instanceID, func() (OperationResult, error) {
|
||||
current, err := s.repository.GetInstance(ctx, instanceID)
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
if !current.Preview.Mods.Supported {
|
||||
return OperationResult{}, errors.New("template does not support mods")
|
||||
}
|
||||
if current.Preview.Mods.Provider != "steam_workshop" {
|
||||
return OperationResult{}, errors.New("mod provider is not implemented safely")
|
||||
}
|
||||
if len(items) > 256 {
|
||||
return OperationResult{}, errors.New("too many mods")
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for _, id := range items {
|
||||
if !workshopIDPattern.MatchString(id) {
|
||||
return OperationResult{}, errors.New("invalid Steam Workshop item ID")
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
return OperationResult{}, errors.New("duplicate mod item ID")
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
items = append([]string(nil), items...)
|
||||
preview := current.Preview
|
||||
preview.Mods.Items = items
|
||||
repository := s.repository.(ConfigurationRepository)
|
||||
if err := repository.SaveInstanceConfiguration(ctx, instanceID, preview, !immediate, "mods", actorID); err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
if !immediate || current.ContainerID == "" {
|
||||
updated, _ := s.repository.GetInstance(ctx, instanceID)
|
||||
return resultFrom(updated, ""), nil
|
||||
}
|
||||
return s.replaceConfigured(ctx, current, preview, "restart")
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LifecycleService) RollbackConfiguration(ctx context.Context, instanceID string, revision int, immediate bool, actorID string) (OperationResult, error) {
|
||||
return s.exclusive(instanceID, func() (OperationResult, error) {
|
||||
repository := s.repository.(ConfigurationRepository)
|
||||
current, err := s.repository.GetInstance(ctx, instanceID)
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
old, err := repository.GetConfigurationRevision(ctx, instanceID, revision)
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
if old.Snapshot.Template != current.Preview.Template {
|
||||
return OperationResult{}, errors.New("revision template no longer matches pinned template")
|
||||
}
|
||||
old.Snapshot.DockerUser = current.Preview.DockerUser
|
||||
old.Snapshot.DockerUserValue = current.Preview.DockerUserValue
|
||||
if _, err := ValidateImageTag(old.Snapshot.ImageTag, old.Snapshot.TemplateDefaultTag); err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
if _, err := old.Snapshot.DeploymentPlan(instanceID); err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
if err := repository.SaveInstanceConfiguration(ctx, instanceID, old.Snapshot, !immediate, "rollback", actorID); err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
if !immediate || current.ContainerID == "" {
|
||||
updated, _ := s.repository.GetInstance(ctx, instanceID)
|
||||
return resultFrom(updated, ""), nil
|
||||
}
|
||||
return s.replaceConfigured(ctx, current, old.Snapshot, "restart")
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LifecycleService) replaceConfigured(ctx context.Context, current StoredInstance, preview Preview, kind string) (OperationResult, error) {
|
||||
agent, ok := s.agent.(replacementAgent)
|
||||
if !ok {
|
||||
return OperationResult{}, errors.New("container replacement is unavailable")
|
||||
}
|
||||
operationID, err := operationToken()
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
current, err = s.repository.BeginOperation(ctx, operationID, current.ID, kind, "update")
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
plan, err := preview.DeploymentPlan(current.ID)
|
||||
if err != nil {
|
||||
return s.fail(ctx, operationID, current.ID, "invalid_plan", err)
|
||||
}
|
||||
state, err := agent.ReplaceInstance(ctx, plan)
|
||||
if err != nil {
|
||||
return s.fail(ctx, operationID, current.ID, "agent_replace_failed", err)
|
||||
}
|
||||
if current.DesiredRunning {
|
||||
state, err = s.agent.StartInstance(ctx, current.ID)
|
||||
}
|
||||
if err != nil {
|
||||
return s.fail(ctx, operationID, current.ID, "agent_start_failed", err)
|
||||
}
|
||||
lifecycle, observed := stateToLifecycle(state)
|
||||
if err := s.repository.FinishOperation(ctx, operationID, lifecycle, observed, state.ContainerID, plan.PlanDigest, current.DesiredRunning, ""); err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
if err := s.repository.(ConfigurationRepository).ClearContainerConfigPending(ctx, current.ID, plan.PlanDigest); err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
return OperationResult{OperationID: operationID, InstanceID: current.ID, State: lifecycle, Observed: observed, ContainerID: state.ContainerID, AgentState: state}, nil
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
const (
|
||||
DockerUserDoGaMa = "dogama"
|
||||
DockerUserCustom = "custom"
|
||||
DockerUserImage = "image"
|
||||
ImageTagTracked = "tracked"
|
||||
ImageTagPinned = "pinned"
|
||||
)
|
||||
|
||||
var (
|
||||
labelKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*(?:/[A-Za-z0-9][A-Za-z0-9_.-]*)?$`)
|
||||
tagPattern = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$`)
|
||||
variablePattern = regexp.MustCompile(`\{\{([^{}]+)\}\}`)
|
||||
)
|
||||
|
||||
var AllowedLabelVariables = []string{
|
||||
"game.name", "game.id", "game.icon_url", "instance.name", "instance.id", "instance.slug", "server.name",
|
||||
}
|
||||
|
||||
type DockerUser struct {
|
||||
Mode string `json:"mode"`
|
||||
UID *uint32 `json:"uid,omitempty"`
|
||||
GID *uint32 `json:"gid,omitempty"`
|
||||
}
|
||||
|
||||
type ImageTag struct {
|
||||
Mode string `json:"mode"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
|
||||
type ContainerConfiguration struct {
|
||||
Labels map[string]string `json:"labels"`
|
||||
DockerUser DockerUser `json:"docker_user"`
|
||||
ImageTag ImageTag `json:"image_tag"`
|
||||
}
|
||||
|
||||
type LabelContext struct {
|
||||
GameName, GameID, GameIconURL string
|
||||
InstanceName, InstanceID, InstanceSlug string
|
||||
ServerName string
|
||||
}
|
||||
|
||||
func ParseLabels(input string) (map[string]string, error) {
|
||||
labels := make(map[string]string)
|
||||
for index, raw := range strings.Split(strings.ReplaceAll(input, "\r\n", "\n"), "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
separator := strings.IndexByte(line, '=')
|
||||
if separator < 1 {
|
||||
return nil, fmt.Errorf("label line %d must use key=value", index+1)
|
||||
}
|
||||
key := strings.TrimSpace(line[:separator])
|
||||
if !labelKeyPattern.MatchString(key) {
|
||||
return nil, fmt.Errorf("label line %d has an invalid key", index+1)
|
||||
}
|
||||
lower := strings.ToLower(key)
|
||||
if strings.HasPrefix(lower, "dogama.") || strings.HasPrefix(lower, "io.dogama.") {
|
||||
return nil, fmt.Errorf("label line %d uses a reserved DoGaMa key", index+1)
|
||||
}
|
||||
value := line[separator+1:]
|
||||
if err := ValidateLabelTemplate(value); err != nil {
|
||||
return nil, fmt.Errorf("label line %d: %w", index+1, err)
|
||||
}
|
||||
labels[key] = value
|
||||
}
|
||||
return labels, nil
|
||||
}
|
||||
|
||||
func ValidateLabelTemplate(value string) error {
|
||||
allowed := make(map[string]bool, len(AllowedLabelVariables))
|
||||
for _, variable := range AllowedLabelVariables {
|
||||
allowed[variable] = true
|
||||
}
|
||||
for _, match := range variablePattern.FindAllStringSubmatch(value, -1) {
|
||||
if !allowed[match[1]] {
|
||||
return fmt.Errorf("unknown label variable %q", match[1])
|
||||
}
|
||||
}
|
||||
withoutKnown := variablePattern.ReplaceAllString(value, "")
|
||||
if strings.Contains(withoutKnown, "{{") || strings.Contains(withoutKnown, "}}") {
|
||||
return errors.New("invalid label variable syntax")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ResolveLabels(labels map[string]string, context LabelContext) (map[string]string, error) {
|
||||
values := map[string]string{
|
||||
"game.name": context.GameName, "game.id": context.GameID, "game.icon_url": context.GameIconURL,
|
||||
"instance.name": context.InstanceName, "instance.id": context.InstanceID, "instance.slug": context.InstanceSlug,
|
||||
"server.name": context.ServerName,
|
||||
}
|
||||
result := make(map[string]string, len(labels))
|
||||
for key, value := range labels {
|
||||
if err := ValidateLabelTemplate(value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[key] = variablePattern.ReplaceAllStringFunc(value, func(token string) string {
|
||||
name := token[2 : len(token)-2]
|
||||
return values[name]
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func MergeLabels(technical, global, local map[string]string) map[string]string {
|
||||
result := make(map[string]string, len(technical)+len(global)+len(local))
|
||||
for key, value := range global {
|
||||
result[key] = value
|
||||
}
|
||||
for key, value := range local {
|
||||
result[key] = value
|
||||
}
|
||||
for key, value := range technical {
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func FormatLabels(labels map[string]string) string {
|
||||
keys := make([]string, 0, len(labels))
|
||||
for key := range labels {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
lines := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
lines = append(lines, key+"="+labels[key])
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func Slugify(value string) string {
|
||||
decomposed := norm.NFD.String(strings.ToLower(strings.TrimSpace(value)))
|
||||
var builder strings.Builder
|
||||
separator := false
|
||||
for _, r := range decomposed {
|
||||
if unicode.Is(unicode.Mn, r) {
|
||||
continue
|
||||
}
|
||||
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
|
||||
if separator && builder.Len() > 0 {
|
||||
builder.WriteByte('-')
|
||||
}
|
||||
builder.WriteRune(r)
|
||||
separator = false
|
||||
} else {
|
||||
separator = true
|
||||
}
|
||||
}
|
||||
return strings.Trim(builder.String(), "-")
|
||||
}
|
||||
|
||||
func ValidateDockerUser(user DockerUser) error {
|
||||
switch user.Mode {
|
||||
case DockerUserDoGaMa, DockerUserImage:
|
||||
if user.UID != nil || user.GID != nil {
|
||||
return errors.New("UID and GID are only valid in custom mode")
|
||||
}
|
||||
case DockerUserCustom:
|
||||
if user.UID == nil || user.GID == nil {
|
||||
return errors.New("custom Docker user requires UID and GID")
|
||||
}
|
||||
default:
|
||||
return errors.New("invalid Docker user mode")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DockerUserValue(user DockerUser, processUID, processGID uint32) (string, error) {
|
||||
if err := ValidateDockerUser(user); err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch user.Mode {
|
||||
case DockerUserImage:
|
||||
return "", nil
|
||||
case DockerUserDoGaMa:
|
||||
return strconv.FormatUint(uint64(processUID), 10) + ":" + strconv.FormatUint(uint64(processGID), 10), nil
|
||||
default:
|
||||
return strconv.FormatUint(uint64(*user.UID), 10) + ":" + strconv.FormatUint(uint64(*user.GID), 10), nil
|
||||
}
|
||||
}
|
||||
|
||||
func ValidateImageTag(value ImageTag, defaultTag string) (ImageTag, error) {
|
||||
if value.Mode == "" {
|
||||
value.Mode = ImageTagTracked
|
||||
}
|
||||
switch value.Mode {
|
||||
case ImageTagTracked:
|
||||
value.Tag = defaultTag
|
||||
case ImageTagPinned:
|
||||
if !tagPattern.MatchString(value.Tag) {
|
||||
return ImageTag{}, errors.New("invalid pinned Docker image tag")
|
||||
}
|
||||
default:
|
||||
return ImageTag{}, errors.New("invalid Docker image tag mode")
|
||||
}
|
||||
if !tagPattern.MatchString(value.Tag) {
|
||||
return ImageTag{}, errors.New("invalid Docker image tag")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package instance
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLabelsAndVariables(t *testing.T) {
|
||||
labels, err := ParseLabels("\n glance.name={{instance.name}}\nquery=a=b=c\n")
|
||||
if err != nil || labels["query"] != "a=b=c" {
|
||||
t.Fatalf("ParseLabels = %#v, %v", labels, err)
|
||||
}
|
||||
if _, err := ParseLabels("missing"); err == nil {
|
||||
t.Fatal("line without separator accepted")
|
||||
}
|
||||
if _, err := ParseLabels("=empty"); err == nil {
|
||||
t.Fatal("empty key accepted")
|
||||
}
|
||||
if _, err := ParseLabels("dogama.managed=false"); err == nil {
|
||||
t.Fatal("reserved key accepted")
|
||||
}
|
||||
if _, err := ParseLabels("io.dogama.managed=false"); err == nil {
|
||||
t.Fatal("technical key accepted")
|
||||
}
|
||||
if _, err := ParseLabels("x={{unknown}}"); err == nil {
|
||||
t.Fatal("unknown variable accepted")
|
||||
}
|
||||
resolved, err := ResolveLabels(map[string]string{"x": "{{game.name}}/{{game.id}}/{{game.icon_url}}/{{instance.name}}/{{instance.id}}/{{instance.slug}}/{{server.name}}"}, LabelContext{GameName: "Palworld", GameID: "palworld", GameIconURL: "/public/game-icons/palworld", InstanceName: "Summer", InstanceID: "id", InstanceSlug: "summer", ServerName: "Server"})
|
||||
if err != nil || resolved["x"] != "Palworld/palworld//public/game-icons/palworld/Summer/id/summer/Server" {
|
||||
t.Fatalf("ResolveLabels = %#v, %v", resolved, err)
|
||||
}
|
||||
merged := MergeLabels(map[string]string{"io.dogama.managed": "true"}, map[string]string{"x": "global"}, map[string]string{"x": "local", "io.dogama.managed": "false"})
|
||||
if merged["x"] != "local" || merged["io.dogama.managed"] != "true" {
|
||||
t.Fatalf("MergeLabels = %#v", merged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlugify(t *testing.T) {
|
||||
cases := map[string]string{"Mon Serveur": "mon-serveur", "Été 2026": "ete-2026", "PvE / PvP": "pve-pvp", "Serveur !!! Test": "serveur-test", "---Été///PvE###1---": "ete-pve-1"}
|
||||
for input, expected := range cases {
|
||||
if actual := Slugify(input); actual != expected {
|
||||
t.Errorf("Slugify(%q)=%q, want %q", input, actual, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerUserAndImageTag(t *testing.T) {
|
||||
uid, gid := uint32(1000), uint32(1001)
|
||||
if value, err := DockerUserValue(DockerUser{Mode: DockerUserDoGaMa}, 42, 43); err != nil || value != "42:43" {
|
||||
t.Fatalf("dogama user = %q, %v", value, err)
|
||||
}
|
||||
if value, err := DockerUserValue(DockerUser{Mode: DockerUserCustom, UID: &uid, GID: &gid}, 0, 0); err != nil || value != "1000:1001" {
|
||||
t.Fatalf("custom user = %q, %v", value, err)
|
||||
}
|
||||
if value, err := DockerUserValue(DockerUser{Mode: DockerUserImage}, 0, 0); err != nil || value != "" {
|
||||
t.Fatalf("image user = %q, %v", value, err)
|
||||
}
|
||||
if _, err := ValidateImageTag(ImageTag{Mode: ImageTagPinned, Tag: "bad/tag"}, "stable"); err == nil {
|
||||
t.Fatal("invalid tag accepted")
|
||||
}
|
||||
tracked, err := ValidateImageTag(ImageTag{Mode: ImageTagTracked}, "stable")
|
||||
if err != nil || tracked.Tag != "stable" {
|
||||
t.Fatalf("tracked tag = %#v, %v", tracked, err)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ type StoredInstance struct {
|
||||
ContainerID string
|
||||
PlanDigest string
|
||||
DesiredRunning bool
|
||||
ContainerConfigPending bool
|
||||
}
|
||||
|
||||
type OperationResult struct {
|
||||
@@ -33,6 +34,7 @@ type OperationResult struct {
|
||||
State string `json:"state"`
|
||||
Observed string `json:"observed_state"`
|
||||
ContainerID string `json:"container_id,omitempty"`
|
||||
ContainerConfigPending bool `json:"container_config_pending"`
|
||||
AgentState agentwire.InstanceState `json:"agent_state,omitempty"`
|
||||
}
|
||||
|
||||
@@ -56,6 +58,8 @@ type LifecycleAgent interface {
|
||||
GetInstanceStats(context.Context, string) (agentwire.InstanceStats, error)
|
||||
}
|
||||
|
||||
type replacementAgent interface { ReplaceInstance(context.Context, agentwire.DeploymentPlan) (agentwire.InstanceState, error) }
|
||||
|
||||
type LifecycleService struct {
|
||||
repository LifecycleRepository
|
||||
agent LifecycleAgent
|
||||
@@ -118,7 +122,16 @@ func (s *LifecycleService) Start(ctx context.Context, instanceID string) (Operat
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
state, err := s.agent.StartInstance(ctx, instanceID)
|
||||
var state agentwire.InstanceState
|
||||
if current.ContainerConfigPending {
|
||||
replacement, ok := s.agent.(replacementAgent)
|
||||
if !ok { return s.fail(ctx, operationID, instanceID, "container_replace_unavailable", errors.New("container replacement is unavailable")) }
|
||||
plan, planErr := current.Preview.DeploymentPlan(instanceID)
|
||||
if planErr != nil { return s.fail(ctx, operationID, instanceID, "invalid_plan", planErr) }
|
||||
state, err = replacement.ReplaceInstance(ctx, plan)
|
||||
if err == nil { err = s.repository.(ConfigurationRepository).ClearContainerConfigPending(ctx, instanceID, plan.PlanDigest) }
|
||||
}
|
||||
if err == nil { state, err = s.agent.StartInstance(ctx, instanceID) }
|
||||
if err != nil {
|
||||
return s.fail(ctx, operationID, instanceID, "agent_start_failed", err)
|
||||
}
|
||||
@@ -319,7 +332,7 @@ func stateToLifecycle(state agentwire.InstanceState) (string, string) {
|
||||
}
|
||||
|
||||
func resultFrom(current StoredInstance, operationID string) OperationResult {
|
||||
return OperationResult{OperationID: operationID, InstanceID: current.ID, State: current.LifecycleState, Observed: current.ObservedState, ContainerID: current.ContainerID}
|
||||
return OperationResult{OperationID: operationID, InstanceID: current.ID, State: current.LifecycleState, Observed: current.ObservedState, ContainerID: current.ContainerID, ContainerConfigPending: current.ContainerConfigPending}
|
||||
}
|
||||
|
||||
func operationToken() (string, error) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package instance_test
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
@@ -46,10 +47,32 @@ func (a *lifecycleAgent) RestartInstance(ctx context.Context, id string, _ int)
|
||||
return a.StartInstance(ctx, id)
|
||||
}
|
||||
func (a *lifecycleAgent) DeleteContainer(context.Context, string) error { return nil }
|
||||
func (a *lifecycleAgent) ReplaceInstance(_ context.Context, plan agentwire.DeploymentPlan) (agentwire.InstanceState, error) {
|
||||
return agentwire.InstanceState{InstanceID: plan.InstanceID, ContainerID: "container-2", PlanDigest: plan.PlanDigest, Health: "stopped"}, nil
|
||||
}
|
||||
func (a *lifecycleAgent) GetInstanceStats(_ context.Context, id string) (agentwire.InstanceStats, error) {
|
||||
return agentwire.InstanceStats{InstanceID: id, MemoryBytes: 42}, nil
|
||||
}
|
||||
|
||||
func TestUpdatePreviewRequiresDigestAndWarnsAboutMods(t *testing.T) {
|
||||
current := instance.StoredInstance{Preview: instance.Preview{
|
||||
Image: "registry.example/game:old",
|
||||
TemplateDefaultTag: "old",
|
||||
Mods: instance.ModsConfiguration{Items: []string{"123"}},
|
||||
UpdatePolicy: instance.UpdatePolicy{BackupBeforeUpdate: true, RollbackOnFailure: true},
|
||||
}}
|
||||
if _, err := instance.PreviewUpdate(current, instance.UpdateRequest{CandidateTag: "new", CandidateDigest: "latest"}); err == nil {
|
||||
t.Fatal("mutable tag without digest was accepted")
|
||||
}
|
||||
value, err := instance.PreviewUpdate(current, instance.UpdateRequest{CandidateTag: "new", CandidateDigest: "sha256:" + strings.Repeat("a", 64)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !value.BackupRequired || !value.ModWarning || !value.RollbackOnFailure || !strings.Contains(value.CandidateReference, "@sha256:") {
|
||||
t.Fatalf("preview = %#v", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLifecycleInstallStartStopAndSafeContainerDeletion(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
|
||||
)
|
||||
|
||||
var imageDigestPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
|
||||
|
||||
type UpdateRequest struct {
|
||||
CandidateTag string `json:"candidate_tag"`
|
||||
CandidateDigest string `json:"candidate_digest"`
|
||||
Confirmed bool `json:"confirmed"`
|
||||
}
|
||||
|
||||
type UpdatePreview struct {
|
||||
CurrentReference string `json:"current_reference"`
|
||||
CandidateReference string `json:"candidate_reference"`
|
||||
BackupRequired bool `json:"backup_required"`
|
||||
ModWarning bool `json:"mod_warning"`
|
||||
RollbackOnFailure bool `json:"rollback_on_failure"`
|
||||
}
|
||||
|
||||
func PreviewUpdate(current StoredInstance, request UpdateRequest) (UpdatePreview, error) {
|
||||
tag, err := ValidateImageTag(ImageTag{Mode: ImageTagPinned, Tag: request.CandidateTag}, current.Preview.TemplateDefaultTag)
|
||||
if err != nil {
|
||||
return UpdatePreview{}, err
|
||||
}
|
||||
if !imageDigestPattern.MatchString(request.CandidateDigest) {
|
||||
return UpdatePreview{}, errors.New("candidate image digest must be sha256")
|
||||
}
|
||||
base := imageRepository(current.Preview.Image)
|
||||
return UpdatePreview{CurrentReference: current.Preview.Image, CandidateReference: base + ":" + tag.Tag + "@" + request.CandidateDigest, BackupRequired: current.Preview.UpdatePolicy.BackupBeforeUpdate, ModWarning: len(current.Preview.Mods.Items) != 0, RollbackOnFailure: current.Preview.UpdatePolicy.RollbackOnFailure}, nil
|
||||
}
|
||||
|
||||
func (s *LifecycleService) Update(ctx context.Context, instanceID string, request UpdateRequest, actorID string) (OperationResult, error) {
|
||||
return s.exclusive(instanceID, func() (OperationResult, error) {
|
||||
current, err := s.repository.GetInstance(ctx, instanceID)
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
candidate, err := PreviewUpdate(current, request)
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
if !request.Confirmed {
|
||||
return OperationResult{}, errors.New("update confirmation is required")
|
||||
}
|
||||
if current.ContainerID == "" {
|
||||
return OperationResult{}, ErrInvalidState
|
||||
}
|
||||
replacement, ok := s.agent.(replacementAgent)
|
||||
if !ok {
|
||||
return OperationResult{}, errors.New("container replacement is unavailable")
|
||||
}
|
||||
previous := current.Preview
|
||||
next := previous
|
||||
next.Image = candidate.CandidateReference
|
||||
next.ImageTag = ImageTag{Mode: ImageTagPinned, Tag: request.CandidateTag}
|
||||
repository := s.repository.(ConfigurationRepository)
|
||||
operationID, err := operationToken()
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
if err := repository.SaveInstanceConfiguration(ctx, instanceID, next, false, "update", actorID); err != nil {
|
||||
return s.fail(ctx, operationID, instanceID, "update_configuration_failed", err)
|
||||
}
|
||||
current, err = s.repository.BeginOperation(ctx, operationID, instanceID, "restart", "update")
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
plan, err := next.DeploymentPlan(instanceID)
|
||||
if err == nil {
|
||||
_, err = replacement.ReplaceInstance(ctx, plan)
|
||||
}
|
||||
var stateErr error
|
||||
if err == nil && current.DesiredRunning {
|
||||
_, stateErr = s.agent.StartInstance(ctx, instanceID)
|
||||
err = stateErr
|
||||
}
|
||||
rollback := func() {
|
||||
if next.UpdatePolicy.RollbackOnFailure {
|
||||
_ = repository.SaveInstanceConfiguration(ctx, instanceID, previous, false, "update_rollback", actorID)
|
||||
if oldPlan, planErr := previous.DeploymentPlan(instanceID); planErr == nil {
|
||||
if _, rollbackErr := replacement.ReplaceInstance(ctx, oldPlan); rollbackErr == nil && current.DesiredRunning {
|
||||
_, _ = s.agent.StartInstance(ctx, instanceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
rollback()
|
||||
return s.fail(ctx, operationID, instanceID, "update_failed", err)
|
||||
}
|
||||
state, err := s.waitForUpdateReadiness(ctx, instanceID, current.DesiredRunning, next.UpdatePolicy.HealthTimeoutSeconds)
|
||||
if err != nil {
|
||||
rollback()
|
||||
return s.fail(ctx, operationID, instanceID, "update_health_failed", err)
|
||||
}
|
||||
lifecycle, observed := stateToLifecycle(state)
|
||||
if err := s.repository.FinishOperation(ctx, operationID, lifecycle, observed, state.ContainerID, plan.PlanDigest, current.DesiredRunning, ""); err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
return OperationResult{OperationID: operationID, InstanceID: instanceID, State: lifecycle, Observed: observed, ContainerID: state.ContainerID, AgentState: state}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LifecycleService) waitForUpdateReadiness(ctx context.Context, instanceID string, shouldRun bool, timeoutSeconds int) (agentwire.InstanceState, error) {
|
||||
if timeoutSeconds < 10 {
|
||||
timeoutSeconds = 10
|
||||
}
|
||||
deadline, cancel := context.WithTimeout(ctx, time.Duration(timeoutSeconds)*time.Second)
|
||||
defer cancel()
|
||||
for {
|
||||
state, err := s.agent.InspectInstance(deadline, instanceID)
|
||||
if err != nil {
|
||||
return agentwire.InstanceState{}, err
|
||||
}
|
||||
if !shouldRun && !state.Running {
|
||||
return state, nil
|
||||
}
|
||||
if shouldRun && state.Ready {
|
||||
return state, nil
|
||||
}
|
||||
select {
|
||||
case <-deadline.Done():
|
||||
return agentwire.InstanceState{}, errors.New("updated instance did not become ready before timeout")
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
// Package notification manages encrypted channels and bounded asynchronous delivery.
|
||||
package notification
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/smtp"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Channel struct {
|
||||
ID, Name, Type string
|
||||
Enabled bool
|
||||
Events []string
|
||||
Configured bool
|
||||
}
|
||||
type Input struct {
|
||||
Name, Type string
|
||||
Enabled bool
|
||||
Events []string
|
||||
Config map[string]string
|
||||
}
|
||||
type Event struct{ Type, Title, Message, InstanceName, OperationID string }
|
||||
type Service struct {
|
||||
db *sql.DB
|
||||
aead cipher.AEAD
|
||||
client *http.Client
|
||||
resolver *net.Resolver
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func New(db *sql.DB, key []byte) (*Service, error) {
|
||||
if len(key) != 32 {
|
||||
return nil, errors.New("notification encryption key must be exactly 32 bytes")
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Service{db: db, aead: aead, resolver: net.DefaultResolver, now: time.Now}
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.DialContext = s.dialSafe
|
||||
s.client = &http.Client{Transport: transport, Timeout: 10 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 3 {
|
||||
return errors.New("too many redirects")
|
||||
}
|
||||
return s.validateURL(req.Context(), req.URL)
|
||||
}}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Service) Upsert(ctx context.Context, id string, in Input) (Channel, error) {
|
||||
if strings.TrimSpace(in.Name) == "" || !validType(in.Type) {
|
||||
return Channel{}, errors.New("invalid notification channel")
|
||||
}
|
||||
if err := validateConfigShape(in.Type, in.Config); err != nil {
|
||||
return Channel{}, err
|
||||
}
|
||||
encrypted, err := s.seal(in.Config)
|
||||
if err != nil {
|
||||
return Channel{}, err
|
||||
}
|
||||
events, _ := json.Marshal(normalizeEvents(in.Events))
|
||||
now := s.now().UTC().Format(time.RFC3339Nano)
|
||||
if id == "" {
|
||||
id = randomID()
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `INSERT INTO notification_channels(id,name,type,enabled,encrypted_config,event_filter_json,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name,type=excluded.type,enabled=excluded.enabled,encrypted_config=excluded.encrypted_config,event_filter_json=excluded.event_filter_json,updated_at=excluded.updated_at`, id, strings.TrimSpace(in.Name), in.Type, in.Enabled, encrypted, string(events), now, now)
|
||||
if err != nil {
|
||||
return Channel{}, fmt.Errorf("save notification channel: %w", err)
|
||||
}
|
||||
return Channel{ID: id, Name: strings.TrimSpace(in.Name), Type: in.Type, Enabled: in.Enabled, Events: normalizeEvents(in.Events), Configured: true}, nil
|
||||
}
|
||||
func (s *Service) List(ctx context.Context) ([]Channel, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id,name,type,enabled,event_filter_json,length(encrypted_config)>0 FROM notification_channels ORDER BY name COLLATE NOCASE`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Channel
|
||||
for rows.Next() {
|
||||
var c Channel
|
||||
var body string
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.Type, &c.Enabled, &body, &c.Configured); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal([]byte(body), &c.Events)
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
func (s *Service) Delete(ctx context.Context, id string) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM notification_channels WHERE id=?`, id)
|
||||
return err
|
||||
}
|
||||
func (s *Service) Queue(ctx context.Context, event Event) error {
|
||||
if !validEvent(event.Type) || len(event.Message) > 1000 {
|
||||
return errors.New("invalid notification event")
|
||||
}
|
||||
payload, _ := json.Marshal(event)
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id,event_filter_json FROM notification_channels WHERE enabled=1`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var channelIDs []string
|
||||
for rows.Next() {
|
||||
var id, filter string
|
||||
if err := rows.Scan(&id, &filter); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
var events []string
|
||||
_ = json.Unmarshal([]byte(filter), &events)
|
||||
if matches(events, event.Type) {
|
||||
channelIDs = append(channelIDs, id)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
rows.Close()
|
||||
now := s.now().UTC().Format(time.RFC3339Nano)
|
||||
for _, id := range channelIDs {
|
||||
if _, err := s.db.ExecContext(ctx, `INSERT INTO notification_deliveries(id,channel_id,event_type,payload_redacted,next_attempt_at,created_at) VALUES(?,?,?,?,?,?)`, randomID(), id, event.Type, string(payload), now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Service) Test(ctx context.Context, id string) error {
|
||||
return s.queueForChannel(ctx, id, Event{Type: "notification.test", Title: "DoGaMa test notification", Message: "This is a test notification from DoGaMa."})
|
||||
}
|
||||
func (s *Service) queueForChannel(ctx context.Context, id string, event Event) error {
|
||||
var enabled bool
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT enabled FROM notification_channels WHERE id=?`, id).Scan(&enabled); err != nil {
|
||||
return err
|
||||
}
|
||||
payload, _ := json.Marshal(event)
|
||||
now := s.now().UTC().Format(time.RFC3339Nano)
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO notification_deliveries(id,channel_id,event_type,payload_redacted,next_attempt_at,created_at) VALUES(?,?,?,?,?,?)`, randomID(), id, event.Type, string(payload), now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) RunDue(ctx context.Context) error {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT d.id,d.attempt,d.payload_redacted,c.type,c.encrypted_config FROM notification_deliveries d JOIN notification_channels c ON c.id=d.channel_id WHERE d.status IN ('queued','retrying') AND d.next_attempt_at<=? ORDER BY d.next_attempt_at LIMIT 20`, s.now().UTC().Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type job struct {
|
||||
id, typ, payload string
|
||||
attempt int
|
||||
encrypted []byte
|
||||
}
|
||||
var jobs []job
|
||||
for rows.Next() {
|
||||
var j job
|
||||
if err := rows.Scan(&j.id, &j.attempt, &j.payload, &j.typ, &j.encrypted); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
jobs = append(jobs, j)
|
||||
}
|
||||
rows.Close()
|
||||
for _, j := range jobs {
|
||||
config, e := s.open(j.encrypted)
|
||||
if e == nil {
|
||||
e = s.deliver(ctx, j.id, j.typ, config, []byte(j.payload))
|
||||
}
|
||||
attempt := j.attempt + 1
|
||||
if e == nil {
|
||||
if _, updateErr := s.db.ExecContext(ctx, `UPDATE notification_deliveries SET status='succeeded',attempt=?,completed_at=?,last_error_code='' WHERE id=?`, attempt, s.now().UTC().Format(time.RFC3339Nano), j.id); updateErr != nil {
|
||||
return updateErr
|
||||
}
|
||||
} else {
|
||||
status := "retrying"
|
||||
if attempt >= 5 {
|
||||
status = "failed"
|
||||
}
|
||||
delay := time.Duration(1<<min(attempt, 6)) * time.Minute
|
||||
_, _ = s.db.ExecContext(ctx, `UPDATE notification_deliveries SET status=?,attempt=?,next_attempt_at=?,last_error_code=? WHERE id=?`, status, attempt, s.now().UTC().Add(delay).Format(time.RFC3339Nano), errorCode(e), j.id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Service) deliver(ctx context.Context, id, typ string, c map[string]string, payload []byte) error {
|
||||
if typ == "email" {
|
||||
host := c["host"]
|
||||
port := c["port"]
|
||||
if port == "" {
|
||||
port = "587"
|
||||
}
|
||||
addr := net.JoinHostPort(host, port)
|
||||
var auth smtp.Auth
|
||||
if c["username"] != "" {
|
||||
auth = smtp.PlainAuth("", c["username"], c["password"], host)
|
||||
}
|
||||
msg := []byte("To: " + c["to"] + "\r\nSubject: DoGaMa notification\r\nContent-Type: application/json\r\n\r\n" + string(payload))
|
||||
return sendSMTP(ctx, addr, host, auth, c["from"], strings.Split(c["to"], ","), msg)
|
||||
}
|
||||
u, err := url.Parse(c["url"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = s.validateURL(ctx, u); err != nil {
|
||||
return err
|
||||
}
|
||||
body := payload
|
||||
if typ == "discord" {
|
||||
var event Event
|
||||
_ = json.Unmarshal(payload, &event)
|
||||
body, _ = json.Marshal(map[string]string{"content": event.Title + "\n" + event.Message})
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-DoGaMa-Event-ID", id)
|
||||
timestamp := strconv.FormatInt(s.now().Unix(), 10)
|
||||
req.Header.Set("X-DoGaMa-Timestamp", timestamp)
|
||||
if secret := c["signing_secret"]; secret != "" {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(timestamp + "." + string(body)))
|
||||
req.Header.Set("X-DoGaMa-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("remote_status_%d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Service) validateURL(ctx context.Context, u *url.URL) error {
|
||||
if u.Scheme != "https" || u.User != nil || u.Hostname() == "" {
|
||||
return errors.New("unsafe_destination")
|
||||
}
|
||||
ips, err := s.resolver.LookupNetIP(ctx, "ip", u.Hostname())
|
||||
if err != nil {
|
||||
return errors.New("destination_resolution_failed")
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if unsafeIP(ip) {
|
||||
return errors.New("unsafe_destination")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) dialSafe(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, errors.New("unsafe_destination")
|
||||
}
|
||||
ips, err := s.resolver.LookupNetIP(ctx, "ip", host)
|
||||
if err != nil || len(ips) == 0 {
|
||||
return nil, errors.New("destination_resolution_failed")
|
||||
}
|
||||
dialer := net.Dialer{Timeout: 10 * time.Second}
|
||||
for _, ip := range ips {
|
||||
if unsafeIP(ip) {
|
||||
return nil, errors.New("unsafe_destination")
|
||||
}
|
||||
connection, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
||||
if dialErr == nil {
|
||||
return connection, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("delivery_failed")
|
||||
}
|
||||
|
||||
func sendSMTP(ctx context.Context, address, host string, auth smtp.Auth, from string, recipients []string, message []byte) error {
|
||||
dialer := net.Dialer{Timeout: 10 * time.Second}
|
||||
connection, err := dialer.DialContext(ctx, "tcp", address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := smtp.NewClient(connection, host)
|
||||
if err != nil {
|
||||
_ = connection.Close()
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
if ok, _ := client.Extension("STARTTLS"); !ok {
|
||||
return errors.New("smtp_tls_required")
|
||||
}
|
||||
if err := client.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil {
|
||||
return err
|
||||
}
|
||||
if auth != nil {
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := client.Mail(from); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, recipient := range recipients {
|
||||
if err := client.Rcpt(strings.TrimSpace(recipient)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = w.Write(message); err != nil {
|
||||
_ = w.Close()
|
||||
return err
|
||||
}
|
||||
if err = w.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
func unsafeIP(ip netip.Addr) bool {
|
||||
return !ip.IsValid() || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified()
|
||||
}
|
||||
func (s *Service) seal(config map[string]string) ([]byte, error) {
|
||||
body, _ := json.Marshal(config)
|
||||
nonce := make([]byte, s.aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.aead.Seal(nonce, nonce, body, nil), nil
|
||||
}
|
||||
func (s *Service) open(body []byte) (map[string]string, error) {
|
||||
n := s.aead.NonceSize()
|
||||
if len(body) < n {
|
||||
return nil, errors.New("invalid encrypted channel")
|
||||
}
|
||||
plain, err := s.aead.Open(nil, body[:n], body[n:], nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out map[string]string
|
||||
err = json.Unmarshal(plain, &out)
|
||||
return out, err
|
||||
}
|
||||
func validType(v string) bool { return v == "email" || v == "webhook" || v == "discord" }
|
||||
func validEvent(v string) bool {
|
||||
return v == "notification.test" || strings.HasSuffix(v, ".failed") || strings.HasSuffix(v, ".completed") || strings.HasSuffix(v, ".required")
|
||||
}
|
||||
func validateConfigShape(typ string, c map[string]string) error {
|
||||
if typ == "email" {
|
||||
if c["host"] == "" || c["from"] == "" || c["to"] == "" {
|
||||
return errors.New("email host, from and to are required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
u, err := url.Parse(c["url"])
|
||||
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil {
|
||||
return errors.New("an HTTPS webhook URL is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func normalizeEvents(in []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, v := range in {
|
||||
v = strings.TrimSpace(v)
|
||||
if validEvent(v) && !seen[v] {
|
||||
seen[v] = true
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func matches(filter []string, event string) bool {
|
||||
for _, v := range filter {
|
||||
if v == event || v == "*" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func errorCode(err error) string {
|
||||
v := err.Error()
|
||||
if strings.HasPrefix(v, "remote_status_") {
|
||||
return v
|
||||
}
|
||||
switch v {
|
||||
case "unsafe_destination", "destination_resolution_failed":
|
||||
return v
|
||||
}
|
||||
return "delivery_failed"
|
||||
}
|
||||
func randomID() string {
|
||||
b := make([]byte, 18)
|
||||
_, _ = rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package notification_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
|
||||
)
|
||||
|
||||
func TestChannelSecretsAreEncryptedAndWriteOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
service, err := notification.New(db, bytes.Repeat([]byte{7}, 32))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channel, err := service.Upsert(ctx, "", notification.Input{Name: "ops", Type: "webhook", Enabled: true, Events: []string{"backup.failed"}, Config: map[string]string{"url": "https://example.com/hook", "signing_secret": "highly-sensitive"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var encrypted []byte
|
||||
if err := db.QueryRowContext(ctx, `SELECT encrypted_config FROM notification_channels WHERE id=?`, channel.ID).Scan(&encrypted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(encrypted), "highly-sensitive") {
|
||||
t.Fatal("secret stored in plaintext")
|
||||
}
|
||||
channels, err := service.List(ctx)
|
||||
if err != nil || len(channels) != 1 || !channels[0].Configured {
|
||||
t.Fatalf("channels=%#v err=%v", channels, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryBlocksPrivateWebhookAndRetriesWithRedactedError(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
service, _ := notification.New(db, bytes.Repeat([]byte{8}, 32))
|
||||
channel, err := service.Upsert(ctx, "", notification.Input{Name: "unsafe", Type: "webhook", Enabled: true, Events: []string{"backup.failed"}, Config: map[string]string{"url": "https://127.0.0.1/hook", "signing_secret": "never-leak"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.Queue(ctx, notification.Event{Type: "backup.failed", Title: "Backup failed", Message: "Operation failed", OperationID: "op-1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.RunDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var status, code string
|
||||
var attempt int
|
||||
if err := db.QueryRowContext(ctx, `SELECT status,attempt,last_error_code FROM notification_deliveries WHERE channel_id=?`, channel.ID).Scan(&status, &attempt, &code); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "retrying" || attempt != 1 || code != "unsafe_destination" || strings.Contains(code, "never-leak") {
|
||||
t.Fatalf("status=%s attempt=%d code=%q", status, attempt, code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsMissingKeyAndInsecureURL(t *testing.T) {
|
||||
if _, err := notification.New(nil, []byte("short")); err == nil {
|
||||
t.Fatal("short key accepted")
|
||||
}
|
||||
ctx := context.Background()
|
||||
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
service, _ := notification.New(db, bytes.Repeat([]byte{9}, 32))
|
||||
if _, err := service.Upsert(ctx, "", notification.Input{Name: "bad", Type: "discord", Enabled: true, Config: map[string]string{"url": "http://example.com"}}); err == nil {
|
||||
t.Fatal("insecure URL accepted")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -24,16 +24,16 @@ func TestOpenAppliesMigrationsAndConfiguration(t *testing.T) {
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("got %d migrations, want 4", count)
|
||||
if count != 9 {
|
||||
t.Fatalf("got %d migrations, want 9", count)
|
||||
}
|
||||
for _, table := range []string{"instance_memberships", "permission_overrides", "installation_requests"} {
|
||||
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("authorization table %q is missing", table)
|
||||
t.Fatalf("required table %q is missing", table)
|
||||
}
|
||||
}
|
||||
var foreignKeys, busyTimeout int
|
||||
@@ -62,8 +62,8 @@ func TestOpenAppliesMigrationsAndConfiguration(t *testing.T) {
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("reopened database has %d migrations, want 4", count)
|
||||
if count != 9 {
|
||||
t.Fatalf("reopened database has %d migrations, want 9", count)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification"
|
||||
)
|
||||
|
||||
func (s *server) requireRecentAdmin(w http.ResponseWriter, r *http.Request, api bool) (auth.User, bool) {
|
||||
var user auth.User
|
||||
var ok bool
|
||||
if api {
|
||||
user, ok = s.requireAPIUser(w, r, true)
|
||||
} else {
|
||||
var err error
|
||||
user, err = s.currentUser(r)
|
||||
ok = err == nil && user.Role == "admin"
|
||||
if ok {
|
||||
session, e := r.Cookie(sessionCookie)
|
||||
ok = e == nil && s.parseForm(w, r) && s.auth.ValidateCSRF(r.Context(), session.Value, r.FormValue("csrf_token"))
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return auth.User{}, false
|
||||
}
|
||||
if time.Since(user.AuthenticatedAt) > 10*time.Minute {
|
||||
if api {
|
||||
s.apiProblem(w, http.StatusForbidden, "reauthentication_required", "Recent authentication is required.")
|
||||
} else {
|
||||
s.problem(w, http.StatusForbidden, "Recent authentication is required.")
|
||||
}
|
||||
return auth.User{}, false
|
||||
}
|
||||
return user, true
|
||||
}
|
||||
func (s *server) recordAudit(r *http.Request, actor auth.User, action, outcome string, summary map[string]string) {
|
||||
if s.audit == nil {
|
||||
return
|
||||
}
|
||||
if err := s.audit.Record(r.Context(), audit.Event{ActorID: actor.ID, ActorLabel: actor.Username, Action: action, Outcome: outcome, Summary: summary}); err != nil {
|
||||
s.logger.Warn("audit recording failed", "event", "audit.record.failed")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) auditList(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireAPIUser(w, r, true); !ok {
|
||||
return
|
||||
}
|
||||
f := audit.Filter{ActorID: r.URL.Query().Get("actor_id"), InstanceID: r.URL.Query().Get("instance_id"), Action: r.URL.Query().Get("action"), Outcome: r.URL.Query().Get("outcome")}
|
||||
events, err := s.audit.List(r.Context(), f)
|
||||
if err != nil {
|
||||
s.apiProblem(w, 500, "audit_unavailable", "Audit events are unavailable.")
|
||||
return
|
||||
}
|
||||
s.apiJSON(w, 200, map[string]any{"events": events})
|
||||
}
|
||||
func (s *server) auditPolicyGet(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireAPIUser(w, r, true); !ok {
|
||||
return
|
||||
}
|
||||
p, err := s.audit.Policy(r.Context())
|
||||
if err != nil {
|
||||
s.apiProblem(w, 500, "audit_unavailable", "Audit policy is unavailable.")
|
||||
return
|
||||
}
|
||||
s.apiJSON(w, 200, p)
|
||||
}
|
||||
func (s *server) auditPolicyPut(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var p audit.Policy
|
||||
if !s.decodeAPIJSON(w, r, &p) {
|
||||
return
|
||||
}
|
||||
if err := s.audit.SetPolicy(r.Context(), p); err != nil {
|
||||
s.recordAudit(r, actor, "audit.policy.update", "failed", map[string]string{"reason_code": "invalid_policy"})
|
||||
s.apiProblem(w, 422, "invalid_policy", "The audit policy is invalid.")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "audit.policy.update", "allowed", nil)
|
||||
s.apiJSON(w, 200, p)
|
||||
}
|
||||
func (s *server) auditPurge(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Before string `json:"before"`
|
||||
Confirm bool `json:"confirm"`
|
||||
}
|
||||
if !s.decodeAPIJSON(w, r, &in) || !in.Confirm {
|
||||
s.apiProblem(w, 422, "confirmation_required", "Purge confirmation is required.")
|
||||
return
|
||||
}
|
||||
before, err := time.Parse(time.RFC3339, in.Before)
|
||||
if err != nil {
|
||||
s.apiProblem(w, 422, "invalid_boundary", "The purge boundary is invalid.")
|
||||
return
|
||||
}
|
||||
n, err := s.audit.Purge(r.Context(), before)
|
||||
if err != nil {
|
||||
s.apiProblem(w, 422, "purge_failed", "The audit purge failed.")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "audit.purge", "allowed", map[string]string{"deleted_count": strconv.FormatInt(n, 10)})
|
||||
s.apiJSON(w, 200, map[string]int64{"deleted_count": n})
|
||||
}
|
||||
|
||||
func (s *server) notificationList(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireAPIUser(w, r, true); !ok {
|
||||
return
|
||||
}
|
||||
values, err := s.notifications.List(r.Context())
|
||||
if err != nil {
|
||||
s.apiProblem(w, 500, "channels_unavailable", "Notification channels are unavailable.")
|
||||
return
|
||||
}
|
||||
s.apiJSON(w, 200, map[string]any{"channels": values})
|
||||
}
|
||||
func (s *server) notificationCreate(w http.ResponseWriter, r *http.Request) {
|
||||
s.notificationUpsert(w, r, "")
|
||||
}
|
||||
func (s *server) notificationUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
s.notificationUpsert(w, r, r.PathValue("id"))
|
||||
}
|
||||
func (s *server) notificationUpsert(w http.ResponseWriter, r *http.Request, id string) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in notification.Input
|
||||
if !s.decodeAPIJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
value, err := s.notifications.Upsert(r.Context(), id, in)
|
||||
if err != nil {
|
||||
s.recordAudit(r, actor, "notification.channel.update", "failed", map[string]string{"channel_type": in.Type, "reason_code": "invalid_channel"})
|
||||
s.apiProblem(w, 422, "invalid_channel", "The notification channel is invalid.")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "notification.channel.update", "allowed", map[string]string{"target_id": value.ID, "channel_type": value.Type})
|
||||
status := http.StatusOK
|
||||
if id == "" {
|
||||
status = http.StatusCreated
|
||||
}
|
||||
s.apiJSON(w, status, value)
|
||||
}
|
||||
func (s *server) notificationDelete(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.notifications.Delete(r.Context(), r.PathValue("id")); err != nil {
|
||||
s.apiProblem(w, 500, "delete_failed", "The channel could not be deleted.")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "notification.channel.delete", "allowed", map[string]string{"target_id": r.PathValue("id")})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
func (s *server) notificationTest(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !s.requireEmptyBody(w, r) {
|
||||
return
|
||||
}
|
||||
if err := s.notifications.Test(r.Context(), r.PathValue("id")); err != nil {
|
||||
s.apiProblem(w, 404, "channel_not_found", "The channel was not found.")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "notification.channel.test", "allowed", map[string]string{"target_id": r.PathValue("id"), "event_type": "notification.test"})
|
||||
s.apiJSON(w, 202, map[string]string{"status": "queued"})
|
||||
}
|
||||
|
||||
func (s *server) notificationForm(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
config := map[string]string{"url": r.FormValue("url"), "signing_secret": r.FormValue("signing_secret"), "host": r.FormValue("host"), "port": r.FormValue("port"), "username": r.FormValue("smtp_username"), "password": r.FormValue("smtp_password"), "from": r.FormValue("from"), "to": r.FormValue("to")}
|
||||
value, err := s.notifications.Upsert(r.Context(), "", notification.Input{Name: r.FormValue("name"), Type: r.FormValue("type"), Enabled: true, Events: strings.Fields(r.FormValue("events")), Config: config})
|
||||
if err != nil {
|
||||
s.problem(w, 422, "Invalid notification channel.")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "notification.channel.update", "allowed", map[string]string{"target_id": value.ID, "channel_type": value.Type})
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
func (s *server) notificationTestForm(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.notifications.Test(r.Context(), r.PathValue("id")); err != nil {
|
||||
s.problem(w, 404, "Notification channel not found.")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "notification.channel.test", "allowed", map[string]string{"target_id": r.PathValue("id"), "event_type": "notification.test"})
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
func (s *server) notificationDeleteForm(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.notifications.Delete(r.Context(), r.PathValue("id")); err != nil {
|
||||
s.problem(w, 500, "Notification channel could not be deleted.")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "notification.channel.delete", "allowed", map[string]string{"target_id": r.PathValue("id")})
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
func (s *server) auditPolicyForm(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
days, _ := strconv.Atoi(r.FormValue("retention_days"))
|
||||
maximum, _ := strconv.Atoi(r.FormValue("maximum_count"))
|
||||
if err := s.audit.SetPolicy(r.Context(), audit.Policy{RetentionDays: days, MaximumCount: maximum}); err != nil {
|
||||
s.problem(w, 422, "Invalid audit policy.")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "audit.policy.update", "allowed", nil)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
func (s *server) auditPurgeForm(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if r.FormValue("confirm") != "yes" {
|
||||
s.problem(w, 422, "Purge confirmation is required.")
|
||||
return
|
||||
}
|
||||
before, err := time.Parse("2006-01-02", r.FormValue("before"))
|
||||
if err != nil {
|
||||
s.problem(w, 422, "Invalid purge date.")
|
||||
return
|
||||
}
|
||||
n, err := s.audit.Purge(r.Context(), before)
|
||||
if err != nil {
|
||||
s.problem(w, 422, "Audit purge failed.")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "audit.purge", "allowed", map[string]string{"deleted_count": strconv.FormatInt(n, 10)})
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
+771
-35
@@ -14,12 +14,20 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/authorization"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/backup"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/importexport"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -53,12 +61,16 @@ var englishMessages = map[string]string{
|
||||
}
|
||||
|
||||
type server struct {
|
||||
auth *auth.Service
|
||||
templates *template.Template
|
||||
logger *slog.Logger
|
||||
repository repository
|
||||
lifecycle *instance.LifecycleService
|
||||
permissions *authorization.Service
|
||||
auth *auth.Service
|
||||
templates *template.Template
|
||||
logger *slog.Logger
|
||||
repository repository
|
||||
lifecycle *instance.LifecycleService
|
||||
permissions *authorization.Service
|
||||
backups *backup.Service
|
||||
imports *importexport.Service
|
||||
audit *audit.Service
|
||||
notifications *notification.Service
|
||||
}
|
||||
|
||||
type repository interface {
|
||||
@@ -69,39 +81,71 @@ type repository interface {
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
Title string
|
||||
CSRFToken string
|
||||
Error string
|
||||
User auth.User
|
||||
Title string
|
||||
CSRFToken string
|
||||
Error string
|
||||
User auth.User
|
||||
GlobalLabels string
|
||||
IsAdmin bool
|
||||
Channels []notification.Channel
|
||||
AuditEvents []audit.Event
|
||||
AuditPolicy audit.Policy
|
||||
AuditActor string
|
||||
AuditInstance string
|
||||
AuditAction string
|
||||
AuditOutcome string
|
||||
}
|
||||
|
||||
// NewHandler constructs the complete HTTP application.
|
||||
func NewHandler(authService *auth.Service, logger *slog.Logger) (http.Handler, error) {
|
||||
return newHandler(authService, nil, nil, logger)
|
||||
return newHandler(authService, nil, nil, nil, logger)
|
||||
}
|
||||
|
||||
// NewHandlerWithRepository enables the authenticated catalog and draft APIs.
|
||||
func NewHandlerWithRepository(authService *auth.Service, repository repository, logger *slog.Logger) (http.Handler, error) {
|
||||
return newHandler(authService, repository, nil, logger)
|
||||
return newHandler(authService, repository, nil, nil, logger)
|
||||
}
|
||||
|
||||
func NewHandlerWithRepositoryAndImports(authService *auth.Service, repository repository, importService *importexport.Service, logger *slog.Logger) (http.Handler, error) {
|
||||
return newHandlerWithImports(authService, repository, nil, nil, importService, logger)
|
||||
}
|
||||
|
||||
// NewHandlerWithLifecycle enables privileged instance lifecycle operations
|
||||
// through the restricted agent boundary.
|
||||
func NewHandlerWithLifecycle(authService *auth.Service, repository repository, agent instance.LifecycleAgent, logger *slog.Logger) (http.Handler, error) {
|
||||
return newHandler(authService, repository, instance.NewLifecycleService(repository, agent), logger)
|
||||
return newHandler(authService, repository, instance.NewLifecycleService(repository, agent), nil, logger)
|
||||
}
|
||||
|
||||
func newHandler(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, logger *slog.Logger) (http.Handler, error) {
|
||||
// NewHandlerWithLifecycleAndBackup enables lifecycle and backup operations.
|
||||
func NewHandlerWithLifecycleAndBackup(authService *auth.Service, repository repository, agent instance.LifecycleAgent, backupService *backup.Service, importService *importexport.Service, logger *slog.Logger) (http.Handler, error) {
|
||||
return newHandlerWithImports(authService, repository, instance.NewLifecycleService(repository, agent), backupService, importService, logger)
|
||||
}
|
||||
|
||||
// NewHandlerComplete enables the milestone-nine administration services.
|
||||
func NewHandlerComplete(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, backupService *backup.Service, importService *importexport.Service, auditService *audit.Service, notificationService *notification.Service, logger *slog.Logger) (http.Handler, error) {
|
||||
return newHandlerServices(authService, repository, lifecycle, backupService, importService, auditService, notificationService, logger)
|
||||
}
|
||||
|
||||
func newHandler(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, backupService *backup.Service, logger *slog.Logger) (http.Handler, error) {
|
||||
return newHandlerWithImports(authService, repository, lifecycle, backupService, nil, logger)
|
||||
}
|
||||
|
||||
func newHandlerWithImports(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, backupService *backup.Service, importService *importexport.Service, logger *slog.Logger) (http.Handler, error) {
|
||||
return newHandlerServices(authService, repository, lifecycle, backupService, importService, nil, nil, logger)
|
||||
}
|
||||
|
||||
func newHandlerServices(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, backupService *backup.Service, importService *importexport.Service, auditService *audit.Service, notificationService *notification.Service, logger *slog.Logger) (http.Handler, error) {
|
||||
templates, err := template.New("views").Funcs(template.FuncMap{"msg": message}).ParseFS(assets, "templates/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &server{auth: authService, templates: templates, logger: logger, repository: repository, lifecycle: lifecycle}
|
||||
s := &server{auth: authService, templates: templates, logger: logger, repository: repository, lifecycle: lifecycle, backups: backupService, imports: importService, audit: auditService, notifications: notificationService}
|
||||
if repository != nil {
|
||||
s.permissions = authorization.New(repository)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
if repository != nil {
|
||||
mux.HandleFunc("GET /public/game-icons/{gameID}", s.publicGameIcon)
|
||||
mux.HandleFunc("GET /api/v1/catalog", s.catalogList)
|
||||
mux.HandleFunc("POST /api/v1/instances/preview", s.instancePreview)
|
||||
mux.HandleFunc("POST /api/v1/instances/drafts", s.instanceDraft)
|
||||
@@ -110,6 +154,21 @@ func newHandler(authService *auth.Service, repository repository, lifecycle *ins
|
||||
mux.HandleFunc("POST /api/v1/installation-requests/{id}/review", s.installationRequestReview)
|
||||
mux.HandleFunc("GET /api/v1/admin/users", s.userList)
|
||||
mux.HandleFunc("POST /api/v1/admin/users", s.userCreate)
|
||||
mux.HandleFunc("GET /api/v1/admin/game-container-labels", s.globalLabelsGet)
|
||||
mux.HandleFunc("PUT /api/v1/admin/game-container-labels", s.globalLabelsPut)
|
||||
if auditService != nil {
|
||||
mux.HandleFunc("GET /api/v1/admin/audit", s.auditList)
|
||||
mux.HandleFunc("GET /api/v1/admin/audit-policy", s.auditPolicyGet)
|
||||
mux.HandleFunc("PUT /api/v1/admin/audit-policy", s.auditPolicyPut)
|
||||
mux.HandleFunc("POST /api/v1/admin/audit/purge", s.auditPurge)
|
||||
}
|
||||
if notificationService != nil {
|
||||
mux.HandleFunc("GET /api/v1/admin/notification-channels", s.notificationList)
|
||||
mux.HandleFunc("POST /api/v1/admin/notification-channels", s.notificationCreate)
|
||||
mux.HandleFunc("PUT /api/v1/admin/notification-channels/{id}", s.notificationUpdate)
|
||||
mux.HandleFunc("DELETE /api/v1/admin/notification-channels/{id}", s.notificationDelete)
|
||||
mux.HandleFunc("POST /api/v1/admin/notification-channels/{id}/test", s.notificationTest)
|
||||
}
|
||||
mux.HandleFunc("GET /api/v1/instances/{id}/memberships", s.membershipList)
|
||||
mux.HandleFunc("PUT /api/v1/instances/{id}/memberships/{userID}", s.membershipSet)
|
||||
mux.HandleFunc("DELETE /api/v1/instances/{id}/memberships/{userID}", s.membershipDelete)
|
||||
@@ -123,6 +182,25 @@ func newHandler(authService *auth.Service, repository repository, lifecycle *ins
|
||||
mux.HandleFunc("POST /api/v1/instances/{id}/stop", s.instanceStop)
|
||||
mux.HandleFunc("POST /api/v1/instances/{id}/restart", s.instanceRestart)
|
||||
mux.HandleFunc("DELETE /api/v1/instances/{id}", s.instanceDeleteContainer)
|
||||
mux.HandleFunc("GET /api/v1/instances/{id}/container-configuration", s.instanceConfigurationGet)
|
||||
mux.HandleFunc("PUT /api/v1/instances/{id}/container-configuration", s.instanceConfigurationPut)
|
||||
mux.HandleFunc("GET /api/v1/instances/{id}/configuration-revisions", s.configurationRevisionList)
|
||||
mux.HandleFunc("POST /api/v1/instances/{id}/configuration-revisions/{revision}/rollback", s.configurationRevisionRollback)
|
||||
mux.HandleFunc("GET /api/v1/instances/{id}/mods", s.instanceModsGet)
|
||||
mux.HandleFunc("PUT /api/v1/instances/{id}/mods", s.instanceModsPut)
|
||||
mux.HandleFunc("POST /api/v1/instances/{id}/update/preview", s.instanceUpdatePreview)
|
||||
mux.HandleFunc("POST /api/v1/instances/{id}/update", s.instanceUpdate)
|
||||
}
|
||||
if backupService != nil {
|
||||
mux.HandleFunc("GET /api/v1/instances/{id}/backups", s.backupList)
|
||||
mux.HandleFunc("POST /api/v1/instances/{id}/backups", s.backupCreate)
|
||||
mux.HandleFunc("GET /api/v1/instances/{id}/backups/{backupID}/export", s.backupExport)
|
||||
mux.HandleFunc("POST /api/v1/instances/{id}/backups/{backupID}/restore", s.backupRestore)
|
||||
mux.HandleFunc("GET /api/v1/instances/{id}/backup-policy", s.backupPolicyGet)
|
||||
mux.HandleFunc("PUT /api/v1/instances/{id}/backup-policy", s.backupPolicySet)
|
||||
}
|
||||
if importService != nil {
|
||||
mux.HandleFunc("POST /api/v1/imports", s.importCreate)
|
||||
}
|
||||
}
|
||||
mux.HandleFunc("GET /static/app.v1.css", s.stylesheet)
|
||||
@@ -131,20 +209,461 @@ func newHandler(authService *auth.Service, repository repository, lifecycle *ins
|
||||
mux.HandleFunc("GET /login", s.loginForm)
|
||||
mux.HandleFunc("POST /login", s.loginSubmit)
|
||||
mux.HandleFunc("POST /logout", s.logout)
|
||||
mux.HandleFunc("POST /admin/game-container-labels", s.globalLabelsForm)
|
||||
mux.HandleFunc("POST /admin/notification-channels", s.notificationForm)
|
||||
mux.HandleFunc("POST /admin/notification-channels/{id}/test", s.notificationTestForm)
|
||||
mux.HandleFunc("POST /admin/notification-channels/{id}/delete", s.notificationDeleteForm)
|
||||
mux.HandleFunc("POST /admin/audit-policy", s.auditPolicyForm)
|
||||
mux.HandleFunc("POST /admin/audit-purge", s.auditPurgeForm)
|
||||
mux.HandleFunc("GET /", s.home)
|
||||
return s.securityHeaders(mux), nil
|
||||
return s.securityHeaders(s.auditRequests(mux)), nil
|
||||
}
|
||||
|
||||
type auditResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (w *auditResponseWriter) WriteHeader(status int) {
|
||||
if w.status == 0 {
|
||||
w.status = status
|
||||
}
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
func (w *auditResponseWriter) Write(body []byte) (int, error) {
|
||||
if w.status == 0 {
|
||||
w.status = http.StatusOK
|
||||
}
|
||||
return w.ResponseWriter.Write(body)
|
||||
}
|
||||
|
||||
func (s *server) auditRequests(next http.Handler) http.Handler {
|
||||
if s.audit == nil {
|
||||
return next
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
action := auditAction(r.Method, r.URL.Path)
|
||||
if action == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
actor, _ := s.currentUser(r)
|
||||
wrapped := &auditResponseWriter{ResponseWriter: w}
|
||||
next.ServeHTTP(wrapped, r)
|
||||
status := wrapped.status
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
outcome := "allowed"
|
||||
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||||
outcome = "denied"
|
||||
} else if status >= 400 {
|
||||
outcome = "failed"
|
||||
}
|
||||
summary := map[string]string{}
|
||||
if id := r.PathValue("id"); id != "" {
|
||||
summary["target_id"] = id
|
||||
}
|
||||
instanceID := ""
|
||||
if status < 400 && strings.HasPrefix(r.URL.Path, "/api/v1/instances/") {
|
||||
instanceID = r.PathValue("id")
|
||||
}
|
||||
_ = s.audit.Record(r.Context(), audit.Event{ActorID: actor.ID, ActorLabel: actor.Username, InstanceID: instanceID, Action: action, Outcome: outcome, Summary: summary})
|
||||
})
|
||||
}
|
||||
|
||||
func auditAction(method, path string) string {
|
||||
if method == http.MethodGet || strings.HasPrefix(path, "/api/v1/admin/audit") || strings.HasPrefix(path, "/api/v1/admin/notification") || strings.HasPrefix(path, "/admin/audit") || strings.HasPrefix(path, "/admin/notification") {
|
||||
return ""
|
||||
}
|
||||
switch {
|
||||
case path == "/api/v1/admin/users":
|
||||
return "user.create"
|
||||
case strings.Contains(path, "/memberships/") && strings.Contains(path, "/permissions/"):
|
||||
return "permission.override.change"
|
||||
case strings.Contains(path, "/memberships/"):
|
||||
return "membership.change"
|
||||
case strings.Contains(path, "installation-requests") && strings.HasSuffix(path, "/review"):
|
||||
return "installation_request.review"
|
||||
case strings.Contains(path, "/backups/") && strings.HasSuffix(path, "/restore"):
|
||||
return "backup.restore"
|
||||
case strings.HasSuffix(path, "/backups"):
|
||||
return "backup.create"
|
||||
case path == "/api/v1/imports":
|
||||
return "import.create"
|
||||
case strings.HasSuffix(path, "/update"):
|
||||
return "instance.update"
|
||||
case strings.Contains(path, "configuration-revisions") && strings.HasSuffix(path, "/rollback"):
|
||||
return "configuration.rollback"
|
||||
case strings.HasSuffix(path, "/start"):
|
||||
return "instance.start"
|
||||
case strings.HasSuffix(path, "/stop"):
|
||||
return "instance.stop"
|
||||
case strings.HasSuffix(path, "/restart"):
|
||||
return "instance.restart"
|
||||
case strings.HasSuffix(path, "/install"):
|
||||
return "instance.create"
|
||||
case strings.HasPrefix(path, "/api/v1/instances/") && method == http.MethodDelete:
|
||||
return "instance.delete"
|
||||
case strings.Contains(path, "container-configuration") || strings.HasSuffix(path, "/mods"):
|
||||
return "instance.configuration.change"
|
||||
case strings.Contains(path, "game-container-labels"):
|
||||
return "security.configuration.change"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var publicGameIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
|
||||
func (s *server) publicGameIcon(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := r.PathValue("gameID")
|
||||
if !publicGameIDPattern.MatchString(gameID) || gameID != "palworld" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
body, err := catalogdata.Files.ReadFile("palworld/assets/icon.png")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
func requestBaseURL(r *http.Request) string {
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
if forwarded := r.Header.Get("X-Forwarded-Proto"); forwarded == "http" || forwarded == "https" {
|
||||
scheme = forwarded
|
||||
}
|
||||
return scheme + "://" + r.Host
|
||||
}
|
||||
|
||||
type applicationModeRequest struct {
|
||||
Apply string `json:"apply"`
|
||||
}
|
||||
|
||||
func (s *server) globalLabelsGet(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireAPIUser(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if actor.Role != "admin" {
|
||||
s.apiProblem(w, http.StatusForbidden, "forbidden", "Administrator access is required.")
|
||||
return
|
||||
}
|
||||
repository := s.repository.(instance.ConfigurationRepository)
|
||||
labels, err := repository.GetGlobalLabels(r.Context())
|
||||
if err != nil {
|
||||
s.apiProblem(w, 500, "settings_unavailable", "The settings are unavailable.")
|
||||
return
|
||||
}
|
||||
s.apiJSON(w, http.StatusOK, map[string]any{"labels": instance.FormatLabels(labels), "variables": instance.AllowedLabelVariables})
|
||||
}
|
||||
|
||||
func (s *server) globalLabelsPut(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireAPIUser(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.permissions.RequireRecentAdmin(actor); err != nil {
|
||||
s.authorizationProblem(w, err)
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
Labels string `json:"labels"`
|
||||
Apply string `json:"apply"`
|
||||
}
|
||||
if !s.decodeStrictJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
labels, err := instance.ParseLabels(request.Labels)
|
||||
if err != nil {
|
||||
s.apiProblem(w, 422, "invalid_labels", err.Error())
|
||||
return
|
||||
}
|
||||
if request.Apply != "immediate" && request.Apply != "next_start" {
|
||||
s.apiProblem(w, 422, "invalid_apply_mode", "Apply must be immediate or next_start.")
|
||||
return
|
||||
}
|
||||
if request.Apply == "immediate" && s.lifecycle == nil {
|
||||
s.apiProblem(w, http.StatusConflict, "lifecycle_unavailable", "Immediate application requires the Docker agent.")
|
||||
return
|
||||
}
|
||||
repository := s.repository.(instance.ConfigurationRepository)
|
||||
affected, running, err := repository.SetGlobalLabels(r.Context(), labels, true)
|
||||
if err != nil {
|
||||
s.apiProblem(w, 500, "settings_update_failed", "The settings could not be updated.")
|
||||
return
|
||||
}
|
||||
if request.Apply == "immediate" && s.lifecycle != nil {
|
||||
for _, current := range mustLifecycleInstances(r.Context(), s.repository) {
|
||||
if _, err := s.lifecycle.Configure(r.Context(), current.ID, instance.FormatLabels(current.Preview.CustomLabels), current.Preview.ImageTag, true); err != nil {
|
||||
s.apiProblem(w, 502, "container_replace_failed", "Global labels were saved, but one or more containers could not be recreated.")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
s.apiJSON(w, http.StatusOK, map[string]any{"labels": instance.FormatLabels(labels), "affected_instances": affected, "running_instances": running, "container_config_pending": request.Apply == "next_start"})
|
||||
}
|
||||
|
||||
func (s *server) globalLabelsForm(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
if r.ParseForm() != nil {
|
||||
s.problem(w, 400, message("error.form"))
|
||||
return
|
||||
}
|
||||
actor, err := s.currentUser(r)
|
||||
session, sessionErr := r.Cookie(sessionCookie)
|
||||
if err != nil || sessionErr != nil || actor.Role != "admin" || !s.auth.ValidateCSRF(r.Context(), session.Value, r.FormValue("csrf_token")) {
|
||||
s.problem(w, http.StatusForbidden, message("error.csrf"))
|
||||
return
|
||||
}
|
||||
if err := s.permissions.RequireRecentAdmin(actor); err != nil {
|
||||
s.problem(w, http.StatusForbidden, "Recent administrator authentication is required.")
|
||||
return
|
||||
}
|
||||
labels, err := instance.ParseLabels(r.FormValue("labels"))
|
||||
if err != nil {
|
||||
s.problem(w, 422, err.Error())
|
||||
return
|
||||
}
|
||||
apply := r.FormValue("apply")
|
||||
if apply != "immediate" && apply != "next_start" {
|
||||
s.problem(w, 422, "Invalid application mode.")
|
||||
return
|
||||
}
|
||||
if apply == "immediate" && r.FormValue("confirm_disconnection") != "yes" {
|
||||
s.problem(w, 422, "Confirm that players will be disconnected.")
|
||||
return
|
||||
}
|
||||
repository := s.repository.(instance.ConfigurationRepository)
|
||||
if _, _, err := repository.SetGlobalLabels(r.Context(), labels, true); err != nil {
|
||||
s.problem(w, 500, message("error.internal"))
|
||||
return
|
||||
}
|
||||
if apply == "immediate" {
|
||||
if s.lifecycle == nil {
|
||||
s.problem(w, 409, "The Docker agent is unavailable.")
|
||||
return
|
||||
}
|
||||
for _, current := range mustLifecycleInstances(r.Context(), s.repository) {
|
||||
if _, err := s.lifecycle.Configure(r.Context(), current.ID, instance.FormatLabels(current.Preview.CustomLabels), current.Preview.ImageTag, true); err != nil {
|
||||
s.problem(w, 502, "The settings were saved, but a container could not be recreated.")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func mustLifecycleInstances(ctx context.Context, repository repository) []instance.StoredInstance {
|
||||
values, err := repository.ListLifecycleInstances(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func (s *server) instanceConfigurationGet(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceView); !ok {
|
||||
return
|
||||
}
|
||||
current, err := s.repository.GetInstance(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
s.lifecycleProblem(w, err)
|
||||
return
|
||||
}
|
||||
s.apiJSON(w, 200, map[string]any{"custom_labels": instance.FormatLabels(current.Preview.CustomLabels), "docker_user": current.Preview.DockerUser, "image_tag": current.Preview.ImageTag, "container_config_pending": current.ContainerConfigPending, "docker_user_immutable": true})
|
||||
}
|
||||
|
||||
func (s *server) instanceConfigurationPut(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceConfigure); !ok {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
Labels string `json:"labels"`
|
||||
ImageTag instance.ImageTag `json:"image_tag"`
|
||||
Apply string `json:"apply"`
|
||||
}
|
||||
if !s.decodeStrictJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
if request.Apply != "immediate" && request.Apply != "next_start" {
|
||||
s.apiProblem(w, 422, "invalid_apply_mode", "Apply must be immediate or next_start.")
|
||||
return
|
||||
}
|
||||
result, err := s.lifecycle.Configure(r.Context(), r.PathValue("id"), request.Labels, request.ImageTag, request.Apply == "immediate")
|
||||
if err != nil {
|
||||
s.apiProblem(w, 422, "invalid_container_configuration", err.Error())
|
||||
return
|
||||
}
|
||||
s.apiJSON(w, 200, result)
|
||||
}
|
||||
|
||||
func (s *server) configurationRevisionList(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceView); !ok {
|
||||
return
|
||||
}
|
||||
values, err := s.repository.(instance.ConfigurationRepository).ListConfigurationRevisions(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
s.lifecycleProblem(w, err)
|
||||
return
|
||||
}
|
||||
s.apiJSON(w, 200, map[string]any{"revisions": values})
|
||||
}
|
||||
|
||||
func (s *server) configurationRevisionRollback(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceConfigure)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
revision, err := strconv.Atoi(r.PathValue("revision"))
|
||||
if err != nil || revision < 1 {
|
||||
s.apiProblem(w, 422, "invalid_revision", "Revision must be positive.")
|
||||
return
|
||||
}
|
||||
var request applicationModeRequest
|
||||
if !s.decodeStrictJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
if request.Apply != "immediate" && request.Apply != "next_start" {
|
||||
s.apiProblem(w, 422, "invalid_apply_mode", "Apply must be immediate or next_start.")
|
||||
return
|
||||
}
|
||||
result, err := s.lifecycle.RollbackConfiguration(r.Context(), r.PathValue("id"), revision, request.Apply == "immediate", actor.ID)
|
||||
if err != nil {
|
||||
s.apiProblem(w, 422, "configuration_rollback_failed", err.Error())
|
||||
return
|
||||
}
|
||||
s.apiJSON(w, 200, result)
|
||||
}
|
||||
|
||||
func (s *server) instanceModsGet(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceView); !ok {
|
||||
return
|
||||
}
|
||||
current, err := s.repository.GetInstance(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
s.lifecycleProblem(w, err)
|
||||
return
|
||||
}
|
||||
s.apiJSON(w, 200, current.Preview.Mods)
|
||||
}
|
||||
|
||||
func (s *server) instanceModsPut(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireInstancePermission(w, r, authorization.PermissionModsManage)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
Items []string `json:"items"`
|
||||
Apply string `json:"apply"`
|
||||
}
|
||||
if !s.decodeStrictJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
if request.Apply != "immediate" && request.Apply != "next_start" {
|
||||
s.apiProblem(w, 422, "invalid_apply_mode", "Apply must be immediate or next_start.")
|
||||
return
|
||||
}
|
||||
result, err := s.lifecycle.ConfigureMods(r.Context(), r.PathValue("id"), request.Items, request.Apply == "immediate", actor.ID)
|
||||
if err != nil {
|
||||
s.apiProblem(w, 422, "invalid_mod_configuration", err.Error())
|
||||
return
|
||||
}
|
||||
s.apiJSON(w, 200, result)
|
||||
}
|
||||
|
||||
func (s *server) instanceUpdatePreview(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceUpdate); !ok {
|
||||
return
|
||||
}
|
||||
var request instance.UpdateRequest
|
||||
if !s.decodeStrictJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
current, err := s.repository.GetInstance(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
s.lifecycleProblem(w, err)
|
||||
return
|
||||
}
|
||||
value, err := instance.PreviewUpdate(current, request)
|
||||
if err != nil {
|
||||
s.apiProblem(w, 422, "invalid_update", err.Error())
|
||||
return
|
||||
}
|
||||
s.apiJSON(w, 200, value)
|
||||
}
|
||||
|
||||
func (s *server) instanceUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceUpdate)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request instance.UpdateRequest
|
||||
if !s.decodeStrictJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
if !request.Confirmed {
|
||||
s.apiProblem(w, 422, "update_confirmation_required", "Update confirmation is required.")
|
||||
return
|
||||
}
|
||||
current, err := s.repository.GetInstance(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
s.lifecycleProblem(w, err)
|
||||
return
|
||||
}
|
||||
if current.Preview.UpdatePolicy.BackupBeforeUpdate {
|
||||
if s.backups == nil {
|
||||
s.apiProblem(w, 409, "backup_unavailable", "The required safety backup service is unavailable.")
|
||||
return
|
||||
}
|
||||
if _, err := s.backups.Create(r.Context(), actor.ID, current.ID, "pre_update"); err != nil {
|
||||
s.apiProblem(w, 422, "pre_update_backup_failed", "The safety backup failed; the update was not started.")
|
||||
return
|
||||
}
|
||||
}
|
||||
result, err := s.lifecycle.Update(r.Context(), current.ID, request, actor.ID)
|
||||
if err != nil {
|
||||
s.queueNotification(r, notification.Event{Type: "update.failed", Title: "Update failed", Message: "The instance update failed.", InstanceName: current.Preview.DisplayName})
|
||||
s.apiProblem(w, 422, "update_failed", err.Error())
|
||||
return
|
||||
}
|
||||
s.queueNotification(r, notification.Event{Type: "update.completed", Title: "Update completed", Message: "The instance update completed.", InstanceName: current.Preview.DisplayName})
|
||||
s.apiJSON(w, 200, result)
|
||||
}
|
||||
|
||||
func (s *server) decodeStrictJSON(w http.ResponseWriter, r *http.Request, value any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if decoder.Decode(value) != nil || decoder.Decode(&struct{}{}) != io.EOF {
|
||||
s.apiProblem(w, 400, "invalid_request", "The request is invalid.")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type previewAPIRequest struct {
|
||||
TemplateID string `json:"template_id"`
|
||||
TemplateVersion string `json:"template_version"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Slug string `json:"slug"`
|
||||
HostPorts map[string]int `json:"host_ports"`
|
||||
MountPaths map[string]string `json:"mount_paths"`
|
||||
Resources catalog.Resources `json:"resources"`
|
||||
DataOrigin string `json:"data_origin"`
|
||||
BackupRetention int `json:"backup_retention"`
|
||||
TemplateID string `json:"template_id"`
|
||||
TemplateVersion string `json:"template_version"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Slug string `json:"slug"`
|
||||
HostPorts map[string]int `json:"host_ports"`
|
||||
MountPaths map[string]string `json:"mount_paths"`
|
||||
Resources catalog.Resources `json:"resources"`
|
||||
DataOrigin string `json:"data_origin"`
|
||||
BackupRetention int `json:"backup_retention"`
|
||||
ImportID string `json:"import_id"`
|
||||
CustomLabels string `json:"custom_labels"`
|
||||
DockerUser instance.DockerUser `json:"docker_user"`
|
||||
ImageTag instance.ImageTag `json:"image_tag"`
|
||||
}
|
||||
|
||||
func (s *server) catalogList(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -208,7 +727,41 @@ func (s *server) instanceStats(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *server) instanceInstall(w http.ResponseWriter, r *http.Request) {
|
||||
s.lifecycleAdminAction(w, r, s.lifecycle.Install)
|
||||
actor, ok := s.requireAPIUser(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.permissions.RequireRecentAdmin(actor); err != nil {
|
||||
s.authorizationProblem(w, err)
|
||||
return
|
||||
}
|
||||
current, err := s.repository.GetInstance(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
s.lifecycleProblem(w, err)
|
||||
return
|
||||
}
|
||||
if current.Preview.DataOrigin == "import" {
|
||||
if s.imports == nil {
|
||||
s.apiProblem(w, http.StatusConflict, "import_unavailable", "The validated import is unavailable.")
|
||||
return
|
||||
}
|
||||
mountPath := ""
|
||||
for _, mount := range current.Preview.Mounts {
|
||||
if mount.ID == current.Preview.Import.DestinationMount {
|
||||
mountPath = mount.HostPath
|
||||
break
|
||||
}
|
||||
}
|
||||
if mountPath == "" {
|
||||
s.apiProblem(w, http.StatusUnprocessableEntity, "invalid_import", "The import destination is invalid.")
|
||||
return
|
||||
}
|
||||
if err := s.imports.ApplyToInstance(r.Context(), current.Preview.Import.ID, current.ID, current.Preview.Template.ID, current.Preview.Template.Version, mountPath, current.Preview.Import.DestinationRelativePath); err != nil {
|
||||
s.apiProblem(w, http.StatusUnprocessableEntity, "invalid_import", "The validated import could not be applied.")
|
||||
return
|
||||
}
|
||||
}
|
||||
s.runLifecycleAction(w, r, s.lifecycle.Install)
|
||||
}
|
||||
|
||||
func (s *server) instanceStart(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -230,13 +783,6 @@ func (s *server) lifecycleAction(w http.ResponseWriter, r *http.Request, permiss
|
||||
s.runLifecycleAction(w, r, action)
|
||||
}
|
||||
|
||||
func (s *server) lifecycleAdminAction(w http.ResponseWriter, r *http.Request, action func(context.Context, string) (instance.OperationResult, error)) {
|
||||
if _, ok := s.requireAPIUser(w, r, true); !ok {
|
||||
return
|
||||
}
|
||||
s.runLifecycleAction(w, r, action)
|
||||
}
|
||||
|
||||
func (s *server) runLifecycleAction(w http.ResponseWriter, r *http.Request, action func(context.Context, string) (instance.OperationResult, error)) {
|
||||
if r.Body != nil {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
@@ -310,18 +856,172 @@ func (s *server) buildAPIPreview(w http.ResponseWriter, r *http.Request) (previe
|
||||
s.apiProblem(w, http.StatusNotFound, "template_not_found", "The template version was not found.")
|
||||
return request, instance.Preview{}, false
|
||||
}
|
||||
if request.DataOrigin == "import" {
|
||||
if s.imports == nil || s.imports.ValidateSelection(r.Context(), request.ImportID, request.TemplateID, request.TemplateVersion) != nil {
|
||||
s.apiProblem(w, http.StatusUnprocessableEntity, "invalid_import", "A validated compatible import is required.")
|
||||
return request, instance.Preview{}, false
|
||||
}
|
||||
}
|
||||
preview, err := instance.BuildPreview(snapshot, instance.PreviewRequest{
|
||||
DisplayName: request.DisplayName, Slug: request.Slug, HostPorts: request.HostPorts,
|
||||
MountPaths: request.MountPaths, Resources: request.Resources, DataOrigin: request.DataOrigin,
|
||||
BackupRetention: request.BackupRetention,
|
||||
BackupRetention: request.BackupRetention, ImportID: request.ImportID,
|
||||
CustomLabels: request.CustomLabels, DockerUser: request.DockerUser, ImageTag: request.ImageTag,
|
||||
PublicBaseURL: requestBaseURL(r),
|
||||
})
|
||||
if err != nil {
|
||||
s.apiProblem(w, http.StatusUnprocessableEntity, "invalid_preview", "The deployment preview is invalid.")
|
||||
return request, instance.Preview{}, false
|
||||
}
|
||||
if configured, ok := s.repository.(instance.ConfigurationRepository); ok {
|
||||
labels, labelErr := configured.GetGlobalLabels(r.Context())
|
||||
if labelErr != nil {
|
||||
s.apiProblem(w, http.StatusInternalServerError, "settings_unavailable", "The global settings are unavailable.")
|
||||
return request, instance.Preview{}, false
|
||||
}
|
||||
preview.GlobalLabels = labels
|
||||
}
|
||||
return request, preview, true
|
||||
}
|
||||
|
||||
func (s *server) backupList(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireInstancePermission(w, r, authorization.PermissionBackupList); !ok {
|
||||
return
|
||||
}
|
||||
values, err := s.backups.List(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
s.backupProblem(w, err)
|
||||
return
|
||||
}
|
||||
s.apiJSON(w, http.StatusOK, map[string]any{"backups": values})
|
||||
}
|
||||
|
||||
func (s *server) backupCreate(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireInstancePermission(w, r, authorization.PermissionBackupCreate)
|
||||
if !ok || !s.requireEmptyBody(w, r) {
|
||||
return
|
||||
}
|
||||
value, err := s.backups.Create(r.Context(), actor.ID, r.PathValue("id"), "manual")
|
||||
if err != nil {
|
||||
s.queueNotification(r, notification.Event{Type: "backup.failed", Title: "Backup failed", Message: "The manual backup failed."})
|
||||
s.backupProblem(w, err)
|
||||
return
|
||||
}
|
||||
s.queueNotification(r, notification.Event{Type: "backup.completed", Title: "Backup completed", Message: "The manual backup completed.", OperationID: value.ID})
|
||||
s.apiJSON(w, http.StatusCreated, value)
|
||||
}
|
||||
|
||||
func (s *server) backupExport(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireInstancePermission(w, r, authorization.PermissionBackupExport); !ok {
|
||||
return
|
||||
}
|
||||
file, value, err := s.backups.Export(r.Context(), r.PathValue("id"), r.PathValue("backupID"))
|
||||
if err != nil {
|
||||
s.backupProblem(w, err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
w.Header().Set("Content-Type", "application/zstd")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+value.ID+`.tar.zst"`)
|
||||
w.Header().Set("X-Content-SHA256", value.SHA256)
|
||||
http.ServeContent(w, r, value.ID+".tar.zst", time.Time{}, file)
|
||||
}
|
||||
|
||||
func (s *server) backupRestore(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireInstancePermission(w, r, authorization.PermissionBackupRestore)
|
||||
if !ok || !s.requireEmptyBody(w, r) {
|
||||
return
|
||||
}
|
||||
if err := s.backups.Restore(r.Context(), actor.ID, r.PathValue("id"), r.PathValue("backupID")); err != nil {
|
||||
s.queueNotification(r, notification.Event{Type: "restore.failed", Title: "Restore failed", Message: "The backup restore failed."})
|
||||
s.backupProblem(w, err)
|
||||
return
|
||||
}
|
||||
s.queueNotification(r, notification.Event{Type: "restore.completed", Title: "Restore completed", Message: "The backup restore completed."})
|
||||
s.apiJSON(w, http.StatusOK, map[string]string{"state": "restored"})
|
||||
}
|
||||
|
||||
func (s *server) backupPolicyGet(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireInstancePermission(w, r, authorization.PermissionBackupList); !ok {
|
||||
return
|
||||
}
|
||||
value, err := s.backups.GetPolicy(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
s.backupProblem(w, err)
|
||||
return
|
||||
}
|
||||
s.apiJSON(w, http.StatusOK, value)
|
||||
}
|
||||
|
||||
func (s *server) backupPolicySet(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceConfigure); !ok {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
CronExpression string `json:"cron_expression"`
|
||||
Timezone string `json:"timezone"`
|
||||
RetentionCount int `json:"retention_count"`
|
||||
}
|
||||
if !s.decodeAPIJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
value, err := s.backups.SetPolicy(r.Context(), backup.Policy{InstanceID: r.PathValue("id"), Enabled: request.Enabled, CronExpression: request.CronExpression, Timezone: request.Timezone, RetentionCount: request.RetentionCount})
|
||||
if err != nil {
|
||||
s.backupProblem(w, err)
|
||||
return
|
||||
}
|
||||
s.apiJSON(w, http.StatusOK, value)
|
||||
}
|
||||
|
||||
func (s *server) backupProblem(w http.ResponseWriter, err error) {
|
||||
status, code := http.StatusInternalServerError, "backup_failed"
|
||||
switch {
|
||||
case errors.Is(err, backup.ErrNotFound), errors.Is(err, instance.ErrInstanceNotFound):
|
||||
status, code = http.StatusNotFound, "backup_not_found"
|
||||
case errors.Is(err, backup.ErrInvalidInput), errors.Is(err, backup.ErrIncompatible):
|
||||
status, code = http.StatusUnprocessableEntity, "invalid_backup"
|
||||
case errors.Is(err, backup.ErrInvalidState), errors.Is(err, instance.ErrOperationConflict):
|
||||
status, code = http.StatusConflict, "backup_conflict"
|
||||
case errors.Is(err, backup.ErrIntegrity):
|
||||
status, code = http.StatusUnprocessableEntity, "backup_integrity_failed"
|
||||
}
|
||||
s.apiProblem(w, status, code, "The backup operation could not be completed.")
|
||||
}
|
||||
|
||||
func (s *server) importCreate(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireAPIUser(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.permissions.RequireRecentAdmin(actor); err != nil {
|
||||
s.authorizationProblem(w, err)
|
||||
return
|
||||
}
|
||||
templateID, version, format := r.URL.Query().Get("template_id"), r.URL.Query().Get("template_version"), r.URL.Query().Get("format")
|
||||
snapshot, err := s.repository.Get(r.Context(), templateID, version)
|
||||
if err != nil {
|
||||
s.apiProblem(w, http.StatusNotFound, "template_not_found", "The template version was not found.")
|
||||
return
|
||||
}
|
||||
if !snapshot.Template.Imports.Supported {
|
||||
s.apiProblem(w, http.StatusUnprocessableEntity, "import_unsupported", "The template does not support imports.")
|
||||
return
|
||||
}
|
||||
maximum := int64(snapshot.Template.Imports.MaxExtractedSizeGB) << 30
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maximum+1)
|
||||
value, err := s.imports.Stage(r.Context(), actor.ID, format, r.Body, importexport.Policy{TemplateID: templateID, TemplateVersion: version, AcceptedFormats: snapshot.Template.Imports.AcceptedFormats, MaxExpandedBytes: maximum, RequiredPaths: snapshot.Template.Imports.RequiredPaths})
|
||||
if err != nil {
|
||||
status, code := http.StatusUnprocessableEntity, "invalid_import"
|
||||
if errors.Is(err, importexport.ErrLimitExceeded) {
|
||||
status, code = http.StatusRequestEntityTooLarge, "import_limit_exceeded"
|
||||
}
|
||||
s.apiProblem(w, status, code, "The import could not be validated.")
|
||||
return
|
||||
}
|
||||
s.apiJSON(w, http.StatusCreated, value)
|
||||
}
|
||||
|
||||
func (s *server) requireInstancePermission(w http.ResponseWriter, r *http.Request, permission string) (auth.User, bool) {
|
||||
user, ok := s.requireAPIUser(w, r, false)
|
||||
if !ok {
|
||||
@@ -472,6 +1172,7 @@ func (s *server) installationRequestCreate(w http.ResponseWriter, r *http.Reques
|
||||
s.authorizationProblem(w, err)
|
||||
return
|
||||
}
|
||||
s.queueNotification(r, notification.Event{Type: "installation_request.required", Title: "Installation request submitted", Message: "An installation request requires administrator review."})
|
||||
s.apiJSON(w, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
@@ -505,9 +1206,19 @@ func (s *server) installationRequestReview(w http.ResponseWriter, r *http.Reques
|
||||
s.authorizationProblem(w, err)
|
||||
return
|
||||
}
|
||||
s.queueNotification(r, notification.Event{Type: "installation_request.completed", Title: "Installation request reviewed", Message: "An installation request was reviewed."})
|
||||
s.apiJSON(w, http.StatusOK, reviewed)
|
||||
}
|
||||
|
||||
func (s *server) queueNotification(r *http.Request, event notification.Event) {
|
||||
if s.notifications == nil {
|
||||
return
|
||||
}
|
||||
if err := s.notifications.Queue(r.Context(), event); err != nil {
|
||||
s.logger.Warn("notification queue failed", "event", "notification.queue.failed")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) decodeAPIJSON(w http.ResponseWriter, r *http.Request, target any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
@@ -637,11 +1348,19 @@ func (s *server) loginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
if errors.Is(err, auth.ErrRateLimited) {
|
||||
status = http.StatusTooManyRequests
|
||||
w.Header().Set("Retry-After", "60")
|
||||
if s.audit != nil {
|
||||
_ = s.audit.Record(r.Context(), audit.Event{ActorLabel: "anonymous", Action: "auth.login.blocked", Outcome: "denied", Summary: map[string]string{"reason_code": "rate_limited"}})
|
||||
}
|
||||
}
|
||||
token := s.anonymousCSRF(w, r)
|
||||
s.render(w, status, "login.html", pageData{Title: message("login.title"), CSRFToken: token, Error: message("error.credentials")})
|
||||
return
|
||||
}
|
||||
if s.audit != nil {
|
||||
if actor, actorErr := s.auth.Authenticate(r.Context(), session.Token); actorErr == nil {
|
||||
_ = s.audit.Record(r.Context(), audit.Event{ActorID: actor.ID, ActorLabel: actor.Username, Action: "auth.login", Outcome: "allowed"})
|
||||
}
|
||||
}
|
||||
if cookie, cookieErr := r.Cookie(sessionCookie); cookieErr == nil {
|
||||
if revokeErr := s.auth.Revoke(r.Context(), cookie.Value); revokeErr != nil {
|
||||
_ = s.auth.Revoke(r.Context(), session.Token)
|
||||
@@ -692,7 +1411,24 @@ func (s *server) home(w http.ResponseWriter, r *http.Request) {
|
||||
s.problem(w, http.StatusForbidden, message("error.csrf"))
|
||||
return
|
||||
}
|
||||
s.render(w, http.StatusOK, "home.html", pageData{Title: message("dashboard.title"), User: user, CSRFToken: csrf.Value})
|
||||
data := pageData{Title: message("dashboard.title"), User: user, CSRFToken: csrf.Value, IsAdmin: user.Role == "admin"}
|
||||
if data.IsAdmin && s.repository != nil {
|
||||
if configured, ok := s.repository.(instance.ConfigurationRepository); ok {
|
||||
if labels, labelErr := configured.GetGlobalLabels(r.Context()); labelErr == nil {
|
||||
data.GlobalLabels = instance.FormatLabels(labels)
|
||||
}
|
||||
}
|
||||
if s.notifications != nil {
|
||||
data.Channels, _ = s.notifications.List(r.Context())
|
||||
}
|
||||
if s.audit != nil {
|
||||
data.AuditActor, data.AuditInstance = r.URL.Query().Get("actor_id"), r.URL.Query().Get("instance_id")
|
||||
data.AuditAction, data.AuditOutcome = r.URL.Query().Get("action"), r.URL.Query().Get("outcome")
|
||||
data.AuditEvents, _ = s.audit.List(r.Context(), audit.Filter{ActorID: data.AuditActor, InstanceID: data.AuditInstance, Action: data.AuditAction, Outcome: data.AuditOutcome, Limit: 50})
|
||||
data.AuditPolicy, _ = s.audit.Policy(r.Context())
|
||||
}
|
||||
}
|
||||
s.render(w, http.StatusOK, "home.html", data)
|
||||
}
|
||||
|
||||
func (s *server) currentUser(r *http.Request) (auth.User, error) {
|
||||
|
||||
@@ -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,197 @@ 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"))
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
:root { color-scheme: light dark; font-family: system-ui, sans-serif; line-height: 1.5; }
|
||||
body { margin: 0; background: #eef2f7; color: #172033; }
|
||||
main { width: min(32rem, calc(100% - 2rem)); margin: 8vh auto; padding: 2rem; background: white; border-radius: .75rem; box-shadow: 0 .5rem 2rem #17203318; }
|
||||
main { width: min(64rem, calc(100% - 2rem)); margin: 4vh auto; padding: 2rem; background: white; border-radius: .75rem; box-shadow: 0 .5rem 2rem #17203318; }
|
||||
section { margin-top: 2.5rem; padding-top: 1rem; border-top: 1px solid #d0d5dd; }
|
||||
header { display: flex; justify-content: space-between; align-items: center; padding: 1rem 2rem; background: white; }
|
||||
form { display: grid; gap: 1rem; }
|
||||
header form { display: block; }
|
||||
.inline { display: inline; margin-left: .5rem; }
|
||||
.inline button { padding: .35rem .55rem; }
|
||||
label { display: grid; gap: .35rem; font-weight: 600; }
|
||||
input, button { box-sizing: border-box; padding: .7rem; font: inherit; border: 1px solid #8a94a6; border-radius: .35rem; }
|
||||
input, textarea, select, button { box-sizing: border-box; padding: .7rem; font: inherit; border: 1px solid #8a94a6; border-radius: .35rem; }
|
||||
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
|
||||
.table-wrap { overflow-x: auto; } table { width: 100%; border-collapse: collapse; } th, td { padding: .6rem; text-align: left; border-bottom: 1px solid #d0d5dd; white-space: nowrap; }
|
||||
.danger { background: #b42318; }
|
||||
.warning { padding: .75rem; border-left: .25rem solid #b54708; background: #fffaeb; color: #7a2e0e; }
|
||||
button { border: 0; background: #3157d5; color: white; font-weight: 700; cursor: pointer; }
|
||||
.error { padding: .75rem; border-left: .25rem solid #b42318; background: #fee4e2; color: #7a271a; }
|
||||
@media (prefers-color-scheme: dark) { body { background: #111827; color: #e5e7eb; } main, header { background: #1f2937; } input { background: #111827; color: #e5e7eb; } }
|
||||
@media (max-width: 40rem) { main { padding: 1rem; margin: 1rem; width: auto; } header { padding: 1rem; } .grid { grid-template-columns: 1fr; } }
|
||||
@media (prefers-color-scheme: dark) { body { background: #111827; color: #e5e7eb; } main, header { background: #1f2937; } input, textarea, select { background: #111827; color: #e5e7eb; } }
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
{{define "home.html"}}<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · DoGaMa</title><link rel="stylesheet" href="/static/app.v1.css"></head>
|
||||
<body><header><strong>{{msg "brand"}}</strong><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">{{msg "logout.submit"}}</button></form></header><main><h1>{{msg "dashboard.title"}}</h1><p>{{msg "dashboard.signed_in"}} <strong>{{.User.Username}}</strong>.</p><p>{{msg "dashboard.ready"}}</p></main></body></html>{{end}}
|
||||
<body><header><strong>{{msg "brand"}}</strong><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">{{msg "logout.submit"}}</button></form></header>
|
||||
<main><h1>{{msg "dashboard.title"}}</h1><p>{{msg "dashboard.signed_in"}} <strong>{{.User.Username}}</strong>.</p><p>{{msg "dashboard.ready"}}</p>
|
||||
{{if .IsAdmin}}
|
||||
<section><h2>Notification channels</h2><p>Secrets are encrypted and never displayed after saving. Delivery is queued and retried without blocking operations.</p>
|
||||
<ul>{{range .Channels}}<li><strong>{{.Name}}</strong> — {{.Type}} · {{if .Enabled}}enabled{{else}}disabled{{end}}
|
||||
<form class="inline" method="post" action="/admin/notification-channels/{{.ID}}/test"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><button type="submit">Send test</button></form>
|
||||
<form class="inline" method="post" action="/admin/notification-channels/{{.ID}}/delete"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><button class="danger" type="submit">Delete</button></form></li>{{else}}<li>No channel configured.</li>{{end}}</ul>
|
||||
<form method="post" action="/admin/notification-channels"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Name<input name="name" required></label><label>Type<select name="type"><option value="webhook">HTTPS webhook</option><option value="discord">Discord webhook</option><option value="email">SMTP email</option></select></label><label>HTTPS URL<input name="url" type="url" placeholder="https://…"></label><label>Signing secret<input name="signing_secret" type="password" autocomplete="new-password"></label><div class="grid"><label>SMTP host<input name="host"></label><label>Port<input name="port" inputmode="numeric" placeholder="587"></label><label>Username<input name="smtp_username"></label><label>Password<input name="smtp_password" type="password" autocomplete="new-password"></label><label>From<input name="from" type="email"></label><label>Recipients<input name="to"></label></div><label>Events (space separated)<input name="events" value="backup.failed update.failed restore.failed security.required"></label><button type="submit">Add channel</button></form></section>
|
||||
<section><h2>Audit retention</h2><form method="post" action="/admin/audit-policy"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><div class="grid"><label>Retention days<input name="retention_days" type="number" min="0" max="3650" value="{{.AuditPolicy.RetentionDays}}"></label><label>Maximum entries<input name="maximum_count" type="number" min="0" max="1000000" value="{{.AuditPolicy.MaximumCount}}"></label></div><p>Zero means unlimited and may grow the database indefinitely.</p><button type="submit">Save retention</button></form>
|
||||
<form method="post" action="/admin/audit-purge"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Delete events before<input name="before" type="date" required></label><label><input name="confirm" type="checkbox" value="yes"> Confirm bounded audit purge</label><button class="danger" type="submit">Purge audit events</button></form></section>
|
||||
<section><h2>Recent audit events</h2><form method="get" action="/"><div class="grid"><label>Actor ID<input name="actor_id" value="{{.AuditActor}}"></label><label>Instance ID<input name="instance_id" value="{{.AuditInstance}}"></label><label>Action<input name="action" value="{{.AuditAction}}"></label><label>Outcome<select name="outcome"><option value="">Any</option><option value="allowed">Allowed</option><option value="denied">Denied</option><option value="failed">Failed</option></select></label></div><button type="submit">Filter audit</button></form>
|
||||
<div class="table-wrap"><table><thead><tr><th>Time</th><th>Actor</th><th>Action</th><th>Outcome</th><th>Instance</th></tr></thead><tbody>{{range .AuditEvents}}<tr><td>{{.OccurredAt.Format "2006-01-02 15:04:05Z"}}</td><td>{{.ActorLabel}}</td><td><code>{{.Action}}</code></td><td>{{.Outcome}}</td><td>{{.InstanceID}}</td></tr>{{else}}<tr><td colspan="5">No audit event.</td></tr>{{end}}</tbody></table></div></section>
|
||||
<section><h2>Game-container labels</h2><form method="post" action="/admin/game-container-labels"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Global labels<textarea name="labels" rows="8" placeholder="key=value">{{.GlobalLabels}}</textarea></label><p>One label per line. Available variables: <code>{{`{{game.name}}`}}</code>, <code>{{`{{game.id}}`}}</code>, <code>{{`{{game.icon_url}}`}}</code>, <code>{{`{{instance.name}}`}}</code>, <code>{{`{{instance.id}}`}}</code>, <code>{{`{{instance.slug}}`}}</code>, <code>{{`{{server.name}}`}}</code>.</p><label><input type="radio" name="apply" value="next_start" checked> Apply on next start</label><label><input type="radio" name="apply" value="immediate"> Apply immediately</label><aside class="warning"><strong>Immediate application stops and recreates affected containers.</strong> Connected players are disconnected immediately. Persistent data is preserved and each instance returns to its previous running or stopped state.</aside><label><input type="checkbox" name="confirm_disconnection" value="yes"> I understand the immediate-disconnection warning</label><button type="submit">Save game-container labels</button></form></section>
|
||||
{{end}}</main></body></html>{{end}}
|
||||
|
||||
@@ -0,0 +1,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);
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE configuration_revisions (
|
||||
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
redacted_snapshot TEXT NOT NULL,
|
||||
reason TEXT NOT NULL CHECK (length(reason) BETWEEN 1 AND 100),
|
||||
created_by TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (instance_id, revision)
|
||||
);
|
||||
|
||||
CREATE INDEX configuration_revision_history_idx ON configuration_revisions(instance_id, revision DESC);
|
||||
@@ -0,0 +1,40 @@
|
||||
CREATE TABLE notification_channels (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK (type IN ('email', 'webhook', 'discord')),
|
||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
||||
encrypted_config BLOB NOT NULL,
|
||||
event_filter_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE notification_deliveries (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
payload_redacted TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'retrying', 'succeeded', 'failed')),
|
||||
attempt INTEGER NOT NULL DEFAULT 0 CHECK (attempt >= 0),
|
||||
next_attempt_at TEXT NOT NULL,
|
||||
last_error_code TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
);
|
||||
CREATE INDEX notification_deliveries_due_idx ON notification_deliveries(status, next_attempt_at);
|
||||
|
||||
CREATE TABLE audit_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
occurred_at TEXT NOT NULL,
|
||||
actor_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
actor_label TEXT NOT NULL,
|
||||
instance_id TEXT REFERENCES instances(id) ON DELETE SET NULL,
|
||||
action TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL CHECK (outcome IN ('allowed', 'denied', 'failed')),
|
||||
summary_json TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
CREATE INDEX audit_events_time_idx ON audit_events(occurred_at DESC, id DESC);
|
||||
CREATE INDEX audit_events_filters_idx ON audit_events(actor_id, instance_id, action, outcome);
|
||||
|
||||
INSERT INTO system_settings(key, value_json, revision, updated_at)
|
||||
VALUES ('audit_policy', '{"retention_days":30,"maximum_count":10000}', 1, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -58,4 +58,4 @@ configuration:
|
||||
|
||||
artifacts:
|
||||
wasm: module.wasm
|
||||
sha256: "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
sha256: "e4b19ecfe66f4d9fb2c6451b82e6989c6eaf8887ec12cef2e97811527b1ff4ea"
|
||||
|
||||
Binary file not shown.
@@ -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() {}
|
||||
@@ -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() {}
|
||||
Reference in New Issue
Block a user