├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── docker-image-release.yaml │ └── docker-image-testing.yaml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── SECURITY.md ├── cmd ├── healthcheck │ └── main.go └── socket-proxy │ ├── checksocketconnection.go │ ├── handlehttprequest.go │ └── main.go ├── cosign.pub ├── examples └── docker-compose │ ├── dozzle │ └── compose.yaml │ └── watchtower │ └── compose.yaml ├── go.mod └── internal └── config └── config.go /.dockerignore: -------------------------------------------------------------------------------- 1 | .github 2 | examples/ 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/docker-image-release.yaml: -------------------------------------------------------------------------------- 1 | name: Build and Publish Release 2 | 3 | on: 4 | push: 5 | tags: ['*'] 6 | 7 | jobs: 8 | 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | env: 13 | GO111MODULE: on 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Run Gosec Security Scanner 20 | uses: securego/gosec@master 21 | with: 22 | args: ./... 23 | 24 | - name: Extract tag name 25 | id: get_tag 26 | run: echo "::set-output name=VERSION::${GITHUB_REF#refs/tags/}" 27 | 28 | - name: Install Cosign 29 | uses: sigstore/cosign-installer@v3.8.1 30 | with: 31 | cosign-release: 'v2.4.3' 32 | 33 | - name: Set up Docker Buildx 34 | uses: docker/setup-buildx-action@v3 35 | 36 | - name: Login to Docker Hub 37 | uses: docker/login-action@v3 38 | with: 39 | username: ${{ secrets.DOCKERHUB_USERNAME }} 40 | password: ${{ secrets.DOCKERHUB_TOKEN }} 41 | 42 | - name: Login to GitHub Container Registry 43 | uses: docker/login-action@v3 44 | with: 45 | registry: ghcr.io 46 | username: ${{ github.actor }} 47 | password: ${{ secrets.GITHUB_TOKEN }} 48 | 49 | - name: Build and push Docker image 50 | uses: docker/build-push-action@v5 51 | id: build-and-push 52 | with: 53 | context: . 54 | platforms: linux/amd64,linux/arm/v7,linux/arm64 55 | push: true 56 | build-args: VERSION=${{ steps.get_tag.outputs.VERSION }} 57 | tags: | 58 | docker.io/wollomatic/socket-proxy:${{ steps.get_tag.outputs.VERSION }} 59 | docker.io/wollomatic/socket-proxy:1 60 | ghcr.io/wollomatic/socket-proxy:${{ steps.get_tag.outputs.VERSION }} 61 | ghcr.io/wollomatic/socket-proxy:1 62 | 63 | - name: Sign images for Docker 64 | run: cosign sign --yes --recursive --key env://COSIGN_PRIVATE_KEY docker.io/wollomatic/socket-proxy:${{ steps.get_tag.outputs.VERSION }}@${{ steps.build-and-push.outputs.digest }} 65 | env: 66 | COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} 67 | COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} 68 | 69 | - name: Sign images for GHCR 70 | run: cosign sign --yes --recursive --key env://COSIGN_PRIVATE_KEY ghcr.io/wollomatic/socket-proxy:${{ steps.get_tag.outputs.VERSION }}@${{ steps.build-and-push.outputs.digest }} 71 | env: 72 | COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} 73 | COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} 74 | -------------------------------------------------------------------------------- /.github/workflows/docker-image-testing.yaml: -------------------------------------------------------------------------------- 1 | name: Build and Publish Testing 2 | 3 | on: 4 | push: 5 | branches: 6 | - develop 7 | 8 | jobs: 9 | 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | env: 14 | GO111MODULE: on 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Run Gosec Security Scanner 21 | uses: securego/gosec@master 22 | with: 23 | args: ./... 24 | 25 | # - name: Install Cosign 26 | # uses: sigstore/cosign-installer@v3.8.1 27 | # with: 28 | # cosign-release: 'v2.4.3' 29 | 30 | - name: Set up Docker Buildx 31 | uses: docker/setup-buildx-action@v3 32 | 33 | - name: Login to Docker Hub 34 | uses: docker/login-action@v3 35 | with: 36 | username: ${{ secrets.DOCKERHUB_USERNAME }} 37 | password: ${{ secrets.DOCKERHUB_TOKEN }} 38 | 39 | - name: Login to GitHub Container Registry 40 | uses: docker/login-action@v3 41 | with: 42 | registry: ghcr.io 43 | username: ${{ github.actor }} 44 | password: ${{ secrets.GITHUB_TOKEN }} 45 | 46 | - name: Build and push Docker image 47 | uses: docker/build-push-action@v5 48 | id: build-and-push 49 | with: 50 | context: . 51 | platforms: linux/amd64,linux/arm/v7,linux/arm64 52 | push: true 53 | build-args: VERSION=testing-${{ github.sha }} 54 | tags: | 55 | docker.io/wollomatic/socket-proxy:testing 56 | docker.io/wollomatic/socket-proxy:testing-${{ github.sha }} 57 | ghcr.io/wollomatic/socket-proxy:testing 58 | ghcr.io/wollomatic/socket-proxy:testing-${{ github.sha }} 59 | 60 | # - name: Sign Docker Hub image 61 | # run: cosign sign --yes --recursive --key env://COSIGN_PRIVATE_KEY docker.io/wollomatic/socket-proxy:testing-${{ github.sha }}@${{ steps.build-and-push.outputs.digest }} 62 | # env: 63 | # COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} 64 | # COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} 65 | # 66 | # - name: Sign GitHub Container Registry image 67 | # run: cosign sign --yes --recursive --key env://COSIGN_PRIVATE_KEY ghcr.io/wollomatic/socket-proxy:testing-${{ github.sha }}@${{ steps.build-and-push.outputs.digest }} 68 | # env: 69 | # COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} 70 | # COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | 23 | # JetBrains IDEA 24 | .idea 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | FROM --platform=$BUILDPLATFORM golang:1.24.2-alpine3.21 AS build 3 | WORKDIR /application 4 | COPY . ./ 5 | ARG TARGETOS 6 | ARG TARGETARCH 7 | ARG VERSION 8 | RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ 9 | go build -tags=netgo -gcflags=all=-d=checkptr -ldflags="-w -s -X 'main.version=${VERSION}'" -trimpath \ 10 | -o / ./... 11 | 12 | FROM scratch 13 | LABEL org.opencontainers.image.source=https://github.com/wollomatic/socket-proxy \ 14 | org.opencontainers.image.description="A lightweight and secure unix socket proxy" \ 15 | org.opencontainers.image.licenses=MIT 16 | USER 65534:65534 17 | VOLUME /var/run/docker.sock 18 | EXPOSE 2375 19 | ENTRYPOINT ["/socket-proxy"] 20 | COPY --from=build ./healthcheck ./socket-proxy / 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Wolfgang Ellsässer (wollomatic) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # socket-proxy 2 | 3 | ## Latest image 4 | - `wollomatic/socket-proxy:1.6.1` / `ghcr.io/wollomatic/socket-proxy:1.6.1` 5 | - `wollomatic/socket-proxy:1` / `ghcr.io/wollomatic/socket-proxy:1` 6 | 7 | ## About 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 also be used for other purposes. 9 | It is heavily inspired by [tecnativa/docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy). 10 | 11 | As an additional benefit, socket-proxy can be used to examine the API calls of the client application. 12 | 13 | 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). 14 | 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. 15 | 16 | The allowlist is configured for each HTTP method separately using the Go regexp syntax, allowing fine-grained control over the allowed HTTP methods. 17 | 18 | The source code is available on [GitHub: wollomatic/socket-proxy](https://github.com/wollomatic/socket-proxy) 19 | 20 | > [!NOTE] 21 | > Starting with version 1.6.0, the socket-proxy container image is also available on GHCR. 22 | 23 | ## Getting Started 24 | 25 | Some examples can be found in the [wiki](https://github.com/wollomatic/socket-proxy/wiki) and in the `examples` directory of the repo. 26 | 27 | ### Warning 28 | 29 | You should know what you are doing. Never expose socket-proxy to a public network. It is meant to be used in a secure environment only. 30 | 31 | ### Installing 32 | 33 | The container image is available on [Docker Hub (wollomatic/socket-proxy)](https://hub.docker.com/r/wollomatic/socket-proxy) 34 | and on the [GitHub Container Registry (ghcr.io/wollomatic/socket-proxy)](https://github.com/wollomatic/socket-proxy/pkgs/container/socket-proxy). 35 | 36 | To pin one specific version, use the version tag (for example, `wollomatic/socket-proxy:1.6.0` or `ghcr.io/wollomatic/socket-proxy:1.6.0`). 37 | 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. 38 | 39 | 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. 40 | 41 | Every socket-proxy release image is signed with Cosign. The public key is available on [GitHub: wollomatic/socket-proxy/main/cosign.pub](https://raw.githubusercontent.com/wollomatic/socket-proxy/main/cosign.pub) and [https://wollomatic.de/socket-proxy/cosign.pub](https://wollomatic.de/socket-proxy/cosign.pub). For more information, please refer to the [Security Policy](https://github.com/wollomatic/socket-proxy/blob/main/SECURITY.md). 42 | As of version 1.6, all multi-arch images are signed. 43 | 44 | ### Allowing access 45 | 46 | Because of the secure-by-default design, you need to allow every access explicitly. 47 | 48 | This is meant to be an additional layer of security. It does not replace other security measures, such as firewalls, network segmentation, etc. Do not expose socket-proxy to a public network. 49 | 50 | #### Setting up the TCP listener 51 | 52 | Socket-proxy listens per default only on `127.0.0.1`. Depending on what you need, you may want to set another listener address with the `-listenip` parameter. In almost every use case, `-listenip=0.0.0.0` will be the correct configuration when using socket-proxy in a docker image. 53 | 54 | #### Using a unix socket instead of a TCP listener 55 | 56 | If you want to proxy/filter the unix socket to a new unix socket instead to a TCP listener, 57 | you need to set the `-proxysocketendpoint` parameter or the `SP_PROXYSOCKETENDPOINT` env variable to the socket path of the new unix socket. 58 | This will also disable the TCP listener. 59 | 60 | For example `-proxysocketendpoint=/tmp/filtered-socket.sock` 61 | 62 | #### Setting up the IP address or hostname allowlist 63 | 64 | Per default, only `127.0.0.1/32` is allowed to connect to socket-proxy. You may want to set another allowlist with the `-allowfrom` parameter, depending on your needs. 65 | 66 | Alternatively, not only IP networks but also hostnames can be configured. So it is now possible to explicitly allow one or more specific hostnames to connect to the proxy, for example, `-allowfrom=traefik`, or `-allowfrom=traefik,dozzle`. 67 | 68 | Using the hostname is an easy-to-configure way to have more security. Access to the socket proxy will not even be permitted from the host system. 69 | 70 | #### Setting up the allowlist for requests 71 | 72 | You must set up regular expressions for each HTTP method the client application needs access to. 73 | 74 | 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. 75 | 76 | 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`). 77 | 78 | If both commandline parameter and environment variable is configured for a particular HTTP method, the environment variable is ignored. 79 | 80 | 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. 81 | 82 | Examples (command line): 83 | + `'-allowGET=/v1\..{1,2}/(version|containers/.*|events.*)'` could be used for allowing access to the docker socket for Traefik v2. 84 | + `'-allowHEAD=.*` allows all HEAD requests. 85 | 86 | Examples (env variables): 87 | + `'SP_ALLOW_GET="/v1\..{1,2}/(version|containers/.*|events.*)"'` could be used for allowing access to the docker socket for Traefik v2. 88 | + `'SP_ALLOW_HEAD=".*"` allows all HEAD requests. 89 | 90 | For more information, refer to the [Go regexp documentation](https://golang.org/pkg/regexp/syntax/). 91 | 92 | An excellent online regexp tester is [regex101.com](https://regex101.com/). 93 | 94 | To determine which HTTP requests your client application uses, you could switch socket-proxy to debug log level and look at the log output while allowing all requests in a secure environment. 95 | 96 | ### Container health check 97 | 98 | 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: 99 | 100 | ``` compose.yaml 101 | # [...] 102 | healthcheck: 103 | test: ["CMD", "./healthcheck"] 104 | interval: 10s 105 | timeout: 5s 106 | retries: 2 107 | # [...] 108 | ``` 109 | ### Socket watchdog 110 | 111 | 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 the container orchestrator can restart it. This feature is disabled by default. To enable it, set the `-watchdoginterval` parameter (or `SP_WATCHDOGINTERVAL` env variable) to the desired interval in seconds and set the `-stoponwatchdog` parameter (or `SP_STOPONWATCHDOG=true`). If `-stoponwatchdog`is not set, the watchdog will only log an error message and continue to run (the problem would still exist in that case). 112 | 113 | ### Example for proxying the docker socket to Traefik 114 | 115 | You need to know how to install Traefik in this environment. See [wollomatic/traefik2-hardened](https://github.com/wollomatic/traefik2-hardened) for an example. 116 | 117 | The image can be deployed with docker compose: 118 | 119 | ``` compose.yaml 120 | services: 121 | dockerproxy: 122 | image: wollomatic/socket-proxy:<> # choose most recent image 123 | restart: unless-stopped 124 | user: "65534:<>" 125 | mem_limit: 64M 126 | read_only: true 127 | cap_drop: 128 | - ALL 129 | security_opt: 130 | - no-new-privileges 131 | command: 132 | - '-loglevel=info' 133 | - '-listenip=0.0.0.0' 134 | - '-allowfrom=traefik' # allow only hostname "traefik" to connect 135 | - '-allowGET=/v1\..{1,2}/(version|containers/.*|events.*)' 136 | - '-watchdoginterval=3600' # check once per hour for socket availability 137 | - '-stoponwatchdog' # halt program on error and let compose restart it 138 | - '-shutdowngracetime=5' # wait 5 seconds before shutting down 139 | volumes: 140 | - /var/run/docker.sock:/var/run/docker.sock:ro 141 | networks: 142 | - docker-proxynet # NEVER EVER expose this to the public internet! 143 | # this is a private network only for traefik and socket-proxy 144 | # it is not the same as the traefik-servicenet 145 | 146 | traefik: 147 | # [...] see github.com/wollomatic/traefik2-hardened for a full example 148 | depends_on: 149 | - dockerproxy 150 | networks: 151 | - traefik-servicenet # this is the common traefik network 152 | - docker-proxynet # this should be only restricted to traefik and socket-proxy 153 | 154 | networks: 155 | traefik-servicenet: 156 | external: true 157 | docker-proxynet: 158 | driver: bridge 159 | internal: true 160 | ``` 161 | 162 | ### Examining the API calls of the client application 163 | 164 | To 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 the client application makes. Allowing all requests can be done by setting the following parameters: 165 | ``` 166 | - '-loglevel=debug' 167 | - '-allowGET=.*' 168 | - '-allowHEAD=.*' 169 | - '-allowPOST=.*' 170 | - '-allowPUT=.*' 171 | - '-allowPATCH=.*' 172 | - '-allowDELETE=.*' 173 | - '-allowCONNECT=.*' 174 | - '-allowTRACE=.*' 175 | - '-allowOPTIONS=.*' 176 | ``` 177 | 178 | ### all parameters and environment variables 179 | 180 | socket-proxy can be configured via command line parameters or via environment variables. If both command line parameter and environment variables are set, the environment variable will be ignored. 181 | 182 | | Parameter | Environment Variable | Default Value | Description | 183 | |--------------------------------|----------------------------------|------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 184 | | `-allowfrom` | `SP_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`. Alternatively, hostnames (comma-separated) can be set, for example `-allowfrom=traefik`, or `-allowfrom=traefik,dozzle`. Please remember that socket-proxy should never be exposed to a public network, regardless of this extra security layer. | 185 | | `-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`) | 186 | | `-listenip` | `SP_LISTENIP` | `127.0.0.1` | Specifies the IP address the server will bind on. Default is only the internal network. | 187 | | `-logjson` | `SP_LOGJSON` | (not set/false) | If set, it enables logging in JSON format. If unset, docker-proxy logs in plain text format. | 188 | | `-loglevel` | `SP_LOGLEVEL` | `INFO` | Sets the log level. Accepted values are: `DEBUG`, `INFO`, `WARN`, `ERROR`. | 189 | | `-proxyport` | `SP_PROXYPORT` | `2375` | Defines the TCP port the proxy listens to. | 190 | | `-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) | 191 | | `-socketpath` | `SP_SOCKETPATH` | `/var/run/docker.sock` | Specifies the UNIX socket path to connect to. By default, it connects to the Docker daemon socket. | 192 | | `-stoponwatchdog` | `SP_STOPONWATCHDOG` | (not set/false) | If set, socket-proxy will be stopped if the watchdog detects that the unix socket is not available. | 193 | | `-watchdoginterval` | `SP_WATCHDOGINTERVAL` | `0` | Check for socket availabibity every x seconds (disable checks, if not set or value is 0) | 194 | | `-proxysocketendpoint` | `SP_PROXYSOCKETENDPOINT` | (not set) | Proxy to the given unix socket instead of a TCP port | 195 | | `-proxysocketendpointfilemode` | `SP_PROXYSOCKETENDPOINTFILEMODE` | `0600` | Explicitly set the file mode for the filtered unix socket endpoint (only useful with `-proxysocketendpoint`) | 196 | 197 | ### Changelog 198 | 199 | 1.0 - initial release 200 | 201 | 1.1 - add hostname support for `-allowfrom` parameter 202 | 203 | 1.2 - reformat logging of allowlist on program start 204 | 205 | 1.3 - allow multiple, comma-separated hostnames in `-allowfrom` parameter 206 | 207 | 1.4 - allow configuration from env variables 208 | 209 | 1.5 - allow unix socket as proxied/filtered endpoint 210 | 211 | 1.6 - Cosign: sign a multi-arch container image AND all referenced, discrete images. Image is also available on GHCR. 212 | 213 | ## License 214 | 215 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 216 | 217 | ## Aknowledgements 218 | 219 | + [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) 220 | + [tecnativa/docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy) 221 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP SIGNED MESSAGE----- 2 | Hash: SHA256 3 | 4 | # Security Policy 5 | 6 | ## Supported Versions 7 | 8 | As no breaking changes to existing features are planned, only the most recent version is supported. 9 | 10 | ## Signed Docker Images 11 | 12 | The docker images are signed with cosign. The public key is available in the repository, on [https://wollomatic.de/socket-proxy/cosign.pub](https://wollomatic.de/socket-proxy/cosign.pub) and here: 13 | ``` 14 | - -----BEGIN PUBLIC KEY----- 15 | MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEYdXlfRbkO6KqPU7Khn1mSjbOIaD3 16 | um421A0NeT1wi840iWNp6MVKyj3tpnAyaQcLgd5/22O+eEHY+5+EHwB+eA== 17 | - -----END PUBLIC KEY----- 18 | ``` 19 | 20 | The signature is stored at Docker hub as well. For more information about cosign, see [https://github.com/sigstore/cosign#readme](https://github.com/sigstore/cosign#readme). 21 | 22 | ## Reporting a Vulnerability 23 | 24 | Please report vulnerabilities to security2025(at)wollomatic.de 25 | 26 | Feel free to encrypt the message if you like: 27 | 28 | ``` 29 | - - -----BEGIN PGP PUBLIC KEY BLOCK----- 30 | Comment: Benutzer-ID: 31 | Comment: Gültig seit: 30.12.2021 18:15 32 | Comment: Gültig bis: 31.12.2025 12:00 33 | Comment: Typ: 4.096-bit RSA 34 | Comment: Fingerabdruck: D57424AC7C262F4B44F45B575586B7A4D15E6CA7 35 | 36 | 37 | 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 84 | Q/PCqEw9 85 | =6UYI 86 | - - -----END PGP PUBLIC KEY BLOCK----- 87 | ``` 88 | -----BEGIN PGP SIGNATURE----- 89 | 90 | iQIzBAEBCAAdFiEE1XQkrHwmL0tE9FtXVYa3pNFebKcFAmUlklgACgkQVYa3pNFe 91 | bKf0IA/9ECqre6kojV3oE4KK5JRjHLHCNcwDHcu8WBOcf7gcZmAdkBf3oh3iBB8H 92 | wPhBcE3UWYRwT6dCGiTNct9KmpiB82JWX/kbGWNY501m8UTP04TB4M6Pp0ZowkkR 93 | GQgqcXSgFRyd6wvoVQVuQSLjCWwvjh+jzdQL24l038eskrXN6GaLXQbasCABDcma 94 | VeTe0BMtkQ+5EBRssMFQimgOod37AuMc3haJoAj4tfsJuH4pOCcU4v9NTF1fOc1u 95 | Gz3jty7v4LmQ/qShrYPXR0O5Id2Jttg5yqpYtox3ULN637UXlkGQSEREVep6lgYE 96 | +9zBLm7lyjmX0jBI/YgJxWE8+BVL7SujnIl+MB8Jx9ySh+JYVQ/qDk79hL+1Cykk 97 | TFPtk9OWqjIHXvsePOCIpx5OhzGdV96OI+m5UapNbBw1EfYgEDGmF2R23bMQECVd 98 | HeGYgVeK/PqOc/sP1fVN9c7qbtEplU4AIcys3rugnn4XVsiHTY7SbxoUx+hZnYLO 99 | Dn0/xj8hIaV9kgVwIzIWhfAF47UsxlN162Rx2Kmqyr1INA7TWa+k+v6x5vc5iMul 100 | xTIJoNUgwtjneyZEceNFwJkjfAn1aT4Ruy4R2s0rWkvWYj/rbR9dgWurhgpnjJke 101 | kgnVHf8ZLligrqyP1HNZnm8bYGCnefdmlNPRt91/996219jKq7Y= 102 | =RUM/ 103 | -----END PGP SIGNATURE----- 104 | -------------------------------------------------------------------------------- /cmd/healthcheck/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | ) 7 | 8 | // main does a health check against the socket-proxy server 9 | // if the health check fails, the program exits with a non-zero exit code and logs an error 10 | // if the health check succeeds, the program exits with a zero exit code 11 | // socket-proxy must be started with the -allowhealthcheck flag 12 | func main() { 13 | resp, err := http.Head("http://localhost:55555/health") 14 | if err != nil { 15 | log.Fatal("error doing health check: ", err) 16 | } 17 | if resp.StatusCode != http.StatusOK { 18 | log.Fatal("health check failed, got status: ", resp.StatusCode) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /cmd/socket-proxy/checksocketconnection.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "log/slog" 6 | "net" 7 | "net/http" 8 | "time" 9 | ) 10 | 11 | const dialTimeout = 5 // timeout in seconds for the socket connection 12 | 13 | // checkSocketAvailability tries to connect to the socket and returns an error if it fails. 14 | func checkSocketAvailability(socketPath string) error { 15 | slog.Debug("checking socket availability", "origin", "checkSocketAvailability") 16 | conn, err := net.DialTimeout("unix", socketPath, dialTimeout*time.Second) 17 | if err != nil { 18 | return err 19 | } 20 | err = conn.Close() 21 | if err != nil { 22 | slog.Error("error closing socket", "origin", "checkSocketAvailability", "error", err) 23 | } 24 | return nil 25 | } 26 | 27 | // startSocketWatchdog starts a watchdog that checks the socket availability every n seconds. 28 | func startSocketWatchdog(socketPath string, interval int64, stopOnWatchdog bool, exitChan chan int) { 29 | ticker := time.NewTicker(time.Duration(interval) * time.Second) 30 | defer ticker.Stop() 31 | 32 | for range ticker.C { 33 | if err := checkSocketAvailability(socketPath); err != nil { 34 | slog.Error("socket is unavailable", "origin", "watchdog", "error", err) 35 | if stopOnWatchdog { 36 | slog.Warn("stopping socket-proxy because of unavailable socket", "origin", "watchdog") 37 | exitChan <- 10 38 | } 39 | } 40 | } 41 | } 42 | 43 | // healthCheckServer starts a http server that listens on localhost:55555/health 44 | // and returns 200 if the socket is available, 503 otherwise. 45 | func healthCheckServer(socketPath string) { 46 | hcMux := http.NewServeMux() 47 | hcMux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { 48 | if r.Method != http.MethodHead { 49 | w.WriteHeader(http.StatusMethodNotAllowed) 50 | return 51 | } 52 | err := checkSocketAvailability(socketPath) 53 | if err != nil { 54 | slog.Error("health check failed", "origin", "healthcheck", "error", err) 55 | w.WriteHeader(http.StatusServiceUnavailable) 56 | return 57 | } 58 | w.WriteHeader(http.StatusOK) 59 | }) 60 | 61 | hcSrv := &http.Server{ 62 | Addr: "127.0.0.1:55555", 63 | Handler: hcMux, 64 | ReadHeaderTimeout: 5 * time.Second, 65 | ReadTimeout: 5 * time.Second, 66 | WriteTimeout: 5 * time.Second, 67 | } 68 | 69 | if err := hcSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { 70 | slog.Error("healthcheck http server problem", "origin", "healthcheck", "error", err) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /cmd/socket-proxy/handlehttprequest.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "log/slog" 6 | "net" 7 | "net/http" 8 | "strings" 9 | ) 10 | 11 | // handleHTTPRequest checks if the request is allowed and sends it to the proxy. 12 | // Otherwise, it returns a "405 Method Not Allowed" or a "403 Forbidden" error. 13 | // In case of an error, it returns a 500 Internal Server Error. 14 | func handleHTTPRequest(w http.ResponseWriter, r *http.Request) { 15 | if cfg.ProxySocketEndpoint == "" { // do not perform this check if we proxy to a unix socket 16 | allowedIP, err := isAllowedClient(r.RemoteAddr) 17 | if err != nil { 18 | slog.Warn("cannot get valid IP address for client allowlist check", "reason", err, "method", r.Method, "URL", r.URL, "client", r.RemoteAddr) 19 | } 20 | if !allowedIP { 21 | communicateBlockedRequest(w, r, "forbidden IP", http.StatusForbidden) 22 | return 23 | } 24 | } 25 | 26 | // check if the request is allowed 27 | allowed, exists := cfg.AllowedRequests[r.Method] 28 | if !exists { // method not in map -> not allowed 29 | communicateBlockedRequest(w, r, "method not allowed", http.StatusMethodNotAllowed) 30 | return 31 | } 32 | if !allowed.MatchString(r.URL.Path) { // path does not match regex -> not allowed 33 | communicateBlockedRequest(w, r, "path not allowed", http.StatusForbidden) 34 | return 35 | } 36 | 37 | // finally log and proxy the request 38 | slog.Debug("allowed request", "method", r.Method, "URL", r.URL, "client", r.RemoteAddr) 39 | socketProxy.ServeHTTP(w, r) // proxy the request 40 | } 41 | 42 | // isAllowedClient checks if the given remote address is allowed to connect to the proxy. 43 | // The IP address is extracted from a RemoteAddr string (the part before the colon). 44 | func isAllowedClient(remoteAddr string) (bool, error) { 45 | // Get the client IP address from the remote address string 46 | clientIPStr, _, err := net.SplitHostPort(remoteAddr) 47 | if err != nil { 48 | return false, err 49 | } 50 | // Parse the IP address 51 | clientIP := net.ParseIP(clientIPStr) 52 | if clientIP == nil { 53 | return false, errors.New("invalid IP format") 54 | } 55 | 56 | _, allowedIPNet, err := net.ParseCIDR(cfg.AllowFrom) 57 | if err == nil { 58 | // AllowFrom is a valid CIDR, so check if IP address is in allowed network 59 | return allowedIPNet.Contains(clientIP), nil 60 | } 61 | 62 | // AllowFrom is not a valid CIDR, so try to resolve it via DNS 63 | // split over comma to support multiple hostnames 64 | allowFromList := strings.Split(cfg.AllowFrom, ",") 65 | for _, allowFrom := range allowFromList { 66 | ips, err := net.LookupIP(allowFrom) 67 | if err != nil { 68 | slog.Warn("error looking up allowed client hostname", "hostname", allowFrom, "error", err.Error()) 69 | } 70 | for _, ip := range ips { 71 | // Check if IP address is one of the resolved IPs 72 | if ip.Equal(clientIP) { 73 | return true, nil 74 | } 75 | } 76 | } 77 | return false, nil 78 | } 79 | 80 | // sendHTTPError sends a HTTP error with the given status code. 81 | func sendHTTPError(w http.ResponseWriter, status int) { 82 | http.Error(w, http.StatusText(status), status) 83 | } 84 | 85 | // communicateBlockedRequest logs a blocked request and sends a HTTP error. 86 | func communicateBlockedRequest(w http.ResponseWriter, r *http.Request, reason string, status int) { 87 | slog.Warn("blocked request", "reason", reason, "method", r.Method, "URL", r.URL, "client", r.RemoteAddr, "response", status) 88 | sendHTTPError(w, status) 89 | } 90 | -------------------------------------------------------------------------------- /cmd/socket-proxy/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "net" 9 | "net/http" 10 | "net/http/httputil" 11 | "net/url" 12 | "os" 13 | "os/signal" 14 | "runtime" 15 | "syscall" 16 | "time" 17 | 18 | "github.com/wollomatic/socket-proxy/internal/config" 19 | ) 20 | 21 | const ( 22 | programURL = "github.com/wollomatic/socket-proxy" 23 | logAddSource = false // set to true to log the source position (file and line) of the log message 24 | ) 25 | 26 | var ( 27 | version = "dev" // will be overwritten by build system 28 | socketProxy *httputil.ReverseProxy 29 | cfg *config.Config 30 | ) 31 | 32 | func main() { 33 | var err error 34 | cfg, err = config.InitConfig() 35 | if err != nil { 36 | slog.Error("error initializing config", "error", err) 37 | os.Exit(1) 38 | } 39 | 40 | // setup channels for graceful shutdown 41 | internalQuit := make(chan int, 1) // send to this channel to invoke graceful shutdown, int is the exit code 42 | externalQuit := make(chan os.Signal, 1) // configure listener for SIGINT and SIGTERM 43 | signal.Notify(externalQuit, syscall.SIGINT, syscall.SIGTERM) 44 | 45 | // setup logging 46 | logOpts := &slog.HandlerOptions{ 47 | AddSource: logAddSource, 48 | Level: cfg.LogLevel, 49 | } 50 | var logger *slog.Logger 51 | if cfg.LogJSON { 52 | logger = slog.New(slog.NewJSONHandler(os.Stdout, logOpts)) 53 | } else { 54 | logger = slog.New(slog.NewTextHandler(os.Stdout, logOpts)) 55 | } 56 | slog.SetDefault(logger) 57 | 58 | // print configuration 59 | slog.Info("starting socket-proxy", "version", version, "os", runtime.GOOS, "arch", runtime.GOARCH, "runtime", runtime.Version(), "URL", programURL) 60 | if cfg.ProxySocketEndpoint == "" { 61 | slog.Info("configuration info", "socketpath", cfg.SocketPath, "listenaddress", cfg.ListenAddress, "loglevel", cfg.LogLevel, "logjson", cfg.LogJSON, "allowfrom", cfg.AllowFrom, "shutdowngracetime", cfg.ShutdownGraceTime) 62 | } else { 63 | slog.Info("configuration info", "socketpath", cfg.SocketPath, "proxysocketendpoint", cfg.ProxySocketEndpoint, "proxysocketendpointfilemode", cfg.ProxySocketEndpointFileMode, "loglevel", cfg.LogLevel, "logjson", cfg.LogJSON, "allowfrom", cfg.AllowFrom, "shutdowngracetime", cfg.ShutdownGraceTime) 64 | slog.Info("proxysocketendpoint is set, so the TCP listener is deactivated") 65 | } 66 | if cfg.WatchdogInterval > 0 { 67 | slog.Info("watchdog enabled", "interval", cfg.WatchdogInterval, "stoponwatchdog", cfg.StopOnWatchdog) 68 | } else { 69 | slog.Info("watchdog disabled") 70 | } 71 | 72 | // print request allow list 73 | if cfg.LogJSON { 74 | for method, regex := range cfg.AllowedRequests { 75 | slog.Info("configured allowed request", "method", method, "regex", regex) 76 | } 77 | } else { 78 | // don't use slog here, as we want to print the regexes as they are 79 | // see https://github.com/wollomatic/socket-proxy/issues/11 80 | fmt.Printf("Request allowlist:\n %-8s %s\n", "Method", "Regex") 81 | for method, regex := range cfg.AllowedRequests { 82 | fmt.Printf(" %-8s %s\n", method, regex) 83 | } 84 | } 85 | 86 | // check if the socket is available 87 | err = checkSocketAvailability(cfg.SocketPath) 88 | if err != nil { 89 | slog.Error("socket not available", "error", err) 90 | os.Exit(2) 91 | } 92 | 93 | // define the reverse proxy 94 | socketURLDummy, _ := url.Parse("http://localhost") // dummy URL - we use the unix socket 95 | socketProxy = httputil.NewSingleHostReverseProxy(socketURLDummy) 96 | socketProxy.Transport = &http.Transport{ 97 | DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { 98 | return net.Dial("unix", cfg.SocketPath) 99 | }, 100 | } 101 | 102 | var l net.Listener 103 | if cfg.ProxySocketEndpoint != "" { 104 | if _, err = os.Stat(cfg.ProxySocketEndpoint); err == nil { 105 | slog.Warn(fmt.Sprintf("%s already exists, removing existing file", cfg.ProxySocketEndpoint)) 106 | if err = os.Remove(cfg.ProxySocketEndpoint); err != nil { 107 | slog.Error("error removing existing socket file", "error", err) 108 | os.Exit(2) 109 | } 110 | } 111 | l, err = net.Listen("unix", cfg.ProxySocketEndpoint) 112 | if err != nil { 113 | slog.Error("error creating socket", "error", err) 114 | os.Exit(2) 115 | } 116 | if err = os.Chmod(cfg.ProxySocketEndpoint, cfg.ProxySocketEndpointFileMode); err != nil { 117 | slog.Error("error setting socket file permissions", "error", err) 118 | os.Exit(2) 119 | } 120 | } else { 121 | l, err = net.Listen("tcp", cfg.ListenAddress) 122 | if err != nil { 123 | slog.Error("error listening on address", "error", err) 124 | os.Exit(2) 125 | } 126 | } 127 | 128 | srv := &http.Server{ // #nosec G112 -- intentionally do not time out the client 129 | Handler: http.HandlerFunc(handleHTTPRequest), // #nosec G112 130 | } // #nosec G112 131 | 132 | // start the server in a goroutine 133 | go func() { 134 | if err := srv.Serve(l); err != nil && !errors.Is(err, http.ErrServerClosed) { 135 | slog.Error("http server problem", "error", err) 136 | os.Exit(2) 137 | } 138 | }() 139 | 140 | slog.Info("socket-proxy running and listening...") 141 | 142 | // start the watchdog if configured 143 | if cfg.WatchdogInterval > 0 { 144 | go startSocketWatchdog(cfg.SocketPath, int64(cfg.WatchdogInterval), cfg.StopOnWatchdog, internalQuit) // #nosec G115 - we validated the integer size in config.go 145 | slog.Debug("watchdog running") 146 | } 147 | 148 | // start the health check server if configured 149 | if cfg.AllowHealthcheck { 150 | go healthCheckServer(cfg.SocketPath) 151 | slog.Debug("healthcheck ready") 152 | } 153 | 154 | // Wait for stop signal 155 | exitCode := 0 156 | select { 157 | case <-externalQuit: 158 | slog.Info("received stop signal - shutting down") 159 | case value := <-internalQuit: 160 | slog.Info("received internal shutdown - shutting down") 161 | exitCode = value 162 | } 163 | // Try to shut down gracefully 164 | ctx, cancel := context.WithTimeout(context.Background(), time.Duration(int64(cfg.ShutdownGraceTime))*time.Second) // #nosec G115 - we validated the integer size in config.go 165 | defer cancel() 166 | if err := srv.Shutdown(ctx); err != nil { 167 | slog.Warn("timeout stopping server", "error", err) 168 | } 169 | slog.Info("shutdown finished - exiting", "exit code", exitCode) 170 | os.Exit(exitCode) 171 | } 172 | -------------------------------------------------------------------------------- /cosign.pub: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEYdXlfRbkO6KqPU7Khn1mSjbOIaD3 3 | um421A0NeT1wi840iWNp6MVKyj3tpnAyaQcLgd5/22O+eEHY+5+EHwB+eA== 4 | -----END PUBLIC KEY----- 5 | -------------------------------------------------------------------------------- /examples/docker-compose/dozzle/compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | dockerproxy: 3 | image: wollomatic/socket-proxy:1 4 | command: 5 | - '-loglevel=info' 6 | - '-allowfrom=dozzle' # allow only the small subnet "docker-proxynet" 7 | - '-listenip=0.0.0.0' 8 | - '-allowGET=/v1\..{2}/(containers/.*|events)|/_ping' 9 | - '-allowHEAD=/_ping' 10 | - '-watchdoginterval=300' 11 | - '-stoponwatchdog' 12 | - '-shutdowngracetime=10' 13 | restart: unless-stopped 14 | read_only: true 15 | mem_limit: 64M 16 | cap_drop: 17 | - ALL 18 | security_opt: 19 | - no-new-privileges 20 | user: 65534:998 # change gid from 998 to the gid of the docker group on your host 21 | volumes: 22 | - /var/run/docker.sock:/var/run/docker.sock:ro 23 | networks: 24 | - docker-proxynet 25 | 26 | dozzle: 27 | image: amir20/dozzle:v6.4.2 # make sure you use the most recent version 28 | user: 65534:65534 29 | read_only: true 30 | mem_limit: 256M 31 | cap_drop: 32 | - ALL 33 | security_opt: 34 | - no-new-privileges 35 | depends_on: 36 | - dockerproxy 37 | environment: 38 | DOZZLE_REMOTE_HOST: tcp://dockerproxy:2375 39 | # # add additional configuration here 40 | # # for example labels for traefik if needed 41 | # or expose the port to the host network: 42 | # ports: 43 | # - 127.0.0.1:8080:8080 # bind only to the host network 44 | networks: 45 | - docker-proxynet 46 | - dozzle 47 | 48 | networks: 49 | docker-proxynet: 50 | internal: true 51 | attachable: false 52 | dozzle: 53 | driver: bridge 54 | attachable: false 55 | -------------------------------------------------------------------------------- /examples/docker-compose/watchtower/compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | dockerproxy: 3 | image: wollomatic/socket-proxy:1 4 | command: 5 | - '-loglevel=info' 6 | - '-allowfrom=watchtower' # allow only access from the "watchtower" service 7 | - '-listenip=0.0.0.0' 8 | - '-shutdowngracetime=10' 9 | # this whitelists the API endpoints that watchtower needs: 10 | - '-allowGET=/v1\..{2}/(containers/.*|images/.*)' 11 | - '-allowPOST=/v1\..{2}/(containers/.*|images/.*|networks/.*)' 12 | - '-allowDELETE=/v1\..{2}/(containers/.*|images/.*)' 13 | # check socket connection every hour and stop the proxy if it fails (will then be restarted by docker): 14 | - '-watchdoginterval=3600' 15 | - '-stoponwatchdog' 16 | restart: unless-stopped 17 | read_only: true 18 | mem_limit: 64M 19 | cap_drop: 20 | - ALL 21 | security_opt: 22 | - no-new-privileges 23 | user: 65534:998 # change gid from 998 to the gid of the docker group on your host 24 | volumes: 25 | - /var/run/docker.sock:/var/run/docker.sock:ro 26 | labels: 27 | - com.centurylinklabs.watchtower.enable=false # if watchtower would try to update the proxy, it would just stop 28 | networks: 29 | - docker-proxynet 30 | 31 | watchtower: 32 | image: containrrr/watchtower:1.7.1 33 | depends_on: 34 | - dockerproxy 35 | command: 36 | - '--host=tcp://dockerproxy:2375' 37 | - '--schedule=0 30 4 * * *' 38 | - '--debug' 39 | - '--stop-timeout=5m' 40 | - '--cleanup' 41 | user: 65534:65534 42 | read_only: true 43 | mem_limit: 256M 44 | cap_drop: 45 | - ALL 46 | security_opt: 47 | - no-new-privileges 48 | networks: 49 | - docker-proxynet 50 | - watchtower 51 | 52 | networks: 53 | docker-proxynet: 54 | internal: true 55 | attachable: false 56 | watchtower: 57 | driver: bridge 58 | attachable: false 59 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/wollomatic/socket-proxy 2 | 3 | go 1.22.6 4 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "log/slog" 8 | "math" 9 | "net" 10 | "net/http" 11 | "os" 12 | "regexp" 13 | "strconv" 14 | "strings" 15 | ) 16 | 17 | var ( 18 | defaultAllowFrom = "127.0.0.1/32" // allowed IPs to connect to the proxy 19 | defaultAllowHealthcheck = false // allow health check requests (HEAD http://localhost:55555/health) 20 | defaultLogJSON = false // if true, log in JSON format 21 | defaultLogLevel = "INFO" // log level as string 22 | defaultListenIP = "127.0.0.1" // ip address to bind the server to 23 | defaultProxyPort = uint(2375) // tcp port to listen on 24 | defaultSocketPath = "/var/run/docker.sock" // path to the unix socket 25 | defaultShutdownGraceTime = uint(10) // Maximum time in seconds to wait for the server to shut down gracefully 26 | defaultWatchdogInterval = uint(0) // watchdog interval in seconds (0 to disable) 27 | defaultStopOnWatchdog = false // set to true to stop the program when the socket gets unavailable (otherwise log only) 28 | defaultProxySocketEndpoint = "" // empty string means no socket listener, but regular TCP listener 29 | defaultProxySocketEndpointFileMode = uint(0o400) // set the file mode of the unix socket endpoint 30 | ) 31 | 32 | type Config struct { 33 | AllowedRequests map[string]*regexp.Regexp 34 | AllowFrom string 35 | AllowHealthcheck bool 36 | LogJSON bool 37 | StopOnWatchdog bool 38 | ShutdownGraceTime uint 39 | WatchdogInterval uint 40 | LogLevel slog.Level 41 | ListenAddress string 42 | SocketPath string 43 | ProxySocketEndpoint string 44 | ProxySocketEndpointFileMode os.FileMode 45 | } 46 | 47 | // used for list of allowed requests 48 | type methodRegex struct { 49 | method string 50 | regexStringFromEnv string 51 | regexStringFromParam string 52 | } 53 | 54 | // mr is the allowlist of requests per http method 55 | // default: regexStringFromEnv and regexStringFromParam are empty, so regexCompiled stays nil and the request is blocked 56 | // if regexStringParam is set with a command line parameter, all requests matching the method and path matching the regex are allowed 57 | // else if regexStringEnv from Environment ist checked 58 | var mr = []methodRegex{ 59 | {method: http.MethodGet}, 60 | {method: http.MethodHead}, 61 | {method: http.MethodPost}, 62 | {method: http.MethodPut}, 63 | {method: http.MethodPatch}, 64 | {method: http.MethodDelete}, 65 | {method: http.MethodConnect}, 66 | {method: http.MethodTrace}, 67 | {method: http.MethodOptions}, 68 | } 69 | 70 | func InitConfig() (*Config, error) { 71 | var ( 72 | cfg Config 73 | listenIP string 74 | proxyPort uint 75 | logLevel string 76 | endpointFileMode uint 77 | ) 78 | 79 | if val, ok := os.LookupEnv("SP_ALLOWFROM"); ok && val != "" { 80 | defaultAllowFrom = val 81 | } 82 | if val, ok := os.LookupEnv("SP_ALLOWHEALTHCHECK"); ok { 83 | if parsedVal, err := strconv.ParseBool(val); err == nil { 84 | defaultAllowHealthcheck = parsedVal 85 | } 86 | } 87 | if val, ok := os.LookupEnv("SP_LOGJSON"); ok { 88 | if parsedVal, err := strconv.ParseBool(val); err == nil { 89 | defaultLogJSON = parsedVal 90 | } 91 | } 92 | if val, ok := os.LookupEnv("SP_LISTENIP"); ok && val != "" { 93 | defaultListenIP = val 94 | } 95 | if val, ok := os.LookupEnv("SP_LOGLEVEL"); ok && val != "" { 96 | defaultLogLevel = val 97 | } 98 | if val, ok := os.LookupEnv("SP_PROXYPORT"); ok && val != "" { 99 | if parsedVal, err := strconv.ParseUint(val, 10, 32); err == nil { 100 | defaultProxyPort = uint(parsedVal) 101 | } 102 | } 103 | if val, ok := os.LookupEnv("SP_SHUTDOWNGRACETIME"); ok && val != "" { 104 | if parsedVal, err := strconv.ParseUint(val, 10, 32); err == nil { 105 | defaultShutdownGraceTime = uint(parsedVal) 106 | } 107 | } 108 | if val, ok := os.LookupEnv("SP_SOCKETPATH"); ok && val != "" { 109 | defaultSocketPath = val 110 | } 111 | if val, ok := os.LookupEnv("SP_STOPONWATCHDOG"); ok { 112 | if parsedVal, err := strconv.ParseBool(val); err == nil { 113 | defaultStopOnWatchdog = parsedVal 114 | } 115 | } 116 | if val, ok := os.LookupEnv("SP_WATCHDOGINTERVAL"); ok && val != "" { 117 | if parsedVal, err := strconv.ParseUint(val, 10, 32); err == nil { 118 | defaultWatchdogInterval = uint(parsedVal) 119 | } 120 | } 121 | if val, ok := os.LookupEnv("SP_PROXYSOCKETENDPOINT"); ok && val != "" { 122 | defaultProxySocketEndpoint = val 123 | } 124 | if val, ok := os.LookupEnv("SP_PROXYSOCKETENDPOINTFILEMODE"); ok { 125 | if parsedVal, err := strconv.ParseUint(val, 8, 32); err == nil { 126 | defaultProxySocketEndpointFileMode = uint(parsedVal) 127 | } 128 | } 129 | 130 | for i := range mr { 131 | if val, ok := os.LookupEnv("SP_ALLOW_" + mr[i].method); ok && val != "" { 132 | mr[i].regexStringFromEnv = val 133 | } 134 | } 135 | 136 | flag.StringVar(&cfg.AllowFrom, "allowfrom", defaultAllowFrom, "allowed IPs or hostname to connect to the proxy") 137 | flag.BoolVar(&cfg.AllowHealthcheck, "allowhealthcheck", defaultAllowHealthcheck, "allow health check requests (HEAD http://localhost:55555/health)") 138 | flag.BoolVar(&cfg.LogJSON, "logjson", defaultLogJSON, "log in JSON format (otherwise log in plain text") 139 | flag.StringVar(&listenIP, "listenip", defaultListenIP, "ip address to listen on") 140 | flag.StringVar(&logLevel, "loglevel", defaultLogLevel, "set log level: DEBUG, INFO, WARN, ERROR") 141 | flag.UintVar(&proxyPort, "proxyport", defaultProxyPort, "tcp port to listen on") 142 | flag.UintVar(&cfg.ShutdownGraceTime, "shutdowngracetime", defaultShutdownGraceTime, "maximum time in seconds to wait for the server to shut down gracefully") 143 | if cfg.ShutdownGraceTime > math.MaxInt { 144 | return nil, fmt.Errorf("shutdowngracetime has to be smaller than %i", math.MaxInt) // this maximum value has no practical significance 145 | } 146 | flag.StringVar(&cfg.SocketPath, "socketpath", defaultSocketPath, "unix socket path to connect to") 147 | flag.BoolVar(&cfg.StopOnWatchdog, "stoponwatchdog", defaultStopOnWatchdog, "stop the program when the socket gets unavailable (otherwise log only)") 148 | flag.UintVar(&cfg.WatchdogInterval, "watchdoginterval", defaultWatchdogInterval, "watchdog interval in seconds (0 to disable)") 149 | if cfg.WatchdogInterval > math.MaxInt { 150 | return nil, fmt.Errorf("watchdoginterval has to be smaller than %i", math.MaxInt) // this maximum value has no practical significance 151 | } 152 | flag.StringVar(&cfg.ProxySocketEndpoint, "proxysocketendpoint", defaultProxySocketEndpoint, "unix socket endpoint (if set, used instead of the TCP listener)") 153 | flag.UintVar(&endpointFileMode, "proxysocketendpointfilemode", defaultProxySocketEndpointFileMode, "set the file mode of the unix socket endpoint") 154 | for i := range mr { 155 | flag.StringVar(&mr[i].regexStringFromParam, "allow"+mr[i].method, "", "regex for "+mr[i].method+" requests (not set means method is not allowed)") 156 | } 157 | flag.Parse() 158 | 159 | // check listenIP and proxyPort 160 | if net.ParseIP(listenIP) == nil { 161 | return nil, fmt.Errorf("invalid IP \"%s\" for listenip", listenIP) 162 | } 163 | if proxyPort < 1 || proxyPort > 65535 { 164 | return nil, errors.New("port number has to be between 1 and 65535") 165 | } 166 | cfg.ListenAddress = fmt.Sprintf("%s:%d", listenIP, proxyPort) 167 | 168 | // parse defaultLogLevel and setup logging handler depending on defaultLogJSON 169 | switch strings.ToUpper(logLevel) { 170 | case "DEBUG": 171 | cfg.LogLevel = slog.LevelDebug 172 | case "INFO": 173 | cfg.LogLevel = slog.LevelInfo 174 | case "WARN": 175 | cfg.LogLevel = slog.LevelWarn 176 | case "ERROR": 177 | cfg.LogLevel = slog.LevelError 178 | default: 179 | return nil, errors.New("invalid log level " + logLevel + ": Supported levels are DEBUG, INFO, WARN, ERROR") 180 | } 181 | 182 | if endpointFileMode > 0o777 { 183 | return nil, errors.New("file mode has to be between 0 and 0o777") 184 | } 185 | cfg.ProxySocketEndpointFileMode = os.FileMode(uint32(endpointFileMode)) 186 | 187 | // compile regexes for allowed requests 188 | cfg.AllowedRequests = make(map[string]*regexp.Regexp) 189 | for _, rx := range mr { 190 | if rx.regexStringFromParam != "" { 191 | r, err := regexp.Compile("^" + rx.regexStringFromParam + "$") 192 | if err != nil { 193 | return nil, fmt.Errorf("invalid regex \"%s\" for method %s in command line parameter: %w", rx.regexStringFromParam, rx.method, err) 194 | } 195 | cfg.AllowedRequests[rx.method] = r 196 | } else if rx.regexStringFromEnv != "" { 197 | r, err := regexp.Compile("^" + rx.regexStringFromEnv + "$") 198 | if err != nil { 199 | return nil, fmt.Errorf("invalid regex \"%s\" for method %s in env variable: %w", rx.regexStringFromParam, rx.method, err) 200 | } 201 | cfg.AllowedRequests[rx.method] = r 202 | } 203 | } 204 | return &cfg, nil 205 | } 206 | --------------------------------------------------------------------------------