feat: add restricted Docker agent foundation
This commit is contained in:
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user