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:<<version>> # choose most recent image
restart: unless-stopped
user: "65534:<<your docker group id>>"
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: <security2025(at)wollomatic.de>
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
}
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
SYMBOL INDEX (209 symbols across 30 files)
FILE: cmd/healthcheck/main.go
function main (line 12) | func main() {
FILE: cmd/socket-proxy/bindmount.go
type mountType (line 41) | type mountType
constant mountTypeBind (line 45) | mountTypeBind mountType = "bind"
type containerCreateRequest (line 50) | type containerCreateRequest struct
type containerHostConfig (line 54) | type containerHostConfig struct
type swarmServiceSpec (line 59) | type swarmServiceSpec struct
type swarmTaskSpec (line 63) | type swarmTaskSpec struct
type swarmContainerSpec (line 67) | type swarmContainerSpec struct
type mountMount (line 71) | type mountMount struct
function checkBindMountRestrictions (line 82) | func checkBindMountRestrictions(allowedBindMounts []string, r *http.Requ...
function checkContainer (line 113) | func checkContainer(allowedBindMounts []string, r *http.Request) error {
function checkService (line 129) | func checkService(allowedBindMounts []string, r *http.Request) error {
function checkHostConfigBindMounts (line 153) | func checkHostConfigBindMounts(allowedBindMounts []string, hostConfig *c...
function validateBindMount (line 178) | func validateBindMount(allowedBindMounts []string, bind string) error {
function validateBindMountSource (line 187) | func validateBindMountSource(allowedBindMounts []string, source string) ...
function readAndRestoreBody (line 204) | func readAndRestoreBody(r *http.Request) ([]byte, error) {
FILE: cmd/socket-proxy/bindmount_test.go
function skipIfNotUnix (line 10) | func skipIfNotUnix(t *testing.T) {
function TestValidateBindMountSource (line 19) | func TestValidateBindMountSource(t *testing.T) {
function TestIsPathAllowed (line 54) | func TestIsPathAllowed(t *testing.T) {
function TestValidateBindMount (line 90) | func TestValidateBindMount(t *testing.T) {
function TestCheckBindMountRestrictions (line 120) | func TestCheckBindMountRestrictions(t *testing.T) {
FILE: cmd/socket-proxy/checksocketconnection.go
constant dialTimeout (line 11) | dialTimeout = 5
function checkSocketAvailability (line 14) | func checkSocketAvailability(socketPath string) error {
function startSocketWatchdog (line 28) | func startSocketWatchdog(socketPath string, interval int64, stopOnWatchd...
function healthCheckServer (line 45) | func healthCheckServer(socketPath string) {
FILE: cmd/socket-proxy/handlehttprequest.go
function handleHTTPRequest (line 16) | func handleHTTPRequest(w http.ResponseWriter, r *http.Request) {
function matchURL (line 44) | func matchURL(allowedURIs []*regexp.Regexp, requestURI string) bool {
function determineAllowList (line 54) | func determineAllowList(r *http.Request) (config.AllowList, bool) {
function isAllowedClient (line 86) | func isAllowedClient(clientIPStr string) (bool, error) {
function sendHTTPError (line 125) | func sendHTTPError(w http.ResponseWriter, status int) {
function communicateBlockedRequest (line 130) | func communicateBlockedRequest(w http.ResponseWriter, r *http.Request, r...
FILE: cmd/socket-proxy/main.go
constant programURL (line 23) | programURL = "github.com/wollomatic/socket-proxy"
constant logAddSource (line 24) | logAddSource = false
function main (line 33) | func main() {
FILE: internal/config/config.go
constant allowedDockerLabelPrefix (line 27) | allowedDockerLabelPrefix = "socket-proxy.allow."
constant defaultAllowFrom (line 30) | defaultAllowFrom = "127.0.0.1/32"
constant defaultAllowHealthcheck (line 31) | defaultAllowHealthcheck = false
constant defaultLogJSON (line 32) | defaultLogJSON = false
constant defaultLogLevel (line 33) | defaultLogLevel = "INFO"
constant defaultListenIP (line 34) | defaultListenIP = "127.0.0.1"
constant defaultProxyPort (line 35) | defaultProxyPort = uint(2375)
constant defaultSocketPath (line 36) | defaultSocketPath = "/var/run/docker.sock"
constant defaultShutdownGraceTime (line 37) | defaultShutdownGraceTime = uint(10)
constant defaultWatchdogInterval (line 38) | defaultWatchdogInterval = uint(0)
constant defaultStopOnWatchdog (line 39) | defaultStopOnWatchdog = false
constant defaultProxySocketEndpoint (line 40) | defaultProxySocketEndpoint = ""
constant defaultProxySocketEndpointFileMode (line 41) | defaultProxySocketEndpointFileMode = uint(0o600)
constant defaultAllowBindMountFrom (line 42) | defaultAllowBindMountFrom = ""
constant defaultProxyContainerName (line 43) | defaultProxyContainerName = ""
type Config (line 46) | type Config struct
method UpdateAllowLists (line 299) | func (cfg *Config) UpdateAllowLists() {
type AllowListRegistry (line 62) | type AllowListRegistry struct
method PrintNetworks (line 371) | func (allowLists *AllowListRegistry) PrintNetworks() {
method PrintDefault (line 381) | func (allowLists *AllowListRegistry) PrintDefault(logJSON bool) {
method PrintByIP (line 386) | func (allowLists *AllowListRegistry) PrintByIP(logJSON bool) {
method FindByIP (line 395) | func (allowLists *AllowListRegistry) FindByIP(ip string) (AllowList, b...
method initByIP (line 403) | func (allowLists *AllowListRegistry) initByIP(ctx context.Context, doc...
method updateFromEvent (line 449) | func (allowLists *AllowListRegistry) updateFromEvent(
method add (line 473) | func (allowLists *AllowListRegistry) add(
method remove (line 527) | func (allowLists *AllowListRegistry) remove(containerID string) []stri...
type AllowList (line 69) | type AllowList struct
method Print (line 543) | func (allowList AllowList) Print(ip string, logJSON bool) {
type methodRegex (line 76) | type methodRegex struct
function InitConfig (line 95) | func InitConfig() (*Config, error) {
function compileRegexp (line 596) | func compileRegexp(regex, method, configLocation string) (*regexp.Regexp...
function newMethodRegexes (line 605) | func newMethodRegexes() []methodRegex {
function effectiveMethodParams (line 616) | func effectiveMethodParams(params arrayParams) []param {
function parseAllowedBindMounts (line 624) | func parseAllowedBindMounts(allowBindMountFromString string) ([]string, ...
function listSocketProxyNetworks (line 636) | func listSocketProxyNetworks(socketPath, proxyContainerName string) ([]s...
function getSocketProxyContainerSummary (line 650) | func getSocketProxyContainerSummary(socketPath, proxyContainerName strin...
function extractLabelData (line 687) | func extractLabelData(cntr container.Summary) (map[string][]*regexp.Rege...
FILE: internal/config/config_test.go
function resetFlagsForTest (line 16) | func resetFlagsForTest(t *testing.T, args []string) func() {
function Test_extractLabelData (line 32) | func Test_extractLabelData(t *testing.T) {
function regexMapsEqual (line 103) | func regexMapsEqual(a, b map[string][]*regexp.Regexp) bool {
function TestInitConfig_AllowMethodFlagOverridesEnv (line 131) | func TestInitConfig_AllowMethodFlagOverridesEnv(t *testing.T) {
function TestInitConfig_ShutdownGraceTimeTooLarge (line 153) | func TestInitConfig_ShutdownGraceTimeTooLarge(t *testing.T) {
function TestInitConfig_WatchdogIntervalTooLarge (line 166) | func TestInitConfig_WatchdogIntervalTooLarge(t *testing.T) {
FILE: internal/config/env.go
constant spAllowPrefix (line 7) | spAllowPrefix = "SP_ALLOW_"
function getAllowFromEnv (line 15) | func getAllowFromEnv(env []string) map[string][]string {
FILE: internal/config/env_test.go
function Test_getAllowFromEnv (line 8) | func Test_getAllowFromEnv(t *testing.T) {
FILE: internal/config/param.go
type from (line 8) | type from
constant fromEnv (line 11) | fromEnv from = 1
constant fromParam (line 12) | fromParam from = 2
type param (line 15) | type param struct
type arrayParams (line 20) | type arrayParams
method String (line 25) | func (a *arrayParams) String() string {
method Set (line 33) | func (a *arrayParams) Set(value string) error {
FILE: internal/docker/api/common.go
constant DefaultVersion (line 11) | DefaultVersion = "1.51"
FILE: internal/docker/api/types/container/container.go
type Summary (line 10) | type Summary struct
FILE: internal/docker/api/types/container/network_settings.go
type NetworkSettingsSummary (line 14) | type NetworkSettingsSummary struct
FILE: internal/docker/api/types/container/options.go
type ListOptions (line 11) | type ListOptions struct
FILE: internal/docker/api/types/error_response.go
type ErrorResponse (line 10) | type ErrorResponse struct
FILE: internal/docker/api/types/events/events.go
type Type (line 11) | type Type
type Action (line 14) | type Action
constant ActionCreate (line 17) | ActionCreate Action = "create"
constant ActionStart (line 18) | ActionStart Action = "start"
constant ActionRestart (line 19) | ActionRestart Action = "restart"
constant ActionStop (line 20) | ActionStop Action = "stop"
constant ActionCheckpoint (line 21) | ActionCheckpoint Action = "checkpoint"
constant ActionPause (line 22) | ActionPause Action = "pause"
constant ActionUnPause (line 23) | ActionUnPause Action = "unpause"
constant ActionAttach (line 24) | ActionAttach Action = "attach"
constant ActionDetach (line 25) | ActionDetach Action = "detach"
constant ActionResize (line 26) | ActionResize Action = "resize"
constant ActionUpdate (line 27) | ActionUpdate Action = "update"
constant ActionRename (line 28) | ActionRename Action = "rename"
constant ActionKill (line 29) | ActionKill Action = "kill"
constant ActionDie (line 30) | ActionDie Action = "die"
constant ActionOOM (line 31) | ActionOOM Action = "oom"
constant ActionDestroy (line 32) | ActionDestroy Action = "destroy"
constant ActionRemove (line 33) | ActionRemove Action = "remove"
constant ActionCommit (line 34) | ActionCommit Action = "commit"
constant ActionTop (line 35) | ActionTop Action = "top"
constant ActionCopy (line 36) | ActionCopy Action = "copy"
constant ActionArchivePath (line 37) | ActionArchivePath Action = "archive-path"
constant ActionExtractToDir (line 38) | ActionExtractToDir Action = "extract-to-dir"
constant ActionExport (line 39) | ActionExport Action = "export"
constant ActionImport (line 40) | ActionImport Action = "import"
constant ActionSave (line 41) | ActionSave Action = "save"
constant ActionLoad (line 42) | ActionLoad Action = "load"
constant ActionTag (line 43) | ActionTag Action = "tag"
constant ActionUnTag (line 44) | ActionUnTag Action = "untag"
constant ActionPush (line 45) | ActionPush Action = "push"
constant ActionPull (line 46) | ActionPull Action = "pull"
constant ActionPrune (line 47) | ActionPrune Action = "prune"
constant ActionDelete (line 48) | ActionDelete Action = "delete"
constant ActionEnable (line 49) | ActionEnable Action = "enable"
constant ActionDisable (line 50) | ActionDisable Action = "disable"
constant ActionConnect (line 51) | ActionConnect Action = "connect"
constant ActionDisconnect (line 52) | ActionDisconnect Action = "disconnect"
constant ActionReload (line 53) | ActionReload Action = "reload"
constant ActionMount (line 54) | ActionMount Action = "mount"
constant ActionUnmount (line 55) | ActionUnmount Action = "unmount"
constant ActionExecCreate (line 65) | ActionExecCreate Action = "exec_create"
constant ActionExecStart (line 74) | ActionExecStart Action = "exec_start"
constant ActionExecDie (line 75) | ActionExecDie Action = "exec_die"
constant ActionExecDetach (line 76) | ActionExecDetach Action = "exec_detach"
constant ActionHealthStatus (line 87) | ActionHealthStatus Action = "health_status"
constant ActionHealthStatusRunning (line 88) | ActionHealthStatusRunning Action = "health_status: running"
constant ActionHealthStatusHealthy (line 89) | ActionHealthStatusHealthy Action = "health_status: healthy"
constant ActionHealthStatusUnhealthy (line 90) | ActionHealthStatusUnhealthy Action = "health_status: unhealthy"
type Actor (line 98) | type Actor struct
type Message (line 104) | type Message struct
type ListOptions (line 111) | type ListOptions struct
FILE: internal/docker/api/types/filters/errors.go
type invalidFilter (line 11) | type invalidFilter struct
method Error (line 16) | func (e invalidFilter) Error() string {
method InvalidParameter (line 29) | func (e invalidFilter) InvalidParameter() {}
FILE: internal/docker/api/types/filters/parse.go
type Args (line 17) | type Args struct
method Keys (line 37) | func (args Args) Keys() []string {
method MarshalJSON (line 46) | func (args Args) MarshalJSON() ([]byte, error) {
method UnmarshalJSON (line 63) | func (args Args) UnmarshalJSON(raw []byte) error {
method Get (line 68) | func (args Args) Get(key string) []string {
method Add (line 81) | func (args Args) Add(key, value string) {
method Del (line 90) | func (args Args) Del(key, value string) {
method Len (line 100) | func (args Args) Len() int {
method MatchKVList (line 106) | func (args Args) MatchKVList(key string, sources map[string]string) bo...
method Match (line 134) | func (args Args) Match(field, source string) bool {
method GetBoolOrDefault (line 155) | func (args Args) GetBoolOrDefault(key string, defaultValue bool) (bool...
method ExactMatch (line 175) | func (args Args) ExactMatch(key, source string) bool {
method UniqueExactMatch (line 188) | func (args Args) UniqueExactMatch(key, source string) bool {
method FuzzyMatch (line 204) | func (args Args) FuzzyMatch(key, source string) bool {
method Contains (line 219) | func (args Args) Contains(field string) bool {
method Validate (line 226) | func (args Args) Validate(accepted map[string]bool) error {
method WalkValues (line 238) | func (args Args) WalkValues(field string, op func(value string) error)...
method Clone (line 251) | func (args Args) Clone() (newArgs Args) {
type KeyValuePair (line 22) | type KeyValuePair struct
function NewArgs (line 28) | func NewArgs(initialArgs ...KeyValuePair) Args {
function ToJSON (line 54) | func ToJSON(a Args) (string, error) {
FILE: internal/docker/api/types/network/endpoint.go
type EndpointSettings (line 9) | type EndpointSettings struct
method Copy (line 22) | func (es *EndpointSettings) Copy() *EndpointSettings {
FILE: internal/docker/api/types/types.go
type Ping (line 10) | type Ping struct
FILE: internal/docker/api/types/versions/compare.go
function compare (line 15) | func compare(v1, v2 string) int {
function LessThan (line 48) | func LessThan(v, other string) bool {
FILE: internal/docker/client/client.go
constant DefaultDockerHost (line 31) | DefaultDockerHost = "unix:///var/run/docker.sock"
constant DummyHost (line 34) | DummyHost = "api.moby.localhost"
constant fallbackAPIVersion (line 41) | fallbackAPIVersion = "1.24"
type Client (line 45) | type Client struct
method Close (line 165) | func (cli *Client) Close() error {
method checkVersion (line 177) | func (cli *Client) checkVersion(ctx context.Context) error {
method getAPIPath (line 199) | func (cli *Client) getAPIPath(ctx context.Context, p string, query url...
method negotiateAPIVersionPing (line 212) | func (cli *Client) negotiateAPIVersionPing(pingResponse types.Ping) {
function CheckRedirect (line 103) | func CheckRedirect(_ *http.Request, via []*http.Request) error {
function NewClientWithOpts (line 113) | func NewClientWithOpts(ops ...Opt) (*Client, error) {
function defaultHTTPClient (line 147) | func defaultHTTPClient(hostURL *url.URL) (*http.Client, error) {
function ParseHostURL (line 237) | func ParseHostURL(host string) (*url.URL, error) {
FILE: internal/docker/client/container_list.go
method ContainerList (line 18) | func (cli *Client) ContainerList(ctx context.Context, options container....
FILE: internal/docker/client/errors.go
type errConnectionFailed (line 14) | type errConnectionFailed struct
method Error (line 19) | func (e errConnectionFailed) Error() string {
method Unwrap (line 23) | func (e errConnectionFailed) Unwrap() error {
function IsErrConnectionFailed (line 28) | func IsErrConnectionFailed(err error) bool {
function connectionFailed (line 34) | func connectionFailed(host string) error {
FILE: internal/docker/client/events.go
method Events (line 21) | func (cli *Client) Events(ctx context.Context, options events.ListOption...
function buildEventsQueryParams (line 73) | func buildEventsQueryParams(options events.ListOptions) (url.Values, err...
FILE: internal/docker/client/options.go
type Opt (line 16) | type Opt
function WithHost (line 19) | func WithHost(host string) Opt {
function WithAPIVersionNegotiation (line 40) | func WithAPIVersionNegotiation() Opt {
FILE: internal/docker/client/ping.go
method Ping (line 22) | func (cli *Client) Ping(ctx context.Context) (types.Ping, error) {
function parsePingResponse (line 57) | func parsePingResponse(cli *Client, resp *http.Response) (types.Ping, er...
FILE: internal/docker/client/request.go
method get (line 25) | func (cli *Client) get(ctx context.Context, path string, query url.Value...
method buildRequest (line 29) | func (cli *Client) buildRequest(ctx context.Context, method, path string...
method sendRequest (line 49) | func (cli *Client) sendRequest(ctx context.Context, method, path string,...
method doRequest (line 66) | func (cli *Client) doRequest(req *http.Request) (*http.Response, error) {
method checkResponseErr (line 98) | func (cli *Client) checkResponseErr(serverResp *http.Response) (retErr e...
method addHeaders (line 184) | func (cli *Client) addHeaders(req *http.Request, headers http.Header) *h...
function ensureReaderClosed (line 208) | func ensureReaderClosed(response *http.Response) {
FILE: internal/go-connections/sockets/sockets.go
constant defaultTimeout (line 19) | defaultTimeout = 10 * time.Second
constant maxUnixSocketPathSize (line 20) | maxUnixSocketPathSize = len(syscall.RawSockaddrUnix{}.Path)
function ConfigureTransport (line 30) | func ConfigureTransport(tr *http.Transport, proto, addr string) error {
function configureUnixTransport (line 52) | func configureUnixTransport(tr *http.Transport, addr string) error {
Condensed preview — 46 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (170K chars).
[
{
"path": ".dockerignore",
"chars": 35,
"preview": "*\n\n!cmd\n!internal\n!go.mod\n!LICENSE\n"
},
{
"path": ".github/ISSUE_TEMPLATE/bug_report.md",
"chars": 834,
"preview": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Describe the b"
},
{
"path": ".github/ISSUE_TEMPLATE/feature_request.md",
"chars": 595,
"preview": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Is your fea"
},
{
"path": ".github/dependabot.yml",
"chars": 694,
"preview": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where "
},
{
"path": ".github/workflows/dependency-review.yml",
"chars": 1001,
"preview": "# Dependency Review Action\n#\n# This Action will scan dependency manifest files that change as part of a Pull Request,\n# "
},
{
"path": ".github/workflows/docker-image-release.yaml",
"chars": 3255,
"preview": "name: Build and Publish Release\npermissions:\n contents: read\n packages: write\n\non:\n push:\n tags: ['*']\n\njobs:\n\n "
},
{
"path": ".github/workflows/docker-image-testing.yaml",
"chars": 3711,
"preview": "name: Build and Publish Testing\npermissions:\n contents: read\n packages: write\n\non:\n workflow_dispatch:\n push:\n br"
},
{
"path": ".gitignore",
"chars": 502,
"preview": "# If you prefer the allow list template instead of the deny list, see community template:\n# https://github.com/github/gi"
},
{
"path": "Dockerfile",
"chars": 784,
"preview": "# syntax=docker/dockerfile:1\nFROM --platform=$BUILDPLATFORM golang:1.26.2-alpine3.23@sha256:f85330846cde1e57ca9ec309382d"
},
{
"path": "LICENSE",
"chars": 12178,
"preview": "MIT License\n\nCopyright (c) 2023 Wolfgang Ellsässer (wollomatic)\n\nPermission is hereby granted, free of charge, to any pe"
},
{
"path": "README.md",
"chars": 27263,
"preview": "# socket-proxy\n\n## Latest image\n- `wollomatic/socket-proxy:1.12.0` / `ghcr.io/wollomatic/socket-proxy:1.12.0`\n- `wolloma"
},
{
"path": "SECURITY.md",
"chars": 4624,
"preview": "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA512\n\n# Security Policy\n\n## Supported Versions\n\nAs no breaking changes to exi"
},
{
"path": "cmd/healthcheck/main.go",
"chars": 582,
"preview": "package main\n\nimport (\n\t\"log\"\n\t\"net/http\"\n)\n\n// main does a health check against the socket-proxy server\n// if the healt"
},
{
"path": "cmd/socket-proxy/bindmount.go",
"chars": 6772,
"preview": "package main\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"path/filepath\"\n\t\"strings\"\n)\n\n/*\n"
},
{
"path": "cmd/socket-proxy/bindmount_test.go",
"chars": 6013,
"preview": "package main\n\nimport (\n\t\"bytes\"\n\t\"net/http\"\n\t\"runtime\"\n\t\"testing\"\n)\n\nfunc skipIfNotUnix(t *testing.T) {\n\tswitch runtime."
},
{
"path": "cmd/socket-proxy/checksocketconnection.go",
"chars": 2231,
"preview": "package main\n\nimport (\n\t\"errors\"\n\t\"log/slog\"\n\t\"net\"\n\t\"net/http\"\n\t\"time\"\n)\n\nconst dialTimeout = 5 // timeout in seconds f"
},
{
"path": "cmd/socket-proxy/handlehttprequest.go",
"chars": 4810,
"preview": "package main\n\nimport (\n\t\"errors\"\n\t\"log/slog\"\n\t\"net\"\n\t\"net/http\"\n\t\"regexp\"\n\n\t\"github.com/wollomatic/socket-proxy/internal"
},
{
"path": "cmd/socket-proxy/main.go",
"chars": 5935,
"preview": "package main\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/http/httputil\"\n\t\"net/url\"\n\t\"os\"\n"
},
{
"path": "cosign.pub",
"chars": 178,
"preview": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEYdXlfRbkO6KqPU7Khn1mSjbOIaD3\num421A0NeT1wi840iWNp6MVKyj3t"
},
{
"path": "examples/docker-compose/dozzle/compose.yaml",
"chars": 1417,
"preview": "services:\n dockerproxy:\n image: wollomatic/socket-proxy:1\n command:\n - '-loglevel=info'\n - '-allowfrom="
},
{
"path": "examples/docker-compose/watchtower/compose.yaml",
"chars": 1958,
"preview": "services:\n dockerproxy:\n image: wollomatic/socket-proxy:1\n command:\n - '-loglevel=info'\n - '-allowfrom="
},
{
"path": "go.mod",
"chars": 53,
"preview": "module github.com/wollomatic/socket-proxy\n\ngo 1.26.0\n"
},
{
"path": "internal/config/config.go",
"chars": 25710,
"preview": "package config\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"flag\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"math\"\n\t\"net\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepat"
},
{
"path": "internal/config/config_test.go",
"chars": 4340,
"preview": "package config\n\nimport (\n\t\"flag\"\n\t\"math\"\n\t\"os\"\n\t\"reflect\"\n\t\"regexp\"\n\t\"sort\"\n\t\"strconv\"\n\t\"testing\"\n\n\t\"github.com/wollomat"
},
{
"path": "internal/config/env.go",
"chars": 753,
"preview": "package config\n\nimport (\n\t\"strings\"\n)\n\nconst spAllowPrefix = \"SP_ALLOW_\"\n\n// getAllowFromEnv reads allowlist regex strin"
},
{
"path": "internal/config/env_test.go",
"chars": 1329,
"preview": "package config\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n)\n\nfunc Test_getAllowFromEnv(t *testing.T) {\n\ttests := []struct {\n\t\tname "
},
{
"path": "internal/config/param.go",
"chars": 569,
"preview": "package config\n\nimport (\n\t\"flag\"\n\t\"strings\"\n)\n\ntype from int\n\nconst (\n\tfromEnv from = 1\n\tfromParam from = 2\n)\n\ntype pa"
},
{
"path": "internal/docker/api/common.go",
"chars": 222,
"preview": "package api\n\n/*\nThis was modified from:\nhttps://github.com/moby/moby/blob/v28.5.1/api/common.go\n*/\n\n// Common constants "
},
{
"path": "internal/docker/api/types/container/container.go",
"chars": 357,
"preview": "package container\n\n/*\nThis was modified from:\nhttps://github.com/moby/moby/blob/v28.5.1/api/types/container/container.go"
},
{
"path": "internal/docker/api/types/container/network_settings.go",
"chars": 394,
"preview": "package container\n\n/*\nThis was modified from:\nhttps://github.com/moby/moby/blob/v28.5.1/api/types/container/network_sett"
},
{
"path": "internal/docker/api/types/container/options.go",
"chars": 309,
"preview": "package container\n\n/*\nThis was modified from:\nhttps://github.com/moby/moby/blob/v28.5.1/api/types/container/options.go\n*"
},
{
"path": "internal/docker/api/types/error_response.go",
"chars": 291,
"preview": "package types\n\n/*\nThis was modified from:\nhttps://github.com/moby/moby/blob/v28.5.1/api/types/error_response.go\n*/\n\n// E"
},
{
"path": "internal/docker/api/types/events/events.go",
"chars": 3902,
"preview": "package events\n\n/*\nThis was modified from:\nhttps://github.com/moby/moby/blob/v28.5.1/api/types/events/events.go\n*/\n\nimpo"
},
{
"path": "internal/docker/api/types/filters/errors.go",
"chars": 588,
"preview": "package filters\n\n/*\nThis was modified from:\nhttps://github.com/moby/moby/blob/v28.5.1/api/types/filters/errors.go\n*/\n\nim"
},
{
"path": "internal/docker/api/types/filters/parse.go",
"chars": 6595,
"preview": "/*\nPackage filters provides tools for encoding a mapping of keys to a set of\nmultiple values.\n\nThis was modified from:\nh"
},
{
"path": "internal/docker/api/types/network/endpoint.go",
"chars": 571,
"preview": "package network\n\n/*\nThis was modified from:\nhttps://github.com/moby/moby/blob/v28.5.1/api/types/network/endpoint.go\n*/\n\n"
},
{
"path": "internal/docker/api/types/types.go",
"chars": 204,
"preview": "package types\n\n/*\nThis was modified from:\nhttps://github.com/moby/moby/blob/v28.5.1/api/types/types.go\n*/\n\n// Ping conta"
},
{
"path": "internal/docker/api/types/versions/compare.go",
"chars": 902,
"preview": "package versions\n\n/*\nThis was modified from:\nhttps://github.com/moby/moby/blob/v28.5.1/api/types/versions/compare.go\n*/\n"
},
{
"path": "internal/docker/client/client.go",
"chars": 8401,
"preview": "/*\nPackage client is a Go client for the Docker Engine API.\n\nFor more information about the Engine API, see the document"
},
{
"path": "internal/docker/client/container_list.go",
"chars": 932,
"preview": "package client\n\n/*\nThis was modified from:\nhttps://github.com/moby/moby/blob/v28.5.1/client/container_list.go\n*/\n\nimport"
},
{
"path": "internal/docker/client/errors.go",
"chars": 1083,
"preview": "package client\n\n/*\nThis was modified from:\nhttps://github.com/moby/moby/blob/v28.5.1/client/errors.go\n*/\n\nimport (\n\t\"err"
},
{
"path": "internal/docker/client/events.go",
"chars": 1885,
"preview": "package client\n\n/*\nThis was modified from:\nhttps://github.com/moby/moby/blob/v28.5.1/client/events.go\n*/\n\nimport (\n\t\"con"
},
{
"path": "internal/docker/client/options.go",
"chars": 1231,
"preview": "package client\n\n/*\nThis was modified from:\nhttps://github.com/moby/moby/blob/v28.5.1/client/options.go\n*/\n\nimport (\n\t\"fm"
},
{
"path": "internal/docker/client/ping.go",
"chars": 2030,
"preview": "package client\n\n/*\nThis was modified from:\nhttps://github.com/moby/moby/blob/v28.5.1/client/ping.go\n*/\n\nimport (\n\t\"conte"
},
{
"path": "internal/docker/client/request.go",
"chars": 6973,
"preview": "package client\n\n/*\nThis was modified from:\nhttps://github.com/moby/moby/blob/v28.5.1/client/request.go\n*/\n\nimport (\n\t\"co"
},
{
"path": "internal/go-connections/sockets/sockets.go",
"chars": 1907,
"preview": "/*\nPackage sockets provides helper functions to create and configure Unix or TCP sockets.\n\nThis was modified from:\nhttps"
}
]
About this extraction
This page contains the full source code of the wollomatic/socket-proxy GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 46 files (153.2 KB), approximately 40.8k tokens, and a symbol index with 209 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.