From 44aed848273bdcdb1ea4ed83da6bd0bda3a23d02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4fer?= Date: Mon, 20 Oct 2025 21:56:04 +0200 Subject: [PATCH 01/20] feat(actions): Sync Images from Docker to GHCR --- .github/workflows/mirror.yaml | 132 ++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 .github/workflows/mirror.yaml diff --git a/.github/workflows/mirror.yaml b/.github/workflows/mirror.yaml new file mode 100644 index 0000000..3f42f19 --- /dev/null +++ b/.github/workflows/mirror.yaml @@ -0,0 +1,132 @@ +name: Mirror & Sign (Docker Hub to GHCR) + +on: + workflow_dispatch: {} + +permissions: + contents: read + packages: write + id-token: write # for keyless OIDC + +env: + SOURCE_IMAGE: docker.io/fosrl/newt + DEST_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} + +jobs: + mirror-and-dual-sign: + runs-on: amd64-runner + steps: + - name: Install skopeo + jq + run: | + sudo apt-get update -y + sudo apt-get install -y skopeo jq + skopeo --version + + - name: Install cosign + uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 + + - name: Input check + run: | + test -n "${SOURCE_IMAGE}" || (echo "SOURCE_IMAGE is empty" && exit 1) + echo "Source : ${SOURCE_IMAGE}" + echo "Target : ${DEST_IMAGE}" + + # Auth for skopeo (containers-auth) + - name: Skopeo login to GHCR + run: | + skopeo login ghcr.io -u "${{ github.actor }}" -p "${{ secrets.GITHUB_TOKEN }}" + + # Auth for cosign (docker-config) + - name: Docker login to GHCR (for cosign) + run: | + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + + - name: List source tags + run: | + set -euo pipefail + skopeo list-tags --retry-times 3 docker://"${SOURCE_IMAGE}" \ + | jq -r '.Tags[]' | sort -u > src-tags.txt + echo "Found source tags: $(wc -l < src-tags.txt)" + head -n 20 src-tags.txt || true + + - name: List destination tags (skip existing) + run: | + set -euo pipefail + if skopeo list-tags --retry-times 3 docker://"${DEST_IMAGE}" >/tmp/dst.json 2>/dev/null; then + jq -r '.Tags[]' /tmp/dst.json | sort -u > dst-tags.txt + else + : > dst-tags.txt + fi + echo "Existing destination tags: $(wc -l < dst-tags.txt)" + + - name: Mirror, dual-sign, and verify + env: + # keyless + COSIGN_YES: "true" + # key-based + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + # verify + COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} + run: | + set -euo pipefail + copied=0; skipped=0; v_ok=0; errs=0 + + issuer="https://token.actions.githubusercontent.com" + id_regex="^https://github.com/${{ github.repository }}/.+" + + while read -r tag; do + [ -z "$tag" ] && continue + + if grep -Fxq "$tag" dst-tags.txt; then + echo "::notice ::Skip (exists) ${DEST_IMAGE}:${tag}" + skipped=$((skipped+1)) + continue + fi + + echo "==> Copy ${SOURCE_IMAGE}:${tag} → ${DEST_IMAGE}:${tag}" + if ! skopeo copy --all --retry-times 3 \ + docker://"${SOURCE_IMAGE}:${tag}" docker://"${DEST_IMAGE}:${tag}"; then + echo "::warning title=Copy failed::${SOURCE_IMAGE}:${tag}" + errs=$((errs+1)); continue + fi + copied=$((copied+1)) + + digest="$(skopeo inspect --retry-times 3 docker://"${DEST_IMAGE}:${tag}" | jq -r '.Digest')" + ref="${DEST_IMAGE}@${digest}" + + echo "==> cosign sign (keyless) --recursive ${ref}" + if ! cosign sign --recursive "${ref}"; then + echo "::warning title=Keyless sign failed::${ref}" + errs=$((errs+1)) + fi + + echo "==> cosign sign (key) --recursive ${ref}" + if ! cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${ref}"; then + echo "::warning title=Key sign failed::${ref}" + errs=$((errs+1)) + fi + + echo "==> cosign verify (public key) ${ref}" + if ! cosign verify --key env://COSIGN_PUBLIC_KEY "${ref}" -o text; then + echo "::warning title=Verify(pubkey) failed::${ref}" + errs=$((errs+1)) + fi + + echo "==> cosign verify (keyless policy) ${ref}" + if ! cosign verify \ + --certificate-oidc-issuer "${issuer}" \ + --certificate-identity-regexp "${id_regex}" \ + "${ref}" -o text; then + echo "::warning title=Verify(keyless) failed::${ref}" + errs=$((errs+1)) + else + v_ok=$((v_ok+1)) + fi + done < src-tags.txt + + echo "---- Summary ----" + echo "Copied : $copied" + echo "Skipped : $skipped" + echo "Verified OK : $v_ok" + echo "Errors : $errs" From 661fd863054f934595f9069e1b8a2f35b5a855dd Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 20 Oct 2025 12:59:17 -0700 Subject: [PATCH 02/20] Update to use gerbil and not newt --- .github/workflows/mirror.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/mirror.yaml b/.github/workflows/mirror.yaml index 3f42f19..793073e 100644 --- a/.github/workflows/mirror.yaml +++ b/.github/workflows/mirror.yaml @@ -9,7 +9,7 @@ permissions: id-token: write # for keyless OIDC env: - SOURCE_IMAGE: docker.io/fosrl/newt + SOURCE_IMAGE: docker.io/fosrl/gerbil DEST_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} jobs: From ca23ae7a30a8294f1bd5e9dd948d35768a7583ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4fer?= Date: Tue, 21 Oct 2025 01:18:33 +0200 Subject: [PATCH 03/20] ci(actions): pin action versions to commit SHAs for security - Pin actions/checkout to SHA for v5.0.0 - Pin docker/setup-qemu-action to SHA for v3.6.0 - Pin docker/setup-buildx-action to SHA for v3.11.1 - Pin docker/login-action to SHA for v3.6.0 - Pin actions/setup-go to SHA for v6.0.0 - Pin actions/upload-artifact to SHA for v4.6.2 --- .github/workflows/cicd.yml | 15 +++++++-------- .github/workflows/test.yml | 6 +++--- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 3402a0a..6059453 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -8,20 +8,20 @@ on: jobs: release: name: Build and Release - runs-on: amd64-runner + runs-on: amd64-runner steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 - name: Log in to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} @@ -31,7 +31,7 @@ jobs: run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV - name: Install Go - uses: actions/setup-go@v6 + uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 with: go-version: 1.25 @@ -45,8 +45,7 @@ jobs: make go-build-release - name: Upload artifacts from /bin - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: binaries path: bin/ - diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1b9637e..2c771f6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,12 +11,12 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 with: - go-version: '1.25' + go-version: 1.25 - name: Build go run: go build From 2b7e93ec9219b529891ec73d2732e7d4dde5d5e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4fer?= Date: Tue, 21 Oct 2025 01:19:36 +0200 Subject: [PATCH 04/20] ci(actions): add permissions section to CI/CD and test workflows --- .github/workflows/cicd.yml | 3 +++ .github/workflows/test.yml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 6059453..5b1b891 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -1,5 +1,8 @@ name: CI/CD Pipeline +permissions: + contents: read + on: push: tags: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2c771f6..40759fa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,5 +1,8 @@ name: Run Tests +permissions: + contents: read + on: pull_request: branches: From 06b1e84f998afdafaf138c20eacb3aae0d1b2e3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4fer?= Date: Tue, 21 Oct 2025 01:20:08 +0200 Subject: [PATCH 05/20] feat(ci): add step to update version in main.go during CI/CD pipeline --- .github/workflows/cicd.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 5b1b891..010df57 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -38,6 +38,16 @@ jobs: with: go-version: 1.25 + - name: Update version in main.go + run: | + TAG=${{ env.TAG }} + if [ -f main.go ]; then + sed -i 's/version_replaceme/'"$TAG"'/' main.go + echo "Updated main.go with version $TAG" + else + echo "main.go not found" + fi + - name: Build and push Docker images run: | TAG=${{ env.TAG }} From 6cde07d47919a647dbbb19110934005da3107e21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4fer?= Date: Tue, 21 Oct 2025 01:30:51 +0200 Subject: [PATCH 06/20] ci(actions): add GHCR mirroring and cosign signing for Docker images - mirror images from Docker Hub to GHCR using skopeo (preserves multi-arch manifests) - login to GHCR via docker/login-action for signing/pushing - install cosign and perform dual signing: keyless (OIDC) + key-based; verify signatures - add required permissions for id-token/packages and reference necessary secrets --- .github/workflows/cicd.yml | 188 ++++++++++++++++++++++++++++--------- 1 file changed, 142 insertions(+), 46 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 010df57..c96ff7f 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -1,64 +1,160 @@ name: CI/CD Pipeline +# CI/CD workflow for building, publishing, mirroring, signing container images and building release binaries. +# Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events. + permissions: contents: read + packages: write # for GHCR push + id-token: write # for Cosign Keyless (OIDC) Signing + +# Required secrets: +# - DOCKER_HUB_USERNAME / DOCKER_HUB_ACCESS_TOKEN: push to Docker Hub +# - GITHUB_TOKEN: used for GHCR login and OIDC keyless signing +# - COSIGN_PRIVATE_KEY / COSIGN_PASSWORD / COSIGN_PUBLIC_KEY: for key-based signing on: - push: - tags: - - "*" + push: + tags: + - "*" + +concurrency: + group: ${{ github.ref }} + cancel-in-progress: true jobs: - release: - name: Build and Release - runs-on: amd64-runner + release: + name: Build and Release + runs-on: amd64-runner + # Job-level timeout to avoid runaway or stuck runs + timeout-minutes: 120 + env: + # Target images + DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKER_HUB_USERNAME }}/${{ github.event.repository.name }} + GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} - steps: - - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + steps: + - name: Checkout code + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - - name: Set up QEMU - uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 + - name: Set up QEMU + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 - - name: Log in to Docker Hub - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 - with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + - name: Log in to Docker Hub + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + registry: docker.io + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - - name: Extract tag name - id: get-tag - run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + - name: Extract tag name + id: get-tag + run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + shell: bash - - name: Install Go - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 - with: - go-version: 1.25 + - name: Install Go + uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + with: + go-version: 1.25 - - name: Update version in main.go - run: | - TAG=${{ env.TAG }} - if [ -f main.go ]; then - sed -i 's/version_replaceme/'"$TAG"'/' main.go - echo "Updated main.go with version $TAG" - else - echo "main.go not found" - fi + - name: Update version in main.go + run: | + TAG=${{ env.TAG }} + if [ -f main.go ]; then + sed -i 's/version_replaceme/'"$TAG"'/' main.go + echo "Updated main.go with version $TAG" + else + echo "main.go not found" + fi + shell: bash - - name: Build and push Docker images - run: | - TAG=${{ env.TAG }} - make docker-build-release tag=$TAG + - name: Build and push Docker images (Docker Hub) + run: | + TAG=${{ env.TAG }} + make docker-build-release tag=$TAG + echo "Built & pushed to: ${{ env.DOCKERHUB_IMAGE }}:${TAG}" + shell: bash - - name: Build binaries - run: | - make go-build-release + - name: Login in to GHCR + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - - name: Upload artifacts from /bin - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: binaries - path: bin/ + - name: Install skopeo + jq + # skopeo: copy/inspect images between registries + # jq: JSON parsing tool used to extract digest values + run: | + sudo apt-get update -y + sudo apt-get install -y skopeo jq + skopeo --version + shell: bash + + - name: Copy tag from Docker Hub to GHCR + # Mirror the already-built image (all architectures) to GHCR so we can sign it + run: | + set -euo pipefail + TAG=${{ env.TAG }} + echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG} -> ${{ env.GHCR_IMAGE }}:${TAG}" + skopeo copy --all --retry-times 3 \ + docker://$DOCKERHUB_IMAGE:$TAG \ + docker://$GHCR_IMAGE:$TAG + shell: bash + + - name: Install cosign + # cosign is used to sign and verify container images (key and keyless) + uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 + + - name: Dual-sign and verify (GHCR & Docker Hub) + # Sign each image by digest using keyless (OIDC) and key-based signing, + # then verify both the public key signature and the keyless OIDC signature. + env: + TAG: ${{ env.TAG }} + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} + COSIGN_YES: "true" + run: | + set -euo pipefail + + issuer="https://token.actions.githubusercontent.com" + id_regex="^https://github.com/${{ github.repository }}/.+" # accept this repo (all workflows/refs) + + for IMAGE in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do + echo "Processing ${IMAGE}:${TAG}" + + DIGEST="$(skopeo inspect --retry-times 3 docker://${IMAGE}:${TAG} | jq -r '.Digest')" + REF="${IMAGE}@${DIGEST}" + echo "Resolved digest: ${REF}" + + echo "==> cosign sign (keyless) --recursive ${REF}" + cosign sign --recursive "${REF}" + + echo "==> cosign sign (key) --recursive ${REF}" + cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}" + + echo "==> cosign verify (public key) ${REF}" + cosign verify --key env://COSIGN_PUBLIC_KEY "${REF}" -o text + + echo "==> cosign verify (keyless policy) ${REF}" + cosign verify \ + --certificate-oidc-issuer "${issuer}" \ + --certificate-identity-regexp "${id_regex}" \ + "${REF}" -o text + done + shell: bash + + - name: Build binaries + run: | + make go-build-release + shell: bash + + - name: Upload artifacts from /bin + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: binaries + path: bin/ From 2a1911a66fabbeaad6c57c2af91111f96135c7f5 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 20 Oct 2025 16:43:18 -0700 Subject: [PATCH 07/20] Update runner to amd64-runner --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 40759fa..9dbce33 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: amd64-runner steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 From d3a16f4c59896e00e52cd2a6531060d91cd3a821 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Oct 2025 22:24:20 +0000 Subject: [PATCH 08/20] Bump actions/upload-artifact from 4.6.2 to 5.0.0 Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.2 to 5.0.0. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/ea165f8d65b6e75b540449e92b4886f43607fa02...330a01c490aca151604b8cf639adc76d48f6c5d4) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 5.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/cicd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index c96ff7f..76b3964 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -154,7 +154,7 @@ jobs: shell: bash - name: Upload artifacts from /bin - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: binaries path: bin/ From 3afc82ef9a6e7cf813c853136118cd5f03bcd520 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Nov 2025 21:51:03 +0000 Subject: [PATCH 09/20] Bump docker/setup-qemu-action from 3.6.0 to 3.7.0 Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3.6.0 to 3.7.0. - [Release notes](https://github.com/docker/setup-qemu-action/releases) - [Commits](https://github.com/docker/setup-qemu-action/compare/29109295f81e9208d7d86ff1c6c12d2833863392...c7c53464625b32c7a7e944ae62b3e17d2b600130) --- updated-dependencies: - dependency-name: docker/setup-qemu-action dependency-version: 3.7.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/cicd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index c96ff7f..244c760 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -38,7 +38,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up QEMU - uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 From a90f6819577811ef4cd487cbc667afaf914726c8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 21:19:30 +0000 Subject: [PATCH 10/20] Bump golang.org/x/crypto in the prod-minor-updates group Bumps the prod-minor-updates group with 1 update: [golang.org/x/crypto](https://github.com/golang/crypto). Updates `golang.org/x/crypto` from 0.43.0 to 0.44.0 - [Commits](https://github.com/golang/crypto/compare/v0.43.0...v0.44.0) --- updated-dependencies: - dependency-name: golang.org/x/crypto dependency-version: 0.44.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates ... Signed-off-by: dependabot[bot] --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 72f9c0f..4fd1aff 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25 require ( github.com/patrickmn/go-cache v2.1.0+incompatible github.com/vishvananda/netlink v1.3.1 - golang.org/x/crypto v0.43.0 + golang.org/x/crypto v0.44.0 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 ) @@ -16,8 +16,8 @@ require ( github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/socket v0.4.1 // indirect github.com/vishvananda/netns v0.0.5 // indirect - golang.org/x/net v0.45.0 // indirect + golang.org/x/net v0.46.0 // indirect golang.org/x/sync v0.1.0 // indirect - golang.org/x/sys v0.37.0 // indirect + golang.org/x/sys v0.38.0 // indirect golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b // indirect ) diff --git a/go.sum b/go.sum index bd7354b..e6e895c 100644 --- a/go.sum +++ b/go.sum @@ -16,16 +16,16 @@ github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= -golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= -golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= +golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b h1:J1CaxgLerRR5lgx3wnr6L04cJFbWoceSK9JWBdglINo= golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b/go.mod h1:tqur9LnfstdR9ep2LaJT4lFUl0EjlHtge+gAjmsHUG4= golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 h1:CawjfCvYQH2OU3/TnxLx97WDSUDRABfT18pCOYwc2GE= From ee27bf3153e4054d75f3d39ae7d94f0c697857ce Mon Sep 17 00:00:00 2001 From: Laurence Date: Thu, 13 Nov 2025 06:26:09 +0000 Subject: [PATCH 11/20] Fix race condition in WireGuard session management The race condition existed because while sync.Map is thread-safe for map operations (Load, Store, Delete, Range), it does not provide thread-safety for the data stored within it. When WireGuardSession structs were stored as pointers in the sync.Map, multiple goroutines could: 1. Retrieve the same session pointer from the map concurrently 2. Access and modify the session's fields (particularly LastSeen) without synchronization 3. Cause data races when one goroutine reads LastSeen while another updates it This fix adds a sync.RWMutex to each WireGuardSession struct to protect concurrent access to its fields. All field access now goes through thread-safe methods that properly acquire/release the mutex. Changes: - Added sync.RWMutex to WireGuardSession struct - Added thread-safe accessor methods (GetLastSeen, GetDestAddr, etc.) - Added atomic CheckAndUpdateIfMatch method for efficient check-and-update - Updated all session field accesses to use thread-safe methods - Removed redundant Store call after updating LastSeen (pointer update is atomic in Go, but field access within pointer was not) --- relay/relay.go | 66 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 57 insertions(+), 9 deletions(-) diff --git a/relay/relay.go b/relay/relay.go index e74ed87..aa2045d 100644 --- a/relay/relay.go +++ b/relay/relay.go @@ -58,12 +58,61 @@ type DestinationConn struct { // Type for storing WireGuard handshake information type WireGuardSession struct { + mu sync.RWMutex ReceiverIndex uint32 SenderIndex uint32 DestAddr *net.UDPAddr LastSeen time.Time } +// UpdateLastSeen updates the LastSeen timestamp in a thread-safe manner +func (s *WireGuardSession) UpdateLastSeen() { + s.mu.Lock() + defer s.mu.Unlock() + s.LastSeen = time.Now() +} + +// GetSenderIndex returns the SenderIndex in a thread-safe manner +func (s *WireGuardSession) GetSenderIndex() uint32 { + s.mu.RLock() + defer s.mu.RUnlock() + return s.SenderIndex +} + +// GetDestAddr returns the DestAddr in a thread-safe manner +func (s *WireGuardSession) GetDestAddr() *net.UDPAddr { + s.mu.RLock() + defer s.mu.RUnlock() + return s.DestAddr +} + +// GetLastSeen returns the LastSeen timestamp in a thread-safe manner +func (s *WireGuardSession) GetLastSeen() time.Time { + s.mu.RLock() + defer s.mu.RUnlock() + return s.LastSeen +} + +// MatchesSenderIndex checks if the SenderIndex matches the given value in a thread-safe manner +func (s *WireGuardSession) MatchesSenderIndex(receiverIndex uint32) bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.SenderIndex == receiverIndex +} + +// CheckAndUpdateIfMatch atomically checks if SenderIndex matches and updates LastSeen if it does. +// Returns the DestAddr and true if there's a match, nil and false otherwise. +// This is more efficient than separate MatchesSenderIndex and UpdateLastSeen calls. +func (s *WireGuardSession) CheckAndUpdateIfMatch(receiverIndex uint32) (*net.UDPAddr, bool) { + s.mu.Lock() + defer s.mu.Unlock() + if s.SenderIndex == receiverIndex { + s.LastSeen = time.Now() + return s.DestAddr, true + } + return nil, false +} + // Type for tracking bidirectional communication patterns to rebuild sessions type CommunicationPattern struct { FromClient *net.UDPAddr // The client address @@ -442,13 +491,10 @@ func (s *UDPProxyServer) handleWireGuardPacket(packet []byte, remoteAddr *net.UD // First check for existing sessions to see if we know where to send this packet s.wgSessions.Range(func(k, v interface{}) bool { session := v.(*WireGuardSession) - if session.SenderIndex == receiverIndex { + // Atomically check if session matches and update LastSeen if it does + if addr, matches := session.CheckAndUpdateIfMatch(receiverIndex); matches { // Found matching session - destAddr = session.DestAddr - - // Update last seen time - session.LastSeen = time.Now() - s.wgSessions.Store(k, session) + destAddr = addr return false // stop iteration } return true // continue iteration @@ -608,7 +654,8 @@ func (s *UDPProxyServer) cleanupIdleSessions() { now := time.Now() s.wgSessions.Range(func(key, value interface{}) bool { session := value.(*WireGuardSession) - if now.Sub(session.LastSeen) > 15*time.Minute { + // Use thread-safe method to read LastSeen + if now.Sub(session.GetLastSeen()) > 15*time.Minute { s.wgSessions.Delete(key) logger.Debug("Removed idle session: %s", key) } @@ -735,8 +782,9 @@ func (s *UDPProxyServer) clearSessionsForIP(ip string) { keyStr := key.(string) session := value.(*WireGuardSession) - // Check if the session's destination address contains the WG IP - if session.DestAddr != nil && session.DestAddr.IP.String() == ip { + // Check if the session's destination address contains the WG IP (thread-safe) + destAddr := session.GetDestAddr() + if destAddr != nil && destAddr.IP.String() == ip { keysToDelete = append(keysToDelete, keyStr) logger.Debug("Marking session for deletion for WG IP %s: %s", ip, keyStr) } From a3f9a89079eb6b5babb6bb32c14936d3fb9c3799 Mon Sep 17 00:00:00 2001 From: Laurence Date: Thu, 13 Nov 2025 06:43:31 +0000 Subject: [PATCH 12/20] Refactor WireGuard session locking and remove unused methods - Remove unused methods: UpdateLastSeen, GetSenderIndex, MatchesSenderIndex (replaced by simpler direct usage in Range callbacks) - Simplify session access pattern: check GetSenderIndex in Range callback, then call GetDestAddr and UpdateLastSeen when match found - Optimize UpdateLastSeen usage: only use for existing sessions already in sync.Map; use direct assignment in struct literals for new sessions (safe since no concurrent access during creation) This simplifies the code while maintaining thread-safety for concurrent access to existing sessions. --- relay/relay.go | 41 ++++++++++------------------------------- 1 file changed, 10 insertions(+), 31 deletions(-) diff --git a/relay/relay.go b/relay/relay.go index aa2045d..595cbb5 100644 --- a/relay/relay.go +++ b/relay/relay.go @@ -65,13 +65,6 @@ type WireGuardSession struct { LastSeen time.Time } -// UpdateLastSeen updates the LastSeen timestamp in a thread-safe manner -func (s *WireGuardSession) UpdateLastSeen() { - s.mu.Lock() - defer s.mu.Unlock() - s.LastSeen = time.Now() -} - // GetSenderIndex returns the SenderIndex in a thread-safe manner func (s *WireGuardSession) GetSenderIndex() uint32 { s.mu.RLock() @@ -93,24 +86,11 @@ func (s *WireGuardSession) GetLastSeen() time.Time { return s.LastSeen } -// MatchesSenderIndex checks if the SenderIndex matches the given value in a thread-safe manner -func (s *WireGuardSession) MatchesSenderIndex(receiverIndex uint32) bool { - s.mu.RLock() - defer s.mu.RUnlock() - return s.SenderIndex == receiverIndex -} - -// CheckAndUpdateIfMatch atomically checks if SenderIndex matches and updates LastSeen if it does. -// Returns the DestAddr and true if there's a match, nil and false otherwise. -// This is more efficient than separate MatchesSenderIndex and UpdateLastSeen calls. -func (s *WireGuardSession) CheckAndUpdateIfMatch(receiverIndex uint32) (*net.UDPAddr, bool) { +// UpdateLastSeen updates the LastSeen timestamp in a thread-safe manner +func (s *WireGuardSession) UpdateLastSeen() { s.mu.Lock() defer s.mu.Unlock() - if s.SenderIndex == receiverIndex { - s.LastSeen = time.Now() - return s.DestAddr, true - } - return nil, false + s.LastSeen = time.Now() } // Type for tracking bidirectional communication patterns to rebuild sessions @@ -491,10 +471,11 @@ func (s *UDPProxyServer) handleWireGuardPacket(packet []byte, remoteAddr *net.UD // First check for existing sessions to see if we know where to send this packet s.wgSessions.Range(func(k, v interface{}) bool { session := v.(*WireGuardSession) - // Atomically check if session matches and update LastSeen if it does - if addr, matches := session.CheckAndUpdateIfMatch(receiverIndex); matches { - // Found matching session - destAddr = addr + // Check if session matches (read lock for check) + if session.GetSenderIndex() == receiverIndex { + // Found matching session - get dest addr and update last seen + destAddr = session.GetDestAddr() + session.UpdateLastSeen() return false // stop iteration } return true // continue iteration @@ -974,14 +955,12 @@ func (s *UDPProxyServer) tryRebuildSession(pattern *CommunicationPattern) { // Check if we already have this session if _, exists := s.wgSessions.Load(sessionKey); !exists { - session := &WireGuardSession{ + s.wgSessions.Store(sessionKey, &WireGuardSession{ ReceiverIndex: pattern.DestIndex, SenderIndex: pattern.ClientIndex, DestAddr: pattern.ToDestination, LastSeen: time.Now(), - } - - s.wgSessions.Store(sessionKey, session) + }) logger.Info("Rebuilt WireGuard session from communication pattern: %s -> %s (packets: %d)", sessionKey, pattern.ToDestination.String(), pattern.PacketCount) } From 697f4131e7e5b3dab3fba49361fb5a349e60cd19 Mon Sep 17 00:00:00 2001 From: Laurence Date: Sun, 16 Nov 2025 05:59:34 +0000 Subject: [PATCH 13/20] enhancement: base context + errgroup; propagate cancellation; graceful shutdown - main: add base context via signal.NotifyContext; establish errgroup and use it to supervise background tasks; convert ticker to context-aware periodicBandwidthCheck; run HTTP server under errgroup and add graceful shutdown; treat context.Canceled as normal exit - relay: thread parent context through UDPProxyServer; add cancel func; make packet reader, workers, and cleanup tickers exit on ctx.Done; Stop cancels, closes listener and downstream UDP connections, and closes packet channel to drain workers - proxy: drop earlier parent context hook for SNI proxy per review; rely on existing Stop() for graceful shutdown Benefits: - unified lifecycle and deterministic shutdown across components - prevents leaked goroutines/tickers and closes sockets cleanly - consolidated error handling via g.Wait(), with context cancellation treated as non-error - sets foundation for child errgroups and future structured concurrency --- main.go | 75 ++++++++++++++++------ relay/relay.go | 168 ++++++++++++++++++++++++++++++++++--------------- 2 files changed, 173 insertions(+), 70 deletions(-) diff --git a/main.go b/main.go index 7a99c4d..61c186f 100644 --- a/main.go +++ b/main.go @@ -2,7 +2,9 @@ package main import ( "bytes" + "context" "encoding/json" + "errors" "flag" "fmt" "io" @@ -21,6 +23,7 @@ import ( "github.com/fosrl/gerbil/proxy" "github.com/fosrl/gerbil/relay" "github.com/vishvananda/netlink" + "golang.org/x/sync/errgroup" "golang.zx2c4.com/wireguard/wgctrl" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" ) @@ -217,6 +220,10 @@ func main() { logger.Init() logger.GetLogger().SetLevel(parseLogLevel(logLevel)) + // Base context for the application; cancel on SIGINT/SIGTERM + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + // try to parse as http://host:port and set the listenAddr to the :port from this reachableAt. if reachableAt != "" && listenAddr == "" { if strings.HasPrefix(reachableAt, "http://") || strings.HasPrefix(reachableAt, "https://") { @@ -324,10 +331,16 @@ func main() { // Ensure the WireGuard peers exist ensureWireguardPeers(wgconfig.Peers) - go periodicBandwidthCheck(remoteConfigURL + "/gerbil/receive-bandwidth") + // Child error group derived from base context + group, groupCtx := errgroup.WithContext(ctx) + + // Periodic bandwidth reporting + group.Go(func() error { + return periodicBandwidthCheck(groupCtx, remoteConfigURL+"/gerbil/receive-bandwidth") + }) // Start the UDP proxy server - proxyRelay = relay.NewUDPProxyServer(":21820", remoteConfigURL, key, reachableAt) + proxyRelay = relay.NewUDPProxyServer(groupCtx, ":21820", remoteConfigURL, key, reachableAt) err = proxyRelay.Start() if err != nil { logger.Fatal("Failed to start UDP proxy server: %v", err) @@ -371,18 +384,39 @@ func main() { http.HandleFunc("/update-local-snis", handleUpdateLocalSNIs) logger.Info("Starting HTTP server on %s", listenAddr) - // Run HTTP server in a goroutine - go func() { - if err := http.ListenAndServe(listenAddr, nil); err != nil { - logger.Error("HTTP server failed: %v", err) + // HTTP server with graceful shutdown on context cancel + server := &http.Server{ + Addr: listenAddr, + Handler: nil, + } + group.Go(func() error { + // http.ErrServerClosed is returned on graceful shutdown; not an error for us + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err } - }() + return nil + }) + group.Go(func() error { + <-groupCtx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = server.Shutdown(shutdownCtx) + // Stop background components as the context is canceled + if proxySNI != nil { + _ = proxySNI.Stop() + } + if proxyRelay != nil { + proxyRelay.Stop() + } + return nil + }) - // Keep the main goroutine running - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - <-sigCh - logger.Info("Shutting down servers...") + // Wait for all goroutines to finish + if err := group.Wait(); err != nil && !errors.Is(err, context.Canceled) { + logger.Error("Service exited with error: %v", err) + } else if errors.Is(err, context.Canceled) { + logger.Info("Context cancelled, shutting down") + } } func loadRemoteConfig(url string, key wgtypes.Key, reachableAt string) (WgConfig, error) { @@ -639,7 +673,7 @@ func ensureMSSClamping() error { if out, err := addCmd.CombinedOutput(); err != nil { errMsg := fmt.Sprintf("Failed to add MSS clamping rule for chain %s: %v (output: %s)", chain, err, string(out)) - logger.Error(errMsg) + logger.Error("%s", errMsg) errors = append(errors, fmt.Errorf("%s", errMsg)) continue } @@ -656,7 +690,7 @@ func ensureMSSClamping() error { if out, err := checkCmd.CombinedOutput(); err != nil { errMsg := fmt.Sprintf("Rule verification failed for chain %s: %v (output: %s)", chain, err, string(out)) - logger.Error(errMsg) + logger.Error("%s", errMsg) errors = append(errors, fmt.Errorf("%s", errMsg)) continue } @@ -977,13 +1011,18 @@ func handleUpdateLocalSNIs(w http.ResponseWriter, r *http.Request) { }) } -func periodicBandwidthCheck(endpoint string) { +func periodicBandwidthCheck(ctx context.Context, endpoint string) error { ticker := time.NewTicker(10 * time.Second) defer ticker.Stop() - for range ticker.C { - if err := reportPeerBandwidth(endpoint); err != nil { - logger.Info("Failed to report peer bandwidth: %v", err) + for { + select { + case <-ticker.C: + if err := reportPeerBandwidth(endpoint); err != nil { + logger.Info("Failed to report peer bandwidth: %v", err) + } + case <-ctx.Done(): + return ctx.Err() } } } diff --git a/relay/relay.go b/relay/relay.go index e74ed87..e3fef04 100644 --- a/relay/relay.go +++ b/relay/relay.go @@ -1,6 +1,7 @@ package relay import ( + "context" "bytes" "encoding/binary" "encoding/json" @@ -112,6 +113,8 @@ type UDPProxyServer struct { connections sync.Map // map[string]*DestinationConn where key is destination "ip:port" privateKey wgtypes.Key packetChan chan Packet + ctx context.Context + cancel context.CancelFunc // Session tracking for WireGuard peers // Key format: "senderIndex:receiverIndex" @@ -123,14 +126,17 @@ type UDPProxyServer struct { ReachableAt string } -// NewUDPProxyServer initializes the server with a buffered packet channel. -func NewUDPProxyServer(addr, serverURL string, privateKey wgtypes.Key, reachableAt string) *UDPProxyServer { +// NewUDPProxyServer initializes the server with a buffered packet channel and derived context. +func NewUDPProxyServer(parentCtx context.Context, addr, serverURL string, privateKey wgtypes.Key, reachableAt string) *UDPProxyServer { + ctx, cancel := context.WithCancel(parentCtx) return &UDPProxyServer{ addr: addr, serverURL: serverURL, privateKey: privateKey, packetChan: make(chan Packet, 1000), ReachableAt: reachableAt, + ctx: ctx, + cancel: cancel, } } @@ -177,17 +183,51 @@ func (s *UDPProxyServer) Start() error { } func (s *UDPProxyServer) Stop() { - s.conn.Close() + // Signal all background goroutines to stop + if s.cancel != nil { + s.cancel() + } + // Close listener to unblock reads + if s.conn != nil { + _ = s.conn.Close() + } + // Close all downstream UDP connections + s.connections.Range(func(key, value interface{}) bool { + if dc, ok := value.(*DestinationConn); ok && dc.conn != nil { + _ = dc.conn.Close() + } + return true + }) + // Close packet channel to stop workers + select { + case <-s.ctx.Done(): + default: + } + close(s.packetChan) } // readPackets continuously reads from the UDP socket and pushes packets into the channel. func (s *UDPProxyServer) readPackets() { for { + // Exit promptly if context is canceled + select { + case <-s.ctx.Done(): + return + default: + } buf := bufferPool.Get().([]byte) n, remoteAddr, err := s.conn.ReadFromUDP(buf) if err != nil { - logger.Error("Error reading UDP packet: %v", err) - continue + // If we're shutting down, exit + select { + case <-s.ctx.Done(): + bufferPool.Put(buf[:1500]) + return + default: + logger.Error("Error reading UDP packet: %v", err) + bufferPool.Put(buf[:1500]) + continue + } } s.packetChan <- Packet{data: buf[:n], remoteAddr: remoteAddr, n: n} } @@ -588,49 +628,67 @@ func (s *UDPProxyServer) handleResponses(conn *net.UDPConn, destAddr *net.UDPAdd // Add a cleanup method to periodically remove idle connections func (s *UDPProxyServer) cleanupIdleConnections() { ticker := time.NewTicker(5 * time.Minute) - for range ticker.C { - now := time.Now() - s.connections.Range(func(key, value interface{}) bool { - destConn := value.(*DestinationConn) - if now.Sub(destConn.lastUsed) > 10*time.Minute { - destConn.conn.Close() - s.connections.Delete(key) - } - return true - }) + defer ticker.Stop() + for { + select { + case <-ticker.C: + now := time.Now() + s.connections.Range(func(key, value interface{}) bool { + destConn := value.(*DestinationConn) + if now.Sub(destConn.lastUsed) > 10*time.Minute { + destConn.conn.Close() + s.connections.Delete(key) + } + return true + }) + case <-s.ctx.Done(): + return + } } } // New method to periodically remove idle sessions func (s *UDPProxyServer) cleanupIdleSessions() { ticker := time.NewTicker(5 * time.Minute) - for range ticker.C { - now := time.Now() - s.wgSessions.Range(func(key, value interface{}) bool { - session := value.(*WireGuardSession) - if now.Sub(session.LastSeen) > 15*time.Minute { - s.wgSessions.Delete(key) - logger.Debug("Removed idle session: %s", key) - } - return true - }) + defer ticker.Stop() + for { + select { + case <-ticker.C: + now := time.Now() + s.wgSessions.Range(func(key, value interface{}) bool { + session := value.(*WireGuardSession) + if now.Sub(session.LastSeen) > 15*time.Minute { + s.wgSessions.Delete(key) + logger.Debug("Removed idle session: %s", key) + } + return true + }) + case <-s.ctx.Done(): + return + } } } // New method to periodically remove idle proxy mappings func (s *UDPProxyServer) cleanupIdleProxyMappings() { ticker := time.NewTicker(10 * time.Minute) - for range ticker.C { - now := time.Now() - s.proxyMappings.Range(func(key, value interface{}) bool { - mapping := value.(ProxyMapping) - // Remove mappings that haven't been used in 30 minutes - if now.Sub(mapping.LastUsed) > 30*time.Minute { - s.proxyMappings.Delete(key) - logger.Debug("Removed idle proxy mapping: %s", key) - } - return true - }) + defer ticker.Stop() + for { + select { + case <-ticker.C: + now := time.Now() + s.proxyMappings.Range(func(key, value interface{}) bool { + mapping := value.(ProxyMapping) + // Remove mappings that haven't been used in 30 minutes + if now.Sub(mapping.LastUsed) > 30*time.Minute { + s.proxyMappings.Delete(key) + logger.Debug("Removed idle proxy mapping: %s", key) + } + return true + }) + case <-s.ctx.Done(): + return + } } } @@ -943,23 +1001,29 @@ func (s *UDPProxyServer) tryRebuildSession(pattern *CommunicationPattern) { // cleanupIdleCommunicationPatterns periodically removes idle communication patterns func (s *UDPProxyServer) cleanupIdleCommunicationPatterns() { ticker := time.NewTicker(10 * time.Minute) - for range ticker.C { - now := time.Now() - s.commPatterns.Range(func(key, value interface{}) bool { - pattern := value.(*CommunicationPattern) + defer ticker.Stop() + for { + select { + case <-ticker.C: + now := time.Now() + s.commPatterns.Range(func(key, value interface{}) bool { + pattern := value.(*CommunicationPattern) - // Get the most recent activity - lastActivity := pattern.LastFromClient - if pattern.LastFromDest.After(lastActivity) { - lastActivity = pattern.LastFromDest - } + // Get the most recent activity + lastActivity := pattern.LastFromClient + if pattern.LastFromDest.After(lastActivity) { + lastActivity = pattern.LastFromDest + } - // Remove patterns that haven't had activity in 20 minutes - if now.Sub(lastActivity) > 20*time.Minute { - s.commPatterns.Delete(key) - logger.Debug("Removed idle communication pattern: %s", key) - } - return true - }) + // Remove patterns that haven't had activity in 20 minutes + if now.Sub(lastActivity) > 20*time.Minute { + s.commPatterns.Delete(key) + logger.Debug("Removed idle communication pattern: %s", key) + } + return true + }) + case <-s.ctx.Done(): + return + } } } From b2392fb2503e1019ebc1c948d34007b23a84f322 Mon Sep 17 00:00:00 2001 From: Laurence Date: Sun, 16 Nov 2025 06:07:48 +0000 Subject: [PATCH 14/20] relay: fix buffer leak on UDP read error by returning buffer to pool When ReadFromUDP fails in readPackets, the buffer was not returned to the sync.Pool, causing a small but persistent leak under error conditions. Return the buffer before continuing to ensure reuse and stable memory. Scope: minimal hotfix (no broader refactors). --- relay/relay.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/relay/relay.go b/relay/relay.go index e74ed87..e0a6a98 100644 --- a/relay/relay.go +++ b/relay/relay.go @@ -187,6 +187,8 @@ func (s *UDPProxyServer) readPackets() { n, remoteAddr, err := s.conn.ReadFromUDP(buf) if err != nil { logger.Error("Error reading UDP packet: %v", err) + // Return buffer to pool on read error to avoid leaks + bufferPool.Put(buf[:1500]) continue } s.packetChan <- Packet{data: buf[:n], remoteAddr: remoteAddr, n: n} From bba4345b0fff4c3b93e0802cd1a722a17e674343 Mon Sep 17 00:00:00 2001 From: Laurence Date: Sun, 16 Nov 2025 08:40:26 +0000 Subject: [PATCH 15/20] main: optimize calculatePeerBandwidth to avoid nested peer scans Build a set of current peer public keys during the primary iteration and prune lastReadings in a single pass, removing the O(n^2) nested loop. No behavior change; improves efficiency when peer lists and lastReadings grow large. --- main.go | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/main.go b/main.go index 7a99c4d..71f5b19 100644 --- a/main.go +++ b/main.go @@ -639,7 +639,7 @@ func ensureMSSClamping() error { if out, err := addCmd.CombinedOutput(); err != nil { errMsg := fmt.Sprintf("Failed to add MSS clamping rule for chain %s: %v (output: %s)", chain, err, string(out)) - logger.Error(errMsg) + logger.Error("%s", errMsg) errors = append(errors, fmt.Errorf("%s", errMsg)) continue } @@ -656,7 +656,7 @@ func ensureMSSClamping() error { if out, err := checkCmd.CombinedOutput(); err != nil { errMsg := fmt.Sprintf("Rule verification failed for chain %s: %v (output: %s)", chain, err, string(out)) - logger.Error(errMsg) + logger.Error("%s", errMsg) errors = append(errors, fmt.Errorf("%s", errMsg)) continue } @@ -1003,8 +1003,13 @@ func calculatePeerBandwidth() ([]PeerBandwidth, error) { mu.Lock() defer mu.Unlock() + // Track the set of peers currently present on the device to prune stale readings efficiently + currentPeerKeys := make(map[string]struct{}, len(device.Peers)) + for _, peer := range device.Peers { publicKey := peer.PublicKey.String() + currentPeerKeys[publicKey] = struct{}{} + currentReading := PeerReading{ BytesReceived: peer.ReceiveBytes, BytesTransmitted: peer.TransmitBytes, @@ -1061,14 +1066,7 @@ func calculatePeerBandwidth() ([]PeerBandwidth, error) { // Clean up old peers for publicKey := range lastReadings { - found := false - for _, peer := range device.Peers { - if peer.PublicKey.String() == publicKey { - found = true - break - } - } - if !found { + if _, exists := currentPeerKeys[publicKey]; !exists { delete(lastReadings, publicKey) } } From 971452e5d35dbc6a23f15a7f6e82d0bdc0c87e7c Mon Sep 17 00:00:00 2001 From: Laurence Date: Sun, 16 Nov 2025 08:42:57 +0000 Subject: [PATCH 16/20] revert: drop logger formatting changes from calcpeerbandwidth optimization branch --- main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index 71f5b19..c8578cd 100644 --- a/main.go +++ b/main.go @@ -639,7 +639,7 @@ func ensureMSSClamping() error { if out, err := addCmd.CombinedOutput(); err != nil { errMsg := fmt.Sprintf("Failed to add MSS clamping rule for chain %s: %v (output: %s)", chain, err, string(out)) - logger.Error("%s", errMsg) + logger.Error(errMsg) errors = append(errors, fmt.Errorf("%s", errMsg)) continue } @@ -656,7 +656,7 @@ func ensureMSSClamping() error { if out, err := checkCmd.CombinedOutput(); err != nil { errMsg := fmt.Sprintf("Rule verification failed for chain %s: %v (output: %s)", chain, err, string(out)) - logger.Error("%s", errMsg) + logger.Error(errMsg) errors = append(errors, fmt.Errorf("%s", errMsg)) continue } From b32da3a7143ff5f6ee8463749bba1fe0c5985092 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Nov 2025 21:25:00 +0000 Subject: [PATCH 17/20] Bump golang.org/x/crypto in the prod-minor-updates group Bumps the prod-minor-updates group with 1 update: [golang.org/x/crypto](https://github.com/golang/crypto). Updates `golang.org/x/crypto` from 0.44.0 to 0.45.0 - [Commits](https://github.com/golang/crypto/compare/v0.44.0...v0.45.0) --- updated-dependencies: - dependency-name: golang.org/x/crypto dependency-version: 0.45.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates ... Signed-off-by: dependabot[bot] --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 4fd1aff..a47ae8b 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25 require ( github.com/patrickmn/go-cache v2.1.0+incompatible github.com/vishvananda/netlink v1.3.1 - golang.org/x/crypto v0.44.0 + golang.org/x/crypto v0.45.0 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 ) @@ -16,7 +16,7 @@ require ( github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/socket v0.4.1 // indirect github.com/vishvananda/netns v0.0.5 // indirect - golang.org/x/net v0.46.0 // indirect + golang.org/x/net v0.47.0 // indirect golang.org/x/sync v0.1.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b // indirect diff --git a/go.sum b/go.sum index e6e895c..4b4298f 100644 --- a/go.sum +++ b/go.sum @@ -16,10 +16,10 @@ github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= -golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= -golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= From 72bee564124bd0160d05a1584b3d53a316560669 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 22:19:05 +0000 Subject: [PATCH 18/20] Bump actions/setup-go from 6.0.0 to 6.1.0 Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6.0.0 to 6.1.0. - [Release notes](https://github.com/actions/setup-go/releases) - [Commits](https://github.com/actions/setup-go/compare/44694675825211faa026b3c33043df3e48a5fa00...4dc6199c7b1a012772edbd06daecab0f50c9053c) --- updated-dependencies: - dependency-name: actions/setup-go dependency-version: 6.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/cicd.yml | 2 +- .github/workflows/test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 3e308e0..9ecdc64 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -56,7 +56,7 @@ jobs: shell: bash - name: Install Go - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 with: go-version: 1.25 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9dbce33..1236a7d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Go - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 with: go-version: 1.25 From 39ce0ac4072d8b284e538c7356fe3ca471b3cc2c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 22:19:10 +0000 Subject: [PATCH 19/20] Bump actions/checkout from 5.0.0 to 6.0.0 Bumps [actions/checkout](https://github.com/actions/checkout) from 5.0.0 to 6.0.0. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/08c6903cd8c0fde910a37f88322edcfb5dd907a8...1af3b93b6815bc44a9784bd300feb67ff0d1eeb3) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/cicd.yml | 2 +- .github/workflows/test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 3e308e0..6c8c2ba 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -35,7 +35,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Set up QEMU uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9dbce33..5d5f37e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,7 @@ jobs: runs-on: amd64-runner steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Set up Go uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 From 2f6d62ab45ed4c9ca6196e5cfe84d26981198523 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 21:23:35 +0000 Subject: [PATCH 20/20] Bump alpine from 3.22 to 3.23 in the minor-updates group Bumps the minor-updates group with 1 update: alpine. Updates `alpine` from 3.22 to 3.23 --- updated-dependencies: - dependency-name: alpine dependency-version: '3.23' dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-updates ... Signed-off-by: dependabot[bot] --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 8b94de3..d3ccae2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -o /gerbil # Start a new stage from scratch -FROM alpine:3.22 AS runner +FROM alpine:3.23 AS runner RUN apk add --no-cache iptables iproute2