diff --git a/Dockerfile b/Dockerfile index 185af8e..3907557 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=$BUILDPLATFORM golang:1.21.5-alpine AS build +FROM --platform=$BUILDPLATFORM golang:1.21.6-alpine AS build WORKDIR /application COPY . ./ ARG TARGETOS diff --git a/README.md b/README.md index 4d78140..e28ed03 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ `socket-proxy` is a lightweight, secure-by-default unix socket proxy. Although it was created to proxy the docker socket to Traefik, it can be also used for other purposes. It is heavily inspired by [tecnativa/docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy). +As an additional benefit socket-proxy can be used to examine the API calls of the client application. + The advantage over other solutions is the very slim container image ("FROM scratch") 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 there is an extra security layer (IP address-based access control). @@ -13,7 +15,7 @@ The source code is available on [GitHub: wollomatic/socket-proxy](https://github ## Getting Started -Some examples can be found in the [wiki](https://github.com/wollomatic/socket-proxy/wiki). +Some examples can be found in the [wiki](https://github.com/wollomatic/socket-proxy/wiki) and in the `examples` directory of the repo. ### Warning @@ -42,6 +44,8 @@ Socket-proxy listens per default only on `127.0.0.1`. Depending what you need, y Per default, only `127.0.0.1/32` ist allowed to connect to socket-proxy. Depending on your needs, you may want to set another allowlist with the `-allowfrom` parameter. +Since version 1.1.0, not only IP networks can be configured, but also hostnames. So it is now possible to explicitly allow only one specific client to connect to the proxy, for example `-allowfrom=traefik` + #### Setting up the allowlist for requests You must set up regular expressions for each HTTP method the client application needs access to. @@ -73,7 +77,9 @@ Health checks are disables by default. As the socket-proxy container may not be retries: 2 # [...] ``` +### Socket watchdog +In certain circumstances (for example, after a Docker engine update), the socket connection may break, causing the client application to fail. To prevent this, the socket-proxy can be configured to check the socket availability at regular intervals. If the socket is not available, the socket-proxy will be stopped, so it can be restarted by the container orchestrator. This feature is disabled by default. To enable it, set the `-watchdoginterval` parameter to the desired interval in seconds and set the `-stoponwatchdog` parameter. If `-stoponwatchdog`is not set, the watchdog will only log an error message and continue to run. ### Example for proxying the docker socket to Traefik @@ -124,20 +130,36 @@ networks: internal: true ``` +### Examining the API calls of the client application + +To just log the API calls of the client application, set the log level to `DEBUG` and allow all requests. Then, you can examine the log output to determine which requests are made by the client application. Allowing all requests can be done by setting the following parameters: +``` +- '-loglevel=debug' +- '-allowGET=.*' +- '-allowHEAD=.*' +- '-allowPOST=.*' +- '-allowPUT=.*' +- '-allowPATCH=.*' +- '-allowDELETE=.*' +- '-allowCONNECT=.*' +- '-allowTRACE=.*' +- '-allowOPTIONS=.*' +``` + ### Parameters -| Parameter | Default Value | Description | -|----------------------|------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `-allowfrom` | `127.0.0.1/32` | Specifies the IP addresses of the clients allowed to connect to the proxy. The default value is `127.0.0.1/32`, which means only localhost is allowed. This default configuration may not be useful in most cases, but it is because of a secure-by-default design. To allow all IPv4 addresses, set `-allowfrom=0.0.0.0/0`. Please remember that socket-proxy should never be exposed to a public network, regardless of this extra security layer. | -| `-allowhealthcheck` | (not set) | 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` | `127.0.0.1` | Specifies the IP address the server will bind on. Default is only the internal network. | -| `-logjson` | (not set) | If set, it enables logging in JSON format. If unset, docker-proxy logs in plain text format. | -| `-loglevel` | `INFO` | Sets the log level. Accepted values are: `DEBUG`, `INFO`, `WARN`, `ERROR`. | -| `-proxyport` | `2375` | Defines the TCP port the proxy listens to. | -| `-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) | -| `-socketpath` | `/var/run/docker.sock` | Specifies the UNIX socket path to connect to. By default, it connects to the Docker daemon socket. | -| `-stoponwatchdog` | (not set) | If set, socket-proxy will be stopped if the watchdog detects that the unix socket is not available. | -| `-watchdoginterval` | `0` | Check for socket availabibity every x seconds (disable checks, if not set or value is 0) | +| Parameter | Default Value | Description | +|----------------------|------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `-allowfrom` | `127.0.0.1/32` | Specifies the IP addresses of the clients or the hostname of one specific client allowed to connect to the proxy. The default value is `127.0.0.1/32`, which means only localhost is allowed. This default configuration may not be useful in most cases, but it is because of a secure-by-default design. To allow all IPv4 addresses, set `-allowfrom=0.0.0.0/0`. Please remember that socket-proxy should never be exposed to a public network, regardless of this extra security layer. | +| `-allowhealthcheck` | (not set) | 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` | `127.0.0.1` | Specifies the IP address the server will bind on. Default is only the internal network. | +| `-logjson` | (not set) | If set, it enables logging in JSON format. If unset, docker-proxy logs in plain text format. | +| `-loglevel` | `INFO` | Sets the log level. Accepted values are: `DEBUG`, `INFO`, `WARN`, `ERROR`. | +| `-proxyport` | `2375` | Defines the TCP port the proxy listens to. | +| `-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) | +| `-socketpath` | `/var/run/docker.sock` | Specifies the UNIX socket path to connect to. By default, it connects to the Docker daemon socket. | +| `-stoponwatchdog` | (not set) | If set, socket-proxy will be stopped if the watchdog detects that the unix socket is not available. | +| `-watchdoginterval` | `0` | Check for socket availabibity every x seconds (disable checks, if not set or value is 0) | ## License diff --git a/cmd/socket-proxy/handlehttprequest.go b/cmd/socket-proxy/handlehttprequest.go index b9ab6e0..f2441e9 100644 --- a/cmd/socket-proxy/handlehttprequest.go +++ b/cmd/socket-proxy/handlehttprequest.go @@ -14,7 +14,7 @@ import ( func handleHttpRequest(w http.ResponseWriter, r *http.Request) { // check if the client's IP is allowed to access - allowedIP, err := isAllowedIP(r.RemoteAddr) + allowedIP, err := isAllowedClient(r.RemoteAddr) if err != nil { slog.Error("invalid RemoteAddr format", "reason", err, "method", r.Method, "URL", r.URL, "client", r.RemoteAddr) sendHTTPError(w, http.StatusInternalServerError) @@ -41,24 +41,38 @@ func handleHttpRequest(w http.ResponseWriter, r *http.Request) { socketProxy.ServeHTTP(w, r) // proxy the request } -// isAllowedIP checks if the given remote address is allowed to connect to the proxy. +// 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 isAllowedIP(remoteAddr string) (bool, error) { - // Get the IP address from the remote address string - ipStr, _, err := net.SplitHostPort(remoteAddr) +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 } // Parse the IP address - ip := net.ParseIP(ipStr) - if ip == nil { + clientIP := net.ParseIP(clientIPStr) + if clientIP == nil { return false, errors.New("invalid IP format") } - // check if IP address is in allowed network - if !config.AllowedNetwork.Contains(ip) { + + _, allowedIPNet, err := net.ParseCIDR(cfg.AllowFrom) + if err == nil { + // AllowFrom is a valid CIDR, so check if IP address is in allowed network + return allowedIPNet.Contains(clientIP), nil + } else { + // AllowFrom is not a valid CIDR, so try to resolve it via DNS + ips, err := net.LookupIP(cfg.AllowFrom) + if err != nil { + return false, errors.New("error looking up allowed client hostname: " + err.Error()) + } + for _, ip := range ips { + // Check if IP address is one of the resolved IPs + if ip.Equal(clientIP) { + return true, nil + } + } return false, nil } - return true, nil } // sendHTTPError sends a HTTP error with the given status code. diff --git a/cmd/socket-proxy/handlehttprequest_test.go b/cmd/socket-proxy/handlehttprequest_test.go deleted file mode 100644 index 6ab1bf6..0000000 --- a/cmd/socket-proxy/handlehttprequest_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package main - -import ( - "github.com/wollomatic/socket-proxy/internal/config" - "net" - "testing" -) - -func TestIsAllowedIP(t *testing.T) { - tests := []struct { - input string - allowedCIDR string - expected bool - expectError bool - }{ - {"192.168.1.1:1234", "0.0.0.0/0", true, false}, - {"127.0.0.1:5432", "127.0.0.1/32", true, false}, - {"172.13.2.4:54320", "127.0.0.1/32", false, false}, - {"172.13.2.4", "127.0.0.1/32", false, true}, - } - for _, test := range tests { - _, config.AllowedNetwork, _ = net.ParseCIDR(test.allowedCIDR) - result, err := isAllowedIP(test.input) - if result != test.expected || (err != nil) != test.expectError { - t.Errorf("For input %q, expected %v, %v, but got %v, %v", test.input, test.expected, test.expectError, result, err != nil) - } - } -} diff --git a/cmd/socket-proxy/main.go b/cmd/socket-proxy/main.go index 82ecbdf..9995f23 100644 --- a/cmd/socket-proxy/main.go +++ b/cmd/socket-proxy/main.go @@ -49,7 +49,7 @@ func main() { // print configuration slog.Info("starting socket-proxy", "version", version, "os", runtime.GOOS, "arch", runtime.GOARCH, "runtime", runtime.Version(), "URL", programUrl) - slog.Info("configuration info", "socketpath", cfg.SocketPath, "listenaddress", cfg.ListenAddress, "loglevel", cfg.LogLevel, "logjson", cfg.LogJSON, "allowfrom", config.AllowedNetwork, "shutdowngracetime", cfg.ShutdownGraceTime) + slog.Info("configuration info", "socketpath", cfg.SocketPath, "listenaddress", cfg.ListenAddress, "loglevel", cfg.LogLevel, "logjson", cfg.LogJSON, "allowfrom", cfg.AllowFrom, "shutdowngracetime", cfg.ShutdownGraceTime) if cfg.WatchdogInterval > 0 { slog.Info("watchdog enabled", "interval", cfg.WatchdogInterval, "stoponwatchdog", cfg.StopOnWatchdog) } else { diff --git a/examples/docker-compose/dozzle/compose.yaml b/examples/docker-compose/dozzle/compose.yaml index 03b0469..04e4dc3 100644 --- a/examples/docker-compose/dozzle/compose.yaml +++ b/examples/docker-compose/dozzle/compose.yaml @@ -3,7 +3,7 @@ services: image: wollomatic/socket-proxy:1 command: - '-loglevel=info' - - '-allowfrom=192.168.254.8/29' # allow only the small subnet "docker-proxynet" + - '-allowfrom=dozzle' # allow only the small subnet "docker-proxynet" - '-listenip=0.0.0.0' - '-allowGET=/v1\..{2}/(containers/.*|events)' - '-allowHEAD=/_ping' @@ -49,9 +49,6 @@ networks: docker-proxynet: internal: true attachable: false - ipam: - config: - - subnet: 192.168.254.8/29 dozzle: driver: bridge attachable: false diff --git a/examples/docker-compose/watchtower/compose.yaml b/examples/docker-compose/watchtower/compose.yaml index 5d82503..7b6074f 100644 --- a/examples/docker-compose/watchtower/compose.yaml +++ b/examples/docker-compose/watchtower/compose.yaml @@ -2,15 +2,17 @@ services: dockerproxy: image: wollomatic/socket-proxy:1 command: - - '-loglevel=debug' - - '-allowfrom=192.168.254.0/29' + - '-loglevel=info' + - '-allowfrom=watchtower' # allow only access from the "watchtower" service - '-listenip=0.0.0.0' + - '-shutdowngracetime=10' + # this whitelists the API endpoints that watchtower needs: - '-allowGET=/v1\..{2}/(containers/.*|images/.*)' - '-allowPOST=/v1\..{2}/(containers/.*|images/.*|networks/.*)' - '-allowDELETE=/v1\..{2}/(containers/.*|images/.*)' + # check socket connection every hour and stop the proxy if it fails (will then be restarted by docker): - '-watchdoginterval=3600' - '-stoponwatchdog' - - '-shutdowngracetime=10' restart: unless-stopped read_only: true mem_limit: 64M @@ -22,7 +24,7 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock:ro labels: - - com.centurylinklabs.watchtower.enable=false + - com.centurylinklabs.watchtower.enable=false # if watchtower would try to update the proxy, it would just stop networks: - docker-proxynet @@ -51,10 +53,6 @@ networks: docker-proxynet: internal: true attachable: false - ipam: - config: - - subnet: 192.168.254.0/29 - watchtower: driver: bridge attachable: false diff --git a/internal/config/config.go b/internal/config/config.go index cf04b10..87cca41 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,6 +27,7 @@ const ( ) type Config struct { + AllowFrom string AllowHealthcheck bool LogJSON bool StopOnWatchdog bool @@ -38,7 +39,6 @@ type Config struct { } var ( - AllowedNetwork *net.IPNet AllowedRequests map[string]*regexp.Regexp ) @@ -66,12 +66,11 @@ var mr = []methodRegex{ func InitConfig() (*Config, error) { var ( cfg Config - allowFrom string listenIP string proxyPort uint logLevel string ) - flag.StringVar(&allowFrom, "allowfrom", defaultAllowFrom, "allowed IPs to connect to the proxy") + flag.StringVar(&cfg.AllowFrom, "allowfrom", defaultAllowFrom, "allowed IPs or hostname to connect to the proxy") flag.BoolVar(&cfg.AllowHealthcheck, "allowhealthcheck", defaultAllowHealthcheck, "allow health check requests (HEAD http://localhost:55555/health)") flag.BoolVar(&cfg.LogJSON, "logjson", defaultLogJSON, "log in JSON format (otherwise log in plain text") flag.StringVar(&listenIP, "listenip", defaultListenIP, "ip address to listen on") @@ -86,13 +85,6 @@ func InitConfig() (*Config, error) { } flag.Parse() - // parse allowFrom to check if it is a valid CIDR - var err error - _, AllowedNetwork, err = net.ParseCIDR(allowFrom) - if err != nil { - return nil, fmt.Errorf("invalid CIDR \"%s\" for allowfrom: %s", allowFrom, err) - } - // pcheck listenIP and proxyPort if net.ParseIP(listenIP) == nil { return nil, fmt.Errorf("invalid IP \"%s\" for listenip", listenIP)