Merge pull request 'feat(release): complete milestone 10 hardening' (#13) from codex/milestone-10 into main
Reviewed-on: #13
This commit was merged in pull request #13.
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
.git
|
||||
.cache
|
||||
dist
|
||||
data
|
||||
secrets
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
# syntax=docker/dockerfile:1@sha256:87999aa3d42bdc6bea60565083ee17e86d1f3339802f543c0d03998580f9cb89
|
||||
FROM golang:1.25-bookworm@sha256:908f8ff2ec296df2f349563072c7925775cd28b50361a52ed834a8a37399b9bf AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod go mod download
|
||||
COPY . .
|
||||
ARG TARGETOS=linux
|
||||
ARG TARGETARCH
|
||||
ARG VERSION=dev
|
||||
ARG COMMIT=unknown
|
||||
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
|
||||
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
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot@sha256:f5b485ea962d9bd1186b2f6b3a061191539b905b82ec395de78cbfae51f20e35 AS dogama
|
||||
WORKDIR /var/lib/dogama
|
||||
COPY --from=build /out/dogama /usr/local/bin/dogama
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/usr/local/bin/dogama"]
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:latest@sha256:a9fcaedd4c9b59e12dd65d954f0b5044f19b0647a8a3712e77205df9e7b102cd AS dogama-agent
|
||||
WORKDIR /var/lib/dogama-agent
|
||||
COPY --from=build /out/dogama-agent /usr/local/bin/dogama-agent
|
||||
EXPOSE 8081
|
||||
ENTRYPOINT ["/usr/local/bin/dogama-agent"]
|
||||
@@ -0,0 +1,17 @@
|
||||
.PHONY: test e2e images release
|
||||
|
||||
VERSION ?= dev
|
||||
COMMIT ?= $(shell git rev-parse --short=12 HEAD)
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
e2e:
|
||||
go test ./tests/e2e -v
|
||||
|
||||
images:
|
||||
docker build --target dogama --build-arg VERSION=$(VERSION) --build-arg COMMIT=$(COMMIT) -t dogama:$(VERSION) .
|
||||
docker build --target dogama-agent --build-arg VERSION=$(VERSION) --build-arg COMMIT=$(COMMIT) -t dogama-agent:$(VERSION) .
|
||||
|
||||
release:
|
||||
./tools/release.sh $(VERSION)
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
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.
|
||||
This repository contains the deployable DoGaMa V1 implementation and its normative product and engineering contracts.
|
||||
|
||||
## Product invariants
|
||||
|
||||
@@ -41,6 +41,7 @@ This repository currently contains the normative product and engineering specifi
|
||||
- [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)
|
||||
|
||||
@@ -48,6 +49,7 @@ This repository currently contains the normative product and engineering specifi
|
||||
|
||||
- [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)
|
||||
@@ -75,7 +77,7 @@ Only the main application's HTTP port is published. The agent and game-managemen
|
||||
|
||||
## Status
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Validate the specification
|
||||
|
||||
|
||||
@@ -16,6 +16,11 @@ import (
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||
)
|
||||
|
||||
var (
|
||||
version = "dev"
|
||||
commit = "unknown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
if err := run(logger); err != nil {
|
||||
@@ -67,7 +72,7 @@ func run(logger *slog.Logger) error {
|
||||
defer stop()
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
logger.Info("agent listening", "event", "agent.started", "address", config.ListenAddress, "allowed_root_count", paths.RootCount())
|
||||
logger.Info("agent listening", "event", "agent.started", "address", config.ListenAddress, "allowed_root_count", paths.RootCount(), "version", version, "commit", commit)
|
||||
errCh <- server.ListenAndServe()
|
||||
}()
|
||||
select {
|
||||
|
||||
+7
-1
@@ -26,6 +26,11 @@ import (
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/web"
|
||||
)
|
||||
|
||||
var (
|
||||
version = "dev"
|
||||
commit = "unknown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
if err := run(logger); err != nil {
|
||||
@@ -127,10 +132,11 @@ func run(logger *slog.Logger) error {
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Minute,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
MaxHeaderBytes: 16 << 10,
|
||||
}
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
logger.Info("application listening", "event", "application.started", "address", listenAddress)
|
||||
logger.Info("application listening", "event", "application.started", "address", listenAddress, "version", version, "commit", commit)
|
||||
errCh <- server.ListenAndServe()
|
||||
}()
|
||||
select {
|
||||
|
||||
@@ -2,6 +2,7 @@ services:
|
||||
dogama:
|
||||
image: ghcr.io/dogama/dogama:${DOGAMA_VERSION:-latest}
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
ports:
|
||||
- "${DOGAMA_HTTP_PORT:-8080}:8080"
|
||||
environment:
|
||||
@@ -19,6 +20,12 @@ services:
|
||||
- ./data:/var/lib/dogama
|
||||
- ${DOGAMA_SERVERS_ROOT:-/srv/game-servers}:/srv/game-servers
|
||||
- ${DOGAMA_BACKUPS_ROOT:-/srv/game-backups}:/srv/game-backups
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,nodev,size=32m
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
networks:
|
||||
- frontend
|
||||
- control
|
||||
@@ -50,6 +57,8 @@ services:
|
||||
- /tmp:rw,noexec,nosuid,nodev,size=16m
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
networks:
|
||||
- control
|
||||
- games
|
||||
|
||||
@@ -4,9 +4,9 @@ Read this compact operational baseline before starting a milestone. Open detaile
|
||||
|
||||
## Baseline
|
||||
|
||||
- Current reference: pre-milestone-10 UI redesign branch from merged milestone 9 baseline `d68d97d`.
|
||||
- Current reference: milestone 10 working branch from the UI-redesign baseline `cd65414`.
|
||||
- Released SQLite migrations: `0001` through `0009`; never rewrite them.
|
||||
- Roadmap milestones 1-9 are implemented.
|
||||
- Roadmap milestones 1-10 are implemented; this is the V1 feature baseline.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -33,6 +33,10 @@ Read this compact operational baseline before starting a milestone. Open detaile
|
||||
- Compact allow-listed audit events for authentication and significant mutations, administrator filtering, bounded manual purge, daily retention and maximum-count enforcement.
|
||||
- Responsive server-rendered application shell with the official square DoGaMa logo, synthwave-derived design tokens, aligned permission-aware navigation, searchable real instance cards and state summaries above the server grid.
|
||||
- Dedicated administrator Audit and Settings pages; notification channels, audit retention/purge and game-container labels retain their existing backend contracts outside the Dashboard.
|
||||
- Restrictive browser headers and bounded public HTTP headers.
|
||||
- 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.
|
||||
|
||||
## Durable decisions
|
||||
|
||||
@@ -56,7 +60,6 @@ Read this compact operational baseline before starting a milestone. Open detaile
|
||||
|
||||
## 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.
|
||||
- Instance detail, catalog and backup management remain API-first; their sidebar entries are deliberately disabled until corresponding web pages exist, so navigation does not imply unavailable routes.
|
||||
- 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.
|
||||
@@ -64,12 +67,12 @@ Read this compact operational baseline before starting a milestone. Open detaile
|
||||
|
||||
## Validation and CI
|
||||
|
||||
- No repository-hosted Gitea/GitHub workflow files are currently present.
|
||||
- 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.
|
||||
- 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.
|
||||
- No V1 milestone remains. Do not begin V1.x or V2 work without an explicit accepted scope.
|
||||
- Update this file after every merged milestone or durable architectural change; keep it compact and remove stale statements.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Contributor testing
|
||||
|
||||
Run the smallest affected package first, then the complete gate from
|
||||
`AGENTS.md`. `make e2e` runs the Linux black-box bootstrap test against the real
|
||||
`dogama` binary and a temporary SQLite database; it verifies first-run closure,
|
||||
CSRF denial, secure cookies, security headers, authentication and protected
|
||||
catalog access.
|
||||
|
||||
The following V1 critical paths are intentionally split between deterministic
|
||||
integration tests and disposable-Docker release verification:
|
||||
|
||||
| Boundary or workflow | Automated coverage |
|
||||
|---|---|
|
||||
| Bootstrap, login, logout, CSRF, session rotation and throttling | `internal/web` and `tests/e2e` |
|
||||
| Admin/user/manager membership and explicit-deny behavior | `internal/web` and `internal/authorization` |
|
||||
| Agent authentication, replay, request bounds and unrelated-container denial | `internal/agent` |
|
||||
| Path, symlink, plan, image, port and label restrictions | `internal/agent` |
|
||||
| Archive traversal, links, extraction limits, backup integrity and restore safety | `internal/importexport` and `internal/backup` |
|
||||
| WASM capability, network, fuel, memory, response and concurrency bounds | `internal/module` |
|
||||
| Digest update success, failed readiness and rollback | `internal/instance` and `internal/web` |
|
||||
| Empty and prior-schema migrations | `internal/persistence/sqlite` |
|
||||
|
||||
Before publishing a release, additionally use an isolated Docker daemon with
|
||||
disposable host roots. Run `docker compose config --quiet`, build both image
|
||||
targets, confirm that the main container has no socket and the agent has no
|
||||
published port, then exercise Palworld draft/install/start/stop, backup/restore,
|
||||
an intentionally failing digest update, and container-only deletion. Confirm
|
||||
that unrelated containers cannot be inspected or mutated and that player and
|
||||
backup roots remain after deletion and interruption. Never point this test at a
|
||||
host containing valuable containers or player data.
|
||||
|
||||
Tests must use generated secrets and fixtures. Do not place real credentials,
|
||||
host paths, saves, database copies or registry tokens in logs or commits. A
|
||||
failure report should name the operation and stable error code without copying
|
||||
secret-bearing request bodies.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Deployment and release
|
||||
|
||||
## Production prerequisites
|
||||
|
||||
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.
|
||||
|
||||
```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
|
||||
docker compose config --quiet
|
||||
DOGAMA_VERSION=v1.0.0 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.
|
||||
|
||||
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 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
@@ -1650,11 +1650,13 @@ func (s *server) problem(w http.ResponseWriter, status int, message string) {
|
||||
|
||||
func (s *server) securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; base-uri 'none'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self'; script-src 'self'; style-src 'self'")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -586,7 +586,7 @@ func TestBootstrapAuthenticationAndLogoutFlow(t *testing.T) {
|
||||
if !strings.Contains(home.Body.String(), "Signed in as <strong>admin</strong>") {
|
||||
t.Fatalf("protected page did not identify user: %s", home.Body.String())
|
||||
}
|
||||
if home.Header().Get("Content-Security-Policy") == "" || home.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||
if home.Header().Get("Content-Security-Policy") == "" || home.Header().Get("X-Content-Type-Options") != "nosniff" || home.Header().Get("Strict-Transport-Security") == "" || home.Header().Get("Cross-Origin-Opener-Policy") != "same-origin" {
|
||||
t.Fatal("security headers missing")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//go:build linux
|
||||
|
||||
package e2e_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestV1BootstrapAuthenticationAndHTTPBoundary(t *testing.T) {
|
||||
repositoryRoot := filepath.Clean(filepath.Join("..", ".."))
|
||||
binary := filepath.Join(t.TempDir(), "dogama")
|
||||
build := exec.Command("go", "build", "-trimpath", "-o", binary, "./cmd/dogama")
|
||||
build.Dir = repositoryRoot
|
||||
if output, err := build.CombinedOutput(); err != nil {
|
||||
t.Fatalf("build dogama: %v\n%s", err, output)
|
||||
}
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
address := listener.Addr().String()
|
||||
_ = listener.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
var logs bytes.Buffer
|
||||
command := exec.CommandContext(ctx, binary)
|
||||
command.Env = append(os.Environ(),
|
||||
"DOGAMA_LISTEN_ADDRESS="+address,
|
||||
"DOGAMA_DATABASE_PATH="+filepath.Join(t.TempDir(), "dogama.db"),
|
||||
"DOGAMA_IMPORTS_ROOT="+filepath.Join(t.TempDir(), "imports"),
|
||||
"DOGAMA_SERVERS_ROOT="+filepath.Join(t.TempDir(), "servers"),
|
||||
)
|
||||
command.Stdout, command.Stderr = &logs, &logs
|
||||
if err := command.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
_ = command.Wait()
|
||||
})
|
||||
|
||||
client := &http.Client{Timeout: 2 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
baseURL := "http://" + address
|
||||
setup := waitFor(t, client, baseURL+"/setup", &logs)
|
||||
assertStatus(t, setup, http.StatusOK)
|
||||
assertSecurityHeaders(t, setup)
|
||||
csrf := cookieValue(t, setup, "dogama_csrf")
|
||||
|
||||
badCSRF := form(t, client, baseURL+"/setup", url.Values{"csrf_token": {"forged"}, "username": {"admin"}, "password": {"correct horse battery staple"}}, csrf)
|
||||
assertStatus(t, badCSRF, http.StatusForbidden)
|
||||
created := form(t, client, baseURL+"/setup", url.Values{"csrf_token": {csrf}, "username": {"admin"}, "password": {"correct horse battery staple"}}, csrf)
|
||||
assertStatus(t, created, http.StatusSeeOther)
|
||||
|
||||
closed := get(t, client, baseURL+"/setup", "")
|
||||
assertStatus(t, closed, http.StatusSeeOther)
|
||||
loginPage := get(t, client, baseURL+"/login", "")
|
||||
csrf = cookieValue(t, loginPage, "dogama_csrf")
|
||||
login := form(t, client, baseURL+"/login", url.Values{"csrf_token": {csrf}, "username": {"admin"}, "password": {"correct horse battery staple"}}, csrf)
|
||||
assertStatus(t, login, http.StatusSeeOther)
|
||||
session := cookieValue(t, login, "dogama_session")
|
||||
csrf = cookieValue(t, login, "dogama_csrf")
|
||||
|
||||
unauthenticated := get(t, client, baseURL+"/api/v1/catalog", "")
|
||||
assertStatus(t, unauthenticated, http.StatusUnauthorized)
|
||||
authenticated := get(t, client, baseURL+"/api/v1/catalog", "dogama_session="+session+"; dogama_csrf="+csrf)
|
||||
assertStatus(t, authenticated, http.StatusOK)
|
||||
if !strings.Contains(readBody(t, authenticated), "palworld-official") {
|
||||
t.Fatal("authenticated catalog omitted the Palworld reference template")
|
||||
}
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, client *http.Client, target string, logs *bytes.Buffer) *http.Response {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
response, err := client.Get(target)
|
||||
if err == nil {
|
||||
return response
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("application did not start:\n%s", logs.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
func get(t *testing.T, client *http.Client, target, cookie string) *http.Response {
|
||||
t.Helper()
|
||||
request, _ := http.NewRequest(http.MethodGet, target, nil)
|
||||
if cookie != "" {
|
||||
request.Header.Set("Cookie", cookie)
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func form(t *testing.T, client *http.Client, target string, values url.Values, csrf string) *http.Response {
|
||||
t.Helper()
|
||||
request, _ := http.NewRequest(http.MethodPost, target, strings.NewReader(values.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("Cookie", "dogama_csrf="+csrf)
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func cookieValue(t *testing.T, response *http.Response, name string) string {
|
||||
t.Helper()
|
||||
for _, cookie := range response.Cookies() {
|
||||
if cookie.Name == name {
|
||||
if !cookie.Secure || !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode {
|
||||
t.Fatalf("insecure %s cookie: %#v", name, cookie)
|
||||
}
|
||||
return cookie.Value
|
||||
}
|
||||
}
|
||||
t.Fatalf("cookie %s not found", name)
|
||||
return ""
|
||||
}
|
||||
|
||||
func assertSecurityHeaders(t *testing.T, response *http.Response) {
|
||||
t.Helper()
|
||||
for name, expected := range map[string]string{"X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", "Cross-Origin-Opener-Policy": "same-origin"} {
|
||||
if got := response.Header.Get(name); got != expected {
|
||||
t.Fatalf("%s = %q, want %q", name, got, expected)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(response.Header.Get("Content-Security-Policy"), "default-src 'none'") || response.Header.Get("Strict-Transport-Security") == "" {
|
||||
t.Fatal("strict browser security headers are incomplete")
|
||||
}
|
||||
}
|
||||
|
||||
func assertStatus(t *testing.T, response *http.Response, expected int) {
|
||||
t.Helper()
|
||||
if response.StatusCode != expected {
|
||||
t.Fatalf("status = %d, want %d; body=%s", response.StatusCode, expected, readBody(t, response))
|
||||
}
|
||||
}
|
||||
|
||||
func readBody(t *testing.T, response *http.Response) string {
|
||||
t.Helper()
|
||||
defer response.Body.Close()
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
version=${1:-}
|
||||
case "$version" in
|
||||
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) echo "usage: $0 vMAJOR.MINOR.PATCH" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
commit=$(git rev-parse HEAD)
|
||||
epoch=${SOURCE_DATE_EPOCH:-$(git show -s --format=%ct HEAD)}
|
||||
release_dir="dist/dogama-${version}"
|
||||
if [ -e "$release_dir" ]; then
|
||||
echo "$release_dir already exists; refusing to overwrite it" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$release_dir"
|
||||
|
||||
for arch in amd64 arm64; do
|
||||
stage="$release_dir/linux-$arch"
|
||||
mkdir -p "$stage"
|
||||
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" ./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
|
||||
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"
|
||||
done
|
||||
|
||||
go list -m -json all | SOURCE_DATE_EPOCH=$epoch go run ./tools/sbom > "$release_dir/dogama-${version}.spdx.json"
|
||||
(cd "$release_dir" && sha256sum ./*.tar.gz ./*.spdx.json > SHA256SUMS)
|
||||
echo "release artifacts written to $release_dir"
|
||||
@@ -0,0 +1,97 @@
|
||||
// Command sbom converts `go list -m -json all` output into a deterministic SPDX 2.3 inventory.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
Path string
|
||||
Version string
|
||||
Sum string
|
||||
}
|
||||
|
||||
type document struct {
|
||||
SPDXVersion string `json:"spdxVersion"`
|
||||
DataLicense string `json:"dataLicense"`
|
||||
SPDXID string `json:"SPDXID"`
|
||||
Name string `json:"name"`
|
||||
DocumentNamespace string `json:"documentNamespace"`
|
||||
CreationInfo creationInfo `json:"creationInfo"`
|
||||
Packages []packageInfo `json:"packages"`
|
||||
}
|
||||
|
||||
type creationInfo struct {
|
||||
Created string `json:"created"`
|
||||
Creators []string `json:"creators"`
|
||||
}
|
||||
|
||||
type packageInfo struct {
|
||||
Name string `json:"name"`
|
||||
SPDXID string `json:"SPDXID"`
|
||||
VersionInfo string `json:"versionInfo,omitempty"`
|
||||
DownloadLocation string `json:"downloadLocation"`
|
||||
FilesAnalyzed bool `json:"filesAnalyzed"`
|
||||
Checksums []checksum `json:"checksums,omitempty"`
|
||||
}
|
||||
|
||||
type checksum struct {
|
||||
Algorithm string `json:"algorithm"`
|
||||
ChecksumValue string `json:"checksumValue"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
decoder := json.NewDecoder(os.Stdin)
|
||||
var modules []module
|
||||
for {
|
||||
var value module
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
fatal(err)
|
||||
}
|
||||
modules = append(modules, value)
|
||||
}
|
||||
epoch, err := strconv.ParseInt(os.Getenv("SOURCE_DATE_EPOCH"), 10, 64)
|
||||
if err != nil {
|
||||
fatal(fmt.Errorf("SOURCE_DATE_EPOCH: %w", err))
|
||||
}
|
||||
digest := sha256.Sum256([]byte(fmt.Sprint(modules)))
|
||||
doc := document{
|
||||
SPDXVersion: "SPDX-2.3", DataLicense: "CC0-1.0", SPDXID: "SPDXRef-DOCUMENT",
|
||||
Name: "DoGaMa Go module inventory", DocumentNamespace: "https://dogama.invalid/spdx/" + hex.EncodeToString(digest[:]),
|
||||
CreationInfo: creationInfo{Created: time.Unix(epoch, 0).UTC().Format(time.RFC3339), Creators: []string{"Tool: dogama-release"}},
|
||||
}
|
||||
for index, value := range modules {
|
||||
pkg := packageInfo{Name: value.Path, SPDXID: fmt.Sprintf("SPDXRef-Package-%d", index), VersionInfo: value.Version, DownloadLocation: "https://proxy.golang.org/" + strings.ToLower(value.Path), FilesAnalyzed: false}
|
||||
if strings.HasPrefix(value.Sum, "h1:") {
|
||||
sum, decodeErr := base64.StdEncoding.DecodeString(strings.TrimPrefix(value.Sum, "h1:"))
|
||||
if decodeErr != nil || len(sum) != sha256.Size {
|
||||
fatal(fmt.Errorf("invalid module checksum for %s", value.Path))
|
||||
}
|
||||
pkg.Checksums = []checksum{{Algorithm: "SHA256", ChecksumValue: hex.EncodeToString(sum)}}
|
||||
}
|
||||
doc.Packages = append(doc.Packages, pkg)
|
||||
}
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(doc); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func fatal(err error) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
Reference in New Issue
Block a user