From eb5cc7efafbf77201095a3d14a2bcc87cc0de643 Mon Sep 17 00:00:00 2001 From: Amanda Wee Date: Sat, 4 Oct 2025 14:27:43 +1300 Subject: [PATCH 01/38] implement per-container allowlists --- Dockerfile | 1 + cmd/socket-proxy/bindmount.go | 41 +++--- cmd/socket-proxy/bindmount_test.go | 25 +--- cmd/socket-proxy/handlehttprequest.go | 61 +++++++-- cmd/socket-proxy/main.go | 15 ++- go.mod | 2 + internal/config/config.go | 184 +++++++++++++++++++++++--- 7 files changed, 261 insertions(+), 68 deletions(-) diff --git a/Dockerfile b/Dockerfile index 78f3032..5f17e0c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,7 @@ COPY . ./ ARG TARGETOS ARG TARGETARCH ARG VERSION +RUN go mod tidy RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ go build -tags=netgo -gcflags=all=-d=checkptr -ldflags="-w -s -X 'main.version=${VERSION}'" -trimpath \ -o / ./... diff --git a/cmd/socket-proxy/bindmount.go b/cmd/socket-proxy/bindmount.go index 5b6ba16..38671c3 100644 --- a/cmd/socket-proxy/bindmount.go +++ b/cmd/socket-proxy/bindmount.go @@ -79,9 +79,9 @@ type ( ) // checkBindMountRestrictions checks if bind mounts in the request are allowed. -func checkBindMountRestrictions(r *http.Request) error { +func checkBindMountRestrictions(allowedBindMounts []string, r *http.Request) error { // Only check if bind mount restrictions are configured - if len(cfg.AllowBindMountFrom) == 0 { + if len(allowedBindMounts) == 0 { return nil } @@ -94,23 +94,23 @@ func checkBindMountRestrictions(r *http.Request) error { switch { case len(pathParts) >= 4 && pathParts[2] == "containers" && pathParts[3] == "create": // Container creation: /vX.xx/containers/create - return checkContainer(r) + return checkContainer(allowedBindMounts, r) case len(pathParts) >= 5 && pathParts[2] == "containers" && pathParts[4] == "update": // Container update: /vX.xx/containers/{id}/update - return checkContainer(r) + return checkContainer(allowedBindMounts, r) case len(pathParts) >= 4 && pathParts[2] == "services" && pathParts[3] == "create": // Service creation: /vX.xx/services/create - return checkService(r) + return checkService(allowedBindMounts, r) case len(pathParts) >= 5 && pathParts[2] == "services" && pathParts[4] == "update": // Service update: /vX.xx/services/{id}/update - return checkService(r) + return checkService(allowedBindMounts, r) default: return nil } } // checkContainer checks bind mounts in container creation requests. -func checkContainer(r *http.Request) error { +func checkContainer(allowedBindMounts []string, r *http.Request) error { body, err := readAndRestoreBody(r) if err != nil { return err @@ -122,11 +122,11 @@ func checkContainer(r *http.Request) error { return nil // Don't block if we can't parse. } - return checkHostConfigBindMounts(req.HostConfig) + return checkHostConfigBindMounts(allowedBindMounts, req.HostConfig) } // checkService checks bind mounts in service creation requests. -func checkService(r *http.Request) error { +func checkService(allowedBindMounts []string, r *http.Request) error { body, err := readAndRestoreBody(r) if err != nil { return err @@ -141,20 +141,23 @@ func checkService(r *http.Request) error { if req.TaskTemplate.ContainerSpec == nil { return nil // No container spec, nothing to check. } - return checkHostConfigBindMounts(&containerHostConfig{ - Mounts: req.TaskTemplate.ContainerSpec.Mounts, - }) + return checkHostConfigBindMounts( + allowedBindMounts, + &containerHostConfig{ + Mounts: req.TaskTemplate.ContainerSpec.Mounts, + }, + ) } // checkHostConfigBindMounts checks bind mounts in HostConfig. -func checkHostConfigBindMounts(hostConfig *containerHostConfig) error { +func checkHostConfigBindMounts(allowedBindMounts []string, hostConfig *containerHostConfig) error { if hostConfig == nil { return nil // No HostConfig, nothing to check } // Check legacy Binds field for _, bind := range hostConfig.Binds { - if err := validateBindMount(bind); err != nil { + if err := validateBindMount(allowedBindMounts, bind); err != nil { return err } } @@ -162,7 +165,7 @@ func checkHostConfigBindMounts(hostConfig *containerHostConfig) error { // Check modern Mounts field for _, mountItem := range hostConfig.Mounts { if mountItem.Type == mountTypeBind { - if err := validateBindMountSource(mountItem.Source); err != nil { + if err := validateBindMountSource(allowedBindMounts, mountItem.Source); err != nil { return err } } @@ -172,23 +175,23 @@ func checkHostConfigBindMounts(hostConfig *containerHostConfig) error { } // validateBindMount validates a bind mount string in the format "source:target:options". -func validateBindMount(bind string) error { +func validateBindMount(allowedBindMounts []string, bind string) error { parts := strings.Split(bind, ":") if len(parts) < 2 { return fmt.Errorf("invalid bind mount format: %s", bind) } - return validateBindMountSource(parts[0]) + return validateBindMountSource(allowedBindMounts, parts[0]) } // validateBindMountSource checks if the source directory is allowed. -func validateBindMountSource(source string) error { +func validateBindMountSource(allowedBindMounts []string, source string) error { // Skip if source is not an absolute path (i.e. bind mount). if !strings.HasPrefix(source, "/") { return nil } source = filepath.Clean(source) // Clean the path to resolve .. and . components. - for _, allowedDir := range cfg.AllowBindMountFrom { + for _, allowedDir := range allowedBindMounts { if allowedDir == "/" || source == allowedDir || strings.HasPrefix(source, allowedDir+"/") { return nil } diff --git a/cmd/socket-proxy/bindmount_test.go b/cmd/socket-proxy/bindmount_test.go index 6fe8d10..d71d74e 100644 --- a/cmd/socket-proxy/bindmount_test.go +++ b/cmd/socket-proxy/bindmount_test.go @@ -5,8 +5,6 @@ import ( "net/http" "runtime" "testing" - - "github.com/wollomatic/socket-proxy/internal/config" ) func skipIfNotUnix(t *testing.T) { @@ -21,9 +19,7 @@ func skipIfNotUnix(t *testing.T) { func TestValidateBindMountSource(t *testing.T) { skipIfNotUnix(t) - cfg = &config.Config{ - AllowBindMountFrom: []string{"/home", "/var/log"}, - } + allowedBindMounts := []string{"/home", "/var/log"} tests := []struct { name string @@ -44,7 +40,7 @@ func TestValidateBindMountSource(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := validateBindMountSource(tt.source) + err := validateBindMountSource(allowedBindMounts, tt.source) if tt.shouldPass && err != nil { t.Errorf("expected %s to pass, but got error: %v", tt.source, err) } @@ -83,10 +79,7 @@ func TestIsPathAllowed(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cfg = &config.Config{ - AllowBindMountFrom: []string{tt.allowedDir}, - } - err := validateBindMountSource(tt.path) + err := validateBindMountSource([]string{tt.allowedDir}, tt.path) if (err == nil) != tt.expected { t.Errorf("isPathAllowed(%s, %s) = %v, expected %v", tt.path, tt.allowedDir, err, tt.expected) } @@ -97,9 +90,7 @@ func TestIsPathAllowed(t *testing.T) { func TestValidateBindMount(t *testing.T) { skipIfNotUnix(t) - cfg = &config.Config{ - AllowBindMountFrom: []string{"/home", "/var/log"}, - } + allowedBindMounts := []string{"/home", "/var/log"} tests := []struct { name string @@ -115,7 +106,7 @@ func TestValidateBindMount(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := validateBindMount(tt.bind) + err := validateBindMount(allowedBindMounts, tt.bind) if tt.shouldPass && err != nil { t.Errorf("expected %s to pass, but got error: %v", tt.bind, err) } @@ -129,9 +120,7 @@ func TestValidateBindMount(t *testing.T) { func TestCheckBindMountRestrictions(t *testing.T) { skipIfNotUnix(t) - cfg = &config.Config{ - AllowBindMountFrom: []string{"/home"}, - } + allowedBindMounts := []string{"/home"} tests := []struct { name string @@ -212,7 +201,7 @@ func TestCheckBindMountRestrictions(t *testing.T) { t.Fatalf("failed to create request: %v", err) } - err = checkBindMountRestrictions(req) + err = checkBindMountRestrictions(allowedBindMounts, req) if tt.shouldPass && err != nil { t.Errorf("expected request to pass, but got error: %v", err) } diff --git a/cmd/socket-proxy/handlehttprequest.go b/cmd/socket-proxy/handlehttprequest.go index 8d748b4..3c61348 100644 --- a/cmd/socket-proxy/handlehttprequest.go +++ b/cmd/socket-proxy/handlehttprequest.go @@ -5,25 +5,20 @@ import ( "log/slog" "net" "net/http" + + "github.com/wollomatic/socket-proxy/internal/config" ) // handleHTTPRequest checks if the request is allowed and sends it to the proxy. // Otherwise, it returns a "405 Method Not Allowed" or a "403 Forbidden" error. // In case of an error, it returns a 500 Internal Server Error. func handleHTTPRequest(w http.ResponseWriter, r *http.Request) { - if cfg.ProxySocketEndpoint == "" { // do not perform this check if we proxy to a unix socket - allowedIP, err := isAllowedClient(r.RemoteAddr) - if err != nil { - slog.Warn("cannot get valid IP address for client allowlist check", "reason", err, "method", r.Method, "URL", r.URL, "client", r.RemoteAddr) - } - if !allowedIP { - communicateBlockedRequest(w, r, "forbidden IP", http.StatusForbidden) - return - } + allowList := determineAllowList(w, r) + if allowList == nil { + return } - // check if the request is allowed - allowed, exists := cfg.AllowedRequests[r.Method] + allowed, exists := allowList.AllowedRequests[r.Method] if !exists { // method not in map -> not allowed communicateBlockedRequest(w, r, "method not allowed", http.StatusMethodNotAllowed) return @@ -34,7 +29,7 @@ func handleHTTPRequest(w http.ResponseWriter, r *http.Request) { } // check bind mount restrictions - if err := checkBindMountRestrictions(r); err != nil { + if err := checkBindMountRestrictions(allowList.AllowedBindMounts, r); err != nil { communicateBlockedRequest(w, r, "bind mount restriction: "+err.Error(), http.StatusForbidden) return } @@ -44,6 +39,48 @@ func handleHTTPRequest(w http.ResponseWriter, r *http.Request) { socketProxy.ServeHTTP(w, r) // proxy the request } +// return the relevant allowlist, or nil if the request has been blocked +func determineAllowList(w http.ResponseWriter, r *http.Request) *config.AllowList { + if cfg.ProxySocketEndpoint == "" { // do not perform this check if we proxy to a unix socket + if cfg.ProxyContainerName != "" { + allowList := checkForAllowListByIP(w, r) + if allowList != nil { + return allowList + } + } + + // Check if client is allowed for the default allowlist: + allowedIP, err := isAllowedClient(r.RemoteAddr) + if err != nil { + slog.Warn("cannot get valid IP address for client allowlist check", "reason", err, "method", r.Method, "URL", r.URL, "client", r.RemoteAddr) + } + if !allowedIP { + communicateBlockedRequest(w, r, "forbidden IP", http.StatusForbidden) + return nil + } + } + + return cfg.AllowLists.Default +} + +// return the allowlist corresponding to the container by IP address used to make the request, +// or nil if none is found +func checkForAllowListByIP(w http.ResponseWriter, r *http.Request) *config.AllowList { + clientIPStr, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return nil + } + + cfg.AllowLists.Mutex.RLock() + defer cfg.AllowLists.Mutex.RUnlock() + + allowList, found := cfg.AllowLists.ByIP[clientIPStr] + if !found { + return nil + } + return allowList +} + // isAllowedClient checks if the given remote address is allowed to connect to the proxy. // The IP address is extracted from a RemoteAddr string (the part before the colon). func isAllowedClient(remoteAddr string) (bool, error) { diff --git a/cmd/socket-proxy/main.go b/cmd/socket-proxy/main.go index 77837cc..afad226 100644 --- a/cmd/socket-proxy/main.go +++ b/cmd/socket-proxy/main.go @@ -71,23 +71,30 @@ func main() { } else { slog.Info("watchdog disabled") } - if len(cfg.AllowBindMountFrom) > 0 { - slog.Info("Docker bind mount restrictions enabled", "allowbindmountfrom", cfg.AllowBindMountFrom) + if len(cfg.AllowLists.Default.AllowedBindMounts) > 0 { + slog.Info("Docker bind mount restrictions enabled", "allowbindmountfrom", cfg.AllowLists.Default.AllowedBindMounts) } else { // we only log this on DEBUG level because bind mount restrictions are a very special use case slog.Debug("no Docker bind mount restrictions") } + if len(cfg.ProxyContainerName) > 0 { + slog.Info("Proxy container name provided", "proxycontainername", cfg.ProxyContainerName) + } else { + // we only log this on DEBUG level because providing the socket-proxy container name + // enables the use of labels to specify per-container allowlists + slog.Debug("no proxy container name provided") + } // print request allowlist if cfg.LogJSON { - for method, regex := range cfg.AllowedRequests { + for method, regex := range cfg.AllowLists.Default.AllowedRequests { slog.Info("configured allowed request", "method", method, "regex", regex) } } else { // don't use slog here, as we want to print the regexes as they are // see https://github.com/wollomatic/socket-proxy/issues/11 fmt.Printf("Request allowlist:\n %-8s %s\n", "Method", "Regex") - for method, regex := range cfg.AllowedRequests { + for method, regex := range cfg.AllowLists.Default.AllowedRequests { fmt.Printf(" %-8s %s\n", method, regex) } } diff --git a/go.mod b/go.mod index aa43515..87ab6d6 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/wollomatic/socket-proxy go 1.22.6 + +require github.com/docker/docker v28.5.0+incompatible diff --git a/internal/config/config.go b/internal/config/config.go index 1f4ae42..5b965d4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "context" "errors" "flag" "fmt" @@ -11,10 +12,18 @@ import ( "os" "path/filepath" "regexp" + "slices" "strconv" "strings" + "sync" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/client" ) +const allowedDockerLabelPrefix = "socket-proxy.allowed." + var ( defaultAllowFrom = "127.0.0.1/32" // allowed IPs to connect to the proxy defaultAllowHealthcheck = false // allow health check requests (HEAD http://localhost:55555/health) @@ -29,10 +38,23 @@ var ( defaultProxySocketEndpoint = "" // empty string means no socket listener, but regular TCP listener defaultProxySocketEndpointFileMode = uint(0o600) // set the file mode of the unix socket endpoint defaultAllowBindMountFrom = "" // empty string means no bind mount restrictions + defaultProxyContainerName = "" // socket-proxy Docker container name (empty string disables container labels for allowlists) ) +type AllowList struct { + ID string // Container ID (empty for the default allow-list) + AllowedRequests map[string]*regexp.Regexp // map of request methods to request path regex patterns (no requests allowed if empty) + AllowedBindMounts []string // list of from portion of allowed bind mounts (all bind mounts allowed if empty) +} + +type AllowListRegistry struct { + Default *AllowList + ByIP map[string]*AllowList + Mutex sync.RWMutex +} + type Config struct { - AllowedRequests map[string]*regexp.Regexp + AllowLists *AllowListRegistry AllowFrom []string AllowHealthcheck bool LogJSON bool @@ -44,7 +66,8 @@ type Config struct { SocketPath string ProxySocketEndpoint string ProxySocketEndpointFileMode os.FileMode - AllowBindMountFrom []string + ProxyContainerName string + ProxyContainerNetworks []string } // used for list of allowed requests @@ -73,6 +96,8 @@ var mr = []methodRegex{ func InitConfig() (*Config, error) { var ( cfg Config + allowLists AllowListRegistry + defaultAllowList AllowList allowFromString string listenIP string proxyPort uint @@ -134,6 +159,9 @@ func InitConfig() (*Config, error) { if val, ok := os.LookupEnv("SP_ALLOWBINDMOUNTFROM"); ok && val != "" { defaultAllowBindMountFrom = val } + if val, ok := os.LookupEnv("SP_PROXYCONTAINERNAME"); ok && val != "" { + defaultProxyContainerName = val + } for i := range mr { if val, ok := os.LookupEnv("SP_ALLOW_" + mr[i].method); ok && val != "" { @@ -160,6 +188,7 @@ func InitConfig() (*Config, error) { flag.StringVar(&cfg.ProxySocketEndpoint, "proxysocketendpoint", defaultProxySocketEndpoint, "unix socket endpoint (if set, used instead of the TCP listener)") flag.UintVar(&endpointFileMode, "proxysocketendpointfilemode", defaultProxySocketEndpointFileMode, "set the file mode of the unix socket endpoint") flag.StringVar(&allowBindMountFromString, "allowbindmountfrom", defaultAllowBindMountFrom, "allowed directories for bind mounts (comma-separated)") + flag.StringVar(&cfg.ProxyContainerName, "proxycontainername", defaultProxyContainerName, "socket-proxy Docker container name") for i := range mr { flag.StringVar(&mr[i].regexStringFromParam, "allow"+mr[i].method, "", "regex for "+mr[i].method+" requests (not set means method is not allowed)") } @@ -170,13 +199,11 @@ func InitConfig() (*Config, error) { // parse allowBindMountFromString into AllowBindMountFrom slice and validate if allowBindMountFromString != "" { - cfg.AllowBindMountFrom = strings.Split(allowBindMountFromString, ",") - for i, dir := range cfg.AllowBindMountFrom { - if !strings.HasPrefix(dir, "/") { - return nil, fmt.Errorf("bind mount directory must start with /: %q", dir) - } - cfg.AllowBindMountFrom[i] = filepath.Clean(dir) + allowedBindMounts, err := parseAllowedBindMounts(allowBindMountFromString) + if err != nil { + return nil, err } + defaultAllowList.AllowedBindMounts = allowedBindMounts } // check listenIP and proxyPort @@ -215,21 +242,148 @@ func InitConfig() (*Config, error) { cfg.ProxySocketEndpointFileMode = os.FileMode(uint32(endpointFileMode)) // compile regexes for allowed requests - cfg.AllowedRequests = make(map[string]*regexp.Regexp) + defaultAllowList.AllowedRequests = make(map[string]*regexp.Regexp) for _, rx := range mr { if rx.regexStringFromParam != "" { - r, err := regexp.Compile("^" + rx.regexStringFromParam + "$") + r, err := compileRegexp(rx.regexStringFromParam, rx.method, "command line parameter") if err != nil { - return nil, fmt.Errorf("invalid regex \"%s\" for method %s in command line parameter: %w", rx.regexStringFromParam, rx.method, err) + return nil, err } - cfg.AllowedRequests[rx.method] = r + defaultAllowList.AllowedRequests[rx.method] = r } else if rx.regexStringFromEnv != "" { - r, err := regexp.Compile("^" + rx.regexStringFromEnv + "$") + r, err := compileRegexp(rx.regexStringFromEnv, rx.method, "env variable") if err != nil { - return nil, fmt.Errorf("invalid regex \"%s\" for method %s in env variable: %w", rx.regexStringFromEnv, rx.method, err) + return nil, err } - cfg.AllowedRequests[rx.method] = r + defaultAllowList.AllowedRequests[rx.method] = r + } + } + + cfg.AllowLists = &allowLists + cfg.AllowLists.Default = &defaultAllowList + if cfg.ProxySocketEndpoint == "" && cfg.ProxyContainerName != "" { + var err error + cfg.ProxyContainerNetworks, err = readProxyContainerNetworks(cfg.ProxyContainerName) + if err != nil { + return nil, err + } + cfg.AllowLists.ByIP, err = readContainerLabelAllowLists(cfg.ProxyContainerNetworks) + if err != nil { + return nil, err } } + return &cfg, nil } + +func compileRegexp(regex, method, configLocation string) (*regexp.Regexp, error) { + r, err := regexp.Compile("^" + regex + "$") + if err != nil { + return nil, fmt.Errorf("invalid regex \"%s\" for method %s in %s: %w", regex, method, configLocation, err) + } + return r, nil +} + +func parseAllowedBindMounts(allowBindMountFromString string) ([]string, error) { + allowedBindMounts := strings.Split(allowBindMountFromString, ",") + for i, dir := range allowedBindMounts { + if !strings.HasPrefix(dir, "/") { + return nil, fmt.Errorf("bind mount directory must start with /: %q", dir) + } + allowedBindMounts[i] = filepath.Clean(dir) + } + return allowedBindMounts, nil +} + +// return list of docker networks that the socket-proxy container is in +func readProxyContainerNetworks(proxyContainerName string) ([]string, error) { + var networks []string + dockerClient, err := client.NewClientWithOpts(client.FromEnv) + if err != nil { + return nil, err + } + defer dockerClient.Close() + + filter := filters.NewArgs() + filter.Add("name", proxyContainerName) + containers, err := dockerClient.ContainerList(context.Background(), container.ListOptions{Filters: filter}) + if err != nil { + return nil, err + } + if len(containers) == 0 { + return nil, fmt.Errorf("socket-proxy container \"%s\" was not found", proxyContainerName) + } + + for networkID, _ := range containers[0].NetworkSettings.Networks { + networks = append(networks, networkID) + } + + return networks, nil +} + +// return AllowListRegistry with allowlists specified by docker container labels +func readContainerLabelAllowLists(networks []string) (map[string]*AllowList, error) { + allowListsByIP := make(map[string]*AllowList) + + dockerClient, err := client.NewClientWithOpts(client.FromEnv) + if err != nil { + return nil, err + } + defer dockerClient.Close() + + var methods []string + for _, rx := range mr { + methods = append(methods, rx.method) + } + + ctx := context.Background() + for _, network := range networks { + filter := filters.NewArgs() + filter.Add("network", network) + containers, err := dockerClient.ContainerList(ctx, container.ListOptions{Filters: filter}) + if err != nil { + return nil, err + } + + for _, cntr := range containers { + allowedRequests := make(map[string]*regexp.Regexp) + allowedBindMounts := []string{} + for labelName, labelValue := range cntr.Labels { + if strings.HasPrefix(labelName, allowedDockerLabelPrefix) && labelValue != "" { + allowSpec := strings.ToUpper(strings.TrimPrefix(labelName, allowedDockerLabelPrefix)) + if slices.Contains(methods, allowSpec) { + r, err := compileRegexp(labelValue, allowSpec, "docker container label") + if err != nil { + return nil, err + } + allowedRequests[allowSpec] = r + } else if allowSpec == "BINDMOUNTFROM" { + allowedBindMounts, err = parseAllowedBindMounts(labelValue) + if err != nil { + return nil, err + } + } + } + } + + if len(allowedRequests) > 0 { + allowList := AllowList{ + ID: cntr.ID, + AllowedRequests: allowedRequests, + AllowedBindMounts: allowedBindMounts, + } + + ipv4Address := cntr.NetworkSettings.Networks[network].IPAddress + if len(ipv4Address) > 0 { + allowListsByIP[ipv4Address] = &allowList + } + ipv6Address := cntr.NetworkSettings.Networks[network].GlobalIPv6Address + if len(ipv6Address) > 0 { + allowListsByIP[ipv6Address] = &allowList + } + } + } + } + + return allowListsByIP, nil +} From 5b313a3dab3286c3eda7d6dc253e473fd31277ff Mon Sep 17 00:00:00 2001 From: Amanda Wee Date: Sat, 4 Oct 2025 14:50:24 +1300 Subject: [PATCH 02/38] Add logging for allowlists --- cmd/socket-proxy/main.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cmd/socket-proxy/main.go b/cmd/socket-proxy/main.go index afad226..153785a 100644 --- a/cmd/socket-proxy/main.go +++ b/cmd/socket-proxy/main.go @@ -84,6 +84,12 @@ func main() { // enables the use of labels to specify per-container allowlists slog.Debug("no proxy container name provided") } + if len(cfg.ProxyContainerNetworks) > 0 { + slog.Info("Proxy container networks provided", "proxycontainernetworks", cfg.ProxyContainerNetworks) + } else { + // we only log this on DEBUG level because the proxy container networks are used for per-container allowlists + slog.Debug("no proxy container networks detected") + } // print request allowlist if cfg.LogJSON { @@ -97,6 +103,12 @@ func main() { for method, regex := range cfg.AllowLists.Default.AllowedRequests { fmt.Printf(" %-8s %s\n", method, regex) } + for ip, allowList := range cfg.AllowLists.ByIP { + fmt.Printf("Request allowlist for %s:\n %-8s %s\n", ip, "Method", "Regex") + for method, regex := range allowList.AllowedRequests { + fmt.Printf(" %-8s %s\n", method, regex) + } + } } // check if the socket is available From 74330ccf4fea3cc1aa515cf50846f977427342a0 Mon Sep 17 00:00:00 2001 From: Amanda Wee Date: Sat, 4 Oct 2025 15:04:39 +1300 Subject: [PATCH 03/38] Fix Docker label prefix typo --- internal/config/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/config/config.go b/internal/config/config.go index 5b965d4..9d84f11 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,7 +22,7 @@ import ( "github.com/docker/docker/client" ) -const allowedDockerLabelPrefix = "socket-proxy.allowed." +const allowedDockerLabelPrefix = "socket-proxy.allow." var ( defaultAllowFrom = "127.0.0.1/32" // allowed IPs to connect to the proxy From bb56d83cc9417dc068ee5ac48c1959ae976782ec Mon Sep 17 00:00:00 2001 From: Amanda Wee Date: Sat, 4 Oct 2025 17:40:14 +1300 Subject: [PATCH 04/38] Update allowlists by subscribing to Docker events stream --- internal/config/config.go | 154 +++++++++++++++++++++++++++++++++----- 1 file changed, 135 insertions(+), 19 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 9d84f11..363eb13 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,6 +18,7 @@ import ( "sync" "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/events" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/client" ) @@ -271,6 +272,8 @@ func InitConfig() (*Config, error) { if err != nil { return nil, err } + + go updateAllowLists(&cfg) } return &cfg, nil @@ -346,27 +349,12 @@ func readContainerLabelAllowLists(networks []string) (map[string]*AllowList, err } for _, cntr := range containers { - allowedRequests := make(map[string]*regexp.Regexp) - allowedBindMounts := []string{} - for labelName, labelValue := range cntr.Labels { - if strings.HasPrefix(labelName, allowedDockerLabelPrefix) && labelValue != "" { - allowSpec := strings.ToUpper(strings.TrimPrefix(labelName, allowedDockerLabelPrefix)) - if slices.Contains(methods, allowSpec) { - r, err := compileRegexp(labelValue, allowSpec, "docker container label") - if err != nil { - return nil, err - } - allowedRequests[allowSpec] = r - } else if allowSpec == "BINDMOUNTFROM" { - allowedBindMounts, err = parseAllowedBindMounts(labelValue) - if err != nil { - return nil, err - } - } - } + allowedRequests, allowedBindMounts, err := extractLabelData(cntr, methods) + if err != nil { + return nil, err } - if len(allowedRequests) > 0 { + if len(allowedRequests) > 0 || len(allowedBindMounts) > 0 { allowList := AllowList{ ID: cntr.ID, AllowedRequests: allowedRequests, @@ -387,3 +375,131 @@ func readContainerLabelAllowLists(networks []string) (map[string]*AllowList, err return allowListsByIP, nil } + +func extractLabelData(cntr container.Summary, methods []string) (map[string]*regexp.Regexp, []string, error) { + allowedRequests := make(map[string]*regexp.Regexp) + allowedBindMounts := []string{} + for labelName, labelValue := range cntr.Labels { + if strings.HasPrefix(labelName, allowedDockerLabelPrefix) && labelValue != "" { + allowSpec := strings.ToUpper(strings.TrimPrefix(labelName, allowedDockerLabelPrefix)) + if slices.Contains(methods, allowSpec) { + r, err := compileRegexp(labelValue, allowSpec, "docker container label") + if err != nil { + return nil, nil, err + } + allowedRequests[allowSpec] = r + } else if allowSpec == "BINDMOUNTFROM" { + var err error + allowedBindMounts, err = parseAllowedBindMounts(labelValue) + if err != nil { + return nil, nil, err + } + } + } + } + return allowedRequests, allowedBindMounts, nil +} + +func updateAllowLists(cfg *Config) { + dockerClient, err := client.NewClientWithOpts(client.FromEnv) + if err != nil { + slog.Error("failed to create Docker client", "error", err) + return + } + defer dockerClient.Close() + + ctx := context.Background() + filter := filters.NewArgs() + filter.Add("type", "container") + filter.Add("event", "start") + filter.Add("event", "die") + eventsChan, errChan := dockerClient.Events(ctx, events.ListOptions{Filters: filter}) + if err != nil { + slog.Error("failed to subscribe to Docker event stream", "error", err) + return + } + + select { + case event := <- eventsChan: + updateErr := updateAllowListsFromEvent(cfg, dockerClient, event) + if updateErr != nil { + slog.Error("error when updating allowlists", "error", updateErr) + return + } + case err := <- errChan: + slog.Error("received error from Docker event stream", "error", err) + return + } +} + +func updateAllowListsFromEvent(cfg *Config, dockerClient *client.Client, event events.Message) error { + containerID := event.Actor.ID + + switch event.Action { + case "start": + err := addAllowList(cfg, dockerClient, containerID) + if err != nil { + return err + } + case "die": + removeAllowList(cfg, containerID) + } + return nil +} + +func addAllowList(cfg *Config, dockerClient *client.Client, containerID string) error { + cfg.AllowLists.Mutex.Lock() + defer cfg.AllowLists.Mutex.Unlock() + + var methods []string + for _, rx := range mr { + methods = append(methods, rx.method) + } + + filter := filters.NewArgs() + filter.Add("id", containerID) + containers, err := dockerClient.ContainerList(context.Background(), container.ListOptions{Filters: filter}) + if err != nil { + return err + } + if len(containers) == 0 { + return fmt.Errorf("newly started container ID \"%s\" was not found", containerID) + } + cntr := containers[0] + + allowedRequests, allowedBindMounts, err := extractLabelData(cntr, methods) + + if len(allowedRequests) > 0 || len(allowedBindMounts) > 0 { + allowList := AllowList{ + ID: cntr.ID, + AllowedRequests: allowedRequests, + AllowedBindMounts: allowedBindMounts, + } + + for networkID, cntrNetwork := range cntr.NetworkSettings.Networks { + if slices.Contains(cfg.ProxyContainerNetworks, networkID) { + ipv4Address := cntrNetwork.IPAddress + if len(ipv4Address) > 0 { + cfg.AllowLists.ByIP[ipv4Address] = &allowList + } + ipv6Address := cntrNetwork.GlobalIPv6Address + if len(ipv6Address) > 0 { + cfg.AllowLists.ByIP[ipv6Address] = &allowList + } + } + } + } + + return nil +} + +func removeAllowList(cfg *Config, containerID string) { + cfg.AllowLists.Mutex.Lock() + defer cfg.AllowLists.Mutex.Unlock() + + for ip, allowList := range cfg.AllowLists.ByIP { + if allowList.ID == containerID { + delete(cfg.AllowLists.ByIP, ip) + } + } +} From 7d5261e583b2fcd85d7a0daae27eb20d4d9ffb7f Mon Sep 17 00:00:00 2001 From: Amanda Wee Date: Sun, 5 Oct 2025 16:57:37 +1300 Subject: [PATCH 05/38] Move non-default allowlist setup to after logger setup --- cmd/socket-proxy/main.go | 24 +---- internal/config/config.go | 198 +++++++++++++++++++++++++------------- 2 files changed, 134 insertions(+), 88 deletions(-) diff --git a/cmd/socket-proxy/main.go b/cmd/socket-proxy/main.go index 153785a..90ce6ed 100644 --- a/cmd/socket-proxy/main.go +++ b/cmd/socket-proxy/main.go @@ -56,6 +56,9 @@ func main() { } slog.SetDefault(logger) + // setup non-default allowlists + go cfg.UpdateAllowLists() + // print configuration slog.Info("starting socket-proxy", "version", version, "os", runtime.GOOS, "arch", runtime.GOARCH, "runtime", runtime.Version(), "URL", programURL) if cfg.ProxySocketEndpoint == "" { @@ -91,25 +94,8 @@ func main() { slog.Debug("no proxy container networks detected") } - // print request allowlist - if cfg.LogJSON { - for method, regex := range cfg.AllowLists.Default.AllowedRequests { - slog.Info("configured allowed request", "method", method, "regex", regex) - } - } else { - // don't use slog here, as we want to print the regexes as they are - // see https://github.com/wollomatic/socket-proxy/issues/11 - fmt.Printf("Request allowlist:\n %-8s %s\n", "Method", "Regex") - for method, regex := range cfg.AllowLists.Default.AllowedRequests { - fmt.Printf(" %-8s %s\n", method, regex) - } - for ip, allowList := range cfg.AllowLists.ByIP { - fmt.Printf("Request allowlist for %s:\n %-8s %s\n", ip, "Method", "Regex") - for method, regex := range allowList.AllowedRequests { - fmt.Printf(" %-8s %s\n", method, regex) - } - } - } + // print default request allowlist + cfg.AllowLists.PrintDefault(cfg.LogJSON) // check if the socket is available err = checkSocketAvailability(cfg.SocketPath) diff --git a/internal/config/config.go b/internal/config/config.go index 363eb13..ac6b21b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -43,15 +43,15 @@ var ( ) type AllowList struct { - ID string // Container ID (empty for the default allow-list) + ID string // Container ID (empty for the default allowlist) AllowedRequests map[string]*regexp.Regexp // map of request methods to request path regex patterns (no requests allowed if empty) AllowedBindMounts []string // list of from portion of allowed bind mounts (all bind mounts allowed if empty) } type AllowListRegistry struct { - Default *AllowList - ByIP map[string]*AllowList - Mutex sync.RWMutex + Default *AllowList // default allowlist + ByIP map[string]*AllowList // map container IP address to allowlist for that container + Mutex sync.RWMutex // mutex to control read/write of ByIP } type Config struct { @@ -268,17 +268,64 @@ func InitConfig() (*Config, error) { if err != nil { return nil, err } - cfg.AllowLists.ByIP, err = readContainerLabelAllowLists(cfg.ProxyContainerNetworks) - if err != nil { - return nil, err - } - - go updateAllowLists(&cfg) } return &cfg, nil } +// Populate the ByIP allowlists then keep them updated +func (cfg *Config) UpdateAllowLists() { + dockerClient, err := client.NewClientWithOpts(client.FromEnv) + if err != nil { + slog.Error("failed to create Docker client", "error", err) + return + } + defer dockerClient.Close() + + allowLists, err := initByIPAllowLists(dockerClient, cfg.ProxyContainerNetworks) + if err != nil { + slog.Error("failed to initialise non-default allowlists", "error", err) + return + } + cfg.AllowLists.Mutex.Lock() + cfg.AllowLists.ByIP = allowLists + cfg.AllowLists.Mutex.Unlock() + slog.Debug("initialised non-default allowlists") + // print non-default request allowlists + cfg.AllowLists.PrintByIP(cfg.LogJSON) + + ctx := context.Background() + filter := filters.NewArgs() + filter.Add("type", "container") + filter.Add("event", "start") + filter.Add("event", "die") + eventsChan, errChan := dockerClient.Events(ctx, events.ListOptions{Filters: filter}) + if err != nil { + slog.Error("failed to subscribe to Docker event stream", "error", err) + return + } + slog.Debug("subscribed to Docker event stream to update allowlists") + + select { + case event := <- eventsChan: + addedIPs, updateErr := cfg.AllowLists.updateFromEvent(dockerClient, event, cfg.ProxyContainerNetworks) + if updateErr != nil { + slog.Error("error when updating allowlists", "error", updateErr) + return + } + if len(addedIPs) > 0 { + cfg.AllowLists.Mutex.RLock() + for _, ip := range addedIPs { + cfg.AllowLists.ByIP[ip].Print(ip, cfg.LogJSON) + } + cfg.AllowLists.Mutex.RUnlock() + } + case err := <- errChan: + slog.Error("received error from Docker event stream", "error", err) + return + } +} + func compileRegexp(regex, method, configLocation string) (*regexp.Regexp, error) { r, err := regexp.Compile("^" + regex + "$") if err != nil { @@ -301,6 +348,7 @@ func parseAllowedBindMounts(allowBindMountFromString string) ([]string, error) { // return list of docker networks that the socket-proxy container is in func readProxyContainerNetworks(proxyContainerName string) ([]string, error) { var networks []string + dockerClient, err := client.NewClientWithOpts(client.FromEnv) if err != nil { return nil, err @@ -324,16 +372,10 @@ func readProxyContainerNetworks(proxyContainerName string) ([]string, error) { return networks, nil } -// return AllowListRegistry with allowlists specified by docker container labels -func readContainerLabelAllowLists(networks []string) (map[string]*AllowList, error) { +// return initial ByIP allowlists +func initByIPAllowLists(dockerClient *client.Client, networks []string) (map[string]*AllowList, error) { allowListsByIP := make(map[string]*AllowList) - dockerClient, err := client.NewClientWithOpts(client.FromEnv) - if err != nil { - return nil, err - } - defer dockerClient.Close() - var methods []string for _, rx := range mr { methods = append(methods, rx.method) @@ -376,6 +418,7 @@ func readContainerLabelAllowLists(networks []string) (map[string]*AllowList, err return allowListsByIP, nil } +// extract Docker container allowlist label data from the container summary func extractLabelData(cntr container.Summary, methods []string) (map[string]*regexp.Regexp, []string, error) { allowedRequests := make(map[string]*regexp.Regexp) allowedBindMounts := []string{} @@ -400,56 +443,32 @@ func extractLabelData(cntr container.Summary, methods []string) (map[string]*reg return allowedRequests, allowedBindMounts, nil } -func updateAllowLists(cfg *Config) { - dockerClient, err := client.NewClientWithOpts(client.FromEnv) - if err != nil { - slog.Error("failed to create Docker client", "error", err) - return - } - defer dockerClient.Close() - - ctx := context.Background() - filter := filters.NewArgs() - filter.Add("type", "container") - filter.Add("event", "start") - filter.Add("event", "die") - eventsChan, errChan := dockerClient.Events(ctx, events.ListOptions{Filters: filter}) - if err != nil { - slog.Error("failed to subscribe to Docker event stream", "error", err) - return - } - - select { - case event := <- eventsChan: - updateErr := updateAllowListsFromEvent(cfg, dockerClient, event) - if updateErr != nil { - slog.Error("error when updating allowlists", "error", updateErr) - return - } - case err := <- errChan: - slog.Error("received error from Docker event stream", "error", err) - return - } -} - -func updateAllowListsFromEvent(cfg *Config, dockerClient *client.Client, event events.Message) error { +// update the allowlist registry based on the Docker event +func (allowLists *AllowListRegistry) updateFromEvent( + dockerClient *client.Client, event events.Message, socketProxyNetworks []string, +) ([]string, error) { containerID := event.Actor.ID switch event.Action { case "start": - err := addAllowList(cfg, dockerClient, containerID) + addedIPs, err := allowLists.add(dockerClient, containerID, socketProxyNetworks) if err != nil { - return err + return nil, err } + return addedIPs, nil case "die": - removeAllowList(cfg, containerID) + allowLists.remove(containerID) } - return nil + return nil, nil } -func addAllowList(cfg *Config, dockerClient *client.Client, containerID string) error { - cfg.AllowLists.Mutex.Lock() - defer cfg.AllowLists.Mutex.Unlock() +// add the allowlist for the container with the given ID to the allowlist registry +// if it has at least one socket-proxy allow label and is in a same network as the socket-proxy +func (allowLists *AllowListRegistry) add( + dockerClient *client.Client, containerID string, socketProxyNetworks []string, +) ([]string, error) { + allowLists.Mutex.Lock() + defer allowLists.Mutex.Unlock() var methods []string for _, rx := range mr { @@ -460,15 +479,16 @@ func addAllowList(cfg *Config, dockerClient *client.Client, containerID string) filter.Add("id", containerID) containers, err := dockerClient.ContainerList(context.Background(), container.ListOptions{Filters: filter}) if err != nil { - return err + return nil, err } if len(containers) == 0 { - return fmt.Errorf("newly started container ID \"%s\" was not found", containerID) + return nil, fmt.Errorf("newly started container ID \"%s\" was not found", containerID) } cntr := containers[0] allowedRequests, allowedBindMounts, err := extractLabelData(cntr, methods) + var ips []string if len(allowedRequests) > 0 || len(allowedBindMounts) > 0 { allowList := AllowList{ ID: cntr.ID, @@ -477,29 +497,69 @@ func addAllowList(cfg *Config, dockerClient *client.Client, containerID string) } for networkID, cntrNetwork := range cntr.NetworkSettings.Networks { - if slices.Contains(cfg.ProxyContainerNetworks, networkID) { + if slices.Contains(socketProxyNetworks, networkID) { ipv4Address := cntrNetwork.IPAddress if len(ipv4Address) > 0 { - cfg.AllowLists.ByIP[ipv4Address] = &allowList + allowLists.ByIP[ipv4Address] = &allowList + ips = append(ips, ipv4Address) } ipv6Address := cntrNetwork.GlobalIPv6Address if len(ipv6Address) > 0 { - cfg.AllowLists.ByIP[ipv6Address] = &allowList + allowLists.ByIP[ipv6Address] = &allowList + ips = append(ips, ipv6Address) } } } } - return nil + return ips, nil } -func removeAllowList(cfg *Config, containerID string) { - cfg.AllowLists.Mutex.Lock() - defer cfg.AllowLists.Mutex.Unlock() +// remove allowlists having the given container ID from the allowlist registry +func (allowLists *AllowListRegistry) remove(containerID string) { + allowLists.Mutex.Lock() + defer allowLists.Mutex.Unlock() - for ip, allowList := range cfg.AllowLists.ByIP { + for ip, allowList := range allowLists.ByIP { if allowList.ID == containerID { - delete(cfg.AllowLists.ByIP, ip) + delete(allowLists.ByIP, ip) + } + } +} + +func (allowLists *AllowListRegistry) PrintDefault(logJSON bool) { + allowLists.Default.Print("", logJSON) +} + +func (allowLists *AllowListRegistry) PrintByIP(logJSON bool) { + allowLists.Mutex.RLock() + defer allowLists.Mutex.RUnlock() + for ip, allowList := range allowLists.ByIP { + allowList.Print(ip, logJSON) + } +} + +func (allowList *AllowList) Print(ip string, logJSON bool) { + if logJSON { + if ip == "" { + for method, regex := range allowList.AllowedRequests { + slog.Info("configured default request allowlist", "method", method, "regex", regex) + } + } else { + for method, regex := range allowList.AllowedRequests { + slog.Info("configured request allowlist", "ip", ip, "method", method, "regex", regex) + } + } + } else { + // don't use slog here, as we want to print the regexes as they are + // see https://github.com/wollomatic/socket-proxy/issues/11 + if ip == "" { + fmt.Printf("Default request allowlist:\n %-8s %s\n", "Method", "Regex") + } else { + fmt.Printf("Request allowlist for %s:\n %-8s %s\n", ip, "Method", "Regex") + } + for method, regex := range allowList.AllowedRequests { + fmt.Printf(" %-8s %s\n", method, regex) } } } From 4b8fe0e523401ed447c6e04f79711be0c307269b Mon Sep 17 00:00:00 2001 From: Amanda Wee Date: Sun, 5 Oct 2025 18:53:01 +1300 Subject: [PATCH 06/38] Refactor proxy container networks to socket proxy networks in allowlist registry --- cmd/socket-proxy/main.go | 8 +++--- internal/config/config.go | 55 ++++++++++++++++++++------------------- 2 files changed, 32 insertions(+), 31 deletions(-) diff --git a/cmd/socket-proxy/main.go b/cmd/socket-proxy/main.go index 90ce6ed..7b4996a 100644 --- a/cmd/socket-proxy/main.go +++ b/cmd/socket-proxy/main.go @@ -87,11 +87,11 @@ func main() { // enables the use of labels to specify per-container allowlists slog.Debug("no proxy container name provided") } - if len(cfg.ProxyContainerNetworks) > 0 { - slog.Info("Proxy container networks provided", "proxycontainernetworks", cfg.ProxyContainerNetworks) + if len(cfg.AllowLists.Networks) > 0 { + slog.Info("socket proxy networks detected", "socketproxynetworks", cfg.AllowLists.Networks) } else { - // we only log this on DEBUG level because the proxy container networks are used for per-container allowlists - slog.Debug("no proxy container networks detected") + // we only log this on DEBUG level because the socket proxy networks are used for per-container allowlists + slog.Debug("no socket proxy networks detected") } // print default request allowlist diff --git a/internal/config/config.go b/internal/config/config.go index ac6b21b..2afbd6d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -49,8 +49,9 @@ type AllowList struct { } type AllowListRegistry struct { - Default *AllowList // default allowlist + Default *AllowList // default allowlist ByIP map[string]*AllowList // map container IP address to allowlist for that container + Networks []string // names of networks in which socket proxy access is allowed for non-default allowlists Mutex sync.RWMutex // mutex to control read/write of ByIP } @@ -68,7 +69,6 @@ type Config struct { ProxySocketEndpoint string ProxySocketEndpointFileMode os.FileMode ProxyContainerName string - ProxyContainerNetworks []string } // used for list of allowed requests @@ -259,17 +259,18 @@ func InitConfig() (*Config, error) { defaultAllowList.AllowedRequests[rx.method] = r } } + allowLists.Default = &defaultAllowList - cfg.AllowLists = &allowLists - cfg.AllowLists.Default = &defaultAllowList if cfg.ProxySocketEndpoint == "" && cfg.ProxyContainerName != "" { var err error - cfg.ProxyContainerNetworks, err = readProxyContainerNetworks(cfg.ProxyContainerName) + allowLists.Networks, err = listSocketProxyNetworks(cfg.ProxyContainerName) if err != nil { return nil, err } } + cfg.AllowLists = &allowLists + return &cfg, nil } @@ -282,14 +283,11 @@ func (cfg *Config) UpdateAllowLists() { } defer dockerClient.Close() - allowLists, err := initByIPAllowLists(dockerClient, cfg.ProxyContainerNetworks) + err = cfg.AllowLists.initByIP(dockerClient) if err != nil { slog.Error("failed to initialise non-default allowlists", "error", err) return } - cfg.AllowLists.Mutex.Lock() - cfg.AllowLists.ByIP = allowLists - cfg.AllowLists.Mutex.Unlock() slog.Debug("initialised non-default allowlists") // print non-default request allowlists cfg.AllowLists.PrintByIP(cfg.LogJSON) @@ -308,7 +306,7 @@ func (cfg *Config) UpdateAllowLists() { select { case event := <- eventsChan: - addedIPs, updateErr := cfg.AllowLists.updateFromEvent(dockerClient, event, cfg.ProxyContainerNetworks) + addedIPs, updateErr := cfg.AllowLists.updateFromEvent(dockerClient, event) if updateErr != nil { slog.Error("error when updating allowlists", "error", updateErr) return @@ -346,7 +344,7 @@ func parseAllowedBindMounts(allowBindMountFromString string) ([]string, error) { } // return list of docker networks that the socket-proxy container is in -func readProxyContainerNetworks(proxyContainerName string) ([]string, error) { +func listSocketProxyNetworks(proxyContainerName string) ([]string, error) { var networks []string dockerClient, err := client.NewClientWithOpts(client.FromEnv) @@ -372,28 +370,33 @@ func readProxyContainerNetworks(proxyContainerName string) ([]string, error) { return networks, nil } -// return initial ByIP allowlists -func initByIPAllowLists(dockerClient *client.Client, networks []string) (map[string]*AllowList, error) { - allowListsByIP := make(map[string]*AllowList) - +// initialise allowlist registry ByIP allowlists +func (allowLists *AllowListRegistry) initByIP(dockerClient *client.Client) error { var methods []string for _, rx := range mr { methods = append(methods, rx.method) } + allowLists.Mutex.Lock() + defer allowLists.Mutex.Unlock() + + allowLists.ByIP = make(map[string]*AllowList) + ctx := context.Background() - for _, network := range networks { + for _, network := range allowLists.Networks { filter := filters.NewArgs() filter.Add("network", network) containers, err := dockerClient.ContainerList(ctx, container.ListOptions{Filters: filter}) if err != nil { - return nil, err + allowLists.ByIP = nil + return err } for _, cntr := range containers { allowedRequests, allowedBindMounts, err := extractLabelData(cntr, methods) if err != nil { - return nil, err + allowLists.ByIP = nil + return err } if len(allowedRequests) > 0 || len(allowedBindMounts) > 0 { @@ -405,17 +408,17 @@ func initByIPAllowLists(dockerClient *client.Client, networks []string) (map[str ipv4Address := cntr.NetworkSettings.Networks[network].IPAddress if len(ipv4Address) > 0 { - allowListsByIP[ipv4Address] = &allowList + allowLists.ByIP[ipv4Address] = &allowList } ipv6Address := cntr.NetworkSettings.Networks[network].GlobalIPv6Address if len(ipv6Address) > 0 { - allowListsByIP[ipv6Address] = &allowList + allowLists.ByIP[ipv6Address] = &allowList } } } } - return allowListsByIP, nil + return nil } // extract Docker container allowlist label data from the container summary @@ -445,13 +448,13 @@ func extractLabelData(cntr container.Summary, methods []string) (map[string]*reg // update the allowlist registry based on the Docker event func (allowLists *AllowListRegistry) updateFromEvent( - dockerClient *client.Client, event events.Message, socketProxyNetworks []string, + dockerClient *client.Client, event events.Message, ) ([]string, error) { containerID := event.Actor.ID switch event.Action { case "start": - addedIPs, err := allowLists.add(dockerClient, containerID, socketProxyNetworks) + addedIPs, err := allowLists.add(dockerClient, containerID) if err != nil { return nil, err } @@ -464,9 +467,7 @@ func (allowLists *AllowListRegistry) updateFromEvent( // add the allowlist for the container with the given ID to the allowlist registry // if it has at least one socket-proxy allow label and is in a same network as the socket-proxy -func (allowLists *AllowListRegistry) add( - dockerClient *client.Client, containerID string, socketProxyNetworks []string, -) ([]string, error) { +func (allowLists *AllowListRegistry) add(dockerClient *client.Client, containerID string) ([]string, error) { allowLists.Mutex.Lock() defer allowLists.Mutex.Unlock() @@ -497,7 +498,7 @@ func (allowLists *AllowListRegistry) add( } for networkID, cntrNetwork := range cntr.NetworkSettings.Networks { - if slices.Contains(socketProxyNetworks, networkID) { + if slices.Contains(allowLists.Networks, networkID) { ipv4Address := cntrNetwork.IPAddress if len(ipv4Address) > 0 { allowLists.ByIP[ipv4Address] = &allowList From b5c75c6928874942634320acadc01f8db1802410 Mon Sep 17 00:00:00 2001 From: Amanda Wee Date: Sun, 5 Oct 2025 19:17:54 +1300 Subject: [PATCH 07/38] Refactor and comment config functions --- internal/config/config.go | 188 +++++++++++++++++++------------------- 1 file changed, 96 insertions(+), 92 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 2afbd6d..5cf395c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -42,19 +42,6 @@ var ( defaultProxyContainerName = "" // socket-proxy Docker container name (empty string disables container labels for allowlists) ) -type AllowList struct { - ID string // Container ID (empty for the default allowlist) - AllowedRequests map[string]*regexp.Regexp // map of request methods to request path regex patterns (no requests allowed if empty) - AllowedBindMounts []string // list of from portion of allowed bind mounts (all bind mounts allowed if empty) -} - -type AllowListRegistry struct { - Default *AllowList // default allowlist - ByIP map[string]*AllowList // map container IP address to allowlist for that container - Networks []string // names of networks in which socket proxy access is allowed for non-default allowlists - Mutex sync.RWMutex // mutex to control read/write of ByIP -} - type Config struct { AllowLists *AllowListRegistry AllowFrom []string @@ -71,6 +58,19 @@ type Config struct { ProxyContainerName string } +type AllowListRegistry struct { + Default *AllowList // default allowlist + ByIP map[string]*AllowList // map container IP address to allowlist for that container + Networks []string // names of networks in which socket proxy access is allowed for non-default allowlists + Mutex sync.RWMutex // mutex to control read/write of ByIP +} + +type AllowList struct { + ID string // Container ID (empty for the default allowlist) + AllowedRequests map[string]*regexp.Regexp // map of request methods to request path regex patterns (no requests allowed if empty) + AllowedBindMounts []string // list of from portion of allowed bind mounts (all bind mounts allowed if empty) +} + // used for list of allowed requests type methodRegex struct { method string @@ -274,7 +274,7 @@ func InitConfig() (*Config, error) { return &cfg, nil } -// Populate the ByIP allowlists then keep them updated +// populate the ByIP allowlists then keep them updated func (cfg *Config) UpdateAllowLists() { dockerClient, err := client.NewClientWithOpts(client.FromEnv) if err != nil { @@ -324,50 +324,18 @@ func (cfg *Config) UpdateAllowLists() { } } -func compileRegexp(regex, method, configLocation string) (*regexp.Regexp, error) { - r, err := regexp.Compile("^" + regex + "$") - if err != nil { - return nil, fmt.Errorf("invalid regex \"%s\" for method %s in %s: %w", regex, method, configLocation, err) - } - return r, nil -} - -func parseAllowedBindMounts(allowBindMountFromString string) ([]string, error) { - allowedBindMounts := strings.Split(allowBindMountFromString, ",") - for i, dir := range allowedBindMounts { - if !strings.HasPrefix(dir, "/") { - return nil, fmt.Errorf("bind mount directory must start with /: %q", dir) - } - allowedBindMounts[i] = filepath.Clean(dir) - } - return allowedBindMounts, nil +// print the default allowlist +func (allowLists *AllowListRegistry) PrintDefault(logJSON bool) { + allowLists.Default.Print("", logJSON) } -// return list of docker networks that the socket-proxy container is in -func listSocketProxyNetworks(proxyContainerName string) ([]string, error) { - var networks []string - - dockerClient, err := client.NewClientWithOpts(client.FromEnv) - if err != nil { - return nil, err - } - defer dockerClient.Close() - - filter := filters.NewArgs() - filter.Add("name", proxyContainerName) - containers, err := dockerClient.ContainerList(context.Background(), container.ListOptions{Filters: filter}) - if err != nil { - return nil, err - } - if len(containers) == 0 { - return nil, fmt.Errorf("socket-proxy container \"%s\" was not found", proxyContainerName) - } - - for networkID, _ := range containers[0].NetworkSettings.Networks { - networks = append(networks, networkID) +// print the non-default allowlists +func (allowLists *AllowListRegistry) PrintByIP(logJSON bool) { + allowLists.Mutex.RLock() + defer allowLists.Mutex.RUnlock() + for ip, allowList := range allowLists.ByIP { + allowList.Print(ip, logJSON) } - - return networks, nil } // initialise allowlist registry ByIP allowlists @@ -421,31 +389,6 @@ func (allowLists *AllowListRegistry) initByIP(dockerClient *client.Client) error return nil } -// extract Docker container allowlist label data from the container summary -func extractLabelData(cntr container.Summary, methods []string) (map[string]*regexp.Regexp, []string, error) { - allowedRequests := make(map[string]*regexp.Regexp) - allowedBindMounts := []string{} - for labelName, labelValue := range cntr.Labels { - if strings.HasPrefix(labelName, allowedDockerLabelPrefix) && labelValue != "" { - allowSpec := strings.ToUpper(strings.TrimPrefix(labelName, allowedDockerLabelPrefix)) - if slices.Contains(methods, allowSpec) { - r, err := compileRegexp(labelValue, allowSpec, "docker container label") - if err != nil { - return nil, nil, err - } - allowedRequests[allowSpec] = r - } else if allowSpec == "BINDMOUNTFROM" { - var err error - allowedBindMounts, err = parseAllowedBindMounts(labelValue) - if err != nil { - return nil, nil, err - } - } - } - } - return allowedRequests, allowedBindMounts, nil -} - // update the allowlist registry based on the Docker event func (allowLists *AllowListRegistry) updateFromEvent( dockerClient *client.Client, event events.Message, @@ -528,18 +471,8 @@ func (allowLists *AllowListRegistry) remove(containerID string) { } } -func (allowLists *AllowListRegistry) PrintDefault(logJSON bool) { - allowLists.Default.Print("", logJSON) -} - -func (allowLists *AllowListRegistry) PrintByIP(logJSON bool) { - allowLists.Mutex.RLock() - defer allowLists.Mutex.RUnlock() - for ip, allowList := range allowLists.ByIP { - allowList.Print(ip, logJSON) - } -} - +// print the allowlist, including the IP address of the associated container if it is not empty, +// and in JSON format if logJSON is true func (allowList *AllowList) Print(ip string, logJSON bool) { if logJSON { if ip == "" { @@ -564,3 +497,74 @@ func (allowList *AllowList) Print(ip string, logJSON bool) { } } } + +// compile allowed requests regex pattern +func compileRegexp(regex, method, configLocation string) (*regexp.Regexp, error) { + r, err := regexp.Compile("^" + regex + "$") + if err != nil { + return nil, fmt.Errorf("invalid regex \"%s\" for method %s in %s: %w", regex, method, configLocation, err) + } + return r, nil +} + +// parse bind mount from string into list of allowed bind mounts +func parseAllowedBindMounts(allowBindMountFromString string) ([]string, error) { + allowedBindMounts := strings.Split(allowBindMountFromString, ",") + for i, dir := range allowedBindMounts { + if !strings.HasPrefix(dir, "/") { + return nil, fmt.Errorf("bind mount directory must start with /: %q", dir) + } + allowedBindMounts[i] = filepath.Clean(dir) + } + return allowedBindMounts, nil +} + +// return list of docker networks that the socket-proxy container is in +func listSocketProxyNetworks(proxyContainerName string) ([]string, error) { + dockerClient, err := client.NewClientWithOpts(client.FromEnv) + if err != nil { + return nil, err + } + defer dockerClient.Close() + + filter := filters.NewArgs() + filter.Add("name", proxyContainerName) + containers, err := dockerClient.ContainerList(context.Background(), container.ListOptions{Filters: filter}) + if err != nil { + return nil, err + } + if len(containers) == 0 { + return nil, fmt.Errorf("socket-proxy container \"%s\" was not found", proxyContainerName) + } + + var networks []string + for networkID, _ := range containers[0].NetworkSettings.Networks { + networks = append(networks, networkID) + } + return networks, nil +} + +// extract Docker container allowlist label data from the container summary +func extractLabelData(cntr container.Summary, methods []string) (map[string]*regexp.Regexp, []string, error) { + allowedRequests := make(map[string]*regexp.Regexp) + allowedBindMounts := []string{} + for labelName, labelValue := range cntr.Labels { + if strings.HasPrefix(labelName, allowedDockerLabelPrefix) && labelValue != "" { + allowSpec := strings.ToUpper(strings.TrimPrefix(labelName, allowedDockerLabelPrefix)) + if slices.Contains(methods, allowSpec) { + r, err := compileRegexp(labelValue, allowSpec, "docker container label") + if err != nil { + return nil, nil, err + } + allowedRequests[allowSpec] = r + } else if allowSpec == "BINDMOUNTFROM" { + var err error + allowedBindMounts, err = parseAllowedBindMounts(labelValue) + if err != nil { + return nil, nil, err + } + } + } + } + return allowedRequests, allowedBindMounts, nil +} From 7840bb3426740cf91a4425fe30f6de57ecab1a07 Mon Sep 17 00:00:00 2001 From: Amanda Wee Date: Sun, 5 Oct 2025 19:57:14 +1300 Subject: [PATCH 08/38] Update readme for per-container allowlists --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index 2449118..0565069 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,29 @@ Bind mount restrictions are applied to relevant Docker API endpoints and work wi **Note**: This feature only restricts bind mounts. Other mount types (volumes, tmpfs, etc.) are not affected by this restriction. +#### Setting up per-container allowlists + +Allowlists for both requests and bind mount restrictions can be specified for particular containers. To do this: + +1. Set `-proxycontainername` or the environment variable `SP_PROXYCONTAINERNAME` to the name of the socket proxy container. +2. Make sure that each container that will use the socket proxy is in a Docker network that the socket proxy container is also in. +3. Use the same regex syntax for request allowlists and for bind mount restrictions that were discussed earlier, but for labels on each container that will use the socket proxy. Each label name will have the prefix of `socket-proxy.allow.`. For example: + +``` compose.yaml +services: + traefik: + # [...] see github.com/wollomatic/traefik-hardened for a full example + depends_on: + - dockerproxy + networks: + - traefik-servicenet # this is the common traefik network + - docker-proxynet # this should be only restricted to traefik and socket-proxy + labels: + - 'socket-proxy.allow.get=.*' # allow all GET requests to the socket poroxy +``` + +When this is used, it is not necessary to specify the container in `-allowfrom` as the presence of the allowlist labels will grant corresponding access. + ### Container health check Health checks are disabled by default. As the socket-proxy container may not be exposed to a public network, a separate health check binary is included in the container image. To activate the health check, the `-allowhealthcheck` parameter or the environment variable `SP_ALLOWHEALTHCHECK=true` must be set. Then, a health check is possible for example with the following docker-compose snippet: @@ -212,6 +235,7 @@ socket-proxy can be configured via command line parameters or via environment va | `-watchdoginterval` | `SP_WATCHDOGINTERVAL` | `0` | Check for socket availability every x seconds (disable checks, if not set or value is 0) | | `-proxysocketendpoint` | `SP_PROXYSOCKETENDPOINT` | (not set) | Proxy to the given unix socket instead of a TCP port | | `-proxysocketendpointfilemode` | `SP_PROXYSOCKETENDPOINTFILEMODE` | `0600` | Explicitly set the file mode for the filtered unix socket endpoint (only useful with `-proxysocketendpoint`) | +| `-proxycontainername` | `SP_PROXYCONTAINERNAME ` | (not set) | Provides the name of the socket proxy container to enable per-container allowlists specified by Docker container labels (not available with `-proxysocketendpoint`) | ### Changelog From 0f7ffd66b18439d39253e95613860db1ffcd498d Mon Sep 17 00:00:00 2001 From: Amanda Wee Date: Sun, 5 Oct 2025 20:57:34 +1300 Subject: [PATCH 09/38] Add more documentation for per-container allowlists --- README.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0565069..7956001 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ As an additional benefit, socket-proxy can be used to examine the API calls of t The advantage over other solutions is the very slim container image (from-scratch-image) without any external dependencies (no OS, no packages, just the Go standard library). It is designed with security in mind, so there are secure defaults and an additional security layer (IP address-based access control) compared to most other solutions. -The allowlist is configured for each HTTP method separately using the Go regexp syntax, allowing fine-grained control over the allowed HTTP methods. +The allowlist is configured for each HTTP method separately using the Go regexp syntax, allowing fine-grained control over the allowed HTTP methods. In bridge network mode, each container that uses socket-proxy can be configured with its own allowlist. The source code is available on [GitHub: wollomatic/socket-proxy](https://github.com/wollomatic/socket-proxy) @@ -116,19 +116,17 @@ Allowlists for both requests and bind mount restrictions can be specified for pa 1. Set `-proxycontainername` or the environment variable `SP_PROXYCONTAINERNAME` to the name of the socket proxy container. 2. Make sure that each container that will use the socket proxy is in a Docker network that the socket proxy container is also in. -3. Use the same regex syntax for request allowlists and for bind mount restrictions that were discussed earlier, but for labels on each container that will use the socket proxy. Each label name will have the prefix of `socket-proxy.allow.`. For example: +3. Use the same regex syntax for request allowlists and for bind mount restrictions that were discussed earlier, but for labels on each container that will use the socket proxy. Each label name will have the prefix of `socket-proxy.allow.`, with `socket-proxy.allow.bindmountfrom` for bind mount restrictions. For example: ``` compose.yaml services: traefik: # [...] see github.com/wollomatic/traefik-hardened for a full example - depends_on: - - dockerproxy networks: - traefik-servicenet # this is the common traefik network - docker-proxynet # this should be only restricted to traefik and socket-proxy labels: - - 'socket-proxy.allow.get=.*' # allow all GET requests to the socket poroxy + - 'socket-proxy.allow.get=.*' # allow all GET requests to socket-proxy ``` When this is used, it is not necessary to specify the container in `-allowfrom` as the presence of the allowlist labels will grant corresponding access. From d1d7b9fc0403de39abf0fe0af6c7478580594ed2 Mon Sep 17 00:00:00 2001 From: Amanda Wee Date: Mon, 6 Oct 2025 17:14:47 +1300 Subject: [PATCH 10/38] Only setup non-default allowlists for TCP proxy and when socket proxy container name is provided --- cmd/socket-proxy/main.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/socket-proxy/main.go b/cmd/socket-proxy/main.go index 7b4996a..59dabee 100644 --- a/cmd/socket-proxy/main.go +++ b/cmd/socket-proxy/main.go @@ -57,7 +57,9 @@ func main() { slog.SetDefault(logger) // setup non-default allowlists - go cfg.UpdateAllowLists() + if cfg.ProxySocketEndpoint == "" && cfg.ProxyContainerName != "" { + go cfg.UpdateAllowLists() + } // print configuration slog.Info("starting socket-proxy", "version", version, "os", runtime.GOOS, "arch", runtime.GOARCH, "runtime", runtime.Version(), "URL", programURL) From ed03073c8805f6723d7e7a9561e38a74ffcd4e64 Mon Sep 17 00:00:00 2001 From: Amanda Wee Date: Mon, 6 Oct 2025 19:02:17 +1300 Subject: [PATCH 11/38] Handle mutex more carefully --- cmd/socket-proxy/handlehttprequest.go | 5 +-- internal/config/config.go | 62 +++++++++++++++------------ 2 files changed, 36 insertions(+), 31 deletions(-) diff --git a/cmd/socket-proxy/handlehttprequest.go b/cmd/socket-proxy/handlehttprequest.go index 3c61348..82c60f2 100644 --- a/cmd/socket-proxy/handlehttprequest.go +++ b/cmd/socket-proxy/handlehttprequest.go @@ -71,10 +71,7 @@ func checkForAllowListByIP(w http.ResponseWriter, r *http.Request) *config.Allow return nil } - cfg.AllowLists.Mutex.RLock() - defer cfg.AllowLists.Mutex.RUnlock() - - allowList, found := cfg.AllowLists.ByIP[clientIPStr] + allowList, found := cfg.AllowLists.FindByIP(clientIPStr) if !found { return nil } diff --git a/internal/config/config.go b/internal/config/config.go index 5cf395c..c35ca89 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -59,10 +59,10 @@ type Config struct { } type AllowListRegistry struct { - Default *AllowList // default allowlist - ByIP map[string]*AllowList // map container IP address to allowlist for that container + mutex sync.RWMutex // mutex to control read/write of byIP Networks []string // names of networks in which socket proxy access is allowed for non-default allowlists - Mutex sync.RWMutex // mutex to control read/write of ByIP + Default *AllowList // default allowlist + byIP map[string]*AllowList // map container IP address to allowlist for that container } type AllowList struct { @@ -274,7 +274,7 @@ func InitConfig() (*Config, error) { return &cfg, nil } -// populate the ByIP allowlists then keep them updated +// populate the byIP allowlists then keep them updated func (cfg *Config) UpdateAllowLists() { dockerClient, err := client.NewClientWithOpts(client.FromEnv) if err != nil { @@ -312,11 +312,11 @@ func (cfg *Config) UpdateAllowLists() { return } if len(addedIPs) > 0 { - cfg.AllowLists.Mutex.RLock() + cfg.AllowLists.mutex.RLock() for _, ip := range addedIPs { - cfg.AllowLists.ByIP[ip].Print(ip, cfg.LogJSON) + cfg.AllowLists.byIP[ip].Print(ip, cfg.LogJSON) } - cfg.AllowLists.Mutex.RUnlock() + cfg.AllowLists.mutex.RUnlock() } case err := <- errChan: slog.Error("received error from Docker event stream", "error", err) @@ -331,24 +331,32 @@ func (allowLists *AllowListRegistry) PrintDefault(logJSON bool) { // print the non-default allowlists func (allowLists *AllowListRegistry) PrintByIP(logJSON bool) { - allowLists.Mutex.RLock() - defer allowLists.Mutex.RUnlock() - for ip, allowList := range allowLists.ByIP { + allowLists.mutex.RLock() + defer allowLists.mutex.RUnlock() + for ip, allowList := range allowLists.byIP { allowList.Print(ip, logJSON) } } -// initialise allowlist registry ByIP allowlists +// return the allowlist corresponding to the given IP address if found +func (allowLists *AllowListRegistry) FindByIP(ip string) (*AllowList, bool) { + allowLists.mutex.RLock() + defer allowLists.mutex.RUnlock() + allowList, found := allowLists.byIP[ip] + return allowList, found +} + +// initialise allowlist registry byIP allowlists func (allowLists *AllowListRegistry) initByIP(dockerClient *client.Client) error { var methods []string for _, rx := range mr { methods = append(methods, rx.method) } - allowLists.Mutex.Lock() - defer allowLists.Mutex.Unlock() + allowLists.mutex.Lock() + defer allowLists.mutex.Unlock() - allowLists.ByIP = make(map[string]*AllowList) + allowLists.byIP = make(map[string]*AllowList) ctx := context.Background() for _, network := range allowLists.Networks { @@ -356,14 +364,14 @@ func (allowLists *AllowListRegistry) initByIP(dockerClient *client.Client) error filter.Add("network", network) containers, err := dockerClient.ContainerList(ctx, container.ListOptions{Filters: filter}) if err != nil { - allowLists.ByIP = nil + allowLists.byIP = nil return err } for _, cntr := range containers { allowedRequests, allowedBindMounts, err := extractLabelData(cntr, methods) if err != nil { - allowLists.ByIP = nil + allowLists.byIP = nil return err } @@ -376,11 +384,11 @@ func (allowLists *AllowListRegistry) initByIP(dockerClient *client.Client) error ipv4Address := cntr.NetworkSettings.Networks[network].IPAddress if len(ipv4Address) > 0 { - allowLists.ByIP[ipv4Address] = &allowList + allowLists.byIP[ipv4Address] = &allowList } ipv6Address := cntr.NetworkSettings.Networks[network].GlobalIPv6Address if len(ipv6Address) > 0 { - allowLists.ByIP[ipv6Address] = &allowList + allowLists.byIP[ipv6Address] = &allowList } } } @@ -411,9 +419,6 @@ func (allowLists *AllowListRegistry) updateFromEvent( // add the allowlist for the container with the given ID to the allowlist registry // if it has at least one socket-proxy allow label and is in a same network as the socket-proxy func (allowLists *AllowListRegistry) add(dockerClient *client.Client, containerID string) ([]string, error) { - allowLists.Mutex.Lock() - defer allowLists.Mutex.Unlock() - var methods []string for _, rx := range mr { methods = append(methods, rx.method) @@ -440,16 +445,19 @@ func (allowLists *AllowListRegistry) add(dockerClient *client.Client, containerI AllowedBindMounts: allowedBindMounts, } + allowLists.mutex.Lock() + defer allowLists.mutex.Unlock() + for networkID, cntrNetwork := range cntr.NetworkSettings.Networks { if slices.Contains(allowLists.Networks, networkID) { ipv4Address := cntrNetwork.IPAddress if len(ipv4Address) > 0 { - allowLists.ByIP[ipv4Address] = &allowList + allowLists.byIP[ipv4Address] = &allowList ips = append(ips, ipv4Address) } ipv6Address := cntrNetwork.GlobalIPv6Address if len(ipv6Address) > 0 { - allowLists.ByIP[ipv6Address] = &allowList + allowLists.byIP[ipv6Address] = &allowList ips = append(ips, ipv6Address) } } @@ -461,12 +469,12 @@ func (allowLists *AllowListRegistry) add(dockerClient *client.Client, containerI // remove allowlists having the given container ID from the allowlist registry func (allowLists *AllowListRegistry) remove(containerID string) { - allowLists.Mutex.Lock() - defer allowLists.Mutex.Unlock() + allowLists.mutex.Lock() + defer allowLists.mutex.Unlock() - for ip, allowList := range allowLists.ByIP { + for ip, allowList := range allowLists.byIP { if allowList.ID == containerID { - delete(allowLists.ByIP, ip) + delete(allowLists.byIP, ip) } } } From 036b964569a76bf528a4488c760b6c391a390602 Mon Sep 17 00:00:00 2001 From: Amanda Wee Date: Mon, 6 Oct 2025 20:26:54 +1300 Subject: [PATCH 12/38] Handle Docker restart events --- internal/config/config.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index c35ca89..81d3d00 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -404,6 +404,8 @@ func (allowLists *AllowListRegistry) updateFromEvent( containerID := event.Actor.ID switch event.Action { + case "restart": + fallthrough case "start": addedIPs, err := allowLists.add(dockerClient, containerID) if err != nil { From ceab24ac238ebfecb03240d6570193cc198bf1a6 Mon Sep 17 00:00:00 2001 From: Amanda Wee Date: Mon, 6 Oct 2025 20:36:12 +1300 Subject: [PATCH 13/38] Retrieve container restart events from Docker API --- internal/config/config.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index 81d3d00..8a7d7fa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -296,6 +296,7 @@ func (cfg *Config) UpdateAllowLists() { filter := filters.NewArgs() filter.Add("type", "container") filter.Add("event", "start") + filter.Add("event", "restart") filter.Add("event", "die") eventsChan, errChan := dockerClient.Events(ctx, events.ListOptions{Filters: filter}) if err != nil { @@ -405,14 +406,17 @@ func (allowLists *AllowListRegistry) updateFromEvent( switch event.Action { case "restart": + slog.Debug("process container restart event", "containerID", containerID) fallthrough case "start": + slog.Debug("process container start event", "containerID", containerID) addedIPs, err := allowLists.add(dockerClient, containerID) if err != nil { return nil, err } return addedIPs, nil case "die": + slog.Debug("process container die event", "containerID", containerID) allowLists.remove(containerID) } return nil, nil From 34536a183cb6e65194a52de6dea1a8c1608583cd Mon Sep 17 00:00:00 2001 From: Amanda Wee Date: Mon, 6 Oct 2025 21:39:07 +1300 Subject: [PATCH 14/38] Loop over Docker event stream --- internal/config/config.go | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 8a7d7fa..01f7b42 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -305,23 +305,26 @@ func (cfg *Config) UpdateAllowLists() { } slog.Debug("subscribed to Docker event stream to update allowlists") - select { - case event := <- eventsChan: - addedIPs, updateErr := cfg.AllowLists.updateFromEvent(dockerClient, event) - if updateErr != nil { - slog.Error("error when updating allowlists", "error", updateErr) - return - } - if len(addedIPs) > 0 { - cfg.AllowLists.mutex.RLock() - for _, ip := range addedIPs { - cfg.AllowLists.byIP[ip].Print(ip, cfg.LogJSON) + for { + select { + case event := <- eventsChan: + slog.Debug("received Docker container event", "action", event.Action, "containerID", event.Actor.ID) + addedIPs, updateErr := cfg.AllowLists.updateFromEvent(dockerClient, event) + if updateErr != nil { + slog.Error("error when updating allowlists", "error", updateErr) + return + } + if len(addedIPs) > 0 { + cfg.AllowLists.mutex.RLock() + for _, ip := range addedIPs { + cfg.AllowLists.byIP[ip].Print(ip, cfg.LogJSON) + } + cfg.AllowLists.mutex.RUnlock() } - cfg.AllowLists.mutex.RUnlock() + case err := <- errChan: + slog.Error("received error from Docker event stream", "error", err) + return } - case err := <- errChan: - slog.Error("received error from Docker event stream", "error", err) - return } } @@ -406,17 +409,14 @@ func (allowLists *AllowListRegistry) updateFromEvent( switch event.Action { case "restart": - slog.Debug("process container restart event", "containerID", containerID) fallthrough case "start": - slog.Debug("process container start event", "containerID", containerID) addedIPs, err := allowLists.add(dockerClient, containerID) if err != nil { return nil, err } return addedIPs, nil case "die": - slog.Debug("process container die event", "containerID", containerID) allowLists.remove(containerID) } return nil, nil From 17356cc0010e54d5214797afde245cde0fdc194e Mon Sep 17 00:00:00 2001 From: Amanda Wee Date: Mon, 6 Oct 2025 23:12:22 +1300 Subject: [PATCH 15/38] Improve container event handling --- internal/config/config.go | 77 +++++++++++++++++++++++---------------- 1 file changed, 45 insertions(+), 32 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 01f7b42..d65d2f0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -276,6 +276,8 @@ func InitConfig() (*Config, error) { // populate the byIP allowlists then keep them updated func (cfg *Config) UpdateAllowLists() { + ctx := context.Background() + dockerClient, err := client.NewClientWithOpts(client.FromEnv) if err != nil { slog.Error("failed to create Docker client", "error", err) @@ -283,7 +285,7 @@ func (cfg *Config) UpdateAllowLists() { } defer dockerClient.Close() - err = cfg.AllowLists.initByIP(dockerClient) + err = cfg.AllowLists.initByIP(ctx, dockerClient) if err != nil { slog.Error("failed to initialise non-default allowlists", "error", err) return @@ -292,38 +294,43 @@ func (cfg *Config) UpdateAllowLists() { // print non-default request allowlists cfg.AllowLists.PrintByIP(cfg.LogJSON) - ctx := context.Background() filter := filters.NewArgs() filter.Add("type", "container") filter.Add("event", "start") filter.Add("event", "restart") filter.Add("event", "die") eventsChan, errChan := dockerClient.Events(ctx, events.ListOptions{Filters: filter}) - if err != nil { - slog.Error("failed to subscribe to Docker event stream", "error", err) - return - } slog.Debug("subscribed to Docker event stream to update allowlists") for { select { - case event := <- eventsChan: + case event, ok := <-eventsChan: + if !ok { + slog.Info("Docker event stream closed") + return + } slog.Debug("received Docker container event", "action", event.Action, "containerID", event.Actor.ID) - addedIPs, updateErr := cfg.AllowLists.updateFromEvent(dockerClient, event) + addedIPs, removedIPs, updateErr := cfg.AllowLists.updateFromEvent(ctx, dockerClient, event) if updateErr != nil { - slog.Error("error when updating allowlists", "error", updateErr) - return + slog.Warn("failed to update allowlists from container event", "error", updateErr) + continue } - if len(addedIPs) > 0 { + for _, ip := range addedIPs { cfg.AllowLists.mutex.RLock() - for _, ip := range addedIPs { - cfg.AllowLists.byIP[ip].Print(ip, cfg.LogJSON) - } + allowList := cfg.AllowLists.byIP[ip] cfg.AllowLists.mutex.RUnlock() + if allowList != nil { + allowList.Print(ip, cfg.LogJSON) + } + } + for _, ip := range removedIPs { + slog.Info("removed allowlist for container", "ip", ip) + } + case err := <-errChan: + if err != nil { + slog.Error("received error from Docker event stream", "error", err) + return } - case err := <- errChan: - slog.Error("received error from Docker event stream", "error", err) - return } } } @@ -351,7 +358,7 @@ func (allowLists *AllowListRegistry) FindByIP(ip string) (*AllowList, bool) { } // initialise allowlist registry byIP allowlists -func (allowLists *AllowListRegistry) initByIP(dockerClient *client.Client) error { +func (allowLists *AllowListRegistry) initByIP(ctx context.Context, dockerClient *client.Client) error { var methods []string for _, rx := range mr { methods = append(methods, rx.method) @@ -362,7 +369,6 @@ func (allowLists *AllowListRegistry) initByIP(dockerClient *client.Client) error allowLists.byIP = make(map[string]*AllowList) - ctx := context.Background() for _, network := range allowLists.Networks { filter := filters.NewArgs() filter.Add("network", network) @@ -403,28 +409,32 @@ func (allowLists *AllowListRegistry) initByIP(dockerClient *client.Client) error // update the allowlist registry based on the Docker event func (allowLists *AllowListRegistry) updateFromEvent( - dockerClient *client.Client, event events.Message, -) ([]string, error) { + ctx context.Context, dockerClient *client.Client, event events.Message, +) ([]string, []string, error) { containerID := event.Actor.ID + var ( + addedIPs []string + removedIPs []string + err error + ) switch event.Action { - case "restart": - fallthrough - case "start": - addedIPs, err := allowLists.add(dockerClient, containerID) + case "start", "restart": + addedIPs, err = allowLists.add(ctx, dockerClient, containerID) if err != nil { - return nil, err + return nil, nil, err } - return addedIPs, nil case "die": - allowLists.remove(containerID) + removedIPs = allowLists.remove(containerID) } - return nil, nil + return addedIPs, removedIPs, nil } // add the allowlist for the container with the given ID to the allowlist registry // if it has at least one socket-proxy allow label and is in a same network as the socket-proxy -func (allowLists *AllowListRegistry) add(dockerClient *client.Client, containerID string) ([]string, error) { +func (allowLists *AllowListRegistry) add( + ctx context.Context, dockerClient *client.Client, containerID string, +) ([]string, error) { var methods []string for _, rx := range mr { methods = append(methods, rx.method) @@ -432,7 +442,7 @@ func (allowLists *AllowListRegistry) add(dockerClient *client.Client, containerI filter := filters.NewArgs() filter.Add("id", containerID) - containers, err := dockerClient.ContainerList(context.Background(), container.ListOptions{Filters: filter}) + containers, err := dockerClient.ContainerList(ctx, container.ListOptions{Filters: filter}) if err != nil { return nil, err } @@ -474,15 +484,18 @@ func (allowLists *AllowListRegistry) add(dockerClient *client.Client, containerI } // remove allowlists having the given container ID from the allowlist registry -func (allowLists *AllowListRegistry) remove(containerID string) { +func (allowLists *AllowListRegistry) remove(containerID string) []string { allowLists.mutex.Lock() defer allowLists.mutex.Unlock() + var removedIPs []string for ip, allowList := range allowLists.byIP { if allowList.ID == containerID { delete(allowLists.byIP, ip) + removedIPs = append(removedIPs, ip) } } + return removedIPs } // print the allowlist, including the IP address of the associated container if it is not empty, From eaec378e9dab44b1519b29a4c9a57bbad6ee2aad Mon Sep 17 00:00:00 2001 From: Amanda Wee Date: Tue, 7 Oct 2025 00:08:48 +1300 Subject: [PATCH 16/38] Improve adding of allowlist --- internal/config/config.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index d65d2f0..387fc4e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -324,7 +324,7 @@ func (cfg *Config) UpdateAllowLists() { } } for _, ip := range removedIPs { - slog.Info("removed allowlist for container", "ip", ip) + slog.Info("removed allowlist for container", "containerID", event.Actor.ID, "ip", ip) } case err := <-errChan: if err != nil { @@ -447,11 +447,15 @@ func (allowLists *AllowListRegistry) add( return nil, err } if len(containers) == 0 { - return nil, fmt.Errorf("newly started container ID \"%s\" was not found", containerID) + slog.Warn("container not found, may have stopped quickly", "containerID", containerID) + return nil, nil } cntr := containers[0] allowedRequests, allowedBindMounts, err := extractLabelData(cntr, methods) + if err != nil { + return nil, err + } var ips []string if len(allowedRequests) > 0 || len(allowedBindMounts) > 0 { @@ -595,3 +599,4 @@ func extractLabelData(cntr container.Summary, methods []string) (map[string]*reg } return allowedRequests, allowedBindMounts, nil } + From 1eefa2d149eb660f231abdc119e4231f2bcf5f47 Mon Sep 17 00:00:00 2001 From: Amanda Wee Date: Tue, 7 Oct 2025 19:04:48 +1300 Subject: [PATCH 17/38] Refactor HTTP request handling for allowlists --- cmd/socket-proxy/handlehttprequest.go | 44 ++++++++++----------------- 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/cmd/socket-proxy/handlehttprequest.go b/cmd/socket-proxy/handlehttprequest.go index 82c60f2..ce0f612 100644 --- a/cmd/socket-proxy/handlehttprequest.go +++ b/cmd/socket-proxy/handlehttprequest.go @@ -13,8 +13,9 @@ import ( // Otherwise, it returns a "405 Method Not Allowed" or a "403 Forbidden" error. // In case of an error, it returns a 500 Internal Server Error. func handleHTTPRequest(w http.ResponseWriter, r *http.Request) { - allowList := determineAllowList(w, r) + allowList := determineAllowList(r) if allowList == nil { + communicateBlockedRequest(w, r, "forbidden IP", http.StatusForbidden) return } @@ -39,23 +40,30 @@ func handleHTTPRequest(w http.ResponseWriter, r *http.Request) { socketProxy.ServeHTTP(w, r) // proxy the request } -// return the relevant allowlist, or nil if the request has been blocked -func determineAllowList(w http.ResponseWriter, r *http.Request) *config.AllowList { +// return the relevant allowlist, or nil if the IP address is forbidden +func determineAllowList(r *http.Request) *config.AllowList { if cfg.ProxySocketEndpoint == "" { // do not perform this check if we proxy to a unix socket + // Get the client IP address from the remote address string + clientIPStr, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + slog.Warn("cannot get valid IP address from request", "reason", err, "method", r.Method, "URL", r.URL, "client", r.RemoteAddr) + return nil + } + + // If applicable, get the non-default allowlist corresponding to the client IP address if cfg.ProxyContainerName != "" { - allowList := checkForAllowListByIP(w, r) - if allowList != nil { + allowList, found := cfg.AllowLists.FindByIP(clientIPStr) + if found && allowList != nil { return allowList } } // Check if client is allowed for the default allowlist: - allowedIP, err := isAllowedClient(r.RemoteAddr) + allowedIP, err := isAllowedClient(clientIPStr) if err != nil { slog.Warn("cannot get valid IP address for client allowlist check", "reason", err, "method", r.Method, "URL", r.URL, "client", r.RemoteAddr) } if !allowedIP { - communicateBlockedRequest(w, r, "forbidden IP", http.StatusForbidden) return nil } } @@ -63,29 +71,9 @@ func determineAllowList(w http.ResponseWriter, r *http.Request) *config.AllowLis return cfg.AllowLists.Default } -// return the allowlist corresponding to the container by IP address used to make the request, -// or nil if none is found -func checkForAllowListByIP(w http.ResponseWriter, r *http.Request) *config.AllowList { - clientIPStr, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - return nil - } - - allowList, found := cfg.AllowLists.FindByIP(clientIPStr) - if !found { - return nil - } - return allowList -} - // isAllowedClient checks if the given remote address is allowed to connect to the proxy. // The IP address is extracted from a RemoteAddr string (the part before the colon). -func isAllowedClient(remoteAddr string) (bool, error) { - // Get the client IP address from the remote address string - clientIPStr, _, err := net.SplitHostPort(remoteAddr) - if err != nil { - return false, err - } +func isAllowedClient(clientIPStr string) (bool, error) { // Parse the IP address clientIP := net.ParseIP(clientIPStr) if clientIP == nil { From dbbed1991e97eef537e282badd5b7fb51e84239f Mon Sep 17 00:00:00 2001 From: Amanda Wee Date: Wed, 8 Oct 2025 19:41:37 +1300 Subject: [PATCH 18/38] Do not export AllowListRegistry networks field --- cmd/socket-proxy/main.go | 7 +----- internal/config/config.go | 46 ++++++++++++++++++++++++--------------- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/cmd/socket-proxy/main.go b/cmd/socket-proxy/main.go index 59dabee..8606221 100644 --- a/cmd/socket-proxy/main.go +++ b/cmd/socket-proxy/main.go @@ -89,12 +89,7 @@ func main() { // enables the use of labels to specify per-container allowlists slog.Debug("no proxy container name provided") } - if len(cfg.AllowLists.Networks) > 0 { - slog.Info("socket proxy networks detected", "socketproxynetworks", cfg.AllowLists.Networks) - } else { - // we only log this on DEBUG level because the socket proxy networks are used for per-container allowlists - slog.Debug("no socket proxy networks detected") - } + cfg.AllowLists.PrintNetworks() // print default request allowlist cfg.AllowLists.PrintDefault(cfg.LogJSON) diff --git a/internal/config/config.go b/internal/config/config.go index 387fc4e..2702095 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -60,7 +60,7 @@ type Config struct { type AllowListRegistry struct { mutex sync.RWMutex // mutex to control read/write of byIP - Networks []string // names of networks in which socket proxy access is allowed for non-default allowlists + networks []string // names of networks in which socket proxy access is allowed for non-default allowlists Default *AllowList // default allowlist byIP map[string]*AllowList // map container IP address to allowlist for that container } @@ -263,7 +263,7 @@ func InitConfig() (*Config, error) { if cfg.ProxySocketEndpoint == "" && cfg.ProxyContainerName != "" { var err error - allowLists.Networks, err = listSocketProxyNetworks(cfg.ProxyContainerName) + allowLists.networks, err = listSocketProxyNetworks(cfg.ProxyContainerName) if err != nil { return nil, err } @@ -335,6 +335,16 @@ func (cfg *Config) UpdateAllowLists() { } } +// print the allowed networks +func (allowLists *AllowListRegistry) PrintNetworks() { + if len(allowLists.networks) > 0 { + slog.Info("socket proxy networks detected", "socketproxynetworks", allowLists.networks) + } else { + // we only log this on DEBUG level because the socket proxy networks are used for per-container allowlists + slog.Debug("no socket proxy networks detected") + } +} + // print the default allowlist func (allowLists *AllowListRegistry) PrintDefault(logJSON bool) { allowLists.Default.Print("", logJSON) @@ -369,7 +379,7 @@ func (allowLists *AllowListRegistry) initByIP(ctx context.Context, dockerClient allowLists.byIP = make(map[string]*AllowList) - for _, network := range allowLists.Networks { + for _, network := range allowLists.networks { filter := filters.NewArgs() filter.Add("network", network) containers, err := dockerClient.ContainerList(ctx, container.ListOptions{Filters: filter}) @@ -386,19 +396,20 @@ func (allowLists *AllowListRegistry) initByIP(ctx context.Context, dockerClient } if len(allowedRequests) > 0 || len(allowedBindMounts) > 0 { - allowList := AllowList{ - ID: cntr.ID, - AllowedRequests: allowedRequests, - AllowedBindMounts: allowedBindMounts, - } - - ipv4Address := cntr.NetworkSettings.Networks[network].IPAddress - if len(ipv4Address) > 0 { - allowLists.byIP[ipv4Address] = &allowList - } - ipv6Address := cntr.NetworkSettings.Networks[network].GlobalIPv6Address - if len(ipv6Address) > 0 { - allowLists.byIP[ipv6Address] = &allowList + cntrNetwork, found := cntr.NetworkSettings.Networks[network] + if found { + allowList := AllowList{ + ID: cntr.ID, + AllowedRequests: allowedRequests, + AllowedBindMounts: allowedBindMounts, + } + + if len(cntrNetwork.IPAddress) > 0 { + allowLists.byIP[cntrNetwork.IPAddress] = &allowList + } + if len(cntrNetwork.GlobalIPv6Address) > 0 { + allowLists.byIP[cntrNetwork.GlobalIPv6Address] = &allowList + } } } } @@ -469,7 +480,7 @@ func (allowLists *AllowListRegistry) add( defer allowLists.mutex.Unlock() for networkID, cntrNetwork := range cntr.NetworkSettings.Networks { - if slices.Contains(allowLists.Networks, networkID) { + if slices.Contains(allowLists.networks, networkID) { ipv4Address := cntrNetwork.IPAddress if len(ipv4Address) > 0 { allowLists.byIP[ipv4Address] = &allowList @@ -599,4 +610,3 @@ func extractLabelData(cntr container.Summary, methods []string) (map[string]*reg } return allowedRequests, allowedBindMounts, nil } - From 323e5c5b24a86b542062cef2c9c883bd2a2d9fae Mon Sep 17 00:00:00 2001 From: Amanda Wee Date: Thu, 9 Oct 2025 19:51:57 +1300 Subject: [PATCH 19/38] Eliminate unnecessary method name extraction --- internal/config/config.go | 38 ++++++++++++++------------------------ 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 2702095..3c85f4a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -209,18 +209,18 @@ func InitConfig() (*Config, error) { // check listenIP and proxyPort if proxyPort < 1 || proxyPort > 65535 { - return nil, errors.New("port number has to be between 1 and 65535") + return nil, errors.New("port number has to be between 1 and 65535") } ip := net.ParseIP(listenIP) if ip == nil { - return nil, fmt.Errorf("invalid IP \"%s\" for listenip", listenIP) + return nil, fmt.Errorf("invalid IP \"%s\" for listenip", listenIP) } // Properly format address for both IPv4 and IPv6 if ip.To4() == nil { - cfg.ListenAddress = fmt.Sprintf("[%s]:%d", listenIP, proxyPort) + cfg.ListenAddress = fmt.Sprintf("[%s]:%d", listenIP, proxyPort) } else { - cfg.ListenAddress = fmt.Sprintf("%s:%d", listenIP, proxyPort) + cfg.ListenAddress = fmt.Sprintf("%s:%d", listenIP, proxyPort) } // parse defaultLogLevel and setup logging handler depending on defaultLogJSON @@ -369,11 +369,6 @@ func (allowLists *AllowListRegistry) FindByIP(ip string) (*AllowList, bool) { // initialise allowlist registry byIP allowlists func (allowLists *AllowListRegistry) initByIP(ctx context.Context, dockerClient *client.Client) error { - var methods []string - for _, rx := range mr { - methods = append(methods, rx.method) - } - allowLists.mutex.Lock() defer allowLists.mutex.Unlock() @@ -389,7 +384,7 @@ func (allowLists *AllowListRegistry) initByIP(ctx context.Context, dockerClient } for _, cntr := range containers { - allowedRequests, allowedBindMounts, err := extractLabelData(cntr, methods) + allowedRequests, allowedBindMounts, err := extractLabelData(cntr) if err != nil { allowLists.byIP = nil return err @@ -399,8 +394,8 @@ func (allowLists *AllowListRegistry) initByIP(ctx context.Context, dockerClient cntrNetwork, found := cntr.NetworkSettings.Networks[network] if found { allowList := AllowList{ - ID: cntr.ID, - AllowedRequests: allowedRequests, + ID: cntr.ID, + AllowedRequests: allowedRequests, AllowedBindMounts: allowedBindMounts, } @@ -446,11 +441,6 @@ func (allowLists *AllowListRegistry) updateFromEvent( func (allowLists *AllowListRegistry) add( ctx context.Context, dockerClient *client.Client, containerID string, ) ([]string, error) { - var methods []string - for _, rx := range mr { - methods = append(methods, rx.method) - } - filter := filters.NewArgs() filter.Add("id", containerID) containers, err := dockerClient.ContainerList(ctx, container.ListOptions{Filters: filter}) @@ -463,7 +453,7 @@ func (allowLists *AllowListRegistry) add( } cntr := containers[0] - allowedRequests, allowedBindMounts, err := extractLabelData(cntr, methods) + allowedRequests, allowedBindMounts, err := extractLabelData(cntr) if err != nil { return nil, err } @@ -471,8 +461,8 @@ func (allowLists *AllowListRegistry) add( var ips []string if len(allowedRequests) > 0 || len(allowedBindMounts) > 0 { allowList := AllowList{ - ID: cntr.ID, - AllowedRequests: allowedRequests, + ID: cntr.ID, + AllowedRequests: allowedRequests, AllowedBindMounts: allowedBindMounts, } @@ -579,7 +569,7 @@ func listSocketProxyNetworks(proxyContainerName string) ([]string, error) { return nil, fmt.Errorf("socket-proxy container \"%s\" was not found", proxyContainerName) } - var networks []string + networks := make([]string, 0, len(containers[0].NetworkSettings.Networks)) for networkID, _ := range containers[0].NetworkSettings.Networks { networks = append(networks, networkID) } @@ -587,13 +577,13 @@ func listSocketProxyNetworks(proxyContainerName string) ([]string, error) { } // extract Docker container allowlist label data from the container summary -func extractLabelData(cntr container.Summary, methods []string) (map[string]*regexp.Regexp, []string, error) { +func extractLabelData(cntr container.Summary) (map[string]*regexp.Regexp, []string, error) { allowedRequests := make(map[string]*regexp.Regexp) - allowedBindMounts := []string{} + var allowedBindMounts []string for labelName, labelValue := range cntr.Labels { if strings.HasPrefix(labelName, allowedDockerLabelPrefix) && labelValue != "" { allowSpec := strings.ToUpper(strings.TrimPrefix(labelName, allowedDockerLabelPrefix)) - if slices.Contains(methods, allowSpec) { + if slices.ContainsFunc(mr, func(rx methodRegex) bool { return rx.method == allowSpec }) { r, err := compileRegexp(labelValue, allowSpec, "docker container label") if err != nil { return nil, nil, err From 24ea509561f732949b8d41a807cd0f6a964f8d86 Mon Sep 17 00:00:00 2001 From: Amanda Wee Date: Thu, 9 Oct 2025 22:49:33 +1300 Subject: [PATCH 20/38] Store AllowList values directly as they should be immutable once set --- cmd/socket-proxy/handlehttprequest.go | 18 +++++----- internal/config/config.go | 51 +++++++++++++-------------- 2 files changed, 34 insertions(+), 35 deletions(-) diff --git a/cmd/socket-proxy/handlehttprequest.go b/cmd/socket-proxy/handlehttprequest.go index ce0f612..7bf1092 100644 --- a/cmd/socket-proxy/handlehttprequest.go +++ b/cmd/socket-proxy/handlehttprequest.go @@ -13,8 +13,8 @@ import ( // Otherwise, it returns a "405 Method Not Allowed" or a "403 Forbidden" error. // In case of an error, it returns a 500 Internal Server Error. func handleHTTPRequest(w http.ResponseWriter, r *http.Request) { - allowList := determineAllowList(r) - if allowList == nil { + allowList, ok := determineAllowList(r) + if !ok { communicateBlockedRequest(w, r, "forbidden IP", http.StatusForbidden) return } @@ -40,21 +40,21 @@ func handleHTTPRequest(w http.ResponseWriter, r *http.Request) { socketProxy.ServeHTTP(w, r) // proxy the request } -// return the relevant allowlist, or nil if the IP address is forbidden -func determineAllowList(r *http.Request) *config.AllowList { +// return the relevant allowlist +func determineAllowList(r *http.Request) (config.AllowList, bool) { if cfg.ProxySocketEndpoint == "" { // do not perform this check if we proxy to a unix socket // Get the client IP address from the remote address string clientIPStr, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { slog.Warn("cannot get valid IP address from request", "reason", err, "method", r.Method, "URL", r.URL, "client", r.RemoteAddr) - return nil + return config.AllowList{}, false } // If applicable, get the non-default allowlist corresponding to the client IP address if cfg.ProxyContainerName != "" { allowList, found := cfg.AllowLists.FindByIP(clientIPStr) - if found && allowList != nil { - return allowList + if found { + return allowList, true } } @@ -64,11 +64,11 @@ func determineAllowList(r *http.Request) *config.AllowList { slog.Warn("cannot get valid IP address for client allowlist check", "reason", err, "method", r.Method, "URL", r.URL, "client", r.RemoteAddr) } if !allowedIP { - return nil + return config.AllowList{}, false } } - return cfg.AllowLists.Default + return cfg.AllowLists.Default, true } // isAllowedClient checks if the given remote address is allowed to connect to the proxy. diff --git a/internal/config/config.go b/internal/config/config.go index 3c85f4a..c5b389e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -59,10 +59,10 @@ type Config struct { } type AllowListRegistry struct { - mutex sync.RWMutex // mutex to control read/write of byIP - networks []string // names of networks in which socket proxy access is allowed for non-default allowlists - Default *AllowList // default allowlist - byIP map[string]*AllowList // map container IP address to allowlist for that container + mutex sync.RWMutex // mutex to control read/write of byIP + networks []string // names of networks in which socket proxy access is allowed for non-default allowlists + Default AllowList // default allowlist + byIP map[string]AllowList // map container IP address to allowlist for that container } type AllowList struct { @@ -97,8 +97,6 @@ var mr = []methodRegex{ func InitConfig() (*Config, error) { var ( cfg Config - allowLists AllowListRegistry - defaultAllowList AllowList allowFromString string listenIP string proxyPort uint @@ -195,16 +193,19 @@ func InitConfig() (*Config, error) { } flag.Parse() + // init allowlist registry to configure default allowlist + cfg.AllowLists = &AllowListRegistry{} + // parse comma-separeted allowFromString into allowFrom slice cfg.AllowFrom = strings.Split(allowFromString, ",") - // parse allowBindMountFromString into AllowBindMountFrom slice and validate + // parse allowBindMountFromString into default allowlist AllowedBindMounts slice and validate if allowBindMountFromString != "" { allowedBindMounts, err := parseAllowedBindMounts(allowBindMountFromString) if err != nil { return nil, err } - defaultAllowList.AllowedBindMounts = allowedBindMounts + cfg.AllowLists.Default.AllowedBindMounts = allowedBindMounts } // check listenIP and proxyPort @@ -242,35 +243,33 @@ func InitConfig() (*Config, error) { } cfg.ProxySocketEndpointFileMode = os.FileMode(uint32(endpointFileMode)) - // compile regexes for allowed requests - defaultAllowList.AllowedRequests = make(map[string]*regexp.Regexp) + // compile regexes for default allowed requests + cfg.AllowLists.Default.AllowedRequests = make(map[string]*regexp.Regexp) for _, rx := range mr { if rx.regexStringFromParam != "" { r, err := compileRegexp(rx.regexStringFromParam, rx.method, "command line parameter") if err != nil { return nil, err } - defaultAllowList.AllowedRequests[rx.method] = r + cfg.AllowLists.Default.AllowedRequests[rx.method] = r } else if rx.regexStringFromEnv != "" { r, err := compileRegexp(rx.regexStringFromEnv, rx.method, "env variable") if err != nil { return nil, err } - defaultAllowList.AllowedRequests[rx.method] = r + cfg.AllowLists.Default.AllowedRequests[rx.method] = r } } - allowLists.Default = &defaultAllowList + // populate list of socket proxy networks if applicable if cfg.ProxySocketEndpoint == "" && cfg.ProxyContainerName != "" { var err error - allowLists.networks, err = listSocketProxyNetworks(cfg.ProxyContainerName) + cfg.AllowLists.networks, err = listSocketProxyNetworks(cfg.ProxyContainerName) if err != nil { return nil, err } } - cfg.AllowLists = &allowLists - return &cfg, nil } @@ -317,9 +316,9 @@ func (cfg *Config) UpdateAllowLists() { } for _, ip := range addedIPs { cfg.AllowLists.mutex.RLock() - allowList := cfg.AllowLists.byIP[ip] + allowList, found := cfg.AllowLists.byIP[ip] cfg.AllowLists.mutex.RUnlock() - if allowList != nil { + if found { allowList.Print(ip, cfg.LogJSON) } } @@ -360,7 +359,7 @@ func (allowLists *AllowListRegistry) PrintByIP(logJSON bool) { } // return the allowlist corresponding to the given IP address if found -func (allowLists *AllowListRegistry) FindByIP(ip string) (*AllowList, bool) { +func (allowLists *AllowListRegistry) FindByIP(ip string) (AllowList, bool) { allowLists.mutex.RLock() defer allowLists.mutex.RUnlock() allowList, found := allowLists.byIP[ip] @@ -372,7 +371,7 @@ func (allowLists *AllowListRegistry) initByIP(ctx context.Context, dockerClient allowLists.mutex.Lock() defer allowLists.mutex.Unlock() - allowLists.byIP = make(map[string]*AllowList) + allowLists.byIP = make(map[string]AllowList) for _, network := range allowLists.networks { filter := filters.NewArgs() @@ -400,10 +399,10 @@ func (allowLists *AllowListRegistry) initByIP(ctx context.Context, dockerClient } if len(cntrNetwork.IPAddress) > 0 { - allowLists.byIP[cntrNetwork.IPAddress] = &allowList + allowLists.byIP[cntrNetwork.IPAddress] = allowList } if len(cntrNetwork.GlobalIPv6Address) > 0 { - allowLists.byIP[cntrNetwork.GlobalIPv6Address] = &allowList + allowLists.byIP[cntrNetwork.GlobalIPv6Address] = allowList } } } @@ -473,12 +472,12 @@ func (allowLists *AllowListRegistry) add( if slices.Contains(allowLists.networks, networkID) { ipv4Address := cntrNetwork.IPAddress if len(ipv4Address) > 0 { - allowLists.byIP[ipv4Address] = &allowList + allowLists.byIP[ipv4Address] = allowList ips = append(ips, ipv4Address) } ipv6Address := cntrNetwork.GlobalIPv6Address if len(ipv6Address) > 0 { - allowLists.byIP[ipv6Address] = &allowList + allowLists.byIP[ipv6Address] = allowList ips = append(ips, ipv6Address) } } @@ -505,7 +504,7 @@ func (allowLists *AllowListRegistry) remove(containerID string) []string { // print the allowlist, including the IP address of the associated container if it is not empty, // and in JSON format if logJSON is true -func (allowList *AllowList) Print(ip string, logJSON bool) { +func (allowList AllowList) Print(ip string, logJSON bool) { if logJSON { if ip == "" { for method, regex := range allowList.AllowedRequests { @@ -570,7 +569,7 @@ func listSocketProxyNetworks(proxyContainerName string) ([]string, error) { } networks := make([]string, 0, len(containers[0].NetworkSettings.Networks)) - for networkID, _ := range containers[0].NetworkSettings.Networks { + for networkID := range containers[0].NetworkSettings.Networks { networks = append(networks, networkID) } return networks, nil From 4db7757df514121af73e0c09f42d38b7b6984c32 Mon Sep 17 00:00:00 2001 From: Amanda Wee Date: Fri, 10 Oct 2025 23:34:38 +1300 Subject: [PATCH 21/38] Cancel context for Docker event stream --- internal/config/config.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/config/config.go b/internal/config/config.go index c5b389e..d10ec47 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -275,7 +275,8 @@ func InitConfig() (*Config, error) { // populate the byIP allowlists then keep them updated func (cfg *Config) UpdateAllowLists() { - ctx := context.Background() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() dockerClient, err := client.NewClientWithOpts(client.FromEnv) if err != nil { From 8ce7d4538b81cb1ca76e37a18d7814d242172c80 Mon Sep 17 00:00:00 2001 From: Amanda Wee Date: Sat, 11 Oct 2025 09:56:25 +1300 Subject: [PATCH 22/38] Add retry logic for socket proxy container summary --- internal/config/config.go | 45 ++++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index d10ec47..bdd5524 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,6 +16,7 @@ import ( "strconv" "strings" "sync" + "time" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/events" @@ -553,27 +554,45 @@ func parseAllowedBindMounts(allowBindMountFromString string) ([]string, error) { // return list of docker networks that the socket-proxy container is in func listSocketProxyNetworks(proxyContainerName string) ([]string, error) { - dockerClient, err := client.NewClientWithOpts(client.FromEnv) + cntr, err := getSocketProxyContainerSummary(proxyContainerName) if err != nil { return nil, err } - defer dockerClient.Close() - filter := filters.NewArgs() - filter.Add("name", proxyContainerName) - containers, err := dockerClient.ContainerList(context.Background(), container.ListOptions{Filters: filter}) - if err != nil { - return nil, err + networks := make([]string, 0, len(cntr.NetworkSettings.Networks)) + for networkID := range cntr.NetworkSettings.Networks { + networks = append(networks, networkID) } - if len(containers) == 0 { - return nil, fmt.Errorf("socket-proxy container \"%s\" was not found", proxyContainerName) + return networks, nil +} + +// return Docker container summary for the socket proxy container +func getSocketProxyContainerSummary(proxyContainerName string) (container.Summary, error) { + const maxTries = 3 + + dockerClient, err := client.NewClientWithOpts(client.FromEnv) + if err != nil { + return container.Summary{}, err } + defer dockerClient.Close() - networks := make([]string, 0, len(containers[0].NetworkSettings.Networks)) - for networkID := range containers[0].NetworkSettings.Networks { - networks = append(networks, networkID) + ctx := context.Background() + filter := filters.NewArgs() + filter.Add("name", proxyContainerName) + var containers []container.Summary + for i := 1; i < maxTries; i++ { + containers, err = dockerClient.ContainerList(ctx, container.ListOptions{Filters: filter}) + if err != nil { + return container.Summary{}, err + } + if len(containers) > 0 { + return containers[0], nil + } + if i < maxTries { + time.Sleep(time.Duration(i) * time.Second) + } } - return networks, nil + return container.Summary{}, fmt.Errorf("socket-proxy container \"%s\" was not found", proxyContainerName) } // extract Docker container allowlist label data from the container summary From cd5c813d41848621615f64aa117347ce7c578e43 Mon Sep 17 00:00:00 2001 From: Amanda Wee Date: Mon, 20 Oct 2025 18:57:20 +1300 Subject: [PATCH 23/38] Print container short IDs to identify them --- internal/config/config.go | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index bdd5524..33113e5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -310,7 +310,7 @@ func (cfg *Config) UpdateAllowLists() { slog.Info("Docker event stream closed") return } - slog.Debug("received Docker container event", "action", event.Action, "containerID", event.Actor.ID) + slog.Debug("received Docker container event", "action", event.Action, "id", event.Actor.ID[:12]) addedIPs, removedIPs, updateErr := cfg.AllowLists.updateFromEvent(ctx, dockerClient, event) if updateErr != nil { slog.Warn("failed to update allowlists from container event", "error", updateErr) @@ -325,7 +325,7 @@ func (cfg *Config) UpdateAllowLists() { } } for _, ip := range removedIPs { - slog.Info("removed allowlist for container", "containerID", event.Actor.ID, "ip", ip) + slog.Info("removed allowlist for container", "id", event.Actor.ID[:12], "ip", ip) } case err := <-errChan: if err != nil { @@ -449,7 +449,7 @@ func (allowLists *AllowListRegistry) add( return nil, err } if len(containers) == 0 { - slog.Warn("container not found, may have stopped quickly", "containerID", containerID) + slog.Warn("container not found, may have stopped quickly", "id", containerID[:12]) return nil, nil } cntr := containers[0] @@ -514,7 +514,12 @@ func (allowList AllowList) Print(ip string, logJSON bool) { } } else { for method, regex := range allowList.AllowedRequests { - slog.Info("configured request allowlist", "ip", ip, "method", method, "regex", regex) + slog.Info("configured request allowlist", + "id", allowList.ID[:12], + "ip", ip, + "method", method, + "regex", regex, + ) } } } else { @@ -523,7 +528,7 @@ func (allowList AllowList) Print(ip string, logJSON bool) { if ip == "" { fmt.Printf("Default request allowlist:\n %-8s %s\n", "Method", "Regex") } else { - fmt.Printf("Request allowlist for %s:\n %-8s %s\n", ip, "Method", "Regex") + fmt.Printf("Request allowlist for %s (%s):\n %-8s %s\n", allowList.ID[:12], ip, "Method", "Regex") } for method, regex := range allowList.AllowedRequests { fmt.Printf(" %-8s %s\n", method, regex) From 71a8366bd842b4e4f8b2b7290141d4ebd5a1d920 Mon Sep 17 00:00:00 2001 From: Amanda Wee Date: Mon, 20 Oct 2025 23:06:03 +1300 Subject: [PATCH 24/38] Print allowed bind mounts for per-container allowlists --- cmd/socket-proxy/main.go | 6 ------ internal/config/config.go | 22 ++++++++++++++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/cmd/socket-proxy/main.go b/cmd/socket-proxy/main.go index 8606221..9fbf9fb 100644 --- a/cmd/socket-proxy/main.go +++ b/cmd/socket-proxy/main.go @@ -76,12 +76,6 @@ func main() { } else { slog.Info("watchdog disabled") } - if len(cfg.AllowLists.Default.AllowedBindMounts) > 0 { - slog.Info("Docker bind mount restrictions enabled", "allowbindmountfrom", cfg.AllowLists.Default.AllowedBindMounts) - } else { - // we only log this on DEBUG level because bind mount restrictions are a very special use case - slog.Debug("no Docker bind mount restrictions") - } if len(cfg.ProxyContainerName) > 0 { slog.Info("Proxy container name provided", "proxycontainername", cfg.ProxyContainerName) } else { diff --git a/internal/config/config.go b/internal/config/config.go index 33113e5..4f0ee1d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -507,6 +507,7 @@ func (allowLists *AllowListRegistry) remove(containerID string) []string { // print the allowlist, including the IP address of the associated container if it is not empty, // and in JSON format if logJSON is true func (allowList AllowList) Print(ip string, logJSON bool) { + // print allowed requests if logJSON { if ip == "" { for method, regex := range allowList.AllowedRequests { @@ -534,6 +535,27 @@ func (allowList AllowList) Print(ip string, logJSON bool) { fmt.Printf(" %-8s %s\n", method, regex) } } + // print allowed bind mounts + if len(allowList.AllowedBindMounts) > 0 { + if ip == "" { + slog.Info("Default Docker bind mount restrictions enabled", + "allowbindmountfrom", allowList.AllowedBindMounts, + ) + } else { + slog.Info("Docker bind mount restrictions enabled", + "allowbindmountfrom", allowList.AllowedBindMounts, + "id", allowList.ID[:12], + "ip", ip, + ) + } + } else { + // we only log this on DEBUG level because bind mount restrictions are a very special use case + if ip == "" { + slog.Debug("no default Docker bind mount restrictions") + } else { + slog.Debug("no Docker bind mount restrictions", "id", allowList.ID[:12], "ip", ip) + } + } } // compile allowed requests regex pattern From a7c7a74780189d337877ec6aa24b6667147a163e Mon Sep 17 00:00:00 2001 From: Amanda Wee Date: Tue, 28 Oct 2025 22:22:47 +1300 Subject: [PATCH 25/38] Integrate Docker SDK to remove dependency --- Dockerfile | 1 - LICENSE | 5 +- README.md | 2 +- go.mod | 2 - internal/config/config.go | 77 +++-- internal/docker/api/common.go | 12 + .../docker/api/types/container/container.go | 15 + .../api/types/container/network_settings.go | 16 + .../docker/api/types/container/options.go | 13 + internal/docker/api/types/error_response.go | 15 + internal/docker/api/types/events/events.go | 128 ++++++++ internal/docker/api/types/filters/errors.go | 29 ++ internal/docker/api/types/filters/parse.go | 305 ++++++++++++++++++ internal/docker/api/types/network/endpoint.go | 25 ++ internal/docker/api/types/types.go | 12 + internal/docker/api/types/versions/compare.go | 50 +++ internal/docker/client/client.go | 257 +++++++++++++++ internal/docker/client/container_list.go | 39 +++ internal/docker/client/errors.go | 42 +++ internal/docker/client/events.go | 85 +++++ internal/docker/client/options.go | 45 +++ internal/docker/client/ping.go | 68 ++++ internal/docker/client/request.go | 219 +++++++++++++ internal/go-connections/sockets/sockets.go | 69 ++++ 24 files changed, 1492 insertions(+), 39 deletions(-) create mode 100644 internal/docker/api/common.go create mode 100644 internal/docker/api/types/container/container.go create mode 100644 internal/docker/api/types/container/network_settings.go create mode 100644 internal/docker/api/types/container/options.go create mode 100644 internal/docker/api/types/error_response.go create mode 100644 internal/docker/api/types/events/events.go create mode 100644 internal/docker/api/types/filters/errors.go create mode 100644 internal/docker/api/types/filters/parse.go create mode 100644 internal/docker/api/types/network/endpoint.go create mode 100644 internal/docker/api/types/types.go create mode 100644 internal/docker/api/types/versions/compare.go create mode 100644 internal/docker/client/client.go create mode 100644 internal/docker/client/container_list.go create mode 100644 internal/docker/client/errors.go create mode 100644 internal/docker/client/events.go create mode 100644 internal/docker/client/options.go create mode 100644 internal/docker/client/ping.go create mode 100644 internal/docker/client/request.go create mode 100644 internal/go-connections/sockets/sockets.go diff --git a/Dockerfile b/Dockerfile index 5f17e0c..78f3032 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,6 @@ COPY . ./ ARG TARGETOS ARG TARGETARCH ARG VERSION -RUN go mod tidy RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ go build -tags=netgo -gcflags=all=-d=checkptr -ldflags="-w -s -X 'main.version=${VERSION}'" -trimpath \ -o / ./... diff --git a/LICENSE b/LICENSE index d968fa0..1d978f0 100644 --- a/LICENSE +++ b/LICENSE @@ -21,9 +21,10 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- -Parts of this project, specifically the file cmd/internal/bindmount.go, +Parts of this project, specifically the file cmd/socket-proxy/bindmount.go and +the files in the internal/docker and internal/go-connections folders, contain source code licensed under the Apache License 2.0. See the comments -in that file for details. +in the applicable files for details. The rest of the project is licensed under the MIT License. Apache License diff --git a/README.md b/README.md index 7956001..5581891 100644 --- a/README.md +++ b/README.md @@ -262,7 +262,7 @@ socket-proxy can be configured via command line parameters or via environment va ## License This project is licensed under the MIT License – see the [LICENSE](LICENSE) file for details. -Parts of the file `cmd/internal/bindmount.go` are licensed under the Apache 2.0 License. +Parts of the file `cmd/socket-proxy/bindmount.go` and files under the `internal/docker` and `internal/go-connections` folders are licensed under the Apache 2.0 License. See the comments in this file and the LICENSE file for more information. ## Aknowledgements diff --git a/go.mod b/go.mod index 87ab6d6..aa43515 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,3 @@ module github.com/wollomatic/socket-proxy go 1.22.6 - -require github.com/docker/docker v28.5.0+incompatible diff --git a/internal/config/config.go b/internal/config/config.go index 4f0ee1d..c6c93e7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,10 +18,10 @@ import ( "sync" "time" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/events" - "github.com/docker/docker/api/types/filters" - "github.com/docker/docker/client" + "github.com/wollomatic/socket-proxy/internal/docker/api/types/container" + "github.com/wollomatic/socket-proxy/internal/docker/api/types/events" + "github.com/wollomatic/socket-proxy/internal/docker/api/types/filters" + "github.com/wollomatic/socket-proxy/internal/docker/client" ) const allowedDockerLabelPrefix = "socket-proxy.allow." @@ -265,7 +265,7 @@ func InitConfig() (*Config, error) { // populate list of socket proxy networks if applicable if cfg.ProxySocketEndpoint == "" && cfg.ProxyContainerName != "" { var err error - cfg.AllowLists.networks, err = listSocketProxyNetworks(cfg.ProxyContainerName) + cfg.AllowLists.networks, err = listSocketProxyNetworks(cfg.SocketPath, cfg.ProxyContainerName) if err != nil { return nil, err } @@ -274,12 +274,15 @@ func InitConfig() (*Config, error) { return &cfg, nil } -// populate the byIP allowlists then keep them updated +// UpdateAllowLists populates the byIP allowlists then keeps them updated func (cfg *Config) UpdateAllowLists() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - dockerClient, err := client.NewClientWithOpts(client.FromEnv) + dockerClient, err := client.NewClientWithOpts( + client.WithHost("unix://"+cfg.SocketPath), + client.WithAPIVersionNegotiation(), + ) if err != nil { slog.Error("failed to create Docker client", "error", err) return @@ -292,8 +295,6 @@ func (cfg *Config) UpdateAllowLists() { return } slog.Debug("initialised non-default allowlists") - // print non-default request allowlists - cfg.AllowLists.PrintByIP(cfg.LogJSON) filter := filters.NewArgs() filter.Add("type", "container") @@ -303,6 +304,10 @@ func (cfg *Config) UpdateAllowLists() { eventsChan, errChan := dockerClient.Events(ctx, events.ListOptions{Filters: filter}) slog.Debug("subscribed to Docker event stream to update allowlists") + // print non-default request allowlists + cfg.AllowLists.PrintByIP(cfg.LogJSON) + + // handle Docker events to update allowlists for { select { case event, ok := <-eventsChan: @@ -336,7 +341,7 @@ func (cfg *Config) UpdateAllowLists() { } } -// print the allowed networks +// PrintNetworks prints the allowed networks func (allowLists *AllowListRegistry) PrintNetworks() { if len(allowLists.networks) > 0 { slog.Info("socket proxy networks detected", "socketproxynetworks", allowLists.networks) @@ -346,12 +351,12 @@ func (allowLists *AllowListRegistry) PrintNetworks() { } } -// print the default allowlist +// PrintDefault prints the default allowlist func (allowLists *AllowListRegistry) PrintDefault(logJSON bool) { allowLists.Default.Print("", logJSON) } -// print the non-default allowlists +// PrintByIP prints the non-default allowlists func (allowLists *AllowListRegistry) PrintByIP(logJSON bool) { allowLists.mutex.RLock() defer allowLists.mutex.RUnlock() @@ -360,7 +365,7 @@ func (allowLists *AllowListRegistry) PrintByIP(logJSON bool) { } } -// return the allowlist corresponding to the given IP address if found +// FindByIP returns the allowlist corresponding to the given IP address if found func (allowLists *AllowListRegistry) FindByIP(ip string) (AllowList, bool) { allowLists.mutex.RLock() defer allowLists.mutex.RUnlock() @@ -370,30 +375,30 @@ func (allowLists *AllowListRegistry) FindByIP(ip string) (AllowList, bool) { // initialise allowlist registry byIP allowlists func (allowLists *AllowListRegistry) initByIP(ctx context.Context, dockerClient *client.Client) error { + filter := filters.NewArgs() + for _, network := range allowLists.networks { + filter.Add("network", network) + } + containers, err := dockerClient.ContainerList(ctx, container.ListOptions{Filters: filter}) + if err != nil { + return err + } + allowLists.mutex.Lock() defer allowLists.mutex.Unlock() allowLists.byIP = make(map[string]AllowList) - for _, network := range allowLists.networks { - filter := filters.NewArgs() - filter.Add("network", network) - containers, err := dockerClient.ContainerList(ctx, container.ListOptions{Filters: filter}) + for _, cntr := range containers { + allowedRequests, allowedBindMounts, err := extractLabelData(cntr) if err != nil { allowLists.byIP = nil return err } - for _, cntr := range containers { - allowedRequests, allowedBindMounts, err := extractLabelData(cntr) - if err != nil { - allowLists.byIP = nil - return err - } - - if len(allowedRequests) > 0 || len(allowedBindMounts) > 0 { - cntrNetwork, found := cntr.NetworkSettings.Networks[network] - if found { + if len(allowedRequests) > 0 || len(allowedBindMounts) > 0 { + for networkID, cntrNetwork := range cntr.NetworkSettings.Networks { + if slices.Contains(allowLists.networks, networkID) { allowList := AllowList{ ID: cntr.ID, AllowedRequests: allowedRequests, @@ -444,12 +449,15 @@ func (allowLists *AllowListRegistry) add( ) ([]string, error) { filter := filters.NewArgs() filter.Add("id", containerID) + for _, network := range allowLists.networks { + filter.Add("network", network) + } containers, err := dockerClient.ContainerList(ctx, container.ListOptions{Filters: filter}) if err != nil { return nil, err } if len(containers) == 0 { - slog.Warn("container not found, may have stopped quickly", "id", containerID[:12]) + slog.Debug("container is not in a network with socket-proxy or may have stopped", "id", containerID[:12]) return nil, nil } cntr := containers[0] @@ -504,7 +512,7 @@ func (allowLists *AllowListRegistry) remove(containerID string) []string { return removedIPs } -// print the allowlist, including the IP address of the associated container if it is not empty, +// Print prints the allowlist, including the IP address of the associated container if it is not empty, // and in JSON format if logJSON is true func (allowList AllowList) Print(ip string, logJSON bool) { // print allowed requests @@ -580,8 +588,8 @@ func parseAllowedBindMounts(allowBindMountFromString string) ([]string, error) { } // return list of docker networks that the socket-proxy container is in -func listSocketProxyNetworks(proxyContainerName string) ([]string, error) { - cntr, err := getSocketProxyContainerSummary(proxyContainerName) +func listSocketProxyNetworks(socketPath, proxyContainerName string) ([]string, error) { + cntr, err := getSocketProxyContainerSummary(socketPath, proxyContainerName) if err != nil { return nil, err } @@ -594,10 +602,13 @@ func listSocketProxyNetworks(proxyContainerName string) ([]string, error) { } // return Docker container summary for the socket proxy container -func getSocketProxyContainerSummary(proxyContainerName string) (container.Summary, error) { +func getSocketProxyContainerSummary(socketPath, proxyContainerName string) (container.Summary, error) { const maxTries = 3 - dockerClient, err := client.NewClientWithOpts(client.FromEnv) + dockerClient, err := client.NewClientWithOpts( + client.WithHost("unix://"+socketPath), + client.WithAPIVersionNegotiation(), + ) if err != nil { return container.Summary{}, err } diff --git a/internal/docker/api/common.go b/internal/docker/api/common.go new file mode 100644 index 0000000..d90b158 --- /dev/null +++ b/internal/docker/api/common.go @@ -0,0 +1,12 @@ +package api + +/* +This was modified from: +https://github.com/moby/moby/blob/v28.5.1/api/common.go +*/ + +// Common constants for daemon and client. +const ( + // DefaultVersion of the current REST API. + DefaultVersion = "1.51" +) diff --git a/internal/docker/api/types/container/container.go b/internal/docker/api/types/container/container.go new file mode 100644 index 0000000..83cd3b9 --- /dev/null +++ b/internal/docker/api/types/container/container.go @@ -0,0 +1,15 @@ +package container + +/* +This was modified from: +https://github.com/moby/moby/blob/v28.5.1/api/types/container/container.go +*/ + +// Summary contains response of Engine API: +// GET "/containers/json" +type Summary struct { + ID string `json:"Id"` + Names []string + Labels map[string]string + NetworkSettings *NetworkSettingsSummary +} diff --git a/internal/docker/api/types/container/network_settings.go b/internal/docker/api/types/container/network_settings.go new file mode 100644 index 0000000..78e5d4c --- /dev/null +++ b/internal/docker/api/types/container/network_settings.go @@ -0,0 +1,16 @@ +package container + +/* +This was modified from: +https://github.com/moby/moby/blob/v28.5.1/api/types/container/network_settings.go +*/ + +import ( + "github.com/wollomatic/socket-proxy/internal/docker/api/types/network" +) + +// NetworkSettingsSummary provides a summary of container's networks +// in /containers/json +type NetworkSettingsSummary struct { + Networks map[string]*network.EndpointSettings +} diff --git a/internal/docker/api/types/container/options.go b/internal/docker/api/types/container/options.go new file mode 100644 index 0000000..4893e5f --- /dev/null +++ b/internal/docker/api/types/container/options.go @@ -0,0 +1,13 @@ +package container + +/* +This was modified from: +https://github.com/moby/moby/blob/v28.5.1/api/types/container/options.go +*/ + +import "github.com/wollomatic/socket-proxy/internal/docker/api/types/filters" + +// ListOptions holds parameters to list containers with. +type ListOptions struct { + Filters filters.Args +} diff --git a/internal/docker/api/types/error_response.go b/internal/docker/api/types/error_response.go new file mode 100644 index 0000000..ab8aa10 --- /dev/null +++ b/internal/docker/api/types/error_response.go @@ -0,0 +1,15 @@ +package types + +/* +This was modified from: +https://github.com/moby/moby/blob/v28.5.1/api/types/error_response.go +*/ + +// ErrorResponse Represents an error. +// swagger:model ErrorResponse +type ErrorResponse struct { + + // The error message. + // Required: true + Message string `json:"message"` +} diff --git a/internal/docker/api/types/events/events.go b/internal/docker/api/types/events/events.go new file mode 100644 index 0000000..d8c7a63 --- /dev/null +++ b/internal/docker/api/types/events/events.go @@ -0,0 +1,128 @@ +package events + +/* +This was modified from: +https://github.com/moby/moby/blob/v28.5.1/api/types/events/events.go +*/ + +import "github.com/wollomatic/socket-proxy/internal/docker/api/types/filters" + +// Type is used for event-types. +type Type string + +// List of known event types. +const ( + BuilderEventType Type = "builder" // BuilderEventType is the event type that the builder generates. + ConfigEventType Type = "config" // ConfigEventType is the event type that configs generate. + ContainerEventType Type = "container" // ContainerEventType is the event type that containers generate. + DaemonEventType Type = "daemon" // DaemonEventType is the event type that daemon generate. + ImageEventType Type = "image" // ImageEventType is the event type that images generate. + NetworkEventType Type = "network" // NetworkEventType is the event type that networks generate. + NodeEventType Type = "node" // NodeEventType is the event type that nodes generate. + PluginEventType Type = "plugin" // PluginEventType is the event type that plugins generate. + SecretEventType Type = "secret" // SecretEventType is the event type that secrets generate. + ServiceEventType Type = "service" // ServiceEventType is the event type that services generate. + VolumeEventType Type = "volume" // VolumeEventType is the event type that volumes generate. +) + +// Action is used for event-actions. +type Action string + +const ( + ActionCreate Action = "create" + ActionStart Action = "start" + ActionRestart Action = "restart" + ActionStop Action = "stop" + ActionCheckpoint Action = "checkpoint" + ActionPause Action = "pause" + ActionUnPause Action = "unpause" + ActionAttach Action = "attach" + ActionDetach Action = "detach" + ActionResize Action = "resize" + ActionUpdate Action = "update" + ActionRename Action = "rename" + ActionKill Action = "kill" + ActionDie Action = "die" + ActionOOM Action = "oom" + ActionDestroy Action = "destroy" + ActionRemove Action = "remove" + ActionCommit Action = "commit" + ActionTop Action = "top" + ActionCopy Action = "copy" + ActionArchivePath Action = "archive-path" + ActionExtractToDir Action = "extract-to-dir" + ActionExport Action = "export" + ActionImport Action = "import" + ActionSave Action = "save" + ActionLoad Action = "load" + ActionTag Action = "tag" + ActionUnTag Action = "untag" + ActionPush Action = "push" + ActionPull Action = "pull" + ActionPrune Action = "prune" + ActionDelete Action = "delete" + ActionEnable Action = "enable" + ActionDisable Action = "disable" + ActionConnect Action = "connect" + ActionDisconnect Action = "disconnect" + ActionReload Action = "reload" + ActionMount Action = "mount" + ActionUnmount Action = "unmount" + + // ActionExecCreate is the prefix used for exec_create events. These + // event-actions are commonly followed by a colon and space (": "), + // and the command that's defined for the exec, for example: + // + // exec_create: /bin/sh -c 'echo hello' + // + // This is far from ideal; it's a compromise to allow filtering and + // to preserve backward-compatibility. + ActionExecCreate Action = "exec_create" + // ActionExecStart is the prefix used for exec_create events. These + // event-actions are commonly followed by a colon and space (": "), + // and the command that's defined for the exec, for example: + // + // exec_start: /bin/sh -c 'echo hello' + // + // This is far from ideal; it's a compromise to allow filtering and + // to preserve backward-compatibility. + ActionExecStart Action = "exec_start" + ActionExecDie Action = "exec_die" + ActionExecDetach Action = "exec_detach" + + // ActionHealthStatus is the prefix to use for health_status events. + // + // Health-status events can either have a pre-defined status, in which + // case the "health_status" action is followed by a colon, or can be + // "free-form", in which case they're followed by the output of the + // health-check output. + // + // This is far form ideal, and a compromise to allow filtering, and + // to preserve backward-compatibility. + ActionHealthStatus Action = "health_status" + ActionHealthStatusRunning Action = "health_status: running" + ActionHealthStatusHealthy Action = "health_status: healthy" + ActionHealthStatusUnhealthy Action = "health_status: unhealthy" +) + +// Actor describes something that generates events, +// like a container, or a network, or a volume. +// It has a defined name and a set of attributes. +// The container attributes are its labels, other actors +// can generate these attributes from other properties. +type Actor struct { + ID string + Attributes map[string]string +} + +// Message represents the information an event contains +type Message struct { + Type Type + Action Action + Actor Actor +} + +// ListOptions holds parameters to filter events with. +type ListOptions struct { + Filters filters.Args +} diff --git a/internal/docker/api/types/filters/errors.go b/internal/docker/api/types/filters/errors.go new file mode 100644 index 0000000..0c795d8 --- /dev/null +++ b/internal/docker/api/types/filters/errors.go @@ -0,0 +1,29 @@ +package filters + +/* +This was modified from: +https://github.com/moby/moby/blob/v28.5.1/api/types/filters/errors.go +*/ + +import "fmt" + +// invalidFilter indicates that the provided filter or its value is invalid +type invalidFilter struct { + Filter string + Value []string +} + +func (e invalidFilter) Error() string { + msg := "invalid filter" + if e.Filter != "" { + msg += " '" + e.Filter + if e.Value != nil { + msg = fmt.Sprintf("%s=%s", msg, e.Value) + } + msg += "'" + } + return msg +} + +// InvalidParameter marks this error as ErrInvalidParameter +func (e invalidFilter) InvalidParameter() {} diff --git a/internal/docker/api/types/filters/parse.go b/internal/docker/api/types/filters/parse.go new file mode 100644 index 0000000..cafebde --- /dev/null +++ b/internal/docker/api/types/filters/parse.go @@ -0,0 +1,305 @@ +/* +Package filters provides tools for encoding a mapping of keys to a set of +multiple values. + +This was modified from: +https://github.com/moby/moby/blob/v28.5.1/api/types/filters/parse.go +*/ +package filters + +import ( + "encoding/json" + "regexp" + "strings" +) + +// Args stores a mapping of keys to a set of multiple values. +type Args struct { + fields map[string]map[string]bool +} + +// KeyValuePair are used to initialize a new Args +type KeyValuePair struct { + Key string + Value string +} + +// Arg creates a new KeyValuePair for initializing Args +func Arg(key, value string) KeyValuePair { + return KeyValuePair{Key: key, Value: value} +} + +// NewArgs returns a new Args populated with the initial args +func NewArgs(initialArgs ...KeyValuePair) Args { + args := Args{fields: map[string]map[string]bool{}} + for _, arg := range initialArgs { + args.Add(arg.Key, arg.Value) + } + return args +} + +// Keys returns all the keys in list of Args +func (args Args) Keys() []string { + keys := make([]string, 0, len(args.fields)) + for k := range args.fields { + keys = append(keys, k) + } + return keys +} + +// MarshalJSON returns a JSON byte representation of the Args +func (args Args) MarshalJSON() ([]byte, error) { + if len(args.fields) == 0 { + return []byte("{}"), nil + } + return json.Marshal(args.fields) +} + +// ToJSON returns the Args as a JSON encoded string +func ToJSON(a Args) (string, error) { + if a.Len() == 0 { + return "", nil + } + buf, err := json.Marshal(a) + return string(buf), err +} + +// FromJSON decodes a JSON encoded string into Args +func FromJSON(p string) (Args, error) { + args := NewArgs() + + if p == "" { + return args, nil + } + + raw := []byte(p) + err := json.Unmarshal(raw, &args) + if err == nil { + return args, nil + } + + // Fallback to parsing arguments in the legacy slice format + deprecated := map[string][]string{} + if legacyErr := json.Unmarshal(raw, &deprecated); legacyErr != nil { + return args, &invalidFilter{} + } + + args.fields = deprecatedArgs(deprecated) + return args, nil +} + +// UnmarshalJSON populates the Args from JSON encode bytes +func (args Args) UnmarshalJSON(raw []byte) error { + return json.Unmarshal(raw, &args.fields) +} + +// Get returns the list of values associated with the key +func (args Args) Get(key string) []string { + values := args.fields[key] + if values == nil { + return make([]string, 0) + } + slice := make([]string, 0, len(values)) + for key := range values { + slice = append(slice, key) + } + return slice +} + +// Add a new value to the set of values +func (args Args) Add(key, value string) { + if _, ok := args.fields[key]; ok { + args.fields[key][value] = true + } else { + args.fields[key] = map[string]bool{value: true} + } +} + +// Del removes a value from the set +func (args Args) Del(key, value string) { + if _, ok := args.fields[key]; ok { + delete(args.fields[key], value) + if len(args.fields[key]) == 0 { + delete(args.fields, key) + } + } +} + +// Len returns the number of keys in the mapping +func (args Args) Len() int { + return len(args.fields) +} + +// MatchKVList returns true if all the pairs in sources exist as key=value +// pairs in the mapping at key, or if there are no values at key. +func (args Args) MatchKVList(key string, sources map[string]string) bool { + fieldValues := args.fields[key] + + // do not filter if there is no filter set or cannot determine filter + if len(fieldValues) == 0 { + return true + } + + if len(sources) == 0 { + return false + } + + for value := range fieldValues { + testK, testV, hasValue := strings.Cut(value, "=") + + v, ok := sources[testK] + if !ok { + return false + } + if hasValue && testV != v { + return false + } + } + + return true +} + +// Match returns true if any of the values at key match the source string +func (args Args) Match(field, source string) bool { + if args.ExactMatch(field, source) { + return true + } + + fieldValues := args.fields[field] + for name2match := range fieldValues { + match, err := regexp.MatchString(name2match, source) + if err != nil { + continue + } + if match { + return true + } + } + return false +} + +// GetBoolOrDefault returns a boolean value of the key if the key is present +// and is interpretable as a boolean value. Otherwise the default value is returned. +// Error is not nil only if the filter values are not valid boolean or are conflicting. +func (args Args) GetBoolOrDefault(key string, defaultValue bool) (bool, error) { + fieldValues, ok := args.fields[key] + if !ok { + return defaultValue, nil + } + + if len(fieldValues) == 0 { + return defaultValue, &invalidFilter{key, nil} + } + + isFalse := fieldValues["0"] || fieldValues["false"] + isTrue := fieldValues["1"] || fieldValues["true"] + if isFalse == isTrue { + // Either no or conflicting truthy/falsy value were provided + return defaultValue, &invalidFilter{key, args.Get(key)} + } + return isTrue, nil +} + +// ExactMatch returns true if the source matches exactly one of the values. +func (args Args) ExactMatch(key, source string) bool { + fieldValues, ok := args.fields[key] + // do not filter if there is no filter set or cannot determine filter + if !ok || len(fieldValues) == 0 { + return true + } + + // try to match full name value to avoid O(N) regular expression matching + return fieldValues[source] +} + +// UniqueExactMatch returns true if there is only one value and the source +// matches exactly the value. +func (args Args) UniqueExactMatch(key, source string) bool { + fieldValues := args.fields[key] + // do not filter if there is no filter set or cannot determine filter + if len(fieldValues) == 0 { + return true + } + if len(args.fields[key]) != 1 { + return false + } + + // try to match full name value to avoid O(N) regular expression matching + return fieldValues[source] +} + +// FuzzyMatch returns true if the source matches exactly one value, or the +// source has one of the values as a prefix. +func (args Args) FuzzyMatch(key, source string) bool { + if args.ExactMatch(key, source) { + return true + } + + fieldValues := args.fields[key] + for prefix := range fieldValues { + if strings.HasPrefix(source, prefix) { + return true + } + } + return false +} + +// Contains returns true if the key exists in the mapping +func (args Args) Contains(field string) bool { + _, ok := args.fields[field] + return ok +} + +// Validate compared the set of accepted keys against the keys in the mapping. +// An error is returned if any mapping keys are not in the accepted set. +func (args Args) Validate(accepted map[string]bool) error { + for name := range args.fields { + if !accepted[name] { + return &invalidFilter{name, nil} + } + } + return nil +} + +// WalkValues iterates over the list of values for a key in the mapping and calls +// op() for each value. If op returns an error the iteration stops and the +// error is returned. +func (args Args) WalkValues(field string, op func(value string) error) error { + if _, ok := args.fields[field]; !ok { + return nil + } + for v := range args.fields[field] { + if err := op(v); err != nil { + return err + } + } + return nil +} + +// Clone returns a copy of args. +func (args Args) Clone() (newArgs Args) { + newArgs.fields = make(map[string]map[string]bool, len(args.fields)) + for k, m := range args.fields { + var mm map[string]bool + if m != nil { + mm = make(map[string]bool, len(m)) + for kk, v := range m { + mm[kk] = v + } + } + newArgs.fields[k] = mm + } + return newArgs +} + +func deprecatedArgs(d map[string][]string) map[string]map[string]bool { + m := map[string]map[string]bool{} + for k, v := range d { + values := map[string]bool{} + for _, vv := range v { + values[vv] = true + } + m[k] = values + } + return m +} diff --git a/internal/docker/api/types/network/endpoint.go b/internal/docker/api/types/network/endpoint.go new file mode 100644 index 0000000..1fdec47 --- /dev/null +++ b/internal/docker/api/types/network/endpoint.go @@ -0,0 +1,25 @@ +package network + +/* +This was modified from: +https://github.com/moby/moby/blob/v28.5.1/api/types/network/endpoint.go +*/ + +// EndpointSettings stores the network endpoint details +type EndpointSettings struct { + // Operational data + NetworkID string + EndpointID string + Gateway string + IPAddress string + IPPrefixLen int + IPv6Gateway string + GlobalIPv6Address string + GlobalIPv6PrefixLen int +} + +// Copy makes a deep copy of `EndpointSettings` +func (es *EndpointSettings) Copy() *EndpointSettings { + epCopy := *es + return &epCopy +} diff --git a/internal/docker/api/types/types.go b/internal/docker/api/types/types.go new file mode 100644 index 0000000..0098f0e --- /dev/null +++ b/internal/docker/api/types/types.go @@ -0,0 +1,12 @@ +package types + +/* +This was modified from: +https://github.com/moby/moby/blob/v28.5.1/api/types/types.go +*/ + +// Ping contains response of Engine API: +// GET "/_ping" +type Ping struct { + APIVersion string +} diff --git a/internal/docker/api/types/versions/compare.go b/internal/docker/api/types/versions/compare.go new file mode 100644 index 0000000..ee76f1f --- /dev/null +++ b/internal/docker/api/types/versions/compare.go @@ -0,0 +1,50 @@ +package versions + +/* +This was modified from: +https://github.com/moby/moby/blob/v28.5.1/api/types/versions/compare.go +*/ + +import ( + "strconv" + "strings" +) + +// compare compares two version strings +// returns -1 if v1 < v2, 1 if v1 > v2, 0 otherwise. +func compare(v1, v2 string) int { + if v1 == v2 { + return 0 + } + var ( + currTab = strings.Split(v1, ".") + otherTab = strings.Split(v2, ".") + ) + + maxVer := len(currTab) + if len(otherTab) > maxVer { + maxVer = len(otherTab) + } + for i := 0; i < maxVer; i++ { + var currInt, otherInt int + + if len(currTab) > i { + currInt, _ = strconv.Atoi(currTab[i]) + } + if len(otherTab) > i { + otherInt, _ = strconv.Atoi(otherTab[i]) + } + if currInt > otherInt { + return 1 + } + if otherInt > currInt { + return -1 + } + } + return 0 +} + +// LessThan checks if a version is less than another +func LessThan(v, other string) bool { + return compare(v, other) == -1 +} diff --git a/internal/docker/client/client.go b/internal/docker/client/client.go new file mode 100644 index 0000000..a77edee --- /dev/null +++ b/internal/docker/client/client.go @@ -0,0 +1,257 @@ +/* +Package client is a Go client for the Docker Engine API. + +For more information about the Engine API, see the documentation: +https://docs.docker.com/reference/api/engine/ + +This was modified from: +https://github.com/moby/moby/blob/v28.5.1/client/client.go +*/ +package client + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "path" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/wollomatic/socket-proxy/internal/docker/api" + "github.com/wollomatic/socket-proxy/internal/docker/api/types" + "github.com/wollomatic/socket-proxy/internal/docker/api/types/versions" + "github.com/wollomatic/socket-proxy/internal/go-connections/sockets" +) + +// DefaultDockerHost defines default host +const DefaultDockerHost = "unix:///var/run/docker.sock" + +// DummyHost is a hostname used for local communication. +const DummyHost = "api.moby.localhost" + +// fallbackAPIVersion is the version to fallback to if API-version negotiation +// fails. This version is the highest version of the API before API-version +// negotiation was introduced. If negotiation fails (or no API version was +// included in the API response), we assume the API server uses the most +// recent version before negotiation was introduced. +const fallbackAPIVersion = "1.24" + +// Client is the API client that performs all operations +// against a docker server. +type Client struct { + // scheme sets the scheme for the client + scheme string + // host holds the server address to connect to + host string + // proto holds the client protocol i.e. unix. + proto string + // addr holds the client address. + addr string + // basePath holds the path to prepend to the requests. + basePath string + // client used to send and receive http requests. + client *http.Client + // version of the server to talk to. + version string + // userAgent is the User-Agent header to use for HTTP requests. It takes + // precedence over User-Agent headers set in customHTTPHeaders, and other + // header variables. When set to an empty string, the User-Agent header + // is removed, and no header is sent. + userAgent *string + // custom HTTP headers configured by users. + customHTTPHeaders map[string]string + + // negotiateVersion indicates if the client should automatically negotiate + // the API version to use when making requests. API version negotiation is + // performed on the first request, after which negotiated is set to "true" + // so that subsequent requests do not re-negotiate. + negotiateVersion bool + + // negotiated indicates that API version negotiation took place + negotiated atomic.Bool + + // negotiateLock is used to single-flight the version negotiation process + negotiateLock sync.Mutex + + // When the client transport is an *http.Transport (default) we need to do some extra things (like closing idle connections). + // Store the original transport as the http.Client transport will be wrapped with tracing libs. + baseTransport *http.Transport +} + +// ErrRedirect is the error returned by checkRedirect when the request is non-GET. +var ErrRedirect = errors.New("unexpected redirect in response") + +// CheckRedirect specifies the policy for dealing with redirect responses. It +// can be set on [http.Client.CheckRedirect] to prevent HTTP redirects for +// non-GET requests. It returns an [ErrRedirect] for non-GET request, otherwise +// returns a [http.ErrUseLastResponse], which is special-cased by http.Client +// to use the last response. +// +// Go 1.8 changed behavior for HTTP redirects (specifically 301, 307, and 308) +// in the client. The client (and by extension API client) can be made to send +// a request like "POST /containers//start" where what would normally be in the +// name section of the URL is empty. This triggers an HTTP 301 from the daemon. +// +// In go 1.8 this 301 is converted to a GET request, and ends up getting +// a 404 from the daemon. This behavior change manifests in the client in that +// before, the 301 was not followed and the client did not generate an error, +// but now results in a message like "Error response from daemon: page not found". +func CheckRedirect(_ *http.Request, via []*http.Request) error { + if via[0].Method == http.MethodGet { + return http.ErrUseLastResponse + } + return ErrRedirect +} + +// NewClientWithOpts initializes a new API client with a default HTTPClient, and +// default API host and version. It also initializes the custom HTTP headers to +// add to each request. +func NewClientWithOpts(ops ...Opt) (*Client, error) { + hostURL, err := ParseHostURL(DefaultDockerHost) + if err != nil { + return nil, err + } + + client, err := defaultHTTPClient(hostURL) + if err != nil { + return nil, err + } + c := &Client{ + host: DefaultDockerHost, + version: api.DefaultVersion, + client: client, + proto: hostURL.Scheme, + addr: hostURL.Host, + scheme: "http", + } + + for _, op := range ops { + if err := op(c); err != nil { + return nil, err + } + } + + if tr, ok := c.client.Transport.(*http.Transport); ok { + // Store the base transport + // This is used, as an example, to close idle connections when the client is closed + c.baseTransport = tr + } + + return c, nil +} + +func defaultHTTPClient(hostURL *url.URL) (*http.Client, error) { + transport := &http.Transport{} + // Necessary to prevent long-lived processes using the + // client from leaking connections due to idle connections + // not being released. + transport.MaxIdleConns = 6 + transport.IdleConnTimeout = 30 * time.Second + err := sockets.ConfigureTransport(transport, hostURL.Scheme, hostURL.Host) + if err != nil { + return nil, err + } + return &http.Client{ + Transport: transport, + CheckRedirect: CheckRedirect, + }, nil +} + +// Close the transport used by the client +func (cli *Client) Close() error { + if cli.baseTransport != nil { + cli.baseTransport.CloseIdleConnections() + return nil + } + return nil +} + +// checkVersion manually triggers API version negotiation (if configured). +// This allows for version-dependent code to use the same version as will +// be negotiated when making the actual requests, and for which cases +// we cannot do the negotiation lazily. +func (cli *Client) checkVersion(ctx context.Context) error { + if cli.negotiateVersion && !cli.negotiated.Load() { + // Ensure exclusive write access to version and negotiated fields + cli.negotiateLock.Lock() + defer cli.negotiateLock.Unlock() + + // May have been set during last execution of critical zone + if cli.negotiated.Load() { + return nil + } + + ping, err := cli.Ping(ctx) + if err != nil { + return err + } + cli.negotiateAPIVersionPing(ping) + } + return nil +} + +// getAPIPath returns the versioned request path to call the API. +// It appends the query parameters to the path if they are not empty. +func (cli *Client) getAPIPath(ctx context.Context, p string, query url.Values) string { + var apiPath string + _ = cli.checkVersion(ctx) + if cli.version != "" { + apiPath = path.Join(cli.basePath, "/v"+strings.TrimPrefix(cli.version, "v"), p) + } else { + apiPath = path.Join(cli.basePath, p) + } + return (&url.URL{Path: apiPath, RawQuery: query.Encode()}).String() +} + +// negotiateAPIVersionPing queries the API and updates the version to match the +// API version from the ping response. +func (cli *Client) negotiateAPIVersionPing(pingResponse types.Ping) { + // default to the latest version before versioning headers existed + if pingResponse.APIVersion == "" { + pingResponse.APIVersion = fallbackAPIVersion + } + + // if the client is not initialized with a version, start with the latest supported version + if cli.version == "" { + cli.version = api.DefaultVersion + } + + // if server version is lower than the client version, downgrade + if versions.LessThan(pingResponse.APIVersion, cli.version) { + cli.version = pingResponse.APIVersion + } + + // Store the results, so that automatic API version negotiation (if enabled) + // won't be performed on the next request. + if cli.negotiateVersion { + cli.negotiated.Store(true) + } +} + +// ParseHostURL parses a url string, validates the string is a host url, and +// returns the parsed URL +func ParseHostURL(host string) (*url.URL, error) { + proto, addr, ok := strings.Cut(host, "://") + if !ok || addr == "" { + return nil, fmt.Errorf("unable to parse docker host `%s`", host) + } + + var basePath string + if proto == "tcp" { + parsed, err := url.Parse("tcp://" + addr) + if err != nil { + return nil, err + } + addr = parsed.Host + basePath = parsed.Path + } + return &url.URL{ + Scheme: proto, + Host: addr, + Path: basePath, + }, nil +} diff --git a/internal/docker/client/container_list.go b/internal/docker/client/container_list.go new file mode 100644 index 0000000..3d12313 --- /dev/null +++ b/internal/docker/client/container_list.go @@ -0,0 +1,39 @@ +package client + +/* +This was modified from: +https://github.com/moby/moby/blob/v28.5.1/client/container_list.go +*/ + +import ( + "context" + "encoding/json" + "net/url" + + "github.com/wollomatic/socket-proxy/internal/docker/api/types/container" + "github.com/wollomatic/socket-proxy/internal/docker/api/types/filters" +) + +// ContainerList returns the list of containers in the docker host. +func (cli *Client) ContainerList(ctx context.Context, options container.ListOptions) ([]container.Summary, error) { + query := url.Values{} + + if options.Filters.Len() > 0 { + filterJSON, err := filters.ToJSON(options.Filters) + if err != nil { + return nil, err + } + + query.Set("filters", filterJSON) + } + + resp, err := cli.get(ctx, "/containers/json", query, nil) + defer ensureReaderClosed(resp) + if err != nil { + return nil, err + } + + var containers []container.Summary + err = json.NewDecoder(resp.Body).Decode(&containers) + return containers, err +} diff --git a/internal/docker/client/errors.go b/internal/docker/client/errors.go new file mode 100644 index 0000000..12fd231 --- /dev/null +++ b/internal/docker/client/errors.go @@ -0,0 +1,42 @@ +package client + +/* +This was modified from: +https://github.com/moby/moby/blob/v28.5.1/client/errors.go +*/ + +import ( + "errors" + "fmt" +) + +// errConnectionFailed implements an error returned when connection failed. +type errConnectionFailed struct { + error +} + +// Error returns a string representation of an errConnectionFailed +func (e errConnectionFailed) Error() string { + return e.error.Error() +} + +func (e errConnectionFailed) Unwrap() error { + return e.error +} + +// IsErrConnectionFailed returns true if the error is caused by connection failed. +func IsErrConnectionFailed(err error) bool { + return errors.As(err, &errConnectionFailed{}) +} + +// connectionFailed returns an error with host in the error message when connection +// to docker daemon failed. +func connectionFailed(host string) error { + var err error + if host == "" { + err = errors.New("cannot connect to the Docker daemon: is the docker daemon running on this host?") + } else { + err = fmt.Errorf("cannot connect to the Docker daemon at %s: is the docker daemon running?", host) + } + return errConnectionFailed{error: err} +} diff --git a/internal/docker/client/events.go b/internal/docker/client/events.go new file mode 100644 index 0000000..cf7d827 --- /dev/null +++ b/internal/docker/client/events.go @@ -0,0 +1,85 @@ +package client + +/* +This was modified from: +https://github.com/moby/moby/blob/v28.5.1/client/events.go +*/ + +import ( + "context" + "encoding/json" + "net/url" + + "github.com/wollomatic/socket-proxy/internal/docker/api/types/events" + "github.com/wollomatic/socket-proxy/internal/docker/api/types/filters" +) + +// Events returns a stream of events in the daemon. It's up to the caller to close the stream +// by cancelling the context. Once the stream has been completely read an io.EOF error will +// be sent over the error channel. If an error is sent all processing will be stopped. It's up +// to the caller to reopen the stream in the event of an error by reinvoking this method. +func (cli *Client) Events(ctx context.Context, options events.ListOptions) (<-chan events.Message, <-chan error) { + messages := make(chan events.Message) + errs := make(chan error, 1) + + started := make(chan struct{}) + go func() { + defer close(errs) + + query, err := buildEventsQueryParams(options) + if err != nil { + close(started) + errs <- err + return + } + + resp, err := cli.get(ctx, "/events", query, nil) + if err != nil { + close(started) + errs <- err + return + } + defer resp.Body.Close() + + decoder := json.NewDecoder(resp.Body) + + close(started) + for { + select { + case <-ctx.Done(): + errs <- ctx.Err() + return + default: + var event events.Message + if err := decoder.Decode(&event); err != nil { + errs <- err + return + } + + select { + case messages <- event: + case <-ctx.Done(): + errs <- ctx.Err() + return + } + } + } + }() + <-started + + return messages, errs +} + +func buildEventsQueryParams(options events.ListOptions) (url.Values, error) { + query := url.Values{} + + if options.Filters.Len() > 0 { + filterJSON, err := filters.ToJSON(options.Filters) + if err != nil { + return nil, err + } + query.Set("filters", filterJSON) + } + + return query, nil +} diff --git a/internal/docker/client/options.go b/internal/docker/client/options.go new file mode 100644 index 0000000..7464a05 --- /dev/null +++ b/internal/docker/client/options.go @@ -0,0 +1,45 @@ +package client + +/* +This was modified from: +https://github.com/moby/moby/blob/v28.5.1/client/options.go +*/ + +import ( + "fmt" + "net/http" + + "github.com/wollomatic/socket-proxy/internal/go-connections/sockets" +) + +// Opt is a configuration option to initialize a [Client]. +type Opt func(*Client) error + +// WithHost overrides the client host with the specified one. +func WithHost(host string) Opt { + return func(c *Client) error { + hostURL, err := ParseHostURL(host) + if err != nil { + return err + } + c.host = host + c.proto = hostURL.Scheme + c.addr = hostURL.Host + c.basePath = hostURL.Path + if transport, ok := c.client.Transport.(*http.Transport); ok { + return sockets.ConfigureTransport(transport, c.proto, c.addr) + } + return fmt.Errorf("cannot apply host to transport: %v", c.client.Transport) + } +} + +// WithAPIVersionNegotiation enables automatic API version negotiation for the client. +// With this option enabled, the client automatically negotiates the API version +// to use when making requests. API version negotiation is performed on the first +// request; subsequent requests do not re-negotiate. +func WithAPIVersionNegotiation() Opt { + return func(c *Client) error { + c.negotiateVersion = true + return nil + } +} diff --git a/internal/docker/client/ping.go b/internal/docker/client/ping.go new file mode 100644 index 0000000..619a4d8 --- /dev/null +++ b/internal/docker/client/ping.go @@ -0,0 +1,68 @@ +package client + +/* +This was modified from: +https://github.com/moby/moby/blob/v28.5.1/client/ping.go +*/ + +import ( + "context" + "net/http" + "path" + + "github.com/wollomatic/socket-proxy/internal/docker/api/types" +) + +// Ping pings the server and returns the value of the "API-Version" header. +// It attempts to use a HEAD request on the endpoint, but falls back to GET if +// HEAD is not supported by the daemon. It ignores internal server errors +// returned by the API, which may be returned if the daemon is in an unhealthy +// state, but returns errors for other non-success status codes, failing to +// connect to the API, or failing to parse the API response. +func (cli *Client) Ping(ctx context.Context) (types.Ping, error) { + var ping types.Ping + + // Using cli.buildRequest() + cli.doRequest() instead of cli.sendRequest() + // because ping requests are used during API version negotiation, so we want + // to hit the non-versioned /_ping endpoint, not /v1.xx/_ping + req, err := cli.buildRequest(ctx, http.MethodHead, path.Join(cli.basePath, "/_ping"), nil, nil) + if err != nil { + return ping, err + } + resp, err := cli.doRequest(req) + if err != nil { + if IsErrConnectionFailed(err) { + return ping, err + } + // We managed to connect, but got some error; continue and try GET request. + } else { + defer ensureReaderClosed(resp) + switch resp.StatusCode { + case http.StatusOK, http.StatusInternalServerError: + // Server handled the request, so parse the response + return parsePingResponse(cli, resp) + } + } + + // HEAD failed; fallback to GET. + req.Method = http.MethodGet + resp, err = cli.doRequest(req) + defer ensureReaderClosed(resp) + if err != nil { + return ping, err + } + return parsePingResponse(cli, resp) +} + +func parsePingResponse(cli *Client, resp *http.Response) (types.Ping, error) { + if resp == nil { + return types.Ping{}, nil + } + + var ping types.Ping + if resp.Header == nil { + return ping, cli.checkResponseErr(resp) + } + ping.APIVersion = resp.Header.Get("Api-Version") + return ping, cli.checkResponseErr(resp) +} diff --git a/internal/docker/client/request.go b/internal/docker/client/request.go new file mode 100644 index 0000000..af7577f --- /dev/null +++ b/internal/docker/client/request.go @@ -0,0 +1,219 @@ +package client + +/* +This was modified from: +https://github.com/moby/moby/blob/v28.5.1/client/request.go +*/ + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "strings" + + "github.com/wollomatic/socket-proxy/internal/docker/api/types" + "github.com/wollomatic/socket-proxy/internal/docker/api/types/versions" +) + +// get sends an http request to the docker API using the method GET with a specific Go context. +func (cli *Client) get(ctx context.Context, path string, query url.Values, headers http.Header) (*http.Response, error) { + return cli.sendRequest(ctx, http.MethodGet, path, query, nil, headers) +} + +func (cli *Client) buildRequest(ctx context.Context, method, path string, body io.Reader, headers http.Header) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, method, path, body) + if err != nil { + return nil, err + } + req = cli.addHeaders(req, headers) + req.URL.Scheme = cli.scheme + req.URL.Host = cli.addr + + if cli.proto == "unix" { + // Override host header for non-tcp connections. + req.Host = DummyHost + } + + if body != nil && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "text/plain") + } + return req, nil +} + +func (cli *Client) sendRequest(ctx context.Context, method, path string, query url.Values, body io.Reader, headers http.Header) (*http.Response, error) { + req, err := cli.buildRequest(ctx, method, cli.getAPIPath(ctx, path, query), body, headers) + if err != nil { + return nil, err + } + + resp, err := cli.doRequest(req) + switch { + case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): + return nil, err + case err == nil: + return resp, cli.checkResponseErr(resp) + default: + return resp, err + } +} + +func (cli *Client) doRequest(req *http.Request) (*http.Response, error) { + resp, err := cli.client.Do(req) + if err != nil { + // Don't decorate context sentinel errors; users may be comparing to + // them directly. + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return nil, err + } + + var uErr *url.Error + if errors.As(err, &uErr) { + var nErr *net.OpError + if errors.As(uErr.Err, &nErr) { + if os.IsPermission(nErr.Err) { + return nil, errConnectionFailed{fmt.Errorf("permission denied while trying to connect to the Docker daemon socket at %v: %v", cli.host, err)} + } + } + } + + var nErr net.Error + if errors.As(err, &nErr) { + if nErr.Timeout() { + return nil, connectionFailed(cli.host) + } + if strings.Contains(nErr.Error(), "connection refused") || strings.Contains(nErr.Error(), "dial unix") { + return nil, connectionFailed(cli.host) + } + } + + return nil, errConnectionFailed{fmt.Errorf("error during connect: %v", err)} + } + + return resp, nil +} + +func (cli *Client) checkResponseErr(serverResp *http.Response) (retErr error) { + if serverResp == nil { + return nil + } + if serverResp.StatusCode >= http.StatusOK && serverResp.StatusCode < http.StatusBadRequest { + return nil + } + defer func() { + if retErr != nil { + retErr = fmt.Errorf("HTTP error %d: %v", serverResp.StatusCode, retErr) + } + }() + + var body []byte + var err error + var reqURL string + if serverResp.Request != nil { + reqURL = serverResp.Request.URL.String() + } + statusMsg := serverResp.Status + if statusMsg == "" { + statusMsg = http.StatusText(serverResp.StatusCode) + } + if serverResp.Body != nil { + bodyMax := 1 * 1024 * 1024 // 1 MiB + bodyR := &io.LimitedReader{ + R: serverResp.Body, + N: int64(bodyMax), + } + body, err = io.ReadAll(bodyR) + if err != nil { + return err + } + if bodyR.N == 0 { + if reqURL != "" { + return fmt.Errorf("request returned %s with a message (> %d bytes) for API route and version %s, check if the server supports the requested API version", statusMsg, bodyMax, reqURL) + } + return fmt.Errorf("request returned %s with a message (> %d bytes); check if the server supports the requested API version", statusMsg, bodyMax) + } + } + if len(body) == 0 { + if reqURL != "" { + return fmt.Errorf("request returned %s for API route and version %s, check if the server supports the requested API version", statusMsg, reqURL) + } + return fmt.Errorf("request returned %s; check if the server supports the requested API version", statusMsg) + } + + var daemonErr error + if serverResp.Header.Get("Content-Type") == "application/json" { + var errorResponse types.ErrorResponse + if err := json.Unmarshal(body, &errorResponse); err != nil { + return fmt.Errorf("Error reading JSON: %v", err) + } + if errorResponse.Message == "" { + // Error-message is empty, which means that we successfully parsed the + // JSON-response (no error produced), but it didn't contain an error + // message. This could either be because the response was empty, or + // the response was valid JSON, but not with the expected schema + // ([types.ErrorResponse]). + // + // We cannot use "strict" JSON handling (json.NewDecoder with DisallowUnknownFields) + // due to the API using an open schema (we must anticipate fields + // being added to [types.ErrorResponse] in the future, and not + // reject those responses. + // + // For these cases, we construct an error with the status-code + // returned, but we could consider returning (a truncated version + // of) the actual response as-is. + + daemonErr = fmt.Errorf(`API returned a %d (%s) but provided no error-message`, + serverResp.StatusCode, + http.StatusText(serverResp.StatusCode), + ) + } else { + daemonErr = errors.New(strings.TrimSpace(errorResponse.Message)) + } + } else { + // Fall back to returning the response as-is for API versions < 1.24 + // that didn't support JSON error responses, and for situations + // where a plain text error is returned. This branch may also catch + // situations where a proxy is involved, returning a HTML response. + daemonErr = errors.New(strings.TrimSpace(string(body))) + } + return fmt.Errorf("Error response from daemon: %v", daemonErr) +} + +func (cli *Client) addHeaders(req *http.Request, headers http.Header) *http.Request { + // Add CLI Config's HTTP Headers BEFORE we set the Docker headers + // then the user can't change OUR headers + for k, v := range cli.customHTTPHeaders { + if versions.LessThan(cli.version, "1.25") && http.CanonicalHeaderKey(k) == "User-Agent" { + continue + } + req.Header.Set(k, v) + } + + for k, v := range headers { + req.Header[http.CanonicalHeaderKey(k)] = v + } + + if cli.userAgent != nil { + if *cli.userAgent == "" { + req.Header.Del("User-Agent") + } else { + req.Header.Set("User-Agent", *cli.userAgent) + } + } + return req +} + +func ensureReaderClosed(response *http.Response) { + if response != nil && response.Body != nil { + // Drain up to 512 bytes and close the body to let the Transport reuse the connection + // see https://github.com/google/go-github/pull/317/files#r57536827 + + _, _ = io.CopyN(io.Discard, response.Body, 512) + _ = response.Body.Close() + } +} diff --git a/internal/go-connections/sockets/sockets.go b/internal/go-connections/sockets/sockets.go new file mode 100644 index 0000000..0d889e8 --- /dev/null +++ b/internal/go-connections/sockets/sockets.go @@ -0,0 +1,69 @@ +/* +Package sockets provides helper functions to create and configure Unix or TCP sockets. + +This was modified from: +https://github.com/docker/go-connections/blob/v0.6.0/sockets/sockets.go +*/ +package sockets + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "syscall" + "time" +) + +const ( + defaultTimeout = 10 * time.Second + maxUnixSocketPathSize = len(syscall.RawSockaddrUnix{}.Path) +) + +// ErrProtocolNotAvailable is returned when a given transport protocol is not provided by the operating system. +var ErrProtocolNotAvailable = errors.New("protocol not available") + +// ConfigureTransport configures the specified [http.Transport] according to the specified proto +// and addr. +// +// If the proto is unix (using a unix socket to communicate) or npipe the compression is disabled. +// For other protos, compression is enabled. If you want to manually enable/disable compression, +// make sure you do it _after_ any subsequent calls to ConfigureTransport is made against the same +// [http.Transport]. +func ConfigureTransport(tr *http.Transport, proto, addr string) error { + if tr.MaxIdleConns == 0 { + // prevent long-lived processes from leaking connections + // due to idle connections not being released. + // + // TODO: see if we can also address this from the server side; see: https://github.com/moby/moby/issues/45539 + tr.MaxIdleConns = 6 + tr.IdleConnTimeout = 30 * time.Second + } + switch proto { + case "unix": + return configureUnixTransport(tr, addr) + default: + tr.Proxy = http.ProxyFromEnvironment + tr.DisableCompression = false + tr.DialContext = (&net.Dialer{ + Timeout: defaultTimeout, + }).DialContext + } + return nil +} + +func configureUnixTransport(tr *http.Transport, addr string) error { + if len(addr) > maxUnixSocketPathSize { + return fmt.Errorf("unix socket path %q is too long", addr) + } + // No need for compression in local communications. + tr.DisableCompression = true + dialer := &net.Dialer{ + Timeout: defaultTimeout, + } + tr.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) { + return dialer.DialContext(ctx, "unix", addr) + } + return nil +} From af71252d7658a6f3dac3103e3c18c5722750aba3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Ells=C3=A4sser?= <67168186+wollomatic@users.noreply.github.com> Date: Sun, 23 Nov 2025 17:52:42 +0100 Subject: [PATCH 26/38] Update README.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Wolfgang Ellsässer <67168186+wollomatic@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5581891..b6e3548 100644 --- a/README.md +++ b/README.md @@ -265,7 +265,7 @@ This project is licensed under the MIT License – see the [LICENSE](LICENSE) fi Parts of the file `cmd/socket-proxy/bindmount.go` and files under the `internal/docker` and `internal/go-connections` folders are licensed under the Apache 2.0 License. See the comments in this file and the LICENSE file for more information. -## Aknowledgements +## Acknowledgements + [Chris Wiegman: Protecting Your Docker Socket With Traefik 2](https://chriswiegman.com/2019/11/protecting-your-docker-socket-with-traefik-2/) [@ChrisWiegman](https://github.com/ChrisWiegman) + [tecnativa/docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy) From 602981afcb33c29aaed28d70124b794f53043e88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Ells=C3=A4sser?= <67168186+wollomatic@users.noreply.github.com> Date: Sun, 23 Nov 2025 17:53:01 +0100 Subject: [PATCH 27/38] Update internal/docker/api/types/events/events.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Wolfgang Ellsässer <67168186+wollomatic@users.noreply.github.com> --- internal/docker/api/types/events/events.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/docker/api/types/events/events.go b/internal/docker/api/types/events/events.go index d8c7a63..7f24c66 100644 --- a/internal/docker/api/types/events/events.go +++ b/internal/docker/api/types/events/events.go @@ -97,7 +97,7 @@ const ( // "free-form", in which case they're followed by the output of the // health-check output. // - // This is far form ideal, and a compromise to allow filtering, and + // This is far from ideal, and a compromise to allow filtering, and // to preserve backward-compatibility. ActionHealthStatus Action = "health_status" ActionHealthStatusRunning Action = "health_status: running" From 17e00e276eb1f4cbc0602344358abc20c6b9e67b Mon Sep 17 00:00:00 2001 From: Amanda Wee Date: Mon, 24 Nov 2025 17:22:04 +1300 Subject: [PATCH 28/38] Update internal/config/config.go To fix number of retries when querying Docker Engine API for proxy container summary. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Amanda Wee --- internal/config/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/config/config.go b/internal/config/config.go index c6c93e7..aa7f5d0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -618,7 +618,7 @@ func getSocketProxyContainerSummary(socketPath, proxyContainerName string) (cont filter := filters.NewArgs() filter.Add("name", proxyContainerName) var containers []container.Summary - for i := 1; i < maxTries; i++ { + for i := 1; i <= maxTries; i++ { containers, err = dockerClient.ContainerList(ctx, container.ListOptions{Filters: filter}) if err != nil { return container.Summary{}, err From bb61be76f24acd82ce7fe14a57e110bea5eed553 Mon Sep 17 00:00:00 2001 From: Amanda Wee Date: Mon, 24 Nov 2025 17:22:51 +1300 Subject: [PATCH 29/38] Update README.md To fix formatting of SP_PROXYCONTAINERNAME Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Amanda Wee --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b6e3548..0324ba2 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,7 @@ socket-proxy can be configured via command line parameters or via environment va | `-watchdoginterval` | `SP_WATCHDOGINTERVAL` | `0` | Check for socket availability every x seconds (disable checks, if not set or value is 0) | | `-proxysocketendpoint` | `SP_PROXYSOCKETENDPOINT` | (not set) | Proxy to the given unix socket instead of a TCP port | | `-proxysocketendpointfilemode` | `SP_PROXYSOCKETENDPOINTFILEMODE` | `0600` | Explicitly set the file mode for the filtered unix socket endpoint (only useful with `-proxysocketendpoint`) | -| `-proxycontainername` | `SP_PROXYCONTAINERNAME ` | (not set) | Provides the name of the socket proxy container to enable per-container allowlists specified by Docker container labels (not available with `-proxysocketendpoint`) | +| `-proxycontainername` | `SP_PROXYCONTAINERNAME` | (not set) | Provides the name of the socket proxy container to enable per-container allowlists specified by Docker container labels (not available with `-proxysocketendpoint`) | ### Changelog From 1a84d37ab2ca2ba68b93d8717d7708139de2d120 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Ells=C3=A4sser?= <67168186+wollomatic@users.noreply.github.com> Date: Thu, 4 Dec 2025 21:15:59 +0100 Subject: [PATCH 30/38] Update internal/docker/client/request.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Wolfgang Ellsässer <67168186+wollomatic@users.noreply.github.com> --- internal/docker/client/request.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/docker/client/request.go b/internal/docker/client/request.go index af7577f..d33103c 100644 --- a/internal/docker/client/request.go +++ b/internal/docker/client/request.go @@ -181,7 +181,7 @@ func (cli *Client) checkResponseErr(serverResp *http.Response) (retErr error) { // situations where a proxy is involved, returning a HTML response. daemonErr = errors.New(strings.TrimSpace(string(body))) } - return fmt.Errorf("Error response from daemon: %v", daemonErr) + return fmt.Errorf("error response from daemon: %v", daemonErr) } func (cli *Client) addHeaders(req *http.Request, headers http.Header) *http.Request { From 4315e122ac7cf222ca0df7cefd41ba4e8fe2999e Mon Sep 17 00:00:00 2001 From: wollomatic Date: Thu, 4 Dec 2025 21:20:11 +0100 Subject: [PATCH 31/38] update README --- README.md | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 2126b42..e0b6406 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # socket-proxy ## Latest image -- `wollomatic/socket-proxy:1.10.0` / `ghcr.io/wollomatic/socket-proxy:1.10.0` +- `wollomatic/socket-proxy:1.11.0` / `ghcr.io/wollomatic/socket-proxy:1.11.0` - `wollomatic/socket-proxy:1` / `ghcr.io/wollomatic/socket-proxy:1` > [!IMPORTANT] @@ -41,7 +41,7 @@ You should know what you are doing. Never expose socket-proxy to a public networ The container image is available on [Docker Hub (wollomatic/socket-proxy)](https://hub.docker.com/r/wollomatic/socket-proxy) and on the [GitHub Container Registry (ghcr.io/wollomatic/socket-proxy)](https://github.com/wollomatic/socket-proxy/pkgs/container/socket-proxy). -To pin one specific version, use the version tag (for example, `wollomatic/socket-proxy:1.10.0` or `ghcr.io/wollomatic/socket-proxy:1.10.0`). +To pin one specific version, use the version tag (for example, `wollomatic/socket-proxy:1.11.0` or `ghcr.io/wollomatic/socket-proxy:1.11.0`). To always use the most recent version, use the `1` tag (`wollomatic/socket-proxy:1` or `ghcr.io/wollomatic/socket-proxy:1`). This tag will be valid as long as there is no breaking change in the deployment. There may be an additional docker image with the `testing`-tag. This image is only for testing. Likely, documentation for the `testing` image could only be found in the GitHub commit messages. It is not recommended to use the `testing` image in production. @@ -82,21 +82,21 @@ Using the hostname is an easy-to-configure way to have more security. Access to You must set up regular expressions for each HTTP method the client application needs access to. -The name of a parameter should be "-allow", followed by the HTTP method name (for example, `-allowGET`). The request will be allowed if that parameter is set and the incoming request matches the method and path matching the regexp. If it is not set, then the corresponding HTTP method will not be allowed. +The name of a parameter should be "-allow", followed by the HTTP method name (for example, `-allowGET`). The request will be allowed if that parameter is set and the incoming request matches the method and path matching the regexp. If unset, the corresponding HTTP method is disallowed. -It is also possible to configure the allowlist via environment variables. The variables are called "SP_ALLOW_", followed by the HTTP method (for example, `SP_ALLLOW_GET`). +It is also possible to configure the allowlist via environment variables. The variables are called "SP_ALLOW_", followed by the HTTP method (for example, `SP_ALLOW_GET`). -If both commandline parameter and environment variable are configured for a particular HTTP method, the environment variable is ignored. +If both command-line parameter and environment variable are configured for a particular HTTP method, the environment variable is ignored. -Use Go's regexp syntax to create the patterns for these parameters. To avoid insecure configurations, the characters ^ at the beginning and $ at the end of the string are automatically added. Note: invalid regexp results in program termination. +Use Go's regexp syntax to create the patterns for these parameters. To avoid insecure configurations, `^` and `$` are added automatically to the start and end of the pattern. Note: invalid regexp results in program termination. -Examples (command line): +Examples (command-line): + `'-allowGET=/v1\..{1,2}/(version|containers/.*|events.*)'` could be used for allowing access to the docker socket for Traefik v2. -+ `'-allowHEAD=.*` allows all HEAD requests. ++ `'-allowHEAD=.*'` allows all HEAD requests. Examples (env variables): + `'SP_ALLOW_GET="/v1\..{1,2}/(version|containers/.*|events.*)"'` could be used for allowing access to the docker socket for Traefik v2. -+ `'SP_ALLOW_HEAD=".*"` allows all HEAD requests. ++ `'SP_ALLOW_HEAD=".*"'` allows all HEAD requests. For more information, refer to the [Go regexp documentation](https://golang.org/pkg/regexp/syntax/). @@ -224,7 +224,7 @@ To log the API calls of the client application, set the log level to `DEBUG` and ### all parameters and environment variables -socket-proxy can be configured via command line parameters or via environment variables. If both command line parameters and environment variables are set, the environment variable will be ignored. +socket-proxy can be configured via command-line parameters or via environment variables. If both command-line parameters and environment variables are set, the environment variable will be ignored. | Parameter | Environment Variable | Default Value | Description | |--------------------------------|----------------------------------|------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| @@ -232,10 +232,10 @@ socket-proxy can be configured via command line parameters or via environment va | `-allowbindmountfrom` | `SP_ALLOWBINDMOUNTFROM` | (not set) | Specifies the directories (comma-separated) that are allowed as bind mount sources. If not set, no bind mount restrictions are applied. When set, only bind mounts from the specified directories or their subdirectories are allowed. Each directory must start with `/`. For example, `-allowbindmountfrom=/home,/var/log` allows bind mounts from `/home`, `/var/log`, and any subdirectories. | | `-allowhealthcheck` | `SP_ALLOWHEALTHCHECK` | (not set/false) | If set, it allows the included health check binary to check the socket connection via TCP port 55555 (socket-proxy then listens on `127.0.0.1:55555/health`) | | `-listenip` | `SP_LISTENIP` | `127.0.0.1` | Specifies the IP address the server will bind on. Default is only the internal network. | -| `-logjson` | `SP_LOGJSON` | (not set/false) | If set, it enables logging in JSON format. If unset, docker-proxy logs in plain text format. | +| `-logjson` | `SP_LOGJSON` | (not set/false) | If set, it enables logging in JSON format. If unset, socket-proxy logs in plain text format. | | `-loglevel` | `SP_LOGLEVEL` | `INFO` | Sets the log level. Accepted values are: `DEBUG`, `INFO`, `WARN`, `ERROR`. | | `-proxyport` | `SP_PROXYPORT` | `2375` | Defines the TCP port the proxy listens to. | -| `-shutdowngracetime` | `SP_SHUTDOWNGRACETIME` | `10` | Defines the time in seconds to wait before forcing the shutdown after sigtern or sigint (socket-proxy first tries to graceful shut down the TCP server) | +| `-shutdowngracetime` | `SP_SHUTDOWNGRACETIME` | `10` | Defines the time in seconds to wait before forcing the shutdown after SIGTERM or SIGINT (socket-proxy first tries to gracefully shut down the TCP server) | | | `-socketpath` | `SP_SOCKETPATH` | `/var/run/docker.sock` | Specifies the UNIX socket path to connect to. By default, it connects to the Docker daemon socket. | | `-stoponwatchdog` | `SP_STOPONWATCHDOG` | (not set/false) | If set, socket-proxy will be stopped if the watchdog detects that the unix socket is not available. | | `-watchdoginterval` | `SP_WATCHDOGINTERVAL` | `0` | Check for socket availability every x seconds (disable checks, if not set or value is 0) | @@ -267,11 +267,16 @@ socket-proxy can be configured via command line parameters or via environment va 1.10 - fix socket file mode (thanks [@amanda-wee](https://github.com/amanda-wee)), optimize build actions (thanks [@reneleonhardt](https://github.com/reneleonhardt)) +1.11 - add per-container allowlists specified by Docker container labels (thanks [@amanda-wee](https://github.com/amanda-wee)) + + ## License -This project is licensed under the MIT License – see the [LICENSE](LICENSE) file for details. -Parts of the file `cmd/socket-proxy/bindmount.go` and files under the `internal/docker` and `internal/go-connections` folders are licensed under the Apache 2.0 License. -See the comments in this file and the LICENSE file for more information. +Parts of this project, specifically the file `cmd/socket-proxy/bindmount.go` and +the files in the `internal/docker` and `internal/go-connections` folders, +contain source code licensed under the Apache License 2.0. See the comments +in the applicable files for details. +The rest of the project is licensed under the MIT License – see the [LICENSE](LICENSE) file for details. ## Acknowledgements From d615adc81f054c5b4c8b0d4e9403cc831657d746 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Ells=C3=A4sser?= <67168186+wollomatic@users.noreply.github.com> Date: Thu, 4 Dec 2025 21:20:49 +0100 Subject: [PATCH 32/38] Update internal/docker/client/request.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Wolfgang Ellsässer <67168186+wollomatic@users.noreply.github.com> --- internal/docker/client/request.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/docker/client/request.go b/internal/docker/client/request.go index d33103c..3c2634c 100644 --- a/internal/docker/client/request.go +++ b/internal/docker/client/request.go @@ -149,7 +149,7 @@ func (cli *Client) checkResponseErr(serverResp *http.Response) (retErr error) { if serverResp.Header.Get("Content-Type") == "application/json" { var errorResponse types.ErrorResponse if err := json.Unmarshal(body, &errorResponse); err != nil { - return fmt.Errorf("Error reading JSON: %v", err) + return fmt.Errorf("error reading JSON: %v", err) } if errorResponse.Message == "" { // Error-message is empty, which means that we successfully parsed the From db5ae2c1eb450eea3b3d5a4dc45e8e97b4c9a3f5 Mon Sep 17 00:00:00 2001 From: wollomatic Date: Thu, 4 Dec 2025 21:23:22 +0100 Subject: [PATCH 33/38] Go 1.25.4 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index cedb41e..b842d57 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -FROM --platform=$BUILDPLATFORM golang:1.25.3-alpine3.22 AS build +FROM --platform=$BUILDPLATFORM golang:1.25.4-alpine3.22 AS build WORKDIR /application COPY . ./ ARG TARGETOS From 59f1cccbb12e9d58f28d985030100d99634ac3d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Ells=C3=A4sser?= <67168186+wollomatic@users.noreply.github.com> Date: Thu, 4 Dec 2025 21:36:15 +0100 Subject: [PATCH 34/38] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Wolfgang Ellsässer <67168186+wollomatic@users.noreply.github.com> --- internal/config/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/config/config.go b/internal/config/config.go index aa7f5d0..9d91f69 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -630,7 +630,7 @@ func getSocketProxyContainerSummary(socketPath, proxyContainerName string) (cont time.Sleep(time.Duration(i) * time.Second) } } - return container.Summary{}, fmt.Errorf("socket-proxy container \"%s\" was not found", proxyContainerName) + return container.Summary{}, fmt.Errorf("socket-proxy container \"%s\" was not found after %d attempts; verify the container name is correct and the container is running", proxyContainerName, maxTries) } // extract Docker container allowlist label data from the container summary From 09bb747b0c389fd443d6ffe90c8c33a1763a0e40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Ells=C3=A4sser?= <67168186+wollomatic@users.noreply.github.com> Date: Thu, 4 Dec 2025 21:36:49 +0100 Subject: [PATCH 35/38] Update README.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Wolfgang Ellsässer <67168186+wollomatic@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e0b6406..bd5b1e3 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ Allowlists for both requests and bind mount restrictions can be specified for pa 2. Make sure that each container that will use the socket proxy is in a Docker network that the socket proxy container is also in. 3. Use the same regex syntax for request allowlists and for bind mount restrictions that were discussed earlier, but for labels on each container that will use the socket proxy. Each label name will have the prefix of `socket-proxy.allow.`, with `socket-proxy.allow.bindmountfrom` for bind mount restrictions. For example: -``` compose.yaml +```yaml services: traefik: # [...] see github.com/wollomatic/traefik-hardened for a full example From 6ea3d8f49dfb9158ef59574ce09f21b2a5fc6fa7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 05:25:03 +0000 Subject: [PATCH 36/38] Bump golang from 1.25.4-alpine3.22 to 1.25.5-alpine3.22 Bumps golang from 1.25.4-alpine3.22 to 1.25.5-alpine3.22. --- updated-dependencies: - dependency-name: golang dependency-version: 1.25.5-alpine3.22 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index b842d57..8039b8c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -FROM --platform=$BUILDPLATFORM golang:1.25.4-alpine3.22 AS build +FROM --platform=$BUILDPLATFORM golang:1.25.5-alpine3.22 AS build WORKDIR /application COPY . ./ ARG TARGETOS From a2d711b47004d554584e07ab4451a61f5d14d29a Mon Sep 17 00:00:00 2001 From: wollomatic Date: Sat, 17 Jan 2026 14:40:37 +0100 Subject: [PATCH 37/38] Go 1.25.6 + Alpine 3.23 as build platform --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 8039b8c..5b73a64 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -FROM --platform=$BUILDPLATFORM golang:1.25.5-alpine3.22 AS build +FROM --platform=$BUILDPLATFORM golang:1.25.6-alpine3.23 AS build WORKDIR /application COPY . ./ ARG TARGETOS From 7a39eff007f0e1ff575dd3ce4de04623e96bac81 Mon Sep 17 00:00:00 2001 From: wollomatic Date: Sat, 17 Jan 2026 14:57:58 +0100 Subject: [PATCH 38/38] Log if per-container allowlists are enabled --- cmd/socket-proxy/main.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/socket-proxy/main.go b/cmd/socket-proxy/main.go index 9fbf9fb..f434cc2 100644 --- a/cmd/socket-proxy/main.go +++ b/cmd/socket-proxy/main.go @@ -78,6 +78,7 @@ func main() { } if len(cfg.ProxyContainerName) > 0 { slog.Info("Proxy container name provided", "proxycontainername", cfg.ProxyContainerName) + slog.Info("per-container allowlists enabled!") } else { // we only log this on DEBUG level because providing the socket-proxy container name // enables the use of labels to specify per-container allowlists