From c7f6e6ac45af58486abaa52246ef1c75f6f5ae13 Mon Sep 17 00:00:00 2001 From: Benjy Date: Fri, 17 Jul 2026 08:52:40 +0200 Subject: [PATCH 1/7] modernize dev image pipeline --- .dockerignore | 7 ++ .github/workflows/docker-image.yml | 91 ++++++++++++++-------- Dockerfile | 23 ++---- README.md | 33 ++++++++ compose.yml | 8 ++ go.mod | 3 + src/main.go | 93 +++++++++++++++------- src/main_test.go | 121 +++++++++++++++++++++++++++++ 8 files changed, 301 insertions(+), 78 deletions(-) create mode 100644 .dockerignore create mode 100644 README.md create mode 100644 go.mod create mode 100644 src/main_test.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..34c29dc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.github +.gitignore +compose.yml +config +README.md +**/*_test.go diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 761676e..5c25064 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -1,65 +1,90 @@ -name: Build and publish Docker image +name: Test and publish development image on: push: branches: - main - tags: - - 'v*' paths: - - src/* + - .github/workflows/docker-image.yml + - Dockerfile + - go.mod + - src/** + pull_request: + paths: + - .github/workflows/docker-image.yml + - Dockerfile + - go.mod + - src/** workflow_dispatch: +concurrency: + group: docker-dev-${{ github.ref }} + cancel-in-progress: true + env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository_owner }}/docker-socket-proxy - PKG_NAME: docker-socket-proxy jobs: - build-and-push: + test: runs-on: ubuntu-latest permissions: contents: read - packages: write + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: false + + - name: Format check + run: test -z "$(gofmt -l .)" + + - name: Vet + run: go vet ./... + - name: Test + run: go test -race ./... + + publish: + if: github.event_name != 'pull_request' + needs: test + runs-on: ubuntu-latest + permissions: + contents: read + packages: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract Docker metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=ref,event=branch - type=ref,event=tag - type=sha - type=raw,value=latest,enable={{is_default_branch}} + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 - - name: Build and push (linux/amd64 only) - uses: docker/build-push-action@v6 + - name: Build and push development image + uses: docker/build-push-action@v7 with: context: . file: ./Dockerfile push: true - platforms: linux/amd64 - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + platforms: linux/amd64,linux/arm64 + tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev build-args: | - VERSION=${{ github.ref_name }} - GIT_SHA=${{ github.sha }} - - - name: Prune old container versions (keep last 3) - uses: actions/delete-package-versions@v5 - with: - package-name: ${{ env.PKG_NAME }} - package-type: container - min-versions-to-keep: 3 + APP_VERSION=dev + APP_GIT_SHA=${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: mode=max + sbom: true diff --git a/Dockerfile b/Dockerfile index 091b510..7eaf148 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,40 +1,31 @@ -# ========================= -# Stage 1 : build Go binary -# ========================= -FROM golang:1.23-alpine AS build +FROM golang:1.26.5-alpine3.24 AS build ARG APP_VERSION="dev" ARG APP_GIT_SHA="unknown" -# On désactive les modules pour un projet simple sans go.mod -ENV GO111MODULE=off \ - CGO_ENABLED=0 +ENV CGO_ENABLED=0 WORKDIR /src -# On copie juste le dossier src/ +COPY go.mod ./ COPY src/ ./src -# Build du binaire RUN go build -trimpath \ -ldflags="-s -w -X main.version=${APP_VERSION} -X main.gitSha=${APP_GIT_SHA}" \ -o /out/docker-socket-proxy ./src -# ========================= -# Stage 2 : image finale -# ========================= -#FROM alpine:3.20 si debug -FROM gcr.io/distroless/base-debian12 +FROM gcr.io/distroless/static-debian13:nonroot -COPY --from=build /out/docker-socket-proxy /usr/local/bin/docker-socket-proxy +COPY --from=build --chown=nonroot:nonroot /out/docker-socket-proxy /usr/local/bin/docker-socket-proxy ENV DOCKER_SOCKET_PATH=/var/run/docker.sock \ PROXY_PORT=2375 EXPOSE 2375 -# Healthcheck intégré : teste le serveur Go + accès Docker via /version HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD ["docker-socket-proxy", "healthcheck"] +USER nonroot:nonroot + ENTRYPOINT ["docker-socket-proxy"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..fb03a63 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# docker-socket-proxy + +Proxy HTTP minimaliste devant le socket Docker. Les clients sont associés à un profil par leur adresse IP et les droits Docker sont refusés par défaut. + +## Image de développement + +```text +ghcr.io/cerede2000/docker-socket-proxy:dev +``` + +L'image est publiée pour `linux/amd64` et `linux/arm64` après validation des tests. Elle utilise un binaire Go statique dans Distroless Debian 13 et s'exécute sans privilèges par défaut. + +## Configuration + +| Variable | Défaut | Description | +| --- | --- | --- | +| `DOCKER_SOCKET_PATH` | `/var/run/docker.sock` | Chemin du socket Docker | +| `PROXY_PORT` | `2375` | Port d'écoute et port utilisé par le healthcheck | +| `PROXY_LISTEN` | vide | Adresse d'écoute complète, prioritaire sur `PROXY_PORT` | +| `SOCKETPROXY_PROFILE_FILE` | `/config/profiles.yml` | Fichier de profils | +| `DISCOVER_INTERVAL` | `30s` | Intervalle de redécouverte | +| `EVENT_DEBOUNCE_DELAY` | `100ms` | Temporisation des événements Docker | + +Les options `--listen`, `--socket`, `--profiles`, `--discover-interval` et `--debounce-delay` restent disponibles et sont prioritaires sur l'environnement. Si `--listen` change le port, `PROXY_PORT` doit être renseigné avec le même port pour le healthcheck. + +Le compte effectif doit pouvoir lire le socket Docker. Le fichier Compose fournit un exemple avec un UID/GID hôte explicite ; adaptez `user` au propriétaire et au groupe du socket de votre machine. + +## Développement + +```bash +go test -race ./... +go vet ./... +``` diff --git a/compose.yml b/compose.yml index 93d3796..9bcca6d 100644 --- a/compose.yml +++ b/compose.yml @@ -4,6 +4,10 @@ services: container_name: docker-socket-proxy user: "107:108" read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true tmpfs: - /tmp volumes: @@ -16,3 +20,7 @@ services: - --dockerproxy-watchtower.info=1 - --dockerproxy-watchtower.events=1 restart: unless-stopped + +networks: + traefikfront: + external: true diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..68e482a --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/cerede2000/docker-socket-proxy + +go 1.26 diff --git a/src/main.go b/src/main.go index 2665538..2404e7a 100644 --- a/src/main.go +++ b/src/main.go @@ -16,6 +16,7 @@ import ( "strconv" "strings" "sync" + "syscall" "time" ) @@ -71,11 +72,11 @@ type ProxyConfig struct { DebounceDelay time.Duration // Délai de debouncing pour les events baseServices map[string]*ServiceConfig // défini par les args (CLI) - + // Protection concurrentielle pour les données partagées - mu sync.RWMutex - services map[string]*ServiceConfig // effectif (CLI + YAML) - ipToRole map[string]string // IP -> nom de rôle + mu sync.RWMutex + services map[string]*ServiceConfig // effectif (CLI + YAML) + ipToRole map[string]string // IP -> nom de rôle selfNetworks map[string]struct{} // réseaux du conteneur socket-proxy (immuable après init) selfNetworksHash string // hash des réseaux pour cache DNS @@ -317,9 +318,26 @@ func parseConfig(args []string, logger *log.Logger) *ProxyConfig { profilesPath = "/config/profiles.yml" } + listen := ":2375" + if port := strings.TrimSpace(os.Getenv("PROXY_PORT")); port != "" { + if n, err := strconv.Atoi(port); err == nil && n > 0 && n <= 65535 { + listen = ":" + port + } else { + logger.Printf("[config] invalid PROXY_PORT=%q, using 2375", port) + } + } + if envListen := strings.TrimSpace(os.Getenv("PROXY_LISTEN")); envListen != "" { + listen = envListen + } + + socketPath := strings.TrimSpace(os.Getenv("DOCKER_SOCKET_PATH")) + if socketPath == "" { + socketPath = "/var/run/docker.sock" + } + cfg := &ProxyConfig{ - Listen: ":2375", - SocketPath: "/var/run/docker.sock", + Listen: listen, + SocketPath: socketPath, DiscoverInterval: discoverIntervalFromEnv(logger), DebounceDelay: debounceDelayFromEnv(logger), ProfilesFile: profilesPath, @@ -566,17 +584,17 @@ func newDockerHTTPClient(socketPath string) *http.Client { DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return net.Dial("unix", socketPath) }, - MaxIdleConns: 10, - IdleConnTimeout: 90 * time.Second, - DisableCompression: false, - DisableKeepAlives: false, + MaxIdleConns: 10, + IdleConnTimeout: 90 * time.Second, + DisableCompression: false, + DisableKeepAlives: false, // Pas de ResponseHeaderTimeout pour supporter /events ResponseHeaderTimeout: 0, } return &http.Client{ Transport: tr, // Timeout à 0 pour supporter les connexions longues (/events) - Timeout: 0, + Timeout: 0, } } @@ -664,20 +682,20 @@ func getSelfNetworksWithCache(ctx context.Context, cfg *ProxyConfig, client *htt if err != nil { return err } - + newHash := hashNetworks(nets) - + // Si le hash n'a pas changé, on garde le cache if cfg.selfNetworksHash != "" && cfg.selfNetworksHash == newHash { logger.Printf("[discover] self networks unchanged (cache hit)") return nil } - + // Mise à jour du cache cfg.selfNetworks = nets cfg.selfNetworksHash = newHash logger.Printf("[discover] self networks updated (cache miss)") - + return nil } @@ -844,7 +862,7 @@ func (d *eventDebouncer) trigger() { d.lastTrigger = time.Now() count := d.pendingEvents d.pendingEvents = 0 - + d.mu.Unlock() if count > 0 { d.callback() @@ -861,7 +879,7 @@ func (d *eventDebouncer) trigger() { d.lastTrigger = time.Now() count := d.pendingEvents d.pendingEvents = 0 - + // Unlock avant d'appeler le callback d.mu.Unlock() if count > 0 { @@ -1021,11 +1039,11 @@ func eventLoop(ctx context.Context, cfg *ProxyConfig, client *http.Client, logge // Déclencher le debouncer avec logging approprié willTriggerImmediately := debouncer.willTriggerImmediately() - + if willTriggerImmediately { logger.Printf("[events] %s -> triggering discovery immediately", logDetails) } else { - logger.Printf("[events] %s -> debouncing discovery (pending=%d, delay=%s)", + logger.Printf("[events] %s -> debouncing discovery (pending=%d, delay=%s)", logDetails, debouncer.getPendingCount()+1, cfg.DebounceDelay) } @@ -1143,8 +1161,12 @@ func classifyPath(path string) (feature string, action string) { } func (s *ServiceConfig) Allow(feature, method, action string) bool { + isRead := method == http.MethodGet || method == http.MethodHead isWrite := method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch || method == http.MethodDelete + if !isRead && !isWrite { + return false + } switch feature { case "ping": @@ -1295,10 +1317,18 @@ func rewriteAPIVersion(path, targetVersion string) string { // Format: /v1.XX/reste idx := strings.Index(path[2:], "/") if idx == -1 { - // Pas de / après la version, path invalide + // Un endpoint tel que /version commence par "/v" sans être un préfixe + // d'API. Dans ce cas, il faut ajouter la version comme pour tout autre + // endpoint non versionné. + versionPart := path[2:] + for _, c := range versionPart { + if (c < '0' || c > '9') && c != '.' { + return "/v" + targetVersion + path + } + } return path } - + // Vérifier que c'est bien une version (v + chiffres + points) versionPart := path[2 : idx+2] for _, c := range versionPart { @@ -1380,7 +1410,12 @@ func runHealthcheck() int { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:2375/version", nil) + port := strings.TrimSpace(os.Getenv("PROXY_PORT")) + if n, err := strconv.Atoi(port); err != nil || n <= 0 || n > 65535 { + port = "2375" + } + healthURL := "http://127.0.0.1:" + port + "/version" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil) if err != nil { logger.Printf("[health] build request error: %v", err) return 1 @@ -1418,12 +1453,12 @@ func main() { cfg := parseConfig(os.Args[1:], logger) - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() // Client pour le proxy (sans timeout pour supporter /events) proxyClient := newDockerHTTPClient(cfg.SocketPath) - + // Client avec timeout pour les opérations de découverte discoveryClient := newDockerHTTPClientWithTimeout(cfg.SocketPath) @@ -1443,7 +1478,7 @@ func main() { maxRetries := 5 retryDelay := 1 * time.Second discoverySuccess := false - + for i := 0; i < maxRetries; i++ { if err := discoverOnce(ctx, cfg, discoveryClient, logger); err != nil { logger.Printf("[discover] initial discovery attempt %d/%d failed: %v", i+1, maxRetries, err) @@ -1457,7 +1492,7 @@ func main() { break } } - + if !discoverySuccess { logger.Printf("[main] WARNING: initial discovery failed after %d attempts - starting anyway", maxRetries) } @@ -1484,9 +1519,9 @@ func main() { ReadHeaderTimeout: 10 * time.Second, // WriteTimeout doit être 0 pour supporter les connexions longues comme /events // Traefik et autres clients maintiennent /events ouvert indéfiniment - WriteTimeout: 0, - IdleTimeout: 120 * time.Second, - MaxHeaderBytes: 1 << 20, // 1 MB + WriteTimeout: 0, + IdleTimeout: 120 * time.Second, + MaxHeaderBytes: 1 << 20, // 1 MB } logger.Printf("[main] listening on %s, docker socket=%s, discover every %s, debounce=%s, profilesFile=%s", diff --git a/src/main_test.go b/src/main_test.go new file mode 100644 index 0000000..4294614 --- /dev/null +++ b/src/main_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "io" + "log" + "net/http" + "testing" +) + +func TestParseConfigUsesEnvironment(t *testing.T) { + t.Setenv("PROXY_PORT", "4242") + t.Setenv("DOCKER_SOCKET_PATH", "/run/custom.sock") + t.Setenv("SOCKETPROXY_PROFILE_FILE", "/tmp/profiles.yml") + + cfg := parseConfig(nil, log.New(io.Discard, "", 0)) + if cfg.Listen != ":4242" { + t.Fatalf("Listen = %q, want %q", cfg.Listen, ":4242") + } + if cfg.SocketPath != "/run/custom.sock" { + t.Fatalf("SocketPath = %q, want %q", cfg.SocketPath, "/run/custom.sock") + } + if cfg.ProfilesFile != "/tmp/profiles.yml" { + t.Fatalf("ProfilesFile = %q, want %q", cfg.ProfilesFile, "/tmp/profiles.yml") + } +} + +func TestParseConfigCLIOverridesEnvironment(t *testing.T) { + t.Setenv("PROXY_PORT", "4242") + t.Setenv("DOCKER_SOCKET_PATH", "/run/env.sock") + + cfg := parseConfig([]string{"--listen=:5252", "--socket=/run/cli.sock"}, log.New(io.Discard, "", 0)) + if cfg.Listen != ":5252" { + t.Fatalf("Listen = %q, want %q", cfg.Listen, ":5252") + } + if cfg.SocketPath != "/run/cli.sock" { + t.Fatalf("SocketPath = %q, want %q", cfg.SocketPath, "/run/cli.sock") + } +} + +func TestClassifyPath(t *testing.T) { + tests := []struct { + path string + feature string + action string + }{ + {"/_ping", "ping", ""}, + {"/v1.51/version", "version", ""}, + {"/v1.51/containers/json", "containers", ""}, + {"/v1.51/containers/id/start", "containers", "start"}, + {"/v1.51/containers/id/restart", "containers", "restart"}, + {"/v1.51/exec/id/start", "exec", ""}, + {"/not-a-docker-endpoint", "unknown", ""}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + feature, action := classifyPath(tt.path) + if feature != tt.feature || action != tt.action { + t.Fatalf("classifyPath(%q) = (%q, %q), want (%q, %q)", tt.path, feature, action, tt.feature, tt.action) + } + }) + } +} + +func TestAllowIsDenyByDefault(t *testing.T) { + service := &ServiceConfig{} + if service.Allow("version", http.MethodGet, "") { + t.Fatal("empty profile unexpectedly allows /version") + } + if service.Allow("unknown", http.MethodGet, "") { + t.Fatal("profile unexpectedly allows an unknown endpoint") + } +} + +func TestAllowReadAndWritePermissions(t *testing.T) { + service := &ServiceConfig{Containers: true} + if !service.Allow("containers", http.MethodGet, "") { + t.Fatal("containers read permission was denied") + } + if service.Allow("containers", http.MethodPost, "") { + t.Fatal("write was allowed without post permission") + } + if service.Allow("containers", http.MethodConnect, "") { + t.Fatal("unsupported method was allowed") + } + + service.Post = true + if !service.Allow("containers", http.MethodPost, "") { + t.Fatal("write was denied with containers and post permissions") + } + if service.Allow("containers", http.MethodPost, "start") { + t.Fatal("start was allowed without allow_start") + } + service.AllowStart = true + if !service.Allow("containers", http.MethodPost, "start") { + t.Fatal("start was denied with allow_start") + } +} + +func TestRewriteAPIVersion(t *testing.T) { + tests := map[string]string{ + "/containers/json": "/v1.51/containers/json", + "/v1.40/containers/json": "/v1.51/containers/json", + "/version": "/v1.51/version", + } + for input, want := range tests { + if got := rewriteAPIVersion(input, "1.51"); got != want { + t.Errorf("rewriteAPIVersion(%q) = %q, want %q", input, got, want) + } + } +} + +func TestParseProfilesYAML(t *testing.T) { + profiles, err := parseProfilesYAML("home:\n ping: true\n containers: false\n") + if err != nil { + t.Fatal(err) + } + if profiles["home"]["ping"] != "true" || profiles["home"]["containers"] != "false" { + t.Fatalf("unexpected profiles: %#v", profiles) + } +} From 8046c7e793a054b6c447175fb8b79b471da00925 Mon Sep 17 00:00:00 2001 From: Benjy Date: Fri, 17 Jul 2026 09:26:13 +0200 Subject: [PATCH 2/7] add scoped container access controls --- README.md | 24 ++ config/profiles.yml | 28 ++ go.mod | 2 + go.sum | 4 + src/main.go | 677 +++++++++++++++++++++++++++++++++++++++----- src/main_test.go | 135 ++++++++- 6 files changed, 799 insertions(+), 71 deletions(-) create mode 100644 go.sum diff --git a/README.md b/README.md index fb03a63..4d1613e 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,30 @@ Les options `--listen`, `--socket`, `--profiles`, `--discover-interval` et `--de Le compte effectif doit pouvoir lire le socket Docker. Le fichier Compose fournit un exemple avec un UID/GID hôte explicite ; adaptez `user` au propriétaire et au groupe du socket de votre machine. +## Portée des conteneurs + +Par défaut, un profil conserve ses droits sur tous les conteneurs. `container_scope` ajoute une restriction optionnelle : + +```yaml +traefik-manager: + containers: true + post: true + allow_start: true + allow_stop: true + container_scope: allowlist + allowed_containers: + - traefik + +operator: + containers: true + events: true + container_scope: blacklist + blocked_containers: + - docker-socket-proxy +``` + +Valeurs possibles : `all` (comportement historique), `allowlist` et `blacklist`. Les noms sont exacts et correspondent au nom Docker sans le préfixe `/`. Les opérations ciblées, les listes de conteneurs et les événements sont filtrés. Les opérations globales de conteneurs (`create` et `prune`) sont refusées pour un profil ayant une portée active. + ## Développement ```bash diff --git a/config/profiles.yml b/config/profiles.yml index c3f072b..cbf56c5 100644 --- a/config/profiles.yml +++ b/config/profiles.yml @@ -19,3 +19,31 @@ portainer: allow_start: true allow_stop: true allow_restart: true + +# Exemple de profil restreint : le rôle conserve ses droits Docker, mais ne peut +# agir que sur le conteneur nommé traefik. +traefik-manager: + ping: true + version: true + containers: true + post: true + allow_start: true + allow_stop: true + allow_restart: true + container_scope: allowlist + allowed_containers: + - traefik + +# Exemple de profil large qui exclut tout de même une cible critique. +operator: + ping: true + version: true + containers: true + events: true + post: true + allow_start: true + allow_stop: true + allow_restart: true + container_scope: blacklist + blocked_containers: + - docker-socket-proxy diff --git a/go.mod b/go.mod index 68e482a..c9a5300 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/cerede2000/docker-socket-proxy go 1.26 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/src/main.go b/src/main.go index 2404e7a..27da9eb 100644 --- a/src/main.go +++ b/src/main.go @@ -1,7 +1,7 @@ package main import ( - "bufio" + "bytes" "context" "encoding/json" "fmt" @@ -18,6 +18,8 @@ import ( "sync" "syscall" "time" + + "gopkg.in/yaml.v3" ) // Variables renseignées par -ldflags au build @@ -62,6 +64,13 @@ type ServiceConfig struct { AllowRestart bool APIRewrite string // Version d'API à forcer (ex: "1.51") + + // ContainerScope contrôle les cibles Docker accessibles par ce profil. + // "all" conserve le comportement historique, "allowlist" refuse tout nom + // absent de AllowedContainers et "blacklist" refuse ceux de BlockedContainers. + ContainerScope string + AllowedContainers map[string]struct{} + BlockedContainers map[string]struct{} } type ProxyConfig struct { @@ -80,6 +89,10 @@ type ProxyConfig struct { selfNetworks map[string]struct{} // réseaux du conteneur socket-proxy (immuable après init) selfNetworksHash string // hash des réseaux pour cache DNS + + containerMu sync.RWMutex + containersByRef map[string]dockerContainerMeta + execToContainer map[string]string } // Getters thread-safe @@ -114,6 +127,45 @@ func (c *ProxyConfig) SetServices(m map[string]*ServiceConfig) { c.services = m } +func (c *ProxyConfig) SetContainerIndex(m map[string]dockerContainerMeta) { + c.containerMu.Lock() + defer c.containerMu.Unlock() + c.containersByRef = m +} + +func (c *ProxyConfig) GetContainer(ref string) (dockerContainerMeta, bool) { + c.containerMu.RLock() + defer c.containerMu.RUnlock() + v, ok := c.containersByRef[normalizeContainerRef(ref)] + return v, ok +} + +func (c *ProxyConfig) UpsertContainer(meta dockerContainerMeta) { + c.containerMu.Lock() + defer c.containerMu.Unlock() + next := make(map[string]dockerContainerMeta, len(c.containersByRef)+3) + for k, v := range c.containersByRef { + next[k] = v + } + for _, ref := range meta.refs() { + next[ref] = meta + } + c.containersByRef = next +} + +func (c *ProxyConfig) SetExecContainer(execID, containerID string) { + c.containerMu.Lock() + defer c.containerMu.Unlock() + c.execToContainer[execID] = containerID +} + +func (c *ProxyConfig) GetExecContainer(execID string) (string, bool) { + c.containerMu.RLock() + defer c.containerMu.RUnlock() + containerID, ok := c.execToContainer[execID] + return containerID, ok +} + // ----------------------------- // Structures pour l'API Docker // ----------------------------- @@ -130,6 +182,7 @@ type dockerContainerSummary struct { ID string `json:"Id"` Names []string `json:"Names"` Labels map[string]string `json:"Labels"` + State string `json:"State"` NetworkSettings dockerContainerNetworkBlock `json:"NetworkSettings"` } @@ -137,6 +190,31 @@ type dockerContainerInspect struct { ID string `json:"Id"` Name string `json:"Name"` NetworkSettings dockerContainerNetworkBlock `json:"NetworkSettings"` + Config struct { + Labels map[string]string `json:"Labels"` + } `json:"Config"` +} + +type dockerContainerMeta struct { + ID string + Name string + Labels map[string]string +} + +func normalizeContainerRef(ref string) string { + return strings.TrimPrefix(strings.TrimSpace(ref), "/") +} + +func (m dockerContainerMeta) refs() []string { + refs := []string{normalizeContainerRef(m.ID), normalizeContainerRef(m.Name)} + if len(m.ID) >= 12 { + refs = append(refs, m.ID[:12]) + } + return refs +} + +type dockerExecInspect struct { + ContainerID string `json:"ContainerID"` } // Événement Docker pour /events @@ -177,7 +255,12 @@ func ensureService(m map[string]*ServiceConfig, role string) *ServiceConfig { if s, ok := m[role]; ok { return s } - s := &ServiceConfig{Name: role} + s := &ServiceConfig{ + Name: role, + ContainerScope: "all", + AllowedContainers: make(map[string]struct{}), + BlockedContainers: make(map[string]struct{}), + } m[role] = s return s } @@ -257,6 +340,26 @@ func applyFlagValue(s *ServiceConfig, flag, value string) { case "apirewrite": // Pour apirewrite, on prend la valeur brute (ex: "1.51") s.APIRewrite = strings.TrimSpace(value) + case "container_scope": + s.ContainerScope = strings.ToLower(strings.TrimSpace(value)) + case "allowed_containers", "allowed_container": + for _, name := range strings.Split(value, ",") { + if name = normalizeContainerRef(name); name != "" { + if s.AllowedContainers == nil { + s.AllowedContainers = make(map[string]struct{}) + } + s.AllowedContainers[name] = struct{}{} + } + } + case "blocked_containers", "blocked_container": + for _, name := range strings.Split(value, ",") { + if name = normalizeContainerRef(name); name != "" { + if s.BlockedContainers == nil { + s.BlockedContainers = make(map[string]struct{}) + } + s.BlockedContainers[name] = struct{}{} + } + } } } @@ -264,11 +367,71 @@ func cloneServices(in map[string]*ServiceConfig) map[string]*ServiceConfig { out := make(map[string]*ServiceConfig, len(in)) for k, v := range in { c := *v + c.AllowedContainers = cloneStringSet(v.AllowedContainers) + c.BlockedContainers = cloneStringSet(v.BlockedContainers) out[k] = &c } return out } +func cloneStringSet(in map[string]struct{}) map[string]struct{} { + out := make(map[string]struct{}, len(in)) + for key := range in { + out[key] = struct{}{} + } + return out +} + +func (s *ServiceConfig) HasContainerScope() bool { + return strings.ToLower(s.ContainerScope) != "" && strings.ToLower(s.ContainerScope) != "all" +} + +func (s *ServiceConfig) AllowsContainer(meta dockerContainerMeta) bool { + name := normalizeContainerRef(meta.Name) + if name == "" { + return false + } + if _, blocked := s.BlockedContainers[name]; blocked { + return false + } + switch strings.ToLower(s.ContainerScope) { + case "", "all": + return true + case "allowlist": + _, allowed := s.AllowedContainers[name] + return allowed + case "blacklist": + return true + default: + return false + } +} + +func validateContainerScope(s *ServiceConfig) error { + mode := strings.ToLower(strings.TrimSpace(s.ContainerScope)) + if mode == "" { + mode = "all" + s.ContainerScope = mode + } + switch mode { + case "all": + if len(s.AllowedContainers) > 0 || len(s.BlockedContainers) > 0 { + return fmt.Errorf("container_scope=all cannot define allowed_containers or blocked_containers") + } + case "allowlist": + if len(s.BlockedContainers) > 0 { + return fmt.Errorf("container_scope=allowlist cannot define blocked_containers") + } + case "blacklist": + if len(s.AllowedContainers) > 0 { + return fmt.Errorf("container_scope=blacklist cannot define allowed_containers") + } + default: + return fmt.Errorf("invalid container_scope=%q (expected all, allowlist or blacklist)", s.ContainerScope) + } + return nil +} + // ----------------------------- // Parsing configuration // ----------------------------- @@ -345,6 +508,8 @@ func parseConfig(args []string, logger *log.Logger) *ProxyConfig { services: make(map[string]*ServiceConfig), ipToRole: make(map[string]string), selfNetworks: make(map[string]struct{}), + containersByRef: make(map[string]dockerContainerMeta), + execToContainer: make(map[string]string), } for _, arg := range args { @@ -412,6 +577,14 @@ func parseConfig(args []string, logger *log.Logger) *ProxyConfig { } cfg.services = cloneServices(cfg.baseServices) + for name, svc := range cfg.services { + if err := validateContainerScope(svc); err != nil { + logger.Printf("[config] profile=%s invalid container scope: %v; denying all container targets", name, err) + svc.ContainerScope = "allowlist" + svc.AllowedContainers = make(map[string]struct{}) + svc.BlockedContainers = make(map[string]struct{}) + } + } logger.Printf("[config] listen=%s socket=%s discover=%s debounce=%s profilesFile=%s", cfg.Listen, cfg.SocketPath, cfg.DiscoverInterval, cfg.DebounceDelay, cfg.ProfilesFile) @@ -430,70 +603,61 @@ func parseConfig(args []string, logger *log.Logger) *ProxyConfig { } // ----------------------------- -// Parser YAML très simple pour profils +// Parser YAML des profils // ----------------------------- -// On retourne map[profil][clé]valeur (valeur brute string) -func parseProfilesYAML(content string) (map[string]map[string]string, error) { - profiles := make(map[string]map[string]string) - scanner := bufio.NewScanner(strings.NewReader(content)) - var current string +var knownProfileKeys = map[string]struct{}{ + "ping": {}, "version": {}, "info": {}, "events": {}, "event": {}, "auth": {}, + "build": {}, "commit": {}, "configs": {}, "containers": {}, "distribution": {}, + "exec": {}, "images": {}, "networks": {}, "nodes": {}, "plugins": {}, "secrets": {}, + "services": {}, "session": {}, "swarm": {}, "system": {}, "tasks": {}, "volumes": {}, + "post": {}, "allow_start": {}, "allow_stop": {}, "allow_restart": {}, "allow_restarts": {}, + "apirewrite": {}, "container_scope": {}, "allowed_containers": {}, "blocked_containers": {}, +} - for scanner.Scan() { - line := scanner.Text() +func parseProfilesYAML(content string) (map[string]*ServiceConfig, error) { + var raw map[string]map[string]any + if err := yaml.Unmarshal([]byte(content), &raw); err != nil { + return nil, err + } - if idx := strings.Index(line, "#"); idx >= 0 { - line = line[:idx] - } - if strings.TrimSpace(line) == "" { - continue + profiles := make(map[string]*ServiceConfig, len(raw)) + for rawName, values := range raw { + role := normalizeRoleName(rawName) + if role == "" { + return nil, fmt.Errorf("empty profile name") } - - // Nouvelle section : "home:" - if !strings.HasPrefix(line, " ") && strings.HasSuffix(strings.TrimSpace(line), ":") { - name := strings.TrimSpace(line) - name = strings.TrimSuffix(name, ":") - if name == "" { - continue + svc := ensureService(profiles, role) + for key, value := range values { + if _, known := knownProfileKeys[key]; !known { + return nil, fmt.Errorf("profile %q: unknown key %q", role, key) } - current = name - if _, ok := profiles[current]; !ok { - profiles[current] = make(map[string]string) + switch key { + case "allowed_containers", "blocked_containers": + items, ok := value.([]any) + if !ok { + return nil, fmt.Errorf("profile %q: %s must be a YAML list", role, key) + } + for _, item := range items { + name, ok := item.(string) + if !ok || normalizeContainerRef(name) == "" { + return nil, fmt.Errorf("profile %q: %s must contain non-empty names", role, key) + } + applyFlagValue(svc, key, name) + } + default: + switch typed := value.(type) { + case bool, string, int, int64, float64: + applyFlagValue(svc, key, fmt.Sprint(typed)) + default: + return nil, fmt.Errorf("profile %q: %s must be a scalar", role, key) + } } - continue } - - // Ligne de flag : " ping: true" - if current == "" { - continue - } - if !strings.HasPrefix(line, " ") { - continue - } - inner := strings.TrimSpace(line) - parts := strings.SplitN(inner, ":", 2) - if len(parts) != 2 { - continue - } - key := strings.TrimSpace(parts[0]) - valStr := strings.TrimSpace(parts[1]) - if key == "" || valStr == "" { - continue - } - - // On enlève des guillemets éventuels - if len(valStr) >= 2 && ((valStr[0] == '"' && valStr[len(valStr)-1] == '"') || - (valStr[0] == '\'' && valStr[len(valStr)-1] == '\'')) { - valStr = valStr[1 : len(valStr)-1] + if err := validateContainerScope(svc); err != nil { + return nil, fmt.Errorf("profile %q: %w", role, err) } - - profiles[current][key] = valStr - } - - if err := scanner.Err(); err != nil { - return nil, err } - return profiles, nil } @@ -518,22 +682,17 @@ func loadProfilesFromFile(cfg *ProxyConfig, logger *log.Logger) error { newServices := cloneServices(cfg.baseServices) - for rawName, flags := range m { - role := normalizeRoleName(rawName) - s := &ServiceConfig{Name: role} - for k, v := range flags { - applyFlagValue(s, k, v) - } - newServices[role] = s + for role, svc := range m { + newServices[role] = svc } cfg.SetServices(newServices) logger.Printf("[profiles] loaded %d profiles from %s", len(newServices), cfg.ProfilesFile) for name, svc := range newServices { - logger.Printf("[profiles] profil=%s ping=%v version=%v info=%v events=%v containers=%v exec=%v post=%v start=%v stop=%v restart=%v apirewrite=%q", + logger.Printf("[profiles] profil=%s ping=%v version=%v info=%v events=%v containers=%v exec=%v post=%v start=%v stop=%v restart=%v scope=%s allowed=%d blocked=%d apirewrite=%q", name, svc.Ping, svc.Version, svc.Info, svc.Events, svc.Containers, - svc.Exec, svc.Post, svc.AllowStart, svc.AllowStop, svc.AllowRestart, svc.APIRewrite) + svc.Exec, svc.Post, svc.AllowStart, svc.AllowStop, svc.AllowRestart, svc.ContainerScope, len(svc.AllowedContainers), len(svc.BlockedContainers), svc.APIRewrite) } return nil @@ -699,8 +858,30 @@ func getSelfNetworksWithCache(ctx context.Context, cfg *ProxyConfig, client *htt return nil } +func indexContainerSummary(c dockerContainerSummary) dockerContainerMeta { + name := "" + if len(c.Names) > 0 { + name = normalizeContainerRef(c.Names[0]) + } + return dockerContainerMeta{ID: c.ID, Name: name, Labels: c.Labels} +} + +func buildContainerIndex(containers []dockerContainerSummary) map[string]dockerContainerMeta { + index := make(map[string]dockerContainerMeta, len(containers)*3) + for _, container := range containers { + meta := indexContainerSummary(container) + if meta.ID == "" || meta.Name == "" { + continue + } + for _, ref := range meta.refs() { + index[ref] = meta + } + } + return index +} + func discoverOnce(ctx context.Context, cfg *ProxyConfig, client *http.Client, logger *log.Logger) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://unix/containers/json?all=0", nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://unix/containers/json?all=1", nil) if err != nil { return err } @@ -720,8 +901,12 @@ func discoverOnce(ctx context.Context, cfg *ProxyConfig, client *http.Client, lo } newMap := make(map[string]string) + newIndex := buildContainerIndex(containers) for _, c := range containers { + if c.State != "running" { + continue + } if len(c.Names) == 0 { continue } @@ -763,6 +948,7 @@ func discoverOnce(ctx context.Context, cfg *ProxyConfig, client *http.Client, lo } cfg.SetIPToRole(newMap) + cfg.SetContainerIndex(newIndex) logger.Printf("[discover] ip→role map size=%d", cfg.GetIPToRoleSize()) return nil @@ -1283,6 +1469,349 @@ func (s *ServiceConfig) Allow(feature, method, action string) bool { return true } +// ----------------------------- +// Portée des conteneurs +// ----------------------------- + +type responseFilterKind string + +const ( + filterContainerList responseFilterKind = "container-list" + filterEvents responseFilterKind = "events" +) + +type responseFilterContext struct { + service *ServiceConfig + kind responseFilterKind +} + +type responseFilterContextKey struct{} + +func (c *ProxyConfig) containerMetas() []dockerContainerMeta { + c.containerMu.RLock() + defer c.containerMu.RUnlock() + seen := make(map[string]struct{}) + metas := make([]dockerContainerMeta, 0, len(c.containersByRef)/2) + for _, meta := range c.containersByRef { + if _, ok := seen[meta.ID]; ok { + continue + } + seen[meta.ID] = struct{}{} + metas = append(metas, meta) + } + return metas +} + +func (c *ProxyConfig) allowedContainerIDs(service *ServiceConfig) []string { + ids := make([]string, 0) + for _, meta := range c.containerMetas() { + if service.AllowsContainer(meta) { + ids = append(ids, meta.ID) + } + } + return ids +} + +func resolveContainer(ctx context.Context, cfg *ProxyConfig, client *http.Client, ref string) (dockerContainerMeta, error) { + if meta, ok := cfg.GetContainer(ref); ok { + return meta, nil + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://unix/containers/"+url.PathEscape(normalizeContainerRef(ref))+"/json", nil) + if err != nil { + return dockerContainerMeta{}, err + } + resp, err := client.Do(req) + if err != nil { + return dockerContainerMeta{}, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return dockerContainerMeta{}, fmt.Errorf("docker inspect container %q: status %d", ref, resp.StatusCode) + } + var inspect dockerContainerInspect + if err := json.NewDecoder(resp.Body).Decode(&inspect); err != nil { + return dockerContainerMeta{}, err + } + meta := dockerContainerMeta{ID: inspect.ID, Name: normalizeContainerRef(inspect.Name), Labels: inspect.Config.Labels} + if meta.ID == "" || meta.Name == "" { + return dockerContainerMeta{}, fmt.Errorf("docker inspect container %q returned incomplete metadata", ref) + } + cfg.UpsertContainer(meta) + return meta, nil +} + +func resolveExecContainer(ctx context.Context, cfg *ProxyConfig, client *http.Client, execID string) (dockerContainerMeta, error) { + if containerID, ok := cfg.GetExecContainer(execID); ok { + return resolveContainer(ctx, cfg, client, containerID) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://unix/exec/"+url.PathEscape(execID)+"/json", nil) + if err != nil { + return dockerContainerMeta{}, err + } + resp, err := client.Do(req) + if err != nil { + return dockerContainerMeta{}, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return dockerContainerMeta{}, fmt.Errorf("docker inspect exec %q: status %d", execID, resp.StatusCode) + } + var inspect dockerExecInspect + if err := json.NewDecoder(resp.Body).Decode(&inspect); err != nil { + return dockerContainerMeta{}, err + } + if inspect.ContainerID == "" { + return dockerContainerMeta{}, fmt.Errorf("docker inspect exec %q returned no container ID", execID) + } + cfg.SetExecContainer(execID, inspect.ContainerID) + return resolveContainer(ctx, cfg, client, inspect.ContainerID) +} + +func pathWithoutAPIVersion(path string) string { + p := trimAPIVersion(path) + if strings.HasPrefix(p, "/engine/api/") { + return strings.TrimPrefix(p, "/engine/api") + } + return p +} + +func directContainerReference(path string) (string, bool) { + parts := strings.Split(strings.Trim(pathWithoutAPIVersion(path), "/"), "/") + if len(parts) < 2 || parts[0] != "containers" { + return "", false + } + switch parts[1] { + case "json", "create", "prune": + return "", false + default: + return parts[1], true + } +} + +func rewriteContainerReference(path, containerID string) string { + parts := strings.Split(path, "/") + for i := 0; i+1 < len(parts); i++ { + if parts[i] == "containers" { + parts[i+1] = containerID + return strings.Join(parts, "/") + } + } + return path +} + +func execReference(path string) (string, bool) { + parts := strings.Split(strings.Trim(pathWithoutAPIVersion(path), "/"), "/") + if len(parts) < 2 || parts[0] != "exec" || parts[1] == "" { + return "", false + } + return parts[1], true +} + +func isContainerList(path string) bool { + return pathWithoutAPIVersion(path) == "/containers/json" +} + +func isContainerGlobalOperation(path string) bool { + p := pathWithoutAPIVersion(path) + return p == "/containers/create" || p == "/containers/prune" +} + +func networkBodyContainerReference(r *http.Request) (string, error) { + const maxBodySize = 1 << 20 + body, err := io.ReadAll(io.LimitReader(r.Body, maxBodySize+1)) + if err != nil { + return "", err + } + r.Body = io.NopCloser(bytes.NewReader(body)) + if len(body) > maxBodySize { + return "", fmt.Errorf("network request body exceeds %d bytes", maxBodySize) + } + var payload struct { + Container string `json:"Container"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return "", err + } + if payload.Container == "" { + return "", fmt.Errorf("network request has no Container field") + } + return payload.Container, nil +} + +func isNetworkContainerOperation(path string) bool { + p := pathWithoutAPIVersion(path) + return strings.HasSuffix(p, "/connect") || strings.HasSuffix(p, "/disconnect") +} + +func authorizeContainer(ctx context.Context, cfg *ProxyConfig, client *http.Client, service *ServiceConfig, ref string) (dockerContainerMeta, error) { + meta, err := resolveContainer(ctx, cfg, client, ref) + if err != nil { + return dockerContainerMeta{}, err + } + if !service.AllowsContainer(meta) { + return dockerContainerMeta{}, fmt.Errorf("container %q is outside profile scope", meta.Name) + } + return meta, nil +} + +func enforceContainerScope(ctx context.Context, cfg *ProxyConfig, client *http.Client, service *ServiceConfig, feature string, r *http.Request) (*responseFilterContext, error) { + if !service.HasContainerScope() { + return nil, nil + } + + switch feature { + case "containers": + if ref, ok := directContainerReference(r.URL.Path); ok { + meta, err := authorizeContainer(ctx, cfg, client, service, ref) + if err != nil { + return nil, err + } + r.URL.Path = rewriteContainerReference(r.URL.Path, meta.ID) + return nil, nil + } + if isContainerList(r.URL.Path) && (r.Method == http.MethodGet || r.Method == http.MethodHead) { + return &responseFilterContext{service: service, kind: filterContainerList}, nil + } + if isContainerGlobalOperation(r.URL.Path) { + return nil, fmt.Errorf("global container operation is denied for scoped profiles") + } + return nil, fmt.Errorf("container operation has no enforceable target") + case "exec": + ref, ok := execReference(r.URL.Path) + if !ok { + return nil, fmt.Errorf("exec operation has no enforceable target") + } + meta, err := resolveExecContainer(ctx, cfg, client, ref) + if err != nil { + return nil, err + } + if !service.AllowsContainer(meta) { + return nil, fmt.Errorf("container %q is outside profile scope", meta.Name) + } + case "events": + return &responseFilterContext{service: service, kind: filterEvents}, nil + case "networks": + if r.Method == http.MethodPost && isNetworkContainerOperation(r.URL.Path) { + ref, err := networkBodyContainerReference(r) + if err != nil { + return nil, err + } + if _, err := authorizeContainer(ctx, cfg, client, service, ref); err != nil { + return nil, err + } + } + case "commit": + if ref := r.URL.Query().Get("container"); ref != "" { + if _, err := authorizeContainer(ctx, cfg, client, service, ref); err != nil { + return nil, err + } + } + } + return nil, nil +} + +func filterContainerListResponse(resp *http.Response, cfg *ProxyConfig, service *ServiceConfig) { + originalBody := resp.Body + reader, writer := io.Pipe() + resp.Body = reader + resp.ContentLength = -1 + resp.Header.Del("Content-Length") + + go func() { + defer originalBody.Close() + defer writer.Close() + decoder := json.NewDecoder(originalBody) + if token, err := decoder.Token(); err != nil || token != json.Delim('[') { + _ = writer.CloseWithError(fmt.Errorf("decode Docker container list: %w", err)) + return + } + if _, err := writer.Write([]byte("[")); err != nil { + return + } + first := true + for decoder.More() { + var container dockerContainerSummary + if err := decoder.Decode(&container); err != nil { + _ = writer.CloseWithError(fmt.Errorf("decode Docker container entry: %w", err)) + return + } + meta := indexContainerSummary(container) + if !service.AllowsContainer(meta) { + continue + } + encoded, err := json.Marshal(container) + if err != nil { + _ = writer.CloseWithError(err) + return + } + if !first { + if _, err := writer.Write([]byte(",")); err != nil { + return + } + } + first = false + if _, err := writer.Write(encoded); err != nil { + return + } + } + if _, err := decoder.Token(); err != nil { + _ = writer.CloseWithError(fmt.Errorf("close Docker container list: %w", err)) + return + } + _, _ = writer.Write([]byte("]")) + }() +} + +func filterEventsResponse(resp *http.Response, cfg *ProxyConfig, service *ServiceConfig) { + originalBody := resp.Body + reader, writer := io.Pipe() + resp.Body = reader + resp.ContentLength = -1 + resp.Header.Del("Content-Length") + + go func() { + defer originalBody.Close() + defer writer.Close() + decoder := json.NewDecoder(originalBody) + encoder := json.NewEncoder(writer) + for { + var event dockerEvent + if err := decoder.Decode(&event); err != nil { + if err != io.EOF { + _ = writer.CloseWithError(fmt.Errorf("decode Docker event: %w", err)) + } + return + } + if event.Type != "container" { + continue + } + meta, ok := cfg.GetContainer(event.Actor.ID) + if !ok || !service.AllowsContainer(meta) { + continue + } + if err := encoder.Encode(event); err != nil { + return + } + } + }() +} + +func scopeResponseFilter(cfg *ProxyConfig) func(*http.Response) error { + return func(resp *http.Response) error { + filter, _ := resp.Request.Context().Value(responseFilterContextKey{}).(*responseFilterContext) + if filter == nil || resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil + } + switch filter.kind { + case filterContainerList: + filterContainerListResponse(resp, cfg, filter.service) + case filterEvents: + filterEventsResponse(resp, cfg, filter.service) + } + return nil + } +} + // ----------------------------- // Handler HTTP / proxy // ----------------------------- @@ -1342,7 +1871,7 @@ func rewriteAPIVersion(path, targetVersion string) string { return "/v" + targetVersion + path[idx+2:] } -func proxyHandler(cfg *ProxyConfig, proxy *httputil.ReverseProxy, logger *log.Logger) http.Handler { +func proxyHandler(cfg *ProxyConfig, resolverClient *http.Client, proxy *httputil.ReverseProxy, logger *log.Logger) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { @@ -1380,6 +1909,17 @@ func proxyHandler(cfg *ProxyConfig, proxy *httputil.ReverseProxy, logger *log.Lo return } + responseFilter, err := enforceContainerScope(r.Context(), cfg, resolverClient, svc, feature, r) + if err != nil { + logger.Printf("[deny] ip=%s role=%s feature=%s method=%s path=%s scope=%s reason=%v", + host, role, feature, method, path, svc.ContainerScope, err) + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + if responseFilter != nil { + r = r.WithContext(context.WithValue(r.Context(), responseFilterContextKey{}, responseFilter)) + } + // Réécriture de la version d'API si configurée originalPath := r.URL.Path if svc.APIRewrite != "" { @@ -1510,8 +2050,9 @@ func main() { // Utiliser le client SANS timeout pour le proxy (support /events) proxy.Transport = proxyClient.Transport proxy.ErrorLog = logger + proxy.ModifyResponse = scopeResponseFilter(cfg) - handler := proxyHandler(cfg, proxy, logger) + handler := proxyHandler(cfg, discoveryClient, proxy, logger) srv := &http.Server{ Addr: cfg.Listen, diff --git a/src/main_test.go b/src/main_test.go index 4294614..fce52c2 100644 --- a/src/main_test.go +++ b/src/main_test.go @@ -1,9 +1,11 @@ package main import ( + "context" "io" "log" "net/http" + "strings" "testing" ) @@ -111,11 +113,138 @@ func TestRewriteAPIVersion(t *testing.T) { } func TestParseProfilesYAML(t *testing.T) { - profiles, err := parseProfilesYAML("home:\n ping: true\n containers: false\n") + profiles, err := parseProfilesYAML("home:\n ping: true\n containers: false\n container_scope: allowlist\n allowed_containers:\n - traefik\n") if err != nil { t.Fatal(err) } - if profiles["home"]["ping"] != "true" || profiles["home"]["containers"] != "false" { - t.Fatalf("unexpected profiles: %#v", profiles) + home := profiles["home"] + if !home.Ping || home.Containers || home.ContainerScope != "allowlist" { + t.Fatalf("unexpected profile: %#v", home) + } + if _, ok := home.AllowedContainers["traefik"]; !ok { + t.Fatalf("traefik missing from allowlist: %#v", home.AllowedContainers) + } +} + +func TestParseProfilesYAMLRejectsInvalidScope(t *testing.T) { + _, err := parseProfilesYAML("manager:\n container_scope: blacklist\n allowed_containers:\n - traefik\n") + if err == nil { + t.Fatal("invalid blacklist profile was accepted") + } +} + +func TestParseProfilesYAMLRejectsUnknownKey(t *testing.T) { + _, err := parseProfilesYAML("manager:\n containers: true\n allowd_containers: []\n") + if err == nil { + t.Fatal("unknown profile key was accepted") + } +} + +func TestContainerScopes(t *testing.T) { + traefik := dockerContainerMeta{ID: "a", Name: "traefik"} + proxy := dockerContainerMeta{ID: "b", Name: "docker-socket-proxy"} + + allowlist := &ServiceConfig{ + ContainerScope: "allowlist", + AllowedContainers: map[string]struct{}{"traefik": {}}, + BlockedContainers: map[string]struct{}{}, + } + if !allowlist.AllowsContainer(traefik) || allowlist.AllowsContainer(proxy) { + t.Fatal("allowlist did not limit the target set") + } + + blacklist := &ServiceConfig{ + ContainerScope: "blacklist", + AllowedContainers: map[string]struct{}{}, + BlockedContainers: map[string]struct{}{"docker-socket-proxy": {}}, + } + if !blacklist.AllowsContainer(traefik) || blacklist.AllowsContainer(proxy) { + t.Fatal("blacklist did not exclude the protected container") + } +} + +func TestBuildContainerIndex(t *testing.T) { + index := buildContainerIndex([]dockerContainerSummary{{ + ID: "0123456789abcdef", + Names: []string{"/traefik"}, + }}) + for _, ref := range []string{"traefik", "0123456789abcdef", "0123456789ab"} { + if got, ok := index[ref]; !ok || got.Name != "traefik" { + t.Fatalf("index[%q] = %#v, %v", ref, got, ok) + } + } +} + +func TestEnforceContainerScopeUsesCachedCanonicalID(t *testing.T) { + meta := dockerContainerMeta{ID: "0123456789abcdef", Name: "traefik"} + cfg := &ProxyConfig{ + containersByRef: buildContainerIndex([]dockerContainerSummary{{ + ID: meta.ID, + Names: []string{"/traefik"}, + }}), + execToContainer: make(map[string]string), + } + service := &ServiceConfig{ + ContainerScope: "allowlist", + AllowedContainers: map[string]struct{}{"traefik": {}}, + BlockedContainers: map[string]struct{}{}, + } + req, err := http.NewRequest(http.MethodPost, "http://proxy/v1.51/containers/traefik/restart", nil) + if err != nil { + t.Fatal(err) + } + if _, err := enforceContainerScope(context.Background(), cfg, nil, service, "containers", req); err != nil { + t.Fatalf("allowed target was denied: %v", err) + } + if req.URL.Path != "/v1.51/containers/0123456789abcdef/restart" { + t.Fatalf("path = %q, target was not rewritten to canonical ID", req.URL.Path) + } +} + +func TestEnforceContainerScopeRejectsBlacklistedAndGlobalOperations(t *testing.T) { + cfg := &ProxyConfig{ + containersByRef: buildContainerIndex([]dockerContainerSummary{{ + ID: "0123456789abcdef", + Names: []string{"/docker-socket-proxy"}, + }}), + execToContainer: make(map[string]string), + } + service := &ServiceConfig{ + ContainerScope: "blacklist", + AllowedContainers: map[string]struct{}{}, + BlockedContainers: map[string]struct{}{"docker-socket-proxy": {}}, + } + for _, path := range []string{"/containers/docker-socket-proxy/stop", "/containers/prune"} { + req, err := http.NewRequest(http.MethodPost, "http://proxy"+path, nil) + if err != nil { + t.Fatal(err) + } + if _, err := enforceContainerScope(context.Background(), cfg, nil, service, "containers", req); err == nil { + t.Fatalf("scoped request %s was unexpectedly allowed", path) + } + } +} + +func TestFilterContainerListResponse(t *testing.T) { + service := &ServiceConfig{ + ContainerScope: "blacklist", + AllowedContainers: map[string]struct{}{}, + BlockedContainers: map[string]struct{}{"docker-socket-proxy": {}}, + } + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`[ + {"Id":"a","Names":["/traefik"]}, + {"Id":"b","Names":["/docker-socket-proxy"]} +]`)), + } + filterContainerListResponse(resp, nil, service) + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(body), "docker-socket-proxy") || !strings.Contains(string(body), "traefik") { + t.Fatalf("unexpected filtered list: %s", body) } } From 1e1cdc99482c11c0fc35148d046fc1b903b74977 Mon Sep 17 00:00:00 2001 From: Benjy Date: Fri, 17 Jul 2026 09:28:27 +0200 Subject: [PATCH 3/7] copy module checksum into build --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 7eaf148..80491ec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ ENV CGO_ENABLED=0 WORKDIR /src -COPY go.mod ./ +COPY go.mod go.sum ./ COPY src/ ./src RUN go build -trimpath \ From 72e5103a09b3d46d2f6c2bcba9f90511691dcb4d Mon Sep 17 00:00:00 2001 From: Benjy Date: Fri, 17 Jul 2026 09:55:08 +0200 Subject: [PATCH 4/7] add per-container read-only rules --- README.md | 74 +++++++++++++++++++- config/profiles.yml | 48 +++++++++++++ src/main.go | 167 ++++++++++++++++++++++++++++++++++++++------ src/main_test.go | 91 +++++++++++++++++++++++- 4 files changed, 354 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 4d1613e..1745a17 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,29 @@ Les options `--listen`, `--socket`, `--profiles`, `--discover-interval` et `--de Le compte effectif doit pouvoir lire le socket Docker. Le fichier Compose fournit un exemple avec un UID/GID hôte explicite ; adaptez `user` au propriétaire et au groupe du socket de votre machine. -## Portée des conteneurs +## Portée et règles par conteneur -Par défaut, un profil conserve ses droits sur tous les conteneurs. `container_scope` ajoute une restriction optionnelle : +Les noms de conteneurs sont exacts et correspondent au nom Docker sans le préfixe `/` (par exemple `container_name: dockman` devient `dockman`). La portée détermine l'accès normal ; `container_rules` ajoute des exceptions par nom. + +### Tous les conteneurs — comportement historique + +`all` est la valeur par défaut. Le profil conserve les droits Docker qui lui sont accordés sur tous les conteneurs. + +```yaml +portainer: + containers: true + images: true + networks: true + post: true + allow_start: true + allow_stop: true + allow_restart: true + container_scope: all +``` + +### Allowlist — agir seulement sur certaines cibles + +Les conteneurs absents de `allowed_containers` sont invisibles et inaccessibles. ```yaml traefik-manager: @@ -35,19 +55,67 @@ traefik-manager: post: true allow_start: true allow_stop: true + allow_restart: true container_scope: allowlist allowed_containers: - traefik +``` + +### Blacklist — profil large avec cibles masquées +Les conteneurs de `blocked_containers` sont invisibles et toute opération les visant est refusée. + +```yaml +dockhand: + containers: true + events: true + post: true + allow_start: true + allow_stop: true + allow_restart: true + container_scope: blacklist + blocked_containers: + - docker-socket-proxy +``` + +### Règle `deny` — masquer une cible, quelle que soit la portée + +`container_rules` est prioritaire sur `container_scope`. Cette variante est utile avec `all`, ou pour rendre la règle plus explicite. + +```yaml operator: + containers: true + container_scope: all + container_rules: + - name: docker-socket-proxy + access: deny +``` + +### Règle `readonly` — voir sans pouvoir agir + +Une cible en lecture seule reste visible dans les listes et événements. Seules les API de consultation suivantes sont admises : `inspect`, `logs`, `stats`, `top` et `changes`. Les opérations de modification, les exec, les archives et l'attach sont refusés. + +```yaml +dockhand: containers: true events: true + post: true + allow_start: true + allow_stop: true + allow_restart: true container_scope: blacklist blocked_containers: - docker-socket-proxy + container_rules: + - name: dockman + access: readonly ``` -Valeurs possibles : `all` (comportement historique), `allowlist` et `blacklist`. Les noms sont exacts et correspondent au nom Docker sans le préfixe `/`. Les opérations ciblées, les listes de conteneurs et les événements sont filtrés. Les opérations globales de conteneurs (`create` et `prune`) sont refusées pour un profil ayant une portée active. +Dans cet exemple, Dockhand peut consulter les logs et statistiques de `dockman`, mais pas le redémarrer ; `docker-socket-proxy` reste entièrement masqué. Les conteneurs non cités conservent les droits du profil. + +Une même cible ne peut pas figurer à la fois dans `blocked_containers` et `container_rules`. Les valeurs autorisées pour `access` sont exclusivement `deny` et `readonly`. + +Pour toute portée active (`allowlist`, `blacklist`, ou au moins une `container_rules`), les opérations globales de conteneurs (`create` et `prune`) sont refusées. Les règles sont appliquées aussi aux listes de conteneurs et au flux d'événements. ## Développement diff --git a/config/profiles.yml b/config/profiles.yml index cbf56c5..50c215b 100644 --- a/config/profiles.yml +++ b/config/profiles.yml @@ -3,6 +3,18 @@ home: version: true containers: true +# `all` est le comportement historique : ce profil dispose de ses droits sur +# tous les conteneurs. +administrator: + ping: true + version: true + containers: true + post: true + allow_start: true + allow_stop: true + allow_restart: true + container_scope: all + portainer: ping: true version: true @@ -47,3 +59,39 @@ operator: container_scope: blacklist blocked_containers: - docker-socket-proxy + +# Une règle `deny` masque la cible même lorsque le scope est `all`. +auditor: + ping: true + version: true + containers: true + container_scope: all + container_rules: + - name: docker-socket-proxy + access: deny + +# Cas mixte : dockhand conserve ses droits sur les autres conteneurs, peut voir +# dockman (logs, stats, inspect, top et changes) mais ne peut pas le modifier, +# et ne voit pas du tout docker-socket-proxy. +dockhand: + ping: true + version: true + info: true + events: true + containers: true + images: true + build: true + networks: true + volumes: true + exec: true + system: true + post: true + allow_start: true + allow_stop: true + allow_restart: true + container_scope: blacklist + blocked_containers: + - docker-socket-proxy + container_rules: + - name: dockman + access: readonly diff --git a/src/main.go b/src/main.go index 27da9eb..0b82a3e 100644 --- a/src/main.go +++ b/src/main.go @@ -71,8 +71,20 @@ type ServiceConfig struct { ContainerScope string AllowedContainers map[string]struct{} BlockedContainers map[string]struct{} + // ContainerRules ajoute des exceptions nominatives à la portée. Une règle + // "deny" masque totalement la cible, tandis que "readonly" ne permet que + // les API de consultation explicitement autorisées. + ContainerRules map[string]ContainerAccess } +type ContainerAccess string + +const ( + containerAccessFull ContainerAccess = "full" + containerAccessReadOnly ContainerAccess = "readonly" + containerAccessDeny ContainerAccess = "deny" +) + type ProxyConfig struct { Listen string SocketPath string @@ -260,6 +272,7 @@ func ensureService(m map[string]*ServiceConfig, role string) *ServiceConfig { ContainerScope: "all", AllowedContainers: make(map[string]struct{}), BlockedContainers: make(map[string]struct{}), + ContainerRules: make(map[string]ContainerAccess), } m[role] = s return s @@ -360,6 +373,20 @@ func applyFlagValue(s *ServiceConfig, flag, value string) { s.BlockedContainers[name] = struct{}{} } } + case "container_rule": + parts := strings.SplitN(value, ":", 2) + if len(parts) != 2 { + return + } + name := normalizeContainerRef(parts[0]) + access := ContainerAccess(strings.ToLower(strings.TrimSpace(parts[1]))) + if name == "" || (access != containerAccessDeny && access != containerAccessReadOnly) { + return + } + if s.ContainerRules == nil { + s.ContainerRules = make(map[string]ContainerAccess) + } + s.ContainerRules[name] = access } } @@ -369,6 +396,7 @@ func cloneServices(in map[string]*ServiceConfig) map[string]*ServiceConfig { c := *v c.AllowedContainers = cloneStringSet(v.AllowedContainers) c.BlockedContainers = cloneStringSet(v.BlockedContainers) + c.ContainerRules = cloneContainerRules(v.ContainerRules) out[k] = &c } return out @@ -382,31 +410,49 @@ func cloneStringSet(in map[string]struct{}) map[string]struct{} { return out } +func cloneContainerRules(in map[string]ContainerAccess) map[string]ContainerAccess { + out := make(map[string]ContainerAccess, len(in)) + for name, access := range in { + out[name] = access + } + return out +} + func (s *ServiceConfig) HasContainerScope() bool { - return strings.ToLower(s.ContainerScope) != "" && strings.ToLower(s.ContainerScope) != "all" + return (strings.ToLower(s.ContainerScope) != "" && strings.ToLower(s.ContainerScope) != "all") || len(s.ContainerRules) > 0 } -func (s *ServiceConfig) AllowsContainer(meta dockerContainerMeta) bool { +func (s *ServiceConfig) ContainerAccess(meta dockerContainerMeta) ContainerAccess { name := normalizeContainerRef(meta.Name) if name == "" { - return false + return containerAccessDeny + } + if access, ok := s.ContainerRules[name]; ok { + return access } if _, blocked := s.BlockedContainers[name]; blocked { - return false + return containerAccessDeny } switch strings.ToLower(s.ContainerScope) { case "", "all": - return true + return containerAccessFull case "allowlist": _, allowed := s.AllowedContainers[name] - return allowed + if allowed { + return containerAccessFull + } + return containerAccessDeny case "blacklist": - return true + return containerAccessFull default: - return false + return containerAccessDeny } } +func (s *ServiceConfig) AllowsContainer(meta dockerContainerMeta) bool { + return s.ContainerAccess(meta) != containerAccessDeny +} + func validateContainerScope(s *ServiceConfig) error { mode := strings.ToLower(strings.TrimSpace(s.ContainerScope)) if mode == "" { @@ -429,6 +475,17 @@ func validateContainerScope(s *ServiceConfig) error { default: return fmt.Errorf("invalid container_scope=%q (expected all, allowlist or blacklist)", s.ContainerScope) } + for name, access := range s.ContainerRules { + if normalizeContainerRef(name) == "" { + return fmt.Errorf("container_rules contains an empty name") + } + if access != containerAccessDeny && access != containerAccessReadOnly { + return fmt.Errorf("container_rules[%q] has invalid access %q (expected deny or readonly)", name, access) + } + if _, blocked := s.BlockedContainers[name]; blocked { + return fmt.Errorf("container %q cannot be both blocked_containers and container_rules", name) + } + } return nil } @@ -583,6 +640,7 @@ func parseConfig(args []string, logger *log.Logger) *ProxyConfig { svc.ContainerScope = "allowlist" svc.AllowedContainers = make(map[string]struct{}) svc.BlockedContainers = make(map[string]struct{}) + svc.ContainerRules = make(map[string]ContainerAccess) } } @@ -593,9 +651,9 @@ func parseConfig(args []string, logger *log.Logger) *ProxyConfig { logger.Printf("[config] WARNING: aucun profil défini (pas de --home / --portainer / etc.)") } else { for name, svc := range cfg.services { - logger.Printf("[config] profil=%s rights: ping=%v version=%v info=%v containers=%v images=%v networks=%v exec=%v post=%v start=%v stop=%v restart=%v apirewrite=%q", + logger.Printf("[config] profil=%s rights: ping=%v version=%v info=%v containers=%v images=%v networks=%v exec=%v post=%v start=%v stop=%v restart=%v scope=%s rules=%d apirewrite=%q", name, svc.Ping, svc.Version, svc.Info, svc.Containers, svc.Images, svc.Networks, - svc.Exec, svc.Post, svc.AllowStart, svc.AllowStop, svc.AllowRestart, svc.APIRewrite) + svc.Exec, svc.Post, svc.AllowStart, svc.AllowStop, svc.AllowRestart, svc.ContainerScope, len(svc.ContainerRules), svc.APIRewrite) } } @@ -612,7 +670,7 @@ var knownProfileKeys = map[string]struct{}{ "exec": {}, "images": {}, "networks": {}, "nodes": {}, "plugins": {}, "secrets": {}, "services": {}, "session": {}, "swarm": {}, "system": {}, "tasks": {}, "volumes": {}, "post": {}, "allow_start": {}, "allow_stop": {}, "allow_restart": {}, "allow_restarts": {}, - "apirewrite": {}, "container_scope": {}, "allowed_containers": {}, "blocked_containers": {}, + "apirewrite": {}, "container_scope": {}, "allowed_containers": {}, "blocked_containers": {}, "container_rules": {}, } func parseProfilesYAML(content string) (map[string]*ServiceConfig, error) { @@ -645,6 +703,33 @@ func parseProfilesYAML(content string) (map[string]*ServiceConfig, error) { } applyFlagValue(svc, key, name) } + case "container_rules": + items, ok := value.([]any) + if !ok { + return nil, fmt.Errorf("profile %q: container_rules must be a YAML list", role) + } + for _, item := range items { + rule, ok := item.(map[string]any) + if !ok || len(rule) != 2 { + return nil, fmt.Errorf("profile %q: each container_rules entry must contain name and access", role) + } + nameValue, hasName := rule["name"] + accessValue, hasAccess := rule["access"] + name, nameOK := nameValue.(string) + access, accessOK := accessValue.(string) + if !hasName || !hasAccess || !nameOK || !accessOK || normalizeContainerRef(name) == "" { + return nil, fmt.Errorf("profile %q: each container_rules entry must contain string name and access", role) + } + normalizedName := normalizeContainerRef(name) + normalizedAccess := ContainerAccess(strings.ToLower(strings.TrimSpace(access))) + if normalizedAccess != containerAccessDeny && normalizedAccess != containerAccessReadOnly { + return nil, fmt.Errorf("profile %q: invalid container rule for %q (access must be deny or readonly)", role, name) + } + if _, exists := svc.ContainerRules[normalizedName]; exists { + return nil, fmt.Errorf("profile %q: duplicate container rule for %q", role, name) + } + svc.ContainerRules[normalizedName] = normalizedAccess + } default: switch typed := value.(type) { case bool, string, int, int64, float64: @@ -690,9 +775,9 @@ func loadProfilesFromFile(cfg *ProxyConfig, logger *log.Logger) error { logger.Printf("[profiles] loaded %d profiles from %s", len(newServices), cfg.ProfilesFile) for name, svc := range newServices { - logger.Printf("[profiles] profil=%s ping=%v version=%v info=%v events=%v containers=%v exec=%v post=%v start=%v stop=%v restart=%v scope=%s allowed=%d blocked=%d apirewrite=%q", + logger.Printf("[profiles] profil=%s ping=%v version=%v info=%v events=%v containers=%v exec=%v post=%v start=%v stop=%v restart=%v scope=%s allowed=%d blocked=%d rules=%d apirewrite=%q", name, svc.Ping, svc.Version, svc.Info, svc.Events, svc.Containers, - svc.Exec, svc.Post, svc.AllowStart, svc.AllowStop, svc.AllowRestart, svc.ContainerScope, len(svc.AllowedContainers), len(svc.BlockedContainers), svc.APIRewrite) + svc.Exec, svc.Post, svc.AllowStart, svc.AllowStop, svc.AllowRestart, svc.ContainerScope, len(svc.AllowedContainers), len(svc.BlockedContainers), len(svc.ContainerRules), svc.APIRewrite) } return nil @@ -1643,15 +1728,39 @@ func isNetworkContainerOperation(path string) bool { return strings.HasSuffix(p, "/connect") || strings.HasSuffix(p, "/disconnect") } -func authorizeContainer(ctx context.Context, cfg *ProxyConfig, client *http.Client, service *ServiceConfig, ref string) (dockerContainerMeta, error) { +func authorizeContainer(ctx context.Context, cfg *ProxyConfig, client *http.Client, service *ServiceConfig, ref string) (dockerContainerMeta, ContainerAccess, error) { meta, err := resolveContainer(ctx, cfg, client, ref) if err != nil { - return dockerContainerMeta{}, err + return dockerContainerMeta{}, containerAccessDeny, err } - if !service.AllowsContainer(meta) { - return dockerContainerMeta{}, fmt.Errorf("container %q is outside profile scope", meta.Name) + access := service.ContainerAccess(meta) + if access == containerAccessDeny { + return dockerContainerMeta{}, access, fmt.Errorf("container %q is outside profile scope", meta.Name) } - return meta, nil + return meta, access, nil +} + +func isReadOnlyContainerRequest(r *http.Request) bool { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + return false + } + parts := strings.Split(strings.Trim(pathWithoutAPIVersion(r.URL.Path), "/"), "/") + if len(parts) != 3 || parts[0] != "containers" || parts[1] == "" { + return false + } + switch parts[2] { + case "json", "logs", "stats", "top", "changes": + return true + default: + return false + } +} + +func requireWritableContainer(access ContainerAccess, meta dockerContainerMeta) error { + if access == containerAccessReadOnly { + return fmt.Errorf("container %q is read-only for this profile", meta.Name) + } + return nil } func enforceContainerScope(ctx context.Context, cfg *ProxyConfig, client *http.Client, service *ServiceConfig, feature string, r *http.Request) (*responseFilterContext, error) { @@ -1662,10 +1771,13 @@ func enforceContainerScope(ctx context.Context, cfg *ProxyConfig, client *http.C switch feature { case "containers": if ref, ok := directContainerReference(r.URL.Path); ok { - meta, err := authorizeContainer(ctx, cfg, client, service, ref) + meta, access, err := authorizeContainer(ctx, cfg, client, service, ref) if err != nil { return nil, err } + if access == containerAccessReadOnly && !isReadOnlyContainerRequest(r) { + return nil, fmt.Errorf("container %q only permits read-only API requests", meta.Name) + } r.URL.Path = rewriteContainerReference(r.URL.Path, meta.ID) return nil, nil } @@ -1685,9 +1797,12 @@ func enforceContainerScope(ctx context.Context, cfg *ProxyConfig, client *http.C if err != nil { return nil, err } - if !service.AllowsContainer(meta) { + if service.ContainerAccess(meta) == containerAccessDeny { return nil, fmt.Errorf("container %q is outside profile scope", meta.Name) } + if err := requireWritableContainer(service.ContainerAccess(meta), meta); err != nil { + return nil, err + } case "events": return &responseFilterContext{service: service, kind: filterEvents}, nil case "networks": @@ -1696,13 +1811,21 @@ func enforceContainerScope(ctx context.Context, cfg *ProxyConfig, client *http.C if err != nil { return nil, err } - if _, err := authorizeContainer(ctx, cfg, client, service, ref); err != nil { + meta, access, err := authorizeContainer(ctx, cfg, client, service, ref) + if err != nil { + return nil, err + } + if err := requireWritableContainer(access, meta); err != nil { return nil, err } } case "commit": if ref := r.URL.Query().Get("container"); ref != "" { - if _, err := authorizeContainer(ctx, cfg, client, service, ref); err != nil { + meta, access, err := authorizeContainer(ctx, cfg, client, service, ref) + if err != nil { + return nil, err + } + if err := requireWritableContainer(access, meta); err != nil { return nil, err } } diff --git a/src/main_test.go b/src/main_test.go index fce52c2..fe294f3 100644 --- a/src/main_test.go +++ b/src/main_test.go @@ -113,7 +113,7 @@ func TestRewriteAPIVersion(t *testing.T) { } func TestParseProfilesYAML(t *testing.T) { - profiles, err := parseProfilesYAML("home:\n ping: true\n containers: false\n container_scope: allowlist\n allowed_containers:\n - traefik\n") + profiles, err := parseProfilesYAML("home:\n ping: true\n containers: false\n container_scope: allowlist\n allowed_containers:\n - traefik\n container_rules:\n - name: dockman\n access: readonly\n") if err != nil { t.Fatal(err) } @@ -124,6 +124,9 @@ func TestParseProfilesYAML(t *testing.T) { if _, ok := home.AllowedContainers["traefik"]; !ok { t.Fatalf("traefik missing from allowlist: %#v", home.AllowedContainers) } + if home.ContainerRules["dockman"] != containerAccessReadOnly { + t.Fatalf("dockman rule = %q, want readonly", home.ContainerRules["dockman"]) + } } func TestParseProfilesYAMLRejectsInvalidScope(t *testing.T) { @@ -133,6 +136,13 @@ func TestParseProfilesYAMLRejectsInvalidScope(t *testing.T) { } } +func TestParseProfilesYAMLRejectsInvalidContainerRule(t *testing.T) { + _, err := parseProfilesYAML("manager:\n container_rules:\n - name: dockman\n access: full\n") + if err == nil { + t.Fatal("invalid container rule was accepted") + } +} + func TestParseProfilesYAMLRejectsUnknownKey(t *testing.T) { _, err := parseProfilesYAML("manager:\n containers: true\n allowd_containers: []\n") if err == nil { @@ -161,6 +171,16 @@ func TestContainerScopes(t *testing.T) { if !blacklist.AllowsContainer(traefik) || blacklist.AllowsContainer(proxy) { t.Fatal("blacklist did not exclude the protected container") } + + rules := &ServiceConfig{ + ContainerScope: "blacklist", + AllowedContainers: map[string]struct{}{}, + BlockedContainers: map[string]struct{}{"docker-socket-proxy": {}}, + ContainerRules: map[string]ContainerAccess{"traefik": containerAccessReadOnly}, + } + if rules.ContainerAccess(traefik) != containerAccessReadOnly || rules.ContainerAccess(proxy) != containerAccessDeny { + t.Fatal("container rules did not override the expected access levels") + } } func TestBuildContainerIndex(t *testing.T) { @@ -225,6 +245,47 @@ func TestEnforceContainerScopeRejectsBlacklistedAndGlobalOperations(t *testing.T } } +func TestEnforceContainerScopeAllowsOnlySafeReadOnlyRoutes(t *testing.T) { + cfg := &ProxyConfig{ + containersByRef: buildContainerIndex([]dockerContainerSummary{{ + ID: "0123456789abcdef", + Names: []string{"/dockman"}, + }}), + execToContainer: make(map[string]string), + } + service := &ServiceConfig{ + ContainerScope: "all", + AllowedContainers: map[string]struct{}{}, + BlockedContainers: map[string]struct{}{}, + ContainerRules: map[string]ContainerAccess{"dockman": containerAccessReadOnly}, + } + for _, path := range []string{"/containers/dockman/json", "/containers/dockman/logs", "/containers/dockman/stats", "/containers/dockman/top"} { + req, err := http.NewRequest(http.MethodGet, "http://proxy"+path, nil) + if err != nil { + t.Fatal(err) + } + if _, err := enforceContainerScope(context.Background(), cfg, nil, service, "containers", req); err != nil { + t.Fatalf("read-only request %s was denied: %v", path, err) + } + } + for _, tc := range []struct { + method string + path string + }{ + {http.MethodPost, "/containers/dockman/restart"}, + {http.MethodPost, "/containers/dockman/exec"}, + {http.MethodGet, "/containers/dockman/archive"}, + } { + req, err := http.NewRequest(tc.method, "http://proxy"+tc.path, nil) + if err != nil { + t.Fatal(err) + } + if _, err := enforceContainerScope(context.Background(), cfg, nil, service, "containers", req); err == nil { + t.Fatalf("unsafe read-only request %s %s was allowed", tc.method, tc.path) + } + } +} + func TestFilterContainerListResponse(t *testing.T) { service := &ServiceConfig{ ContainerScope: "blacklist", @@ -248,3 +309,31 @@ func TestFilterContainerListResponse(t *testing.T) { t.Fatalf("unexpected filtered list: %s", body) } } + +func TestFilterContainerListKeepsReadOnlyContainer(t *testing.T) { + service := &ServiceConfig{ + ContainerScope: "all", + AllowedContainers: map[string]struct{}{}, + BlockedContainers: map[string]struct{}{}, + ContainerRules: map[string]ContainerAccess{ + "dockman": containerAccessReadOnly, + "docker-socket-proxy": containerAccessDeny, + }, + } + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`[ + {"Id":"a","Names":["/dockman"]}, + {"Id":"b","Names":["/docker-socket-proxy"]} +]`)), + } + filterContainerListResponse(resp, nil, service) + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), "dockman") || strings.Contains(string(body), "docker-socket-proxy") { + t.Fatalf("unexpected filtered list: %s", body) + } +} From 148ebcaeba6c50abaab4c457f270f24a8658ba0d Mon Sep 17 00:00:00 2001 From: Benjy Date: Fri, 17 Jul 2026 10:30:35 +0200 Subject: [PATCH 5/7] preserve Docker response fields when filtering --- src/main.go | 29 ++++++++++++++++++----------- src/main_test.go | 35 +++++++++++++++++++++++++++++++++-- 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/src/main.go b/src/main.go index 0b82a3e..594db3c 100644 --- a/src/main.go +++ b/src/main.go @@ -1853,8 +1853,13 @@ func filterContainerListResponse(resp *http.Response, cfg *ProxyConfig, service } first := true for decoder.More() { + var raw json.RawMessage + if err := decoder.Decode(&raw); err != nil { + _ = writer.CloseWithError(fmt.Errorf("decode Docker container entry: %w", err)) + return + } var container dockerContainerSummary - if err := decoder.Decode(&container); err != nil { + if err := json.Unmarshal(raw, &container); err != nil { _ = writer.CloseWithError(fmt.Errorf("decode Docker container entry: %w", err)) return } @@ -1862,18 +1867,13 @@ func filterContainerListResponse(resp *http.Response, cfg *ProxyConfig, service if !service.AllowsContainer(meta) { continue } - encoded, err := json.Marshal(container) - if err != nil { - _ = writer.CloseWithError(err) - return - } if !first { if _, err := writer.Write([]byte(",")); err != nil { return } } first = false - if _, err := writer.Write(encoded); err != nil { + if _, err := writer.Write(raw); err != nil { return } } @@ -1896,15 +1896,19 @@ func filterEventsResponse(resp *http.Response, cfg *ProxyConfig, service *Servic defer originalBody.Close() defer writer.Close() decoder := json.NewDecoder(originalBody) - encoder := json.NewEncoder(writer) for { - var event dockerEvent - if err := decoder.Decode(&event); err != nil { + var raw json.RawMessage + if err := decoder.Decode(&raw); err != nil { if err != io.EOF { _ = writer.CloseWithError(fmt.Errorf("decode Docker event: %w", err)) } return } + var event dockerEvent + if err := json.Unmarshal(raw, &event); err != nil { + _ = writer.CloseWithError(fmt.Errorf("decode Docker event: %w", err)) + return + } if event.Type != "container" { continue } @@ -1912,7 +1916,10 @@ func filterEventsResponse(resp *http.Response, cfg *ProxyConfig, service *Servic if !ok || !service.AllowsContainer(meta) { continue } - if err := encoder.Encode(event); err != nil { + if _, err := writer.Write(raw); err != nil { + return + } + if _, err := writer.Write([]byte("\n")); err != nil { return } } diff --git a/src/main_test.go b/src/main_test.go index fe294f3..59d57b6 100644 --- a/src/main_test.go +++ b/src/main_test.go @@ -324,7 +324,7 @@ func TestFilterContainerListKeepsReadOnlyContainer(t *testing.T) { StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[ - {"Id":"a","Names":["/dockman"]}, + {"Id":"a","Names":["/dockman"],"Image":"dockman:latest","FutureDockerField":"preserved"}, {"Id":"b","Names":["/docker-socket-proxy"]} ]`)), } @@ -333,7 +333,38 @@ func TestFilterContainerListKeepsReadOnlyContainer(t *testing.T) { if err != nil { t.Fatal(err) } - if !strings.Contains(string(body), "dockman") || strings.Contains(string(body), "docker-socket-proxy") { + if !strings.Contains(string(body), "dockman") || !strings.Contains(string(body), "FutureDockerField") || strings.Contains(string(body), "docker-socket-proxy") { t.Fatalf("unexpected filtered list: %s", body) } } + +func TestFilterEventsPreservesDockerEventFields(t *testing.T) { + allowed := dockerContainerMeta{ID: "allowed", Name: "dockman"} + denied := dockerContainerMeta{ID: "denied", Name: "docker-socket-proxy"} + cfg := &ProxyConfig{ + containersByRef: map[string]dockerContainerMeta{ + allowed.ID: allowed, + denied.ID: denied, + }, + } + service := &ServiceConfig{ + ContainerScope: "all", + ContainerRules: map[string]ContainerAccess{ + "docker-socket-proxy": containerAccessDeny, + }, + } + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("{\"Type\":\"container\",\"Action\":\"start\",\"Actor\":{\"ID\":\"allowed\",\"Attributes\":{\"name\":\"dockman\"}},\"scope\":\"local\",\"time\":123,\"timeNano\":123456789000,\"FutureDockerField\":\"preserved\"}\n{\"Type\":\"container\",\"Action\":\"start\",\"Actor\":{\"ID\":\"denied\",\"Attributes\":{\"name\":\"docker-socket-proxy\"}},\"timeNano\":123456789001}\n")), + } + filterEventsResponse(resp, cfg, service) + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + got := string(body) + if !strings.Contains(got, `"timeNano":123456789000`) || !strings.Contains(got, `"FutureDockerField":"preserved"`) || strings.Contains(got, "docker-socket-proxy") { + t.Fatalf("unexpected filtered events: %s", got) + } +} From 7e9497e4c9be4b715b4b098b0cb41924130d2df4 Mon Sep 17 00:00:00 2001 From: Benjy Date: Fri, 17 Jul 2026 10:32:36 +0200 Subject: [PATCH 6/7] format response filter regression test --- src/main_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main_test.go b/src/main_test.go index 59d57b6..ecf0f23 100644 --- a/src/main_test.go +++ b/src/main_test.go @@ -356,7 +356,7 @@ func TestFilterEventsPreservesDockerEventFields(t *testing.T) { resp := &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), - Body: io.NopCloser(strings.NewReader("{\"Type\":\"container\",\"Action\":\"start\",\"Actor\":{\"ID\":\"allowed\",\"Attributes\":{\"name\":\"dockman\"}},\"scope\":\"local\",\"time\":123,\"timeNano\":123456789000,\"FutureDockerField\":\"preserved\"}\n{\"Type\":\"container\",\"Action\":\"start\",\"Actor\":{\"ID\":\"denied\",\"Attributes\":{\"name\":\"docker-socket-proxy\"}},\"timeNano\":123456789001}\n")), + Body: io.NopCloser(strings.NewReader("{\"Type\":\"container\",\"Action\":\"start\",\"Actor\":{\"ID\":\"allowed\",\"Attributes\":{\"name\":\"dockman\"}},\"scope\":\"local\",\"time\":123,\"timeNano\":123456789000,\"FutureDockerField\":\"preserved\"}\n{\"Type\":\"container\",\"Action\":\"start\",\"Actor\":{\"ID\":\"denied\",\"Attributes\":{\"name\":\"docker-socket-proxy\"}},\"timeNano\":123456789001}\n")), } filterEventsResponse(resp, cfg, service) body, err := io.ReadAll(resp.Body) From 7feae04f8254e49208960366ed26b1f136c7d8e2 Mon Sep 17 00:00:00 2001 From: Benjy Date: Fri, 17 Jul 2026 11:22:44 +0200 Subject: [PATCH 7/7] keep Docker event stream free of timeouts --- src/main.go | 13 ++++++++----- src/main_test.go | 13 +++++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/main.go b/src/main.go index 594db3c..629cdb5 100644 --- a/src/main.go +++ b/src/main.go @@ -1202,13 +1202,16 @@ func (d *eventDebouncer) willTriggerImmediately() bool { return d.lastTrigger.IsZero() || timeSinceLastTrigger > d.delay*2 } -func eventLoop(ctx context.Context, cfg *ProxyConfig, client *http.Client, logger *log.Logger) { +// eventLoop écoute un flux Docker long-vivant. eventsClient ne doit donc pas +// avoir de timeout global ; discoveryClient reste borné pour les rafraîchissements +// ponctuels déclenchés par les événements. +func eventLoop(ctx context.Context, cfg *ProxyConfig, eventsClient, discoveryClient *http.Client, logger *log.Logger) { backoff := 2 * time.Second maxBackoff := 30 * time.Second // Créer le debouncer avec callback de découverte debouncer := newEventDebouncer(cfg.DebounceDelay, func() { - if err := discoverOnce(ctx, cfg, client, logger); err != nil { + if err := discoverOnce(ctx, cfg, discoveryClient, logger); err != nil { logger.Printf("[events] discover error: %v", err) } }) @@ -1236,7 +1239,7 @@ func eventLoop(ctx context.Context, cfg *ProxyConfig, client *http.Client, logge continue } - resp, err := client.Do(req) + resp, err := eventsClient.Do(req) if err != nil { if ctx.Err() != nil { logger.Printf("[events] request aborted (context done): %v", err) @@ -2170,10 +2173,10 @@ func main() { // Boucles de fond : // - découverte périodique (avec timeout) // - watcher du fichier de profiles - // - écoute des events Docker (update au fil de l'eau avec debouncing intelligent, avec timeout) + // - écoute des events Docker (flux long-vivant, sans timeout global) go discoverLoop(ctx, cfg, discoveryClient, logger) go profileWatcher(ctx, cfg, logger) - go eventLoop(ctx, cfg, discoveryClient, logger) + go eventLoop(ctx, cfg, proxyClient, discoveryClient, logger) targetURL, _ := url.Parse("http://docker") proxy := httputil.NewSingleHostReverseProxy(targetURL) diff --git a/src/main_test.go b/src/main_test.go index ecf0f23..92bcacc 100644 --- a/src/main_test.go +++ b/src/main_test.go @@ -7,6 +7,7 @@ import ( "net/http" "strings" "testing" + "time" ) func TestParseConfigUsesEnvironment(t *testing.T) { @@ -39,6 +40,18 @@ func TestParseConfigCLIOverridesEnvironment(t *testing.T) { } } +func TestDockerClientTimeoutsSeparateStreamingFromDiscovery(t *testing.T) { + streaming := newDockerHTTPClient("/tmp/docker.sock") + if streaming.Timeout != 0 { + t.Fatalf("streaming client timeout = %s, want 0", streaming.Timeout) + } + + discovery := newDockerHTTPClientWithTimeout("/tmp/docker.sock") + if discovery.Timeout != 30*time.Second { + t.Fatalf("discovery client timeout = %s, want 30s", discovery.Timeout) + } +} + func TestClassifyPath(t *testing.T) { tests := []struct { path string