Initial commit with basic example

This commit is contained in:
Mike Conrad
2025-05-30 09:55:44 -04:00
commit f26a2fad95
4 changed files with 227 additions and 0 deletions

26
proxy/Dockerfile Normal file
View 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
View File

@ -0,0 +1,3 @@
module hackanooga/dockerproxy
go 1.24.2

149
proxy/main.go Normal file
View 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")
}
}