diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index 88a993d..221bcbf 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -43,7 +43,7 @@ jobs: - name: Dependency Licenses Review run: make check-dependency-licenses - name: Check for spelling errors - uses: crate-ci/typos@8a48f81b6c64dcfea44b3633223084c4be58ac5f # v1 + uses: crate-ci/typos@a8168dc2984a9e2352f183ffe788f0f23a300389 # v1 env: CLICOLOR: "1" - name: Check if source code files have license header diff --git a/.github/workflows/container-registry-pr.yaml b/.github/workflows/container-registry-pr.yaml index 0d120ed..8fd5adb 100644 --- a/.github/workflows/container-registry-pr.yaml +++ b/.github/workflows/container-registry-pr.yaml @@ -31,5 +31,7 @@ jobs: with: context: . push: true - tags: ghcr.io/${{ github.repository }}:pr-${{ github.event.number }} + tags: | + ghcr.io/${{ github.repository }}:pr-${{ github.event.number }} + ghcr.io/${{ github.repository }}:pr-${{ github.event.number }}-${{ github.sha }} platforms: linux/amd64 diff --git a/.gitignore b/.gitignore index 116c9df..89821fc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ /main +/go-pmtud /build/ .idea +.vscode/ +.serena/ diff --git a/Dockerfile b/Dockerfile index 79f65a9..96015ac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,17 +2,20 @@ # # SPDX-License-Identifier: Apache-2.0 -FROM golang:1.26-alpine AS builder +FROM golang:1.27-alpine AS builder +ENV GOTOOLCHAIN=auto WORKDIR /go/src/github.com/sapcc/go-pmtud -ADD go.mod go.sum ./ +COPY go.mod go.sum ./ RUN go mod download -ADD . . -RUN go build -v -o /go-pmtud cmd/go-pmtud/main.go +COPY . . +RUN CGO_ENABLED=0 go build -v -o /go-pmtud cmd/go-pmtud/main.go -FROM ubuntu:noble +FROM alpine:latest AS certs +RUN apk add --no-cache ca-certificates + +FROM scratch LABEL source_repository="https://github.com/sapcc/go-pmtud" -RUN apt-get update && apt-get install -y \ - iptables iproute2 \ - && rm -rf /var/lib/apt/lists/* +COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=builder /go-pmtud /go-pmtud +ENTRYPOINT ["/go-pmtud"] diff --git a/README.md b/README.md index a95f009..5cc00a2 100644 --- a/README.md +++ b/README.md @@ -107,5 +107,17 @@ There is an iptables rule on each node that redirects ICMP Destination Unreachab Important: we need ignore packets from summarized source networks of all nodes in the local cluster to avoid re-sending loops. Use `ignore-networks` option for this. This means a node will not re-send already retransmitted ICMP messages. It will only resend messages that are usually originated by routers on the path. +## Container Lifecycle + +The binary manages firewall state during its runtime: + +**On startup** ([`internal/cmd/command.go:96-105`](internal/cmd/command.go#L96-L105)): The binary calls `firewall.Manager.Setup()`, which: +- Sets `net.ipv4.conf.all.rp_filter=0` and `net.ipv4.conf..rp_filter=0` for each configured interface +- Creates an nftables rule in the `raw` chain (priority -300, prerouting hook) that copies ICMP type 3 code 4 packets from the default-route interface to the configured NFLOG group + +**On shutdown**: A deferred `firewall.Manager.Teardown()` call removes the nftables rule when the binary receives SIGTERM, ensuring cleanup within the pod's termination grace period. + +See [`internal/firewall/`](internal/firewall/) for the implementation (Linux only; non-Linux platforms get a no-op stub). + ## License This project is licensed under the Apache2 License - see the [LICENSE](LICENSE) file for details diff --git a/go.mod b/go.mod index d147f44..ccf2bbd 100644 --- a/go.mod +++ b/go.mod @@ -5,56 +5,69 @@ go 1.27 replace github.com/mdlayher/arp => github.com/sapcc/arp v0.0.0-20250110162920-e2ed913995be require ( - github.com/florianl/go-nflog/v2 v2.2.0 - github.com/go-logr/logr v1.4.3 - github.com/mdlayher/arp v0.0.0-20220512170110-6706a2966875 + github.com/florianl/go-nflog/v2 v2.3.0 + github.com/go-logr/logr v1.4.4 + github.com/google/nftables v0.3.0 + github.com/mdlayher/arp v0.0.0-20260528070854-93566ba168e9 github.com/mdlayher/ethernet v0.0.0-20220221185849-529eae5b6118 github.com/mdlayher/packet v1.1.2 - github.com/prometheus/client_golang v1.23.2 + github.com/prometheus/client_golang v1.24.1 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/vishvananda/netlink v1.3.1 - golang.org/x/net v0.47.0 - k8s.io/api v0.35.0 - k8s.io/apimachinery v0.35.0 - k8s.io/client-go v0.35.0 - sigs.k8s.io/controller-runtime v0.23.1 + golang.org/x/net v0.58.0 + golang.org/x/sys v0.47.0 + k8s.io/api v0.37.0 + k8s.io/apimachinery v0.37.0 + k8s.io/client-go v0.37.0 + sigs.k8s.io/controller-runtime v0.24.1 ) require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch v5.6.0+incompatible // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect + github.com/fxamacker/cbor/v2 v2.9.3 // indirect github.com/go-logr/zapr v1.3.0 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/jsonreference v0.20.2 // indirect - github.com/go-openapi/swag v0.23.0 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/go-openapi/jsonpointer v1.0.0 // indirect + github.com/go-openapi/jsonreference v1.0.1 // indirect + github.com/go-openapi/swag v0.29.1 // indirect + github.com/go-openapi/swag/cmdutils v0.29.1 // indirect + github.com/go-openapi/swag/conv v0.29.1 // indirect + github.com/go-openapi/swag/fileutils v0.29.1 // indirect + github.com/go-openapi/swag/jsonutils v0.29.1 // indirect + github.com/go-openapi/swag/loading v0.29.1 // indirect + github.com/go-openapi/swag/mangling v0.29.1 // indirect + github.com/go-openapi/swag/netutils v0.29.1 // indirect + github.com/go-openapi/swag/pools v0.29.1 // indirect + github.com/go-openapi/swag/stringutils v0.29.1 // indirect + github.com/go-openapi/swag/typeutils v0.29.1 // indirect + github.com/go-openapi/swag/yamlutils v0.29.1 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/google/btree v1.1.3 // indirect - github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/josharian/native v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/mailru/easyjson v0.7.7 // indirect - github.com/mdlayher/netlink v1.7.2 // indirect - github.com/mdlayher/socket v0.5.1 // indirect + github.com/mailru/easyjson v0.9.2 // indirect + github.com/mdlayher/netlink v1.11.2 // indirect + github.com/mdlayher/socket v0.6.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pelletier/go-toml/v2 v2.4.3 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.66.1 // indirect - github.com/prometheus/procfs v0.16.1 // indirect - github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/prometheus/common v0.70.1 // indirect + github.com/prometheus/procfs v0.21.1 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect @@ -63,26 +76,25 @@ require ( github.com/vishvananda/netns v0.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/term v0.37.0 // indirect - golang.org/x/text v0.31.0 // indirect - golang.org/x/time v0.9.0 // indirect - gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/protobuf v1.36.8 // indirect + go.uber.org/zap v1.28.0 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.41.0 // indirect + golang.org/x/time v0.15.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiextensions-apiserver v0.35.0 // indirect - k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect - k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + k8s.io/apiextensions-apiserver v0.37.0 // indirect + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/kube-openapi v0.0.0-20260821135717-be32def86098 // indirect + k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 746bd02..30d9d64 100644 --- a/go.sum +++ b/go.sum @@ -12,38 +12,80 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U= github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/florianl/go-nflog/v2 v2.2.0 h1:25c3fv77e7j5Nr1uLPdvXNHDQpfkbWsfWhg0JYUJQnI= github.com/florianl/go-nflog/v2 v2.2.0/go.mod h1:n6j0kC+qiMz7vsUeFmfB1pInaDSXDO2RFT2C2U/Z81w= +github.com/florianl/go-nflog/v2 v2.3.0 h1:mghOcw09/XPs9dEoR1ffKPNC/tsNRCuhq/2BTz2GRjQ= +github.com/florianl/go-nflog/v2 v2.3.0/go.mod h1:ob11SI7TLgZJVZj356/kiD5DtTzKLw/caENAKeVSgSM= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q= +github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= +github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/jsonreference v1.0.1 h1:4zJ7AmYDKNmD3aSpfPnFNCFA5E80/xMHUNKgydaLh38= +github.com/go-openapi/jsonreference v1.0.1/go.mod h1:dYplQXa6p5lXprLcJ8LE2iU7vNpXsAHDQ5ZAgL+Qx3A= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/swag v0.29.1 h1:C6EeWzUwQtcWEhE9eqBdUubGXxhWY4PlzHMLD7kLaiQ= +github.com/go-openapi/swag v0.29.1/go.mod h1:BzxEXKiPlSXRsRTv1KSBF/BpGKHxA/YciCnr4tv9bvA= +github.com/go-openapi/swag/cmdutils v0.29.1 h1:3DorPGfUdE80BogKY22EzoHBcHMrkVomZMoV7kS4ANY= +github.com/go-openapi/swag/cmdutils v0.29.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.29.1 h1:AC4Eh/5c/eUDOUCzzsRC9ghmFgOSBHeRMGIngY0ZUGA= +github.com/go-openapi/swag/conv v0.29.1/go.mod h1:S1X7/ZrBEZOC0Wc8AGxjbcGS92l3WEjA7aPtpl+RaqM= +github.com/go-openapi/swag/fileutils v0.29.1 h1:ZcPzMceVhU1WPbK6N1G6sNQKdd1CWJlf3cA08UHuoM0= +github.com/go-openapi/swag/fileutils v0.29.1/go.mod h1:/wofKYckbtRl2p3+EwQsosie5CT1B38+dQ+PS579BzI= +github.com/go-openapi/swag/jsonutils v0.29.1 h1:AFCxs0eQZ24/QyfhVHM2t49rMz7Vv3XCsZQI6yrNy+c= +github.com/go-openapi/swag/jsonutils v0.29.1/go.mod h1:u3+sCfJpttDpcmS5kpm0yxL6GK0eWgODsx8Yw8fcqNM= +github.com/go-openapi/swag/loading v0.29.1 h1:FCv5fG8UhTdDJa2R7w+5O9Ekpcbw7tt0nFWvmDKGBjc= +github.com/go-openapi/swag/loading v0.29.1/go.mod h1:N0ESuem4p2oedKal8EJhciqnJ9Q9Wmt83L1CRB3Fouw= +github.com/go-openapi/swag/mangling v0.29.1 h1:lHALtvYCdxVnRl4GrHmFPwfBTZYIObqdGNSKyu/8D6I= +github.com/go-openapi/swag/mangling v0.29.1/go.mod h1:SAop9pB7PUjQ/CGCNf/JmCKTRK+GDO+RqE9UHqC/N6s= +github.com/go-openapi/swag/netutils v0.29.1 h1:IjIvdEP5duKcghFqJEPSUraRnkKYHoM65kTluTu+Jb4= +github.com/go-openapi/swag/netutils v0.29.1/go.mod h1:DUde7x4Bx00k5jYl2AdRpNAO0m7atUvD2x6X+bWkbno= +github.com/go-openapi/swag/pools v0.29.1 h1:NRogYxdEW9SjRM4mkAOji9iefO4MRXq3p/ZJcoQbUKg= +github.com/go-openapi/swag/pools v0.29.1/go.mod h1:leDcaghjkRAhCuCRv9NfJU5f0mjoU3cT/XZObhMk3pc= +github.com/go-openapi/swag/stringutils v0.29.1 h1:1ykunK7iJQk1uOO7+oUH1ukbsK85fFCOiCFMOVSY+F0= +github.com/go-openapi/swag/stringutils v0.29.1/go.mod h1:7fSqZ+z8Qc0tOfAAK0jVa5qFGrnIlRi6n7NeGGrr1vc= +github.com/go-openapi/swag/typeutils v0.29.1 h1:Nzv9nhnlLCRBPQqfOX+7lB6Guju370or8StT+lIOf6M= +github.com/go-openapi/swag/typeutils v0.29.1/go.mod h1:hxpgDZJVBkBsi/d3MIUosafoFdE5exaQRmVp0zwu3YE= +github.com/go-openapi/swag/yamlutils v0.29.1 h1:69w3tsBajm7MR/fejLy7HD/3J68Ys1SeeZMEzZ3w2sk= +github.com/go-openapi/swag/yamlutils v0.29.1/go.mod h1:rgsp3vT/QdWzKwn43CigDwjOGIenPyTZMKnxEM8jZOA= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= +github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -51,6 +93,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg= +github.com/google/nftables v0.3.0/go.mod h1:BCp9FsrbF1Fn/Yu6CLUc9GGZFw/+hsxfluNXXmxBfRM= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -66,6 +110,7 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -77,16 +122,22 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M= +github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mdlayher/ethernet v0.0.0-20220221185849-529eae5b6118 h1:2oDp6OOhLxQ9JBoUuysVz9UZ9uI6oLUbvAZu0x8o+vE= github.com/mdlayher/ethernet v0.0.0-20220221185849-529eae5b6118/go.mod h1:ZFUnHIVchZ9lJoWoEGUg8Q3M4U8aNNWA3CVSUTkW4og= -github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g= -github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= +github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= +github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= +github.com/mdlayher/netlink v1.11.2 h1:HKh2jqe+omdSWcQ88nrT7INE61B0NXfiSPFdgL4YbNI= +github.com/mdlayher/netlink v1.11.2/go.mod h1:uT2Yc/QLaZubzDpZIBi9d4GoeLwtp3x1AMeqSRrK2sA= github.com/mdlayher/packet v1.0.0/go.mod h1:eE7/ctqDhoiRhQ44ko5JZU2zxB88g+JH/6jmnjzPjOU= github.com/mdlayher/packet v1.1.2 h1:3Up1NG6LZrsgDVn6X4L9Ge/iyRyxFEFD9o6Pr3Q1nQY= github.com/mdlayher/packet v1.1.2/go.mod h1:GEu1+n9sG5VtiRE4SydOmX5GTwyyYlteZiFU+x0kew4= github.com/mdlayher/socket v0.2.1/go.mod h1:QLlNPkFR88mRUNQIzRBMfXxwKal8H7u1h3bL1CV+f0E= github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= +github.com/mdlayher/socket v0.6.1 h1:M7uj2NtuujUY4mYr1C57NmfNiRHbkKpnBxO856lsc3A= +github.com/mdlayher/socket v0.6.1/go.mod h1:+/SGtqc9V+5dAuRgQsU0fGBI+oRDiW7O2Obx10OIWfg= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -97,27 +148,41 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/sapcc/arp v0.0.0-20250110162920-e2ed913995be h1:jUfZVDqM2R11EPi25OD2woGTFeQxO/Nfo+P9nPlfUhA= github.com/sapcc/arp v0.0.0-20250110162920-e2ed913995be/go.mod h1:kfOoFJuHWp76v1RgZCb9/gVUc7XdY877S2uVYbNliGc= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= @@ -138,6 +203,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -158,22 +224,35 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -181,20 +260,33 @@ 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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= +gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -207,25 +299,43 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= +k8s.io/api v0.37.0 h1:Z//Vj9N7RA/yS2sDmxyeo7h+RR4zbUrd2vrd3Z0TbB4= +k8s.io/api v0.37.0/go.mod h1:LKXgcJWMc+f4OLbP5SFR8rulEg07zZhpi/zMULiBImk= k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= +k8s.io/apiextensions-apiserver v0.37.0 h1:zRMQ3+/LIE5oZ0tVvXwYHC+dIkSP5cjNWju7AZU1LOI= +k8s.io/apiextensions-apiserver v0.37.0/go.mod h1:HU0PfSBwchHL5iDau6jjt9zU6ryWkDDlaVUiq91NK80= k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/apimachinery v0.37.0 h1:Np2AbDtf8x6RDHiD8T9LbKJ9gaegeVNa8yNm5FuGKm0= +k8s.io/apimachinery v0.37.0/go.mod h1:RN3nhprFSCxOi5Selxd7oMTXOe/c+ZbcE7Im+TS2zkE= k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= +k8s.io/client-go v0.37.0 h1:nsN31fy8wBySuZ+QRnKmrjRSQLOG2rvoGN0tKd12zhQ= +k8s.io/client-go v0.37.0/go.mod h1:FcGqw+Ll/gNQiq+nPGY1Oyt9y7SgDh1d3MW3RFDEbn0= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/kube-openapi v0.0.0-20260821135717-be32def86098 h1:z5+pcu1jTyKK5mNTe2/+x+U6Uuv9jRVOJQLaBJJMpeI= +k8s.io/kube-openapi v0.0.0-20260821135717-be32def86098/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE= +k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM= sigs.k8s.io/controller-runtime v0.23.1 h1:TjJSM80Nf43Mg21+RCy3J70aj/W6KyvDtOlpKf+PupE= sigs.k8s.io/controller-runtime v0.23.1/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= +sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= +sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs= sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.4.2 h1:qdOxHwrl2Kaag1aQEarlYcOA9vSyGCp3CIki3aW8c4Q= +sigs.k8s.io/structured-merge-diff/v6 v6.4.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/cmd/command.go b/internal/cmd/command.go index 9e175ac..94f6519 100644 --- a/internal/cmd/command.go +++ b/internal/cmd/command.go @@ -21,6 +21,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller" conf "github.com/sapcc/go-pmtud/internal/config" + "github.com/sapcc/go-pmtud/internal/firewall" metr "github.com/sapcc/go-pmtud/internal/metrics" "github.com/sapcc/go-pmtud/internal/nflog" "github.com/sapcc/go-pmtud/internal/node" @@ -91,6 +92,18 @@ func runRootCmd(cmd *cobra.Command, args []string) error { o.Development = true }).WithName("runRoot") ctrl.SetLogger(log) + + fw := firewall.New(&cfg, log.WithName("firewall")) + if err := fw.Setup(); err != nil { + log.Error(err, "firewall setup failed") + return err + } + defer func() { + if err := fw.Teardown(); err != nil { + log.Error(err, "firewall teardown failed") + } + }() + managerOpts := manager.Options{ Metrics: metricsserver.Options{BindAddress: cfg.MetricsPort}, HealthProbeBindAddress: cfg.HealthPort, @@ -98,12 +111,12 @@ func runRootCmd(cmd *cobra.Command, args []string) error { restConfig, err := config.GetConfigWithContext(cfg.KubeContext) if err != nil { log.Error(err, "error getting kube config. Exiting.") - os.Exit(1) + return err } mgr, err := manager.New(restConfig, managerOpts) if err != nil { - log.Error(err, "error creating manager. Exiting.") - os.Exit(1) + log.Error(err, "error creating manager.") + return err } // add node-controller diff --git a/internal/firewall/manager.go b/internal/firewall/manager.go new file mode 100644 index 0000000..d0b4849 --- /dev/null +++ b/internal/firewall/manager.go @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + +//go:build linux + +package firewall + +import ( + "errors" + "fmt" + "os" + + "github.com/go-logr/logr" + "github.com/google/nftables" + + "github.com/sapcc/go-pmtud/internal/config" +) + +// Manager owns the host-level firewall state required by go-pmtud: +// rp_filter=0 on the relevant interfaces, and the NFLOG nftables rule. +type Manager struct { + cfg *config.Config + log logr.Logger + fsRoot string // injectable for tests; "/" in production +} + +// New returns a Manager. In production pass cfg and log; fsRoot is set to "/". +func New(cfg *config.Config, log logr.Logger) *Manager { + return &Manager{cfg: cfg, log: log, fsRoot: "/"} +} + +// Setup sets rp_filter=0 and installs the NFLOG nftables rule. +// Must be called after cfg.DefaultInterface and cfg.InterfaceNames are populated +// (i.e. after preRunRootCmd). +func (m *Manager) Setup() error { + if err := m.setupSysctl(); err != nil { + return fmt.Errorf("firewall sysctl setup: %w", err) + } + if err := m.setupNFT(); err != nil { + return fmt.Errorf("firewall nft setup: %w", err) + } + m.log.Info("firewall ready", "iifname", m.cfg.DefaultInterface, "nflog_group", m.cfg.NfGroup) + return nil +} + +// Teardown deletes the pmtud nftables table. rp_filter is not restored. +// Safe to call even if Setup was never called or failed partway through. +func (m *Manager) Teardown() error { + conn, err := nftables.New() + if err != nil { + return fmt.Errorf("firewall teardown: nftables.New: %w", err) + } + table, _, _ := buildNFTObjects(m.cfg.DefaultInterface, m.cfg.NfGroup) + conn.DelTable(table) + if err := conn.Flush(); err != nil { + return fmt.Errorf("firewall teardown: flush: %w", err) + } + m.log.Info("firewall torn down") + return nil +} + +func (m *Manager) setupSysctl() error { + // "all" is mandatory — effective rp_filter is max(all, ). + const allPath = "proc/sys/net/ipv4/conf/all/rp_filter" + m.log.Info("setting sysctl", "path", allPath, "value", 0) + if err := writeSysctl(m.fsRoot, allPath, 0); err != nil { + return fmt.Errorf("write %s: %w", allPath, err) + } + for _, iface := range m.cfg.InterfaceNames { + p := fmt.Sprintf("proc/sys/net/ipv4/conf/%s/rp_filter", iface) + m.log.Info("setting sysctl", "path", p, "value", 0) + if err := writeSysctl(m.fsRoot, p, 0); err != nil { + if errors.Is(err, os.ErrNotExist) { + m.log.Info("sysctl path not found, skipping", "path", p) + continue + } + return fmt.Errorf("write %s: %w", p, err) + } + } + return nil +} + +func (m *Manager) setupNFT() error { + conn, err := nftables.New() + if err != nil { + return fmt.Errorf("nftables.New: %w", err) + } + table, chain, rule := buildNFTObjects(m.cfg.DefaultInterface, m.cfg.NfGroup) + // Delete any existing pmtud table first to make Setup idempotent across restarts. + conn.DelTable(table) + if err := conn.Flush(); err != nil { + // Ignore "no such table" — it just means we're starting fresh. + // use a logger with higher verbosity level to avoid spamming; emit only in debug/trace mode. + m.log.V(1).Info("pre-cleanup flush (ignore if table didn't exist)", "err", err) + } + // Fresh connection after the delete flush. + conn, err = nftables.New() + if err != nil { + return fmt.Errorf("nftables.New (post-cleanup): %w", err) + } + conn.AddTable(table) + conn.AddChain(chain) + conn.AddRule(rule) + if err := conn.Flush(); err != nil { + return fmt.Errorf("flush: %w", err) + } + return nil +} diff --git a/internal/firewall/manager_nonlinux.go b/internal/firewall/manager_nonlinux.go new file mode 100644 index 0000000..0144a9f --- /dev/null +++ b/internal/firewall/manager_nonlinux.go @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + +//go:build !linux + +package firewall + +import ( + "github.com/go-logr/logr" + + "github.com/sapcc/go-pmtud/internal/config" +) + +// Manager owns the host-level firewall state required by go-pmtud. +// On non-Linux platforms, this is a no-op stub. +type Manager struct { + cfg *config.Config + log logr.Logger +} + +// New returns a Manager. +func New(cfg *config.Config, log logr.Logger) *Manager { + return &Manager{cfg: cfg, log: log} +} + +// Setup is a no-op on non-Linux platforms. +func (m *Manager) Setup() error { + return nil +} + +// Teardown is a no-op on non-Linux platforms. +func (m *Manager) Teardown() error { + return nil +} diff --git a/internal/firewall/manager_test.go b/internal/firewall/manager_test.go new file mode 100644 index 0000000..cfd9294 --- /dev/null +++ b/internal/firewall/manager_test.go @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + +//go:build linux + +package firewall + +import ( + "os" + "path/filepath" + "testing" + + "github.com/go-logr/logr" + + "github.com/sapcc/go-pmtud/internal/config" +) + +// makeRoot creates a tempdir that mimics /proc/sys/net/ipv4/conf//rp_filter +// for each of the given interface names (plus "all"). Returns the root path. +func makeRoot(t *testing.T, ifaces ...string) string { + t.Helper() + root := t.TempDir() + for _, iface := range append([]string{"all"}, ifaces...) { + dir := filepath.Join(root, "proc", "sys", "net", "ipv4", "conf", iface) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "rp_filter"), []byte("1\n"), 0644); err != nil { + t.Fatal(err) + } + } + return root +} + +func TestSetupSysctl_HappyPath(t *testing.T) { + root := makeRoot(t, "eth0") + m := &Manager{ + cfg: &config.Config{InterfaceNames: []string{"eth0"}}, + log: logr.Discard(), + fsRoot: root, + } + + if err := m.setupSysctl(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for _, iface := range []string{"all", "eth0"} { + p := filepath.Join(root, "proc", "sys", "net", "ipv4", "conf", iface, "rp_filter") + got, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + if string(got) != "0" { + t.Errorf("iface %s: got %q, want \"0\"", iface, string(got)) + } + } +} + +// TestSetupSysctl_MissingIface verifies that a per-interface path that does not +// exist in procfs is silently skipped (the interface may not be present on every +// node of the DaemonSet). +func TestSetupSysctl_MissingIface(t *testing.T) { + // Only "all" exists; "eth0" does not. + root := makeRoot(t) + m := &Manager{ + cfg: &config.Config{InterfaceNames: []string{"eth0"}}, + log: logr.Discard(), + fsRoot: root, + } + + if err := m.setupSysctl(); err != nil { + t.Fatalf("missing iface should be skipped, got error: %v", err) + } + + // "all" must still have been written. + allPath := filepath.Join(root, "proc", "sys", "net", "ipv4", "conf", "all", "rp_filter") + got, err := os.ReadFile(allPath) + if err != nil { + t.Fatal(err) + } + if string(got) != "0" { + t.Errorf("all/rp_filter: got %q, want \"0\"", string(got)) + } +} + +// TestSetupSysctl_AllMissing verifies that a missing "all/rp_filter" path is fatal. +func TestSetupSysctl_AllMissing(t *testing.T) { + root := t.TempDir() // empty — no procfs entries at all + m := &Manager{ + cfg: &config.Config{}, + log: logr.Discard(), + fsRoot: root, + } + + if err := m.setupSysctl(); err == nil { + t.Fatal("expected error for missing all/rp_filter, got nil") + } +} diff --git a/internal/firewall/rule.go b/internal/firewall/rule.go new file mode 100644 index 0000000..265a745 --- /dev/null +++ b/internal/firewall/rule.go @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + +//go:build linux + +package firewall + +import ( + "github.com/google/nftables" + "github.com/google/nftables/expr" + "golang.org/x/sys/unix" +) + +const tableName = "pmtud" + +// ifnamePad pads a network interface name to 16 bytes (null-terminated), matching +// the kernel's IFNAMSIZ representation used by nftables meta iifname comparisons. +func ifnamePad(name string) []byte { + if len(name) > 15 { + panic("interface name exceeds 15 bytes: " + name) + } + b := make([]byte, 16) + copy(b, name+"\x00") + return b +} + +// buildNFTObjects constructs the nftables table, chain, and rule for the PMTUD +// NFLOG rule. No kernel I/O; safe to call in tests. +// +// Equivalent shell: iptables-nft -t raw -I PREROUTING -i -p icmp +// +// --icmp-type 3/4 -j NFLOG --nflog-group +func buildNFTObjects(iifname string, nfGroup uint16) (*nftables.Table, *nftables.Chain, *nftables.Rule) { + table := &nftables.Table{ + Family: nftables.TableFamilyIPv4, + Name: tableName, + } + chain := &nftables.Chain{ + Name: "prerouting", + Table: table, + Type: nftables.ChainTypeFilter, + Hooknum: nftables.ChainHookPrerouting, + Priority: nftables.ChainPriorityRaw, + } + rule := &nftables.Rule{ + Table: table, + Chain: chain, + Exprs: []expr.Any{ + // meta load iifname => reg 1 + &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, + // cmp eq reg 1 + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ifnamePad(iifname)}, + // meta load l4proto => reg 1 + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, + // cmp eq reg 1 IPPROTO_ICMP (1) + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.IPPROTO_ICMP}}, + // payload load 1b @ transport header + 0 => reg 1 (ICMP type) + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 0, Len: 1}, + // cmp eq reg 1 3 (destination-unreachable) + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{3}}, + // payload load 1b @ transport header + 1 => reg 1 (ICMP code) + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 1, Len: 1}, + // cmp eq reg 1 4 (fragmentation needed) + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{4}}, + // log group (non-terminating NFLOG) + &expr.Log{ + Key: uint32(1 << unix.NFTA_LOG_GROUP), + Group: nfGroup, + }, + }, + } + return table, chain, rule +} diff --git a/internal/firewall/rule_test.go b/internal/firewall/rule_test.go new file mode 100644 index 0000000..92d7ce7 --- /dev/null +++ b/internal/firewall/rule_test.go @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + +//go:build linux + +package firewall + +import ( + "testing" + + "github.com/google/nftables" + "github.com/google/nftables/expr" + "golang.org/x/sys/unix" +) + +func TestBuildNFTObjects(t *testing.T) { + table, chain, rule := buildNFTObjects("eth0", 33) + + // table + if table.Name != "pmtud" { + t.Errorf("table name: got %q, want %q", table.Name, "pmtud") + } + if table.Family != nftables.TableFamilyIPv4 { + t.Errorf("table family: got %v, want TableFamilyIPv4", table.Family) + } + + // chain + if chain.Name != "prerouting" { + t.Errorf("chain name: got %q, want %q", chain.Name, "prerouting") + } + if chain.Type != nftables.ChainTypeFilter { + t.Errorf("chain type: got %v, want ChainTypeFilter", chain.Type) + } + if *chain.Hooknum != *nftables.ChainHookPrerouting { + t.Errorf("chain hook: got %v, want ChainHookPrerouting", *chain.Hooknum) + } + if *chain.Priority != *nftables.ChainPriorityRaw { + t.Errorf("chain priority: got %v, want ChainPriorityRaw (-300)", *chain.Priority) + } + + // rule expressions: [meta iifname, cmp iifname, meta l4proto, cmp icmp, payload type, cmp 3, payload code, cmp 4, log] + if len(rule.Exprs) != 9 { + t.Fatalf("rule expr count: got %d, want 9", len(rule.Exprs)) + } + + // meta iifname => reg 1 + metaIface, ok := rule.Exprs[0].(*expr.Meta) + if !ok || metaIface.Key != expr.MetaKeyIIFNAME || metaIface.Register != 1 { + t.Errorf("expr[0]: want Meta{Key:IIFNAME, Register:1}, got %+v", rule.Exprs[0]) + } + + // cmp eq reg 1 "eth0" + cmpIface, ok := rule.Exprs[1].(*expr.Cmp) + if !ok || cmpIface.Op != expr.CmpOpEq || cmpIface.Register != 1 { + t.Errorf("expr[1]: want Cmp{Op:Eq, Register:1}, got %+v", rule.Exprs[1]) + } + wantIFName := ifnamePad("eth0") + for i, b := range wantIFName { + if cmpIface.Data[i] != b { + t.Errorf("expr[1].Data[%d]: got %x, want %x", i, cmpIface.Data[i], b) + } + } + + // meta l4proto => reg 1 + metaL4, ok := rule.Exprs[2].(*expr.Meta) + if !ok || metaL4.Key != expr.MetaKeyL4PROTO || metaL4.Register != 1 { + t.Errorf("expr[2]: want Meta{Key:L4PROTO, Register:1}, got %+v", rule.Exprs[2]) + } + + // cmp eq reg 1 IPPROTO_ICMP + cmpL4, ok := rule.Exprs[3].(*expr.Cmp) + if !ok || cmpL4.Op != expr.CmpOpEq || cmpL4.Data[0] != unix.IPPROTO_ICMP { + t.Errorf("expr[3]: want Cmp{Op:Eq, Data:[1]}, got %+v", rule.Exprs[3]) + } + + // payload network header offset 9 len 1 (ip protocol field) — wait, we already + // used l4proto for that; exprs[4] is icmp type from transport header offset 0 + payloadType, ok := rule.Exprs[4].(*expr.Payload) + if !ok || payloadType.Base != expr.PayloadBaseTransportHeader || + payloadType.Offset != 0 || payloadType.Len != 1 || payloadType.DestRegister != 1 { + t.Errorf("expr[4]: want Payload{transport,off=0,len=1,reg=1}, got %+v", rule.Exprs[4]) + } + + // cmp eq reg 1 3 (ICMP type destination-unreachable) + cmpType, ok := rule.Exprs[5].(*expr.Cmp) + if !ok || cmpType.Op != expr.CmpOpEq || cmpType.Data[0] != 3 { + t.Errorf("expr[5]: want Cmp{Op:Eq, Data:[3]}, got %+v", rule.Exprs[5]) + } + + // payload transport header offset 1 len 1 (icmp code) + payloadCode, ok := rule.Exprs[6].(*expr.Payload) + if !ok || payloadCode.Base != expr.PayloadBaseTransportHeader || + payloadCode.Offset != 1 || payloadCode.Len != 1 || payloadCode.DestRegister != 1 { + t.Errorf("expr[6]: want Payload{transport,off=1,len=1,reg=1}, got %+v", rule.Exprs[6]) + } + + // cmp eq reg 1 4 (ICMP code frag-needed) + cmpCode, ok := rule.Exprs[7].(*expr.Cmp) + if !ok || cmpCode.Op != expr.CmpOpEq || cmpCode.Data[0] != 4 { + t.Errorf("expr[7]: want Cmp{Op:Eq, Data:[4]}, got %+v", rule.Exprs[7]) + } + + // log group 33 + logExpr, ok := rule.Exprs[8].(*expr.Log) + if !ok { + t.Fatalf("expr[8]: want *expr.Log, got %T", rule.Exprs[8]) + } + if logExpr.Group != 33 { + t.Errorf("log group: got %d, want 33", logExpr.Group) + } + wantKey := uint32(1 << unix.NFTA_LOG_GROUP) + if logExpr.Key != wantKey { + t.Errorf("log key: got %d, want %d", logExpr.Key, wantKey) + } +} + +func TestIfnamePadTooLong(t *testing.T) { + defer func() { + if recover() == nil { + t.Error("ifnamePad should panic for names > 15 bytes") + } + }() + ifnamePad("this_is_a_very_long_interface_name") +} diff --git a/internal/firewall/sysctl.go b/internal/firewall/sysctl.go new file mode 100644 index 0000000..394dcac --- /dev/null +++ b/internal/firewall/sysctl.go @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + +//go:build linux + +package firewall + +import ( + "fmt" + "os" + "path/filepath" +) + +// writeSysctl writes value to fsRoot/path (a /proc/sys-style path, forward-slash separated). +// fsRoot is "/" in production; injectable for tests. +func writeSysctl(fsRoot, path string, value int) error { + full := filepath.Join(fsRoot, filepath.FromSlash(path)) + return os.WriteFile(full, fmt.Appendf(nil, "%d", value), 0644) +} diff --git a/internal/firewall/sysctl_test.go b/internal/firewall/sysctl_test.go new file mode 100644 index 0000000..9fdf142 --- /dev/null +++ b/internal/firewall/sysctl_test.go @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + +//go:build linux + +package firewall + +import ( + "os" + "path/filepath" + "testing" +) + +func TestWriteSysctl(t *testing.T) { + root := t.TempDir() + path := "net/ipv4/conf/all/rp_filter" + full := filepath.Join(root, filepath.FromSlash(path)) + + if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte("1\n"), 0644); err != nil { + t.Fatal(err) + } + + if err := writeSysctl(root, path, 0); err != nil { + t.Fatalf("writeSysctl: %v", err) + } + + got, err := os.ReadFile(full) + if err != nil { + t.Fatal(err) + } + if string(got) != "0" { + t.Errorf("got %q, want %q", string(got), "0") + } +} diff --git a/openspec/changes/in-binary-firewall-lifecycle/design.md b/openspec/changes/in-binary-firewall-lifecycle/design.md new file mode 100644 index 0000000..b8394c6 --- /dev/null +++ b/openspec/changes/in-binary-firewall-lifecycle/design.md @@ -0,0 +1,265 @@ + + +# In-binary firewall lifecycle (drop init container + preStop) + +## Problem + +The runtime image was migrated to `FROM scratch` (see [Dockerfile](../../../Dockerfile)), +which contains no shell and no `iptables`/`ip` binaries. The helm chart +([sapcc/helm-charts `system/go-pmtud`](https://github.com/sapcc/helm-charts/tree/master/system/go-pmtud)) +relies on shell scripts for the packet-capture plumbing: + +- An **init container** (separate `iptables` image) runs `iptables-init.sh`, which: + 1. disables `rp_filter` (`sysctl net.ipv4.conf.all.rp_filter=0` plus one per replication interface), and + 2. installs an NFLOG rule in the `raw`/`PREROUTING` chain that copies incoming + ICMP `frag-needed` packets (type 3, code 4) on the default-route interface to + an NFLOG group. +- A **`preStop` lifecycle hook** on the main pmtud container runs `iptables-stop.sh` + (`#!/usr/bin/env bash`) to delete that NFLOG rule. + +The `preStop` hook executes **inside the pmtud container**. On `scratch` there is no +shell/`env`/`ip`/`iptables-nft`, so the hook fails with `ENOENT` and the NFLOG rule is +never removed on shutdown — leaving stale host `nftables` rules that accumulate on +every pod restart/reschedule. The init container itself is *not* the blocker (it runs +in its own image), but the `preStop` hook is. + +## Goal + +Move the full NFLOG rule lifecycle **and** the `rp_filter` sysctl setup into the +go-pmtud binary, so: + +- the `scratch` image needs no shell or external binaries, and +- the helm chart can drop **both** the init container and the `preStop` hook. + +## Non-goals + +- Restoring `rp_filter` to its prior value on shutdown (see Decisions). +- Changing the packet-capture semantics (same match: ICMP type 3 code 4 on the + default-route interface, copied to the configured NFLOG group). +- Adding new command-line flags or config (the binary already has everything needed). + +## Decisions + +1. **Full lifecycle ownership.** The binary creates the rule on startup and deletes it + on shutdown, using its **own dedicated `nftables` table**. This avoids trying to + pattern-match and delete a rule created by the `iptables-nft` translation layer, + which is fragile. +2. **Move `rp_filter` sysctls into the binary too**, letting the chart drop the init + container entirely. +3. **Do not restore `rp_filter` on shutdown.** Matches current production behavior. + `net.ipv4.conf.all.rp_filter` is shared, node-global state (hostNetwork) that pmtud + does not exclusively own; restoring a remembered value can clobber other writers and + causes `0 → 1 → 0` flapping on every DaemonSet pod cycle, momentarily re-enabling the + reverse-path filtering that pmtud's L3 resend mechanism depends on. `rp_filter=0` is + pmtud's intended steady state on a node that runs it. +4. **Library:** `github.com/google/nftables` — pure-Go, netlink-based, no external + binary, works on `scratch`. The repo already depends on `vishvananda/netlink` for + interface discovery; `google/nftables` is the standard pure-Go choice for rule + management. + +## Design + +### New package `internal/firewall` + +A `Manager` owns the two host mutations, mirroring exactly what the shell scripts did. + +```go +type Manager struct { + Cfg *config.Config + Log logr.Logger + // seams for testing (see Testability) +} + +func New(cfg *config.Config, log logr.Logger) *Manager + +// Setup replaces iptables-init.sh: sets rp_filter=0, then creates the nftables +// table/chain/rule. Idempotent (safe to re-run: table is (re)created cleanly). +func (m *Manager) Setup() error + +// Teardown replaces iptables-stop.sh: deletes the pmtud nftables table. +// Best-effort and idempotent (deleting a non-existent table is not an error). +func (m *Manager) Teardown() error +``` + +#### `Setup()` + +1. **rp_filter:** write `0` to `/proc/sys/net/ipv4/conf/all/rp_filter` and to + `/proc/sys/net/ipv4/conf//rp_filter` for each `cfg.InterfaceNames`. + (Both `all` and per-interface are required because the effective value is + `max(all, )`.) +2. **nftables rule:** + - Table: family `ip` (IPv4), name `pmtud`. + - Chain: base chain `prerouting`, type `filter`, hook `prerouting`, + priority `raw` (-300), policy `accept`. + - Rule (expressions), matching `-i -p icmp -m icmp --icmp-type 3/4 -j NFLOG --nflog-group `: + 1. `meta iifname == cfg.DefaultInterface` + 2. `ip protocol == icmp` (network-header offset 9, len 1, == 1) + 3. ICMP `type == 3` (transport-header offset 0, len 1) + 4. ICMP `code == 4` (transport-header offset 1, len 1) + 5. `log group ` (NFLOG; non-terminating, so packets continue — + matches `-j NFLOG`). + - Applied by (re)adding the table then flushing the connection. To keep `Setup` + idempotent across restarts, delete any existing `pmtud` table first, then add. + +`cfg.DefaultInterface` (default-route interface) and `cfg.InterfaceNames` are already +populated by `preRunRootCmd` via `util.GetDefaultInterface` / `util.GetReplicationInterface`, +and `cfg.NfGroup` is an existing flag — **no new configuration is needed**. + +#### `Teardown()` + +Delete the `pmtud` table (removes its chain and rule in one operation) and flush. +`rp_filter` is intentionally left as-is. + +### Lifecycle integration ([internal/cmd/command.go](../../../internal/cmd/command.go)) + +In `runRootCmd`, before `mgr.Start`: + +```go +fw := firewall.New(&cfg, log.WithName("firewall")) +if err := fw.Setup(); err != nil { + log.Error(err, "firewall setup failed") + return err +} +defer func() { + if err := fw.Teardown(); err != nil { + log.Error(err, "firewall teardown failed") + } +}() + +// ... existing manager/controller wiring ... + +err = mgr.Start(signals.SetupSignalHandler()) +``` + +`signals.SetupSignalHandler()` already cancels the context on SIGTERM/SIGINT, so +`mgr.Start` returns on shutdown and the deferred `Teardown()` runs — comfortably within +the chart's `terminationGracePeriodSeconds: 5`. `Setup()` runs before `mgr.Start` +(replacing the init container's "before main container" ordering) so the rule and +sysctls are in place before the NFLOG reader starts. + +> Note: `Setup` is placed after the `os.Exit`-prone config/manager construction and +> before `mgr.Start`, so the remaining path returns errors (not `os.Exit`) and the +> deferred teardown always runs. + +## Testability + +- **sysctl:** the writer takes an injectable filesystem root (default `/`). Unit test + with a temp dir asserts `0` is written to the correct `.../conf/all/rp_filter` and + per-interface paths — runs unprivileged. +- **nftables construction:** split rule/table/chain **construction** (pure, returns the + `google/nftables` structs) from **application** (the netlink flush). Unit-test the + built expressions against the intended rule without touching the kernel. +- **Integration (optional, skipped by default):** a real `Setup`/`Teardown` round-trip + inside a fresh network namespace, guarded to skip when not running as root + (`CAP_NET_ADMIN`). Default CI (`make build/cover.out` on `ubuntu-latest`, unprivileged) + cannot apply nftables rules, so this test must self-skip there. + +## Companion helm-chart change (separate repo — not in this repo) + +These land in `sapcc/helm-charts` `system/go-pmtud` and **must ship together** with the +image change (otherwise the init container's rule and the binary's rule coexist, causing +duplicate NFLOG delivery and duplicate ICMP resends during rollout): + +- Remove the `initContainers` block (`iptables-init`). +- Remove the `lifecycle.preStop` hook from the pmtud container. +- Remove the `pmtud` configMap volume and its `volumeMounts` from both containers. +- Delete the `go-pmtud-configmap.yaml` template and the `etc/_iptables_init.tpl` / + `etc/_iptables_stop.tpl` scripts. +- Remove `images.iptables` from `values.yaml` (and the iptables image build). +- Keep `securityContext.privileged: true` and `hostNetwork: true` on the pmtud + container (required to write `/proc/sys` and manage nftables). +- Bump `images.pmtud.tag` to the new scratch image built from this branch. + +## Risks / mitigations + +- **Rollout coexistence:** duplicate rules if only one repo is deployed → land both PRs + together; document the ordering in the chart PR. +- **Privileges:** nftables and `/proc/sys` writes require `CAP_NET_ADMIN` / privileged; + already satisfied by the existing pod securityContext. +- **`google/nftables` availability on scratch:** pure-Go, uses netlink sockets only — no + runtime dependency; verified by the goal of a shell-free image. + +## Verification (manual) + +Two review concerns: (1) do ICMP frag-needed packets reach the *right* NFLOG group, and +(2) is the PREROUTING rule correctly placed without interfering with kube-proxy or the CNI? + +**Group correctness is structural** (not coincidental): the write side +([rule.go](../../../internal/firewall/rule.go) `buildNFTObjects` → `expr.Log{Group: nfGroup}`) +and the read side ([internal/nflog/pmtud.go](../../../internal/nflog/pmtud.go) `nflog.Config{Group: cfg.NfGroup}`) +both read the single shared `cfg.NfGroup`, bound once to `--nflog_group` (default 33) +before either component starts. They cannot diverge. + +**Interference risk is LOW by design:** the rule's only action is a *non-terminating*, +verdict-neutral NFLOG in a *dedicated* `ip pmtud` table. It sets no verdict, mutates +nothing, and touches no conntrack, so it cannot drop/DNAT/reroute/reorder any other +table's chains. kube-proxy (tables `nat`/`filter`/`mangle`, or the `kube-proxy` nftables +table) and the common CNIs (Calico/Cilium/Flannel) all scope writes to their own named +tables and never `nft flush ruleset` in normal operation. The one residual operational +risk is an *external* `nft flush ruleset` (reboot / misbehaving tool) wiping the table. + +The commands below verify both concerns end-to-end on a representative node. Substitute +`` (default-route interface) and `` (NFLOG group, default 33). + +### NFLOG delivery + +```bash +# (i) Rule present and correctly shaped +sudo nft list table ip pmtud +# expect: chain prerouting { type filter hook prerouting priority raw; +# iifname "" meta l4proto icmp icmp type destination-unreachable +# icmp code frag-needed log group } + +# (ii) Confirm packets actually hit the rule (temporary counter — does not change match) +sudo nft add rule ip pmtud prerouting iifname "" \ + icmp type destination-unreachable icmp code frag-needed counter +watch -n1 'sudo nft list table ip pmtud' # counter packets/bytes should climb +# ...or a live trace: +sudo nft add rule ip pmtud prerouting meta nftrace set 1 +sudo nft monitor trace +# Generate a real frag-needed (from a peer across a smaller-MTU path) or replay a pcap: +ping -M do -s 2000 +sudo tcpreplay -i fragneeded.pcap + +# (iii) Confirm the NFLOG GROUP delivers to userspace. +# Easiest: the go-pmtud reader already consumes group — watch its logs/metrics: +kubectl -n logs ds/go-pmtud -f +curl -s localhost:/metrics | grep -E 'recv_packets|sent_packets' +# NOTE: libpcap's "nflog:" device only reads group 0, so it will NOT see the default group 33. +``` + +### Interference (kube-proxy / CNI) + +```bash +# Snapshot BEFORE go-pmtud starts, then AFTER, and diff +sudo nft list tables > /tmp/nft_before.txt +sudo iptables-save | grep -c KUBE > /tmp/kube_before.txt +# ... start go-pmtud (Setup runs), then: +sudo nft list tables > /tmp/nft_after.txt +diff /tmp/nft_before.txt /tmp/nft_after.txt # expect ONLY an added: table ip pmtud +sudo iptables-save | grep -c KUBE # must equal /tmp/kube_before.txt + +# kube-proxy / CNI chains untouched +sudo nft list ruleset | grep -iE 'KUBE-|CILIUM|cali-' +# Calico-nftables shares hook prerouting priority raw(-300); equal-priority base chains +# all run independently — coexistence is expected and harmless for an observational rule: +sudo nft list ruleset | grep -iE 'hook prerouting priority (raw|-300)' + +# Dataplane sanity — connectivity and conntrack stay healthy +kubectl run t --image=busybox --restart=Never -it --rm -- wget -qO- http://: +conntrack -S # no insert_failed/drop spikes attributable to pmtud +kubectl get nodes # node stays Ready + +# Teardown removes the whole dedicated table cleanly +sudo nft delete table ip pmtud +``` + +> An automated, self-skipping netns integration test (apply the real rule in a throwaway +> namespace, inject a crafted ICMP type-3/code-4 frame, assert NFLOG delivery, with a +> type-3/code-3 negative control) can be added later behind a `PMTUD_INTEGRATION=1` gate in +> a privileged CI lane. Not required for correctness — the group wiring is structural and +> the manual ladder above closes both concerns empirically. diff --git a/openspec/changes/in-binary-firewall-lifecycle/tasks.md b/openspec/changes/in-binary-firewall-lifecycle/tasks.md new file mode 100644 index 0000000..c7d2bed --- /dev/null +++ b/openspec/changes/in-binary-firewall-lifecycle/tasks.md @@ -0,0 +1,728 @@ + + +# In-binary Firewall Lifecycle — Tasks + +**Goal:** Move NFLOG rule creation/deletion and `rp_filter` sysctl setup into the go-pmtud binary so the `scratch` runtime image needs no shell, and remove the init container and `preStop` hook from the helm chart. + +**Architecture:** A new `internal/firewall` package exposes a `Manager` with `Setup()` (called before `mgr.Start`) and `Teardown()` (deferred, runs on SIGTERM). `Setup` writes `rp_filter=0` to `/proc/sys` and creates a dedicated `pmtud` nftables table with one NFLOG rule; `Teardown` deletes that table. Both repos (`go-pmtud` and `sapcc-helm-charts`) change together. + +**Tech Stack:** `github.com/google/nftables v0.3.0` (already added to `go.mod`), `golang.org/x/sys/unix` for `NFTA_LOG_*` constants. + +## Global Constraints + +- Go 1.26 (`go.mod`) +- License header required on every new `.go` file: `// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company\n// SPDX-License-Identifier: Apache-2.0` +- `CGO_ENABLED=0` — no cgo; all dependencies must be pure-Go +- `github.com/google/nftables v0.3.0` is the nftables library (already in `go.mod` as indirect; promote to direct in Task 1) +- Tests run unprivileged in CI (`make build/cover.out` on `ubuntu-latest`); integration tests touching the kernel must be guarded with `testing.Short()` skip or a root-check skip +- Both repos must land together to avoid duplicate NFLOG rules during rollout + +--- + +### Task 1: Add `google/nftables` as a direct dependency + +**Files:** +- Modify: `go.mod` +- Modify: `go.sum` + +**Interfaces:** +- Produces: `github.com/google/nftables v0.3.0` available as a direct (non-`// indirect`) import + +- [ ] **Step 1: Promote nftables to direct dependency** + +```bash +go get github.com/google/nftables@v0.3.0 +go mod tidy +``` + +- [ ] **Step 2: Verify `go.mod` entry is not marked indirect** + +```bash +grep nftables go.mod +# Expected: github.com/google/nftables v0.3.0 (no "// indirect") +``` + +- [ ] **Step 3: Build to confirm no compilation errors** + +```bash +go build ./... +``` + +- [ ] **Step 4: Commit** + +```bash +git add go.mod go.sum +git commit -m "chore: add github.com/google/nftables as direct dependency" +``` + +--- + +### Task 2: `internal/firewall` package — sysctl writer + +**Files:** +- Create: `internal/firewall/sysctl.go` +- Create: `internal/firewall/sysctl_test.go` + +**Interfaces:** +- Produces: `writeSysctl(fsRoot, path string, value int) error` — writes `value` as a decimal string to `fsRoot+path`. Used by `Manager.Setup` (Task 3). + +The sysctl paths for the rule are: +- `net/ipv4/conf/all/rp_filter` +- `net/ipv4/conf//rp_filter` for each `cfg.InterfaceNames` + +- [ ] **Step 1: Write failing test** + +Create `internal/firewall/sysctl_test.go`: + +```go +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + +package firewall + +import ( + "os" + "path/filepath" + "testing" +) + +func TestWriteSysctl(t *testing.T) { + root := t.TempDir() + path := "net/ipv4/conf/all/rp_filter" + full := filepath.Join(root, filepath.FromSlash(path)) + + if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte("1\n"), 0644); err != nil { + t.Fatal(err) + } + + if err := writeSysctl(root, path, 0); err != nil { + t.Fatalf("writeSysctl: %v", err) + } + + got, err := os.ReadFile(full) + if err != nil { + t.Fatal(err) + } + if string(got) != "0" { + t.Errorf("got %q, want %q", string(got), "0") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +go test ./internal/firewall/ -run TestWriteSysctl -v +# Expected: FAIL — package does not exist yet +``` + +- [ ] **Step 3: Implement `writeSysctl`** + +Create `internal/firewall/sysctl.go`: + +```go +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + +package firewall + +import ( + "fmt" + "os" + "path/filepath" +) + +// writeSysctl writes value to fsRoot/path (a /proc/sys-style path, forward-slash separated). +// fsRoot is "/" in production; injectable for tests. +func writeSysctl(fsRoot, path string, value int) error { + full := filepath.Join(fsRoot, filepath.FromSlash(path)) + return os.WriteFile(full, []byte(fmt.Sprintf("%d", value)), 0644) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +go test ./internal/firewall/ -run TestWriteSysctl -v +# Expected: PASS +``` + +- [ ] **Step 5: Commit** + +```bash +git add internal/firewall/sysctl.go internal/firewall/sysctl_test.go +git commit -m "feat(firewall): add sysctl writer with injectable fs root" +``` + +--- + +### Task 3: `internal/firewall` package — `Manager` struct, `buildRule`, and unit tests + +This task creates the `Manager` type and a pure (kernel-free) `buildRule` helper that constructs the nftables objects. Testing the construction lets CI verify rule correctness without root. + +**Files:** +- Create: `internal/firewall/manager.go` +- Create: `internal/firewall/rule.go` +- Create: `internal/firewall/rule_test.go` + +**Interfaces:** +- Consumes: `writeSysctl` from Task 2 +- Produces: + - `firewall.New(cfg *config.Config, log logr.Logger) *Manager` + - `(*Manager).Setup() error` + - `(*Manager).Teardown() error` + - `buildNFTObjects(iifname string, nfGroup uint16) (*nftables.Table, *nftables.Chain, *nftables.Rule)` — pure constructor, no kernel I/O; used by `Setup` and tested in `rule_test.go` + +- [ ] **Step 1: Write failing unit test for `buildRule`** + +Create `internal/firewall/rule_test.go`: + +```go +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + +package firewall + +import ( + "testing" + + "github.com/google/nftables" + "github.com/google/nftables/expr" + "golang.org/x/sys/unix" +) + +func TestBuildNFTObjects(t *testing.T) { + table, chain, rule := buildNFTObjects("eth0", 33) + + // table + if table.Name != "pmtud" { + t.Errorf("table name: got %q, want %q", table.Name, "pmtud") + } + if table.Family != nftables.TableFamilyIPv4 { + t.Errorf("table family: got %v, want TableFamilyIPv4", table.Family) + } + + // chain + if chain.Name != "prerouting" { + t.Errorf("chain name: got %q, want %q", chain.Name, "prerouting") + } + if chain.Type != nftables.ChainTypeFilter { + t.Errorf("chain type: got %v, want ChainTypeFilter", chain.Type) + } + if *chain.Hooknum != *nftables.ChainHookPrerouting { + t.Errorf("chain hook: got %v, want ChainHookPrerouting", *chain.Hooknum) + } + if *chain.Priority != *nftables.ChainPriorityRaw { + t.Errorf("chain priority: got %v, want ChainPriorityRaw (-300)", *chain.Priority) + } + + // rule expressions: [meta iifname, cmp iifname, meta l4proto, cmp icmp, payload type, cmp 3, payload code, cmp 4, log] + if len(rule.Exprs) != 9 { + t.Fatalf("rule expr count: got %d, want 9", len(rule.Exprs)) + } + + // meta iifname => reg 1 + metaIface, ok := rule.Exprs[0].(*expr.Meta) + if !ok || metaIface.Key != expr.MetaKeyIIFNAME || metaIface.Register != 1 { + t.Errorf("expr[0]: want Meta{Key:IIFNAME, Register:1}, got %+v", rule.Exprs[0]) + } + + // cmp eq reg 1 "eth0" + cmpIface, ok := rule.Exprs[1].(*expr.Cmp) + if !ok || cmpIface.Op != expr.CmpOpEq || cmpIface.Register != 1 { + t.Errorf("expr[1]: want Cmp{Op:Eq, Register:1}, got %+v", rule.Exprs[1]) + } + wantIFName := ifnamePad("eth0") + for i, b := range wantIFName { + if cmpIface.Data[i] != b { + t.Errorf("expr[1].Data[%d]: got %x, want %x", i, cmpIface.Data[i], b) + } + } + + // meta l4proto => reg 1 + metaL4, ok := rule.Exprs[2].(*expr.Meta) + if !ok || metaL4.Key != expr.MetaKeyL4PROTO || metaL4.Register != 1 { + t.Errorf("expr[2]: want Meta{Key:L4PROTO, Register:1}, got %+v", rule.Exprs[2]) + } + + // cmp eq reg 1 IPPROTO_ICMP + cmpL4, ok := rule.Exprs[3].(*expr.Cmp) + if !ok || cmpL4.Op != expr.CmpOpEq || cmpL4.Data[0] != unix.IPPROTO_ICMP { + t.Errorf("expr[3]: want Cmp{Op:Eq, Data:[1]}, got %+v", rule.Exprs[3]) + } + + // payload network header offset 9 len 1 (ip protocol field) — wait, we already + // used l4proto for that; exprs[4] is icmp type from transport header offset 0 + payloadType, ok := rule.Exprs[4].(*expr.Payload) + if !ok || payloadType.Base != expr.PayloadBaseTransportHeader || + payloadType.Offset != 0 || payloadType.Len != 1 || payloadType.DestRegister != 1 { + t.Errorf("expr[4]: want Payload{transport,off=0,len=1,reg=1}, got %+v", rule.Exprs[4]) + } + + // cmp eq reg 1 3 (ICMP type destination-unreachable) + cmpType, ok := rule.Exprs[5].(*expr.Cmp) + if !ok || cmpType.Op != expr.CmpOpEq || cmpType.Data[0] != 3 { + t.Errorf("expr[5]: want Cmp{Op:Eq, Data:[3]}, got %+v", rule.Exprs[5]) + } + + // payload transport header offset 1 len 1 (icmp code) + payloadCode, ok := rule.Exprs[6].(*expr.Payload) + if !ok || payloadCode.Base != expr.PayloadBaseTransportHeader || + payloadCode.Offset != 1 || payloadCode.Len != 1 || payloadCode.DestRegister != 1 { + t.Errorf("expr[6]: want Payload{transport,off=1,len=1,reg=1}, got %+v", rule.Exprs[6]) + } + + // cmp eq reg 1 4 (ICMP code frag-needed) + cmpCode, ok := rule.Exprs[7].(*expr.Cmp) + if !ok || cmpCode.Op != expr.CmpOpEq || cmpCode.Data[0] != 4 { + t.Errorf("expr[7]: want Cmp{Op:Eq, Data:[4]}, got %+v", rule.Exprs[7]) + } + + // log group 33 + logExpr, ok := rule.Exprs[8].(*expr.Log) + if !ok { + t.Fatalf("expr[8]: want *expr.Log, got %T", rule.Exprs[8]) + } + if logExpr.Group != 33 { + t.Errorf("log group: got %d, want 33", logExpr.Group) + } + wantKey := uint32(1 << unix.NFTA_LOG_GROUP) + if logExpr.Key != wantKey { + t.Errorf("log key: got %d, want %d", logExpr.Key, wantKey) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +go test ./internal/firewall/ -run TestBuildNFTObjects -v +# Expected: FAIL — buildNFTObjects not defined +``` + +- [ ] **Step 3: Implement `rule.go` (pure construction, no kernel I/O)** + +Create `internal/firewall/rule.go`: + +```go +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + +package firewall + +import ( + "github.com/google/nftables" + "github.com/google/nftables/expr" + "golang.org/x/sys/unix" +) + +const tableName = "pmtud" + +// ifnamePad pads a network interface name to 16 bytes (null-terminated), matching +// the kernel's IFNAMSIZ representation used by nftables meta iifname comparisons. +func ifnamePad(name string) []byte { + b := make([]byte, 16) + copy(b, name+"\x00") + return b +} + +// buildNFTObjects constructs the nftables table, chain, and rule for the PMTUD +// NFLOG rule. No kernel I/O; safe to call in tests. +// +// Equivalent shell: iptables-nft -t raw -I PREROUTING -i -p icmp +// +// --icmp-type 3/4 -j NFLOG --nflog-group +func buildNFTObjects(iifname string, nfGroup uint16) (*nftables.Table, *nftables.Chain, *nftables.Rule) { + table := &nftables.Table{ + Family: nftables.TableFamilyIPv4, + Name: tableName, + } + chain := &nftables.Chain{ + Name: "prerouting", + Table: table, + Type: nftables.ChainTypeFilter, + Hooknum: nftables.ChainHookPrerouting, + Priority: nftables.ChainPriorityRaw, + } + rule := &nftables.Rule{ + Table: table, + Chain: chain, + Exprs: []expr.Any{ + // meta load iifname => reg 1 + &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, + // cmp eq reg 1 + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ifnamePad(iifname)}, + // meta load l4proto => reg 1 + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, + // cmp eq reg 1 IPPROTO_ICMP (1) + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.IPPROTO_ICMP}}, + // payload load 1b @ transport header + 0 => reg 1 (ICMP type) + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 0, Len: 1}, + // cmp eq reg 1 3 (destination-unreachable) + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{3}}, + // payload load 1b @ transport header + 1 => reg 1 (ICMP code) + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 1, Len: 1}, + // cmp eq reg 1 4 (fragmentation needed) + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{4}}, + // log group (non-terminating NFLOG) + &expr.Log{ + Key: uint32(1 << unix.NFTA_LOG_GROUP), + Group: nfGroup, + }, + }, + } + return table, chain, rule +} +``` + +- [ ] **Step 4: Implement `manager.go`** + +Create `internal/firewall/manager.go`: + +```go +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + +package firewall + +import ( + "fmt" + + "github.com/go-logr/logr" + "github.com/google/nftables" + + "github.com/sapcc/go-pmtud/internal/config" +) + +// Manager owns the host-level firewall state required by go-pmtud: +// rp_filter=0 on the relevant interfaces, and the NFLOG nftables rule. +type Manager struct { + cfg *config.Config + log logr.Logger + fsRoot string // injectable for tests; "/" in production +} + +// New returns a Manager. In production pass cfg and log; fsRoot is set to "/". +func New(cfg *config.Config, log logr.Logger) *Manager { + return &Manager{cfg: cfg, log: log, fsRoot: "/"} +} + +// Setup sets rp_filter=0 and installs the NFLOG nftables rule. +// Must be called after cfg.DefaultInterface and cfg.InterfaceNames are populated +// (i.e. after preRunRootCmd). +func (m *Manager) Setup() error { + if err := m.setupSysctl(); err != nil { + return fmt.Errorf("firewall sysctl setup: %w", err) + } + if err := m.setupNFT(); err != nil { + return fmt.Errorf("firewall nft setup: %w", err) + } + m.log.Info("firewall ready", "iifname", m.cfg.DefaultInterface, "nflog_group", m.cfg.NfGroup) + return nil +} + +// Teardown deletes the pmtud nftables table. rp_filter is not restored. +// Safe to call even if Setup was never called or failed partway through. +func (m *Manager) Teardown() error { + conn, err := nftables.New() + if err != nil { + return fmt.Errorf("firewall teardown: nftables.New: %w", err) + } + table, _, _ := buildNFTObjects(m.cfg.DefaultInterface, m.cfg.NfGroup) + conn.DelTable(table) + if err := conn.Flush(); err != nil { + return fmt.Errorf("firewall teardown: flush: %w", err) + } + m.log.Info("firewall torn down") + return nil +} + +func (m *Manager) setupSysctl() error { + paths := []string{"net/ipv4/conf/all/rp_filter"} + for _, iface := range m.cfg.InterfaceNames { + paths = append(paths, fmt.Sprintf("net/ipv4/conf/%s/rp_filter", iface)) + } + for _, p := range paths { + m.log.Info("setting sysctl", "path", p, "value", 0) + if err := writeSysctl(m.fsRoot, p, 0); err != nil { + return fmt.Errorf("write %s: %w", p, err) + } + } + return nil +} + +func (m *Manager) setupNFT() error { + conn, err := nftables.New() + if err != nil { + return fmt.Errorf("nftables.New: %w", err) + } + table, chain, rule := buildNFTObjects(m.cfg.DefaultInterface, m.cfg.NfGroup) + // Delete any existing pmtud table first to make Setup idempotent across restarts. + conn.DelTable(table) + if err := conn.Flush(); err != nil { + // Ignore "no such table" — it just means we're starting fresh. + m.log.V(1).Info("pre-cleanup flush (ignore if table didn't exist)", "err", err) + } + // Fresh connection after the delete flush. + conn, err = nftables.New() + if err != nil { + return fmt.Errorf("nftables.New (post-cleanup): %w", err) + } + conn.AddTable(table) + conn.AddChain(chain) + conn.AddRule(rule) + if err := conn.Flush(); err != nil { + return fmt.Errorf("flush: %w", err) + } + return nil +} +``` + +- [ ] **Step 5: Run tests** + +```bash +go test ./internal/firewall/ -v +# Expected: all PASS (sysctl + buildNFTObjects tests; no kernel needed) +``` + +- [ ] **Step 6: Build the whole module** + +```bash +go build ./... +``` + +- [ ] **Step 7: Commit** + +```bash +git add internal/firewall/ +git commit -m "feat(firewall): Manager with sysctl setup and nftables NFLOG rule lifecycle" +``` + +--- + +### Task 4: Wire `Manager` into `runRootCmd` + +**Files:** +- Modify: `internal/cmd/command.go` + +**Interfaces:** +- Consumes: `firewall.New`, `(*Manager).Setup`, `(*Manager).Teardown` from Task 3 + +The existing `runRootCmd` calls `mgr.Start(signals.SetupSignalHandler())` which blocks until SIGTERM. The deferred `Teardown` runs immediately after `mgr.Start` returns. + +- [ ] **Step 1: Edit `internal/cmd/command.go`** + +Add the import and the setup/teardown block. The diff below shows exactly what changes: + +Add to the import block: +```go +"github.com/sapcc/go-pmtud/internal/firewall" +``` + +In `runRootCmd`, insert after the `ctrl.SetLogger(log)` line and before `managerOpts := ...`: + +```go +fw := firewall.New(&cfg, log.WithName("firewall")) +if err := fw.Setup(); err != nil { + log.Error(err, "firewall setup failed") + return err +} +defer func() { + if err := fw.Teardown(); err != nil { + log.Error(err, "firewall teardown failed") + } +}() +``` + +The full updated `runRootCmd` beginning should look like: + +```go +func runRootCmd(cmd *cobra.Command, args []string) error { + log := zap.New(func(o *zap.Options) { + o.Development = true + }).WithName("runRoot") + ctrl.SetLogger(log) + + fw := firewall.New(&cfg, log.WithName("firewall")) + if err := fw.Setup(); err != nil { + log.Error(err, "firewall setup failed") + return err + } + defer func() { + if err := fw.Teardown(); err != nil { + log.Error(err, "firewall teardown failed") + } + }() + + managerOpts := manager.Options{ + // ... rest unchanged +``` + +- [ ] **Step 2: Build to verify it compiles** + +```bash +go build ./... +``` + +- [ ] **Step 3: Run all tests** + +```bash +go test ./... +``` + +- [ ] **Step 4: Commit** + +```bash +git add internal/cmd/command.go +git commit -m "feat(cmd): wire firewall.Manager Setup/Teardown around mgr.Start" +``` + +--- + +### Task 5: Helm chart — remove init container, preStop, configmap, and iptables image + +Both files live in `/system/go-pmtud/`. + +**Files:** +- Modify: `templates/go-pmtud-daemonset.yaml` +- Delete: `templates/go-pmtud-configmap.yaml` +- Delete: `templates/etc/_iptables_init.tpl` +- Delete: `templates/etc/_iptables_stop.tpl` +- Modify: `values.yaml` + +**Interfaces:** +- Consumes: the daemonset and values files as they exist in the repo +- Produces: a daemonset with no initContainers, no lifecycle.preStop, no configmap volume, and no iptables image reference + +- [ ] **Step 1: Remove the init container block from `templates/go-pmtud-daemonset.yaml`** + +Delete these lines entirely from the `spec.template.spec` section: + +```yaml + initContainers: + - name: iptables-init + image: "{{ required ".Values.images.iptables.image is missing" .Values.images.iptables.image }}:{{ required ".Values.images.iptables.image is missing" .Values.images.iptables.tag }}" + command: + - /scripts/pmtud/iptables-init.sh + securityContext: + privileged: true + volumeMounts: + - name: pmtud + mountPath: /scripts/pmtud +``` + +- [ ] **Step 2: Remove the `preStop` hook and `volumeMounts` from the pmtud container** + +In the `containers[0]` (pmtud) section, delete the `lifecycle` block: + +```yaml + lifecycle: + preStop: + exec: + command: ["/scripts/pmtud/iptables-stop.sh"] +``` + +And delete its `volumeMounts` block: + +```yaml + volumeMounts: + - name: pmtud + mountPath: /scripts/pmtud +``` + +- [ ] **Step 3: Remove the configmap volume from `spec.template.spec.volumes`** + +Delete: + +```yaml + - name: pmtud + configMap: + name: pmtud + defaultMode: 0744 +``` + +- [ ] **Step 4: Delete template files no longer needed** + +```bash +rm /system/go-pmtud/templates/go-pmtud-configmap.yaml +rm /system/go-pmtud/templates/etc/_iptables_init.tpl +rm /system/go-pmtud/templates/etc/_iptables_stop.tpl +rmdir /system/go-pmtud/templates/etc +``` + +- [ ] **Step 5: Remove `images.iptables` from `values.yaml`** + +Delete the `iptables` entry under `images:`: + +```yaml + iptables: + tag: v20241210113345 +``` + +After deletion the `images` block should contain only: + +```yaml +images: + pmtud: + tag: sha-2d470b4e0140b4b6ac7ab33eb727af1cec7e4907 +``` + +- [ ] **Step 6: Verify the daemonset template is valid YAML (no helm rendering needed)** + +```bash +python3 -c "import yaml, open; yaml.safe_load(open('/system/go-pmtud/templates/go-pmtud-daemonset.yaml').read())" 2>&1 || echo "Note: helm template expressions cause YAML parse errors; check manually for structural issues instead" +# For a structural sanity check without helm: +grep -n 'initContainer\|preStop\|iptables-init\|iptables-stop\|pmtud-configmap\|images.iptables' \ + /system/go-pmtud/templates/go-pmtud-daemonset.yaml +# Expected: no output +``` + +- [ ] **Step 7: Commit in the helm-charts repo** + +```bash +cd +git add system/go-pmtud/ +git commit -m "feat(go-pmtud): remove init container and preStop hook (rule lifecycle now in binary)" +``` + +--- + +### Task 6: Add REUSE/license header to new Go files and run `go mod tidy` + +CI runs `check-license-headers` and `check-dependency-licenses`; this task ensures the new files pass. + +**Files:** +- Verify: `internal/firewall/sysctl.go`, `internal/firewall/sysctl_test.go`, `internal/firewall/manager.go`, `internal/firewall/rule.go`, `internal/firewall/rule_test.go` + +- [ ] **Step 1: Verify all new files have correct SPDX headers** + +```bash +grep -L 'SPDX-FileCopyrightText' internal/firewall/*.go +# Expected: no output (all files have the header) +``` + +- [ ] **Step 2: Run `go mod tidy` to remove any unused indirect entries** + +```bash +go mod tidy +``` + +- [ ] **Step 3: Build and test one final time** + +```bash +go build ./... +go test ./... +``` + +- [ ] **Step 4: Commit if go.mod/go.sum changed** + +```bash +git diff --quiet go.mod go.sum || git commit -m "chore: go mod tidy" go.mod go.sum +```