feat: add restricted Docker agent foundation

This commit is contained in:
2026-08-06 20:25:41 +00:00
parent af6a23eaef
commit 42e9cfffbf
20 changed files with 1368 additions and 4 deletions
+3 -2
View File
@@ -45,8 +45,9 @@ instead of repeating the repository workflow.
- Commits and pushes on non-`main` branches are allowed only when they are
necessary for the stated task. Do not infer that implementation alone
requires publication, and always report the operations performed.
- Do not perform merges or create merge/pull requests in Gitea. When either is
needed, give the user the procedure to complete it in Gitea.
- Codex may create merge/pull requests from working branches when delivery
requires review. Codex must never approve or merge them; leave approval and
fusion into `main` to an authorized user in Gitea.
- Do not alter remotes, credentials or repository-wide Git configuration
unless the task explicitly requires it.
- Never use destructive recovery commands such as `git reset --hard`,
+1 -1
View File
@@ -72,7 +72,7 @@ Only the main application's HTTP port is published. The agent and game-managemen
## Status
The first roadmap foundation is implemented: the main Go binary, embedded server-rendered UI, SQLite migrations, first-administrator bootstrap, and local session authentication. Later roadmap components, including the restricted Docker agent and WebAssembly runtime, are not implemented yet.
The first two roadmap foundations are implemented: the main Go binary, embedded server-rendered UI, SQLite migrations, first-administrator bootstrap, local session authentication, and the restricted agent boundary with authenticated private requests, replay defense, canonical allowed-root enforcement, authenticated local registry and bounded Docker health/disk inspection. Deployment plans, container lifecycle operations and the WebAssembly runtime remain later roadmap work.
## Validate the specification
+74
View File
@@ -0,0 +1,74 @@
// Command dogama-agent runs the restricted same-host Docker agent.
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agent"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
if err := run(logger); err != nil {
logger.Error("agent stopped", "event", "agent.failed", "error", err)
os.Exit(1)
}
}
func run(logger *slog.Logger) error {
config, err := agent.LoadConfig()
if err != nil {
return err
}
paths, err := agent.NewPathPolicy(config.AllowedRoots)
if err != nil {
return err
}
registry, err := agent.OpenRegistry(config.RegistryPath, config.Secret)
if err != nil {
return err
}
authenticator, err := agent.NewAuthenticator(config.Secret)
if err != nil {
return err
}
docker, err := agent.NewDockerPinger(config.DockerSocket)
if err != nil {
return err
}
handler := agent.NewHandler(authenticator, paths, registry, docker, logger)
server := &http.Server{
Addr: config.ListenAddress,
Handler: handler,
ReadHeaderTimeout: 3 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 30 * time.Second,
MaxHeaderBytes: 16 << 10,
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() {
logger.Info("agent listening", "event", "agent.started", "address", config.ListenAddress, "allowed_root_count", paths.RootCount())
errCh <- server.ListenAndServe()
}()
select {
case err := <-errCh:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return server.Shutdown(shutdownCtx)
}
}
+12
View File
@@ -26,17 +26,26 @@ services:
agent:
image: ghcr.io/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_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
tmpfs:
- /tmp:rw,noexec,nosuid,nodev,size=16m
security_opt:
- no-new-privileges:true
networks:
- control
- games
@@ -47,6 +56,9 @@ networks:
internal: true
games:
volumes:
agent_state:
secrets:
agent_token:
file: ./secrets/agent_token
+36 -1
View File
@@ -4,6 +4,23 @@
The agent reduces the chance that an application bug becomes arbitrary Docker control. Because Docker socket access is effectively host-level privilege, the agent is small, independently testable and deny-by-default.
## Private API foundation
The same-host V1 agent listens on the private control network only. The current
foundation exposes three authenticated routes:
- `GET /v1/health` verifies that the configured Docker Unix socket answers its
bounded `_ping` request without exposing daemon details;
- `POST /v1/check-disk` accepts at most 16 existing absolute paths and returns
capacity only after symlink-aware allowed-root validation;
- `GET /v1/instances` returns only entries from the authenticated agent-local
registry.
Every route, including health, requires request authentication. Container
creation and mutation routes remain closed until the canonical deployment-plan
contract and template validation are implemented in the following roadmap
milestones. The agent never exposes its internal Docker HTTP client as a proxy.
## Allowed V1 operations
- `CreateInstance(plan)`
@@ -55,6 +72,25 @@ V1 templates do not expose arbitrary Docker security options. The agent applies
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.
The wire format is `DoGaMa-HMAC-SHA256 <base64url-signature>` in the
`Authorization` header, with `X-DoGaMa-Timestamp` in RFC 3339 and a random
base64url `X-DoGaMa-Nonce`. The HMAC-SHA-256 input is the following exact
newline-separated canonical value:
```text
DOGAMA-HMAC-V1
<HTTP method>
<escaped URL path>
<timestamp header>
<nonce header>
<lowercase SHA-256 hex digest of the body>
```
Queries are rejected. The accepted clock skew is 30 seconds, a nonce is valid
once only, and request bodies are limited to 64 KiB before dispatch. The agent
returns stable JSON problem codes and never includes secrets, socket paths or
raw Docker errors.
The agent listens only on the internal control network and publishes no host port. Authentication remains mandatory even on that network.
## Failure semantics
@@ -69,4 +105,3 @@ The agent listens only on the internal control network and publishes no host por
## Testing focus
Negative integration tests must cover forged labels, unknown IDs, path traversal, symlink escape, reserved-label overrides, host networking, privileged flags, extra mounts, image substitution, conflicting ports, replayed requests and attempts to target unrelated containers.
+15
View File
@@ -36,6 +36,21 @@ go run ./cmd/dogama
Browser sessions always use `Secure`, `HttpOnly`, and `SameSite=Strict` cookies. Place the application behind a trusted TLS reverse proxy for browser use, including development environments. The application does not accept forwarded client addresses as authoritative for authentication throttling.
The restricted agent is a separate binary:
```sh
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` and
`/var/lib/dogama-agent/registry.json`. 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.
## Contract changes
Template schema, manifest schema, normalized module API and agent deployment plan are versioned contracts.
+120
View File
@@ -0,0 +1,120 @@
package agent
import (
"bytes"
"encoding/base64"
"errors"
"io"
"net/http"
"strings"
"sync"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
)
const (
maxRequestBody = 64 << 10
maxClockSkew = 30 * time.Second
)
// Authenticator validates signed requests and rejects replayed nonces.
type Authenticator struct {
secret []byte
clock func() time.Time
mu sync.Mutex
nonces map[string]time.Time
}
// NewAuthenticator constructs the deny-by-default private API authenticator.
func NewAuthenticator(secret []byte) (*Authenticator, error) {
return newAuthenticator(secret, time.Now)
}
func newAuthenticator(secret []byte, clock func() time.Time) (*Authenticator, error) {
if len(secret) < 32 {
return nil, errors.New("agent secret must contain at least 32 bytes")
}
return &Authenticator{
secret: append([]byte(nil), secret...),
clock: clock,
nonces: make(map[string]time.Time),
}, nil
}
// Middleware authenticates every agent route before dispatch.
func (a *Authenticator) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := readBoundedBody(r)
if err != nil {
writeProblem(w, http.StatusRequestEntityTooLarge, "request_too_large", "The request exceeded the size limit.")
return
}
r.Body = io.NopCloser(bytes.NewReader(body))
if !a.authenticate(r, body) {
writeProblem(w, http.StatusUnauthorized, "authentication_failed", "Request authentication failed.")
return
}
next.ServeHTTP(w, r)
})
}
func (a *Authenticator) authenticate(r *http.Request, body []byte) bool {
authorization := r.Header.Get("Authorization")
prefix := agentwire.AuthorizationScheme + " "
if !strings.HasPrefix(authorization, prefix) || r.URL.RawQuery != "" {
return false
}
provided := strings.TrimPrefix(authorization, prefix)
timestampText := r.Header.Get(agentwire.HeaderTimestamp)
nonce := r.Header.Get(agentwire.HeaderNonce)
timestamp, err := time.Parse(time.RFC3339Nano, timestampText)
if err != nil || !validNonce(nonce) {
return false
}
now := a.clock().UTC()
delta := now.Sub(timestamp)
if delta < -maxClockSkew || delta > maxClockSkew {
return false
}
expected := agentwire.Signature(a.secret, r.Method, r.URL.EscapedPath(), timestampText, nonce, body)
if !agentwire.EqualSignature(provided, expected) {
return false
}
return a.rememberNonce(nonce, now)
}
func (a *Authenticator) rememberNonce(nonce string, now time.Time) bool {
a.mu.Lock()
defer a.mu.Unlock()
for value, expires := range a.nonces {
if !expires.After(now) {
delete(a.nonces, value)
}
}
if _, exists := a.nonces[nonce]; exists {
return false
}
a.nonces[nonce] = now.Add(2 * maxClockSkew)
return true
}
func validNonce(value string) bool {
decoded, err := base64.RawURLEncoding.DecodeString(value)
return err == nil && len(decoded) >= 16 && len(decoded) <= 64
}
func readBoundedBody(r *http.Request) ([]byte, error) {
if r.Body == nil {
return nil, nil
}
defer r.Body.Close()
body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBody+1))
if err != nil {
return nil, err
}
if len(body) > maxRequestBody {
return nil, errors.New("request body too large")
}
return body, nil
}
+99
View File
@@ -0,0 +1,99 @@
package agent
import (
"bytes"
"encoding/base64"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
)
func TestAuthenticatorAcceptsOnceAndRejectsReplay(t *testing.T) {
now := time.Date(2026, 8, 6, 20, 0, 0, 0, time.UTC)
authenticator, err := newAuthenticator(testSecret(), func() time.Time { return now })
if err != nil {
t.Fatal(err)
}
handler := authenticator.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
request := signedAgentRequest(t, http.MethodPost, "/v1/test", []byte(`{"value":1}`), now, testNonce(), testSecret())
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusNoContent {
t.Fatalf("first request status = %d", recorder.Code)
}
replayed := signedAgentRequest(t, http.MethodPost, "/v1/test", []byte(`{"value":1}`), now, testNonce(), testSecret())
recorder = httptest.NewRecorder()
handler.ServeHTTP(recorder, replayed)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("replayed request status = %d", recorder.Code)
}
}
func TestAuthenticatorRejectsTamperingStaleRequestsAndQueries(t *testing.T) {
now := time.Date(2026, 8, 6, 20, 0, 0, 0, time.UTC)
for name, mutate := range map[string]func(*http.Request){
"body": func(request *http.Request) {
request.Body = http.NoBody
},
"stale": func(request *http.Request) {
old := now.Add(-time.Minute).Format(time.RFC3339Nano)
request.Header.Set(agentwire.HeaderTimestamp, old)
},
"query": func(request *http.Request) {
request.URL.RawQuery = "unexpected=true"
},
} {
t.Run(name, func(t *testing.T) {
authenticator, err := newAuthenticator(testSecret(), func() time.Time { return now })
if err != nil {
t.Fatal(err)
}
request := signedAgentRequest(t, http.MethodPost, "/v1/test", []byte(`{"value":1}`), now, testNonce(), testSecret())
mutate(request)
recorder := httptest.NewRecorder()
authenticator.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
})).ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("status = %d", recorder.Code)
}
})
}
}
func TestAuthenticatorRejectsOversizedBody(t *testing.T) {
authenticator, err := NewAuthenticator(testSecret())
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/v1/test", strings.NewReader(strings.Repeat("x", maxRequestBody+1)))
recorder := httptest.NewRecorder()
authenticator.Middleware(http.NotFoundHandler()).ServeHTTP(recorder, request)
if recorder.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d", recorder.Code)
}
}
func signedAgentRequest(t *testing.T, method, path string, body []byte, timestamp time.Time, nonce string, secret []byte) *http.Request {
t.Helper()
request := httptest.NewRequest(method, path, bytes.NewReader(body))
timestampText := timestamp.UTC().Format(time.RFC3339Nano)
request.Header.Set(agentwire.HeaderTimestamp, timestampText)
request.Header.Set(agentwire.HeaderNonce, nonce)
request.Header.Set("Authorization", agentwire.AuthorizationScheme+" "+agentwire.Signature(secret, method, path, timestampText, nonce, body))
return request
}
func testSecret() []byte {
return bytes.Repeat([]byte{0x42}, 32)
}
func testNonce() string {
return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{0x24}, 24))
}
+70
View File
@@ -0,0 +1,70 @@
package agent
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
)
// Config contains bootstrap-only settings for the restricted agent.
type Config struct {
ListenAddress string
Secret []byte
AllowedRoots []string
RegistryPath string
DockerSocket string
}
// 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")
}
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 != "" {
roots = append(roots, value)
}
}
if len(roots) == 0 {
return Config{}, errors.New("at least one allowed root is required")
}
config := Config{
ListenAddress: environment("DOGAMA_AGENT_LISTEN_ADDRESS", ":8081"),
Secret: secret,
AllowedRoots: roots,
RegistryPath: environment("DOGAMA_AGENT_REGISTRY_PATH", "/var/lib/dogama-agent/registry.json"),
DockerSocket: environment("DOGAMA_DOCKER_SOCKET", "/var/run/docker.sock"),
}
if !filepath.IsAbs(config.RegistryPath) || !filepath.IsAbs(config.DockerSocket) {
return Config{}, errors.New("registry and Docker socket paths must be absolute")
}
return config, nil
}
func readSecretFile(path string) ([]byte, error) {
body, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read agent secret: %w", err)
}
body = bytes.TrimSuffix(body, []byte("\n"))
body = bytes.TrimSuffix(body, []byte("\r"))
if len(body) < 32 || len(body) > 4096 {
return nil, errors.New("agent secret must contain between 32 and 4096 bytes")
}
return body, nil
}
func environment(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
+42
View File
@@ -0,0 +1,42 @@
package agent
import (
"bytes"
"os"
"path/filepath"
"testing"
)
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 {
t.Fatal(err)
}
serverRoot := filepath.Join(temporary, "servers")
if err := os.Mkdir(serverRoot, 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("DOGAMA_AGENT_TOKEN_FILE", secretPath)
t.Setenv("DOGAMA_ALLOWED_SERVER_ROOT", serverRoot)
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"))
config, err := LoadConfig()
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(config.Secret, secret) || len(config.AllowedRoots) != 1 || config.ListenAddress != ":8081" {
t.Fatalf("config = %#v", config)
}
}
func TestLoadConfigRequiresTokenAndRoot(t *testing.T) {
t.Setenv("DOGAMA_AGENT_TOKEN_FILE", "")
t.Setenv("DOGAMA_ALLOWED_SERVER_ROOT", "")
t.Setenv("DOGAMA_ALLOWED_BACKUP_ROOT", "")
if _, err := LoadConfig(); err == nil {
t.Fatal("missing token unexpectedly accepted")
}
}
+55
View File
@@ -0,0 +1,55 @@
package agent
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"path/filepath"
"strings"
"time"
)
// DockerPinger is the only Docker capability needed by the foundation agent.
// Container mutation is added only with validated deployment plans.
type DockerPinger interface {
Ping(context.Context) error
}
type dockerPinger struct {
client *http.Client
}
// NewDockerPinger constructs a client pinned to one configured Unix socket.
func NewDockerPinger(socketPath string) (DockerPinger, error) {
if !filepath.IsAbs(socketPath) {
return nil, errors.New("docker socket path must be absolute")
}
dialer := &net.Dialer{Timeout: 2 * time.Second}
transport := &http.Transport{
DisableCompression: true,
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return dialer.DialContext(ctx, "unix", socketPath)
},
}
return &dockerPinger{client: &http.Client{Transport: transport, Timeout: 3 * time.Second}}, nil
}
func (d *dockerPinger) Ping(ctx context.Context) error {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://docker/_ping", nil)
if err != nil {
return err
}
response, err := d.client.Do(request)
if err != nil {
return errors.New("docker daemon is unavailable")
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, 16))
if err != nil || response.StatusCode != http.StatusOK || strings.TrimSpace(string(body)) != "OK" {
return fmt.Errorf("docker daemon ping failed with HTTP %d", response.StatusCode)
}
return nil
}
+43
View File
@@ -0,0 +1,43 @@
package agent
import (
"context"
"net"
"net/http"
"path/filepath"
"testing"
)
func TestDockerPingerUsesConfiguredUnixSocket(t *testing.T) {
socket := filepath.Join(t.TempDir(), "docker.sock")
listener, err := net.Listen("unix", socket)
if err != nil {
t.Fatal(err)
}
server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/_ping" {
http.NotFound(w, r)
return
}
_, _ = w.Write([]byte("OK"))
})}
go func() { _ = server.Serve(listener) }()
t.Cleanup(func() { _ = server.Shutdown(context.Background()) })
pinger, err := NewDockerPinger(socket)
if err != nil {
t.Fatal(err)
}
if err := pinger.Ping(context.Background()); err != nil {
t.Fatal(err)
}
}
func TestDockerPingerDoesNotExposeConnectionDetails(t *testing.T) {
pinger, err := NewDockerPinger(filepath.Join(t.TempDir(), "missing.sock"))
if err != nil {
t.Fatal(err)
}
if err := pinger.Ping(context.Background()); err == nil || err.Error() != "docker daemon is unavailable" {
t.Fatalf("Ping error = %v", err)
}
}
+75
View File
@@ -0,0 +1,75 @@
package agent
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
var (
// ErrInvalidPath means the requested path cannot be safely canonicalized.
ErrInvalidPath = errors.New("invalid path")
// ErrPathOutsideRoots means the canonical path escaped every allowed root.
ErrPathOutsideRoots = errors.New("path is outside allowed roots")
)
// PathPolicy performs symlink-aware allowlisted-root enforcement.
type PathPolicy struct {
roots []string
}
// NewPathPolicy validates and canonicalizes all configured roots.
func NewPathPolicy(roots []string) (*PathPolicy, error) {
if len(roots) == 0 {
return nil, errors.New("at least one allowed root is required")
}
canonical := make([]string, 0, len(roots))
seen := make(map[string]struct{}, len(roots))
for _, root := range roots {
if !filepath.IsAbs(root) {
return nil, fmt.Errorf("allowed root must be absolute: %w", ErrInvalidPath)
}
resolved, err := filepath.EvalSymlinks(filepath.Clean(root))
if err != nil {
return nil, fmt.Errorf("resolve allowed root: %w", ErrInvalidPath)
}
info, err := os.Stat(resolved)
if err != nil || !info.IsDir() {
return nil, fmt.Errorf("allowed root must be a directory: %w", ErrInvalidPath)
}
if _, exists := seen[resolved]; exists {
continue
}
seen[resolved] = struct{}{}
canonical = append(canonical, resolved)
}
return &PathPolicy{roots: canonical}, nil
}
// Resolve returns an existing canonical path only when it is within a root.
func (p *PathPolicy) Resolve(path string) (string, error) {
if path == "" || !filepath.IsAbs(path) {
return "", ErrInvalidPath
}
resolved, err := filepath.EvalSymlinks(filepath.Clean(path))
if err != nil {
return "", ErrInvalidPath
}
for _, root := range p.roots {
relative, err := filepath.Rel(root, resolved)
if err != nil {
continue
}
if relative == "." || (relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))) {
return resolved, nil
}
}
return "", ErrPathOutsideRoots
}
// RootCount returns the number of canonical roots without disclosing them.
func (p *PathPolicy) RootCount() int {
return len(p.roots)
}
+72
View File
@@ -0,0 +1,72 @@
package agent
import (
"errors"
"os"
"path/filepath"
"testing"
)
func TestPathPolicyAllowsCanonicalDescendants(t *testing.T) {
root := t.TempDir()
child := filepath.Join(root, "instance", "data")
if err := os.MkdirAll(child, 0o700); err != nil {
t.Fatal(err)
}
policy, err := NewPathPolicy([]string{root})
if err != nil {
t.Fatal(err)
}
resolved, err := policy.Resolve(filepath.Join(root, "instance", "..", "instance", "data"))
if err != nil {
t.Fatal(err)
}
expected, _ := filepath.EvalSymlinks(child)
if resolved != expected {
t.Fatalf("resolved = %q, want %q", resolved, expected)
}
}
func TestPathPolicyRejectsTraversalSiblingAndRelativePaths(t *testing.T) {
parent := t.TempDir()
root := filepath.Join(parent, "servers")
sibling := filepath.Join(parent, "servers-escape")
if err := os.MkdirAll(root, 0o700); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(sibling, 0o700); err != nil {
t.Fatal(err)
}
policy, err := NewPathPolicy([]string{root})
if err != nil {
t.Fatal(err)
}
for _, path := range []string{sibling, filepath.Join(root, "..", "servers-escape"), "relative/path"} {
if _, err := policy.Resolve(path); !errors.Is(err, ErrPathOutsideRoots) && !errors.Is(err, ErrInvalidPath) {
t.Fatalf("Resolve(%q) error = %v", path, err)
}
}
}
func TestPathPolicyRejectsSymlinkEscape(t *testing.T) {
parent := t.TempDir()
root := filepath.Join(parent, "servers")
outside := filepath.Join(parent, "outside")
if err := os.MkdirAll(root, 0o700); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(outside, 0o700); err != nil {
t.Fatal(err)
}
link := filepath.Join(root, "escape")
if err := os.Symlink(outside, link); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
policy, err := NewPathPolicy([]string{root})
if err != nil {
t.Fatal(err)
}
if _, err := policy.Resolve(link); !errors.Is(err, ErrPathOutsideRoots) {
t.Fatalf("symlink escape error = %v", err)
}
}
+143
View File
@@ -0,0 +1,143 @@
package agent
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
)
const registryVersion = 1
// RegisteredInstance binds a DoGaMa instance to its expected Docker identity.
type RegisteredInstance struct {
InstanceID string `json:"instance_id"`
ContainerID string `json:"container_id"`
PlanDigest string `json:"plan_digest"`
}
type registryPayload struct {
Version int `json:"version"`
Instances []RegisteredInstance `json:"instances"`
}
type registryEnvelope struct {
Payload registryPayload `json:"payload"`
MAC string `json:"mac"`
}
// Registry is the authenticated durable agent-local registration store.
type Registry struct {
mu sync.RWMutex
path string
macKey []byte
instances []RegisteredInstance
}
// OpenRegistry loads and verifies the registry or creates an empty one.
func OpenRegistry(path string, secret []byte) (*Registry, error) {
if !filepath.IsAbs(path) {
return nil, errors.New("registry path must be absolute")
}
keyMAC := hmac.New(sha256.New, secret)
_, _ = keyMAC.Write([]byte("dogama-agent-registry-v1"))
registry := &Registry{path: path, macKey: keyMAC.Sum(nil)}
if err := registry.load(); err != nil {
if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
if err := registry.save(nil); err != nil {
return nil, err
}
}
return registry, nil
}
// List returns a stable copy of every authenticated registration.
func (r *Registry) List() []RegisteredInstance {
r.mu.RLock()
defer r.mu.RUnlock()
result := append([]RegisteredInstance(nil), r.instances...)
sort.Slice(result, func(i, j int) bool { return result[i].InstanceID < result[j].InstanceID })
return result
}
func (r *Registry) load() error {
info, err := os.Lstat(r.path)
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return errors.New("registry must be a regular file")
}
body, err := os.ReadFile(r.path)
if err != nil {
return fmt.Errorf("read agent registry: %w", err)
}
var envelope registryEnvelope
decoderErr := json.Unmarshal(body, &envelope)
if decoderErr != nil || envelope.Payload.Version != registryVersion || !r.validMAC(envelope.Payload, envelope.MAC) {
return errors.New("agent registry integrity check failed")
}
r.instances = append([]RegisteredInstance(nil), envelope.Payload.Instances...)
return nil
}
func (r *Registry) save(instances []RegisteredInstance) error {
r.mu.Lock()
defer r.mu.Unlock()
payload := registryPayload{Version: registryVersion, Instances: append([]RegisteredInstance(nil), instances...)}
envelope := registryEnvelope{Payload: payload, MAC: r.mac(payload)}
body, err := json.Marshal(envelope)
if err != nil {
return fmt.Errorf("encode agent registry: %w", err)
}
if err := os.MkdirAll(filepath.Dir(r.path), 0o700); err != nil {
return fmt.Errorf("create agent registry directory: %w", err)
}
temporary, err := os.CreateTemp(filepath.Dir(r.path), ".registry-*")
if err != nil {
return fmt.Errorf("create agent registry temporary file: %w", err)
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if err = temporary.Chmod(0o600); err == nil {
_, err = temporary.Write(body)
}
if err == nil {
err = temporary.Sync()
}
if closeErr := temporary.Close(); err == nil {
err = closeErr
}
if err != nil {
return fmt.Errorf("write agent registry: %w", err)
}
if err := os.Rename(temporaryPath, r.path); err != nil {
return fmt.Errorf("replace agent registry: %w", err)
}
r.instances = append([]RegisteredInstance(nil), instances...)
return nil
}
func (r *Registry) mac(payload registryPayload) string {
body, _ := json.Marshal(payload)
mac := hmac.New(sha256.New, r.macKey)
_, _ = mac.Write(body)
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func (r *Registry) validMAC(payload registryPayload, provided string) bool {
providedBytes, err := base64.RawURLEncoding.DecodeString(provided)
if err != nil {
return false
}
expectedBytes, _ := base64.RawURLEncoding.DecodeString(r.mac(payload))
return hmac.Equal(providedBytes, expectedBytes)
}
+66
View File
@@ -0,0 +1,66 @@
package agent
import (
"bytes"
"os"
"path/filepath"
"testing"
)
func TestRegistryCreatesPersistsAndSortsAuthenticatedEntries(t *testing.T) {
path := filepath.Join(t.TempDir(), "state", "registry.json")
registry, err := OpenRegistry(path, testSecret())
if err != nil {
t.Fatal(err)
}
if instances := registry.List(); len(instances) != 0 {
t.Fatalf("new registry contains %d instances", len(instances))
}
entries := []RegisteredInstance{
{InstanceID: "instance-b", ContainerID: "container-b", PlanDigest: "digest-b"},
{InstanceID: "instance-a", ContainerID: "container-a", PlanDigest: "digest-a"},
}
if err := registry.save(entries); err != nil {
t.Fatal(err)
}
reopened, err := OpenRegistry(path, testSecret())
if err != nil {
t.Fatal(err)
}
actual := reopened.List()
if len(actual) != 2 || actual[0].InstanceID != "instance-a" || actual[1].InstanceID != "instance-b" {
t.Fatalf("sorted instances = %#v", actual)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("registry permissions = %o", info.Mode().Perm())
}
}
func TestRegistryRejectsTamperingAndWrongKey(t *testing.T) {
path := filepath.Join(t.TempDir(), "registry.json")
if _, err := OpenRegistry(path, testSecret()); err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
tampered := bytes.Replace(body, []byte(`"version":1`), []byte(`"version":2`), 1)
if err := os.WriteFile(path, tampered, 0o600); err != nil {
t.Fatal(err)
}
if _, err := OpenRegistry(path, testSecret()); err == nil {
t.Fatal("tampered registry unexpectedly accepted")
}
if err := os.WriteFile(path, body, 0o600); err != nil {
t.Fatal(err)
}
wrongKey := bytes.Repeat([]byte{0x99}, 32)
if _, err := OpenRegistry(path, wrongKey); err == nil {
t.Fatal("registry with wrong key unexpectedly accepted")
}
}
+125
View File
@@ -0,0 +1,125 @@
// Package agent implements the deny-by-default privileged Docker boundary.
package agent
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"syscall"
"time"
)
const maxDiskPaths = 16
type service struct {
paths *PathPolicy
registry *Registry
docker DockerPinger
logger *slog.Logger
}
// NewHandler constructs the complete authenticated private agent API.
func NewHandler(authenticator *Authenticator, paths *PathPolicy, registry *Registry, docker DockerPinger, logger *slog.Logger) http.Handler {
server := &service{paths: paths, registry: registry, docker: docker, logger: logger}
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/health", server.health)
mux.HandleFunc("POST /v1/check-disk", server.checkDisk)
mux.HandleFunc("GET /v1/instances", server.listInstances)
return server.headers(authenticator.Middleware(mux))
}
func (s *service) health(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := s.docker.Ping(ctx); err != nil {
s.logger.Warn("Docker daemon unavailable", "event", "agent.docker.unavailable")
writeProblem(w, http.StatusServiceUnavailable, "docker_unavailable", "The Docker daemon is unavailable.")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *service) checkDisk(w http.ResponseWriter, r *http.Request) {
var request struct {
Paths []string `json:"paths"`
}
if err := decodeJSON(r.Body, &request); err != nil || len(request.Paths) == 0 || len(request.Paths) > maxDiskPaths {
writeProblem(w, http.StatusBadRequest, "invalid_request", "The request is invalid.")
return
}
type diskInfo struct {
Path string `json:"path"`
BytesAvailable uint64 `json:"bytes_available"`
BytesTotal uint64 `json:"bytes_total"`
}
response := struct {
Paths []diskInfo `json:"paths"`
}{Paths: make([]diskInfo, 0, len(request.Paths))}
seen := make(map[string]struct{}, len(request.Paths))
for _, requested := range request.Paths {
canonical, err := s.paths.Resolve(requested)
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "path_not_allowed", "A requested path is not allowed.")
return
}
if _, exists := seen[canonical]; exists {
continue
}
seen[canonical] = struct{}{}
var filesystem syscall.Statfs_t
if err := syscall.Statfs(canonical, &filesystem); err != nil || filesystem.Bsize <= 0 {
writeProblem(w, http.StatusUnprocessableEntity, "path_unavailable", "A requested path is unavailable.")
return
}
blockSize := uint64(filesystem.Bsize)
response.Paths = append(response.Paths, diskInfo{
Path: requested,
BytesAvailable: blockSize * filesystem.Bavail,
BytesTotal: blockSize * filesystem.Blocks,
})
}
writeJSON(w, http.StatusOK, response)
}
func (s *service) listInstances(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, struct {
Instances []RegisteredInstance `json:"instances"`
}{Instances: s.registry.List()})
}
func (s *service) headers(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
w.Header().Set("X-Content-Type-Options", "nosniff")
next.ServeHTTP(w, r)
})
}
func decodeJSON(body io.Reader, destination any) error {
decoder := json.NewDecoder(body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(destination); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return errors.New("multiple JSON values")
}
return nil
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func writeProblem(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, struct {
Code string `json:"code"`
Message string `json:"message"`
}{Code: code, Message: message})
}
+108
View File
@@ -0,0 +1,108 @@
package agent_test
import (
"bytes"
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agent"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentclient"
)
type fakeDocker struct {
err error
}
func (d fakeDocker) Ping(context.Context) error { return d.err }
func TestAuthenticatedAgentClientOperations(t *testing.T) {
root := t.TempDir()
secret := bytes.Repeat([]byte{0x31}, 32)
handler := newTestHandler(t, root, secret, fakeDocker{})
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
client, err := agentclient.New(server.URL, secret, server.Client())
if err != nil {
t.Fatal(err)
}
if err := client.Health(context.Background()); err != nil {
t.Fatal(err)
}
disks, err := client.CheckDisk(context.Background(), []string{root, root})
if err != nil {
t.Fatal(err)
}
if len(disks) != 1 || disks[0].Path != root || disks[0].BytesTotal == 0 {
t.Fatalf("disk response = %#v", disks)
}
instances, err := client.ListRegisteredInstances(context.Background())
if err != nil {
t.Fatal(err)
}
if len(instances) != 0 {
t.Fatalf("instances = %#v", instances)
}
}
func TestAgentRejectsUnauthenticatedAndOutsideRootRequests(t *testing.T) {
root := t.TempDir()
secret := bytes.Repeat([]byte{0x31}, 32)
handler := newTestHandler(t, root, secret, fakeDocker{})
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1/instances", nil))
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated status = %d", recorder.Code)
}
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
client, err := agentclient.New(server.URL, secret, server.Client())
if err != nil {
t.Fatal(err)
}
_, err = client.CheckDisk(context.Background(), []string{filepath.Dir(root)})
var problem *agentclient.ProblemError
if !errors.As(err, &problem) || problem.Status != http.StatusUnprocessableEntity || problem.Code != "path_not_allowed" {
t.Fatalf("outside-root error = %#v", err)
}
}
func TestAgentReportsDockerUnavailableWithoutDetails(t *testing.T) {
root := t.TempDir()
secret := bytes.Repeat([]byte{0x31}, 32)
handler := newTestHandler(t, root, secret, fakeDocker{err: errors.New("sensitive socket detail")})
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
client, err := agentclient.New(server.URL, secret, server.Client())
if err != nil {
t.Fatal(err)
}
err = client.Health(context.Background())
var problem *agentclient.ProblemError
if !errors.As(err, &problem) || problem.Status != http.StatusServiceUnavailable || problem.Code != "docker_unavailable" {
t.Fatalf("health error = %#v", err)
}
}
func newTestHandler(t *testing.T, root string, secret []byte, docker agent.DockerPinger) http.Handler {
t.Helper()
paths, err := agent.NewPathPolicy([]string{root})
if err != nil {
t.Fatal(err)
}
registry, err := agent.OpenRegistry(filepath.Join(t.TempDir(), "registry.json"), secret)
if err != nil {
t.Fatal(err)
}
authenticator, err := agent.NewAuthenticator(secret)
if err != nil {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
return agent.NewHandler(authenticator, paths, registry, docker, logger)
}
+163
View File
@@ -0,0 +1,163 @@
// Package agentclient calls the restricted same-host Docker agent.
package agentclient
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
)
const maxResponseBytes = 64 << 10
// Client is an authenticated client for the private agent API.
type Client struct {
baseURL *url.URL
secret []byte
http *http.Client
clock func() time.Time
random io.Reader
}
// DiskInfo is the bounded filesystem information returned for a requested path.
type DiskInfo struct {
Path string `json:"path"`
BytesAvailable uint64 `json:"bytes_available"`
BytesTotal uint64 `json:"bytes_total"`
}
// RegisteredInstance is an agent-owned registration binding.
type RegisteredInstance struct {
InstanceID string `json:"instance_id"`
ContainerID string `json:"container_id"`
PlanDigest string `json:"plan_digest"`
}
// ProblemError is a typed, non-sensitive error returned by the agent.
type ProblemError struct {
Status int
Code string
Message string
}
func (e *ProblemError) Error() string {
return fmt.Sprintf("agent request failed: %s (HTTP %d)", e.Code, e.Status)
}
// New constructs a client. The base URL must not contain credentials, a query
// or a path beyond an optional trailing slash.
func New(baseURL string, secret []byte, httpClient *http.Client) (*Client, error) {
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return nil, errors.New("invalid agent URL")
}
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") {
return nil, errors.New("agent URL must contain only scheme and authority")
}
if len(secret) < 32 {
return nil, errors.New("agent secret must contain at least 32 bytes")
}
if httpClient == nil {
httpClient = &http.Client{Timeout: 5 * time.Second}
}
parsed.Path = strings.TrimSuffix(parsed.Path, "/")
return &Client{
baseURL: parsed,
secret: append([]byte(nil), secret...),
http: httpClient,
clock: time.Now,
random: rand.Reader,
}, nil
}
// Health verifies both the authenticated API and Docker daemon reachability.
func (c *Client) Health(ctx context.Context) error {
return c.do(ctx, http.MethodGet, "/v1/health", nil, nil)
}
// CheckDisk returns filesystem capacity only for paths accepted by the agent.
func (c *Client) CheckDisk(ctx context.Context, paths []string) ([]DiskInfo, error) {
var response struct {
Paths []DiskInfo `json:"paths"`
}
if err := c.do(ctx, http.MethodPost, "/v1/check-disk", struct {
Paths []string `json:"paths"`
}{Paths: paths}, &response); err != nil {
return nil, err
}
return response.Paths, nil
}
// ListRegisteredInstances returns only the agent's authenticated registry.
func (c *Client) ListRegisteredInstances(ctx context.Context) ([]RegisteredInstance, error) {
var response struct {
Instances []RegisteredInstance `json:"instances"`
}
if err := c.do(ctx, http.MethodGet, "/v1/instances", nil, &response); err != nil {
return nil, err
}
return response.Instances, nil
}
func (c *Client) do(ctx context.Context, method, path string, input, output any) error {
var body []byte
var err error
if input != nil {
body, err = json.Marshal(input)
if err != nil {
return fmt.Errorf("encode agent request: %w", err)
}
}
nonceBytes := make([]byte, 24)
if _, err := io.ReadFull(c.random, nonceBytes); err != nil {
return fmt.Errorf("generate agent nonce: %w", err)
}
nonce := base64.RawURLEncoding.EncodeToString(nonceBytes)
timestamp := c.clock().UTC().Format(time.RFC3339Nano)
requestURL := *c.baseURL
requestURL.Path = path
request, err := http.NewRequestWithContext(ctx, method, requestURL.String(), bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create agent request: %w", err)
}
request.Header.Set("Authorization", agentwire.AuthorizationScheme+" "+agentwire.Signature(c.secret, method, requestURL.EscapedPath(), timestamp, nonce, body))
request.Header.Set(agentwire.HeaderTimestamp, timestamp)
request.Header.Set(agentwire.HeaderNonce, nonce)
if input != nil {
request.Header.Set("Content-Type", "application/json")
}
response, err := c.http.Do(request)
if err != nil {
return fmt.Errorf("call agent: %w", err)
}
defer response.Body.Close()
limited := io.LimitReader(response.Body, maxResponseBytes+1)
responseBody, err := io.ReadAll(limited)
if err != nil {
return fmt.Errorf("read agent response: %w", err)
}
if len(responseBody) > maxResponseBytes {
return errors.New("agent response exceeded size limit")
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
problem := &ProblemError{Status: response.StatusCode, Code: "agent_error", Message: "The agent request failed."}
_ = json.Unmarshal(responseBody, problem)
return problem
}
if output != nil && len(responseBody) != 0 {
if err := json.Unmarshal(responseBody, output); err != nil {
return fmt.Errorf("decode agent response: %w", err)
}
}
return nil
}
+46
View File
@@ -0,0 +1,46 @@
// Package agentwire defines the small authenticated protocol shared by the
// main application and the restricted Docker agent.
package agentwire
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"strings"
)
const (
AuthorizationScheme = "DoGaMa-HMAC-SHA256"
HeaderTimestamp = "X-DoGaMa-Timestamp"
HeaderNonce = "X-DoGaMa-Nonce"
)
// Signature signs the request method, escaped path, timestamp, nonce and body.
func Signature(secret []byte, method, path, timestamp, nonce string, body []byte) string {
digest := sha256.Sum256(body)
canonical := strings.Join([]string{
"DOGAMA-HMAC-V1",
method,
path,
timestamp,
nonce,
hex.EncodeToString(digest[:]),
}, "\n")
mac := hmac.New(sha256.New, secret)
_, _ = mac.Write([]byte(canonical))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// EqualSignature compares encoded signatures in constant time.
func EqualSignature(provided, expected string) bool {
providedBytes, err := base64.RawURLEncoding.DecodeString(provided)
if err != nil {
return false
}
expectedBytes, err := base64.RawURLEncoding.DecodeString(expected)
if err != nil {
return false
}
return hmac.Equal(providedBytes, expectedBytes)
}