Repository: wollomatic/socket-proxy Branch: main Commit: 272ef5f88a12 Files: 46 Total size: 153.2 KB Directory structure: gitextract_yodnu69f/ ├── .dockerignore ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ ├── dependabot.yml │ └── workflows/ │ ├── dependency-review.yml │ ├── docker-image-release.yaml │ └── docker-image-testing.yaml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── SECURITY.md ├── cmd/ │ ├── healthcheck/ │ │ └── main.go │ └── socket-proxy/ │ ├── bindmount.go │ ├── bindmount_test.go │ ├── checksocketconnection.go │ ├── handlehttprequest.go │ └── main.go ├── cosign.pub ├── examples/ │ └── docker-compose/ │ ├── dozzle/ │ │ └── compose.yaml │ └── watchtower/ │ └── compose.yaml ├── go.mod └── internal/ ├── config/ │ ├── config.go │ ├── config_test.go │ ├── env.go │ ├── env_test.go │ └── param.go ├── docker/ │ ├── api/ │ │ ├── common.go │ │ └── types/ │ │ ├── container/ │ │ │ ├── container.go │ │ │ ├── network_settings.go │ │ │ └── options.go │ │ ├── error_response.go │ │ ├── events/ │ │ │ └── events.go │ │ ├── filters/ │ │ │ ├── errors.go │ │ │ └── parse.go │ │ ├── network/ │ │ │ └── endpoint.go │ │ ├── types.go │ │ └── versions/ │ │ └── compare.go │ └── client/ │ ├── client.go │ ├── container_list.go │ ├── errors.go │ ├── events.go │ ├── options.go │ ├── ping.go │ └── request.go └── go-connections/ └── sockets/ └── sockets.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ * !cmd !internal !go.mod !LICENSE ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - OS: [e.g. iOS] - Browser [e.g. chrome, safari] - Version [e.g. 22] **Smartphone (please complete the following information):** - Device: [e.g. iPhone6] - OS: [e.g. iOS8.1] - Browser [e.g. stock browser, safari] - Version [e.g. 22] **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: '' assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/dependabot.yml ================================================ # To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference version: 2 updates: - package-ecosystem: "docker" directory: "/" schedule: interval: "weekly" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" - package-ecosystem: "gomod" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "weekly" ================================================ FILE: .github/workflows/dependency-review.yml ================================================ # Dependency Review Action # # This Action will scan dependency manifest files that change as part of a Pull Request, # surfacing known-vulnerable versions of the packages declared or updated in the PR. # Once installed, if the workflow run is marked as required, # PRs introducing known-vulnerable packages will be blocked from merging. # # Source repository: https://github.com/actions/dependency-review-action name: 'Dependency Review' on: [pull_request] permissions: contents: read jobs: dependency-review: runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 with: egress-policy: audit - name: 'Checkout Repository' uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: 'Dependency Review' uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 ================================================ FILE: .github/workflows/docker-image-release.yaml ================================================ name: Build and Publish Release permissions: contents: read packages: write on: push: tags: ['*'] jobs: build: runs-on: ubuntu-latest env: GO111MODULE: on steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: '1.26' - name: Run Gosec Security Scanner run: | go install github.com/securego/gosec/v2/cmd/gosec@v2.23.0 gosec ./... - name: Run Go tests run: go test ./... - name: Extract tag name id: get_tag run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" - name: Install Cosign uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 with: cosign-release: 'v2.6.1' - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Login to Docker Hub uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push Docker Hub image uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 id: push-dockerhub with: context: . platforms: linux/amd64,linux/arm/v7,linux/arm64 push: true build-args: VERSION=${{ steps.get_tag.outputs.VERSION }} tags: | docker.io/wollomatic/socket-proxy:${{ steps.get_tag.outputs.VERSION }} docker.io/wollomatic/socket-proxy:1 - name: Sign Docker Hub image run: cosign sign --yes --recursive --key env://COSIGN_PRIVATE_KEY docker.io/wollomatic/socket-proxy:${{ steps.get_tag.outputs.VERSION }}@${{ steps.push-dockerhub.outputs.digest }} env: COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} - name: Build and push GHCR image uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 id: push-ghcr with: context: . platforms: linux/amd64,linux/arm/v7,linux/arm64 push: true build-args: VERSION=${{ steps.get_tag.outputs.VERSION }} tags: | ghcr.io/wollomatic/socket-proxy:${{ steps.get_tag.outputs.VERSION }} ghcr.io/wollomatic/socket-proxy:1 - name: Sign GHCR image run: cosign sign --yes --recursive --key env://COSIGN_PRIVATE_KEY ghcr.io/wollomatic/socket-proxy:${{ steps.get_tag.outputs.VERSION }}@${{ steps.push-ghcr.outputs.digest }} env: COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} ================================================ FILE: .github/workflows/docker-image-testing.yaml ================================================ name: Build and Publish Testing permissions: contents: read packages: write on: workflow_dispatch: push: branches: - develop jobs: build: runs-on: ubuntu-latest env: GO111MODULE: on steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: '1.26' - name: Run Gosec Security Scanner run: | go install github.com/securego/gosec/v2/cmd/gosec@v2.23.0 gosec ./... - name: Run Go tests run: go test ./... # - name: Install Cosign # uses: sigstore/cosign-installer@v3.10.0 # with: # cosign-release: 'v2.6.0' - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Login to Docker Hub uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push image to Docker Hub and GHCR uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 id: push-all with: context: . platforms: linux/amd64,linux/arm/v7,linux/arm64 push: true build-args: VERSION=testing-${{ github.sha }} tags: | docker.io/wollomatic/socket-proxy:testing docker.io/wollomatic/socket-proxy:testing-${{ github.sha }} ghcr.io/wollomatic/socket-proxy:testing ghcr.io/wollomatic/socket-proxy:testing-${{ github.sha }} # - name: Build and push Docker Hub image # uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 # id: push-dockerhub # with: # context: . # platforms: linux/amd64,linux/arm/v7,linux/arm64 # push: true # build-args: VERSION=testing-${{ github.sha }} # tags: | # docker.io/wollomatic/socket-proxy:testing # docker.io/wollomatic/socket-proxy:testing-${{ github.sha }} # # - name: Sign Docker Hub image # run: cosign sign --yes --recursive --key env://COSIGN_PRIVATE_KEY docker.io/wollomatic/socket-proxy:testing-${{ github.sha }}@${{ steps.push-dockerhub.outputs.digest }} # env: # COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} # COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} # # - name: Build and push GHCR image # uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 # id: push-ghcr # with: # context: . # platforms: linux/amd64,linux/arm/v7,linux/arm64 # push: true # build-args: VERSION=testing-${{ github.sha }} # tags: | # ghcr.io/wollomatic/socket-proxy:testing # ghcr.io/wollomatic/socket-proxy:testing-${{ github.sha }} # # - name: Sign GHCR image # run: cosign sign --yes --recursive --key env://COSIGN_PRIVATE_KEY ghcr.io/wollomatic/socket-proxy:testing-${{ github.sha }}@${{ steps.push-ghcr.outputs.digest }} # env: # COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} # COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} ================================================ FILE: .gitignore ================================================ # If you prefer the allow list template instead of the deny list, see community template: # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore # # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib # Test binary, built with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out # Dependency directories (remove the comment below to include it) # vendor/ # Go workspace file go.work # JetBrains IDEA .idea ================================================ FILE: Dockerfile ================================================ # syntax=docker/dockerfile:1 FROM --platform=$BUILDPLATFORM golang:1.26.2-alpine3.23@sha256:f85330846cde1e57ca9ec309382da3b8e6ae3ab943d2739500e08c86393a21b1 AS build WORKDIR /application COPY . ./ ARG TARGETOS ARG TARGETARCH ARG VERSION 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 / ./... FROM scratch LABEL org.opencontainers.image.source=https://github.com/wollomatic/socket-proxy \ org.opencontainers.image.description="A lightweight and secure unix socket proxy" \ org.opencontainers.image.licenses=MIT USER 65534:65534 VOLUME /var/run/docker.sock EXPOSE 2375 ENTRYPOINT ["/socket-proxy"] COPY --from=build ./healthcheck ./socket-proxy / ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2023 Wolfgang Ellsässer (wollomatic) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 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/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. Apache License Version 2.0, January 2004 https://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS Copyright 2013-2018 Docker, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ # socket-proxy ## Latest image - `wollomatic/socket-proxy:1.12.0` / `ghcr.io/wollomatic/socket-proxy:1.12.0` - `wollomatic/socket-proxy:1` / `ghcr.io/wollomatic/socket-proxy:1` > [!IMPORTANT] >## Usage with Traefik >= 2.11.31 / >= 3.6.1 >Due to a change in how Traefik retrieves the Docker API version (traefik/traefik#12256), the Socket-Proxy configuration for Traefik must be updated to allow `HEAD` requests to `/_ping`: > > - '-allowHEAD=/_ping' > >Otherwise, Traefik would fall back to API version 1.51, which would break the Docker provider on older Docker versions. ## About `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. 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-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. 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) > [!NOTE] > Starting with version 1.6.0, the socket-proxy container image is also available on GHCR. ## Getting Started Some examples can be found in the [wiki](https://github.com/wollomatic/socket-proxy/wiki) and in the `examples` directory of the repo. ### Warning 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. ### Installing 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.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. 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). As of version 1.6, all multi-arch images are signed. ### Allowing access Because of the secure-by-default design, you need to allow every access explicitly. 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. #### Setting up the TCP listener 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. #### Using a unix socket instead of a TCP listener If you want to proxy/filter the unix socket to a new unix socket instead to a TCP listener, you need to set the `-proxysocketendpoint` parameter or the `SP_PROXYSOCKETENDPOINT` env variable to the socket path of the new unix socket. This will also disable the TCP listener. For example `-proxysocketendpoint=/tmp/filtered-socket.sock` > [!NOTE] > Versions prior to 1.10.0 of socket-proxy set the default file permissions of the Unix socket to 0400, instead of 0600 as stated in the documentation. #### Setting up the IP address or hostname allowlist 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. 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`. 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. #### Setting up the allowlist for requests 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 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_ALLOW_GET`). 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, `^` and `$` are added automatically to the start and end of the pattern. Note: invalid regexp results in program termination. 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. + `'-allowGET=/version -allowGET=/_ping'` supports using `-allowGET` multiple times 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_GET="/version" SP_ALLOW_GET_2="/_ping"'` supports multiple `SP_ALLOW_GET` entries For more information, refer to the [Go regexp documentation](https://golang.org/pkg/regexp/syntax/). An excellent online regexp tester is [regex101.com](https://regex101.com/). 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. > [!NOTE] > Starting with version 1.12.0, the socket-proxy supports using multiple -allow* entries in params, environment, or docker labels. #### Setting up bind mount restrictions By default, socket-proxy does not restrict bind mounts. If you want to add an additional layer of security by restricting which directories can be used as bind mount sources, you can use the `-allowbindmountfrom` parameter or the `SP_ALLOWBINDMOUNTFROM` environment variable. When configured, only bind mounts from the specified directories or their subdirectories are allowed. Each directory must start with `/`. Multiple directories can be specified separated by commas. For example: + `-allowbindmountfrom=/home,/var/log` allows bind mounts from `/home`, `/var/log`, and any subdirectories like `/home/user/data` or `/var/log/app` + `SP_ALLOWBINDMOUNTFROM="/app/data,/tmp"` allows bind mounts from `/app/data` and `/tmp` directories Bind mount restrictions are applied to relevant Docker API endpoints and work with both legacy bind mount syntax (`-v /host/path:/container/path`) and modern mount syntax. **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.`, with `socket-proxy.allow.bindmountfrom` for bind mount restrictions. For example: ```yaml services: traefik: # [...] see github.com/wollomatic/traefik-hardened for a full example 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 socket-proxy - 'socket-proxy.allow.head=/version' # HEAD `/version` requests to socket-proxy - 'socket-proxy.allow.head.1=/exec' # another HEAD `exec` 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. ### 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: ``` compose.yaml # [...] healthcheck: test: ["CMD", "./healthcheck"] interval: 10s timeout: 5s 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 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). ### Example for proxying the docker socket to Traefik You need to know how to install Traefik in this environment. See [wollomatic/traefik2-hardened](https://github.com/wollomatic/traefik2-hardened) for an example. The image can be deployed with docker compose: ``` compose.yaml services: dockerproxy: image: wollomatic/socket-proxy:<> # choose most recent image restart: unless-stopped user: "65534:<>" mem_limit: 64M read_only: true cap_drop: - ALL security_opt: - no-new-privileges command: - '-loglevel=info' - '-listenip=0.0.0.0' - '-allowfrom=traefik' # allow only hostname "traefik" to connect - '-allowGET=/v1\..{1,2}/(version|containers/.*|events.*)' - '-allowbindmountfrom=/var/log,/tmp' # restrict bind mounts to specific directories - '-watchdoginterval=3600' # check once per hour for socket availability - '-stoponwatchdog' # halt program on error and let compose restart it - '-shutdowngracetime=5' # wait 5 seconds before shutting down volumes: - /var/run/docker.sock:/var/run/docker.sock:ro networks: - docker-proxynet # NEVER EVER expose this to the public internet! # this is a private network only for traefik and socket-proxy # it is not the same as the traefik-servicenet 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 networks: traefik-servicenet: external: true docker-proxynet: driver: bridge internal: true ``` ### Examining the API calls of the client application 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: ``` - '-loglevel=debug' - '-allowGET=.*' - '-allowHEAD=.*' - '-allowPOST=.*' - '-allowPUT=.*' - '-allowPATCH=.*' - '-allowDELETE=.*' - '-allowCONNECT=.*' - '-allowTRACE=.*' - '-allowOPTIONS=.*' ``` ### 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. | Parameter | Environment Variable | Default Value | Description | |--------------------------------|----------------------------------|------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `-allowfrom` | `SP_ALLOWFROM` | `127.0.0.1/32` | Specifies the IP addresses or hostnames (comma-separated) 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 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. | | `-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, 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 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) | | `-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 1.0 - initial release 1.1 - add hostname support for `-allowfrom` parameter 1.2 - reformat logging of allowlist on program start 1.3 - allow multiple, comma-separated hostnames in `-allowfrom` parameter (thanks [@ildyria](https://github.com/ildyria)) 1.4 - allow configuration from env variables 1.5 - allow unix socket as proxied/filtered endpoint 1.6 - Cosign: sign a multi-arch container image AND all referenced, discrete images. Image is also available on GHCR. 1.7 - also allow comma-separated CIDRs in `-allowfrom` (not only hostnames as in versions > 1.3) 1.8 - add optional bind mount restrictions (thanks [@powerman](https://github.com/powerman), [@C4tWithShell](https://github.com/C4tWithShell)) 1.9 - add IPv6 support to `-listenip` (thanks [@op3](https://github.com/op3)) 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)) 1.12 - support use of allow* multiple times in env, flag and docker labels (thanks [@qianlongzt](https://github.com/qianlongzt)) ## License 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 + [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) + [@justsomescripts](https://github.com/justsomescripts) fix parsing environment variable to configure unix socket ## Alternatives + [hectorm/cetusguard](https://github.com/hectorm/cetusguard) + [11notes/docker-socket-proxy](https://github.com/11notes/docker-socket-proxy) ================================================ FILE: SECURITY.md ================================================ -----BEGIN PGP SIGNED MESSAGE----- Hash: SHA512 # Security Policy ## Supported Versions As no breaking changes to existing features are planned, only the most recent version is supported. ## Signed Docker Images 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: ``` - - - -----BEGIN PUBLIC KEY----- MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEYdXlfRbkO6KqPU7Khn1mSjbOIaD3 um421A0NeT1wi840iWNp6MVKyj3tpnAyaQcLgd5/22O+eEHY+5+EHwB+eA== - - - -----END PUBLIC KEY----- ``` 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). ## Reporting a Vulnerability Please report vulnerabilities to git2026(at)wollomatic.dev Feel free to encrypt the message if you like: [Download PGP public key 9123F130](https://wollomatic.dev/gpg/9123F130.gpg.asc) ``` - - - - -----BEGIN PGP PUBLIC KEY BLOCK----- Comment: Benutzer-ID: Comment: Gültig seit: 30.12.2021 18:15 Comment: Gültig bis: 31.12.2025 12:00 Comment: Typ: 4.096-bit RSA Comment: Fingerabdruck: D57424AC7C262F4B44F45B575586B7A4D15E6CA7 mQINBGHN6UwBEADglyuMVQxNfZJ9RU/UA56sxdR/cgt9mNUUNzepQxYXhTJPBrPu gnMcy8oJOHla9wjgSz/RWqi/VN29asXYikortnL+iRzzDdCQDZS2ULCR0BBvNpoi HgyeSn3xowapCHY44ghekERU+Zv2Kbw6GiYdNhzCmpCt+Du8LxF/tyoUlyJY4uas Dmdu6ZXp+5rRgXpYSWj2fgeRz15FDEWsHXFC2CuZZSGgcy4paVQrDFlpVDdlV0JX ktFPDCwF3zcVGSElJjZGAzDDoPb30Mh/ui2NSBElF9iuZk6Rt0h7rVwTOCyJL76d J2mBk5ldf//JRBUfxC5zHlDhAxmsWFSCuCgkK7lvyUYzlG0mBneYVQpjmOEPZscU PlNafwxMHBNIkO3B4Y9HWy5dbwAjey4X8gZRTJv4e9O9WoUx41Hdf/UIicCIvGWq DJ6Z8iWnqddX/nxb5mWhxb79Tj022wdMjVInn7bbOOwj6lERqsGqQYdEQgTPfMg+ TswfXnFPwsOdXCw7NmfUAyRS9uam+ThDQbIgKjGgqn2+0pKtd4jPFv/woMN77CWo o5ZBSd7pF1dTdkmAI0gSapAyjewEsExq73OicYbCIwfTUxvWFNyp2gHPXWFSfAgb Yvo6GGnmFL6wFE6H9eVi824+pdYnYuE65xB8+3TUu6FKvToJgbjDaObcXwARAQAB tBpzZWN1cml0eTIwMjVAd29sbG9tYXRpYy5kZYkCWAQTAQgAQhYhBNV0JKx8Ji9L RPRbV1WGt6TRXmynBQJhzelMAhsDBQkHhxjkBQsJCAcCAyICAQYVCgkICwIEFgID AQIeBwIXgAAKCRBVhrek0V5sp8gFEACFgXwLwpVjEhAWwGF54MxFxyHNreJ8b0xa 5OYG8UsSSW6L6SvWOjl+FV4L6OFgUAM22WosvbOfL3NMDt8RVv2RxQ6WcIBaxPq3 esNm/O2bT/gDCdvqUo2J6hAbeIilTrYXA74cwggCovJN3aTf7P4ieggYPbMi9SoT EdGb3Q1TeaCPqEsutroTG0gqG6Ff+gZs6IHCYcpb7+gSomARoxD5Xlmu+rgPgmcT I5DERSt6iXiPAsGVaiPePm32aj8gRwLDHmuMEV3UYxdjffLBqM991ZyuYVUmVPQp AkMRz3sQ5lZOoV503mDR02761yQxCf8bOxlTpuuvRGV86hlKvce+fj9yvegUBOBJ bwtquKhVfxZYkvyL+Nt4jjB8rH8M9UIZjMScaA4NoWjEzBDh6MdRjMMZvB/UpROA mPsN5YgzQGnEH04bP07QYfH1cGeIUx6YvT8nwnKj+aMWbHy/hyxPF4RCzveCkC+X KlMPTj3/oCw1pf9AigJ2PBZrg9SW4Wtr5BoLEqdVr+Wxm2Um75GPji5C+ZbimDXL D3gTxPi81guhkPi51gucrqqhzAHIoRiPAC0rqbO+PPKejJzDfLgrRhWD8hhOAbLK wx0eWhMKMU/lZyH7RSCpnOd1lU46pSUfosZBgO0c8DGW54AAFrmc4lyx/uJ2KDDU GgliSPDEnLkCDQRhzelMARAAn/EtaUC6/O79fiYWILeGbK8YqBu5u1HhRn4V6ztH PW5n4oz1GUVnfUX+/o95jGdCxfrrC+lCF6D9Utvk7vGNMxKfdyM5CFzUeYvgZ6OP m+0s9dZRahRh/01jgRVbkojoH0nAeWhLGRjQ20ElwJak3c/Moe+3EjQUrzm7hHuL wE7XPiBhYsR3mqq4GgwrXOmm7tDy8ccFVs5kq/8zneaCwr3OMz6aa94zIxvSIKf5 vMrTKNvDnc2BLIj2IwUSd7OOD0tnBb610pr+rDX/NHA5y03Vw2DbD4uS7TBCYwjm Y/2YvuRcWu+3jOMteQrDyNzSIKN0V2tgiYt249IzqAosKiWfOazZFDwloglCQSkf FeJYZPYfmRTi6slxZvaEWJGMIElBm/yl1fDD96YGqI+CWn45FBzO4hsmXxeOkPqJ NInU5vQiV9aFSOaQs9Zo/aw33P4UwqJWLNHJaf++kITuLBU6994wproNWmtxLK2v lPet2BWJRrcRgV00cLXyOVwcfG7x3I5d1ohMhQa7NyJTi9XTwWBdy/cd32J8FSPj 6L0Oyvx5p0+wy9B6exBXNcaQKbqrtetmJ0XG2CBew1CZGr5ARULeTnitB2ma2rTr BmiQDWM6kpKgfBn1Ek8XXlj8wEvLuKN+TEADjD9CnRsy61yofOszfI/882hkKGkt /bEAEQEAAYkCPAQYAQgAJhYhBNV0JKx8Ji9LRPRbV1WGt6TRXmynBQJhzelMAhsM BQkHhxjkAAoJEFWGt6TRXmynEKcP/2v7ds6b9rKD/GMtZgElXYDNbDYAcUoOR9UG Df5o7tcZ2gao/dald5YASaBUs1cA1BJG7/cORzyWuwEpzRsNjI2E/tpwN3Ki2B2/ 2oI4rZaxiuh9h+Z46umo0gLlqF9AE+MFb1t+oGoMzkioTo2pC6ce9P68MRP63mGs PYFe1ghH56N8giTGHQqafzNHEVr9PGMXgPaQr5C9tWwd37g3BZPY0jQHf3kRa1r3 AvczeBUnEIkBFZA+CGM1EaE77TlcY7Sh7H035P5xe1y1ehrAtP5Nb4e82WLOgV0K 4XoiHm+1uOF1kCT1pT5q5l9H9QYvLUJ7+XpGuIt25GtQcd55hU3NFMiAD13gAOPg 7zO5pz++4jG8x7osDAPjquKoEsTDH2qmWEcF+/5tOit/byqzB/wTCZIxAFNLKdUn VihMY7iTlDZMrnXOKDmuyLIsV3TWzddUDv9DOTRH2kdSYdIzMA2gYiLHIb+mb9T/ CzfsxB8x4pEjtvrWK5vEH5G9tSBfSBTbVJI/mwVUBftkBuJpCrTUknzJJhD6gW4s AGx0J/IYKvNwbYErCoOsqM78lZZ20hvKwDCW1jNEZibqiL98yhQhoEymTu9FHShR WrjWE3RoPNCEPKwCVSh08Y/bVcUyfkDNKkN3l8lT34TIEUOkzdXD2JLL6cogLpn2 Q/PCqEw9 =6UYI - - - - -----END PGP PUBLIC KEY BLOCK----- ``` -----BEGIN PGP SIGNATURE----- iHUEARYKAB0WIQQX7u5MQzQWc4kIq7Te/gx2oe2rbQUCaaybJwAKCRDe/gx2oe2r bYhkAQCRbh/Bn1+/7sFlP2jU9BKfNUkSy/Ss5PA9DpYlHu4SaAD/RJXH70xYb7jW t90C39ppKOCFyshcaTWPGWmE5treYQo= =er7Z -----END PGP SIGNATURE----- ================================================ FILE: cmd/healthcheck/main.go ================================================ package main import ( "log" "net/http" ) // main does a health check against the socket-proxy server // if the health check fails, the program exits with a non-zero exit code and logs an error // if the health check succeeds, the program exits with a zero exit code // socket-proxy must be started with the -allowhealthcheck flag func main() { resp, err := http.Head("http://localhost:55555/health") if err != nil { log.Fatal("error doing health check: ", err) } if resp.StatusCode != http.StatusOK { log.Fatal("health check failed, got status: ", resp.StatusCode) } } ================================================ FILE: cmd/socket-proxy/bindmount.go ================================================ package main import ( "bytes" "encoding/json" "fmt" "io" "log/slog" "net/http" "path/filepath" "strings" ) /* The subsets of github.com/docker/docker/api/types/ are licensed under a Apache 2.0 license. NOTICE regarding this file only: Docker Copyright 2012-2017 Docker, Inc. This product includes software developed at Docker, Inc. (https://www.docker.com). This product contains software (https://github.com/creack/pty) developed by Keith Rarick, licensed under the MIT License. The following is courtesy of our legal counsel: Use and transfer of Docker may be subject to certain restrictions by the United States and other governments. It is your responsibility to ensure that your use and/or transfer does not violate applicable laws. For more information, please see https://www.bis.doc.gov See also https://www.apache.org/dev/crypto.html and/or seek legal counsel. */ // mountType is the subset of github.com/docker/docker/api/types/mount.Type. type mountType string const ( // mountTypeBind is the type for mounting host dir. mountTypeBind mountType = "bind" ) type ( // containerCreateRequest is the subset of github.com/docker/docker/api/types/container.CreateRequest. containerCreateRequest struct { HostConfig *containerHostConfig `json:"HostConfig,omitempty"` } // containerHostConfig is the subset of github.com/docker/docker/api/types/container.HostConfig. containerHostConfig struct { Binds []string // List of volume bindings for this container. Mounts []mountMount `json:",omitempty"` // Mounts specs used by the container. } // swarmServiceSpec is the subset of github.com/docker/docker/api/types/swarm.ServiceSpec. swarmServiceSpec struct { TaskTemplate swarmTaskSpec `json:",omitempty"` } // swarmTaskSpec is the subset of github.com/docker/docker/api/types/swarm.TaskSpec. swarmTaskSpec struct { ContainerSpec *swarmContainerSpec `json:",omitempty"` } // swarmContainerSpec is the subset of github.com/docker/docker/api/types/swarm.ContainerSpec. swarmContainerSpec struct { Mounts []mountMount `json:",omitempty"` } // mountMount is the subset of github.com/docker/docker/api/types/mount.Mount. mountMount struct { Type mountType `json:",omitempty"` // Source specifies the name of the mount. Depending on mount type, this // may be a volume name or a host path, or even ignored. // Source is not supported for tmpfs (must be an empty value) Source string `json:",omitempty"` Target string `json:",omitempty"` } ) // checkBindMountRestrictions checks if bind mounts in the request are allowed. func checkBindMountRestrictions(allowedBindMounts []string, r *http.Request) error { // Only check if bind mount restrictions are configured if len(allowedBindMounts) == 0 { return nil } if r.Method != http.MethodPost { return nil } // Check different API endpoints that can use bind mounts pathParts := strings.Split(r.URL.Path, "/") switch { case len(pathParts) >= 4 && pathParts[2] == "containers" && pathParts[3] == "create": // Container creation: /vX.xx/containers/create return checkContainer(allowedBindMounts, r) case len(pathParts) >= 5 && pathParts[2] == "containers" && pathParts[4] == "update": // Container update: /vX.xx/containers/{id}/update return checkContainer(allowedBindMounts, r) case len(pathParts) >= 4 && pathParts[2] == "services" && pathParts[3] == "create": // Service creation: /vX.xx/services/create return checkService(allowedBindMounts, r) case len(pathParts) >= 5 && pathParts[2] == "services" && pathParts[4] == "update": // Service update: /vX.xx/services/{id}/update return checkService(allowedBindMounts, r) default: return nil } } // checkContainer checks bind mounts in container creation requests. func checkContainer(allowedBindMounts []string, r *http.Request) error { body, err := readAndRestoreBody(r) if err != nil { return err } var req containerCreateRequest if err := json.Unmarshal(body, &req); err != nil { slog.Debug("failed to parse container request", "error", err) return nil // Don't block if we can't parse. } return checkHostConfigBindMounts(allowedBindMounts, req.HostConfig) } // checkService checks bind mounts in service creation requests. func checkService(allowedBindMounts []string, r *http.Request) error { body, err := readAndRestoreBody(r) if err != nil { return err } var req swarmServiceSpec if err := json.Unmarshal(body, &req); err != nil { slog.Debug("failed to parse service request", "error", err) return nil // Don't block if we can't parse. } if req.TaskTemplate.ContainerSpec == nil { return nil // No container spec, nothing to check. } return checkHostConfigBindMounts( allowedBindMounts, &containerHostConfig{ Mounts: req.TaskTemplate.ContainerSpec.Mounts, }, ) } // checkHostConfigBindMounts checks bind mounts in HostConfig. 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(allowedBindMounts, bind); err != nil { return err } } // Check modern Mounts field for _, mountItem := range hostConfig.Mounts { if mountItem.Type == mountTypeBind { if err := validateBindMountSource(allowedBindMounts, mountItem.Source); err != nil { return err } } } return nil } // validateBindMount validates a bind mount string in the format "source:target:options". 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(allowedBindMounts, parts[0]) } // validateBindMountSource checks if the source directory is allowed. 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 allowedBindMounts { if allowedDir == "/" || source == allowedDir || strings.HasPrefix(source, allowedDir+"/") { return nil } } return fmt.Errorf("bind mount source directory not allowed: %s", source) } // readAndRestoreBody reads the request body and restores it for further processing. func readAndRestoreBody(r *http.Request) ([]byte, error) { body, err := io.ReadAll(r.Body) if err != nil { return nil, fmt.Errorf("failed to read request body: %w", err) } // Restore the body for further processing r.Body = io.NopCloser(bytes.NewBuffer(body)) return body, nil } ================================================ FILE: cmd/socket-proxy/bindmount_test.go ================================================ package main import ( "bytes" "net/http" "runtime" "testing" ) func skipIfNotUnix(t *testing.T) { switch runtime.GOOS { case "linux", "darwin", "freebsd", "openbsd", "netbsd", "dragonfly", "solaris", "aix": // Supported Unix platforms default: t.Skip("skipping test: only runs on Unix-like systems") } } func TestValidateBindMountSource(t *testing.T) { skipIfNotUnix(t) allowedBindMounts := []string{"/home", "/var/log"} tests := []struct { name string source string shouldPass bool }{ {"exact match", "/home", true}, {"subdirectory", "/home/user", true}, {"deep subdirectory", "/home/user/data", true}, {"not allowed", "/etc", false}, {"empty source", "", true}, // empty sources are skipped {"relative path", "home", true}, // relative paths are skipped {"var log exact", "/var/log", true}, {"var log subdir", "/var/log/app", true}, {"similar but different", "/home2", false}, {"prefix but not subdir", "/home2/user", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := validateBindMountSource(allowedBindMounts, tt.source) if tt.shouldPass && err != nil { t.Errorf("expected %s to pass, but got error: %v", tt.source, err) } if !tt.shouldPass && err == nil { t.Errorf("expected %s to fail, but it passed", tt.source) } }) } } func TestIsPathAllowed(t *testing.T) { skipIfNotUnix(t) tests := []struct { name string path string allowedDir string expected bool }{ {"exact match", "/home", "/home", true}, {"subdirectory", "/home/user", "/home", true}, {"deep subdirectory", "/home/user/data", "/home", true}, {"not subdirectory", "/etc", "/home", false}, {"similar prefix", "/home2", "/home", false}, {"parent directory", "/", "/home", false}, {"path traversal with ..", "/home/user/../..", "/home", false}, {"path traversal to allowed", "/home/user/..", "/home", true}, {"path traversal outside", "/home/../etc", "/home", false}, {"complex path traversal", "/home/user/../../etc", "/home", false}, {"path with dots in name", "/home/user.name", "/home", true}, {"path with current dir", "/home/./user", "/home", true}, {"root directory exact match", "/", "/", true}, {"any path should be allowed when root is allowed", "/etc", "/", true}, {"deep path should be allowed when root is allowed", "/var/log/app", "/", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { 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) } }) } } func TestValidateBindMount(t *testing.T) { skipIfNotUnix(t) allowedBindMounts := []string{"/home", "/var/log"} tests := []struct { name string bind string shouldPass bool }{ {"valid bind", "/home/user:/app", true}, {"invalid format", "/home/user", false}, {"not allowed source", "/etc:/app", false}, {"allowed with options", "/home/user:/app:ro", true}, {"var log bind", "/var/log:/logs:ro", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := validateBindMount(allowedBindMounts, tt.bind) if tt.shouldPass && err != nil { t.Errorf("expected %s to pass, but got error: %v", tt.bind, err) } if !tt.shouldPass && err == nil { t.Errorf("expected %s to fail, but it passed", tt.bind) } }) } } func TestCheckBindMountRestrictions(t *testing.T) { skipIfNotUnix(t) allowedBindMounts := []string{"/home"} tests := []struct { name string method string path string body string shouldPass bool }{ { name: "GET request should pass", method: "GET", path: "/v1.40/containers/json", body: "", shouldPass: true, }, { name: "POST to non-container endpoint should pass", method: "POST", path: "/v1.40/images/create", body: "", shouldPass: true, }, { name: "container create with allowed bind", method: "POST", path: "/v1.40/containers/create", body: `{"HostConfig":{"Binds":["/home/user:/app"]}}`, shouldPass: true, }, { name: "container create with disallowed bind", method: "POST", path: "/v1.40/containers/create", body: `{"HostConfig":{"Binds":["/etc:/app"]}}`, shouldPass: false, }, { name: "path traversal attack", method: "POST", path: "/v1.40/containers/create", body: `{"HostConfig":{"Binds":["/home/user/../../etc:/app"]}}`, shouldPass: false, }, { name: "container create with no binds", method: "POST", path: "/v1.40/containers/create", body: `{"HostConfig":{}}`, shouldPass: true, }, { name: "container update with bind mount", method: "POST", path: "/v1.40/containers/abc123/update", body: `{"HostConfig":{"Binds":["/home/user:/app"]}}`, shouldPass: true, }, { name: "service create with bind mount", method: "POST", path: "/v1.40/services/create", body: `{"TaskTemplate":{"ContainerSpec":{"Mounts":[{"Type":"bind","Source":"/etc","Target":"/app"}]}}}`, shouldPass: false, }, { name: "v2 API should work too", method: "POST", path: "/v2.0/containers/create", body: `{"HostConfig":{"Binds":["/etc:/app"]}}`, shouldPass: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req, err := http.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body)) if err != nil { t.Fatalf("failed to create request: %v", err) } err = checkBindMountRestrictions(allowedBindMounts, req) if tt.shouldPass && err != nil { t.Errorf("expected request to pass, but got error: %v", err) } if !tt.shouldPass && err == nil { t.Errorf("expected request to fail, but it passed") } }) } } ================================================ FILE: cmd/socket-proxy/checksocketconnection.go ================================================ package main import ( "errors" "log/slog" "net" "net/http" "time" ) const dialTimeout = 5 // timeout in seconds for the socket connection // checkSocketAvailability tries to connect to the socket and returns an error if it fails. func checkSocketAvailability(socketPath string) error { slog.Debug("checking socket availability", "origin", "checkSocketAvailability") conn, err := net.DialTimeout("unix", socketPath, dialTimeout*time.Second) if err != nil { return err } err = conn.Close() if err != nil { slog.Error("error closing socket", "origin", "checkSocketAvailability", "error", err) } return nil } // startSocketWatchdog starts a watchdog that checks the socket availability every n seconds. func startSocketWatchdog(socketPath string, interval int64, stopOnWatchdog bool, exitChan chan int) { ticker := time.NewTicker(time.Duration(interval) * time.Second) defer ticker.Stop() for range ticker.C { if err := checkSocketAvailability(socketPath); err != nil { slog.Error("socket is unavailable", "origin", "watchdog", "error", err) if stopOnWatchdog { slog.Warn("stopping socket-proxy because of unavailable socket", "origin", "watchdog") exitChan <- 10 } } } } // healthCheckServer starts a http server that listens on localhost:55555/health // and returns 200 if the socket is available, 503 otherwise. func healthCheckServer(socketPath string) { hcMux := http.NewServeMux() hcMux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodHead { w.WriteHeader(http.StatusMethodNotAllowed) return } err := checkSocketAvailability(socketPath) if err != nil { slog.Error("health check failed", "origin", "healthcheck", "error", err) w.WriteHeader(http.StatusServiceUnavailable) return } w.WriteHeader(http.StatusOK) }) hcSrv := &http.Server{ Addr: "127.0.0.1:55555", Handler: hcMux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second, } if err := hcSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { slog.Error("healthcheck http server problem", "origin", "healthcheck", "error", err) } } ================================================ FILE: cmd/socket-proxy/handlehttprequest.go ================================================ package main import ( "errors" "log/slog" "net" "net/http" "regexp" "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) { allowList, ok := determineAllowList(r) if !ok { communicateBlockedRequest(w, r, "forbidden IP", http.StatusForbidden) return } allowed, exists := allowList.AllowedRequests[r.Method] if !exists { // method not in map -> not allowed communicateBlockedRequest(w, r, "method not allowed", http.StatusMethodNotAllowed) return } if !matchURL(allowed, r.URL.Path) { // path does not match regex -> not allowed communicateBlockedRequest(w, r, "path not allowed", http.StatusForbidden) return } // check bind mount restrictions if err := checkBindMountRestrictions(allowList.AllowedBindMounts, r); err != nil { communicateBlockedRequest(w, r, "bind mount restriction: "+err.Error(), http.StatusForbidden) return } // finally, log and proxy the request slog.Debug("allowed request", "method", r.Method, "URL", r.URL, "client", r.RemoteAddr) // #nosec G706 - structured logging (slog) safely encodes values socketProxy.ServeHTTP(w, r) // #nosec G704 - Request target is always the specified socket } func matchURL(allowedURIs []*regexp.Regexp, requestURI string) bool { for _, allowedURI := range allowedURIs { if allowedURI.MatchString(requestURI) { return true } } return false } // 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) // #nosec G706 - structured logging (slog) safely encodes values 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 { return allowList, true } } // Check if client is allowed for the default allowlist: 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) // #nosec G706 - structured logging (slog) safely encodes values } if !allowedIP { return config.AllowList{}, false } } return cfg.AllowLists.Default, true } // 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(clientIPStr string) (bool, error) { // Parse the IP address clientIP := net.ParseIP(clientIPStr) if clientIP == nil { return false, errors.New("invalid IP format") } for _, allowFromItem := range cfg.AllowFrom { // first try to handle as an CIDR _, allowedIPNet, err := net.ParseCIDR(allowFromItem) if err == nil { // AllowFrom is a valid CIDR, so check if IP address is in allowed network if allowedIPNet.Contains(clientIP) { return true, nil } continue } // AllowFrom is not a valid CIDR, so try to resolve it via DNS // We intentionally do not cache the DNS lookups. // In our use case, the resolver should be a local service, and we don't want to cause DNS caching errors. ips, err := net.LookupIP(allowFromItem) if err != nil { slog.Warn("error looking up allowed client hostname", "hostname", allowFromItem, "error", err.Error()) } for _, ip := range ips { // Check if the IP address is one of the resolved IPs if ip.Equal(clientIP) { return true, nil } } } // If we get here, the IP address is not allowed return false, nil } // sendHTTPError sends an HTTP error with the given status code. func sendHTTPError(w http.ResponseWriter, status int) { http.Error(w, http.StatusText(status), status) } // communicateBlockedRequest logs a blocked request and sends a HTTP error. func communicateBlockedRequest(w http.ResponseWriter, r *http.Request, reason string, status int) { slog.Warn("blocked request", "reason", reason, "method", r.Method, "URL", r.URL, "client", r.RemoteAddr, "response", status) // #nosec G706 - structured logging (slog) safely encodes values sendHTTPError(w, status) } ================================================ FILE: cmd/socket-proxy/main.go ================================================ package main import ( "context" "errors" "fmt" "log/slog" "net" "net/http" "net/http/httputil" "net/url" "os" "os/signal" "runtime" "strings" "syscall" "time" "github.com/wollomatic/socket-proxy/internal/config" ) const ( programURL = "github.com/wollomatic/socket-proxy" logAddSource = false // set to true to log the source position (file and line) of the log message ) var ( version = "dev" // will be overwritten by build system socketProxy *httputil.ReverseProxy cfg *config.Config ) func main() { var err error cfg, err = config.InitConfig() if err != nil { slog.Error("error initializing config", "error", err) os.Exit(1) } // setup channels for graceful shutdown internalQuit := make(chan int, 1) // send to this channel to invoke graceful shutdown, int is the exit code externalQuit := make(chan os.Signal, 1) // configure listener for SIGINT and SIGTERM signal.Notify(externalQuit, syscall.SIGINT, syscall.SIGTERM) // setup logging logOpts := &slog.HandlerOptions{ AddSource: logAddSource, Level: cfg.LogLevel, } var logger *slog.Logger if cfg.LogJSON { logger = slog.New(slog.NewJSONHandler(os.Stdout, logOpts)) } else { logger = slog.New(slog.NewTextHandler(os.Stdout, logOpts)) } slog.SetDefault(logger) // setup non-default allowlists 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) if cfg.ProxySocketEndpoint == "" { // join the cfg.AllowFrom slice to a string to avoid the brackets in the logging (avoid confusion with IPv6 addresses) allowFromString := strings.Join(cfg.AllowFrom, ",") slog.Info("configuration info", "socketpath", cfg.SocketPath, "listenaddress", cfg.ListenAddress, "loglevel", cfg.LogLevel, "logjson", cfg.LogJSON, "allowfrom", allowFromString, "shutdowngracetime", cfg.ShutdownGraceTime) } else { slog.Info("configuration info", "socketpath", cfg.SocketPath, "proxysocketendpoint", cfg.ProxySocketEndpoint, "proxysocketendpointfilemode", cfg.ProxySocketEndpointFileMode, "loglevel", cfg.LogLevel, "logjson", cfg.LogJSON, "shutdowngracetime", cfg.ShutdownGraceTime) slog.Info("proxysocketendpoint is set, so the TCP listener is deactivated") } if cfg.WatchdogInterval > 0 { slog.Info("watchdog enabled", "interval", cfg.WatchdogInterval, "stoponwatchdog", cfg.StopOnWatchdog) } else { slog.Info("watchdog disabled") } 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 slog.Debug("no proxy container name provided") } cfg.AllowLists.PrintNetworks() // print default request allowlist cfg.AllowLists.PrintDefault(cfg.LogJSON) // check if the socket is available err = checkSocketAvailability(cfg.SocketPath) if err != nil { slog.Error("socket not available", "error", err) os.Exit(2) } // define the reverse proxy socketURLDummy, _ := url.Parse("http://localhost") // dummy URL - we use the unix socket socketProxy = httputil.NewSingleHostReverseProxy(socketURLDummy) socketProxy.Transport = &http.Transport{ DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { return net.Dial("unix", cfg.SocketPath) }, } var l net.Listener if cfg.ProxySocketEndpoint != "" { if _, err = os.Stat(cfg.ProxySocketEndpoint); err == nil { slog.Warn(fmt.Sprintf("%s already exists, removing existing file", cfg.ProxySocketEndpoint)) if err = os.Remove(cfg.ProxySocketEndpoint); err != nil { slog.Error("error removing existing socket file", "error", err) os.Exit(2) } } l, err = net.Listen("unix", cfg.ProxySocketEndpoint) if err != nil { slog.Error("error creating socket", "error", err) os.Exit(2) } if err = os.Chmod(cfg.ProxySocketEndpoint, cfg.ProxySocketEndpointFileMode); err != nil { slog.Error("error setting socket file permissions", "error", err) os.Exit(2) } } else { l, err = net.Listen("tcp", cfg.ListenAddress) if err != nil { slog.Error("error listening on address", "error", err) os.Exit(2) } } srv := &http.Server{ // #nosec G112 -- intentionally do not time out the client Handler: http.HandlerFunc(handleHTTPRequest), // #nosec G112 } // #nosec G112 // start the server in a goroutine go func() { if err := srv.Serve(l); err != nil && !errors.Is(err, http.ErrServerClosed) { slog.Error("http server problem", "error", err) os.Exit(2) } }() slog.Info("socket-proxy running and listening...") // start the watchdog if configured if cfg.WatchdogInterval > 0 { go startSocketWatchdog(cfg.SocketPath, int64(cfg.WatchdogInterval), cfg.StopOnWatchdog, internalQuit) // #nosec G115 - we validated the integer size in config.go slog.Debug("watchdog running") } // start the health check server if configured if cfg.AllowHealthcheck { go healthCheckServer(cfg.SocketPath) slog.Debug("healthcheck ready") } // Wait for stop signal exitCode := 0 select { case <-externalQuit: slog.Info("received stop signal - shutting down") case value := <-internalQuit: slog.Info("received internal shutdown - shutting down") exitCode = value } // Try to shut down gracefully ctx, cancel := context.WithTimeout(context.Background(), time.Duration(int64(cfg.ShutdownGraceTime))*time.Second) // #nosec G115 - we validated the integer size in config.go defer cancel() if err := srv.Shutdown(ctx); err != nil { slog.Warn("timeout stopping server", "error", err) } slog.Info("shutdown finished - exiting", "exit code", exitCode) os.Exit(exitCode) } ================================================ FILE: cosign.pub ================================================ -----BEGIN PUBLIC KEY----- MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEYdXlfRbkO6KqPU7Khn1mSjbOIaD3 um421A0NeT1wi840iWNp6MVKyj3tpnAyaQcLgd5/22O+eEHY+5+EHwB+eA== -----END PUBLIC KEY----- ================================================ FILE: examples/docker-compose/dozzle/compose.yaml ================================================ services: dockerproxy: image: wollomatic/socket-proxy:1 command: - '-loglevel=info' - '-allowfrom=dozzle' # allow only the small subnet "docker-proxynet" - '-listenip=0.0.0.0' - '-allowGET=/v1\..{2}/(containers/.*|events)|/_ping' - '-allowHEAD=/_ping' - '-watchdoginterval=300' - '-stoponwatchdog' - '-shutdowngracetime=10' restart: unless-stopped read_only: true mem_limit: 64M cap_drop: - ALL security_opt: - no-new-privileges user: 65534:998 # change gid from 998 to the gid of the docker group on your host volumes: - /var/run/docker.sock:/var/run/docker.sock:ro networks: - docker-proxynet dozzle: image: amir20/dozzle:v10.0 # make sure you use the most recent version user: 65534:65534 read_only: true mem_limit: 256M cap_drop: - ALL security_opt: - no-new-privileges depends_on: - dockerproxy environment: DOZZLE_REMOTE_HOST: tcp://dockerproxy:2375 # # add additional configuration here # # for example labels for traefik if needed # or expose the port to the host network: # ports: # - 127.0.0.1:8080:8080 # bind only to the host network networks: - docker-proxynet - dozzle networks: docker-proxynet: internal: true attachable: false dozzle: driver: bridge attachable: false ================================================ FILE: examples/docker-compose/watchtower/compose.yaml ================================================ services: dockerproxy: image: wollomatic/socket-proxy:1 command: - '-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/.*)' - '-allowHEAD=/_ping' # check socket connection every hour and stop the proxy if it fails (will then be restarted by docker): - '-watchdoginterval=3600' - '-stoponwatchdog' restart: unless-stopped read_only: true mem_limit: 64M cap_drop: - ALL security_opt: - no-new-privileges user: 65534:998 # change gid from 998 to the gid of the docker group on your host volumes: - /var/run/docker.sock:/var/run/docker.sock:ro labels: - com.centurylinklabs.watchtower.enable=false # if watchtower would try to update the proxy, it would just stop networks: - docker-proxynet watchtower: # image: containrrr/watchtower:1.7.1 # https://github.com/containrrr/watchtower was archived on December 17, 2025. # https://github.com/nicholas-fedor/watchtower is a maintained fork. image: ghcr.io/nicholas-fedor/watchtower:1.14.2 # the containrrr repo is no longer maintained depends_on: - dockerproxy command: - '--host=tcp://dockerproxy:2375' - '--schedule=0 30 4 * * *' - '--debug' - '--stop-timeout=5m' - '--cleanup' user: 65534:65534 read_only: true mem_limit: 256M cap_drop: - ALL security_opt: - no-new-privileges networks: - docker-proxynet - watchtower networks: docker-proxynet: internal: true attachable: false watchtower: driver: bridge attachable: false ================================================ FILE: go.mod ================================================ module github.com/wollomatic/socket-proxy go 1.26.0 ================================================ FILE: internal/config/config.go ================================================ package config import ( "context" "errors" "flag" "fmt" "log/slog" "math" "net" "net/http" "os" "path/filepath" "regexp" "slices" "strconv" "strings" "sync" "time" "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." const ( defaultAllowFrom = "127.0.0.1/32" // allowed IPs to connect to the proxy defaultAllowHealthcheck = false // allow health check requests (HEAD http://localhost:55555/health) defaultLogJSON = false // if true, log in JSON format defaultLogLevel = "INFO" // log level as string defaultListenIP = "127.0.0.1" // ip address to bind the server to defaultProxyPort = uint(2375) // tcp port to listen on defaultSocketPath = "/var/run/docker.sock" // path to the unix socket defaultShutdownGraceTime = uint(10) // Maximum time in seconds to wait for the server to shut down gracefully defaultWatchdogInterval = uint(0) // watchdog interval in seconds (0 to disable) defaultStopOnWatchdog = false // set to true to stop the program when the socket gets unavailable (otherwise log only) 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 Config struct { AllowLists *AllowListRegistry AllowFrom []string AllowHealthcheck bool LogJSON bool StopOnWatchdog bool ShutdownGraceTime uint WatchdogInterval uint LogLevel slog.Level ListenAddress string SocketPath string ProxySocketEndpoint string ProxySocketEndpointFileMode os.FileMode ProxyContainerName string } 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 } 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 regexStrings arrayParams } var supportedHTTPMethods = []string{ http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodConnect, http.MethodTrace, http.MethodOptions, } // InitConfig reads configuration from environment variables and command-line // flags, validates the resulting values, and returns the initialized Config. func InitConfig() (*Config, error) { var ( cfg Config allowFromString string listenIP string proxyPort uint logLevel string endpointFileMode uint allowBindMountFromString string defaultAllowFromValue = defaultAllowFrom defaultAllowHealthcheckValue = defaultAllowHealthcheck defaultLogJSONValue = defaultLogJSON defaultListenIPValue = defaultListenIP defaultLogLevelValue = defaultLogLevel defaultProxyPortValue = defaultProxyPort defaultShutdownGraceTimeValue = defaultShutdownGraceTime defaultSocketPathValue = defaultSocketPath defaultStopOnWatchdogValue = defaultStopOnWatchdog defaultWatchdogIntervalValue = defaultWatchdogInterval defaultProxySocketEndpointValue = defaultProxySocketEndpoint defaultProxySocketEndpointFileModeValue = defaultProxySocketEndpointFileMode defaultAllowBindMountFromValue = defaultAllowBindMountFrom defaultProxyContainerNameValue = defaultProxyContainerName ) if val, ok := os.LookupEnv("SP_ALLOWFROM"); ok && val != "" { defaultAllowFromValue = val } if val, ok := os.LookupEnv("SP_ALLOWHEALTHCHECK"); ok { if parsedVal, err := strconv.ParseBool(val); err == nil { defaultAllowHealthcheckValue = parsedVal } } if val, ok := os.LookupEnv("SP_LOGJSON"); ok { if parsedVal, err := strconv.ParseBool(val); err == nil { defaultLogJSONValue = parsedVal } } if val, ok := os.LookupEnv("SP_LISTENIP"); ok && val != "" { defaultListenIPValue = val } if val, ok := os.LookupEnv("SP_LOGLEVEL"); ok && val != "" { defaultLogLevelValue = val } if val, ok := os.LookupEnv("SP_PROXYPORT"); ok && val != "" { if parsedVal, err := strconv.ParseUint(val, 10, 32); err == nil { defaultProxyPortValue = uint(parsedVal) } } if val, ok := os.LookupEnv("SP_SHUTDOWNGRACETIME"); ok && val != "" { if parsedVal, err := strconv.ParseUint(val, 10, 32); err == nil { defaultShutdownGraceTimeValue = uint(parsedVal) } } if val, ok := os.LookupEnv("SP_SOCKETPATH"); ok && val != "" { defaultSocketPathValue = val } if val, ok := os.LookupEnv("SP_STOPONWATCHDOG"); ok { if parsedVal, err := strconv.ParseBool(val); err == nil { defaultStopOnWatchdogValue = parsedVal } } if val, ok := os.LookupEnv("SP_WATCHDOGINTERVAL"); ok && val != "" { if parsedVal, err := strconv.ParseUint(val, 10, 32); err == nil { defaultWatchdogIntervalValue = uint(parsedVal) } } if val, ok := os.LookupEnv("SP_PROXYSOCKETENDPOINT"); ok && val != "" { defaultProxySocketEndpointValue = val } if val, ok := os.LookupEnv("SP_PROXYSOCKETENDPOINTFILEMODE"); ok { if parsedVal, err := strconv.ParseUint(val, 8, 32); err == nil { defaultProxySocketEndpointFileModeValue = uint(parsedVal) } } if val, ok := os.LookupEnv("SP_ALLOWBINDMOUNTFROM"); ok && val != "" { defaultAllowBindMountFromValue = val } if val, ok := os.LookupEnv("SP_PROXYCONTAINERNAME"); ok && val != "" { defaultProxyContainerNameValue = val } methodAllowLists := newMethodRegexes() // multiple values per method // like SP_ALLOW_GET_0, SP_ALLOW_GET_1, ... allowFromEnv := getAllowFromEnv(os.Environ()) for i := range methodAllowLists { if val, ok := allowFromEnv[methodAllowLists[i].method]; ok && len(val) > 0 { for _, v := range val { methodAllowLists[i].regexStrings = append(methodAllowLists[i].regexStrings, param{value: v, from: fromEnv}) } } } flag.StringVar(&allowFromString, "allowfrom", defaultAllowFromValue, "allowed IPs or hostname to connect to the proxy") flag.BoolVar(&cfg.AllowHealthcheck, "allowhealthcheck", defaultAllowHealthcheckValue, "allow health check requests (HEAD http://localhost:55555/health)") flag.BoolVar(&cfg.LogJSON, "logjson", defaultLogJSONValue, "log in JSON format (otherwise log in plain text") flag.StringVar(&listenIP, "listenip", defaultListenIPValue, "ip address to listen on") flag.StringVar(&logLevel, "loglevel", defaultLogLevelValue, "set log level: DEBUG, INFO, WARN, ERROR") flag.UintVar(&proxyPort, "proxyport", defaultProxyPortValue, "tcp port to listen on") flag.UintVar(&cfg.ShutdownGraceTime, "shutdowngracetime", defaultShutdownGraceTimeValue, "maximum time in seconds to wait for the server to shut down gracefully") flag.StringVar(&cfg.SocketPath, "socketpath", defaultSocketPathValue, "unix socket path to connect to") flag.BoolVar(&cfg.StopOnWatchdog, "stoponwatchdog", defaultStopOnWatchdogValue, "stop the program when the socket gets unavailable (otherwise log only)") flag.UintVar(&cfg.WatchdogInterval, "watchdoginterval", defaultWatchdogIntervalValue, "watchdog interval in seconds (0 to disable)") flag.StringVar(&cfg.ProxySocketEndpoint, "proxysocketendpoint", defaultProxySocketEndpointValue, "unix socket endpoint (if set, used instead of the TCP listener)") flag.UintVar(&endpointFileMode, "proxysocketendpointfilemode", defaultProxySocketEndpointFileModeValue, "set the file mode of the unix socket endpoint") flag.StringVar(&allowBindMountFromString, "allowbindmountfrom", defaultAllowBindMountFromValue, "allowed directories for bind mounts (comma-separated)") flag.StringVar(&cfg.ProxyContainerName, "proxycontainername", defaultProxyContainerNameValue, "socket-proxy Docker container name") for i := range methodAllowLists { flag.Var(&methodAllowLists[i].regexStrings, "allow"+methodAllowLists[i].method, "regex for "+methodAllowLists[i].method+" requests (not set means method is not allowed)") } 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 default allowlist AllowedBindMounts slice and validate if allowBindMountFromString != "" { allowedBindMounts, err := parseAllowedBindMounts(allowBindMountFromString) if err != nil { return nil, err } cfg.AllowLists.Default.AllowedBindMounts = allowedBindMounts } // check listenIP and proxyPort if proxyPort < 1 || proxyPort > 65535 { return nil, errors.New("port number has to be between 1 and 65535") } if cfg.ShutdownGraceTime > math.MaxInt { return nil, fmt.Errorf("shutdowngracetime has to be smaller than %d", math.MaxInt) // this maximum value has no practical significance } if cfg.WatchdogInterval > math.MaxInt { return nil, fmt.Errorf("watchdoginterval has to be smaller than %d", math.MaxInt) // this maximum value has no practical significance } ip := net.ParseIP(listenIP) if ip == nil { 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) } else { cfg.ListenAddress = fmt.Sprintf("%s:%d", listenIP, proxyPort) } // parse defaultLogLevel and setup logging handler depending on defaultLogJSON switch strings.ToUpper(logLevel) { case "DEBUG": cfg.LogLevel = slog.LevelDebug case "INFO": cfg.LogLevel = slog.LevelInfo case "WARN": cfg.LogLevel = slog.LevelWarn case "ERROR": cfg.LogLevel = slog.LevelError default: return nil, errors.New("invalid log level " + logLevel + ": Supported levels are DEBUG, INFO, WARN, ERROR") } if endpointFileMode > 0o777 { return nil, errors.New("file mode has to be between 0 and 0o777") } cfg.ProxySocketEndpointFileMode = os.FileMode(uint32(endpointFileMode)) // compile regexes for default allowed requests cfg.AllowLists.Default.AllowedRequests = make(map[string][]*regexp.Regexp) for _, rx := range methodAllowLists { for _, regexString := range effectiveMethodParams(rx.regexStrings) { if regexString.value != "" { location := "" switch regexString.from { case fromEnv: location = "env variable" case fromParam: location = "command line parameter" } r, err := compileRegexp(regexString.value, rx.method, location) if err != nil { return nil, err } cfg.AllowLists.Default.AllowedRequests[rx.method] = append(cfg.AllowLists.Default.AllowedRequests[rx.method], r) } } } // populate list of socket proxy networks if applicable if cfg.ProxySocketEndpoint == "" && cfg.ProxyContainerName != "" { var err error cfg.AllowLists.networks, err = listSocketProxyNetworks(cfg.SocketPath, cfg.ProxyContainerName) if err != nil { return nil, err } } return &cfg, nil } // 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.WithHost("unix://"+cfg.SocketPath), client.WithAPIVersionNegotiation(), ) if err != nil { slog.Error("failed to create Docker client", "error", err) return } defer func(dockerClient *client.Client) { err := dockerClient.Close() if err != nil { slog.Error("failed to close Docker client", "error", err) } }(dockerClient) err = cfg.AllowLists.initByIP(ctx, dockerClient) if err != nil { slog.Error("failed to initialise non-default allowlists", "error", err) return } slog.Debug("initialised non-default allowlists") 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}) 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: if !ok { slog.Info("Docker event stream closed") return } 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) continue } for _, ip := range addedIPs { cfg.AllowLists.mutex.RLock() allowList, found := cfg.AllowLists.byIP[ip] cfg.AllowLists.mutex.RUnlock() if found { allowList.Print(ip, cfg.LogJSON) } } for _, ip := range removedIPs { slog.Info("removed allowlist for container", "id", event.Actor.ID[:12], "ip", ip) } case err := <-errChan: if err != nil { slog.Error("received error from Docker event stream", "error", err) return } } } } // PrintNetworks prints 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") } } // PrintDefault prints the default allowlist func (allowLists *AllowListRegistry) PrintDefault(logJSON bool) { allowLists.Default.Print("", logJSON) } // PrintByIP prints 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) } } // 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() allowList, found := allowLists.byIP[ip] return allowList, found } // 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 _, cntr := range containers { allowedRequests, allowedBindMounts, err := extractLabelData(cntr) if err != nil { allowLists.byIP = nil return err } 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, AllowedBindMounts: allowedBindMounts, } if len(cntrNetwork.IPAddress) > 0 { allowLists.byIP[cntrNetwork.IPAddress] = allowList } if len(cntrNetwork.GlobalIPv6Address) > 0 { allowLists.byIP[cntrNetwork.GlobalIPv6Address] = allowList } } } } } return nil } // update the allowlist registry based on the Docker event func (allowLists *AllowListRegistry) updateFromEvent( 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 "start", "restart": addedIPs, err = allowLists.add(ctx, dockerClient, containerID) if err != nil { return nil, nil, err } case "die": removedIPs = allowLists.remove(containerID) } 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( ctx context.Context, dockerClient *client.Client, containerID string, ) ([]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.Debug("container is not in a network with socket-proxy or may have stopped", "id", containerID[:12]) return nil, nil } cntr := containers[0] allowedRequests, allowedBindMounts, err := extractLabelData(cntr) if err != nil { return nil, err } var ips []string if len(allowedRequests) > 0 || len(allowedBindMounts) > 0 { allowList := AllowList{ ID: cntr.ID, AllowedRequests: allowedRequests, 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 ips = append(ips, ipv4Address) } ipv6Address := cntrNetwork.GlobalIPv6Address if len(ipv6Address) > 0 { allowLists.byIP[ipv6Address] = allowList ips = append(ips, ipv6Address) } } } } return ips, nil } // remove allowlists having the given container ID from the allowlist registry 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 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 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", "id", allowList.ID[:12], "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 (%s):\n %-8s %s\n", allowList.ID[:12], ip, "Method", "Regex") } for method, regex := range allowList.AllowedRequests { 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 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 } // newMethodRegexes returns one methodRegex entry for each supported HTTP method. func newMethodRegexes() []methodRegex { methods := make([]methodRegex, 0, len(supportedHTTPMethods)) for _, method := range supportedHTTPMethods { methods = append(methods, methodRegex{method: method}) } return methods } // effectiveMethodParams returns the parameters that should be applied for one // HTTP method, preferring command-line values over environment values when both // are present. func effectiveMethodParams(params arrayParams) []param { if slices.ContainsFunc(params, func(p param) bool { return p.from == fromParam }) { return slices.DeleteFunc(slices.Clone(params), func(p param) bool { return p.from == fromEnv }) } return params } // 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(socketPath, proxyContainerName string) ([]string, error) { cntr, err := getSocketProxyContainerSummary(socketPath, proxyContainerName) if err != nil { return nil, err } networks := make([]string, 0, len(cntr.NetworkSettings.Networks)) for networkID := range cntr.NetworkSettings.Networks { networks = append(networks, networkID) } return networks, nil } // return Docker container summary for the socket proxy container func getSocketProxyContainerSummary(socketPath, proxyContainerName string) (container.Summary, error) { const maxTries = 3 dockerClient, err := client.NewClientWithOpts( client.WithHost("unix://"+socketPath), client.WithAPIVersionNegotiation(), ) if err != nil { return container.Summary{}, err } defer func(dockerClient *client.Client) { err := dockerClient.Close() if err != nil { slog.Error("failed to close Docker client", "error", err) } }(dockerClient) 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 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 func extractLabelData(cntr container.Summary) (map[string][]*regexp.Regexp, []string, error) { allowedRequests := make(map[string][]*regexp.Regexp) var allowedBindMounts []string for labelName, labelValue := range cntr.Labels { if strings.HasPrefix(labelName, allowedDockerLabelPrefix) && labelValue != "" { allowSpec := strings.ToUpper(strings.TrimPrefix(labelName, allowedDockerLabelPrefix)) method, _, _ := strings.Cut(allowSpec, ".") if slices.Contains(supportedHTTPMethods, method) { r, err := compileRegexp(labelValue, method, "docker container label") if err != nil { return nil, nil, err } allowedRequests[method] = append(allowedRequests[method], r) } else if allowSpec == "BINDMOUNTFROM" { var err error allowedBindMounts, err = parseAllowedBindMounts(labelValue) if err != nil { return nil, nil, err } } } } return allowedRequests, allowedBindMounts, nil } ================================================ FILE: internal/config/config_test.go ================================================ package config import ( "flag" "math" "os" "reflect" "regexp" "sort" "strconv" "testing" "github.com/wollomatic/socket-proxy/internal/docker/api/types/container" ) func resetFlagsForTest(t *testing.T, args []string) func() { t.Helper() prevCommandLine := flag.CommandLine prevArgs := os.Args flag.CommandLine = flag.NewFlagSet(args[0], flag.ContinueOnError) flag.CommandLine.SetOutput(os.Stderr) os.Args = args return func() { flag.CommandLine = prevCommandLine os.Args = prevArgs } } func Test_extractLabelData(t *testing.T) { tests := []struct { name string // description of this test case // Named input parameters for target function. cntr container.Summary want map[string][]*regexp.Regexp want2 []string wantErr bool }{ { name: "valid labels with multiple methods and regexes", cntr: container.Summary{ Labels: map[string]string{ "socket-proxy.allow.get.0": "regex1", "socket-proxy.allow.get.1": "regex2", "socket-proxy.allow.post": "regex3", }, }, want: map[string][]*regexp.Regexp{ "GET": {regexp.MustCompile("^regex1$"), regexp.MustCompile("^regex2$")}, "POST": {regexp.MustCompile("^regex3$")}, }, want2: nil, wantErr: false, }, { name: "invalid regex in label value", cntr: container.Summary{ Labels: map[string]string{ "socket-proxy.allow.get": "invalid[regex", }, }, want: nil, want2: nil, wantErr: true, }, { name: "non-allow labels are ignored", cntr: container.Summary{ Labels: map[string]string{ "socket-proxy.allow.get": "regex1", "other.label": "value", }, }, want: map[string][]*regexp.Regexp{ "GET": {regexp.MustCompile("^regex1$")}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, got2, gotErr := extractLabelData(tt.cntr) if gotErr != nil { if !tt.wantErr { t.Errorf("extractLabelData() failed: %v", gotErr) } return } if tt.wantErr { t.Fatal("extractLabelData() succeeded unexpectedly") } if !regexMapsEqual(got, tt.want) { t.Errorf("extractLabelData() = %v, want %v", got, tt.want) } if !reflect.DeepEqual(got2, tt.want2) { t.Errorf("extractLabelData() = %v, want %v", got2, tt.want2) } }) } } func regexMapsEqual(a, b map[string][]*regexp.Regexp) bool { if len(a) != len(b) { return false } for method, aRegexes := range a { bRegexes, ok := b[method] if !ok || len(aRegexes) != len(bRegexes) { return false } aRegexStrings := make([]string, 0, len(aRegexes)) for _, ar := range aRegexes { aRegexStrings = append(aRegexStrings, ar.String()) } bRegexStrings := make([]string, 0, len(bRegexes)) for _, br := range bRegexes { bRegexStrings = append(bRegexStrings, br.String()) } sort.Strings(aRegexStrings) sort.Strings(bRegexStrings) for i, ar := range aRegexStrings { if ar != bRegexStrings[i] { return false } } } return true } func TestInitConfig_AllowMethodFlagOverridesEnv(t *testing.T) { t.Setenv("SP_ALLOW_GET", "/from-env") restore := resetFlagsForTest(t, []string{"socket-proxy", "-allowGET=/from-flag"}) defer restore() cfg, err := InitConfig() if err != nil { t.Fatalf("InitConfig() error = %v", err) } regexes := cfg.AllowLists.Default.AllowedRequests["GET"] if len(regexes) != 1 { t.Fatalf("expected 1 GET regex, got %d", len(regexes)) } if !regexes[0].MatchString("/from-flag") { t.Fatalf("expected GET regex to match /from-flag, got %q", regexes[0].String()) } if regexes[0].MatchString("/from-env") { t.Fatalf("expected env GET regex to be ignored when flag is present, got %q", regexes[0].String()) } } func TestInitConfig_ShutdownGraceTimeTooLarge(t *testing.T) { restore := resetFlagsForTest(t, []string{ "socket-proxy", "-shutdowngracetime=" + strconv.FormatUint(uint64(math.MaxInt)+1, 10), }) defer restore() _, err := InitConfig() if err == nil { t.Fatal("InitConfig() unexpectedly succeeded") } } func TestInitConfig_WatchdogIntervalTooLarge(t *testing.T) { restore := resetFlagsForTest(t, []string{ "socket-proxy", "-watchdoginterval=" + strconv.FormatUint(uint64(math.MaxInt)+1, 10), }) defer restore() _, err := InitConfig() if err == nil { t.Fatal("InitConfig() unexpectedly succeeded") } } ================================================ FILE: internal/config/env.go ================================================ package config import ( "strings" ) const spAllowPrefix = "SP_ALLOW_" // getAllowFromEnv reads allowlist regex strings from environment variables. // // Environment variables should be of the form // like SP_ALLOW_GET, SP_ALLOW_GET_0, SP_ALLOW_GET_1, SP_ALLOW_POST // returning a map of method to list of regex strings. // like: {"GET":[], "POST":[]} func getAllowFromEnv(env []string) map[string][]string { result := make(map[string][]string) for _, v := range env { if v, ok := strings.CutPrefix(v, spAllowPrefix); ok { key, value, found := strings.Cut(v, "=") if found { // optional number suffix after method method, _, _ := strings.Cut(key, "_") result[method] = append(result[method], value) } } } return result } ================================================ FILE: internal/config/env_test.go ================================================ package config import ( "reflect" "testing" ) func Test_getAllowFromEnv(t *testing.T) { tests := []struct { name string // description of this test case // Named input parameters for target function. env []string want map[string][]string }{ { name: "single method", env: []string{"SP_ALLOW_GET=/allowed/path"}, want: map[string][]string{"GET": {"/allowed/path"}}, }, { name: "multiple methods", env: []string{"SP_ALLOW_GET=/get/path", "SP_ALLOW_POST=/post/path"}, want: map[string][]string{"GET": {"/get/path"}, "POST": {"/post/path"}}, }, { name: "multiple entries for one method", env: []string{"SP_ALLOW_GET=/path/one", "SP_ALLOW_GET_1=/path/two"}, want: map[string][]string{"GET": {"/path/one", "/path/two"}}, }, { name: "multiple entries for one method with non-sequential index", env: []string{"SP_ALLOW_GET=/path/one", "SP_ALLOW_GET_2=/path/two"}, want: map[string][]string{"GET": {"/path/one", "/path/two"}}, }, { name: "no relevant env vars", env: []string{"OTHER_ENV=some_value"}, want: map[string][]string{}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := getAllowFromEnv(tt.env) if !reflect.DeepEqual(got, tt.want) { t.Errorf("getAllowFromEnv() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: internal/config/param.go ================================================ package config import ( "flag" "strings" ) type from int const ( fromEnv from = 1 fromParam from = 2 ) type param struct { value string from from } type arrayParams []param // ensure that arrayParams implements the flag.Value interface var _ flag.Value = (*arrayParams)(nil) func (a *arrayParams) String() string { var values []string for _, p := range *a { values = append(values, p.value) } return strings.Join(values, ", ") } func (a *arrayParams) Set(value string) error { *a = append(*a, param{value: value, from: fromParam}) return nil } ================================================ FILE: internal/docker/api/common.go ================================================ 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" ) ================================================ FILE: internal/docker/api/types/container/container.go ================================================ 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 } ================================================ FILE: internal/docker/api/types/container/network_settings.go ================================================ 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 } ================================================ FILE: internal/docker/api/types/container/options.go ================================================ 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 } ================================================ FILE: internal/docker/api/types/error_response.go ================================================ 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"` } ================================================ FILE: internal/docker/api/types/events/events.go ================================================ 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 // 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 from 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 } ================================================ FILE: internal/docker/api/types/filters/errors.go ================================================ 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() {} ================================================ FILE: internal/docker/api/types/filters/parse.go ================================================ /* 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 } // 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 } // 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 } ================================================ FILE: internal/docker/api/types/network/endpoint.go ================================================ 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 { return new(*es) } ================================================ FILE: internal/docker/api/types/types.go ================================================ 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 } ================================================ FILE: internal/docker/api/types/versions/compare.go ================================================ 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 } ================================================ FILE: internal/docker/client/client.go ================================================ /* 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 } ================================================ FILE: internal/docker/client/container_list.go ================================================ 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 } ================================================ FILE: internal/docker/client/errors.go ================================================ 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} } ================================================ FILE: internal/docker/client/events.go ================================================ 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 } ================================================ FILE: internal/docker/client/options.go ================================================ 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 } } ================================================ FILE: internal/docker/client/ping.go ================================================ 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) } ================================================ FILE: internal/docker/client/request.go ================================================ 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) // #nosec G704 - Request target is always the specified socket 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 } if uErr, ok := errors.AsType[*url.Error](err); ok { if nErr, ok2 := errors.AsType[*net.OpError](uErr.Err); ok2 { 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)} } } } if nErr, ok := errors.AsType[net.Error](err); ok { 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() } } ================================================ FILE: internal/go-connections/sockets/sockets.go ================================================ /* 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" "fmt" "net" "net/http" "syscall" "time" ) const ( defaultTimeout = 10 * time.Second maxUnixSocketPathSize = len(syscall.RawSockaddrUnix{}.Path) ) // 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 }