commit f26a2fad95dd565890dde19635f0b5ad7a442d5c Author: Mike Conrad Date: Fri May 30 09:55:44 2025 -0400 Initial commit with basic example diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..c7769b9 --- /dev/null +++ b/compose.yml @@ -0,0 +1,49 @@ +name: traefik_secure +services: + socket-proxy: + image: dockerproxy + container_name: socket-proxy + networks: + - traefik + volumes: + - /var/run/docker.sock:/var/run/docker.sock + restart: unless-stopped + traefik: + image: traefik:latest + container_name: traefik + command: + - "--api.dashboard=true" + - "--log.level=INFO" + - "--entrypoints.web.address=:80" + - "--entrypoints.websecure.address=:443" + - "--providers.docker=true" + - "--providers.docker.endpoint=tcp://socket-proxy" + - "--providers.docker.exposedbydefault=false" + - "--entrypoints.traefik.address=:8080" + - "--api.insecure=true" + - "--api.dashboard=true" + labels: + - "traefik.enable=true" + - "traefik.http.routers.api.rule=Host(`traefik.docker.localhost`)" + - "traefik.http.routers.api.entrypoints=web" + - "traefik.http.routers.api.service=api@internal" +# - "traefik.http.routers.api.middlewares=auth" +# - "traefik.http.middlewares.auth.basicauth.users=admin:$apr1$rANDOMhASh$eUcPa3gZzIU0TNaipFi.Q/" + ports: + - "80:80" + - "8080:8080" + networks: + - traefik + depends_on: + - socket-proxy + restart: unless-stopped + whoami: + image: traefik/whoami + networks: + - traefik + labels: + - "traefik.enable=true" + - "traefik.http.routers.whoami.rule=Host(`whoami.docker.localhost`)" + - "traefik.http.routers.whoami.entrypoints=web" +networks: + traefik: diff --git a/proxy/Dockerfile b/proxy/Dockerfile new file mode 100644 index 0000000..86afc06 --- /dev/null +++ b/proxy/Dockerfile @@ -0,0 +1,26 @@ +# Stage 1: Build the Go binary +FROM golang:1.24.2-alpine AS builder + +# Set working directory inside the build container +WORKDIR /app + +# Copy Go module files and source code +COPY go.mod ./ +RUN go mod download + +COPY . . + +# Build the Go binary statically +RUN CGO_ENABLED=0 GOOS=linux go build -o docker-api-proxy . + +# Stage 2: Run binary in minimal container +FROM scratch + +# Copy binary from builder +COPY --from=builder /app/docker-api-proxy /usr/local/bin/docker-api-proxy + +# Run binary +ENTRYPOINT ["docker-api-proxy"] + +# Default port +EXPOSE 80 diff --git a/proxy/go.mod b/proxy/go.mod new file mode 100644 index 0000000..96a2286 --- /dev/null +++ b/proxy/go.mod @@ -0,0 +1,3 @@ +module hackanooga/dockerproxy + +go 1.24.2 diff --git a/proxy/main.go b/proxy/main.go new file mode 100644 index 0000000..734f620 --- /dev/null +++ b/proxy/main.go @@ -0,0 +1,149 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "net/http/httputil" + "strings" +) + +func main() { + tr := &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return net.Dial("unix", "/var/run/docker.sock") + }, + DisableKeepAlives: true, + } + + proxy := &httputil.ReverseProxy{ + Director: func(req *http.Request) { + req.URL.Scheme = "http" + req.URL.Host = "docker" // dummy host, won't be used + }, + Transport: tr, + ModifyResponse: redactSensitiveFields, + } + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + log.Printf("➡️ %s %s", r.Method, r.URL.Path) + + if !isAllowed(r.Method, r.URL.Path) { + log.Printf("⛔ Blocked: %s %s", r.Method, r.URL.Path) + + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + proxy.ServeHTTP(w, r) + }) + + log.Println("Unix socket proxy running on :80") + log.Fatal(http.ListenAndServe(":80", nil)) +} + +func isAllowed(method, path string) bool { + if method == "GET" { + if strings.HasSuffix(path, "/containers/json") || + strings.HasSuffix(path, "/networks") || + strings.HasSuffix(path, "/services") || + strings.HasSuffix(path, "/version") || + strings.HasSuffix(path, "/info") || + strings.HasSuffix(path, "/events") || + strings.HasSuffix(path, "/tasks") { + log.Printf("➡️ isAllowed %s", path) + + return true + } + } + if method == "POST" { + if strings.HasSuffix(path, "/start") || + strings.HasSuffix(path, "/stop") { + return true + } + } + return allowPath(path) + +} + +func redactSensitiveFields(resp *http.Response) error { + data, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("❌ Error reading body: %v", err) + return err + } + + // Try to parse as a JSON object first + var obj map[string]interface{} + if err := json.Unmarshal(data, &obj); err == nil { + log.Printf("➡️ Body (object): %+v", obj) + + // Redact sensitive fields from single object + redactObject(obj) + + redacted, _ := json.Marshal(obj) + resp.Body = io.NopCloser(strings.NewReader(string(redacted))) + resp.ContentLength = int64(len(redacted)) + resp.Header.Set("Content-Length", fmt.Sprint(len(redacted))) + return nil + } + + // Try to parse as a JSON array + var arr []map[string]interface{} + if err := json.Unmarshal(data, &arr); err == nil { + log.Printf("➡️ Body (array): %d items", len(arr)) + + // Redact sensitive fields from each item in the array + for _, item := range arr { + redactObject(item) + } + + redacted, _ := json.Marshal(arr) + resp.Body = io.NopCloser(strings.NewReader(string(redacted))) + resp.ContentLength = int64(len(redacted)) + resp.Header.Set("Content-Length", fmt.Sprint(len(redacted))) + return nil + } + + // Fallback if it's neither an object nor an array + log.Printf("⚠️ Failed to parse response body as JSON object or array") + resp.Body = io.NopCloser(strings.NewReader(string(data))) + resp.ContentLength = int64(len(data)) + resp.Header.Set("Content-Length", fmt.Sprint(len(data))) + return nil +} + +// Only allow specific Docker API paths (e.g., container inspect, used by Traefik) +func allowPath(path string) bool { + log.Printf("Inspecting %s", path) + // Example: /v1.24/containers//json + parts := strings.Split(path, "/") + log.Printf("Parts %d parts[0] %s", len(parts), parts) + if len(parts) == 5 && parts[1] == "v1.24" && parts[2] == "containers" && parts[4] == "json" { + log.Printf("Passes") + return true + } + return false +} + +func redactObject(obj map[string]interface{}) { + // Top-level redactions + delete(obj, "HostConfig") + delete(obj, "GraphDriver") + delete(obj, "Mounts") + + // Nested redactions + if config, ok := obj["Config"].(map[string]interface{}); ok { + delete(config, "Env") + delete(config, "Volumes") + delete(config, "User") + } + + if netSettings, ok := obj["NetworkSettings"].(map[string]interface{}); ok { + delete(netSettings, "SandboxID") + delete(netSettings, "SandboxKey") + } +}