fix(deploy): remove initializer and self-bootstrap secrets
This commit is contained in:
+4
-2
@@ -3,5 +3,7 @@ DOGAMA_VERSION=latest
|
||||
DOGAMA_HTTP_PORT=8080
|
||||
TZ=UTC
|
||||
DOGAMA_DATA_PATH=./data
|
||||
DOGAMA_SERVERS_PATH=./data/servers
|
||||
DOGAMA_BACKUPS_PATH=./data/backups
|
||||
DOGAMA_SERVERS_PATH=./servers
|
||||
DOGAMA_BACKUPS_PATH=./backups
|
||||
DOGAMA_NETWORK=dogama
|
||||
DOGAMA_GAMES_NETWORK=dogama-games
|
||||
|
||||
+2
-5
@@ -12,14 +12,11 @@ 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 && \
|
||||
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -buildvcs=false \
|
||||
-ldflags="-s -w" -o /out/dogama-init ./cmd/dogama-init
|
||||
-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
|
||||
FROM gcr.io/distroless/static-debian12:latest@sha256:a9fcaedd4c9b59e12dd65d954f0b5044f19b0647a8a3712e77205df9e7b102cd 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"]
|
||||
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
+49
-13
@@ -67,15 +67,10 @@ func run(logger *slog.Logger) error {
|
||||
auditService := audit.New(db)
|
||||
var notificationService *notification.Service
|
||||
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)
|
||||
key, keyErr := loadMasterKey(keyFile)
|
||||
if keyErr != nil {
|
||||
return errors.New("read encryption key file")
|
||||
return keyErr
|
||||
}
|
||||
notificationService, keyErr = notification.New(db, key)
|
||||
if keyErr != nil {
|
||||
@@ -97,12 +92,7 @@ func run(logger *slog.Logger) error {
|
||||
if agentURL == "" || tokenFile == "" {
|
||||
return errors.New("DOGAMA_AGENT_URL and DOGAMA_AGENT_TOKEN_FILE must be configured together")
|
||||
}
|
||||
secret, readErr := os.ReadFile(tokenFile)
|
||||
if readErr != nil {
|
||||
return errors.New("read agent token file")
|
||||
}
|
||||
secret = bytes.TrimSuffix(bytes.TrimSuffix(secret, []byte("\n")), []byte("\r"))
|
||||
agent, clientErr := agentclient.New(agentURL, secret, &http.Client{Timeout: 15 * time.Minute})
|
||||
agent, clientErr := waitForAgent(ctx, agentURL, tokenFile, &http.Client{Timeout: 15 * time.Minute}, 60*time.Second, 250*time.Millisecond)
|
||||
if clientErr != nil {
|
||||
return clientErr
|
||||
}
|
||||
@@ -160,6 +150,52 @@ func run(logger *slog.Logger) error {
|
||||
}
|
||||
}
|
||||
|
||||
func loadMasterKey(path string) ([]byte, error) {
|
||||
if path == "secrets/master_key" {
|
||||
if _, err := internalsecrets.Ensure(path, 0o600); err != nil {
|
||||
return nil, errors.New("initialize encryption key file")
|
||||
}
|
||||
}
|
||||
key, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, errors.New("read encryption key file")
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func waitForAgent(ctx context.Context, agentURL, tokenFile string, httpClient *http.Client, timeout, retryInterval time.Duration) (*agentclient.Client, error) {
|
||||
waitCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
for {
|
||||
secret, err := os.ReadFile(tokenFile)
|
||||
if err == nil {
|
||||
secret = bytes.TrimSuffix(bytes.TrimSuffix(secret, []byte("\n")), []byte("\r"))
|
||||
if len(secret) != 32 {
|
||||
return nil, errors.New("agent token file is invalid")
|
||||
}
|
||||
client, clientErr := agentclient.New(agentURL, secret, httpClient)
|
||||
if clientErr != nil {
|
||||
return nil, errors.New("agent configuration is invalid")
|
||||
}
|
||||
healthCtx, healthCancel := context.WithTimeout(waitCtx, 2*time.Second)
|
||||
healthErr := client.Health(healthCtx)
|
||||
healthCancel()
|
||||
if healthErr == nil {
|
||||
return client, nil
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, errors.New("read agent token file")
|
||||
}
|
||||
timer := time.NewTimer(retryInterval)
|
||||
select {
|
||||
case <-waitCtx.Done():
|
||||
timer.Stop()
|
||||
return nil, errors.New("agent did not become ready before startup timeout")
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runObservabilityScheduler(ctx context.Context, auditService *audit.Service, notificationService *notification.Service, logger *slog.Logger) {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadMasterKeyCreatesAndReusesDefault(t *testing.T) {
|
||||
working := t.TempDir()
|
||||
previous, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(working); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(previous) })
|
||||
|
||||
first, err := loadMasterKey("secrets/master_key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := loadMasterKey("secrets/master_key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(first) != 32 || !bytes.Equal(first, second) {
|
||||
t.Fatal("master key was not created once and reused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMasterKeyRefusesInvalidExistingDefault(t *testing.T) {
|
||||
working := t.TempDir()
|
||||
previous, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(working); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(previous) })
|
||||
if err := os.Mkdir("secrets", 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("secrets", "master_key"), []byte("invalid"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadMasterKey("secrets/master_key"); err == nil {
|
||||
t.Fatal("invalid master key unexpectedly accepted")
|
||||
}
|
||||
got, _ := os.ReadFile(filepath.Join("secrets", "master_key"))
|
||||
if string(got) != "invalid" {
|
||||
t.Fatal("invalid master key was replaced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForAgentHandlesConcurrentFirstStart(t *testing.T) {
|
||||
ready := make(chan struct{})
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
select {
|
||||
case <-ready:
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
http.Error(w, "not ready", http.StatusServiceUnavailable)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
tokenFile := filepath.Join(t.TempDir(), "secrets", "token")
|
||||
go func() {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
_ = os.MkdirAll(filepath.Dir(tokenFile), 0o750)
|
||||
_ = os.WriteFile(tokenFile, bytes.Repeat([]byte{0x31}, 32), 0o640)
|
||||
close(ready)
|
||||
}()
|
||||
if _, err := waitForAgent(context.Background(), server.URL, tokenFile, server.Client(), time.Second, 5*time.Millisecond); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForAgentRefusesInvalidToken(t *testing.T) {
|
||||
tokenFile := filepath.Join(t.TempDir(), "token")
|
||||
if err := os.WriteFile(tokenFile, []byte("invalid"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := waitForAgent(context.Background(), "http://agent:8081", tokenFile, http.DefaultClient, time.Second, time.Millisecond); err == nil {
|
||||
t.Fatal("invalid agent token unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
+9
-33
@@ -1,23 +1,4 @@
|
||||
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: git.zaynet.fr/dogama/dogama:${DOGAMA_VERSION:-latest}
|
||||
restart: unless-stopped
|
||||
@@ -30,8 +11,8 @@ services:
|
||||
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
|
||||
- ${DOGAMA_SERVERS_PATH:-./servers}:/srv/game-servers
|
||||
- ${DOGAMA_BACKUPS_PATH:-./backups}:/srv/game-backups
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,nodev,size=32m
|
||||
security_opt:
|
||||
@@ -39,12 +20,9 @@ services:
|
||||
cap_drop:
|
||||
- ALL
|
||||
networks:
|
||||
- frontend
|
||||
- dogama
|
||||
- control
|
||||
- games
|
||||
depends_on:
|
||||
init:
|
||||
condition: service_completed_successfully
|
||||
agent:
|
||||
condition: service_started
|
||||
|
||||
@@ -54,11 +32,12 @@ services:
|
||||
read_only: true
|
||||
environment:
|
||||
TZ: ${TZ:-UTC}
|
||||
DOGAMA_DOCKER_NETWORK: ${DOGAMA_GAMES_NETWORK:-dogama-games}
|
||||
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
|
||||
- ${DOGAMA_SERVERS_PATH:-./servers}:/srv/game-servers
|
||||
- ${DOGAMA_BACKUPS_PATH:-./backups}:/srv/game-backups
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,nodev,size=16m
|
||||
security_opt:
|
||||
@@ -67,17 +46,14 @@ services:
|
||||
- ALL
|
||||
networks:
|
||||
- control
|
||||
- games
|
||||
depends_on:
|
||||
init:
|
||||
condition: service_completed_successfully
|
||||
|
||||
networks:
|
||||
frontend:
|
||||
dogama:
|
||||
name: ${DOGAMA_NETWORK:-dogama}
|
||||
control:
|
||||
internal: true
|
||||
games:
|
||||
name: dogama-games
|
||||
name: ${DOGAMA_GAMES_NETWORK:-dogama-games}
|
||||
|
||||
volumes:
|
||||
agent_state:
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/internalsecrets"
|
||||
)
|
||||
|
||||
// Config contains bootstrap-only settings for the restricted agent.
|
||||
@@ -22,6 +24,9 @@ type Config struct {
|
||||
// LoadConfig reads the agent's bootstrap settings and shared secret file.
|
||||
func LoadConfig() (Config, error) {
|
||||
tokenFile := environment("DOGAMA_AGENT_TOKEN_FILE", "/var/lib/dogama-agent/secrets/token")
|
||||
if _, err := internalsecrets.Ensure(tokenFile, 0o640); err != nil {
|
||||
return Config{}, errors.New("initialize agent secret")
|
||||
}
|
||||
secret, err := readSecretFile(tokenFile)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
|
||||
@@ -11,7 +11,7 @@ func TestLoadConfigReadsSecretFileAndRoots(t *testing.T) {
|
||||
temporary := t.TempDir()
|
||||
secretPath := filepath.Join(temporary, "agent-token")
|
||||
secret := bytes.Repeat([]byte("a"), 32)
|
||||
if err := os.WriteFile(secretPath, append(secret, '\n'), 0o600); err != nil {
|
||||
if err := os.WriteFile(secretPath, secret, 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
serverRoot := filepath.Join(temporary, "servers")
|
||||
@@ -23,20 +23,53 @@ func TestLoadConfigReadsSecretFileAndRoots(t *testing.T) {
|
||||
t.Setenv("DOGAMA_ALLOWED_BACKUP_ROOT", "")
|
||||
t.Setenv("DOGAMA_AGENT_REGISTRY_PATH", filepath.Join(temporary, "registry.json"))
|
||||
t.Setenv("DOGAMA_DOCKER_SOCKET", filepath.Join(temporary, "docker.sock"))
|
||||
t.Setenv("DOGAMA_DOCKER_NETWORK", "nas-games")
|
||||
config, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(config.Secret, secret) || len(config.AllowedRoots) != 2 || config.ListenAddress != ":8081" || config.DockerNetwork != "dogama-games" {
|
||||
if !bytes.Equal(config.Secret, secret) || len(config.AllowedRoots) != 2 || config.ListenAddress != ":8081" || config.DockerNetwork != "nas-games" {
|
||||
t.Fatalf("config = %#v", config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigUsesConfinedProductionDefaults(t *testing.T) {
|
||||
t.Setenv("DOGAMA_AGENT_TOKEN_FILE", filepath.Join(t.TempDir(), "missing-token"))
|
||||
func TestLoadConfigCreatesAndReusesToken(t *testing.T) {
|
||||
tokenPath := filepath.Join(t.TempDir(), "secrets", "token")
|
||||
t.Setenv("DOGAMA_AGENT_TOKEN_FILE", tokenPath)
|
||||
t.Setenv("DOGAMA_ALLOWED_SERVER_ROOT", "")
|
||||
t.Setenv("DOGAMA_ALLOWED_BACKUP_ROOT", "")
|
||||
first, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(first.Secret) != 32 || !bytes.Equal(first.Secret, second.Secret) {
|
||||
t.Fatal("agent token was not created once and reused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigRefusesInvalidExistingToken(t *testing.T) {
|
||||
tokenPath := filepath.Join(t.TempDir(), "token")
|
||||
if err := os.WriteFile(tokenPath, []byte("invalid"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("DOGAMA_AGENT_TOKEN_FILE", tokenPath)
|
||||
if _, err := LoadConfig(); err == nil {
|
||||
t.Fatal("missing default token unexpectedly accepted")
|
||||
t.Fatal("invalid agent token unexpectedly accepted")
|
||||
}
|
||||
got, _ := os.ReadFile(tokenPath)
|
||||
if string(got) != "invalid" {
|
||||
t.Fatal("invalid agent token was replaced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigRefusesInvalidDockerNetwork(t *testing.T) {
|
||||
t.Setenv("DOGAMA_AGENT_TOKEN_FILE", filepath.Join(t.TempDir(), "secrets", "token"))
|
||||
t.Setenv("DOGAMA_DOCKER_NETWORK", "host/network")
|
||||
if _, err := LoadConfig(); err == nil {
|
||||
t.Fatal("invalid Docker network unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestDockerRuntimeCreatesFixedSecurityBaseline(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
createdBodies := make(chan []byte, 1)
|
||||
createdBodies := make(chan []byte, 2)
|
||||
server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1.41/images/create":
|
||||
@@ -62,7 +62,7 @@ func TestDockerRuntimeCreatesFixedSecurityBaseline(t *testing.T) {
|
||||
})}
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
t.Cleanup(func() { _ = server.Shutdown(context.Background()) })
|
||||
runtime, err := NewDockerRuntime(socket, "dogama-games")
|
||||
runtime, err := NewDockerRuntime(socket, "nas-games")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -74,12 +74,14 @@ func TestDockerRuntimeCreatesFixedSecurityBaseline(t *testing.T) {
|
||||
if err := runtime.CheckPorts(context.Background(), plan.Ports); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, err := runtime.Create(context.Background(), plan, []AssetMount{{HostPath: "/srv/games/.dogama/helper", ContainerPath: "/pal/helper.sh"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id != "container-1" {
|
||||
t.Fatalf("container ID = %q", id)
|
||||
for range 2 {
|
||||
id, err := runtime.Create(context.Background(), plan, []AssetMount{{HostPath: "/srv/games/.dogama/helper", ContainerPath: "/pal/helper.sh"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id != "container-1" {
|
||||
t.Fatalf("container ID = %q", id)
|
||||
}
|
||||
}
|
||||
var payload struct {
|
||||
User string `json:"User"`
|
||||
@@ -92,11 +94,13 @@ func TestDockerRuntimeCreatesFixedSecurityBaseline(t *testing.T) {
|
||||
NanoCPUs int64 `json:"NanoCpus"`
|
||||
} `json:"HostConfig"`
|
||||
}
|
||||
if err := json.Unmarshal(<-createdBodies, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.HostConfig.NetworkMode != "dogama-games" || len(payload.HostConfig.CapDrop) != 1 || payload.HostConfig.CapDrop[0] != "ALL" || len(payload.HostConfig.SecurityOpt) != 1 || payload.HostConfig.Memory <= 0 || payload.HostConfig.NanoCPUs <= 0 {
|
||||
t.Fatalf("insecure Docker host config: %#v", payload.HostConfig)
|
||||
for range 2 {
|
||||
if err := json.Unmarshal(<-createdBodies, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.HostConfig.NetworkMode != "nas-games" || len(payload.HostConfig.CapDrop) != 1 || payload.HostConfig.CapDrop[0] != "ALL" || len(payload.HostConfig.SecurityOpt) != 1 || payload.HostConfig.Memory <= 0 || payload.HostConfig.NanoCPUs <= 0 {
|
||||
t.Fatalf("insecure Docker host config: %#v", payload.HostConfig)
|
||||
}
|
||||
}
|
||||
if payload.Labels["io.dogama.instance-id"] != plan.InstanceID || payload.Labels["io.dogama.plan-digest"] != plan.PlanDigest {
|
||||
t.Fatalf("binding labels = %#v", payload.Labels)
|
||||
|
||||
@@ -3,6 +3,8 @@ package agent_test
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -10,6 +12,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agent"
|
||||
@@ -185,6 +188,36 @@ func TestAgentRejectsPlanSubstitutionAndEscapingMount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRejectsArbitraryNetworkInAPIPlan(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
secret := bytes.Repeat([]byte{0x31}, 32)
|
||||
plan := testPlan(t, "abcdefghijklmnopqrstuvwx", root)
|
||||
body, err := json.Marshal(plan)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload["network"] = "host"
|
||||
body, err = json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/instances", bytes.NewReader(body))
|
||||
timestamp := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
nonce := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{0x42}, 24))
|
||||
request.Header.Set(agentwire.HeaderTimestamp, timestamp)
|
||||
request.Header.Set(agentwire.HeaderNonce, nonce)
|
||||
request.Header.Set("Authorization", agentwire.AuthorizationScheme+" "+agentwire.Signature(secret, request.Method, request.URL.EscapedPath(), timestamp, nonce, body))
|
||||
recorder := httptest.NewRecorder()
|
||||
newTestHandler(t, root, secret, fakeDocker{}).ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("arbitrary network status = %d, want %d", recorder.Code, http.StatusUnprocessableEntity)
|
||||
}
|
||||
}
|
||||
|
||||
func testPlan(t *testing.T, instanceID, root string) agentwire.DeploymentPlan {
|
||||
t.Helper()
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
|
||||
@@ -16,7 +16,7 @@ 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) {
|
||||
func Ensure(path string, mode os.FileMode) (bool, error) {
|
||||
if mode.Perm()&0o007 != 0 || mode.Perm()&0o700 == 0 {
|
||||
return false, errors.New("secret permissions are invalid")
|
||||
}
|
||||
@@ -69,14 +69,6 @@ func Ensure(path string, uid, gid int, mode os.FileMode) (bool, error) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
func TestEnsureGeneratesAndReusesSecret(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "private", "token")
|
||||
created, err := Ensure(path, os.Getuid(), os.Getgid(), 0o600)
|
||||
created, err := Ensure(path, 0o600)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("first Ensure() = %v, %v", created, err)
|
||||
}
|
||||
@@ -19,7 +19,7 @@ func TestEnsureGeneratesAndReusesSecret(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err = Ensure(path, os.Getuid(), os.Getgid(), 0o600)
|
||||
created, err = Ensure(path, 0o600)
|
||||
if err != nil || created {
|
||||
t.Fatalf("second Ensure() = %v, %v", created, err)
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func TestEnsureConcurrentInitializationCreatesOneSecret(t *testing.T) {
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
wasCreated, err := Ensure(path, os.Getuid(), os.Getgid(), 0o600)
|
||||
wasCreated, err := Ensure(path, 0o600)
|
||||
created <- wasCreated
|
||||
errors <- err
|
||||
}()
|
||||
@@ -73,7 +73,7 @@ func TestEnsureRefusesInvalidExistingSecret(t *testing.T) {
|
||||
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 {
|
||||
if created, err := Ensure(path, 0o600); err == nil || created {
|
||||
t.Fatalf("invalid existing secret was accepted: %v, %v", created, err)
|
||||
}
|
||||
got, _ := os.ReadFile(path)
|
||||
|
||||
@@ -23,8 +23,6 @@ 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"
|
||||
|
||||
@@ -117,7 +117,7 @@ def validate_coverage() -> None:
|
||||
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"
|
||||
assert set(services) == {"dogama", "agent"}, "Compose must expose exactly the application and restricted agent"
|
||||
forbidden = {
|
||||
"DOGAMA_AGENT_TOKEN_FILE", "DOGAMA_MASTER_KEY_FILE", "DOGAMA_SERVERS_ROOT",
|
||||
"DOGAMA_BACKUPS_ROOT", "DOGAMA_IMPORTS_ROOT", "DOGAMA_ALLOWED_SERVER_ROOT",
|
||||
@@ -128,14 +128,18 @@ def validate_compose() -> None:
|
||||
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 all(not service.get("cap_add") for service in services.values()), "Compose adds Linux capabilities"
|
||||
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
|
||||
assert "${DOGAMA_SERVERS_PATH:-./servers}:/srv/game-servers" in rendered
|
||||
assert "${DOGAMA_BACKUPS_PATH:-./backups}:/srv/game-backups" in rendered
|
||||
assert "name: ${DOGAMA_NETWORK:-dogama}" in rendered, "public application network is not configurable"
|
||||
assert "name: ${DOGAMA_GAMES_NETWORK:-dogama-games}" in rendered, "game network is not configurable"
|
||||
assert "DOGAMA_DOCKER_NETWORK: ${DOGAMA_GAMES_NETWORK:-dogama-games}" in rendered, "configured game network does not reach the agent"
|
||||
|
||||
|
||||
def validate_workflows() -> None:
|
||||
|
||||
Reference in New Issue
Block a user