Fix some bugs, add network whitelisting, start on documentation

This commit is contained in:
Mike Conrad
2025-05-30 12:12:02 -04:00
parent f26a2fad95
commit 28b292453f
4 changed files with 191 additions and 23 deletions

40
README.md Normal file
View File

@ -0,0 +1,40 @@
# Docker Socket Proxy
## Description
I wanted an easy/simple and secure way to use Traefik in my homelab without giving it free reign over my host machine and the Docker socket. This project is a WIP where I am testing out some concepts and ideas.
## Getting Started
First build and run the proxy.
```shell
cd proxy
go run main.go # You may need sudo to connect to /var/run/docker.sock
```
Now try it out! Traefik uses pinned API version routes so first get the version:
```shell
$ export DOCKER_API_VERSION=v$(curl localhost:8000/version | jq -r '.ApiVersion')
$ echo $DOCKER_API_VERSION
1.49
```
Now make some requests
```shell
# List containers (Allowed)
$ curl localhost:8000/v$DOCKER_API_VERSION/containers/json | jq
# Be sure to replace the below container ids with a valid one when testing.
# Stop a running container (Allowed)
$ curl -X POST localhost:8000/$DOCKER_API_VERSION/containers/52812bebe72b45cbe960babc2e3ff43a21bf9dd6c29ce9462ed39ec3c4e31072/stop
# Start a container (Allowed)
$ curl -X POST localhost:8000/$DOCKER_API_VERSION/containers/52812bebe72b45cbe960babc2e3ff43a21bf9dd6c29ce9462ed39ec3c4e31072/start
```
Now try something sneaky like creating a new network:
```shell
$ curl -X POST localhost:8000/$DOCKER_API_VERSION/networks/create -H 'content-type: application/json' -d @example-payloads/network.json
Forbidden
```
See the full code for the list of routes that are allowed. Any not in the allow list are blocked by default.

View File

@ -2,12 +2,19 @@ name: traefik_secure
services: services:
socket-proxy: socket-proxy:
image: dockerproxy image: dockerproxy
build:
context: ./proxy
dockerfile: Dockerfile
container_name: socket-proxy container_name: socket-proxy
ports:
- 8000:8000
networks: networks:
- traefik - traefik
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock:ro
restart: unless-stopped restart: unless-stopped
environment:
- ALLOWED_NETWORKS=traefik_secure_traefik
traefik: traefik:
image: traefik:latest image: traefik:latest
container_name: traefik container_name: traefik
@ -17,7 +24,7 @@ services:
- "--entrypoints.web.address=:80" - "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443" - "--entrypoints.websecure.address=:443"
- "--providers.docker=true" - "--providers.docker=true"
- "--providers.docker.endpoint=tcp://socket-proxy" - "--providers.docker.endpoint=tcp://socket-proxy:8000"
- "--providers.docker.exposedbydefault=false" - "--providers.docker.exposedbydefault=false"
- "--entrypoints.traefik.address=:8080" - "--entrypoints.traefik.address=:8080"
- "--api.insecure=true" - "--api.insecure=true"
@ -27,8 +34,6 @@ services:
- "traefik.http.routers.api.rule=Host(`traefik.docker.localhost`)" - "traefik.http.routers.api.rule=Host(`traefik.docker.localhost`)"
- "traefik.http.routers.api.entrypoints=web" - "traefik.http.routers.api.entrypoints=web"
- "traefik.http.routers.api.service=api@internal" - "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: ports:
- "80:80" - "80:80"
- "8080:8080" - "8080:8080"

View File

@ -0,0 +1,43 @@
{
"Name": "my_network",
"Driver": "bridge",
"Scope": "string",
"Internal": true,
"Attachable": true,
"Ingress": false,
"ConfigOnly": false,
"ConfigFrom": {
"Network": "config_only_network_01"
},
"IPAM": {
"Driver": "default",
"Config": [
{
"Subnet": "172.20.0.0/16",
"IPRange": "172.20.10.0/24",
"Gateway": "172.20.10.11",
"AuxiliaryAddresses": {
"property1": "string",
"property2": "string"
}
}
],
"Options": {
"foo": "bar"
}
},
"EnableIPv4": true,
"EnableIPv6": true,
"Options": {
"com.docker.network.bridge.default_bridge": "true",
"com.docker.network.bridge.enable_icc": "true",
"com.docker.network.bridge.enable_ip_masquerade": "true",
"com.docker.network.bridge.host_binding_ipv4": "0.0.0.0",
"com.docker.network.bridge.name": "docker0",
"com.docker.network.driver.mtu": "1500"
},
"Labels": {
"com.example.some-label": "some-value",
"com.example.some-other-label": "some-other-value"
}
}

View File

@ -9,6 +9,8 @@ import (
"net" "net"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"os"
"regexp"
"strings" "strings"
) )
@ -41,11 +43,12 @@ func main() {
proxy.ServeHTTP(w, r) proxy.ServeHTTP(w, r)
}) })
log.Println("Unix socket proxy running on :80") log.Println("Unix socket proxy running on :8000")
log.Fatal(http.ListenAndServe(":80", nil)) log.Fatal(http.ListenAndServe(":8000", nil))
} }
func isAllowed(method, path string) bool { func isAllowed(method, path string) bool {
// White listed endpoints that are considered relatively safe for now.
if method == "GET" { if method == "GET" {
if strings.HasSuffix(path, "/containers/json") || if strings.HasSuffix(path, "/containers/json") ||
strings.HasSuffix(path, "/networks") || strings.HasSuffix(path, "/networks") ||
@ -65,10 +68,28 @@ func isAllowed(method, path string) bool {
return true return true
} }
} }
// If it doesn't fit into any of the above, then do some more checks on it.
return allowPath(path) return allowPath(path)
} }
// Only allow specific Docker API paths (e.g., container inspect, used by Traefik)
func allowPath(path string) bool {
pattern := `^v\d+\.\d+$`
re := regexp.MustCompile(pattern)
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 && re.MatchString(parts[1]) && parts[2] == "containers" && parts[4] == "json" {
log.Printf("Passes")
return true
}
return false
}
// Remove some sensitive data from the Docker API response.
func redactSensitiveFields(resp *http.Response) error { func redactSensitiveFields(resp *http.Response) error {
data, err := io.ReadAll(resp.Body) data, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
@ -82,6 +103,8 @@ func redactSensitiveFields(resp *http.Response) error {
log.Printf("➡️ Body (object): %+v", obj) log.Printf("➡️ Body (object): %+v", obj)
// Redact sensitive fields from single object // Redact sensitive fields from single object
log.Printf("🧪 Item: %+v", obj)
redactObject(obj) redactObject(obj)
redacted, _ := json.Marshal(obj) redacted, _ := json.Marshal(obj)
@ -96,9 +119,22 @@ func redactSensitiveFields(resp *http.Response) error {
if err := json.Unmarshal(data, &arr); err == nil { if err := json.Unmarshal(data, &arr); err == nil {
log.Printf("➡️ Body (array): %d items", len(arr)) log.Printf("➡️ Body (array): %d items", len(arr))
// Redact sensitive fields from each item in the array // Special case: filter top-level networks (from /networks)
for _, item := range arr { if strings.Contains(resp.Request.URL.Path, "/networks") {
redactObject(item) filtered := make([]map[string]interface{}, 0)
for _, item := range arr {
if name, ok := item["Name"].(string); ok && allowedNetworks[name] {
filtered = append(filtered, item)
} else {
log.Printf("🗑️ Removing disallowed network: %s", name)
}
}
arr = filtered
} else {
// Generic redaction
for _, item := range arr {
redactObject(item)
}
} }
redacted, _ := json.Marshal(arr) redacted, _ := json.Marshal(arr)
@ -116,34 +152,78 @@ func redactSensitiveFields(resp *http.Response) error {
return nil return nil
} }
// Only allow specific Docker API paths (e.g., container inspect, used by Traefik) var allowedNetworks = getAllowedNetworks()
func allowPath(path string) bool {
log.Printf("Inspecting %s", path) func getAllowedNetworks() map[string]bool {
// Example: /v1.24/containers/<container-id>/json env := os.Getenv("ALLOWED_NETWORKS")
parts := strings.Split(path, "/") nets := strings.Split(env, ",")
log.Printf("Parts %d parts[0] %s", len(parts), parts) allowed := make(map[string]bool, len(nets))
if len(parts) == 5 && parts[1] == "v1.24" && parts[2] == "containers" && parts[4] == "json" { for _, n := range nets {
log.Printf("Passes") n = strings.TrimSpace(n)
return true if n != "" {
allowed[n] = true
}
} }
return false return allowed
} }
// This function is responsible for removing keys/properties from the response.
func redactObject(obj map[string]interface{}) { func redactObject(obj map[string]interface{}) {
// Top-level redactions // Top-level redactions
delete(obj, "HostConfig") delete(obj, "HostConfig")
delete(obj, "GraphDriver") delete(obj, "GraphDriver")
delete(obj, "Mounts") delete(obj, "Mounts")
// Nested redactions // Config redactions
if config, ok := obj["Config"].(map[string]interface{}); ok { if config, ok := obj["Config"].(map[string]interface{}); ok {
delete(config, "Env") delete(config, "Env")
delete(config, "Volumes") delete(config, "Volumes")
delete(config, "User") delete(config, "User")
} }
if netSettings, ok := obj["NetworkSettings"].(map[string]interface{}); ok { // NetworkSettings redactions
delete(netSettings, "SandboxID") netSettings, ok := obj["NetworkSettings"].(map[string]interface{})
delete(netSettings, "SandboxKey") if !ok {
log.Println("🔍 No NetworkSettings present")
return
}
delete(netSettings, "SandboxID")
delete(netSettings, "SandboxKey")
networksRaw, ok := netSettings["Networks"]
if !ok {
log.Println("🔍 No Networks field present")
return
}
// Filter out any networks that traefik is not on and therefore should not care/know about.
switch networks := networksRaw.(type) {
case map[string]interface{}:
log.Printf("🌐 Found networks: %v", keysOf(networks))
for name := range networks {
if !allowedNetworks[name] {
log.Printf("🗑️ Removing disallowed network: %s", name)
delete(networks, name)
}
}
case []interface{}:
log.Printf("🌐 Found networks (array): %v", networks)
var filtered []interface{}
for _, v := range networks {
if name, ok := v.(string); ok && allowedNetworks[name] {
filtered = append(filtered, name)
}
}
netSettings["Networks"] = filtered
default:
log.Printf("⚠️ Unknown type for Networks: %T", networksRaw)
} }
} }
func keysOf(m map[string]interface{}) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}