diff --git a/README.md b/README.md new file mode 100644 index 0000000..0d69afe --- /dev/null +++ b/README.md @@ -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. \ No newline at end of file diff --git a/compose.yml b/compose.yml index c7769b9..bbcb0a4 100644 --- a/compose.yml +++ b/compose.yml @@ -2,12 +2,19 @@ name: traefik_secure services: socket-proxy: image: dockerproxy + build: + context: ./proxy + dockerfile: Dockerfile container_name: socket-proxy + ports: + - 8000:8000 networks: - traefik volumes: - - /var/run/docker.sock:/var/run/docker.sock + - /var/run/docker.sock:/var/run/docker.sock:ro restart: unless-stopped + environment: + - ALLOWED_NETWORKS=traefik_secure_traefik traefik: image: traefik:latest container_name: traefik @@ -17,7 +24,7 @@ services: - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" - "--providers.docker=true" - - "--providers.docker.endpoint=tcp://socket-proxy" + - "--providers.docker.endpoint=tcp://socket-proxy:8000" - "--providers.docker.exposedbydefault=false" - "--entrypoints.traefik.address=:8080" - "--api.insecure=true" @@ -27,8 +34,6 @@ services: - "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" diff --git a/proxy/example-payloads/network.json b/proxy/example-payloads/network.json new file mode 100644 index 0000000..ec170b6 --- /dev/null +++ b/proxy/example-payloads/network.json @@ -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" + } +} diff --git a/proxy/main.go b/proxy/main.go index 734f620..c1ba27e 100644 --- a/proxy/main.go +++ b/proxy/main.go @@ -9,6 +9,8 @@ import ( "net" "net/http" "net/http/httputil" + "os" + "regexp" "strings" ) @@ -41,11 +43,12 @@ func main() { proxy.ServeHTTP(w, r) }) - log.Println("Unix socket proxy running on :80") - log.Fatal(http.ListenAndServe(":80", nil)) + log.Println("Unix socket proxy running on :8000") + log.Fatal(http.ListenAndServe(":8000", nil)) } func isAllowed(method, path string) bool { + // White listed endpoints that are considered relatively safe for now. if method == "GET" { if strings.HasSuffix(path, "/containers/json") || strings.HasSuffix(path, "/networks") || @@ -65,10 +68,28 @@ func isAllowed(method, path string) bool { return true } } + // If it doesn't fit into any of the above, then do some more checks on it. 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//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 { data, err := io.ReadAll(resp.Body) if err != nil { @@ -82,6 +103,8 @@ func redactSensitiveFields(resp *http.Response) error { log.Printf("โžก๏ธ Body (object): %+v", obj) // Redact sensitive fields from single object + log.Printf("๐Ÿงช Item: %+v", obj) + redactObject(obj) redacted, _ := json.Marshal(obj) @@ -96,9 +119,22 @@ func redactSensitiveFields(resp *http.Response) error { 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) + // Special case: filter top-level networks (from /networks) + if strings.Contains(resp.Request.URL.Path, "/networks") { + 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) @@ -116,34 +152,78 @@ func redactSensitiveFields(resp *http.Response) error { 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 +var allowedNetworks = getAllowedNetworks() + +func getAllowedNetworks() map[string]bool { + env := os.Getenv("ALLOWED_NETWORKS") + nets := strings.Split(env, ",") + allowed := make(map[string]bool, len(nets)) + for _, n := range nets { + n = strings.TrimSpace(n) + 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{}) { // Top-level redactions delete(obj, "HostConfig") delete(obj, "GraphDriver") delete(obj, "Mounts") - // Nested redactions + // Config 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") + // NetworkSettings redactions + netSettings, ok := obj["NetworkSettings"].(map[string]interface{}) + 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 +}