From 46bc90a0e847cfb57e1e4ad0c9fba24b6b32ad0e Mon Sep 17 00:00:00 2001 From: Tony Date: Sun, 9 Aug 2026 22:11:21 +0200 Subject: [PATCH] feat(release): harden production packaging --- .env.example | 7 + .gitea/workflows/ci.yml | 38 ++++ .gitea/workflows/release.yml | 96 +++++++++ .golangci.yml | 10 + Dockerfile | 5 +- README.md | 215 ++++++++++++++------- cmd/dogama-init/main.go | 52 +++++ cmd/dogama/main.go | 18 +- compose.yaml | 65 ++++--- docs/PROJECT-STATE.md | 9 +- docs/architecture/docker-agent.md | 4 +- docs/architecture/main-application.md | 4 +- docs/contributing/development.md | 12 +- docs/operations/deployment-and-release.md | 103 ++++------ docs/operations/notifications-and-audit.md | 7 +- docs/security/security-and-threat-model.md | 4 +- internal/agent/agent_config.go | 12 +- internal/agent/agent_config_test.go | 8 +- internal/internalsecrets/secrets.go | 107 ++++++++++ internal/internalsecrets/secrets_test.go | 83 ++++++++ tools/create_gitea_release.py | 35 ++++ tools/release.sh | 2 + tools/validate_spec.py | 39 +++- 23 files changed, 739 insertions(+), 196 deletions(-) create mode 100644 .env.example create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitea/workflows/release.yml create mode 100644 .golangci.yml create mode 100644 cmd/dogama-init/main.go create mode 100644 internal/internalsecrets/secrets.go create mode 100644 internal/internalsecrets/secrets_test.go create mode 100644 tools/create_gitea_release.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..256d4cb --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Public deployment settings only. Internal paths and secrets are automatic. +DOGAMA_VERSION=latest +DOGAMA_HTTP_PORT=8080 +TZ=UTC +DOGAMA_DATA_PATH=./data +DOGAMA_SERVERS_PATH=./data/servers +DOGAMA_BACKUPS_PATH=./data/backups diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..8c0253b --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install specification validators + run: python -m pip install --requirement tools/requirements-validation.txt + - name: Check formatting + run: test -z "$(gofmt -l .)" + - name: Test + run: go test ./... + - name: Build without cgo + run: CGO_ENABLED=0 go build ./... + - name: Race detection + run: go test -race ./... + - name: Vet + run: go vet ./... + - name: Staticcheck + run: go run honnef.co/go/tools/cmd/staticcheck@v0.6.1 ./... + - uses: golangci/golangci-lint-action@v8 + with: + version: v2.4.0 + - name: Validate specifications and Compose contract + run: python tools/validate_spec.py diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..016d942 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,96 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python -m pip install --requirement tools/requirements-validation.txt + - name: Validate release tag + run: echo "$GITHUB_REF_NAME" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' + - name: Check formatting + run: test -z "$(gofmt -l .)" + - run: go test ./... + - run: CGO_ENABLED=0 go build ./... + - run: go test -race ./... + - run: go vet ./... + - run: go run honnef.co/go/tools/cmd/staticcheck@v0.6.1 ./... + - uses: golangci/golangci-lint-action@v8 + with: + version: v2.4.0 + - run: python tools/validate_spec.py + + publish: + needs: validate + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - name: Derive release metadata + shell: bash + run: | + version="${GITHUB_REF_NAME#v}" + echo "VERSION=$version" >> "$GITHUB_ENV" + if [[ "$version" == *-* ]]; then echo "PRERELEASE=true" >> "$GITHUB_ENV"; else echo "PRERELEASE=false" >> "$GITHUB_ENV"; fi + - name: Log in to the Gitea registry + shell: bash + run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.zaynet.fr --username codex --password-stdin + - name: Publish application image + shell: bash + run: | + tags="--tag git.zaynet.fr/dogama/dogama:$VERSION" + if [[ "$PRERELEASE" == false ]]; then tags="$tags --tag git.zaynet.fr/dogama/dogama:latest"; fi + docker buildx build --platform linux/amd64,linux/arm64 --target dogama --build-arg VERSION="$GITHUB_REF_NAME" --build-arg COMMIT="$GITHUB_SHA" $tags --push . + - name: Publish agent image + shell: bash + run: | + tags="--tag git.zaynet.fr/dogama/dogama-agent:$VERSION" + if [[ "$PRERELEASE" == false ]]; then tags="$tags --tag git.zaynet.fr/dogama/dogama-agent:latest"; fi + docker buildx build --platform linux/amd64,linux/arm64 --target dogama-agent --build-arg VERSION="$GITHUB_REF_NAME" --build-arg COMMIT="$GITHUB_SHA" $tags --push . + - name: Build release archives + run: make release VERSION="$GITHUB_REF_NAME" + - name: Create release notes + shell: bash + run: | + previous="$(git describe --tags --abbrev=0 "$GITHUB_SHA^" 2>/dev/null || true)" + if [[ -n "$previous" ]]; then range="$previous..$GITHUB_SHA"; else range="$GITHUB_SHA"; fi + { + echo "Container images:" + echo "- git.zaynet.fr/dogama/dogama:$VERSION" + echo "- git.zaynet.fr/dogama/dogama-agent:$VERSION" + echo + echo "Changes included in this tag:" + git log --format='- %s (%h)' "$range" + } > release-notes.md + - name: Create Gitea release + env: + GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + GITEA_SERVER_URL: ${{ github.server_url }} + GITEA_REPOSITORY: ${{ github.repository }} + RELEASE_TAG: ${{ github.ref_name }} + RELEASE_COMMIT: ${{ github.sha }} + run: python tools/create_gitea_release.py release-notes.md diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..4bebd52 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,10 @@ +version: "2" + +# Staticcheck and go vet run as dedicated gates. Keep this aggregate gate +# focused until the repository's pre-existing errcheck debt is addressed. +linters: + default: none + enable: + - govet + - ineffassign + - unused diff --git a/Dockerfile b/Dockerfile index cf70f6f..7eb4ac8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,11 +12,14 @@ RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -buildvcs=false \ -ldflags="-s -w -X main.version=$VERSION -X main.commit=$COMMIT" -o /out/dogama ./cmd/dogama && \ CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -buildvcs=false \ - -ldflags="-s -w -X main.version=$VERSION -X main.commit=$COMMIT" -o /out/dogama-agent ./cmd/dogama-agent + -ldflags="-s -w -X main.version=$VERSION -X main.commit=$COMMIT" -o /out/dogama-agent ./cmd/dogama-agent && \ + CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -buildvcs=false \ + -ldflags="-s -w" -o /out/dogama-init ./cmd/dogama-init FROM gcr.io/distroless/static-debian12:nonroot@sha256:f5b485ea962d9bd1186b2f6b3a061191539b905b82ec395de78cbfae51f20e35 AS dogama WORKDIR /var/lib/dogama COPY --from=build /out/dogama /usr/local/bin/dogama +COPY --from=build /out/dogama-init /usr/local/bin/dogama-init EXPOSE 8080 ENTRYPOINT ["/usr/local/bin/dogama"] diff --git a/README.md b/README.md index 940e73f..3e24063 100644 --- a/README.md +++ b/README.md @@ -1,84 +1,165 @@ # DoGaMa -![DoGaMa synthwave banner](docs/assets/dogama-brand-banner.png) +![DoGaMa banner](docs/assets/dogama-brand-banner.png) -DoGaMa is a lightweight, self-hosted manager for private game servers running as Docker containers. It is designed for families and small groups of friends, not for commercial hosting or general Docker administration. +DoGaMa is a lightweight, self-hosted manager for private Docker game servers. It provides a web interface for families and small groups while keeping direct Docker access isolated in a restricted private agent. -This repository contains the deployable DoGaMa V1 implementation and its normative product and engineering contracts. +DoGaMa V1 is feature-complete. Linux is the production target; advanced instance, catalog and backup workflows remain partly API-first. -## Product invariants +## Features -- DoGaMa only displays and operates game-server containers that it created or explicitly adopted through a controlled administrator workflow. -- The main application never mounts the Docker socket. A separate, private, restricted agent is the only component allowed to reach Docker. -- The main application is a small Go service with an embedded web UI and SQLite. -- Game-specific integrations are exclusively lightweight WebAssembly adapters. They never run as privileged host processes or sidecar containers. -- A module may contact only the API endpoint of its assigned instance, through host-provided functions and declared ports. -- Templates are declarative, versioned YAML documents validated against a JSON Schema. -- Almost all operational configuration is performed in the web interface. `compose.yaml` only bootstraps DoGaMa itself. -- Secrets are never returned after submission, logged, audited, or included in normal exports. -- Destructive operations preserve player data and backups by default. +- Local administrator bootstrap, accounts, sessions, roles and per-instance permissions. +- Validated game catalog and deployment previews, with Palworld as the reference integration. +- Controlled create, inspect, start, stop, restart, update and container-only deletion workflows. +- Persistent player data, scheduled and manual backups, safe import, export and restore. +- Digest-aware updates with optional safety backups, readiness checks and rollback. +- Sandboxed WebAssembly game adapters; no native plugins or host scripts. +- Encrypted notification channels, bounded delivery retries and security-focused audit events. +- Responsive server-rendered web interface with no external runtime asset dependency. -## Documentation map +## Screenshots -### Product +The repository currently includes the official banner above but no maintained product screenshots. Screenshots will be added only when they can be kept aligned with released UI behavior. -- [Vision and scope](docs/product/vision-and-scope.md) -- [V1 acceptance criteria](docs/product/acceptance-criteria.md) -- [Roadmap](docs/product/roadmap.md) +## Installation -### Architecture and domain +Install Docker Engine with the Compose plugin on a Linux host. Save the repository's [`compose.yaml`](compose.yaml), optionally copy [`.env.example`](.env.example) to `.env`, then run: -- [System architecture](docs/architecture/system-architecture.md) -- [Main application](docs/architecture/main-application.md) -- [Restricted Docker agent](docs/architecture/docker-agent.md) -- [WebAssembly module runtime](docs/architecture/wasm-modules.md) -- [Data model](docs/domain/data-model.md) -- [Roles and permissions](docs/domain/authorization.md) -- [Instance lifecycle](docs/domain/instance-lifecycle.md) - -### Operations and security - -- [Backups, import, restore and export](docs/operations/backups-import-export.md) -- [Resources, ports, storage, mods and updates](docs/operations/instance-operations.md) -- [Notifications and audit](docs/operations/notifications-and-audit.md) -- [Deployment and release](docs/operations/deployment-and-release.md) -- [Security and threat model](docs/security/security-and-threat-model.md) -- [Administration and manager interfaces](docs/ux/interfaces.md) - -### Contributor contracts - -- [Current operational project state](docs/PROJECT-STATE.md) -- [Development conventions](docs/contributing/development.md) -- [Contributor testing](docs/contributing/testing.md) -- [AI and Codex contributor guide](docs/contributing/ai-codex-guide.md) -- [Template schema](specs/template.schema.json) -- [Module manifest schema](specs/module-manifest.schema.json) -- [Normalized module API](specs/normalized-module-api.md) -- [Palworld reference template](catalog/palworld/template.yaml) -- [Palworld reference module manifest](modules/palworld-rest/manifest.yaml) - -## Intended deployment - -```text -Browser - | - v -DoGaMa main application ---- SQLite / catalog / backups - | - | private authenticated API - v -Restricted Docker agent ---- Docker socket - | - v -Managed game-server containers +```sh +mkdir -p data/servers data/backups +docker compose pull +docker compose up -d ``` -Only the main application's HTTP port is published. The agent and game-management APIs remain on private Docker networks. Individual game ports are published by the managed instances according to approved templates and administrator configuration. +The default web address is `http://HOST:8080`. DoGaMa generates its internal agent token and encryption key automatically on first start; do not create or add them to `.env`. -## Status +A complete minimal Compose deployment is: -The V1 roadmap is implemented. See the current operational baseline and known limitations in [PROJECT-STATE.md](docs/PROJECT-STATE.md), and use the deployment guide for production installation and release verification. +```yaml +services: + init: + image: git.zaynet.fr/dogama/dogama:${DOGAMA_VERSION:-latest} + user: "0:0" + entrypoint: ["/usr/local/bin/dogama-init"] + volumes: + - ${DOGAMA_DATA_PATH:-./data}:/var/lib/dogama + - agent_state:/var/lib/dogama-agent + - ${DOGAMA_SERVERS_PATH:-./data/servers}:/srv/game-servers + - ${DOGAMA_BACKUPS_PATH:-./data/backups}:/srv/game-backups + cap_add: [CHOWN, DAC_READ_SEARCH, FOWNER] -## Validate the specification + dogama: + image: git.zaynet.fr/dogama/dogama:${DOGAMA_VERSION:-latest} + restart: unless-stopped + ports: + - "${DOGAMA_HTTP_PORT:-8080}:8080" + environment: + DOGAMA_AGENT_URL: http://agent:8081 + volumes: + - ${DOGAMA_DATA_PATH:-./data}:/var/lib/dogama + - agent_state:/var/lib/dogama-agent:ro + - ${DOGAMA_SERVERS_PATH:-./data/servers}:/srv/game-servers + - ${DOGAMA_BACKUPS_PATH:-./data/backups}:/srv/game-backups + depends_on: + init: + condition: service_completed_successfully + agent: + condition: service_started -Install the temporary validation dependencies from `tools/requirements-validation.txt`, then run `python tools/validate_spec.py`. The check validates both JSON Schemas, YAML examples, cross-referenced ports/mounts/capabilities, packaged-asset checksums, JSON fixtures, requirement coverage and internal Markdown links. + agent: + image: git.zaynet.fr/dogama/dogama-agent:${DOGAMA_VERSION:-latest} + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - agent_state:/var/lib/dogama-agent + - ${DOGAMA_SERVERS_PATH:-./data/servers}:/srv/game-servers + - ${DOGAMA_BACKUPS_PATH:-./data/backups}:/srv/game-backups + depends_on: + init: + condition: service_completed_successfully + +volumes: + agent_state: +``` + +Use the repository Compose file for its complete network and container-hardening settings; the excerpt shows only the installation contract. + +## Initial setup + +Open `/setup`, create the first administrator, then sign in. Configure global labels and notification channels from Settings. Create game instances only after checking the host paths, ports and backup policy shown by the deployment preview. + +## Updating + +Pin `DOGAMA_VERSION` to a released version such as `0.1.0`, back up the DoGaMa data directory, then pull and recreate: + +```sh +docker compose pull +docker compose up -d +``` + +Never delete `data`, the server/backup directories or the internal `agent_state` volume during an update. + +## Volumes and persistent data + +| Host setting | Container path | Contents | +|---|---|---| +| `DOGAMA_DATA_PATH` | `/var/lib/dogama` | SQLite database, imports and the application-only master key | +| `DOGAMA_SERVERS_PATH` | `/srv/game-servers` | Game-server configuration and player data | +| `DOGAMA_BACKUPS_PATH` | `/srv/game-backups` | DoGaMa-managed game backups | + +`agent_state` is an internal named volume. It contains the shared authentication token and the authenticated agent registry that prevents operations against unknown containers. It is not a user configuration surface, but it must be backed up with the other DoGaMa state. + +## Ports + +| Port | Exposure | Purpose | +|---|---|---| +| `8080/tcp` | Host, configurable | DoGaMa web interface and API | +| `8081/tcp` | Private Compose network only | Authenticated application-to-agent API | + +Managed game ports are selected per instance from validated templates. + +## Environment variables + +| Variable | Default | Purpose | +|---|---|---| +| `DOGAMA_VERSION` | `latest` | Image version; pin a release in production | +| `DOGAMA_HTTP_PORT` | `8080` | Published web port | +| `TZ` | `UTC` | Container timezone | +| `DOGAMA_DATA_PATH` | `./data` | Host path for DoGaMa data | +| `DOGAMA_SERVERS_PATH` | `./data/servers` | Host path for game-server data | +| `DOGAMA_BACKUPS_PATH` | `./data/backups` | Host path for backups | + +Internal container paths, allowed roots and service authentication are intentionally not configurable through the public Compose interface. + +## Security + +- The main application never mounts the Docker socket. +- Only the private, non-published agent can access Docker, through typed and deny-by-default operations. +- The application runs non-root with a read-only root filesystem; both long-running services drop Linux capabilities. +- Internal secrets are generated from the operating system cryptographic random source, stored with restrictive permissions and never logged or exposed in the UI. +- The master key is mounted only through the application data path; the agent has no access to it. +- Agent path checks remain fixed to `/srv/game-servers` and `/srv/game-backups` inside the containers. + +Use a trusted TLS reverse proxy and restrict access to all host data directories. The Docker agent still has host-equivalent power through the socket and must never be published. + +## Supported platforms + +Release images and archives target Linux `amd64` and `arm64`. The full validation suite is designed for Linux; native Windows execution is not supported. + +## Documentation + +- [Deployment and release](docs/operations/deployment-and-release.md) +- [Current project state](docs/PROJECT-STATE.md) +- [Architecture](docs/architecture/system-architecture.md) +- [Restricted Docker agent](docs/architecture/docker-agent.md) +- [Security and threat model](docs/security/security-and-threat-model.md) +- [Contributor development guide](docs/contributing/development.md) +- [Contributor testing guide](docs/contributing/testing.md) + +## Support and issues + +Report reproducible problems in the [Gitea issue tracker](https://git.zaynet.fr/DoGaMa/DoGaMa-serv/issues). Include the DoGaMa version, host architecture and sanitized logs; never attach secrets, databases or player data. + +## License + +No repository-wide license file is currently present, so no general redistribution license is asserted here. The Palworld reference module has its own [license](modules/palworld-rest/LICENSE). A project-wide license must be added by the owner before public distribution. diff --git a/cmd/dogama-init/main.go b/cmd/dogama-init/main.go new file mode 100644 index 0000000..8648ad3 --- /dev/null +++ b/cmd/dogama-init/main.go @@ -0,0 +1,52 @@ +// Command dogama-init initializes persistent secrets for the standard Compose deployment. +package main + +import ( + "fmt" + "os" + "strconv" + + "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/internalsecrets" +) + +func main() { + uid, gid := integer("DOGAMA_APP_UID", 65532), integer("DOGAMA_APP_GID", 65532) + items := []struct { + path string + uid, gid int + mode os.FileMode + }{ + {"/var/lib/dogama-agent/secrets/token", 0, gid, 0o640}, + {"/var/lib/dogama/secrets/master_key", uid, gid, 0o600}, + } + for _, item := range items { + if _, err := internalsecrets.Ensure(item.path, item.uid, item.gid, item.mode); err != nil { + fmt.Fprintf(os.Stderr, "internal secret initialization failed: %v\n", err) + os.Exit(1) + } + } + for _, path := range []string{"/var/lib/dogama", "/srv/game-servers", "/srv/game-backups"} { + if err := prepareDirectory(path, uid, gid); err != nil { + fmt.Fprintf(os.Stderr, "persistent directory initialization failed: %v\n", err) + os.Exit(1) + } + } +} + +func prepareDirectory(path string, uid, gid int) error { + if err := os.MkdirAll(path, 0o750); err != nil { + return err + } + if err := os.Chmod(path, 0o750); err != nil { + return err + } + return os.Chown(path, uid, gid) +} + +func integer(name string, fallback int) int { + value, err := strconv.Atoi(os.Getenv(name)) + if err != nil || value < 0 { + return fallback + } + return value +} diff --git a/cmd/dogama/main.go b/cmd/dogama/main.go index fd9ce6f..02163d4 100644 --- a/cmd/dogama/main.go +++ b/cmd/dogama/main.go @@ -21,6 +21,7 @@ import ( "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/internalsecrets" "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" @@ -65,24 +66,31 @@ func run(logger *slog.Logger) error { var backupService *backup.Service auditService := audit.New(db) var notificationService *notification.Service - if keyFile := os.Getenv("DOGAMA_MASTER_KEY_FILE"); keyFile != "" { + keyFile := environment("DOGAMA_MASTER_KEY_FILE", "secrets/master_key") + if keyFile == "secrets/master_key" { + if _, keyErr := internalsecrets.Ensure(keyFile, os.Getuid(), os.Getgid(), 0o600); keyErr != nil { + return errors.New("initialize encryption key file") + } + } + if 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") + agentURL := os.Getenv("DOGAMA_AGENT_URL") + tokenFile := os.Getenv("DOGAMA_AGENT_TOKEN_FILE") + if agentURL != "" && tokenFile == "" { + tokenFile = "/var/lib/dogama-agent/secrets/token" + } if agentURL == "" && tokenFile == "" { logger.Warn("instance lifecycle disabled", "event", "lifecycle.disabled") } else { diff --git a/compose.yaml b/compose.yaml index ad81fe1..999a28e 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,6 +1,25 @@ services: + init: + image: git.zaynet.fr/dogama/dogama:${DOGAMA_VERSION:-latest} + user: "0:0" + read_only: true + entrypoint: ["/usr/local/bin/dogama-init"] + volumes: + - ${DOGAMA_DATA_PATH:-./data}:/var/lib/dogama + - agent_state:/var/lib/dogama-agent + - ${DOGAMA_SERVERS_PATH:-./data/servers}:/srv/game-servers + - ${DOGAMA_BACKUPS_PATH:-./data/backups}:/srv/game-backups + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - CHOWN + - DAC_READ_SEARCH + - FOWNER + dogama: - image: ghcr.io/dogama/dogama:${DOGAMA_VERSION:-latest} + image: git.zaynet.fr/dogama/dogama:${DOGAMA_VERSION:-latest} restart: unless-stopped read_only: true ports: @@ -8,18 +27,11 @@ services: environment: TZ: ${TZ:-UTC} 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 volumes: - - ./data:/var/lib/dogama - - ${DOGAMA_SERVERS_ROOT:-/srv/game-servers}:/srv/game-servers - - ${DOGAMA_BACKUPS_ROOT:-/srv/game-backups}:/srv/game-backups + - ${DOGAMA_DATA_PATH:-./data}:/var/lib/dogama + - agent_state:/var/lib/dogama-agent:ro + - ${DOGAMA_SERVERS_PATH:-./data/servers}:/srv/game-servers + - ${DOGAMA_BACKUPS_PATH:-./data/backups}:/srv/game-backups tmpfs: - /tmp:rw,noexec,nosuid,nodev,size=32m security_opt: @@ -31,28 +43,22 @@ services: - control - games depends_on: - - agent + init: + condition: service_completed_successfully + agent: + condition: service_started agent: - image: ghcr.io/dogama/dogama-agent:${DOGAMA_VERSION:-latest} + image: git.zaynet.fr/dogama/dogama-agent:${DOGAMA_VERSION:-latest} restart: unless-stopped read_only: true environment: TZ: ${TZ:-UTC} - DOGAMA_AGENT_LISTEN_ADDRESS: :8081 - DOGAMA_AGENT_TOKEN_FILE: /run/secrets/agent_token - DOGAMA_AGENT_REGISTRY_PATH: /var/lib/dogama-agent/registry.json - DOGAMA_DOCKER_SOCKET: /var/run/docker.sock - DOGAMA_DOCKER_NETWORK: dogama-games - DOGAMA_ALLOWED_SERVER_ROOT: /srv/game-servers - DOGAMA_ALLOWED_BACKUP_ROOT: /srv/game-backups - secrets: - - agent_token volumes: - /var/run/docker.sock:/var/run/docker.sock - agent_state:/var/lib/dogama-agent - - ${DOGAMA_SERVERS_ROOT:-/srv/game-servers}:/srv/game-servers - - ${DOGAMA_BACKUPS_ROOT:-/srv/game-backups}:/srv/game-backups + - ${DOGAMA_SERVERS_PATH:-./data/servers}:/srv/game-servers + - ${DOGAMA_BACKUPS_PATH:-./data/backups}:/srv/game-backups tmpfs: - /tmp:rw,noexec,nosuid,nodev,size=16m security_opt: @@ -62,6 +68,9 @@ services: networks: - control - games + depends_on: + init: + condition: service_completed_successfully networks: frontend: @@ -72,9 +81,3 @@ networks: volumes: agent_state: - -secrets: - agent_token: - file: ./secrets/agent_token - master_key: - file: ./secrets/master_key diff --git a/docs/PROJECT-STATE.md b/docs/PROJECT-STATE.md index 485bd2b..bf0c437 100644 --- a/docs/PROJECT-STATE.md +++ b/docs/PROJECT-STATE.md @@ -4,7 +4,7 @@ Read this compact operational baseline before starting a milestone. Open detaile ## Baseline -- Current reference: milestone 10 working branch from the UI-redesign baseline `cd65414`. +- Current reference: post-milestone-10 production-readiness branch from merged baseline `deb1088`. - Released SQLite migrations: `0001` through `0009`; never rewrite them. - Roadmap milestones 1-10 are implemented; this is the V1 feature baseline. @@ -37,6 +37,8 @@ Read this compact operational baseline before starting a milestone. Open detaile - Hardened read-only Compose services, capability dropping, private agent networking and distinct minimal OCI image targets. - Linux black-box bootstrap/authentication E2E coverage plus a documented disposable-Docker V1 release verification matrix. - Deterministic Linux `amd64`/`arm64` archives with embedded build identity, SPDX module SBOM and SHA-256 checksums. +- Minimal production Compose with automatic persistent internal secrets, fixed container path boundaries and no user-managed application-to-agent settings. +- Gitea CI for pull requests and `main`, plus tag-only multi-architecture image publication and Gitea Release creation. ## Durable decisions @@ -54,7 +56,8 @@ Read this compact operational baseline before starting a milestone. Open detaile - 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. +- The application generates and retains its 32-byte master key outside SQLite; the agent never receives it. Ciphertext is authenticated AES-GCM and secrets are never returned by list APIs. +- `agent_state` remains durable because it stores both the shared token and the MAC-protected instance-to-container registry; losing it must never trigger automatic adoption. - 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. @@ -67,7 +70,7 @@ Read this compact operational baseline before starting a milestone. Open detaile ## Validation and CI -- No repository-hosted Gitea/GitHub workflow files are present; release validation is host-agnostic and documented through `Makefile`, `AGENTS.md` and the contributor testing guide. +- `.gitea/workflows/ci.yml` validates pull requests and `main`; `.gitea/workflows/release.yml` publishes only tagged SemVer releases after the same required gate. - 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. diff --git a/docs/architecture/docker-agent.md b/docs/architecture/docker-agent.md index 1aeea51..a0576ae 100644 --- a/docs/architecture/docker-agent.md +++ b/docs/architecture/docker-agent.md @@ -54,7 +54,7 @@ io.dogama.template-version= io.dogama.plan-digest= ``` -Labels alone are insufficient. The agent keeps a durable registry of instance ID, expected container identity and plan digest, authenticated by an agent-local key or MAC. An operation succeeds only when request, registry and inspected labels agree. +Labels alone are insufficient. The agent keeps a durable registry of instance ID, expected container identity and plan digest, authenticated with a key derived from the persistent shared agent token. An operation succeeds only when request, registry and inspected labels agree. This registry is why the internal `agent_state` volume must persist: it cannot be reconstructed safely without adopting containers on labels alone. ## Deployment-plan validation @@ -83,7 +83,7 @@ V1 templates do not expose arbitrary Docker security options. The agent applies ## Authentication and replay defense -Requests use a shared secret read from a Docker secret file. Sign method, path, body digest, timestamp and nonce. Reject clock-skewed or reused nonces. Use constant-time comparison, small body limits and short timeouts. Rotate the token through an explicit maintenance workflow. +Requests use a shared token generated automatically in the internal `agent_state` volume before both services start. It is owned by `root:65532`, mode `0640`; the application receives the volume read-only and the agent never receives the application master key. Sign method, path, body digest, timestamp and nonce. Reject clock-skewed or reused nonces. Use constant-time comparison, small body limits and short timeouts. Rotate the token through an explicit maintenance workflow. The wire format is `DoGaMa-HMAC-SHA256 ` in the `Authorization` header, with `X-DoGaMa-Timestamp` in RFC 3339 and a random diff --git a/docs/architecture/main-application.md b/docs/architecture/main-application.md index c4bc975..879e01d 100644 --- a/docs/architecture/main-application.md +++ b/docs/architecture/main-application.md @@ -59,7 +59,7 @@ Package names express business capabilities. Avoid a generic `utils` package and ## Configuration precedence -1. Bootstrap-only environment or secret files: listen address, database/data root, agent endpoint/token file, master-key file and allowed bind roots. +1. Internal bootstrap contracts: listen address, database/data root, private agent endpoint, automatically generated token and master-key files, and fixed allowed bind roots. The public Compose interface exposes only host-side storage locations. 2. Global administrator settings in SQLite: public game address, defaults, audit retention, notification channels, upload limits and safety policies. 3. Template defaults. 4. Per-instance administrator settings. @@ -74,4 +74,4 @@ Production CSS and script assets are compiled before the Go build and embedded. ## Secret handling -Encrypt secret values with an authenticated encryption algorithm using a master key external to SQLite. Store key version and nonce with ciphertext. Support key rotation as a maintenance workflow. Decrypt only at the last responsible moment, keep plaintext lifetimes short and redact structured errors. +Encrypt secret values with an authenticated encryption algorithm using a persistent master key stored outside SQLite under the application data path. Generate it atomically at first startup, never replace an existing key, and never mount it into the Docker agent. Store key version and nonce with ciphertext. Support key rotation as a maintenance workflow. Decrypt only at the last responsible moment, keep plaintext lifetimes short and redact structured errors. diff --git a/docs/contributing/development.md b/docs/contributing/development.md index a9419b5..546c9dd 100644 --- a/docs/contributing/development.md +++ b/docs/contributing/development.md @@ -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`, `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: +The main application requires Go 1.25. SQLite uses the pure-Go `modernc.org/sqlite` driver, so neither cgo nor a system SQLite development library is required. Standard Compose supplies all internal bootstrap contracts and generates both secrets automatically. Direct developer execution may override `DOGAMA_LISTEN_ADDRESS`, `DOGAMA_DATABASE_PATH`, `DOGAMA_AGENT_URL`, `DOGAMA_AGENT_TOKEN_FILE` and `DOGAMA_MASTER_KEY_FILE`; lifecycle routes remain disabled when both agent overrides are absent. These are development controls, not public deployment settings. Run it with: ```sh go run ./cmd/dogama @@ -42,11 +42,11 @@ The restricted agent is a separate binary: go run ./cmd/dogama-agent ``` -It fails closed unless `DOGAMA_AGENT_TOKEN_FILE` references a 32-byte-or-longer -secret and at least one of `DOGAMA_ALLOWED_SERVER_ROOT` or -`DOGAMA_ALLOWED_BACKUP_ROOT` is configured. Its bootstrap-only defaults are -`:8081`, `/var/run/docker.sock`, the fixed `dogama-games` Docker network and -`/var/lib/dogama-agent/registry.json`. The configured roots must already exist +It fails closed unless its token file contains a 32-byte-or-longer secret. The +standard deployment uses `/var/lib/dogama-agent/secrets/token`, confines paths to +`/srv/game-servers` and `/srv/game-backups`, and stores the authenticated +registry at `/var/lib/dogama-agent/registry.json`. Developer overrides remain +available for isolated tests. The configured roots must already exist and are canonicalized with symlinks resolved. For normal deployment, use the secret file and private control network defined in `compose.yaml`; never publish the agent port on the host. diff --git a/docs/operations/deployment-and-release.md b/docs/operations/deployment-and-release.md index 1f7ae25..36264d4 100644 --- a/docs/operations/deployment-and-release.md +++ b/docs/operations/deployment-and-release.md @@ -1,77 +1,54 @@ # Deployment and release -## Production prerequisites +## Production deployment -DoGaMa V1 targets one Linux Docker host with the Compose plugin. Put the public -application behind a trusted TLS reverse proxy; never publish the agent port. -Create the server and backup roots on the host and restrict them to the -administrator responsible for DoGaMa. Back up `data/dogama.db`, the server -roots, and the backup roots using host-level tooling. - -Create secrets before the first start. Both files must be readable only by the -deployment administrator; the master key is exactly 32 bytes and the agent -token is at least 32 bytes. +DoGaMa V1 targets one Linux Docker host with the Compose plugin. Use the root `compose.yaml`, place the public HTTP service behind a trusted TLS reverse proxy, and never publish the agent port. ```sh -install -d -m 0700 secrets -install -d -m 0750 -o 65532 -g 65532 data -umask 077 -head -c 32 /dev/urandom > secrets/master_key -head -c 32 /dev/urandom > secrets/agent_token +cp .env.example .env +mkdir -p data/servers data/backups docker compose config --quiet -DOGAMA_VERSION=v1.0.0 docker compose up -d +docker compose pull +docker compose up -d ``` -Pin `DOGAMA_VERSION` to an immutable released version in production. The main -application runs as a non-root user with a read-only root filesystem, no Linux -capabilities and no Docker socket. The root-running restricted agent is isolated -on the private control network, has a read-only root filesystem and no added -capabilities; only it receives the Docker socket. The game and backup bind roots -remain writable because lifecycle and recovery workflows require them. +The one-shot `init` service creates the agent token and master encryption key from the operating system cryptographic random source before either long-running service starts. It uses atomic create-without-replacement behavior and restrictive ownership/modes. Repeated starts validate and reuse the existing files; invalid existing files stop initialization instead of generating a replacement. Secret values never enter Compose environment values or logs. -TLS termination must retain DoGaMa's CSP, HSTS, frame, MIME and referrer -headers. Do not make forwarded client addresses authoritative for login rate -limiting. After startup, verify that only the configured application port is -published and that normal use redirects to `/setup` until the first -administrator is created. +The application data path contains SQLite, import staging and the application-only master key. The internal `agent_state` volume contains the agent token and authenticated container-binding registry. The registry is durable security state: losing it makes existing containers unknown rather than silently adopting them. The agent never receives the master key. -The application image uses numeric UID/GID `65532:65532`. Grant that identity -write access to the configured server and backup roots (with ACLs or matching -ownership) while keeping access unavailable to unrelated host users. +Only host-side storage locations, image version, web port and timezone are public Compose settings. Container paths and allowed agent roots are fixed internal contracts. Back up the application data, game servers, backups and `agent_state` volume together. -## Release procedure - -From a clean, signed-off release commit, run the complete validation gate in -`AGENTS.md`, then create deterministic Linux archives: - -```sh -make release VERSION=v1.0.0 -(cd dist/dogama-v1.0.0 && sha256sum -c SHA256SUMS) -``` - -The release command refuses to overwrite an existing release directory. It -produces static `amd64` and `arm64` archives, embedded Go build information, an -SPDX 2.3 module SBOM and SHA-256 checksums. `SOURCE_DATE_EPOCH` defaults to the -release commit timestamp and may be supplied explicitly for reproduction. - -Build the two OCI images from the same commit and version: - -```sh -make images VERSION=v1.0.0 -``` - -The Dockerfile has distinct `dogama` and `dogama-agent` targets. Publish both -images under the same immutable version and record their registry digests in -the release notes. A release is complete only after a fresh-host Compose smoke -test, bootstrap, Palworld draft/install against a disposable Docker daemon, -backup/restore, failed-update rollback, and the security-denial checks described -in the contributor testing guide. +The application image uses numeric UID/GID `65532:65532`. Grant that identity write access to the configured application, server and backup paths. The short-lived initializer runs as root only to establish file ownership and retains `CAP_CHOWN`, `CAP_FOWNER` and read/search access needed to validate an existing mode-`0600` key; long-running services keep their existing restricted profiles. ## Upgrade and rollback -Stop the application, take a filesystem-consistent copy of the SQLite database, -then change only `DOGAMA_VERSION` and start Compose. Startup applies append-only -migrations before serving requests. Preserve the pre-upgrade database copy and -all player/backup roots. If startup or validation fails, stop the new containers, -restore the database copy, select the prior image version and start again. Never -roll back only the database while a newer application is writing to it. +No production deployment predates automatic secret initialization, so this change requires no legacy migration. For future updates, stop DoGaMa, take a filesystem-consistent backup of all four persistent stores, change only `DOGAMA_VERSION`, then run `docker compose pull` and `docker compose up -d`. + +Startup applies append-only SQLite migrations before serving requests. Never remove or recreate `agent_state`, and never replace an existing master key: doing so would invalidate the agent registry or make encrypted notification data unreadable. On failure, restore all state from the same backup point and select the prior image version. + +## Release pipeline + +Normal CI runs for pull requests and pushes to `main`; it does not publish images. `.gitea/workflows/release.yml` runs only for tags matching `v*.*.*`, validates the exact tag commit, then publishes the `dogama` and `dogama-agent` images for Linux `amd64` and `arm64`. + +The external tag keeps its `v` (`v0.1.0`), while container images use `0.1.0`. Stable releases also update `latest`; prerelease tags containing a hyphen never do. Images are published to: + +```text +git.zaynet.fr/dogama/dogama: +git.zaynet.fr/dogama/dogama-agent: +``` + +The workflow uses the protected Gitea Actions secret `REGISTRY_TOKEN` for registry login and Gitea Release creation. After all validations and both image pushes succeed, it creates `DoGaMa ` against the exact tagged commit with notes derived from actual commit subjects. Tokens are never stored in the repository or printed. + +The repository owner should protect the Gitea tag pattern `v*` so only authorized accounts can trigger production publication. Do not create a release tag until the candidate commit has passed the full disposable-host verification below. + +## Release verification + +Run the complete validation gate in `AGENTS.md`, then verify the release artifacts and both image targets: + +```sh +make release VERSION=v0.1.0 +(cd dist/dogama-v0.1.0 && sha256sum -c SHA256SUMS) +make images VERSION=v0.1.0 +``` + +On an isolated Docker host, confirm automatic secret initialization and reuse, application bootstrap, Palworld draft/install, lifecycle operations, backup/restore, failed-update rollback, and denial against unrelated containers. Confirm that only the web port is published, the main application lacks the Docker socket, the agent cannot access the master key, and player/backup data survive container recreation and interruption. diff --git a/docs/operations/notifications-and-audit.md b/docs/operations/notifications-and-audit.md index 52988df..a076240 100644 --- a/docs/operations/notifications-and-audit.md +++ b/docs/operations/notifications-and-audit.md @@ -10,9 +10,10 @@ 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 +The main application reads its automatically generated 32-byte +authenticated-encryption key from the private application data path. An invalid +or inaccessible existing key stops startup; DoGaMa never silently replaces a +key that protects existing ciphertext. 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 diff --git a/docs/security/security-and-threat-model.md b/docs/security/security-and-threat-model.md index 92ca56e..be1c149 100644 --- a/docs/security/security-and-threat-model.md +++ b/docs/security/security-and-threat-model.md @@ -14,7 +14,7 @@ - Internet users and authenticated non-admin users may be malicious. - Managers are trusted only for assigned permissions, not host administration. - Templates, modules, archives, artwork, webhooks and game responses are untrusted. -- The host administrator controls Compose, secret files and bind roots. +- The host administrator controls Compose and persistent host paths; DoGaMa initializes its internal secret files. - A fully compromised Docker daemon or host is outside DoGaMa's containment guarantee. ## Principal threats and controls @@ -41,7 +41,7 @@ V1 local accounts use a current password-hashing algorithm with calibrated parameters. Bootstrap accepts the first administrator only through a one-time local setup state. Sessions rotate at login/privilege change, can be revoked, and never appear in URLs. Critical actions require recent password confirmation. -The deployment documentation must recommend TLS through a trusted reverse proxy and restrictive permissions on `secrets/`, data and backup paths. +The deployment documentation must recommend TLS through a trusted reverse proxy and restrictive permissions on application data, server and backup paths. Internal secret files use restrictive ownership and permissions and are never public Compose inputs. ## Template and module trust diff --git a/internal/agent/agent_config.go b/internal/agent/agent_config.go index 7c6b30a..69dc169 100644 --- a/internal/agent/agent_config.go +++ b/internal/agent/agent_config.go @@ -21,17 +21,17 @@ type Config struct { // LoadConfig reads the agent's bootstrap settings and shared secret file. func LoadConfig() (Config, error) { - tokenFile := os.Getenv("DOGAMA_AGENT_TOKEN_FILE") - if tokenFile == "" { - return Config{}, errors.New("DOGAMA_AGENT_TOKEN_FILE is required") - } + tokenFile := environment("DOGAMA_AGENT_TOKEN_FILE", "/var/lib/dogama-agent/secrets/token") secret, err := readSecretFile(tokenFile) if err != nil { return Config{}, err } roots := make([]string, 0, 2) - for _, name := range []string{"DOGAMA_ALLOWED_SERVER_ROOT", "DOGAMA_ALLOWED_BACKUP_ROOT"} { - if value := os.Getenv(name); value != "" { + for _, item := range []struct{ name, fallback string }{ + {"DOGAMA_ALLOWED_SERVER_ROOT", "/srv/game-servers"}, + {"DOGAMA_ALLOWED_BACKUP_ROOT", "/srv/game-backups"}, + } { + if value := environment(item.name, item.fallback); value != "" { roots = append(roots, value) } } diff --git a/internal/agent/agent_config_test.go b/internal/agent/agent_config_test.go index e825805..dbe8dfc 100644 --- a/internal/agent/agent_config_test.go +++ b/internal/agent/agent_config_test.go @@ -27,16 +27,16 @@ func TestLoadConfigReadsSecretFileAndRoots(t *testing.T) { if err != nil { t.Fatal(err) } - if !bytes.Equal(config.Secret, secret) || len(config.AllowedRoots) != 1 || config.ListenAddress != ":8081" || config.DockerNetwork != "dogama-games" { + if !bytes.Equal(config.Secret, secret) || len(config.AllowedRoots) != 2 || config.ListenAddress != ":8081" || config.DockerNetwork != "dogama-games" { t.Fatalf("config = %#v", config) } } -func TestLoadConfigRequiresTokenAndRoot(t *testing.T) { - t.Setenv("DOGAMA_AGENT_TOKEN_FILE", "") +func TestLoadConfigUsesConfinedProductionDefaults(t *testing.T) { + t.Setenv("DOGAMA_AGENT_TOKEN_FILE", filepath.Join(t.TempDir(), "missing-token")) t.Setenv("DOGAMA_ALLOWED_SERVER_ROOT", "") t.Setenv("DOGAMA_ALLOWED_BACKUP_ROOT", "") if _, err := LoadConfig(); err == nil { - t.Fatal("missing token unexpectedly accepted") + t.Fatal("missing default token unexpectedly accepted") } } diff --git a/internal/internalsecrets/secrets.go b/internal/internalsecrets/secrets.go new file mode 100644 index 0000000..c664291 --- /dev/null +++ b/internal/internalsecrets/secrets.go @@ -0,0 +1,107 @@ +// Package internalsecrets initializes persistent deployment secrets without +// exposing their values through environment variables or logs. +package internalsecrets + +import ( + "crypto/rand" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" +) + +const secretSize = 32 + +// Ensure creates path atomically with cryptographically random bytes. Existing +// destination files are validated and never replaced. +func Ensure(path string, uid, gid int, mode os.FileMode) (bool, error) { + if mode.Perm()&0o007 != 0 || mode.Perm()&0o700 == 0 { + return false, errors.New("secret permissions are invalid") + } + if err := validateExisting(path, mode); err == nil { + return false, nil + } else if !errors.Is(err, os.ErrNotExist) { + return false, err + } + + value := make([]byte, secretSize) + if _, err := io.ReadFull(rand.Reader, value); err != nil { + return false, fmt.Errorf("generate secret: %w", err) + } + + directory := filepath.Dir(path) + directoryMode := os.FileMode(0o700) + if mode.Perm()&0o040 != 0 { + directoryMode = 0o750 + } + if err := os.MkdirAll(directory, directoryMode); err != nil { + return false, fmt.Errorf("create secret directory: %w", err) + } + if err := os.Chmod(directory, directoryMode); err != nil { + return false, fmt.Errorf("restrict secret directory: %w", err) + } + temporary, err := os.CreateTemp(directory, ".secret-*") + if err != nil { + return false, fmt.Errorf("create temporary secret: %w", err) + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(mode); err != nil { + temporary.Close() + return false, fmt.Errorf("restrict temporary secret: %w", err) + } + if _, err := temporary.Write(value); err != nil { + temporary.Close() + return false, fmt.Errorf("write temporary secret: %w", err) + } + if err := temporary.Sync(); err != nil { + temporary.Close() + return false, fmt.Errorf("sync temporary secret: %w", err) + } + if err := temporary.Close(); err != nil { + return false, fmt.Errorf("close temporary secret: %w", err) + } + if err := os.Link(temporaryPath, path); err != nil { + if errors.Is(err, os.ErrExist) { + return false, validateExisting(path, mode) + } + return false, fmt.Errorf("install secret: %w", err) + } + if runtime.GOOS != "windows" && os.Geteuid() == 0 { + if err := os.Chown(path, uid, gid); err != nil { + return false, fmt.Errorf("set secret owner: %w", err) + } + if err := os.Chown(directory, uid, gid); err != nil { + return false, fmt.Errorf("set secret directory owner: %w", err) + } + } + return true, nil +} + +func validateExisting(path string, mode os.FileMode) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("secret must be a regular file") + } + if runtime.GOOS != "windows" && info.Mode().Perm() != mode.Perm() { + return errors.New("secret permissions do not match the required mode") + } + _, err = readSecret(path) + return err +} + +func readSecret(path string) ([]byte, error) { + value, err := os.ReadFile(path) + if err != nil { + return nil, err + } + if len(value) != secretSize { + return nil, fmt.Errorf("secret must contain exactly %d bytes", secretSize) + } + return value, nil +} diff --git a/internal/internalsecrets/secrets_test.go b/internal/internalsecrets/secrets_test.go new file mode 100644 index 0000000..5c024eb --- /dev/null +++ b/internal/internalsecrets/secrets_test.go @@ -0,0 +1,83 @@ +package internalsecrets + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "sync" + "testing" +) + +func TestEnsureGeneratesAndReusesSecret(t *testing.T) { + path := filepath.Join(t.TempDir(), "private", "token") + created, err := Ensure(path, os.Getuid(), os.Getgid(), 0o600) + if err != nil || !created { + t.Fatalf("first Ensure() = %v, %v", created, err) + } + first, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + created, err = Ensure(path, os.Getuid(), os.Getgid(), 0o600) + if err != nil || created { + t.Fatalf("second Ensure() = %v, %v", created, err) + } + second, _ := os.ReadFile(path) + if len(first) != 32 || !bytes.Equal(first, second) { + t.Fatal("secret was not generated once and reused") + } + if runtime.GOOS != "windows" { + info, _ := os.Stat(path) + if info.Mode().Perm() != 0o600 { + t.Fatalf("secret permissions = %o", info.Mode().Perm()) + } + } +} + +func TestEnsureConcurrentInitializationCreatesOneSecret(t *testing.T) { + path := filepath.Join(t.TempDir(), "private", "token") + var wait sync.WaitGroup + created := make(chan bool, 2) + errors := make(chan error, 2) + for range 2 { + wait.Add(1) + go func() { + defer wait.Done() + wasCreated, err := Ensure(path, os.Getuid(), os.Getgid(), 0o600) + created <- wasCreated + errors <- err + }() + } + wait.Wait() + close(created) + close(errors) + createdCount := 0 + for wasCreated := range created { + if wasCreated { + createdCount++ + } + } + for err := range errors { + if err != nil { + t.Fatal(err) + } + } + if createdCount != 1 { + t.Fatalf("created count = %d, want 1", createdCount) + } +} + +func TestEnsureRefusesInvalidExistingSecret(t *testing.T) { + path := filepath.Join(t.TempDir(), "master_key") + if err := os.WriteFile(path, []byte("short"), 0o600); err != nil { + t.Fatal(err) + } + if created, err := Ensure(path, os.Getuid(), os.Getgid(), 0o600); err == nil || created { + t.Fatalf("invalid existing secret was accepted: %v, %v", created, err) + } + got, _ := os.ReadFile(path) + if string(got) != "short" { + t.Fatal("invalid existing secret was replaced") + } +} diff --git a/tools/create_gitea_release.py b/tools/create_gitea_release.py new file mode 100644 index 0000000..e68879e --- /dev/null +++ b/tools/create_gitea_release.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Create the Gitea release for the current, already validated tag.""" + +import json +import os +import sys +import urllib.request +from pathlib import Path + + +def main() -> int: + notes = Path(sys.argv[1]).read_text(encoding="utf-8") + tag = os.environ["RELEASE_TAG"] + payload = json.dumps({ + "tag_name": tag, + "target_commitish": os.environ["RELEASE_COMMIT"], + "name": f"DoGaMa {tag}", + "body": notes, + "draft": False, + "prerelease": "-" in tag, + }).encode() + url = f'{os.environ["GITEA_SERVER_URL"]}/api/v1/repos/{os.environ["GITEA_REPOSITORY"]}/releases' + request = urllib.request.Request(url, data=payload, method="POST", headers={ + "Authorization": f'token {os.environ["GITEA_TOKEN"]}', + "Content-Type": "application/json", + }) + with urllib.request.urlopen(request, timeout=30) as response: + if response.status != 201: + raise RuntimeError(f"Gitea release creation returned HTTP {response.status}") + print(f"Created Gitea release {tag}.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/release.sh b/tools/release.sh index 8f4558a..f8f2e48 100755 --- a/tools/release.sh +++ b/tools/release.sh @@ -23,6 +23,8 @@ for arch in amd64 arm64; do -ldflags="-s -w -X main.version=$version -X main.commit=$commit" -o "$stage/dogama" ./cmd/dogama CGO_ENABLED=0 GOOS=linux GOARCH=$arch go build -trimpath -buildvcs=false \ -ldflags="-s -w -X main.version=$version -X main.commit=$commit" -o "$stage/dogama-agent" ./cmd/dogama-agent + CGO_ENABLED=0 GOOS=linux GOARCH=$arch go build -trimpath -buildvcs=false \ + -ldflags="-s -w" -o "$stage/dogama-init" ./cmd/dogama-init go version -m "$stage/dogama" > "$stage/build-info.txt" tar --sort=name --owner=0 --group=0 --numeric-owner --mtime="@$epoch" \ -C "$release_dir" -czf "$release_dir/dogama-${version}-linux-$arch.tar.gz" "linux-$arch" diff --git a/tools/validate_spec.py b/tools/validate_spec.py index 99115c7..ed6de10 100644 --- a/tools/validate_spec.py +++ b/tools/validate_spec.py @@ -114,10 +114,47 @@ def validate_coverage() -> None: raise ValueError("Missing required coverage: " + ", ".join(missing)) +def validate_compose() -> None: + compose = load_yaml(ROOT / "compose.yaml") + services = compose["services"] + assert set(services) == {"init", "dogama", "agent"}, "Compose must expose only the initializer and required services" + forbidden = { + "DOGAMA_AGENT_TOKEN_FILE", "DOGAMA_MASTER_KEY_FILE", "DOGAMA_SERVERS_ROOT", + "DOGAMA_BACKUPS_ROOT", "DOGAMA_IMPORTS_ROOT", "DOGAMA_ALLOWED_SERVER_ROOT", + "DOGAMA_ALLOWED_BACKUP_ROOT", + } + for name, service in services.items(): + environment = service.get("environment", {}) + assert forbidden.isdisjoint(environment), f"{name} exposes an internal environment setting" + assert "secrets" not in service, f"{name} still requires a user-provided Compose secret" + assert "secrets" not in compose, "Compose still defines user-provided internal secrets" + assert "/var/run/docker.sock:/var/run/docker.sock" not in services["dogama"].get("volumes", []), "main application mounts Docker" + assert not services["agent"].get("ports"), "agent must not publish a port" + assert "agent_state:/var/lib/dogama-agent" in services["agent"]["volumes"], "authenticated agent registry is not persistent" + assert "agent_state:/var/lib/dogama-agent:ro" in services["dogama"]["volumes"], "main application cannot read the shared token" + assert all("master_key" not in volume for volume in services["agent"]["volumes"]), "agent can access the master key" + rendered = (ROOT / "compose.yaml").read_text(encoding="utf-8") + assert "${DOGAMA_SERVERS_PATH:-./data/servers}:/srv/game-servers" in rendered + assert "${DOGAMA_BACKUPS_PATH:-./data/backups}:/srv/game-backups" in rendered + + +def validate_workflows() -> None: + ci = (ROOT / ".gitea/workflows/ci.yml").read_text(encoding="utf-8") + release = (ROOT / ".gitea/workflows/release.yml").read_text(encoding="utf-8") + assert "pull_request:" in ci and "branches: [main]" in ci, "normal CI does not cover PRs and main" + assert "docker login" not in ci and "--push" not in ci, "normal CI can publish images" + assert 'tags:\n - "v*.*.*"' in release, "release workflow tag trigger is incorrect" + assert "needs: validate" in release, "release publication does not depend on validation" + assert release.count("--push .") == 2, "release workflow must publish exactly two image targets" + assert "create_gitea_release.py" in release, "release workflow does not create a Gitea Release" + assert "branches: [main]" not in release and "pull_request:" not in release, "release workflow has a non-tag trigger" + + def main() -> int: template = validate(ROOT / "specs/template.schema.json", ROOT / "catalog/palworld/template.yaml") manifest = validate(ROOT / "specs/module-manifest.schema.json", ROOT / "modules/palworld-rest/manifest.yaml") - load_yaml(ROOT / "compose.yaml") + validate_compose() + validate_workflows() for fixture in ROOT.rglob("*.json"): load_json(fixture) validate_links()