Fix some bugs, add network whitelisting, start on documentation
This commit is contained in:
40
README.md
Normal file
40
README.md
Normal 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.
|
13
compose.yml
13
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"
|
||||
|
43
proxy/example-payloads/network.json
Normal file
43
proxy/example-payloads/network.json
Normal 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"
|
||||
}
|
||||
}
|
110
proxy/main.go
110
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/<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 {
|
||||
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,10 +119,23 @@ 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
|
||||
// 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)
|
||||
resp.Body = io.NopCloser(strings.NewReader(string(redacted)))
|
||||
@ -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/<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
|
||||
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 {
|
||||
// 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
|
||||
}
|
||||
|
Reference in New Issue
Block a user