Initial commit with basic example
This commit is contained in:
49
compose.yml
Normal file
49
compose.yml
Normal file
@ -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:
|
26
proxy/Dockerfile
Normal file
26
proxy/Dockerfile
Normal file
@ -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
|
3
proxy/go.mod
Normal file
3
proxy/go.mod
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
module hackanooga/dockerproxy
|
||||||
|
|
||||||
|
go 1.24.2
|
149
proxy/main.go
Normal file
149
proxy/main.go
Normal file
@ -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/<container-id>/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")
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user