mirror of
https://github.com/slackhq/nebula.git
synced 2026-08-16 01:46:58 +02:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02471b4121 | |||
| 58ab7250f5 | |||
| 184cdc8586 | |||
| 7d3166a19d | |||
| fe1c5682f0 | |||
| e4cc80aaca | |||
| 16b302c11d | |||
| ab539f8a3f | |||
| b7d83b0500 | |||
| ef95b25fa3 | |||
| 36b38396af | |||
| 2e9117da5b | |||
| a690c904ba | |||
| e028e6bf1a | |||
| 3db406b8ac | |||
| eaad4896c1 | |||
| e6032f81aa | |||
| b041f306cb | |||
| 3a95495c63 | |||
| 873f94f465 | |||
| 72bad1603a | |||
| 0c1ad9bb48 | |||
| 074a123a4b | |||
| 04dea41f74 | |||
| 0d23377c65 | |||
| ffd5249cf5 | |||
| 625f58b84a | |||
| 99c5854e5c | |||
| 3c121e7ab1 | |||
| 6c7ebb0875 | |||
| 110ea8f45c |
@@ -1,34 +0,0 @@
|
||||
name: gofmt
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/gofmt.yml'
|
||||
- '**.go'
|
||||
jobs:
|
||||
|
||||
gofmt:
|
||||
name: Run gofmt
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.25'
|
||||
check-latest: true
|
||||
|
||||
- name: Install goimports
|
||||
run: |
|
||||
go install golang.org/x/tools/cmd/goimports@latest
|
||||
|
||||
- name: gofmt
|
||||
run: |
|
||||
if [ "$(find . -iname '*.go' | grep -v '\.pb\.go$' | xargs goimports -l)" ]
|
||||
then
|
||||
find . -iname '*.go' | grep -v '\.pb\.go$' | xargs goimports -d
|
||||
exit 1
|
||||
fi
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
name: Build Linux/BSD All
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
HAS_SIGNING_CREDS: ${{ secrets.AC_USERNAME != '' }}
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
# be overwritten
|
||||
- name: Checkout code
|
||||
if: ${{ env.HAS_DOCKER_CREDS == 'true' }}
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Download artifacts
|
||||
if: ${{ env.HAS_DOCKER_CREDS == 'true' }}
|
||||
@@ -163,14 +163,17 @@ jobs:
|
||||
mkdir -p build/linux-{amd64,arm64}
|
||||
tar -zxvf artifacts/nebula-linux-amd64.tar.gz -C build/linux-amd64/
|
||||
tar -zxvf artifacts/nebula-linux-arm64.tar.gz -C build/linux-arm64/
|
||||
docker buildx build . --push -f docker/Dockerfile --platform linux/amd64,linux/arm64 --tag "${DOCKER_IMAGE_REPO}:${DOCKER_IMAGE_TAG}" --tag "${DOCKER_IMAGE_REPO}:${GITHUB_REF#refs/tags/v}"
|
||||
docker buildx build . --push -f docker/Dockerfile --platform linux/amd64,linux/arm64 \
|
||||
--build-arg VERSION="${GITHUB_REF#refs/tags/v}" \
|
||||
--build-arg REVISION="${GITHUB_SHA}" \
|
||||
--tag "${DOCKER_IMAGE_REPO}:${DOCKER_IMAGE_TAG}" --tag "${DOCKER_IMAGE_REPO}:${GITHUB_REF#refs/tags/v}"
|
||||
|
||||
release:
|
||||
name: Create and Upload Release
|
||||
needs: [build-linux, build-darwin, build-windows]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
VAGRANT_DEFAULT_PROVIDER: libvirt
|
||||
steps:
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
VAGRANT_DEFAULT_PROVIDER: virtualbox
|
||||
steps:
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
@@ -36,6 +36,14 @@ jobs:
|
||||
working-directory: ./.github/workflows/smoke
|
||||
run: ./smoke.sh
|
||||
|
||||
- name: setup docker image ipv6
|
||||
working-directory: ./.github/workflows/smoke
|
||||
run: SMOKE_OVERLAY_IPV6=1 ./build.sh
|
||||
|
||||
- name: run smoke ipv6
|
||||
working-directory: ./.github/workflows/smoke
|
||||
run: SMOKE_OVERLAY_IPV6=1 ./smoke.sh
|
||||
|
||||
- name: setup relay docker image
|
||||
working-directory: ./.github/workflows/smoke
|
||||
run: ./build-relay.sh
|
||||
|
||||
@@ -5,6 +5,19 @@ set -e -x
|
||||
rm -rf ./build
|
||||
mkdir ./build
|
||||
|
||||
if [ "$SMOKE_OVERLAY_IPV6" ]
|
||||
then
|
||||
LIGHTHOUSE_NIP="fd00:4242:0:0:0:ffff:c0a8:6401"
|
||||
HOST2_NIP="fd00:4242:0:0:0:ffff:c0a8:6402"
|
||||
HOST3_NIP="fd00:4242:0:0:0:ffff:c0a8:6403"
|
||||
HOST4_NIP="fd00:4242:0:0:0:ffff:c0a8:6404"
|
||||
else
|
||||
LIGHTHOUSE_NIP="192.168.100.1"
|
||||
HOST2_NIP="192.168.100.2"
|
||||
HOST3_NIP="192.168.100.3"
|
||||
HOST4_NIP="192.168.100.4"
|
||||
fi
|
||||
|
||||
# Smoke containers run on a dedicated docker network whose subnet is allocated
|
||||
# at smoke time, not known at build time. Configs are written with TEST-NET-3
|
||||
# placeholder IPs (RFC 5737) and smoke.sh / smoke-vagrant.sh / smoke-relay.sh
|
||||
@@ -31,24 +44,24 @@ LIGHTHOUSE_IP="203.0.113.2"
|
||||
../genconfig.sh >lighthouse1.yml
|
||||
|
||||
HOST="host2" \
|
||||
LIGHTHOUSES="192.168.100.1 $LIGHTHOUSE_IP:4242" \
|
||||
LIGHTHOUSES="$LIGHTHOUSE_NIP $LIGHTHOUSE_IP:4242" \
|
||||
../genconfig.sh >host2.yml
|
||||
|
||||
HOST="host3" \
|
||||
LIGHTHOUSES="192.168.100.1 $LIGHTHOUSE_IP:4242" \
|
||||
LIGHTHOUSES="$LIGHTHOUSE_NIP $LIGHTHOUSE_IP:4242" \
|
||||
INBOUND='[{"port": "any", "proto": "icmp", "group": "lighthouse"}]' \
|
||||
../genconfig.sh >host3.yml
|
||||
|
||||
HOST="host4" \
|
||||
LIGHTHOUSES="192.168.100.1 $LIGHTHOUSE_IP:4242" \
|
||||
LIGHTHOUSES="$LIGHTHOUSE_NIP $LIGHTHOUSE_IP:4242" \
|
||||
OUTBOUND='[{"port": "any", "proto": "icmp", "group": "lighthouse"}]' \
|
||||
../genconfig.sh >host4.yml
|
||||
|
||||
../../../../nebula-cert ca -curve "${CURVE:-25519}" -name "Smoke Test"
|
||||
../../../../nebula-cert sign -name "lighthouse1" -groups "lighthouse,lighthouse1" -ip "192.168.100.1/24"
|
||||
../../../../nebula-cert sign -name "host2" -groups "host,host2" -ip "192.168.100.2/24"
|
||||
../../../../nebula-cert sign -name "host3" -groups "host,host3" -ip "192.168.100.3/24"
|
||||
../../../../nebula-cert sign -name "host4" -groups "host,host4" -ip "192.168.100.4/24"
|
||||
../../../../nebula-cert sign -name "lighthouse1" -groups "lighthouse,lighthouse1" -ip "$LIGHTHOUSE_NIP/24"
|
||||
../../../../nebula-cert sign -name "host2" -groups "host,host2" -ip "$HOST2_NIP/24"
|
||||
../../../../nebula-cert sign -name "host3" -groups "host,host3" -ip "$HOST3_NIP/24"
|
||||
../../../../nebula-cert sign -name "host4" -groups "host,host4" -ip "$HOST4_NIP/24"
|
||||
)
|
||||
|
||||
docker build -t "nebula:${NAME:-smoke}" .
|
||||
|
||||
@@ -47,6 +47,19 @@ HOST2_IP="$PREFIX.3"
|
||||
HOST3_IP="$PREFIX.4"
|
||||
HOST4_IP="$PREFIX.5"
|
||||
|
||||
if [ "$SMOKE_OVERLAY_IPV6" ]
|
||||
then
|
||||
LIGHTHOUSE_NIP="fd00:4242:0:0:0:ffff:c0a8:6401"
|
||||
HOST2_NIP="fd00:4242:0:0:0:ffff:c0a8:6402"
|
||||
HOST3_NIP="fd00:4242:0:0:0:ffff:c0a8:6403"
|
||||
HOST4_NIP="fd00:4242:0:0:0:ffff:c0a8:6404"
|
||||
else
|
||||
LIGHTHOUSE_NIP="192.168.100.1"
|
||||
HOST2_NIP="192.168.100.2"
|
||||
HOST3_NIP="192.168.100.3"
|
||||
HOST4_NIP="192.168.100.4"
|
||||
fi
|
||||
|
||||
# Sed the placeholder TEST-NET-3 IPs in the host configs to the real ones.
|
||||
# build/lighthouse1.yml has no IPs to rewrite so it's skipped.
|
||||
for f in build/host2.yml build/host3.yml build/host4.yml; do
|
||||
@@ -80,28 +93,28 @@ docker exec host3 tcpdump -i eth0 -q -w - -U 2>logs/host3.outside.log >logs/host
|
||||
docker exec host4 tcpdump -i tun0 -q -w - -U 2>logs/host4.inside.log >logs/host4.inside.pcap &
|
||||
docker exec host4 tcpdump -i eth0 -q -w - -U 2>logs/host4.outside.log >logs/host4.outside.pcap &
|
||||
|
||||
docker exec host2 ncat -nklv 0.0.0.0 2000 &
|
||||
docker exec host3 ncat -nklv 0.0.0.0 2000 &
|
||||
docker exec host4 ncat -e '/usr/bin/echo helloagainfromhost4' -nkluv 0.0.0.0 4000 &
|
||||
docker exec host2 ncat -e '/usr/bin/echo host2' -nkluv 0.0.0.0 3000 &
|
||||
docker exec host3 ncat -e '/usr/bin/echo host3' -nkluv 0.0.0.0 3000 &
|
||||
docker exec host2 ncat -nklv 2000 &
|
||||
docker exec host3 ncat -nklv 2000 &
|
||||
docker exec host4 ncat -e '/usr/bin/echo helloagainfromhost4' -nkluv 4000 &
|
||||
docker exec host2 ncat -e '/usr/bin/echo host2' -nkluv 3000 &
|
||||
docker exec host3 ncat -e '/usr/bin/echo host3' -nkluv 3000 &
|
||||
|
||||
set +x
|
||||
echo
|
||||
echo " *** Testing ping from lighthouse1"
|
||||
echo
|
||||
set -x
|
||||
docker exec lighthouse1 ping -c1 192.168.100.2
|
||||
docker exec lighthouse1 ping -c1 192.168.100.3
|
||||
docker exec lighthouse1 ping -c1 $HOST2_NIP
|
||||
docker exec lighthouse1 ping -c1 $HOST3_NIP
|
||||
|
||||
set +x
|
||||
echo
|
||||
echo " *** Testing ping from host2"
|
||||
echo
|
||||
set -x
|
||||
docker exec host2 ping -c1 192.168.100.1
|
||||
docker exec host2 ping -c1 $LIGHTHOUSE_NIP
|
||||
# Should fail because not allowed by host3 inbound firewall
|
||||
! docker exec host2 ping -c1 192.168.100.3 -w5 || exit 1
|
||||
! docker exec host2 ping -c1 $HOST3_NIP -w5 || exit 1
|
||||
|
||||
set +x
|
||||
echo
|
||||
@@ -109,34 +122,34 @@ echo " *** Testing ncat from host2"
|
||||
echo
|
||||
set -x
|
||||
# Should fail because not allowed by host3 inbound firewall
|
||||
! docker exec host2 ncat -nzv -w5 192.168.100.3 2000 || exit 1
|
||||
! docker exec host2 ncat -nzuv -w5 192.168.100.3 3000 | grep -q host3 || exit 1
|
||||
! docker exec host2 ncat -nzv -w5 $HOST3_NIP 2000 || exit 1
|
||||
! docker exec host2 ncat -nzuv -w5 $HOST3_NIP 3000 | grep -q host3 || exit 1
|
||||
|
||||
set +x
|
||||
echo
|
||||
echo " *** Testing ping from host3"
|
||||
echo
|
||||
set -x
|
||||
docker exec host3 ping -c1 192.168.100.1
|
||||
docker exec host3 ping -c1 192.168.100.2
|
||||
docker exec host3 ping -c1 $LIGHTHOUSE_NIP
|
||||
docker exec host3 ping -c1 $HOST2_NIP
|
||||
|
||||
set +x
|
||||
echo
|
||||
echo " *** Testing ncat from host3"
|
||||
echo
|
||||
set -x
|
||||
docker exec host3 ncat -nzv -w5 192.168.100.2 2000
|
||||
docker exec host3 ncat -nzuv -w5 192.168.100.2 3000 | grep -q host2
|
||||
docker exec host3 ncat -nzv -w5 $HOST2_NIP 2000
|
||||
docker exec host3 ncat -nzuv -w5 $HOST2_NIP 3000 | grep -q host2
|
||||
|
||||
set +x
|
||||
echo
|
||||
echo " *** Testing ping from host4"
|
||||
echo
|
||||
set -x
|
||||
docker exec host4 ping -c1 192.168.100.1
|
||||
docker exec host4 ping -c1 $LIGHTHOUSE_NIP
|
||||
# Should fail because not allowed by host4 outbound firewall
|
||||
! docker exec host4 ping -c1 192.168.100.2 -w5 || exit 1
|
||||
! docker exec host4 ping -c1 192.168.100.3 -w5 || exit 1
|
||||
! docker exec host4 ping -c1 $HOST2_NIP -w5 || exit 1
|
||||
! docker exec host4 ping -c1 $HOST3_NIP -w5 || exit 1
|
||||
|
||||
set +x
|
||||
echo
|
||||
@@ -144,10 +157,10 @@ echo " *** Testing ncat from host4"
|
||||
echo
|
||||
set -x
|
||||
# Should fail because not allowed by host4 outbound firewall
|
||||
! docker exec host4 ncat -nzv -w5 192.168.100.2 2000 || exit 1
|
||||
! docker exec host4 ncat -nzv -w5 192.168.100.3 2000 || exit 1
|
||||
! docker exec host4 ncat -nzuv -w5 192.168.100.2 3000 | grep -q host2 || exit 1
|
||||
! docker exec host4 ncat -nzuv -w5 192.168.100.3 3000 | grep -q host3 || exit 1
|
||||
! docker exec host4 ncat -nzv -w5 $HOST2_NIP 2000 || exit 1
|
||||
! docker exec host4 ncat -nzv -w5 $HOST3_NIP 2000 || exit 1
|
||||
! docker exec host4 ncat -nzuv -w5 $HOST2_NIP 3000 | grep -q host2 || exit 1
|
||||
! docker exec host4 ncat -nzuv -w5 $HOST3_NIP 3000 | grep -q host3 || exit 1
|
||||
|
||||
set +x
|
||||
echo
|
||||
@@ -159,7 +172,7 @@ set -x
|
||||
# cannot initiate UDP to host2. Once host2 initiates a flow to host4:4000,
|
||||
# conntrack must let host4's listener reply on that flow. If it doesn't,
|
||||
# the echo back from host4 never reaches host2.
|
||||
docker exec host2 sh -c "(/usr/bin/echo host2; sleep 2) | ncat -nuv 192.168.100.4 4000" | grep -q helloagainfromhost4
|
||||
docker exec host2 sh -c "(/usr/bin/echo host2; sleep 2) | ncat -nuv $HOST4_NIP 4000" | grep -q helloagainfromhost4
|
||||
|
||||
docker exec host4 sh -c 'kill 1'
|
||||
docker exec host3 sh -c 'kill 1'
|
||||
|
||||
+96
-78
@@ -13,20 +13,28 @@ on:
|
||||
- 'go.sum'
|
||||
jobs:
|
||||
|
||||
test-linux:
|
||||
name: Build all and test on ubuntu-linux
|
||||
static:
|
||||
name: Static checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.25'
|
||||
check-latest: true
|
||||
|
||||
- name: Build
|
||||
run: make all
|
||||
- name: Install goimports
|
||||
run: go install golang.org/x/tools/cmd/goimports@latest
|
||||
|
||||
- name: gofmt
|
||||
run: |
|
||||
if [ "$(find . -iname '*.go' | grep -v '\.pb\.go$' | xargs goimports -l)" ]
|
||||
then
|
||||
find . -iname '*.go' | grep -v '\.pb\.go$' | xargs goimports -d
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Vet
|
||||
run: make vet
|
||||
@@ -36,97 +44,107 @@ jobs:
|
||||
with:
|
||||
version: v2.5
|
||||
|
||||
- name: Test
|
||||
run: make test
|
||||
|
||||
- name: End 2 end
|
||||
run: make e2evv
|
||||
|
||||
- name: Build test mobile
|
||||
run: make build-test-mobile
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: e2e packet flow linux-latest
|
||||
path: e2e/mermaid/linux-latest
|
||||
if-no-files-found: warn
|
||||
|
||||
test-linux-boringcrypto:
|
||||
name: Build and test on linux with boringcrypto
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.25'
|
||||
check-latest: true
|
||||
|
||||
- name: Build
|
||||
run: make bin-boringcrypto
|
||||
|
||||
- name: Test
|
||||
run: make test-boringcrypto
|
||||
|
||||
- name: End 2 end
|
||||
run: make e2e GOEXPERIMENT=boringcrypto CGO_ENABLED=1 TEST_ENV="TEST_LOGS=1" TEST_FLAGS="-v -ldflags -checklinkname=0"
|
||||
|
||||
test-linux-pkcs11:
|
||||
name: Build and test on linux with pkcs11
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.25'
|
||||
check-latest: true
|
||||
|
||||
- name: Build
|
||||
run: make bin-pkcs11
|
||||
|
||||
- name: Test
|
||||
run: make test-pkcs11
|
||||
|
||||
test:
|
||||
name: Build and test on ${{ matrix.os }}
|
||||
name: Test ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, macos-latest]
|
||||
include:
|
||||
- name: linux
|
||||
os: ubuntu-latest
|
||||
build-cmd: go build ./cmd/nebula ./cmd/nebula-cert
|
||||
test-cmd: make test
|
||||
e2e-cmd: make e2evv
|
||||
- name: linux-boringcrypto
|
||||
os: ubuntu-latest
|
||||
build-cmd: make bin-boringcrypto
|
||||
test-cmd: make test-boringcrypto
|
||||
e2e-cmd: make e2e GOEXPERIMENT=boringcrypto CGO_ENABLED=1 TEST_ENV="TEST_LOGS=1" TEST_FLAGS="-v -ldflags -checklinkname=0"
|
||||
- name: linux-pkcs11
|
||||
os: ubuntu-latest
|
||||
build-cmd: make bin-pkcs11
|
||||
test-cmd: make test-pkcs11
|
||||
e2e-cmd: ''
|
||||
- name: macos
|
||||
os: macos-latest
|
||||
build-cmd: go build ./cmd/nebula ./cmd/nebula-cert
|
||||
test-cmd: make test
|
||||
e2e-cmd: make e2evv
|
||||
- name: windows
|
||||
os: windows-latest
|
||||
build-cmd: go build ./cmd/nebula ./cmd/nebula-cert
|
||||
test-cmd: make test
|
||||
e2e-cmd: make e2evv
|
||||
steps:
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.25'
|
||||
check-latest: true
|
||||
|
||||
- name: Build nebula
|
||||
run: go build ./cmd/nebula
|
||||
- name: Build
|
||||
run: ${{ matrix.build-cmd }}
|
||||
|
||||
- name: Build nebula-cert
|
||||
run: go build ./cmd/nebula-cert
|
||||
|
||||
- name: Vet
|
||||
run: make vet
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: v2.5
|
||||
- name: Cross-build darwin-amd64
|
||||
if: matrix.name == 'macos'
|
||||
run: GOARCH=amd64 go build -o /tmp/nebula-amd64 ./cmd/nebula && GOARCH=amd64 go build -o /tmp/nebula-cert-amd64 ./cmd/nebula-cert
|
||||
|
||||
- name: Test
|
||||
run: make test
|
||||
run: ${{ matrix.test-cmd }}
|
||||
|
||||
- name: End 2 end
|
||||
run: make e2evv
|
||||
if: matrix.e2e-cmd != ''
|
||||
run: ${{ matrix.e2e-cmd }}
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: matrix.e2e-cmd != '' && always()
|
||||
with:
|
||||
name: e2e packet flow ${{ matrix.os }}
|
||||
path: e2e/mermaid/${{ matrix.os }}
|
||||
name: e2e packet flow ${{ matrix.name }}
|
||||
path: e2e/mermaid/
|
||||
if-no-files-found: warn
|
||||
|
||||
cross-build:
|
||||
name: Cross-build ${{ matrix.name }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- {name: linux-arm, make-target: all-cross-linux-arm}
|
||||
- {name: linux-mips, make-target: all-cross-linux-mips}
|
||||
- {name: linux-other, make-target: all-cross-linux-other}
|
||||
- {name: freebsd, make-target: all-freebsd}
|
||||
- {name: openbsd, make-target: all-openbsd}
|
||||
- {name: netbsd, make-target: all-netbsd}
|
||||
- {name: windows, make-target: all-cross-windows}
|
||||
- {name: mobile, make-target: build-test-mobile}
|
||||
steps:
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.25'
|
||||
check-latest: true
|
||||
|
||||
- name: Build ${{ matrix.name }}
|
||||
run: make -j"$(nproc)" ${{ matrix.make-target }}
|
||||
|
||||
finish:
|
||||
name: CI status
|
||||
if: always()
|
||||
needs: [static, test, cross-build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- name: Fail if any upstream job failed
|
||||
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
|
||||
run: |
|
||||
echo "upstream results: ${{ toJSON(needs) }}"
|
||||
exit 1
|
||||
|
||||
- name: All upstream jobs passed
|
||||
run: echo "ok"
|
||||
|
||||
@@ -60,6 +60,18 @@ ALL = $(ALL_LINUX) \
|
||||
windows-amd64 \
|
||||
windows-arm64
|
||||
|
||||
# Cross-build shards used by .github/workflows/test.yml — same as ALL_*
|
||||
# but with the arch that has a native CI runner removed, so the cross-build
|
||||
# job is not duplicating coverage the native test jobs already give.
|
||||
ALL_CROSS_LINUX = $(filter-out linux-amd64,$(ALL_LINUX))
|
||||
|
||||
# ALL_CROSS_LINUX further split into family sub-shards so each can run on
|
||||
# its own CI runner in parallel. Union of the three must equal
|
||||
# ALL_CROSS_LINUX; adding a new linux arch goes into the matching family.
|
||||
ALL_CROSS_LINUX_ARM = linux-arm-5 linux-arm-6 linux-arm-7 linux-arm64
|
||||
ALL_CROSS_LINUX_MIPS = linux-mips linux-mipsle linux-mips64 linux-mips64le linux-mips-softfloat
|
||||
ALL_CROSS_LINUX_OTHER = linux-386 linux-ppc64le linux-riscv64 linux-loong64
|
||||
|
||||
e2e:
|
||||
$(TEST_ENV) go test -tags=e2e_testing -count=1 $(TEST_FLAGS) ./e2e
|
||||
|
||||
@@ -82,6 +94,35 @@ DOCKER_BIN = build/linux-amd64/nebula build/linux-amd64/nebula-cert
|
||||
|
||||
all: $(ALL:%=build/%/nebula) $(ALL:%=build/%/nebula-cert)
|
||||
|
||||
all-linux: $(ALL_LINUX:%=build/%/nebula) $(ALL_LINUX:%=build/%/nebula-cert)
|
||||
|
||||
all-freebsd: $(ALL_FREEBSD:%=build/%/nebula) $(ALL_FREEBSD:%=build/%/nebula-cert)
|
||||
|
||||
all-openbsd: $(ALL_OPENBSD:%=build/%/nebula) $(ALL_OPENBSD:%=build/%/nebula-cert)
|
||||
|
||||
all-netbsd: $(ALL_NETBSD:%=build/%/nebula) $(ALL_NETBSD:%=build/%/nebula-cert)
|
||||
|
||||
all-darwin: build/darwin-amd64/nebula build/darwin-amd64/nebula-cert build/darwin-arm64/nebula build/darwin-arm64/nebula-cert
|
||||
|
||||
all-windows: build/windows-amd64/nebula.exe build/windows-amd64/nebula-cert.exe build/windows-arm64/nebula.exe build/windows-arm64/nebula-cert.exe
|
||||
|
||||
# CI cross-build shards. darwin-arm64 is covered by the native macos-latest
|
||||
# job; windows-amd64 is covered by the native windows-latest job; both are
|
||||
# omitted here to avoid building them a second time. darwin-amd64 stays in
|
||||
# all-cross-darwin because intel mac is only a labeled/master-time native
|
||||
# job, so PRs still need cross-build coverage for it.
|
||||
all-cross-linux: $(ALL_CROSS_LINUX:%=build/%/nebula) $(ALL_CROSS_LINUX:%=build/%/nebula-cert)
|
||||
|
||||
all-cross-linux-arm: $(ALL_CROSS_LINUX_ARM:%=build/%/nebula) $(ALL_CROSS_LINUX_ARM:%=build/%/nebula-cert)
|
||||
|
||||
all-cross-linux-mips: $(ALL_CROSS_LINUX_MIPS:%=build/%/nebula) $(ALL_CROSS_LINUX_MIPS:%=build/%/nebula-cert)
|
||||
|
||||
all-cross-linux-other: $(ALL_CROSS_LINUX_OTHER:%=build/%/nebula) $(ALL_CROSS_LINUX_OTHER:%=build/%/nebula-cert)
|
||||
|
||||
all-cross-darwin: build/darwin-amd64/nebula build/darwin-amd64/nebula-cert
|
||||
|
||||
all-cross-windows: build/windows-arm64/nebula.exe build/windows-arm64/nebula-cert.exe
|
||||
|
||||
docker: docker/linux-$(shell go env GOARCH)
|
||||
|
||||
release: $(ALL:%=build/nebula-%.tar.gz)
|
||||
@@ -227,6 +268,9 @@ smoke-relay-docker: bin-docker
|
||||
cd .github/workflows/smoke/ && ./build-relay.sh
|
||||
cd .github/workflows/smoke/ && ./smoke-relay.sh
|
||||
|
||||
smoke-docker-ipv6: export SMOKE_OVERLAY_IPV6 = 1
|
||||
smoke-docker-ipv6: smoke-docker
|
||||
|
||||
smoke-docker-race: BUILD_ARGS = -race
|
||||
smoke-docker-race: CGO_ENABLED = 1
|
||||
smoke-docker-race: smoke-docker
|
||||
@@ -236,5 +280,5 @@ smoke-vagrant/%: bin-docker build/%/nebula
|
||||
cd .github/workflows/smoke/ && ./smoke-vagrant.sh $*
|
||||
|
||||
.FORCE:
|
||||
.PHONY: bench bench-cpu bench-cpu-long bin build-test-mobile e2e e2ev e2evv e2evvv e2evvvv proto release service smoke-docker smoke-docker-race test test-cov-html smoke-vagrant/%
|
||||
.PHONY: all all-linux all-freebsd all-openbsd all-netbsd all-darwin all-windows all-cross-linux all-cross-linux-arm all-cross-linux-mips all-cross-linux-other all-cross-darwin all-cross-windows bench bench-cpu bench-cpu-long bin build-test-mobile e2e e2ev e2evv e2evvv e2evvvv proto release service smoke-docker smoke-docker-race test test-cov-html smoke-vagrant/%
|
||||
.DEFAULT_GOAL := bin
|
||||
|
||||
+10
-4
@@ -13,6 +13,12 @@ import (
|
||||
"golang.org/x/crypto/ed25519"
|
||||
)
|
||||
|
||||
// testCertNow is the reference "now" used to derive default before/after times
|
||||
// in NewTestCaCert and NewTestCert. Holding it fixed for the lifetime of the
|
||||
// test binary keeps CA and leaf defaults aligned at the same second, so a leaf
|
||||
// signed with default times can never expire after its CA on a rounding race.
|
||||
var testCertNow = time.Now().Round(time.Second)
|
||||
|
||||
// NewTestCaCert will create a new ca certificate
|
||||
func NewTestCaCert(version Version, curve Curve, before, after time.Time, networks, unsafeNetworks []netip.Prefix, groups []string) (Certificate, []byte, []byte, []byte) {
|
||||
var err error
|
||||
@@ -34,10 +40,10 @@ func NewTestCaCert(version Version, curve Curve, before, after time.Time, networ
|
||||
}
|
||||
|
||||
if before.IsZero() {
|
||||
before = time.Now().Add(time.Second * -60).Round(time.Second)
|
||||
before = testCertNow.Add(time.Second * -60)
|
||||
}
|
||||
if after.IsZero() {
|
||||
after = time.Now().Add(time.Second * 60).Round(time.Second)
|
||||
after = testCertNow.Add(time.Second * 60)
|
||||
}
|
||||
|
||||
t := &TBSCertificate{
|
||||
@@ -70,11 +76,11 @@ func NewTestCaCert(version Version, curve Curve, before, after time.Time, networ
|
||||
// Expiry times are defaulted if you do not pass them in
|
||||
func NewTestCert(v Version, curve Curve, ca Certificate, key []byte, name string, before, after time.Time, networks, unsafeNetworks []netip.Prefix, groups []string) (Certificate, []byte, []byte, []byte) {
|
||||
if before.IsZero() {
|
||||
before = time.Now().Add(time.Second * -60).Round(time.Second)
|
||||
before = testCertNow.Add(time.Second * -60)
|
||||
}
|
||||
|
||||
if after.IsZero() {
|
||||
after = time.Now().Add(time.Second * 60).Round(time.Second)
|
||||
after = testCertNow.Add(time.Second * 60)
|
||||
}
|
||||
|
||||
if len(networks) == 0 {
|
||||
|
||||
+10
-4
@@ -14,6 +14,12 @@ import (
|
||||
"golang.org/x/crypto/ed25519"
|
||||
)
|
||||
|
||||
// testCertNow is the reference "now" used to derive default before/after times
|
||||
// in NewTestCaCert and NewTestCert. Holding it fixed for the lifetime of the
|
||||
// test binary keeps CA and leaf defaults aligned at the same second, so a leaf
|
||||
// signed with default times can never expire after its CA on a rounding race.
|
||||
var testCertNow = time.Now().Round(time.Second)
|
||||
|
||||
// NewTestCaCert will create a new ca certificate
|
||||
func NewTestCaCert(version cert.Version, curve cert.Curve, before, after time.Time, networks, unsafeNetworks []netip.Prefix, groups []string) (cert.Certificate, []byte, []byte, []byte) {
|
||||
var err error
|
||||
@@ -35,10 +41,10 @@ func NewTestCaCert(version cert.Version, curve cert.Curve, before, after time.Ti
|
||||
}
|
||||
|
||||
if before.IsZero() {
|
||||
before = time.Now().Add(time.Second * -60).Round(time.Second)
|
||||
before = testCertNow.Add(time.Second * -60)
|
||||
}
|
||||
if after.IsZero() {
|
||||
after = time.Now().Add(time.Second * 60).Round(time.Second)
|
||||
after = testCertNow.Add(time.Second * 60)
|
||||
}
|
||||
|
||||
t := &cert.TBSCertificate{
|
||||
@@ -71,11 +77,11 @@ func NewTestCaCert(version cert.Version, curve cert.Curve, before, after time.Ti
|
||||
// Expiry times are defaulted if you do not pass them in
|
||||
func NewTestCert(v cert.Version, curve cert.Curve, ca cert.Certificate, key []byte, name string, before, after time.Time, networks, unsafeNetworks []netip.Prefix, groups []string) (cert.Certificate, []byte, []byte, []byte) {
|
||||
if before.IsZero() {
|
||||
before = time.Now().Add(time.Second * -60).Round(time.Second)
|
||||
before = testCertNow.Add(time.Second * -60)
|
||||
}
|
||||
|
||||
if after.IsZero() {
|
||||
after = time.Now().Add(time.Second * 60).Round(time.Second)
|
||||
after = testCertNow.Add(time.Second * 60)
|
||||
}
|
||||
|
||||
var pub, priv []byte
|
||||
|
||||
+32
-7
@@ -97,6 +97,19 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error
|
||||
if err = mustFlagString("out-key", cf.outKeyPath); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// out-key is meaningless under PKCS#11 because the private key never
|
||||
// leaves the HSM; reject it so we never silently accept or claim a
|
||||
// stdout slot for it.
|
||||
outKeySet := false
|
||||
cf.set.Visit(func(f *flag.Flag) {
|
||||
if f.Name == "out-key" {
|
||||
outKeySet = true
|
||||
}
|
||||
})
|
||||
if outKeySet {
|
||||
return newHelpErrorf("cannot set -out-key with -pkcs11")
|
||||
}
|
||||
}
|
||||
if err := mustFlagString("out-crt", cf.outCertPath); err != nil {
|
||||
return err
|
||||
@@ -171,12 +184,21 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error
|
||||
}
|
||||
}
|
||||
|
||||
var claims ioClaims
|
||||
if err := reserveOutputs(&claims,
|
||||
"out-key", *cf.outKeyPath,
|
||||
"out-crt", *cf.outCertPath,
|
||||
"out-qr", *cf.outQRPath,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var passphrase []byte
|
||||
if !isP11 && *cf.encryption {
|
||||
passphrase = []byte(os.Getenv("NEBULA_CA_PASSPHRASE"))
|
||||
if len(passphrase) == 0 {
|
||||
for i := 0; i < 5; i++ {
|
||||
out.Write([]byte("Enter passphrase: "))
|
||||
errOut.Write([]byte("Enter passphrase: "))
|
||||
passphrase, err = pr.ReadPassword()
|
||||
|
||||
if err == ErrNoTerminal {
|
||||
@@ -261,14 +283,16 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error
|
||||
Curve: curve,
|
||||
}
|
||||
|
||||
if !isP11 {
|
||||
if !isP11 && !isStdio(*cf.outKeyPath) {
|
||||
if _, err := os.Stat(*cf.outKeyPath); err == nil {
|
||||
return fmt.Errorf("refusing to overwrite existing CA key: %s", *cf.outKeyPath)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := os.Stat(*cf.outCertPath); err == nil {
|
||||
return fmt.Errorf("refusing to overwrite existing CA cert: %s", *cf.outCertPath)
|
||||
if !isStdio(*cf.outCertPath) {
|
||||
if _, err := os.Stat(*cf.outCertPath); err == nil {
|
||||
return fmt.Errorf("refusing to overwrite existing CA cert: %s", *cf.outCertPath)
|
||||
}
|
||||
}
|
||||
|
||||
var c cert.Certificate
|
||||
@@ -294,7 +318,7 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error
|
||||
b = cert.MarshalSigningPrivateKeyToPEM(curve, rawPriv)
|
||||
}
|
||||
|
||||
err = os.WriteFile(*cf.outKeyPath, b, 0600)
|
||||
err = writeOutput(*cf.outKeyPath, b, 0600, out)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error while writing out-key: %s", err)
|
||||
}
|
||||
@@ -305,7 +329,7 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error
|
||||
return fmt.Errorf("error while marshalling certificate: %s", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(*cf.outCertPath, b, 0600)
|
||||
err = writeOutput(*cf.outCertPath, b, 0600, out)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error while writing out-crt: %s", err)
|
||||
}
|
||||
@@ -316,7 +340,7 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error
|
||||
return fmt.Errorf("error while generating qr code: %s", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(*cf.outQRPath, b, 0600)
|
||||
err = writeOutput(*cf.outQRPath, b, 0600, out)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error while writing out-qr: %s", err)
|
||||
}
|
||||
@@ -332,6 +356,7 @@ func caSummary() string {
|
||||
func caHelp(out io.Writer) {
|
||||
cf := newCaFlags()
|
||||
out.Write([]byte("Usage of " + os.Args[0] + " " + caSummary() + "\n"))
|
||||
out.Write([]byte(stdioHelpText))
|
||||
cf.set.SetOutput(out)
|
||||
cf.set.PrintDefaults()
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ func Test_caHelp(t *testing.T) {
|
||||
assert.Equal(
|
||||
t,
|
||||
"Usage of "+os.Args[0]+" ca <flags>: create a self signed certificate authority\n"+
|
||||
" Pass \"-\" to any path flag to read from stdin or write to stdout.\n"+
|
||||
" -argon-iterations uint\n"+
|
||||
" \tOptional: Argon2 iterations parameter used for encrypted private key passphrase (default 1)\n"+
|
||||
" -argon-memory uint\n"+
|
||||
@@ -84,7 +85,7 @@ func Test_ca(t *testing.T) {
|
||||
err: nil,
|
||||
}
|
||||
|
||||
pwPromptOb := "Enter passphrase: "
|
||||
pwPromptEB := "Enter passphrase: "
|
||||
|
||||
// required args
|
||||
assertHelpError(t, ca(
|
||||
@@ -168,8 +169,8 @@ func Test_ca(t *testing.T) {
|
||||
eb.Reset()
|
||||
args = []string{"-version", "1", "-encrypt", "-name", "test", "-duration", "100m", "-groups", "1,2,3,4,5", "-out-crt", crtF.Name(), "-out-key", keyF.Name()}
|
||||
require.NoError(t, ca(args, ob, eb, testpw))
|
||||
assert.Equal(t, pwPromptOb, ob.String())
|
||||
assert.Empty(t, eb.String())
|
||||
assert.Empty(t, ob.String())
|
||||
assert.Equal(t, pwPromptEB, eb.String())
|
||||
|
||||
// test encrypted key with passphrase environment variable
|
||||
os.Remove(keyF.Name())
|
||||
@@ -207,8 +208,8 @@ func Test_ca(t *testing.T) {
|
||||
eb.Reset()
|
||||
args = []string{"-version", "1", "-encrypt", "-name", "test", "-duration", "100m", "-groups", "1,2,3,4,5", "-out-crt", crtF.Name(), "-out-key", keyF.Name()}
|
||||
require.Error(t, ca(args, ob, eb, errpw))
|
||||
assert.Equal(t, pwPromptOb, ob.String())
|
||||
assert.Empty(t, eb.String())
|
||||
assert.Empty(t, ob.String())
|
||||
assert.Equal(t, pwPromptEB, eb.String())
|
||||
|
||||
// test when user fails to enter a password
|
||||
os.Remove(keyF.Name())
|
||||
@@ -217,8 +218,8 @@ func Test_ca(t *testing.T) {
|
||||
eb.Reset()
|
||||
args = []string{"-version", "1", "-encrypt", "-name", "test", "-duration", "100m", "-groups", "1,2,3,4,5", "-out-crt", crtF.Name(), "-out-key", keyF.Name()}
|
||||
require.EqualError(t, ca(args, ob, eb, nopw), "no passphrase specified, remove -encrypt flag to write out-key in plaintext")
|
||||
assert.Equal(t, strings.Repeat(pwPromptOb, 5), ob.String()) // prompts 5 times before giving up
|
||||
assert.Empty(t, eb.String())
|
||||
assert.Empty(t, ob.String())
|
||||
assert.Equal(t, strings.Repeat(pwPromptEB, 5), eb.String()) // prompts 5 times before giving up
|
||||
|
||||
// create valid cert/key for overwrite tests
|
||||
os.Remove(keyF.Name())
|
||||
@@ -247,3 +248,67 @@ func Test_ca(t *testing.T) {
|
||||
os.Remove(keyF.Name())
|
||||
|
||||
}
|
||||
|
||||
func Test_ca_stdio(t *testing.T) {
|
||||
nopw := &StubPasswordReader{}
|
||||
|
||||
keyF, err := os.CreateTemp("", "ca.key")
|
||||
require.NoError(t, err)
|
||||
os.Remove(keyF.Name())
|
||||
defer os.Remove(keyF.Name())
|
||||
|
||||
crtF, err := os.CreateTemp("", "ca.crt")
|
||||
require.NoError(t, err)
|
||||
os.Remove(crtF.Name())
|
||||
defer os.Remove(crtF.Name())
|
||||
|
||||
// out-crt on stdout, out-key on disk
|
||||
ob := &bytes.Buffer{}
|
||||
eb := &bytes.Buffer{}
|
||||
require.NoError(t, ca([]string{"-name", "test-ca", "-duration", "1h", "-out-crt", "-", "-out-key", keyF.Name()}, ob, eb, nopw))
|
||||
assert.Empty(t, eb.String())
|
||||
c, _, err := cert.UnmarshalCertificateFromPEM(ob.Bytes())
|
||||
require.NoError(t, err)
|
||||
assert.True(t, c.IsCA())
|
||||
assert.Equal(t, "test-ca", c.Name())
|
||||
|
||||
// out-key on stdout, out-crt on disk
|
||||
os.Remove(keyF.Name())
|
||||
ob.Reset()
|
||||
eb.Reset()
|
||||
require.NoError(t, ca([]string{"-name", "test-ca", "-duration", "1h", "-out-crt", crtF.Name(), "-out-key", "-"}, ob, eb, nopw))
|
||||
assert.Empty(t, eb.String())
|
||||
_, _, curve, err := cert.UnmarshalSigningPrivateKeyFromPEM(ob.Bytes())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, cert.Curve_CURVE25519, curve)
|
||||
|
||||
// dual stdout is rejected up front
|
||||
os.Remove(crtF.Name())
|
||||
ob.Reset()
|
||||
eb.Reset()
|
||||
require.EqualError(t,
|
||||
ca([]string{"-name", "test-ca", "-duration", "1h", "-out-crt", "-", "-out-key", "-"}, ob, eb, nopw),
|
||||
`-out-key and -out-crt both set to "-", only one output may write to stdout`)
|
||||
assert.Empty(t, ob.String())
|
||||
|
||||
// an output conflict combined with -encrypt must error BEFORE prompting
|
||||
// for a passphrase; pr would record any read attempt
|
||||
tracker := &trackingPasswordReader{}
|
||||
ob.Reset()
|
||||
eb.Reset()
|
||||
require.EqualError(t,
|
||||
ca([]string{"-name", "test-ca", "-duration", "1h", "-encrypt", "-out-crt", "-", "-out-key", "-"}, ob, eb, tracker),
|
||||
`-out-key and -out-crt both set to "-", only one output may write to stdout`)
|
||||
assert.Empty(t, ob.String())
|
||||
assert.Empty(t, eb.String())
|
||||
assert.Zero(t, tracker.calls, "passphrase prompt should not have been called")
|
||||
}
|
||||
|
||||
type trackingPasswordReader struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (pr *trackingPasswordReader) ReadPassword() ([]byte, error) {
|
||||
pr.calls++
|
||||
return []byte(""), nil
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ func keygen(args []string, out io.Writer, errOut io.Writer) error {
|
||||
if err = mustFlagString("out-key", cf.outKeyPath); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if *cf.outKeyPath != "" {
|
||||
return newHelpErrorf("cannot set -out-key with -pkcs11")
|
||||
}
|
||||
if err = mustFlagString("out-pub", cf.outPubPath); err != nil {
|
||||
return err
|
||||
@@ -69,6 +71,14 @@ func keygen(args []string, out io.Writer, errOut io.Writer) error {
|
||||
}
|
||||
}
|
||||
|
||||
var claims ioClaims
|
||||
if err := reserveOutputs(&claims,
|
||||
"out-key", *cf.outKeyPath,
|
||||
"out-pub", *cf.outPubPath,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if isP11 {
|
||||
p11Client, err := pkclient.FromUrl(*cf.p11url)
|
||||
if err != nil {
|
||||
@@ -82,12 +92,12 @@ func keygen(args []string, out io.Writer, errOut io.Writer) error {
|
||||
return fmt.Errorf("error while getting public key: %w", err)
|
||||
}
|
||||
} else {
|
||||
err = os.WriteFile(*cf.outKeyPath, cert.MarshalPrivateKeyToPEM(curve, rawPriv), 0600)
|
||||
err = writeOutput(*cf.outKeyPath, cert.MarshalPrivateKeyToPEM(curve, rawPriv), 0600, out)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error while writing out-key: %s", err)
|
||||
}
|
||||
}
|
||||
err = os.WriteFile(*cf.outPubPath, cert.MarshalPublicKeyToPEM(curve, pub), 0600)
|
||||
err = writeOutput(*cf.outPubPath, cert.MarshalPublicKeyToPEM(curve, pub), 0600, out)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error while writing out-pub: %s", err)
|
||||
}
|
||||
@@ -102,6 +112,7 @@ func keygenSummary() string {
|
||||
func keygenHelp(out io.Writer) {
|
||||
cf := newKeygenFlags()
|
||||
_, _ = out.Write([]byte("Usage of " + os.Args[0] + " " + keygenSummary() + "\n"))
|
||||
_, _ = out.Write([]byte(stdioHelpText))
|
||||
cf.set.SetOutput(out)
|
||||
cf.set.PrintDefaults()
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ func Test_keygenHelp(t *testing.T) {
|
||||
assert.Equal(
|
||||
t,
|
||||
"Usage of "+os.Args[0]+" keygen <flags>: create a public/private key pair. the public key can be passed to `nebula-cert sign`\n"+
|
||||
" Pass \"-\" to any path flag to read from stdin or write to stdout.\n"+
|
||||
" -curve string\n"+
|
||||
" \tECDH Curve (25519, P256) (default \"25519\")\n"+
|
||||
" -out-key string\n"+
|
||||
@@ -93,3 +94,43 @@ func Test_keygen(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, lPub, 32)
|
||||
}
|
||||
|
||||
func Test_keygen_stdio(t *testing.T) {
|
||||
keyF, err := os.CreateTemp("", "test.key")
|
||||
require.NoError(t, err)
|
||||
os.Remove(keyF.Name())
|
||||
defer os.Remove(keyF.Name())
|
||||
|
||||
pubF, err := os.CreateTemp("", "test.pub")
|
||||
require.NoError(t, err)
|
||||
os.Remove(pubF.Name())
|
||||
defer os.Remove(pubF.Name())
|
||||
|
||||
// out-pub on stdout, out-key on disk
|
||||
ob := &bytes.Buffer{}
|
||||
eb := &bytes.Buffer{}
|
||||
require.NoError(t, keygen([]string{"-out-pub", "-", "-out-key", keyF.Name()}, ob, eb))
|
||||
assert.Empty(t, eb.String())
|
||||
lPub, _, curve, err := cert.UnmarshalPublicKeyFromPEM(ob.Bytes())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, cert.Curve_CURVE25519, curve)
|
||||
assert.Len(t, lPub, 32)
|
||||
|
||||
// out-key on stdout, out-pub on disk
|
||||
os.Remove(keyF.Name())
|
||||
ob.Reset()
|
||||
eb.Reset()
|
||||
require.NoError(t, keygen([]string{"-out-pub", pubF.Name(), "-out-key", "-"}, ob, eb))
|
||||
assert.Empty(t, eb.String())
|
||||
lKey, _, curve, err := cert.UnmarshalPrivateKeyFromPEM(ob.Bytes())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, cert.Curve_CURVE25519, curve)
|
||||
assert.Len(t, lKey, 32)
|
||||
|
||||
// both on stdout is a conflict caught up front
|
||||
ob.Reset()
|
||||
eb.Reset()
|
||||
require.EqualError(t, keygen([]string{"-out-pub", "-", "-out-key", "-"}, ob, eb),
|
||||
`-out-key and -out-pub both set to "-", only one output may write to stdout`)
|
||||
assert.Empty(t, ob.String())
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ func (pr StdinPasswordReader) ReadPassword() ([]byte, error) {
|
||||
}
|
||||
|
||||
password, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Println()
|
||||
// Terminal echo is off while reading, so the user's Enter key does not
|
||||
// produce a visible newline. Emit one on stderr to match the prompt.
|
||||
fmt.Fprintln(os.Stderr)
|
||||
|
||||
return password, err
|
||||
}
|
||||
|
||||
@@ -40,11 +40,23 @@ func printCert(args []string, out io.Writer, errOut io.Writer) error {
|
||||
return err
|
||||
}
|
||||
|
||||
rawCert, err := os.ReadFile(*pf.path)
|
||||
var claims ioClaims
|
||||
if err := reserveInputs(&claims, "path", *pf.path); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := reserveOutputs(&claims, "out-qr", *pf.outQRPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rawCert, err := readInput("path", *pf.path, &claims)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to read cert; %s", err)
|
||||
}
|
||||
|
||||
// When the QR is going to stdout, suppress the human-readable text/json
|
||||
// output so the binary stream is not contaminated.
|
||||
qrToStdout := isStdio(*pf.outQRPath)
|
||||
|
||||
var c cert.Certificate
|
||||
var qrBytes []byte
|
||||
part := 0
|
||||
@@ -57,11 +69,13 @@ func printCert(args []string, out io.Writer, errOut io.Writer) error {
|
||||
return fmt.Errorf("error while unmarshaling cert: %s", err)
|
||||
}
|
||||
|
||||
if *pf.json {
|
||||
jsonCerts = append(jsonCerts, c)
|
||||
} else {
|
||||
_, _ = out.Write([]byte(c.String()))
|
||||
_, _ = out.Write([]byte("\n"))
|
||||
if !qrToStdout {
|
||||
if *pf.json {
|
||||
jsonCerts = append(jsonCerts, c)
|
||||
} else {
|
||||
_, _ = out.Write([]byte(c.String()))
|
||||
_, _ = out.Write([]byte("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
if *pf.outQRPath != "" {
|
||||
@@ -79,7 +93,7 @@ func printCert(args []string, out io.Writer, errOut io.Writer) error {
|
||||
part++
|
||||
}
|
||||
|
||||
if *pf.json {
|
||||
if *pf.json && !qrToStdout {
|
||||
b, _ := json.Marshal(jsonCerts)
|
||||
_, _ = out.Write(b)
|
||||
_, _ = out.Write([]byte("\n"))
|
||||
@@ -91,7 +105,7 @@ func printCert(args []string, out io.Writer, errOut io.Writer) error {
|
||||
return fmt.Errorf("error while generating qr code: %s", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(*pf.outQRPath, b, 0600)
|
||||
err = writeOutput(*pf.outQRPath, b, 0600, out)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error while writing out-qr: %s", err)
|
||||
}
|
||||
@@ -107,6 +121,7 @@ func printSummary() string {
|
||||
func printHelp(out io.Writer) {
|
||||
pf := newPrintFlags()
|
||||
out.Write([]byte("Usage of " + os.Args[0] + " " + printSummary() + "\n"))
|
||||
out.Write([]byte(stdioHelpText))
|
||||
pf.set.SetOutput(out)
|
||||
pf.set.PrintDefaults()
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ func Test_printHelp(t *testing.T) {
|
||||
assert.Equal(
|
||||
t,
|
||||
"Usage of "+os.Args[0]+" print <flags>: prints details about a certificate\n"+
|
||||
" Pass \"-\" to any path flag to read from stdin or write to stdout.\n"+
|
||||
" -json\n"+
|
||||
" \tOptional: outputs certificates in json format\n"+
|
||||
" -out-qr string\n"+
|
||||
@@ -178,6 +179,44 @@ func Test_printCert(t *testing.T) {
|
||||
ob.String(),
|
||||
)
|
||||
assert.Empty(t, eb.String())
|
||||
|
||||
// read cert from stdin
|
||||
ob.Reset()
|
||||
eb.Reset()
|
||||
withStdin(t, bytes.NewReader(p))
|
||||
err = printCert([]string{"-json", "-path", "-"}, ob, eb)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(
|
||||
t,
|
||||
`[{"details":{"curve":"CURVE25519","groups":["hi"],"isCa":false,"issuer":"`+c.Issuer()+`","name":"test","networks":["10.0.0.123/8"],"notAfter":"0001-01-01T00:00:00Z","notBefore":"0001-01-01T00:00:00Z","publicKey":"`+pk+`","unsafeNetworks":[]},"fingerprint":"`+fp+`","signature":"`+sig+`","version":1}]
|
||||
`,
|
||||
ob.String(),
|
||||
)
|
||||
assert.Empty(t, eb.String())
|
||||
|
||||
// -out-qr - sends only the PNG to stdout, suppressing the cert dump
|
||||
ob.Reset()
|
||||
eb.Reset()
|
||||
withStdin(t, bytes.NewReader(p))
|
||||
err = printCert([]string{"-path", "-", "-out-qr", "-"}, ob, eb)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, eb.String())
|
||||
stdout := ob.Bytes()
|
||||
require.NotEmpty(t, stdout)
|
||||
// PNG magic, no PEM/JSON noise prepended
|
||||
assert.Equal(t, []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}, stdout[:8])
|
||||
assert.NotContains(t, string(stdout), "NebulaCertificate")
|
||||
assert.NotContains(t, string(stdout), `"details"`)
|
||||
|
||||
// json + out-qr - still suppresses json
|
||||
ob.Reset()
|
||||
eb.Reset()
|
||||
withStdin(t, bytes.NewReader(p))
|
||||
err = printCert([]string{"-json", "-path", "-", "-out-qr", "-"}, ob, eb)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, eb.String())
|
||||
assert.Equal(t, []byte{0x89, 'P', 'N', 'G'}, ob.Bytes()[:4])
|
||||
assert.NotContains(t, ob.String(), `"details"`)
|
||||
}
|
||||
|
||||
// NewTestCaCert will generate a CA cert
|
||||
|
||||
+42
-20
@@ -85,6 +85,9 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
|
||||
if !isP11 && *sf.inPubPath != "" && *sf.outKeyPath != "" {
|
||||
return newHelpErrorf("cannot set both -in-pub and -out-key")
|
||||
}
|
||||
if isP11 && *sf.outKeyPath != "" {
|
||||
return newHelpErrorf("cannot set -out-key with -pkcs11")
|
||||
}
|
||||
|
||||
var v4Networks []netip.Prefix
|
||||
var v6Networks []netip.Prefix
|
||||
@@ -102,13 +105,35 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
|
||||
return newHelpErrorf("-version must be either %v or %v", cert.Version1, cert.Version2)
|
||||
}
|
||||
|
||||
if *sf.outKeyPath == "" {
|
||||
*sf.outKeyPath = *sf.name + ".key"
|
||||
}
|
||||
if *sf.outCertPath == "" {
|
||||
*sf.outCertPath = *sf.name + ".crt"
|
||||
}
|
||||
|
||||
var claims ioClaims
|
||||
if err := reserveInputs(&claims,
|
||||
"ca-key", *sf.caKeyPath,
|
||||
"ca-crt", *sf.caCertPath,
|
||||
"in-pub", *sf.inPubPath,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := reserveOutputs(&claims,
|
||||
"out-key", *sf.outKeyPath,
|
||||
"out-crt", *sf.outCertPath,
|
||||
"out-qr", *sf.outQRPath,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var curve cert.Curve
|
||||
var caKey []byte
|
||||
|
||||
if !isP11 {
|
||||
var rawCAKey []byte
|
||||
rawCAKey, err := os.ReadFile(*sf.caKeyPath)
|
||||
|
||||
rawCAKey, err = readInput("ca-key", *sf.caKeyPath, &claims)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error while reading ca-key: %s", err)
|
||||
}
|
||||
@@ -121,7 +146,7 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
|
||||
if len(passphrase) == 0 {
|
||||
// ask for a passphrase until we get one
|
||||
for i := 0; i < 5; i++ {
|
||||
out.Write([]byte("Enter passphrase: "))
|
||||
errOut.Write([]byte("Enter passphrase: "))
|
||||
passphrase, err = pr.ReadPassword()
|
||||
|
||||
if errors.Is(err, ErrNoTerminal) {
|
||||
@@ -147,7 +172,7 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
|
||||
}
|
||||
}
|
||||
|
||||
rawCACert, err := os.ReadFile(*sf.caCertPath)
|
||||
rawCACert, err := readInput("ca-crt", *sf.caCertPath, &claims)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error while reading ca-crt: %s", err)
|
||||
}
|
||||
@@ -245,7 +270,7 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
|
||||
|
||||
if *sf.inPubPath != "" {
|
||||
var pubCurve cert.Curve
|
||||
rawPub, err := os.ReadFile(*sf.inPubPath)
|
||||
rawPub, err := readInput("in-pub", *sf.inPubPath, &claims)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error while reading in-pub: %s", err)
|
||||
}
|
||||
@@ -266,16 +291,10 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
|
||||
pub, rawPriv = newKeypair(curve)
|
||||
}
|
||||
|
||||
if *sf.outKeyPath == "" {
|
||||
*sf.outKeyPath = *sf.name + ".key"
|
||||
}
|
||||
|
||||
if *sf.outCertPath == "" {
|
||||
*sf.outCertPath = *sf.name + ".crt"
|
||||
}
|
||||
|
||||
if _, err := os.Stat(*sf.outCertPath); err == nil {
|
||||
return fmt.Errorf("refusing to overwrite existing cert: %s", *sf.outCertPath)
|
||||
if !isStdio(*sf.outCertPath) {
|
||||
if _, err := os.Stat(*sf.outCertPath); err == nil {
|
||||
return fmt.Errorf("refusing to overwrite existing cert: %s", *sf.outCertPath)
|
||||
}
|
||||
}
|
||||
|
||||
var crts []cert.Certificate
|
||||
@@ -360,11 +379,13 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
|
||||
}
|
||||
|
||||
if !isP11 && *sf.inPubPath == "" {
|
||||
if _, err := os.Stat(*sf.outKeyPath); err == nil {
|
||||
return fmt.Errorf("refusing to overwrite existing key: %s", *sf.outKeyPath)
|
||||
if !isStdio(*sf.outKeyPath) {
|
||||
if _, err := os.Stat(*sf.outKeyPath); err == nil {
|
||||
return fmt.Errorf("refusing to overwrite existing key: %s", *sf.outKeyPath)
|
||||
}
|
||||
}
|
||||
|
||||
err = os.WriteFile(*sf.outKeyPath, cert.MarshalPrivateKeyToPEM(curve, rawPriv), 0600)
|
||||
err = writeOutput(*sf.outKeyPath, cert.MarshalPrivateKeyToPEM(curve, rawPriv), 0600, out)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error while writing out-key: %s", err)
|
||||
}
|
||||
@@ -379,7 +400,7 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
|
||||
b = append(b, sb...)
|
||||
}
|
||||
|
||||
err = os.WriteFile(*sf.outCertPath, b, 0600)
|
||||
err = writeOutput(*sf.outCertPath, b, 0600, out)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error while writing out-crt: %s", err)
|
||||
}
|
||||
@@ -390,7 +411,7 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
|
||||
return fmt.Errorf("error while generating qr code: %s", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(*sf.outQRPath, b, 0600)
|
||||
err = writeOutput(*sf.outQRPath, b, 0600, out)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error while writing out-qr: %s", err)
|
||||
}
|
||||
@@ -440,6 +461,7 @@ func signSummary() string {
|
||||
func signHelp(out io.Writer) {
|
||||
sf := newSignFlags()
|
||||
out.Write([]byte("Usage of " + os.Args[0] + " " + signSummary() + "\n"))
|
||||
out.Write([]byte(stdioHelpText))
|
||||
sf.set.SetOutput(out)
|
||||
sf.set.PrintDefaults()
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ func Test_signHelp(t *testing.T) {
|
||||
assert.Equal(
|
||||
t,
|
||||
"Usage of "+os.Args[0]+" sign <flags>: create and sign a certificate\n"+
|
||||
" Pass \"-\" to any path flag to read from stdin or write to stdout.\n"+
|
||||
" -ca-crt string\n"+
|
||||
" \tOptional: path to the signing CA cert (default \"ca.crt\")\n"+
|
||||
" -ca-key string\n"+
|
||||
@@ -376,15 +377,18 @@ func Test_signCert(t *testing.T) {
|
||||
// test with the proper password
|
||||
args = []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", caKeyF.Name(), "-name", "test", "-ip", "1.1.1.1/24", "-out-crt", crtF.Name(), "-out-key", keyF.Name(), "-duration", "100m", "-subnets", "10.1.1.1/32, , 10.2.2.2/32 , , ,, 10.5.5.5/32", "-groups", "1,, 2 , ,,,3,4,5"}
|
||||
require.NoError(t, signCert(args, ob, eb, testpw))
|
||||
assert.Equal(t, "Enter passphrase: ", ob.String())
|
||||
assert.Empty(t, eb.String())
|
||||
assert.Empty(t, ob.String())
|
||||
assert.Equal(t, "Enter passphrase: ", eb.String())
|
||||
|
||||
// test with the proper password in the environment
|
||||
os.Remove(crtF.Name())
|
||||
os.Remove(keyF.Name())
|
||||
args = []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", caKeyF.Name(), "-name", "test", "-ip", "1.1.1.1/24", "-out-crt", crtF.Name(), "-out-key", keyF.Name(), "-duration", "100m", "-subnets", "10.1.1.1/32, , 10.2.2.2/32 , , ,, 10.5.5.5/32", "-groups", "1,, 2 , ,,,3,4,5"}
|
||||
os.Setenv("NEBULA_CA_PASSPHRASE", string(passphrase))
|
||||
ob.Reset()
|
||||
eb.Reset()
|
||||
require.NoError(t, signCert(args, ob, eb, testpw))
|
||||
assert.Empty(t, ob.String())
|
||||
assert.Empty(t, eb.String())
|
||||
os.Setenv("NEBULA_CA_PASSPHRASE", "")
|
||||
|
||||
@@ -395,8 +399,8 @@ func Test_signCert(t *testing.T) {
|
||||
testpw.password = []byte("invalid password")
|
||||
args = []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", caKeyF.Name(), "-name", "test", "-ip", "1.1.1.1/24", "-out-crt", crtF.Name(), "-out-key", keyF.Name(), "-duration", "100m", "-subnets", "10.1.1.1/32, , 10.2.2.2/32 , , ,, 10.5.5.5/32", "-groups", "1,, 2 , ,,,3,4,5"}
|
||||
require.Error(t, signCert(args, ob, eb, testpw))
|
||||
assert.Equal(t, "Enter passphrase: ", ob.String())
|
||||
assert.Empty(t, eb.String())
|
||||
assert.Empty(t, ob.String())
|
||||
assert.Equal(t, "Enter passphrase: ", eb.String())
|
||||
|
||||
// test with the wrong password in environment
|
||||
ob.Reset()
|
||||
@@ -416,8 +420,8 @@ func Test_signCert(t *testing.T) {
|
||||
args = []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", caKeyF.Name(), "-name", "test", "-ip", "1.1.1.1/24", "-out-crt", crtF.Name(), "-out-key", keyF.Name(), "-duration", "100m", "-subnets", "10.1.1.1/32, , 10.2.2.2/32 , , ,, 10.5.5.5/32", "-groups", "1,, 2 , ,,,3,4,5"}
|
||||
require.Error(t, signCert(args, ob, eb, nopw))
|
||||
// normally the user hitting enter on the prompt would add newlines between these
|
||||
assert.Equal(t, "Enter passphrase: Enter passphrase: Enter passphrase: Enter passphrase: Enter passphrase: ", ob.String())
|
||||
assert.Empty(t, eb.String())
|
||||
assert.Empty(t, ob.String())
|
||||
assert.Equal(t, "Enter passphrase: Enter passphrase: Enter passphrase: Enter passphrase: Enter passphrase: ", eb.String())
|
||||
|
||||
// test an error condition
|
||||
ob.Reset()
|
||||
@@ -425,6 +429,106 @@ func Test_signCert(t *testing.T) {
|
||||
|
||||
args = []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", caKeyF.Name(), "-name", "test", "-ip", "1.1.1.1/24", "-out-crt", crtF.Name(), "-out-key", keyF.Name(), "-duration", "100m", "-subnets", "10.1.1.1/32, , 10.2.2.2/32 , , ,, 10.5.5.5/32", "-groups", "1,, 2 , ,,,3,4,5"}
|
||||
require.Error(t, signCert(args, ob, eb, errpw))
|
||||
assert.Equal(t, "Enter passphrase: ", ob.String())
|
||||
assert.Empty(t, eb.String())
|
||||
assert.Empty(t, ob.String())
|
||||
assert.Equal(t, "Enter passphrase: ", eb.String())
|
||||
}
|
||||
|
||||
func Test_signCert_stdio(t *testing.T) {
|
||||
nopw := &StubPasswordReader{
|
||||
password: []byte(""),
|
||||
err: nil,
|
||||
}
|
||||
|
||||
caPub, caPriv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
rawCAKey := cert.MarshalSigningPrivateKeyToPEM(cert.Curve_CURVE25519, caPriv)
|
||||
|
||||
ca, _ := NewTestCaCert("ca", caPub, caPriv, time.Now(), time.Now().Add(time.Minute*200), nil, nil, nil)
|
||||
rawCACrt, _ := ca.MarshalPEM()
|
||||
|
||||
caCrtF, err := os.CreateTemp("", "sign-cert.crt")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(caCrtF.Name())
|
||||
caCrtF.Write(rawCACrt)
|
||||
|
||||
caKeyF, err := os.CreateTemp("", "sign-cert.key")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(caKeyF.Name())
|
||||
caKeyF.Write(rawCAKey)
|
||||
|
||||
keyF, err := os.CreateTemp("", "sign.key")
|
||||
require.NoError(t, err)
|
||||
os.Remove(keyF.Name())
|
||||
defer os.Remove(keyF.Name())
|
||||
|
||||
// ca-key on stdin, cert to stdout
|
||||
withStdin(t, bytes.NewReader(rawCAKey))
|
||||
ob := &bytes.Buffer{}
|
||||
eb := &bytes.Buffer{}
|
||||
args := []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", "-", "-name", "stdin-test", "-ip", "1.1.1.1/24", "-out-crt", "-", "-out-key", keyF.Name(), "-duration", "100m"}
|
||||
require.NoError(t, signCert(args, ob, eb, nopw))
|
||||
assert.Empty(t, eb.String())
|
||||
|
||||
lCrt, _, err := cert.UnmarshalCertificateFromPEM(ob.Bytes())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "stdin-test", lCrt.Name())
|
||||
assert.True(t, lCrt.CheckSignature(caPub))
|
||||
|
||||
// two flags reading from stdin should error before any read attempt;
|
||||
// otherwise an interactive shell would hang on io.ReadAll
|
||||
stdinIn := bytes.NewReader(rawCAKey)
|
||||
withStdin(t, stdinIn)
|
||||
ob.Reset()
|
||||
eb.Reset()
|
||||
args = []string{"-version", "1", "-ca-crt", "-", "-ca-key", "-", "-name", "stdin-test", "-ip", "1.1.1.1/24", "-out-crt", "nope", "-out-key", "nope", "-duration", "100m"}
|
||||
require.EqualError(t, signCert(args, ob, eb, nopw),
|
||||
`-ca-key and -ca-crt both set to "-", only one input may read from stdin`)
|
||||
assert.Equal(t, len(rawCAKey), stdinIn.Len(), "stdin should be untouched when conflict is caught up front")
|
||||
|
||||
// two flags writing to stdout should error before any output is written
|
||||
// AND before stdin is consumed
|
||||
stdinR := bytes.NewReader(rawCAKey)
|
||||
withStdin(t, stdinR)
|
||||
ob.Reset()
|
||||
eb.Reset()
|
||||
args = []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", "-", "-name", "stdin-test", "-ip", "1.1.1.1/24", "-out-crt", "-", "-out-key", "-", "-duration", "100m"}
|
||||
require.EqualError(t, signCert(args, ob, eb, nopw),
|
||||
`-out-key and -out-crt both set to "-", only one output may write to stdout`)
|
||||
assert.Empty(t, ob.String())
|
||||
// stdin should be untouched because the conflict was caught up front
|
||||
assert.Equal(t, len(rawCAKey), stdinR.Len())
|
||||
|
||||
// out-key on stdout, cert on disk
|
||||
keyF2, err := os.CreateTemp("", "sign.key")
|
||||
require.NoError(t, err)
|
||||
os.Remove(keyF2.Name())
|
||||
defer os.Remove(keyF2.Name())
|
||||
crtF, err := os.CreateTemp("", "sign.crt")
|
||||
require.NoError(t, err)
|
||||
os.Remove(crtF.Name())
|
||||
defer os.Remove(crtF.Name())
|
||||
|
||||
ob.Reset()
|
||||
eb.Reset()
|
||||
args = []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", caKeyF.Name(), "-name", "stdin-test", "-ip", "1.1.1.1/24", "-out-crt", crtF.Name(), "-out-key", "-", "-duration", "100m"}
|
||||
require.NoError(t, signCert(args, ob, eb, nopw))
|
||||
assert.Empty(t, eb.String())
|
||||
_, _, curve, err := cert.UnmarshalPrivateKeyFromPEM(ob.Bytes())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, cert.Curve_CURVE25519, curve)
|
||||
|
||||
// in-pub on stdin (caller already has a keypair, only the cert is generated)
|
||||
inPub, _ := x25519Keypair()
|
||||
rawInPub := cert.MarshalPublicKeyToPEM(cert.Curve_CURVE25519, inPub)
|
||||
|
||||
withStdin(t, bytes.NewReader(rawInPub))
|
||||
os.Remove(crtF.Name())
|
||||
ob.Reset()
|
||||
eb.Reset()
|
||||
args = []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", caKeyF.Name(), "-name", "in-pub-test", "-ip", "1.1.1.1/24", "-in-pub", "-", "-out-crt", "-", "-duration", "100m"}
|
||||
require.NoError(t, signCert(args, ob, eb, nopw))
|
||||
assert.Empty(t, eb.String())
|
||||
stdinCrt, _, err := cert.UnmarshalCertificateFromPEM(ob.Bytes())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "in-pub-test", stdinCrt.Name())
|
||||
assert.Equal(t, inPub, stdinCrt.PublicKey())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// stdioPath is the special path value that selects stdin (for inputs) or
|
||||
// stdout (for outputs) instead of a file on disk.
|
||||
const stdioPath = "-"
|
||||
|
||||
// stdioHelpText is rendered just under the Usage line of each subcommand
|
||||
// help so the - convention is documented once instead of on every flag.
|
||||
const stdioHelpText = " Pass \"-\" to any path flag to read from stdin or write to stdout.\n"
|
||||
|
||||
// stdinReader is the source used when an input flag is set to "-".
|
||||
// It is a package level var so tests can swap in a deterministic reader.
|
||||
// Tests that mutate stdinReader cannot run with t.Parallel().
|
||||
var stdinReader io.Reader = os.Stdin
|
||||
|
||||
// ioClaims tracks which flags have claimed stdin and stdout during a single
|
||||
// command invocation so we can refuse a second flag asking for the same
|
||||
// stream.
|
||||
type ioClaims struct {
|
||||
in string
|
||||
out string
|
||||
}
|
||||
|
||||
func (c *ioClaims) claimIn(flagName string) error {
|
||||
if c.in != "" && c.in != flagName {
|
||||
return fmt.Errorf("-%s and -%s both set to %q, only one input may read from stdin", c.in, flagName, stdioPath)
|
||||
}
|
||||
c.in = flagName
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ioClaims) claimOut(flagName string) error {
|
||||
if c.out != "" && c.out != flagName {
|
||||
return fmt.Errorf("-%s and -%s both set to %q, only one output may write to stdout", c.out, flagName, stdioPath)
|
||||
}
|
||||
c.out = flagName
|
||||
return nil
|
||||
}
|
||||
|
||||
// reserveInputs walks alternating (flagName, path) pairs and claims stdin
|
||||
// for any path equal to stdioPath. It must be called before any input is
|
||||
// read so a conflict can be reported immediately instead of blocking on
|
||||
// io.ReadAll while waiting for input that will never arrive.
|
||||
func reserveInputs(claims *ioClaims, pairs ...string) error {
|
||||
return reserveStdio(claims, "reserveInputs", (*ioClaims).claimIn, pairs)
|
||||
}
|
||||
|
||||
// reserveOutputs walks alternating (flagName, path) pairs and claims stdout
|
||||
// for any path equal to stdioPath. It must be called before any output is
|
||||
// written so a conflict cannot leave one stream half written before the
|
||||
// second flag fails.
|
||||
func reserveOutputs(claims *ioClaims, pairs ...string) error {
|
||||
return reserveStdio(claims, "reserveOutputs", (*ioClaims).claimOut, pairs)
|
||||
}
|
||||
|
||||
func reserveStdio(claims *ioClaims, who string, claim func(*ioClaims, string) error, pairs []string) error {
|
||||
if len(pairs)%2 != 0 {
|
||||
panic(who + " requires alternating name, path pairs")
|
||||
}
|
||||
for i := 0; i < len(pairs); i += 2 {
|
||||
name, path := pairs[i], pairs[i+1]
|
||||
if path != stdioPath {
|
||||
continue
|
||||
}
|
||||
if err := claim(claims, name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readInput returns the bytes referenced by path, reading from stdin when
|
||||
// path is stdioPath.
|
||||
func readInput(flagName, path string, claims *ioClaims) ([]byte, error) {
|
||||
if path == stdioPath {
|
||||
if err := claims.claimIn(flagName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return io.ReadAll(stdinReader)
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
|
||||
// openInput returns a reader for path. When path is stdioPath the returned
|
||||
// reader wraps stdin and Close is a no-op.
|
||||
func openInput(flagName, path string, claims *ioClaims) (io.ReadCloser, error) {
|
||||
if path == stdioPath {
|
||||
if err := claims.claimIn(flagName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return io.NopCloser(stdinReader), nil
|
||||
}
|
||||
return os.Open(path)
|
||||
}
|
||||
|
||||
// writeOutput writes data to path, or to stdout when path is stdioPath. perm
|
||||
// is only used for file output. The caller must have already claimed stdout
|
||||
// via reserveOutputs before invoking with stdioPath.
|
||||
func writeOutput(path string, data []byte, perm os.FileMode, stdout io.Writer) error {
|
||||
if path == stdioPath {
|
||||
_, err := stdout.Write(data)
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, perm)
|
||||
}
|
||||
|
||||
// isStdio reports whether path is the stdio sentinel and so should skip
|
||||
// existence checks like "refuse to overwrite".
|
||||
func isStdio(path string) bool {
|
||||
return path == stdioPath
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// withStdin temporarily replaces stdinReader for the duration of t.
|
||||
func withStdin(t *testing.T, r io.Reader) {
|
||||
t.Helper()
|
||||
prev := stdinReader
|
||||
stdinReader = r
|
||||
t.Cleanup(func() { stdinReader = prev })
|
||||
}
|
||||
|
||||
func Test_readInput_stdin(t *testing.T) {
|
||||
withStdin(t, bytes.NewBufferString("hello"))
|
||||
var claims ioClaims
|
||||
|
||||
got, err := readInput("path", "-", &claims)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("hello"), got)
|
||||
assert.Equal(t, "path", claims.in)
|
||||
}
|
||||
|
||||
func Test_readInput_file(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "f")
|
||||
require.NoError(t, os.WriteFile(p, []byte("file"), 0600))
|
||||
var claims ioClaims
|
||||
|
||||
got, err := readInput("path", p, &claims)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("file"), got)
|
||||
assert.Empty(t, claims.in)
|
||||
}
|
||||
|
||||
func Test_readInput_doubleStdinErrors(t *testing.T) {
|
||||
withStdin(t, bytes.NewBufferString("hello"))
|
||||
var claims ioClaims
|
||||
|
||||
_, err := readInput("ca-key", "-", &claims)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = readInput("ca-crt", "-", &claims)
|
||||
require.EqualError(t, err, `-ca-key and -ca-crt both set to "-", only one input may read from stdin`)
|
||||
}
|
||||
|
||||
func Test_openInput_stdin(t *testing.T) {
|
||||
withStdin(t, bytes.NewBufferString("hi"))
|
||||
var claims ioClaims
|
||||
|
||||
r, err := openInput("ca", "-", &claims)
|
||||
require.NoError(t, err)
|
||||
defer r.Close()
|
||||
b, err := io.ReadAll(r)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("hi"), b)
|
||||
}
|
||||
|
||||
func Test_openInput_doubleStdinErrors(t *testing.T) {
|
||||
withStdin(t, bytes.NewBufferString("hi"))
|
||||
var claims ioClaims
|
||||
|
||||
r, err := openInput("ca", "-", &claims)
|
||||
require.NoError(t, err)
|
||||
r.Close()
|
||||
|
||||
_, err = openInput("crt", "-", &claims)
|
||||
require.EqualError(t, err, `-ca and -crt both set to "-", only one input may read from stdin`)
|
||||
}
|
||||
|
||||
func Test_writeOutput_stdout(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
|
||||
err := writeOutput("-", []byte("payload"), 0600, out)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "payload", out.String())
|
||||
}
|
||||
|
||||
func Test_writeOutput_file(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "f")
|
||||
out := &bytes.Buffer{}
|
||||
|
||||
err := writeOutput(p, []byte("payload"), 0600, out)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, out.String())
|
||||
got, err := os.ReadFile(p)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("payload"), got)
|
||||
}
|
||||
|
||||
func Test_reserveOutputs_noConflict(t *testing.T) {
|
||||
var claims ioClaims
|
||||
require.NoError(t, reserveOutputs(&claims,
|
||||
"out-key", "/tmp/key",
|
||||
"out-crt", "-",
|
||||
"out-qr", "",
|
||||
))
|
||||
assert.Equal(t, "out-crt", claims.out)
|
||||
}
|
||||
|
||||
func Test_reserveOutputs_conflict(t *testing.T) {
|
||||
var claims ioClaims
|
||||
err := reserveOutputs(&claims,
|
||||
"out-key", "-",
|
||||
"out-crt", "-",
|
||||
)
|
||||
require.EqualError(t, err, `-out-key and -out-crt both set to "-", only one output may write to stdout`)
|
||||
}
|
||||
|
||||
func Test_reserveOutputs_panicsOnOddPairs(t *testing.T) {
|
||||
defer func() {
|
||||
r := recover()
|
||||
require.NotNil(t, r)
|
||||
}()
|
||||
var claims ioClaims
|
||||
_ = reserveOutputs(&claims, "out-key")
|
||||
}
|
||||
|
||||
func Test_reserveInputs_noConflict(t *testing.T) {
|
||||
var claims ioClaims
|
||||
require.NoError(t, reserveInputs(&claims,
|
||||
"ca-key", "/tmp/ca.key",
|
||||
"ca-crt", "-",
|
||||
"in-pub", "",
|
||||
))
|
||||
assert.Equal(t, "ca-crt", claims.in)
|
||||
}
|
||||
|
||||
func Test_reserveInputs_conflict(t *testing.T) {
|
||||
var claims ioClaims
|
||||
err := reserveInputs(&claims,
|
||||
"ca-key", "-",
|
||||
"ca-crt", "-",
|
||||
)
|
||||
require.EqualError(t, err, `-ca-key and -ca-crt both set to "-", only one input may read from stdin`)
|
||||
}
|
||||
|
||||
func Test_claimIn_idempotent(t *testing.T) {
|
||||
// pre-claim then a lazy re-claim of the same flag should be a no-op
|
||||
var claims ioClaims
|
||||
require.NoError(t, claims.claimIn("ca-key"))
|
||||
require.NoError(t, claims.claimIn("ca-key"))
|
||||
assert.Equal(t, "ca-key", claims.in)
|
||||
}
|
||||
|
||||
func Test_claimOut_idempotent(t *testing.T) {
|
||||
var claims ioClaims
|
||||
require.NoError(t, claims.claimOut("out-crt"))
|
||||
require.NoError(t, claims.claimOut("out-crt"))
|
||||
assert.Equal(t, "out-crt", claims.out)
|
||||
}
|
||||
|
||||
func Test_isStdio(t *testing.T) {
|
||||
assert.True(t, isStdio("-"))
|
||||
assert.False(t, isStdio(""))
|
||||
assert.False(t, isStdio("./-"))
|
||||
assert.False(t, isStdio("foo"))
|
||||
}
|
||||
@@ -39,18 +39,26 @@ func verify(args []string, out io.Writer, errOut io.Writer) error {
|
||||
return err
|
||||
}
|
||||
|
||||
caFile, err := os.Open(*vf.caPath)
|
||||
var claims ioClaims
|
||||
if err := reserveInputs(&claims,
|
||||
"ca", *vf.caPath,
|
||||
"crt", *vf.certPath,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
caReader, err := openInput("ca", *vf.caPath, &claims)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error while reading ca: %w", err)
|
||||
}
|
||||
defer caFile.Close()
|
||||
defer caReader.Close()
|
||||
|
||||
caPool, err := cert.NewCAPoolFromPEMReader(caFile)
|
||||
caPool, err := cert.NewCAPoolFromPEMReader(caReader)
|
||||
if err != nil && !errors.Is(err, cert.ErrExpired) {
|
||||
return fmt.Errorf("error while adding ca cert to pool: %w", err)
|
||||
}
|
||||
|
||||
rawCert, err := os.ReadFile(*vf.certPath)
|
||||
rawCert, err := readInput("crt", *vf.certPath, &claims)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to read crt: %w", err)
|
||||
}
|
||||
@@ -85,6 +93,7 @@ func verifySummary() string {
|
||||
func verifyHelp(out io.Writer) {
|
||||
vf := newVerifyFlags()
|
||||
_, _ = out.Write([]byte("Usage of " + os.Args[0] + " " + verifySummary() + "\n"))
|
||||
_, _ = out.Write([]byte(stdioHelpText))
|
||||
vf.set.SetOutput(out)
|
||||
vf.set.PrintDefaults()
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ func Test_verifyHelp(t *testing.T) {
|
||||
assert.Equal(
|
||||
t,
|
||||
"Usage of "+os.Args[0]+" verify <flags>: verifies a certificate isn't expired and was signed by a trusted authority.\n"+
|
||||
" Pass \"-\" to any path flag to read from stdin or write to stdout.\n"+
|
||||
" -ca string\n"+
|
||||
" \tRequired: path to a file containing one or more ca certificates\n"+
|
||||
" -crt string\n"+
|
||||
@@ -122,3 +123,46 @@ func Test_verify(t *testing.T) {
|
||||
assert.Empty(t, eb.String())
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_verify_stdio(t *testing.T) {
|
||||
ob := &bytes.Buffer{}
|
||||
eb := &bytes.Buffer{}
|
||||
|
||||
caPub, caPriv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
ca, _ := NewTestCaCert("test-ca", caPub, caPriv, time.Now().Add(time.Hour*-1), time.Now().Add(time.Hour*2), nil, nil, nil)
|
||||
caPEM, _ := ca.MarshalPEM()
|
||||
|
||||
crt, _ := NewTestCert(ca, caPriv, "test-cert", time.Now().Add(time.Hour*-1), time.Now().Add(time.Hour), nil, nil, nil)
|
||||
crtPEM, _ := crt.MarshalPEM()
|
||||
|
||||
caFile, err := os.CreateTemp("", "verify-ca")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(caFile.Name())
|
||||
caFile.Write(caPEM)
|
||||
|
||||
// crt on stdin, ca on disk
|
||||
withStdin(t, bytes.NewReader(crtPEM))
|
||||
require.NoError(t, verify([]string{"-ca", caFile.Name(), "-crt", "-"}, ob, eb))
|
||||
assert.Empty(t, ob.String())
|
||||
assert.Empty(t, eb.String())
|
||||
|
||||
// ca on stdin, crt on disk
|
||||
certFile, err := os.CreateTemp("", "verify-cert")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(certFile.Name())
|
||||
certFile.Write(crtPEM)
|
||||
|
||||
withStdin(t, bytes.NewReader(caPEM))
|
||||
ob.Reset()
|
||||
eb.Reset()
|
||||
require.NoError(t, verify([]string{"-ca", "-", "-crt", certFile.Name()}, ob, eb))
|
||||
assert.Empty(t, ob.String())
|
||||
assert.Empty(t, eb.String())
|
||||
|
||||
// both flags on stdin should error
|
||||
withStdin(t, bytes.NewReader(caPEM))
|
||||
ob.Reset()
|
||||
eb.Reset()
|
||||
require.EqualError(t, verify([]string{"-ca", "-", "-crt", "-"}, ob, eb),
|
||||
`-ca and -crt both set to "-", only one input may read from stdin`)
|
||||
}
|
||||
|
||||
@@ -61,9 +61,12 @@ func main() {
|
||||
}
|
||||
|
||||
if *configPath == "" {
|
||||
fmt.Println("-config flag must be set")
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
p, err := config.DefaultPath()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
*configPath = p
|
||||
}
|
||||
|
||||
c := config.NewC(l)
|
||||
|
||||
@@ -3,8 +3,6 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
"github.com/slackhq/nebula"
|
||||
@@ -57,24 +55,13 @@ func (p *program) Stop(s service.Service) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileExists(filename string) bool {
|
||||
_, err := os.Stat(filename)
|
||||
if os.IsNotExist(err) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func doService(configPath *string, configTest *bool, build string, serviceFlag *string) error {
|
||||
if *configPath == "" {
|
||||
ex, err := os.Executable()
|
||||
p, err := config.DefaultPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*configPath = filepath.Dir(ex) + "/config.yaml"
|
||||
if !fileExists(*configPath) {
|
||||
*configPath = filepath.Dir(ex) + "/config.yml"
|
||||
}
|
||||
*configPath = p
|
||||
}
|
||||
|
||||
svcConfig := &service.Config{
|
||||
|
||||
+6
-3
@@ -50,9 +50,12 @@ func main() {
|
||||
}
|
||||
|
||||
if *configPath == "" {
|
||||
fmt.Println("-config flag must be set")
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
p, err := config.DefaultPath()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
*configPath = p
|
||||
}
|
||||
|
||||
l := logging.NewLogger(os.Stdout)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// DefaultPath returns a path to a config file alongside the running executable, preferring config.yaml over config.yml.
|
||||
// If neither file exists an error is returned that names both paths checked.
|
||||
func DefaultPath() (string, error) {
|
||||
ex, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return defaultPathInDir(filepath.Dir(ex))
|
||||
}
|
||||
|
||||
func defaultPathInDir(dir string) (string, error) {
|
||||
yamlPath := filepath.Join(dir, "config.yaml")
|
||||
if _, err := os.Stat(yamlPath); err == nil {
|
||||
return yamlPath, nil
|
||||
}
|
||||
ymlPath := filepath.Join(dir, "config.yml")
|
||||
if _, err := os.Stat(ymlPath); err == nil {
|
||||
return ymlPath, nil
|
||||
}
|
||||
return "", fmt.Errorf("no default config found at %s or %s", yamlPath, ymlPath)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDefaultPathInDir(t *testing.T) {
|
||||
t.Run("prefers config.yaml when both exist", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
want := filepath.Join(dir, "config.yaml")
|
||||
other := filepath.Join(dir, "config.yml")
|
||||
require.NoError(t, os.WriteFile(want, []byte("a: 1"), 0644))
|
||||
require.NoError(t, os.WriteFile(other, []byte("a: 2"), 0644))
|
||||
|
||||
got, err := defaultPathInDir(dir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, got)
|
||||
})
|
||||
|
||||
t.Run("returns config.yaml when only it exists", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
want := filepath.Join(dir, "config.yaml")
|
||||
require.NoError(t, os.WriteFile(want, []byte("a: 1"), 0644))
|
||||
|
||||
got, err := defaultPathInDir(dir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, got)
|
||||
})
|
||||
|
||||
t.Run("falls back to config.yml when only it exists", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
want := filepath.Join(dir, "config.yml")
|
||||
require.NoError(t, os.WriteFile(want, []byte("a: 1"), 0644))
|
||||
|
||||
got, err := defaultPathInDir(dir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, got)
|
||||
})
|
||||
|
||||
t.Run("errors when neither exists and names both paths", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
got, err := defaultPathInDir(dir)
|
||||
assert.Empty(t, got)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), filepath.Join(dir, "config.yaml"))
|
||||
assert.Contains(t, err.Error(), filepath.Join(dir, "config.yml"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestDefaultPath(t *testing.T) {
|
||||
got, err := DefaultPath()
|
||||
if err != nil {
|
||||
ex, exErr := os.Executable()
|
||||
require.NoError(t, exErr)
|
||||
assert.Contains(t, err.Error(), filepath.Dir(ex))
|
||||
return
|
||||
}
|
||||
ex, err := os.Executable()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, filepath.Dir(ex), filepath.Dir(got))
|
||||
assert.Contains(t, []string{"config.yaml", "config.yml"}, filepath.Base(got))
|
||||
}
|
||||
+2
-10
@@ -136,14 +136,6 @@ func (cm *connectionManager) getAndResetTrafficCheck(h *HostInfo, now time.Time)
|
||||
return in, out
|
||||
}
|
||||
|
||||
// AddTrafficWatch must be called for every new HostInfo.
|
||||
// We will continue to monitor the HostInfo until the tunnel is dropped.
|
||||
func (cm *connectionManager) AddTrafficWatch(h *HostInfo) {
|
||||
if h.out.Swap(true) == false {
|
||||
cm.trafficTimer.Add(h.localIndexId, cm.checkInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func (cm *connectionManager) Start(ctx context.Context) {
|
||||
clockSource := time.NewTicker(cm.trafficTimer.t.tickDuration)
|
||||
defer clockSource.Stop()
|
||||
@@ -306,8 +298,8 @@ func (cm *connectionManager) migrateRelayUsed(oldhostinfo, newhostinfo *HostInfo
|
||||
} else {
|
||||
cm.intf.SendMessageToHostInfo(header.Control, 0, newhostinfo, msg, make([]byte, 12), make([]byte, mtu))
|
||||
cm.l.Info("send CreateRelayRequest",
|
||||
"relayFrom", req.RelayFromAddr,
|
||||
"relayTo", req.RelayToAddr,
|
||||
"relayFrom", relayFrom,
|
||||
"relayTo", relayTo,
|
||||
"initiatorRelayIndex", req.InitiatorRelayIndex,
|
||||
"responderRelayIndex", req.ResponderRelayIndex,
|
||||
"vpnAddrs", newhostinfo.vpnAddrs,
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/slackhq/nebula/noiseutil"
|
||||
)
|
||||
|
||||
const ReplayWindow = 8192
|
||||
const ReplayWindow = 1024
|
||||
|
||||
type ConnectionState struct {
|
||||
eKey noiseutil.CipherState
|
||||
|
||||
Vendored
+1
-1
@@ -62,7 +62,7 @@ function nebula.dissector(tvbuf, pktinfo, root)
|
||||
tree:add(pf_version, tvbuf:range(0,1))
|
||||
local type = tree:add(pf_type, tvbuf:range(0,1))
|
||||
|
||||
local nebula_type = bit32.band(tvbuf:range(0,1):uint(), 0x0F)
|
||||
local nebula_type = bit.band(tvbuf:range(0,1):uint(), 0x0F)
|
||||
if nebula_type == 0 then
|
||||
local stage = tvbuf(8,8):uint64()
|
||||
tree:add(pf_subtype_handshake, tvbuf:range(1,1))
|
||||
|
||||
+92
-26
@@ -11,19 +11,21 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/gaissmai/bart"
|
||||
"github.com/miekg/dns"
|
||||
"github.com/slackhq/nebula/config"
|
||||
)
|
||||
|
||||
type dnsServer struct {
|
||||
sync.RWMutex
|
||||
l *slog.Logger
|
||||
ctx context.Context
|
||||
dnsMap4 map[string]netip.Addr
|
||||
dnsMap6 map[string]netip.Addr
|
||||
hostMap *HostMap
|
||||
myVpnAddrsTable *bart.Lite
|
||||
l *slog.Logger
|
||||
ctx context.Context
|
||||
dnsMap4 map[string]netip.Addr
|
||||
dnsMap6 map[string]netip.Addr
|
||||
hostMap *HostMap
|
||||
pki *PKI
|
||||
|
||||
// selfHost is the cached FQDN we last seeded for ourselves
|
||||
selfHost string
|
||||
|
||||
mux *dns.ServeMux
|
||||
|
||||
@@ -55,14 +57,14 @@ type dnsServer struct {
|
||||
// they no-op when DNS isn't enabled. Each Start invocation owns a ctx-cancel
|
||||
// watcher that tears the listener down on nebula shutdown. The returned
|
||||
// pointer is always non-nil, even on error.
|
||||
func newDnsServerFromConfig(ctx context.Context, l *slog.Logger, cs *CertState, hostMap *HostMap, c *config.C) (*dnsServer, error) {
|
||||
func newDnsServerFromConfig(ctx context.Context, l *slog.Logger, pki *PKI, hostMap *HostMap, c *config.C) (*dnsServer, error) {
|
||||
ds := &dnsServer{
|
||||
l: l,
|
||||
ctx: ctx,
|
||||
dnsMap4: make(map[string]netip.Addr),
|
||||
dnsMap6: make(map[string]netip.Addr),
|
||||
hostMap: hostMap,
|
||||
myVpnAddrsTable: cs.myVpnAddrsTable,
|
||||
l: l,
|
||||
ctx: ctx,
|
||||
dnsMap4: make(map[string]netip.Addr),
|
||||
dnsMap6: make(map[string]netip.Addr),
|
||||
hostMap: hostMap,
|
||||
pki: pki,
|
||||
}
|
||||
ds.mux = dns.NewServeMux()
|
||||
ds.mux.HandleFunc(".", ds.handleDnsRequest)
|
||||
@@ -76,6 +78,7 @@ func newDnsServerFromConfig(ctx context.Context, l *slog.Logger, cs *CertState,
|
||||
if err := ds.reload(c, true); err != nil {
|
||||
return ds, err
|
||||
}
|
||||
ds.seedSelf()
|
||||
return ds, nil
|
||||
}
|
||||
|
||||
@@ -113,7 +116,7 @@ func (d *dnsServer) reload(c *config.C, initial bool) error {
|
||||
d.Stop()
|
||||
}
|
||||
// Drop any records that accumulated while enabled; a later re-enable
|
||||
// will repopulate from fresh handshakes.
|
||||
// will repopulate from fresh handshakes and a fresh seedSelf.
|
||||
d.clearRecords()
|
||||
return nil
|
||||
}
|
||||
@@ -121,17 +124,14 @@ func (d *dnsServer) reload(c *config.C, initial bool) error {
|
||||
if running == nil {
|
||||
// Was disabled (or never started); bring it up now.
|
||||
go d.Start()
|
||||
return nil
|
||||
} else if !sameAddr {
|
||||
d.shutdownServer(running, runningStarted, "reload")
|
||||
// Old Start goroutine has now exited; bring up a fresh listener on the new address.
|
||||
go d.Start()
|
||||
}
|
||||
|
||||
if sameAddr {
|
||||
return nil
|
||||
}
|
||||
|
||||
d.shutdownServer(running, runningStarted, "reload")
|
||||
// Old Start goroutine has now exited; bring up a fresh listener on the
|
||||
// new address.
|
||||
go d.Start()
|
||||
// Refresh the self entry every enabled reload so cert renewals that change our name or VPN addresses are picked up.
|
||||
d.seedSelf()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -249,6 +249,20 @@ func (d *dnsServer) QueryCert(data string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// The hostmap only ever contains peers we have handshaked with, so it never carries an entry for ourselves.
|
||||
// Answer self lookups straight from the local cert state.
|
||||
if cs := d.certState(); cs != nil && cs.myVpnAddrsTable != nil && cs.myVpnAddrsTable.Contains(ip) {
|
||||
c := cs.GetDefaultCertificate()
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
b, err := c.MarshalJSON()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
hostinfo := d.hostMap.QueryVpnAddr(ip)
|
||||
if hostinfo == nil {
|
||||
return ""
|
||||
@@ -266,12 +280,60 @@ func (d *dnsServer) QueryCert(data string) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// clearRecords drops all DNS records.
|
||||
// clearRecords drops all DNS records, including the self entry.
|
||||
func (d *dnsServer) clearRecords() {
|
||||
d.Lock()
|
||||
defer d.Unlock()
|
||||
clear(d.dnsMap4)
|
||||
clear(d.dnsMap6)
|
||||
d.selfHost = ""
|
||||
}
|
||||
|
||||
// seedSelf inserts (or refreshes) a record for our own cert name pointing at our VPN addresses,
|
||||
// so a single-lighthouse network can resolve the lighthouse's own hostname without the two-process workaround.
|
||||
func (d *dnsServer) seedSelf() {
|
||||
if !d.enabled.Load() {
|
||||
return
|
||||
}
|
||||
cs := d.certState()
|
||||
if cs == nil {
|
||||
return
|
||||
}
|
||||
c := cs.GetDefaultCertificate()
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
newHost := strings.ToLower(c.Name()) + "."
|
||||
|
||||
d.Lock()
|
||||
defer d.Unlock()
|
||||
if d.selfHost != "" && d.selfHost != newHost {
|
||||
delete(d.dnsMap4, d.selfHost)
|
||||
delete(d.dnsMap6, d.selfHost)
|
||||
}
|
||||
d.selfHost = newHost
|
||||
delete(d.dnsMap4, newHost)
|
||||
delete(d.dnsMap6, newHost)
|
||||
haveV4, haveV6 := false, false
|
||||
for _, addr := range cs.myVpnAddrs {
|
||||
if addr.Is4() && !haveV4 {
|
||||
d.dnsMap4[newHost] = addr
|
||||
haveV4 = true
|
||||
} else if addr.Is6() && !haveV6 {
|
||||
d.dnsMap6[newHost] = addr
|
||||
haveV6 = true
|
||||
}
|
||||
if haveV4 && haveV6 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dnsServer) certState() *CertState {
|
||||
if d.pki == nil {
|
||||
return nil
|
||||
}
|
||||
return d.pki.getCertState()
|
||||
}
|
||||
|
||||
// Add adds the first IPv4 and IPv6 address that appears in `addresses` as the record for `host`
|
||||
@@ -309,8 +371,12 @@ func (d *dnsServer) isSelfNebulaOrLocalhost(addr string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
cs := d.certState()
|
||||
if cs == nil || cs.myVpnAddrsTable == nil {
|
||||
return false
|
||||
}
|
||||
//if we found it in this table, it's good
|
||||
return d.myVpnAddrsTable.Contains(b)
|
||||
return cs.myVpnAddrsTable.Contains(b)
|
||||
}
|
||||
|
||||
func (d *dnsServer) parseQuery(m *dns.Msg, w dns.ResponseWriter) {
|
||||
|
||||
@@ -9,7 +9,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gaissmai/bart"
|
||||
"github.com/miekg/dns"
|
||||
"github.com/slackhq/nebula/cert"
|
||||
"github.com/slackhq/nebula/cert_test"
|
||||
"github.com/slackhq/nebula/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -276,6 +279,92 @@ func TestDnsServer_Stop_beforeBind_doesNotHang(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// newTestPKI builds a minimal *PKI with a single v1 cert whose name and
|
||||
// VPN addresses are caller-provided, suitable for exercising seedSelf and
|
||||
// QueryCert self handling.
|
||||
func newTestPKI(t *testing.T, name string, addrs []netip.Addr) *PKI {
|
||||
t.Helper()
|
||||
networks := make([]netip.Prefix, 0, len(addrs))
|
||||
for _, a := range addrs {
|
||||
bits := 32
|
||||
if a.Is6() {
|
||||
bits = 128
|
||||
}
|
||||
networks = append(networks, netip.PrefixFrom(a, bits))
|
||||
}
|
||||
ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version2, cert.Curve_CURVE25519, time.Time{}, time.Time{}, nil, nil, nil)
|
||||
c, _, _, _ := cert_test.NewTestCert(cert.Version2, cert.Curve_CURVE25519, ca, caKey, name, time.Time{}, time.Time{}, networks, nil, nil)
|
||||
|
||||
addrsTable := new(bart.Lite)
|
||||
for _, a := range addrs {
|
||||
addrsTable.Insert(netip.PrefixFrom(a, a.BitLen()))
|
||||
}
|
||||
|
||||
cs := &CertState{
|
||||
v2Cert: c,
|
||||
initiatingVersion: cert.Version2,
|
||||
myVpnAddrs: addrs,
|
||||
myVpnAddrsTable: addrsTable,
|
||||
}
|
||||
pki := &PKI{}
|
||||
pki.cs.Store(cs)
|
||||
return pki
|
||||
}
|
||||
|
||||
func TestDnsServer_seedSelf_addsOwnRecord(t *testing.T) {
|
||||
ds, c := newTestDnsServer(t)
|
||||
myV4 := netip.MustParseAddr("10.0.0.1")
|
||||
myV6 := netip.MustParseAddr("fd00::1")
|
||||
ds.pki = newTestPKI(t, "lighthouse", []netip.Addr{myV4, myV6})
|
||||
setDnsConfig(c, "127.0.0.1", "0", true, true)
|
||||
require.NoError(t, ds.reload(c, true))
|
||||
|
||||
ds.seedSelf()
|
||||
got4, exists := ds.Query(dns.TypeA, "lighthouse.")
|
||||
assert.True(t, exists)
|
||||
assert.Equal(t, myV4, got4)
|
||||
got6, exists := ds.Query(dns.TypeAAAA, "lighthouse.")
|
||||
assert.True(t, exists)
|
||||
assert.Equal(t, myV6, got6)
|
||||
}
|
||||
|
||||
func TestDnsServer_seedSelf_disabled_noOp(t *testing.T) {
|
||||
ds, c := newTestDnsServer(t)
|
||||
ds.pki = newTestPKI(t, "lighthouse", []netip.Addr{netip.MustParseAddr("10.0.0.1")})
|
||||
setDnsConfig(c, "127.0.0.1", "0", true, false)
|
||||
require.NoError(t, ds.reload(c, true))
|
||||
|
||||
ds.seedSelf()
|
||||
_, exists := ds.Query(dns.TypeA, "lighthouse.")
|
||||
assert.False(t, exists)
|
||||
}
|
||||
|
||||
func TestDnsServer_clearRecords_dropsSelfHost(t *testing.T) {
|
||||
ds, c := newTestDnsServer(t)
|
||||
ds.pki = newTestPKI(t, "lighthouse", []netip.Addr{netip.MustParseAddr("10.0.0.1")})
|
||||
setDnsConfig(c, "127.0.0.1", "0", true, true)
|
||||
require.NoError(t, ds.reload(c, true))
|
||||
ds.seedSelf()
|
||||
require.NotEmpty(t, ds.selfHost)
|
||||
|
||||
ds.clearRecords()
|
||||
assert.Empty(t, ds.selfHost)
|
||||
_, exists := ds.Query(dns.TypeA, "lighthouse.")
|
||||
assert.False(t, exists)
|
||||
}
|
||||
|
||||
func TestDnsServer_QueryCert_returnsOwnCert(t *testing.T) {
|
||||
ds, _ := newTestDnsServer(t)
|
||||
myV4 := netip.MustParseAddr("10.0.0.1")
|
||||
ds.pki = newTestPKI(t, "lighthouse", []netip.Addr{myV4})
|
||||
|
||||
got := ds.QueryCert(myV4.String() + ".")
|
||||
assert.NotEmpty(t, got, "TXT lookup of our own VPN address should return our cert")
|
||||
|
||||
other := netip.MustParseAddr("10.0.0.99")
|
||||
assert.Empty(t, ds.QueryCert(other.String()+"."), "unknown peer IP should return nothing")
|
||||
}
|
||||
|
||||
func TestDnsServer_reload_disable_stopsRunningServer(t *testing.T) {
|
||||
port := freeUDPPort(t)
|
||||
ds, c := newTestDnsServer(t)
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
FROM gcr.io/distroless/static:latest
|
||||
|
||||
ARG TARGETOS TARGETARCH
|
||||
|
||||
ARG VERSION=dev
|
||||
ARG REVISION=unknown
|
||||
LABEL org.opencontainers.image.title="nebula" \
|
||||
org.opencontainers.image.description="A scalable overlay networking tool with a focus on performance, simplicity and security" \
|
||||
org.opencontainers.image.vendor="Nebula OSS" \
|
||||
org.opencontainers.image.source="https://github.com/slackhq/nebula" \
|
||||
org.opencontainers.image.version="${VERSION}" \
|
||||
org.opencontainers.image.revision="${REVISION}"
|
||||
|
||||
COPY build/$TARGETOS-$TARGETARCH/nebula /nebula
|
||||
COPY build/$TARGETOS-$TARGETARCH/nebula-cert /nebula-cert
|
||||
|
||||
|
||||
+4
-2
@@ -4,13 +4,15 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"io"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"log/slog"
|
||||
|
||||
"dario.cat/mergo"
|
||||
"github.com/google/gopacket"
|
||||
"github.com/google/gopacket/layers"
|
||||
@@ -380,7 +382,7 @@ func getAddrs(ns []netip.Prefix) []netip.Addr {
|
||||
func NewTestLogger() *slog.Logger {
|
||||
v := os.Getenv("TEST_LOGS")
|
||||
if v == "" {
|
||||
return slog.New(slog.DiscardHandler)
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
level := slog.LevelInfo
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/slackhq/nebula/header"
|
||||
"github.com/slackhq/nebula/udp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -373,6 +374,100 @@ func TestCrossStackRelaysWork(t *testing.T) {
|
||||
//relayControl.Stop()
|
||||
}
|
||||
|
||||
// TestRelayReplayProtection asserts that a relay (forwarding-type) node rejects
|
||||
// replayed relay frames. A captured relay frame, re-injected with the same
|
||||
// message counter, must be dropped by the replay window rather than re-forwarded
|
||||
// to the relay target. Before the fix, handleOutsideRelayPacket authenticated the
|
||||
// frame but never advanced the replay window, so every replay was re-forwarded.
|
||||
func TestRelayReplayProtection(t *testing.T) {
|
||||
t.Parallel()
|
||||
ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version2, cert.Curve_CURVE25519, time.Now(), time.Now().Add(10*time.Minute), nil, nil, []string{})
|
||||
myControl, myVpnIpNet, _, _ := newSimpleServer(cert.Version2, ca, caKey, "me ", "10.128.0.1/24,fc00::1/64", m{"relay": m{"use_relays": true}})
|
||||
relayControl, relayVpnIpNet, relayUdpAddr, _ := newSimpleServer(cert.Version2, ca, caKey, "relay ", "10.128.0.128/24,fc00::128/64", m{"relay": m{"am_relay": true}})
|
||||
theirUdp := netip.MustParseAddrPort("10.0.0.2:4242")
|
||||
theirControl, theirVpnIpNet, theirUdpAddr, _ := newSimpleServerWithUdp(cert.Version2, ca, caKey, "them ", "fc00::2/64", theirUdp, m{"relay": m{"use_relays": true}})
|
||||
|
||||
myVpnV6 := myVpnIpNet[1]
|
||||
relayVpnV4 := relayVpnIpNet[0]
|
||||
relayVpnV6 := relayVpnIpNet[1]
|
||||
theirVpnV6 := theirVpnIpNet[0]
|
||||
|
||||
// Teach me how to reach the relay and that them is reachable via the relay
|
||||
myControl.InjectLightHouseAddr(relayVpnV4.Addr(), relayUdpAddr)
|
||||
myControl.InjectLightHouseAddr(relayVpnV6.Addr(), relayUdpAddr)
|
||||
myControl.InjectRelays(theirVpnV6.Addr(), []netip.Addr{relayVpnV6.Addr()})
|
||||
relayControl.InjectLightHouseAddr(theirVpnV6.Addr(), theirUdpAddr)
|
||||
|
||||
r := router.NewR(t, myControl, relayControl, theirControl)
|
||||
defer r.RenderFlow()
|
||||
|
||||
myControl.Start()
|
||||
relayControl.Start()
|
||||
theirControl.Start()
|
||||
|
||||
// Establish the relayed tunnel in both directions so all handshakes complete.
|
||||
t.Log("Establish the relayed tunnel")
|
||||
myControl.InjectTunPacket(BuildTunUDPPacket(theirVpnV6.Addr(), 80, myVpnV6.Addr(), 80, []byte("Hi from me")))
|
||||
p := r.RouteForAllUntilTxTun(theirControl)
|
||||
assertUdpPacket(t, []byte("Hi from me"), p, myVpnV6.Addr(), theirVpnV6.Addr(), 80, 80)
|
||||
theirControl.InjectTunPacket(BuildTunUDPPacket(myVpnV6.Addr(), 80, theirVpnV6.Addr(), 80, []byte("Hi from them")))
|
||||
p = r.RouteForAllUntilTxTun(myControl)
|
||||
assertUdpPacket(t, []byte("Hi from them"), p, theirVpnV6.Addr(), myVpnV6.Addr(), 80, 80)
|
||||
|
||||
// Drain anything still queued on me's UDP tx so the next packet we pull is the
|
||||
// relay frame we are about to generate.
|
||||
for myControl.GetFromUDP(false) != nil {
|
||||
}
|
||||
|
||||
// Capture a single legitimate relay frame that me transmits toward the relay.
|
||||
t.Log("Capture a relay frame from me -> relay")
|
||||
myControl.InjectTunPacket(BuildTunUDPPacket(theirVpnV6.Addr(), 80, myVpnV6.Addr(), 80, []byte("replay me")))
|
||||
relayFrame := myControl.GetFromUDP(true)
|
||||
require.Equal(t, relayUdpAddr, relayFrame.To, "captured frame should be addressed to the relay")
|
||||
var fh header.H
|
||||
require.NoError(t, fh.Parse(relayFrame.Data))
|
||||
require.Equal(t, header.Message, fh.Type)
|
||||
require.Equal(t, header.MessageRelay, fh.Subtype)
|
||||
|
||||
// drainForwards counts relay frames the relay forwards toward them within the
|
||||
// settle window. We match on destination + (Message, MessageRelay) so the
|
||||
// relay's own direct traffic to them can't be miscounted.
|
||||
drainForwards := func(settle time.Duration) int {
|
||||
ch := relayControl.GetUDPTxChan()
|
||||
count := 0
|
||||
for {
|
||||
select {
|
||||
case pkt := <-ch:
|
||||
var ph header.H
|
||||
if pkt.To == theirUdpAddr && ph.Parse(pkt.Data) == nil &&
|
||||
ph.Type == header.Message && ph.Subtype == header.MessageRelay {
|
||||
count++
|
||||
}
|
||||
pkt.Release()
|
||||
case <-time.After(settle):
|
||||
return count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// First delivery of the captured frame: the relay should forward it once.
|
||||
t.Log("Deliver the captured frame once; relay forwards it to them")
|
||||
relayControl.InjectUDPPacket(relayFrame)
|
||||
require.Equal(t, 1, drainForwards(200*time.Millisecond), "relay should forward the first, legitimate copy")
|
||||
|
||||
// Replay the exact same frame several times. A correct replay window rejects
|
||||
// these duplicates so the relay forwards none of them.
|
||||
t.Log("Replay the captured frame; relay must drop the duplicates")
|
||||
const replays = 3
|
||||
for i := 0; i < replays; i++ {
|
||||
relayControl.InjectUDPPacket(relayFrame)
|
||||
}
|
||||
forwarded := drainForwards(200 * time.Millisecond)
|
||||
assert.Equal(t, 0, forwarded, "relay re-forwarded %d/%d replayed relay frames; replay protection is ineffective on relay tunnels", forwarded, replays)
|
||||
|
||||
r.RenderHostmaps("Final hostmaps", myControl, relayControl, theirControl)
|
||||
}
|
||||
|
||||
func TestCloseTunnelAuthenticated(t *testing.T) {
|
||||
t.Parallel()
|
||||
ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version1, cert.Curve_CURVE25519, time.Now(), time.Now().Add(10*time.Minute), nil, nil, []string{})
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
package nebula
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInnerECN(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
pkt []byte
|
||||
want byte
|
||||
}{
|
||||
{"empty", nil, 0},
|
||||
{"v4_NotECT", v4WithToS(0x00), 0x00},
|
||||
{"v4_ECT0", v4WithToS(0x02), 0x02},
|
||||
{"v4_ECT1", v4WithToS(0x01), 0x01},
|
||||
{"v4_CE", v4WithToS(0x03), 0x03},
|
||||
{"v4_DSCP_then_NotECT", v4WithToS(0x88 | 0x00), 0x00},
|
||||
{"v4_DSCP_then_CE", v4WithToS(0x88 | 0x03), 0x03},
|
||||
{"v6_NotECT", v6WithTC(0x00), 0x00},
|
||||
{"v6_ECT0", v6WithTC(0x02), 0x02},
|
||||
{"v6_CE", v6WithTC(0x03), 0x03},
|
||||
{"v6_DSCP_then_CE", v6WithTC(0x88 | 0x03), 0x03},
|
||||
{"unknown_version", []byte{0xa5, 0xff}, 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := innerECN(c.pkt)
|
||||
if got != c.want {
|
||||
t.Errorf("innerECN=0x%02x want 0x%02x", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// v4WithToS returns a 2-byte slice tall enough for innerECN: byte 0 carries
|
||||
// version=4 in the high nibble, byte 1 is the full ToS so we exercise both
|
||||
// the DSCP and ECN portions through the byte 1 mask.
|
||||
func v4WithToS(tos byte) []byte {
|
||||
return []byte{0x45, tos}
|
||||
}
|
||||
|
||||
// v6WithTC builds a 2-byte slice that places a known traffic class value
|
||||
// across bytes 0 (high nibble of TC) and 1 (low nibble of TC). innerECN
|
||||
// extracts ECN as (b[1]>>4)&0x03, which corresponds to TC[1:0].
|
||||
func v6WithTC(tc byte) []byte {
|
||||
return []byte{0x60 | (tc>>4)&0x0f, (tc & 0x0f) << 4}
|
||||
}
|
||||
|
||||
func TestApplyOuterECN(t *testing.T) {
|
||||
silent := slog.New(slog.DiscardHandler)
|
||||
hi := &HostInfo{}
|
||||
|
||||
// Build a v4 packet helper with a given inner ECN field.
|
||||
v4 := func(innerECN byte) []byte {
|
||||
// 20-byte minimal IPv4 header with ToS = innerECN (DSCP zeroed).
|
||||
return []byte{
|
||||
0x45, innerECN, 0, 28,
|
||||
0, 0, 0x40, 0,
|
||||
64, 6, 0, 0,
|
||||
10, 0, 0, 1,
|
||||
10, 0, 0, 2,
|
||||
}
|
||||
}
|
||||
// Build a v6 packet helper with a given inner ECN field. ECN occupies
|
||||
// TC[1:0] which sit at byte 1 mask 0x30.
|
||||
v6 := func(innerECN byte) []byte {
|
||||
// 40-byte minimal IPv6 header with TC[1:0] = innerECN.
|
||||
pkt := make([]byte, 40)
|
||||
pkt[0] = 0x60 // version=6, TC[7:4]=0
|
||||
pkt[1] = (innerECN & 0x03) << 4 // TC[3:0]: low 2 bits = ECN, top 2 = DSCP-low (0)
|
||||
return pkt
|
||||
}
|
||||
|
||||
type cell struct {
|
||||
outer byte
|
||||
inner byte
|
||||
wantECN byte
|
||||
wantSame bool // expect inner unchanged (true => verify the byte didn't move)
|
||||
}
|
||||
|
||||
// RFC 6040 normal-mode combine table. Only outer==CE causes mutation.
|
||||
table := []cell{
|
||||
{ecnNotECT, ecnNotECT, ecnNotECT, true},
|
||||
{ecnNotECT, ecnECT0, ecnECT0, true},
|
||||
{ecnNotECT, ecnECT1, ecnECT1, true},
|
||||
{ecnNotECT, ecnCE, ecnCE, true},
|
||||
|
||||
{ecnECT0, ecnNotECT, ecnNotECT, true},
|
||||
{ecnECT0, ecnECT0, ecnECT0, true},
|
||||
{ecnECT0, ecnECT1, ecnECT1, true},
|
||||
{ecnECT0, ecnCE, ecnCE, true},
|
||||
|
||||
{ecnECT1, ecnNotECT, ecnNotECT, true},
|
||||
{ecnECT1, ecnECT0, ecnECT0, true},
|
||||
{ecnECT1, ecnECT1, ecnECT1, true},
|
||||
{ecnECT1, ecnCE, ecnCE, true},
|
||||
|
||||
{ecnCE, ecnNotECT, ecnNotECT, true}, // legacy: log, leave alone
|
||||
{ecnCE, ecnECT0, ecnCE, false}, // CE folded in
|
||||
{ecnCE, ecnECT1, ecnCE, false},
|
||||
{ecnCE, ecnCE, ecnCE, true},
|
||||
}
|
||||
|
||||
for _, c := range table {
|
||||
t.Run("v4", func(t *testing.T) {
|
||||
pkt := v4(c.inner)
|
||||
applyOuterECN(pkt, c.outer, hi, silent)
|
||||
got := pkt[1] & 0x03
|
||||
if got != c.wantECN {
|
||||
t.Errorf("v4 outer=0x%02x inner=0x%02x: got 0x%02x want 0x%02x", c.outer, c.inner, got, c.wantECN)
|
||||
}
|
||||
})
|
||||
t.Run("v6", func(t *testing.T) {
|
||||
pkt := v6(c.inner)
|
||||
applyOuterECN(pkt, c.outer, hi, silent)
|
||||
got := (pkt[1] >> 4) & 0x03
|
||||
if got != c.wantECN {
|
||||
t.Errorf("v6 outer=0x%02x inner=0x%02x: got 0x%02x want 0x%02x", c.outer, c.inner, got, c.wantECN)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -397,7 +397,7 @@ firewall:
|
||||
# `drop` (default): silently drop the packet.
|
||||
# `reject`: send a reject reply.
|
||||
# - For TCP, this will be a RST "Connection Reset" packet.
|
||||
# - For other protocols, this will be an ICMP port unreachable packet.
|
||||
# - For other protocols, this will be an ICMP "Destination unreachable: Communication administratively prohibited" packet.
|
||||
outbound_action: drop
|
||||
inbound_action: drop
|
||||
|
||||
|
||||
+43
-26
@@ -58,8 +58,9 @@ type Firewall struct {
|
||||
routableNetworks *bart.Lite
|
||||
|
||||
// assignedNetworks is a list of vpn networks assigned to us in the certificate.
|
||||
assignedNetworks []netip.Prefix
|
||||
hasUnsafeNetworks bool
|
||||
assignedNetworks []netip.Prefix
|
||||
// unsafeNetworks is the list of unsafe networks issued to us in the certificate
|
||||
unsafeNetworks []netip.Prefix
|
||||
|
||||
rules string
|
||||
rulesVersion uint16
|
||||
@@ -158,10 +159,9 @@ func NewFirewall(l *slog.Logger, tcpTimeout, UDPTimeout, defaultTimeout time.Dur
|
||||
assignedNetworks = append(assignedNetworks, network)
|
||||
}
|
||||
|
||||
hasUnsafeNetworks := false
|
||||
for _, n := range c.UnsafeNetworks() {
|
||||
unsafeNetworks := c.UnsafeNetworks()
|
||||
for _, n := range unsafeNetworks {
|
||||
routableNetworks.Insert(n)
|
||||
hasUnsafeNetworks = true
|
||||
}
|
||||
|
||||
return &Firewall{
|
||||
@@ -169,15 +169,15 @@ func NewFirewall(l *slog.Logger, tcpTimeout, UDPTimeout, defaultTimeout time.Dur
|
||||
Conns: make(map[firewall.Packet]*conn),
|
||||
TimerWheel: NewTimerWheel[firewall.Packet](tmin, tmax),
|
||||
},
|
||||
InRules: newFirewallTable(),
|
||||
OutRules: newFirewallTable(),
|
||||
TCPTimeout: tcpTimeout,
|
||||
UDPTimeout: UDPTimeout,
|
||||
DefaultTimeout: defaultTimeout,
|
||||
routableNetworks: routableNetworks,
|
||||
assignedNetworks: assignedNetworks,
|
||||
hasUnsafeNetworks: hasUnsafeNetworks,
|
||||
l: l,
|
||||
InRules: newFirewallTable(),
|
||||
OutRules: newFirewallTable(),
|
||||
TCPTimeout: tcpTimeout,
|
||||
UDPTimeout: UDPTimeout,
|
||||
DefaultTimeout: defaultTimeout,
|
||||
routableNetworks: routableNetworks,
|
||||
assignedNetworks: assignedNetworks,
|
||||
unsafeNetworks: unsafeNetworks,
|
||||
l: l,
|
||||
|
||||
incomingMetrics: firewallMetrics{
|
||||
droppedLocalAddr: metrics.GetOrRegisterCounter("firewall.incoming.dropped.local_addr", nil),
|
||||
@@ -897,7 +897,7 @@ func (flc *firewallLocalCIDR) addRule(f *Firewall, localCidr string) error {
|
||||
}
|
||||
|
||||
if localCidr == "" {
|
||||
if !f.hasUnsafeNetworks || f.defaultLocalCIDRAny {
|
||||
if len(f.unsafeNetworks) == 0 || f.defaultLocalCIDRAny {
|
||||
flc.Any = true
|
||||
return nil
|
||||
}
|
||||
@@ -1055,7 +1055,6 @@ func (r *rule) sanity() error {
|
||||
}
|
||||
|
||||
func parsePort(s string) (int32, int32, error) {
|
||||
var err error
|
||||
const notAPort int32 = -2
|
||||
if s == "any" {
|
||||
return firewall.PortAny, firewall.PortAny, nil
|
||||
@@ -1064,11 +1063,11 @@ func parsePort(s string) (int32, int32, error) {
|
||||
return firewall.PortFragment, firewall.PortFragment, nil
|
||||
}
|
||||
if !strings.Contains(s, `-`) {
|
||||
rPort, err := strconv.Atoi(s)
|
||||
rPort, err := parsePortValue("", s)
|
||||
if err != nil {
|
||||
return notAPort, notAPort, fmt.Errorf("was not a number; `%s`", s)
|
||||
return notAPort, notAPort, err
|
||||
}
|
||||
return int32(rPort), int32(rPort), nil
|
||||
return rPort, rPort, nil
|
||||
}
|
||||
|
||||
sPorts := strings.SplitN(s, `-`, 2)
|
||||
@@ -1079,22 +1078,40 @@ func parsePort(s string) (int32, int32, error) {
|
||||
return notAPort, notAPort, fmt.Errorf("appears to be a range but could not be parsed; `%s`", s)
|
||||
}
|
||||
|
||||
rStartPort, err := strconv.Atoi(sPorts[0])
|
||||
startPort, err := parsePortValue("beginning range ", sPorts[0])
|
||||
if err != nil {
|
||||
return notAPort, notAPort, fmt.Errorf("beginning range was not a number; `%s`", sPorts[0])
|
||||
return notAPort, notAPort, err
|
||||
}
|
||||
|
||||
rEndPort, err := strconv.Atoi(sPorts[1])
|
||||
endPort, err := parsePortValue("ending range ", sPorts[1])
|
||||
if err != nil {
|
||||
return notAPort, notAPort, fmt.Errorf("ending range was not a number; `%s`", sPorts[1])
|
||||
return notAPort, notAPort, err
|
||||
}
|
||||
|
||||
startPort := int32(rStartPort)
|
||||
endPort := int32(rEndPort)
|
||||
|
||||
if startPort == firewall.PortAny {
|
||||
endPort = firewall.PortAny
|
||||
}
|
||||
|
||||
return startPort, endPort, nil
|
||||
}
|
||||
|
||||
// parsePortValue accepts a base-10 decimal in [0, 65535] and returns it
|
||||
// widened to int32. Using strconv.ParseUint with bitSize 16 rejects
|
||||
// negative input, out-of-range input (>65535), and any non-decimal byte
|
||||
// by construction, so the int32 widening that follows is provably safe
|
||||
// and cannot collide with firewall.PortAny (0) or firewall.PortFragment
|
||||
// (-1) via integer truncation.
|
||||
//
|
||||
// prefix is prepended to both error messages so callers can disambiguate
|
||||
// the single-port path (prefix="") from the range bounds (prefix="beginning
|
||||
// range " / "ending range "), preserving the historical error strings.
|
||||
func parsePortValue(prefix, s string) (int32, error) {
|
||||
n, err := strconv.ParseUint(s, 10, 16)
|
||||
if err == nil {
|
||||
return int32(n), nil
|
||||
}
|
||||
if errors.Is(err, strconv.ErrRange) {
|
||||
return 0, fmt.Errorf("%sout of range [0,65535]; `%s`", prefix, s)
|
||||
}
|
||||
return 0, fmt.Errorf("%swas not a number; `%s`", prefix, s)
|
||||
}
|
||||
|
||||
+2
-4
@@ -5,8 +5,6 @@ import (
|
||||
"log/slog"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/slackhq/nebula/logging"
|
||||
)
|
||||
|
||||
// ConntrackCache is used as a local routine cache to know if a given flow
|
||||
@@ -58,8 +56,8 @@ func (c *ConntrackCacheTicker) Get() ConntrackCache {
|
||||
if tick := c.cacheTick.Load(); tick != c.cacheV {
|
||||
c.cacheV = tick
|
||||
if ll := len(c.cache); ll > 0 {
|
||||
if c.l.Enabled(context.Background(), logging.LevelTrace) {
|
||||
c.l.Log(context.Background(), logging.LevelTrace, "resetting conntrack cache", "len", ll)
|
||||
if c.l.Enabled(context.Background(), slog.LevelDebug) {
|
||||
c.l.Debug("resetting conntrack cache", "len", ll)
|
||||
}
|
||||
c.cache = make(ConntrackCache, ll)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/slackhq/nebula/logging"
|
||||
"github.com/slackhq/nebula/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -31,27 +30,27 @@ func newFixedTicker(t *testing.T, l *slog.Logger, cacheLen int) *ConntrackCacheT
|
||||
|
||||
func TestConntrackCacheTicker_Get_TextFormat(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
l := test.NewLoggerWithOutputAndLevel(buf, logging.LevelTrace)
|
||||
l := test.NewLoggerWithOutputAndLevel(buf, slog.LevelDebug)
|
||||
|
||||
c := newFixedTicker(t, l, 3)
|
||||
c.Get()
|
||||
|
||||
assert.Equal(t, "level=DEBUG-4 msg=\"resetting conntrack cache\" len=3\n", buf.String())
|
||||
assert.Equal(t, "level=DEBUG msg=\"resetting conntrack cache\" len=3\n", buf.String())
|
||||
}
|
||||
|
||||
func TestConntrackCacheTicker_Get_JSONFormat(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
l := test.NewJSONLoggerWithOutput(buf, logging.LevelTrace)
|
||||
l := test.NewJSONLoggerWithOutput(buf, slog.LevelDebug)
|
||||
|
||||
c := newFixedTicker(t, l, 2)
|
||||
c.Get()
|
||||
|
||||
assert.JSONEq(t, `{"level":"DEBUG-4","msg":"resetting conntrack cache","len":2}`, strings.TrimSpace(buf.String()))
|
||||
assert.JSONEq(t, `{"level":"DEBUG","msg":"resetting conntrack cache","len":2}`, strings.TrimSpace(buf.String()))
|
||||
}
|
||||
|
||||
func TestConntrackCacheTicker_Get_QuietBelowTrace(t *testing.T) {
|
||||
func TestConntrackCacheTicker_Get_QuietBelowDebug(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
l := test.NewLoggerWithOutputAndLevel(buf, slog.LevelDebug)
|
||||
l := test.NewLoggerWithOutputAndLevel(buf, slog.LevelInfo)
|
||||
|
||||
c := newFixedTicker(t, l, 5)
|
||||
c.Get()
|
||||
@@ -61,7 +60,7 @@ func TestConntrackCacheTicker_Get_QuietBelowTrace(t *testing.T) {
|
||||
|
||||
func TestConntrackCacheTicker_Get_QuietWhenCacheEmpty(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
l := test.NewLoggerWithOutputAndLevel(buf, logging.LevelTrace)
|
||||
l := test.NewLoggerWithOutputAndLevel(buf, slog.LevelDebug)
|
||||
|
||||
c := newFixedTicker(t, l, 0)
|
||||
c.Get()
|
||||
|
||||
@@ -1029,6 +1029,75 @@ func Test_parsePort(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Test_parsePort_invalid covers inputs that must error. The named bug is
|
||||
// that int32(strconv.Atoi("4294967296")) truncates to 0 == firewall.PortAny,
|
||||
// silently turning a typo into a match-all-ports rule; the rest are
|
||||
// representative syntax/range probes.
|
||||
func Test_parsePort_invalid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErrContains string
|
||||
}{
|
||||
// Numeric overflow (the named bug + boundary).
|
||||
{"named bug: 2^32 truncates to PortAny", "4294967296", "out of range"},
|
||||
{"just above max real port", "65536", "out of range"},
|
||||
|
||||
// Negatives route through the range branch and hit the empty-half
|
||||
// guard; included as defense in depth so a future refactor cannot
|
||||
// accidentally reach the int32 cast.
|
||||
{"negative", "-1", "could not be parsed"},
|
||||
|
||||
// Syntax probes.
|
||||
{"NUL between digits", "4\x002", "was not a number"},
|
||||
{"hex notation", "0x10", "was not a number"},
|
||||
{"scientific notation", "1e3", "was not a number"},
|
||||
{"leading whitespace", " 42", "was not a number"},
|
||||
{"fullwidth digits", "42", "was not a number"},
|
||||
|
||||
// Range branch.
|
||||
{"range upper out of range", "1-65536", "ending range out of range"},
|
||||
{"range lower out of range", "65536-65537", "beginning range out of range"},
|
||||
{"range with negative upper", "1--1", "ending range was not a number"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, _, err := parsePort(tc.input)
|
||||
require.Error(t, err, "input %q must error", tc.input)
|
||||
require.ErrorContains(t, err, tc.wantErrContains)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test_parsePort_valid_boundaries locks in success cases at 0, 1, and 65535
|
||||
// so a future refactor cannot regress the boundaries.
|
||||
func Test_parsePort_valid_boundaries(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantStart int32
|
||||
wantEnd int32
|
||||
}{
|
||||
{"zero is PortAny", "0", 0, 0},
|
||||
{"min real port", "1", 1, 1},
|
||||
{"max real port", "65535", 65535, 65535},
|
||||
{"range zero to max forces end to zero", "0-65535", 0, 0},
|
||||
{"range max to max", "65535-65535", 65535, 65535},
|
||||
{"range one to max", "1-65535", 1, 65535},
|
||||
{"range with whitespace inside", " 1 - 2 ", 1, 2},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s, e, err := parsePort(tc.input)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.wantStart, s, "start port")
|
||||
assert.Equal(t, tc.wantEnd, e, "end port")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFirewallFromConfig(t *testing.T) {
|
||||
l := test.NewLogger()
|
||||
// Test a bad rule definition
|
||||
|
||||
@@ -9,7 +9,7 @@ require (
|
||||
github.com/armon/go-radix v1.0.0
|
||||
github.com/cyberdelia/go-metrics-graphite v0.0.0-20161219230853-39f87cc3b432
|
||||
github.com/flynn/noise v1.1.0
|
||||
github.com/gaissmai/bart v0.26.1
|
||||
github.com/gaissmai/bart v0.28.0
|
||||
github.com/gogo/protobuf v1.3.2
|
||||
github.com/google/gopacket v1.1.19
|
||||
github.com/kardianos/service v1.2.4
|
||||
@@ -24,12 +24,12 @@ require (
|
||||
github.com/vishvananda/netlink v1.3.1
|
||||
go.uber.org/goleak v1.3.0
|
||||
go.yaml.in/yaml/v3 v3.0.4
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/exp v0.0.0-20230725093048-515e97ebf090
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/sys v0.43.0
|
||||
golang.org/x/term v0.42.0
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/sync v0.21.0
|
||||
golang.org/x/sys v0.46.0
|
||||
golang.org/x/term v0.44.0
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2
|
||||
golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b
|
||||
golang.zx2c4.com/wireguard/windows v0.6.1
|
||||
|
||||
@@ -26,8 +26,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg=
|
||||
github.com/flynn/noise v1.1.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag=
|
||||
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
|
||||
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
||||
github.com/gaissmai/bart v0.28.0 h1:89yZLo8NmyqD0RYgJ3QO9HhqqGGw+oWhf90cZm69Lko=
|
||||
github.com/gaissmai/bart v0.28.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||
@@ -162,8 +162,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 h1:Di6/M8l0O2lCLc6VVRWhgCiApHV8MnQurBnFSHsQtNY=
|
||||
golang.org/x/exp v0.0.0-20230725093048-515e97ebf090/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
@@ -182,8 +182,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL
|
||||
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -191,8 +191,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -208,11 +208,11 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
|
||||
@@ -13,6 +13,7 @@ var (
|
||||
ErrUnknownSubtype = errors.New("unknown handshake subtype")
|
||||
ErrMissingContent = errors.New("expected handshake content but message was empty")
|
||||
ErrUnexpectedContent = errors.New("received unexpected handshake content")
|
||||
ErrInvalidRemoteIndex = errors.New("peer sent an invalid index in handshake payload")
|
||||
ErrIndexAllocation = errors.New("failed to allocate local index")
|
||||
ErrNoCredential = errors.New("no handshake credential available for cert version")
|
||||
ErrAsymmetricCipherKeys = errors.New("noise produced only one cipher key")
|
||||
|
||||
+10
-2
@@ -312,11 +312,19 @@ func (m *Machine) processPayload(msg []byte, flags msgFlags) error {
|
||||
|
||||
// Process payload
|
||||
if flags.expectsPayload {
|
||||
var remoteIndex uint32
|
||||
if m.result.Initiator {
|
||||
m.result.RemoteIndex = payload.ResponderIndex
|
||||
remoteIndex = payload.ResponderIndex
|
||||
} else {
|
||||
m.result.RemoteIndex = payload.InitiatorIndex
|
||||
remoteIndex = payload.InitiatorIndex
|
||||
}
|
||||
// The payload presence check above can be satisfied by Time alone, so a payload
|
||||
// could still carry a zero index here. We need to reject it.
|
||||
if remoteIndex == 0 {
|
||||
m.failed = true
|
||||
return ErrInvalidRemoteIndex
|
||||
}
|
||||
m.result.RemoteIndex = remoteIndex
|
||||
m.result.HandshakeTime = payload.Time
|
||||
m.payloadSet = true
|
||||
}
|
||||
|
||||
@@ -229,6 +229,24 @@ func TestMachineProcessPayload(t *testing.T) {
|
||||
require.ErrorIs(t, err, ErrUnexpectedContent)
|
||||
assert.True(t, m.Failed())
|
||||
})
|
||||
|
||||
t.Run("zero initiator index on responder is fatal", func(t *testing.T) {
|
||||
m := newTestMachine(t, cs, v, false, 100)
|
||||
bytes := MarshalPayload(nil, Payload{InitiatorIndex: 0, Time: 1})
|
||||
err := m.processPayload(bytes, msgFlags{expectsPayload: true})
|
||||
require.ErrorIs(t, err, ErrInvalidRemoteIndex)
|
||||
assert.True(t, m.Failed())
|
||||
assert.Zero(t, m.result.RemoteIndex)
|
||||
})
|
||||
|
||||
t.Run("zero responder index on initiator is fatal", func(t *testing.T) {
|
||||
m := newTestMachine(t, cs, v, true, 100)
|
||||
bytes := MarshalPayload(nil, Payload{InitiatorIndex: 100, ResponderIndex: 0, Time: 1})
|
||||
err := m.processPayload(bytes, msgFlags{expectsPayload: true})
|
||||
require.ErrorIs(t, err, ErrInvalidRemoteIndex)
|
||||
assert.True(t, m.Failed())
|
||||
assert.Zero(t, m.result.RemoteIndex)
|
||||
})
|
||||
}
|
||||
|
||||
// TestMachineRequireComplete checks the fail-on-incomplete-handshake path
|
||||
|
||||
@@ -83,6 +83,7 @@ type HandshakeHostInfo struct {
|
||||
initiatingVersionOverride cert.Version // Should we use a non-default cert version for this handshake?
|
||||
counter int64 // How many attempts have we made so far
|
||||
lastRemotes []netip.AddrPort // Remotes that we sent to during the previous attempt
|
||||
lastRelays []netip.Addr // Relays we attempted to use during the previous attempt
|
||||
packetStore []*cachedPacket // A set of packets to be transmitted once the handshake completes
|
||||
|
||||
hostinfo *HostInfo
|
||||
@@ -217,7 +218,6 @@ func (hm *HandshakeManager) handleOutbound(vpnIp netip.Addr, lighthouseTriggered
|
||||
fields := []any{
|
||||
"udpAddrs", hh.hostinfo.remotes.CopyAddrs(hm.mainHostMap.GetPreferredRanges()),
|
||||
"initiatorIndex", hh.hostinfo.localIndexId,
|
||||
"remoteIndex", hh.hostinfo.remoteIndexId,
|
||||
"durationNs", time.Since(hh.startTime).Nanoseconds(),
|
||||
}
|
||||
// hh.machine can be nil here if buildStage0Packet never succeeded
|
||||
@@ -323,7 +323,7 @@ func (hm *HandshakeManager) handleOutbound(vpnIp netip.Addr, lighthouseTriggered
|
||||
)
|
||||
}
|
||||
|
||||
hm.f.relayManager.StartRelays(hm.f, vpnIp, hostinfo, stage0)
|
||||
hm.f.relayManager.StartRelays(hm.f, vpnIp, hh, stage0)
|
||||
|
||||
// If a lighthouse triggered this attempt then we are still in the timer wheel and do not need to re-add
|
||||
if !lighthouseTriggered {
|
||||
@@ -465,7 +465,6 @@ func (hm *HandshakeManager) CheckAndComplete(hostinfo *HostInfo, handshakePacket
|
||||
// We have a collision, but this can happen since we can't control
|
||||
// the remote ID. Just log about the situation as a note.
|
||||
hostinfo.logger(hm.l).Info("New host shadows existing host remoteIndex",
|
||||
"remoteIndex", hostinfo.remoteIndexId,
|
||||
"collision", existingRemoteIndex.vpnAddrs,
|
||||
)
|
||||
}
|
||||
@@ -488,7 +487,6 @@ func (hm *HandshakeManager) Complete(hostinfo *HostInfo, f *Interface) {
|
||||
// We have a collision, but this can happen since we can't control
|
||||
// the remote ID. Just log about the situation as a note.
|
||||
hostinfo.logger(hm.l).Info("New host shadows existing host remoteIndex",
|
||||
"remoteIndex", hostinfo.remoteIndexId,
|
||||
"collision", existingRemoteIndex.vpnAddrs,
|
||||
)
|
||||
}
|
||||
@@ -798,7 +796,6 @@ func (hm *HandshakeManager) beginHandshake(via ViaSender, packet []byte, h *head
|
||||
}
|
||||
|
||||
hm.sendHandshakeResponse(via, response, hostinfo, false)
|
||||
f.connectionManager.AddTrafficWatch(hostinfo)
|
||||
hostinfo.remotes.RefreshFromHandshake(vpnAddrs)
|
||||
|
||||
// Don't wait for UpdateWorker
|
||||
@@ -965,7 +962,6 @@ func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostIn
|
||||
hostinfo.buildNetworks(f.myVpnNetworksTable, remoteCert.Certificate)
|
||||
|
||||
hm.Complete(hostinfo, f)
|
||||
f.connectionManager.AddTrafficWatch(hostinfo)
|
||||
|
||||
if len(hh.packetStore) > 0 {
|
||||
if f.l.Enabled(context.Background(), slog.LevelDebug) {
|
||||
@@ -974,7 +970,6 @@ func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostIn
|
||||
nb := make([]byte, 12, 12)
|
||||
out := make([]byte, mtu)
|
||||
for _, cp := range hh.packetStore {
|
||||
//todo use a sendbatcher
|
||||
cp.callback(cp.messageType, cp.messageSubType, hostinfo, cp.packet, nb, out)
|
||||
}
|
||||
f.cachedPacketMetrics.sent.Inc(int64(len(hh.packetStore)))
|
||||
|
||||
+6
-1
@@ -138,9 +138,9 @@ func (rs *RelayState) InsertRelayTo(ip netip.Addr) {
|
||||
}
|
||||
|
||||
func (rs *RelayState) CopyRelayIps() []netip.Addr {
|
||||
ret := make([]netip.Addr, len(rs.relays))
|
||||
rs.RLock()
|
||||
defer rs.RUnlock()
|
||||
ret := make([]netip.Addr, len(rs.relays))
|
||||
copy(ret, rs.relays)
|
||||
return ret
|
||||
}
|
||||
@@ -623,6 +623,11 @@ func (hm *HostMap) unlockedAddHostInfo(hostinfo *HostInfo, f *Interface) {
|
||||
hm.Indexes[hostinfo.localIndexId] = hostinfo
|
||||
hm.RemoteIndexes[hostinfo.remoteIndexId] = hostinfo
|
||||
|
||||
hostinfo.out.Store(true)
|
||||
if f.connectionManager != nil { // f.connectionManager is only nil in some unit tests
|
||||
f.connectionManager.trafficTimer.Add(hostinfo.localIndexId, f.connectionManager.checkInterval)
|
||||
}
|
||||
|
||||
if hm.l.Enabled(context.Background(), slog.LevelDebug) {
|
||||
hm.l.Debug("Hostmap vpnIp added",
|
||||
"hostMap", m{"vpnAddrs": hostinfo.vpnAddrs, "mapTotalSize": len(hm.Hosts),
|
||||
|
||||
@@ -2,7 +2,6 @@ package nebula
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
|
||||
@@ -10,17 +9,10 @@ import (
|
||||
"github.com/slackhq/nebula/header"
|
||||
"github.com/slackhq/nebula/iputil"
|
||||
"github.com/slackhq/nebula/noiseutil"
|
||||
"github.com/slackhq/nebula/overlay/batch"
|
||||
"github.com/slackhq/nebula/routing"
|
||||
"github.com/slackhq/nebula/wire"
|
||||
)
|
||||
|
||||
func (f *Interface) consumeInsidePacket(pkt wire.TunPacket, fwPacket *firewall.Packet, nb []byte, sendBatch *batch.SendBatch, rejectBuf []byte, q int, localCache firewall.ConntrackCache) {
|
||||
// pkt.Bytes is either one IP datagram (GSO zero) or a TSO/USO
|
||||
// superpacket. In both cases the L3+L4 headers at the start describe
|
||||
// the same 5-tuple every segment will share, so a single newPacket /
|
||||
// firewall check covers the whole superpacket.
|
||||
packet := pkt.Bytes
|
||||
func (f *Interface) consumeInsidePacket(packet []byte, fwPacket *firewall.Packet, nb, out []byte, q int, localCache firewall.ConntrackCache) {
|
||||
err := newPacket(packet, false, fwPacket)
|
||||
if err != nil {
|
||||
if f.l.Enabled(context.Background(), slog.LevelDebug) {
|
||||
@@ -45,10 +37,7 @@ func (f *Interface) consumeInsidePacket(pkt wire.TunPacket, fwPacket *firewall.P
|
||||
// routes packets from the Nebula addr to the Nebula addr through the Nebula
|
||||
// TUN device.
|
||||
if immediatelyForwardToSelf {
|
||||
err := pkt.PerSegment(func(seg []byte) error {
|
||||
_, werr := f.readers[q].Write(seg)
|
||||
return werr
|
||||
})
|
||||
_, err := f.readers[q].Write(packet)
|
||||
if err != nil {
|
||||
f.l.Error("Failed to forward to tun", "error", err)
|
||||
}
|
||||
@@ -64,23 +53,11 @@ func (f *Interface) consumeInsidePacket(pkt wire.TunPacket, fwPacket *firewall.P
|
||||
}
|
||||
|
||||
hostinfo, ready := f.getOrHandshakeConsiderRouting(fwPacket, func(hh *HandshakeHostInfo) {
|
||||
// borrowed: PerSegment builds each segment in the kernel-supplied pkt
|
||||
// bytes underneath. cachePacket explicitly copies its argument (handshake_manager.go cachePacket),
|
||||
// so retaining segments past the loop is safe.
|
||||
err := pkt.PerSegment(func(seg []byte) error {
|
||||
hh.cachePacket(f.l, header.Message, 0, seg, f.sendMessageNow, f.cachedPacketMetrics)
|
||||
return nil
|
||||
})
|
||||
if err != nil && f.l.Enabled(context.Background(), slog.LevelDebug) {
|
||||
f.l.Debug("Failed to segment superpacket for handshake cache",
|
||||
"error", err,
|
||||
"vpnAddr", fwPacket.RemoteAddr,
|
||||
)
|
||||
}
|
||||
hh.cachePacket(f.l, header.Message, 0, packet, f.sendMessageNow, f.cachedPacketMetrics)
|
||||
})
|
||||
|
||||
if hostinfo == nil {
|
||||
f.rejectInside(packet, rejectBuf, q)
|
||||
f.rejectInside(packet, out, q)
|
||||
if f.l.Enabled(context.Background(), slog.LevelDebug) {
|
||||
f.l.Debug("dropping outbound packet, vpnAddr not in our vpn networks or in unsafe networks",
|
||||
"vpnAddr", fwPacket.RemoteAddr,
|
||||
@@ -96,9 +73,10 @@ func (f *Interface) consumeInsidePacket(pkt wire.TunPacket, fwPacket *firewall.P
|
||||
|
||||
dropReason := f.firewall.Drop(*fwPacket, false, hostinfo, f.pki.GetCAPool(), localCache)
|
||||
if dropReason == nil {
|
||||
f.sendInsideMessage(hostinfo, pkt, nb, sendBatch)
|
||||
f.sendNoMetrics(header.Message, 0, hostinfo.ConnectionState, hostinfo, netip.AddrPort{}, packet, nb, out, q)
|
||||
|
||||
} else {
|
||||
f.rejectInside(packet, rejectBuf, q)
|
||||
f.rejectInside(packet, out, q)
|
||||
if f.l.Enabled(context.Background(), slog.LevelDebug) {
|
||||
hostinfo.logger(f.l).Debug("dropping outbound packet",
|
||||
"fwPacket", fwPacket,
|
||||
@@ -108,149 +86,6 @@ func (f *Interface) consumeInsidePacket(pkt wire.TunPacket, fwPacket *firewall.P
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Interface) sendInsideEncrypt(hostinfo *HostInfo, ci *ConnectionState, seg, scratch, nb []byte) []byte {
|
||||
if noiseutil.EncryptLockNeeded {
|
||||
ci.writeLock.Lock()
|
||||
}
|
||||
c := ci.messageCounter.Add(1)
|
||||
|
||||
out := header.Encode(scratch, header.Version, header.Message, 0, hostinfo.remoteIndexId, c)
|
||||
f.connectionManager.Out(hostinfo)
|
||||
|
||||
out, encErr := ci.eKey.EncryptDanger(out, out, seg, c, nb)
|
||||
if noiseutil.EncryptLockNeeded {
|
||||
ci.writeLock.Unlock()
|
||||
}
|
||||
if encErr != nil {
|
||||
hostinfo.logger(f.l).Error("Failed to encrypt outgoing packet",
|
||||
"error", encErr,
|
||||
"udpAddr", hostinfo.remote,
|
||||
"counter", c,
|
||||
)
|
||||
// Skip this segment; the rest of the superpacket can still
|
||||
// go out — TCP will retransmit anything we drop here.
|
||||
return nil
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// sendInsideMessage encrypts a firewall-approved inside packet (or every
|
||||
// segment of a TSO/USO superpacket) into the caller's batch slot for
|
||||
// later sendmmsg flush. Segmentation is fused with encryption here so the
|
||||
// kernel-supplied superpacket bytes never get written into a separate
|
||||
// scratch arena: PerSegment builds each segment's plaintext in
|
||||
// segScratch[:segLen] in turn, and we encrypt directly into a fresh
|
||||
// SendBatch slot.
|
||||
func (f *Interface) sendInsideMessage(hostinfo *HostInfo, pkt wire.TunPacket, nb []byte, sendBatch *batch.SendBatch) {
|
||||
ci := hostinfo.ConnectionState
|
||||
if ci.eKey == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ecnEnabled := f.ecnEnabled.Load()
|
||||
if hostinfo.lastRebindCount != f.rebindCount {
|
||||
//NOTE: there is an update hole if a tunnel isn't used and exactly 256 rebinds occur before the tunnel is
|
||||
// finally used again. This tunnel would eventually be torn down and recreated if this action didn't help.
|
||||
f.lightHouse.QueryServer(hostinfo.vpnAddrs[0])
|
||||
hostinfo.lastRebindCount = f.rebindCount
|
||||
if f.l.Enabled(context.Background(), slog.LevelDebug) {
|
||||
hostinfo.logger(f.l).Debug("Lighthouse update triggered for punch due to rebind counter",
|
||||
"vpnAddrs", hostinfo.vpnAddrs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if !hostinfo.remote.IsValid() { //the relay path
|
||||
//first, find our relay hostinfo:
|
||||
var relayHostInfo *HostInfo
|
||||
var relay *Relay
|
||||
var err error
|
||||
for _, relayIP := range hostinfo.relayState.CopyRelayIps() {
|
||||
relayHostInfo, relay, err = f.hostMap.QueryVpnAddrsRelayFor(hostinfo.vpnAddrs, relayIP)
|
||||
if err != nil {
|
||||
hostinfo.relayState.DeleteRelay(relayIP)
|
||||
hostinfo.logger(f.l).Info("sendNoMetrics failed to find HostInfo",
|
||||
"relay", relayIP,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if relayHostInfo == nil || relay == nil {
|
||||
//failure already logged
|
||||
return
|
||||
}
|
||||
|
||||
err = pkt.PerSegment(func(seg []byte) error {
|
||||
//relay header + header + plaintext + AEAD tag (16 bytes for both AES-GCM and ChaCha20-Poly1305) + relay tag
|
||||
scratch := sendBatch.Reserve(header.Len + header.Len + len(seg) + 16 + 16)
|
||||
|
||||
innerPacket := f.sendInsideEncrypt(hostinfo, ci, seg, scratch[header.Len:], nb)
|
||||
if innerPacket == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
//now we need to do a relay-encrypt:
|
||||
toSend, err := f.prepareSendVia(relayHostInfo, relay, innerPacket, nb, scratch, true)
|
||||
if err != nil {
|
||||
//already logged
|
||||
return nil
|
||||
}
|
||||
|
||||
var ecn byte
|
||||
if ecnEnabled {
|
||||
ecn = innerECN(seg)
|
||||
}
|
||||
sendBatch.Commit(toSend, relayHostInfo.remote, ecn)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
hostinfo.logger(f.l).Error("Failed to segment superpacket for relay send", "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
err := pkt.PerSegment(func(seg []byte) error {
|
||||
// header + plaintext + AEAD tag (16 bytes for both AES-GCM and ChaCha20-Poly1305)
|
||||
scratch := sendBatch.Reserve(header.Len + len(seg) + 16)
|
||||
|
||||
out := f.sendInsideEncrypt(hostinfo, ci, seg, scratch, nb)
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var ecn byte
|
||||
if ecnEnabled {
|
||||
ecn = innerECN(seg)
|
||||
}
|
||||
sendBatch.Commit(out, hostinfo.remote, ecn)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
hostinfo.logger(f.l).Error("Failed to segment superpacket for send",
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// innerECN returns the 2-bit IP-level ECN codepoint of an inner IPv4 or IPv6
|
||||
// packet, or 0 if pkt is too short or its IP version is unrecognized. Used at
|
||||
// encap to copy the inner codepoint onto the outer carrier per RFC 6040.
|
||||
func innerECN(pkt []byte) byte {
|
||||
if len(pkt) < 2 {
|
||||
return 0
|
||||
}
|
||||
switch pkt[0] >> 4 {
|
||||
case 4:
|
||||
return pkt[1] & 0x03
|
||||
case 6:
|
||||
return (pkt[1] >> 4) & 0x03
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (f *Interface) rejectInside(packet []byte, out []byte, q int) {
|
||||
if !f.firewall.InSendReject {
|
||||
return
|
||||
@@ -440,13 +275,21 @@ func (f *Interface) sendTo(t header.MessageType, st header.MessageSubType, ci *C
|
||||
f.sendNoMetrics(t, st, ci, hostinfo, remote, p, nb, out, 0)
|
||||
}
|
||||
|
||||
func (f *Interface) prepareSendVia(via *HostInfo,
|
||||
// SendVia sends a payload through a Relay tunnel. No authentication or encryption is done
|
||||
// to the payload for the ultimate target host, making this a useful method for sending
|
||||
// handshake messages to peers through relay tunnels.
|
||||
// via is the HostInfo through which the message is relayed.
|
||||
// ad is the plaintext data to authenticate, but not encrypt
|
||||
// nb is a buffer used to store the nonce value, re-used for performance reasons.
|
||||
// out is a buffer used to store the result of the Encrypt operation
|
||||
// q indicates which writer to use to send the packet.
|
||||
func (f *Interface) SendVia(via *HostInfo,
|
||||
relay *Relay,
|
||||
ad,
|
||||
nb,
|
||||
out []byte,
|
||||
nocopy bool,
|
||||
) ([]byte, error) {
|
||||
) {
|
||||
if noiseutil.EncryptLockNeeded {
|
||||
// NOTE: for goboring AESGCMTLS we need to lock because of the nonce check
|
||||
via.ConnectionState.writeLock.Lock()
|
||||
@@ -468,7 +311,7 @@ func (f *Interface) prepareSendVia(via *HostInfo,
|
||||
"headerLen", len(out),
|
||||
"cipherOverhead", via.ConnectionState.eKey.Overhead(),
|
||||
)
|
||||
return nil, io.ErrShortBuffer
|
||||
return
|
||||
}
|
||||
|
||||
// The header bytes are written to the 'out' slice; Grow the slice to hold the header and associated data payload.
|
||||
@@ -488,37 +331,13 @@ func (f *Interface) prepareSendVia(via *HostInfo,
|
||||
}
|
||||
if err != nil {
|
||||
via.logger(f.l).Info("Failed to EncryptDanger in sendVia", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
f.connectionManager.RelayUsed(relay.LocalIndex)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SendVia sends a payload through a Relay tunnel. No authentication or encryption is done
|
||||
// to the payload for the ultimate target host, making this a useful method for sending
|
||||
// handshake messages to peers through relay tunnels.
|
||||
// via is the HostInfo through which the message is relayed.
|
||||
// ad is the plaintext data to authenticate, but not encrypt
|
||||
// nb is a buffer used to store the nonce value, re-used for performance reasons.
|
||||
// out is a buffer used to store the result of the Encrypt operation.
|
||||
// The write goes through writers[0] — SendVia is called from contexts
|
||||
// without a per-queue index (handshake, async control paths).
|
||||
func (f *Interface) SendVia(via *HostInfo,
|
||||
relay *Relay,
|
||||
ad,
|
||||
nb,
|
||||
out []byte,
|
||||
nocopy bool,
|
||||
) {
|
||||
toSend, err := f.prepareSendVia(via, relay, ad, nb, out, nocopy)
|
||||
if err != nil {
|
||||
via.logger(f.l).Info("Failed to prepareSendVia", "error", err)
|
||||
return
|
||||
}
|
||||
err = f.writers[0].WriteTo(toSend, via.remote)
|
||||
err = f.writers[0].WriteTo(out, via.remote)
|
||||
if err != nil {
|
||||
via.logger(f.l).Info("Failed to WriteTo in sendVia", "error", err)
|
||||
}
|
||||
f.connectionManager.RelayUsed(relay.LocalIndex)
|
||||
}
|
||||
|
||||
func (f *Interface) sendNoMetrics(t header.MessageType, st header.MessageSubType, ci *ConnectionState, hostinfo *HostInfo, remote netip.AddrPort, p, nb, out []byte, q int) {
|
||||
@@ -572,7 +391,6 @@ func (f *Interface) sendNoMetrics(t header.MessageType, st header.MessageSubType
|
||||
"error", err,
|
||||
"udpAddr", remote,
|
||||
"counter", c,
|
||||
"attemptedCounter", c,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
+58
-131
@@ -4,24 +4,22 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gaissmai/bart"
|
||||
"github.com/rcrowley/go-metrics"
|
||||
"github.com/slackhq/nebula/util"
|
||||
"github.com/slackhq/nebula/wire"
|
||||
|
||||
"github.com/slackhq/nebula/cert"
|
||||
"github.com/slackhq/nebula/config"
|
||||
"github.com/slackhq/nebula/firewall"
|
||||
"github.com/slackhq/nebula/header"
|
||||
"github.com/slackhq/nebula/overlay"
|
||||
"github.com/slackhq/nebula/overlay/batch"
|
||||
"github.com/slackhq/nebula/overlay/tio"
|
||||
"github.com/slackhq/nebula/udp"
|
||||
)
|
||||
|
||||
@@ -41,7 +39,6 @@ type InterfaceConfig struct {
|
||||
DropLocalBroadcast bool
|
||||
DropMulticast bool
|
||||
routines int
|
||||
batchSize int
|
||||
MessageMetrics *MessageMetrics
|
||||
version string
|
||||
relayManager *relayManager
|
||||
@@ -52,14 +49,7 @@ type InterfaceConfig struct {
|
||||
reQueryWait time.Duration
|
||||
|
||||
ConntrackCacheTimeout time.Duration
|
||||
|
||||
// CpuAffinity, when non-empty, names the CPUs each TUN reader goroutine
|
||||
// should pin to. Queue i pins to CpuAffinity[i % len(CpuAffinity)] —
|
||||
// shorter lists than `routines` cycle. Empty list keeps the default
|
||||
// pin-to-(i % NumCPU) behavior.
|
||||
CpuAffinity []int
|
||||
|
||||
l *slog.Logger
|
||||
l *slog.Logger
|
||||
}
|
||||
|
||||
type Interface struct {
|
||||
@@ -81,19 +71,9 @@ type Interface struct {
|
||||
dropLocalBroadcast bool
|
||||
dropMulticast bool
|
||||
routines int
|
||||
batchSize int
|
||||
disconnectInvalid atomic.Bool
|
||||
closed atomic.Bool
|
||||
// cpuAffinity, when non-empty, names the CPUs each TUN reader goroutine
|
||||
// should pin to. Queue i pins to cpuAffinity[i % len(cpuAffinity)].
|
||||
// Empty falls back to the default pin-to-(i % NumCPU) behavior.
|
||||
cpuAffinity []int
|
||||
// ecnEnabled gates RFC 6040 underlay ECN propagation. When true,
|
||||
// inside.go copies the inner ECN onto the outer carrier on encap and
|
||||
// decryptToTun folds outer CE into the inner header on decap. Toggle
|
||||
// via tunnels.ecn (default true).
|
||||
ecnEnabled atomic.Bool
|
||||
relayManager *relayManager
|
||||
relayManager *relayManager
|
||||
|
||||
tryPromoteEvery atomic.Uint32
|
||||
reQueryEvery atomic.Uint32
|
||||
@@ -110,12 +90,8 @@ type Interface struct {
|
||||
|
||||
ctx context.Context
|
||||
writers []udp.Conn
|
||||
readers []tio.Queue
|
||||
// batchers is one per tun queue, wrapping readers[i].
|
||||
// decryptToTun sends plaintext into the batch.RxBatcher;
|
||||
// listenOut calls its Flush at the end of each UDP recvmmsg batch.
|
||||
batchers []batch.RxBatcher
|
||||
wg sync.WaitGroup
|
||||
readers []io.ReadWriteCloser
|
||||
wg sync.WaitGroup
|
||||
|
||||
// fatalErr holds the first unexpected reader error that caused shutdown.
|
||||
// nil means "no fatal error" (yet)
|
||||
@@ -211,11 +187,9 @@ func NewInterface(ctx context.Context, c *InterfaceConfig) (*Interface, error) {
|
||||
dropLocalBroadcast: c.DropLocalBroadcast,
|
||||
dropMulticast: c.DropMulticast,
|
||||
routines: c.routines,
|
||||
batchSize: c.batchSize,
|
||||
version: c.version,
|
||||
writers: make([]udp.Conn, c.routines),
|
||||
readers: make([]tio.Queue, c.routines),
|
||||
batchers: make([]batch.RxBatcher, c.routines),
|
||||
readers: make([]io.ReadWriteCloser, c.routines),
|
||||
myVpnNetworks: cs.myVpnNetworks,
|
||||
myVpnNetworksTable: cs.myVpnNetworksTable,
|
||||
myVpnAddrs: cs.myVpnAddrs,
|
||||
@@ -224,7 +198,6 @@ func NewInterface(ctx context.Context, c *InterfaceConfig) (*Interface, error) {
|
||||
relayManager: c.relayManager,
|
||||
connectionManager: c.connectionManager,
|
||||
conntrackCacheTimeout: c.ConntrackCacheTimeout,
|
||||
cpuAffinity: c.CpuAffinity,
|
||||
|
||||
metricHandshakes: metrics.GetOrRegisterHistogram("handshakes", nil, metrics.NewExpDecaySample(1028, 0.015)),
|
||||
messageMetrics: c.MessageMetrics,
|
||||
@@ -274,27 +247,15 @@ func (f *Interface) activate() error {
|
||||
metrics.GetOrRegisterGauge("routines", nil).Update(int64(f.routines))
|
||||
|
||||
// Prepare n tun queues
|
||||
var reader io.ReadWriteCloser = f.inside
|
||||
for i := 0; i < f.routines; i++ {
|
||||
if i > 0 {
|
||||
if err = f.inside.NewMultiQueueReader(); err != nil {
|
||||
reader, err = f.inside.NewMultiQueueReader()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
f.readers = f.inside.Readers()
|
||||
for i := range f.readers {
|
||||
caps := f.readers[i].Capabilities()
|
||||
if caps.TSO || caps.USO {
|
||||
// Multi-lane: TCP gets coalesced when TSO is on, UDP when USO
|
||||
// is on, everything else (and either lane disabled) falls
|
||||
// through to passthrough so non-IP / non-TCP-UDP traffic still
|
||||
// reaches the TUN.
|
||||
arena := util.NewArena(max(f.batchSize, 1) * 65535)
|
||||
f.batchers[i] = batch.NewMultiCoalescer(f.readers[i], f.l, arena, caps.TSO, caps.USO)
|
||||
} else {
|
||||
arena := util.NewArena(max(f.batchSize, 1) * udp.MTU)
|
||||
f.batchers[i] = batch.NewPassthrough(f.readers[i], f.batchSize, arena)
|
||||
}
|
||||
f.readers[i] = reader
|
||||
}
|
||||
|
||||
f.wg.Add(1) // for us to wait on Close() to return
|
||||
@@ -352,21 +313,14 @@ func (f *Interface) listenOut(i int) {
|
||||
|
||||
ctCache := firewall.NewConntrackCacheTicker(f.ctx, f.l, f.conntrackCacheTimeout)
|
||||
lhh := f.lightHouse.NewRequestHandler()
|
||||
plaintext := make([]byte, udp.MTU)
|
||||
h := &header.H{}
|
||||
fwPacket := &firewall.Packet{}
|
||||
nb := make([]byte, 12, 12)
|
||||
|
||||
listener := func(fromUdpAddr netip.AddrPort, payload []byte, meta udp.RxMeta) {
|
||||
f.readOutsidePackets(ViaSender{UdpAddr: fromUdpAddr}, payload, h, fwPacket, lhh, nb, i, ctCache.Get(), meta)
|
||||
}
|
||||
|
||||
flusher := func() {
|
||||
if err := f.batchers[i].Flush(); err != nil {
|
||||
f.l.Error("Failed to flush tun coalescer", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
err := li.ListenOut(listener, flusher)
|
||||
err := li.ListenOut(func(fromUdpAddr netip.AddrPort, payload []byte) {
|
||||
f.readOutsidePackets(ViaSender{UdpAddr: fromUdpAddr}, plaintext[:0], payload, h, fwPacket, lhh, nb, i, ctCache.Get())
|
||||
})
|
||||
|
||||
if err != nil && !f.closed.Load() {
|
||||
f.l.Error("Error while reading inbound packet, closing", "error", err)
|
||||
@@ -376,57 +330,28 @@ func (f *Interface) listenOut(i int) {
|
||||
f.l.Debug("underlay reader is done", "reader", i)
|
||||
}
|
||||
|
||||
func (f *Interface) listenIn(reader tio.Queue, q int) {
|
||||
// Pinning this thread (and goroutine) to a single CPU keeps every sendmmsg from this goroutine going through the
|
||||
// same TX ring on the nic, so the wire sees per-flow order.
|
||||
cpu := q % runtime.NumCPU()
|
||||
if n := len(f.cpuAffinity); n > 0 {
|
||||
cpu = f.cpuAffinity[q%n]
|
||||
}
|
||||
if err := util.PinThreadToCPU(cpu); err != nil {
|
||||
f.l.Warn("failed to pin tun reader to CPU", "queue", q, "cpu", cpu, "err", err)
|
||||
}
|
||||
|
||||
const bonusInfo = 16
|
||||
bufferScale := udp.MTU + bonusInfo
|
||||
numTunPackets := 1
|
||||
caps := reader.Capabilities()
|
||||
if caps.TSO || caps.USO {
|
||||
bufferScale = 65535 + bonusInfo
|
||||
numTunPackets = f.batchSize
|
||||
}
|
||||
|
||||
rejectBuf := make([]byte, mtu)
|
||||
tunPackets := make([]wire.TunPacket, numTunPackets)
|
||||
packetMem := make([]byte, bufferScale*numTunPackets)
|
||||
|
||||
arenaSize := batch.SendBatchCap * (udp.MTU + 32)
|
||||
sb := batch.NewSendBatch(f.writers[q], batch.SendBatchCap, util.NewArena(arenaSize))
|
||||
func (f *Interface) listenIn(reader io.ReadWriteCloser, i int) {
|
||||
packet := make([]byte, mtu)
|
||||
out := make([]byte, mtu)
|
||||
fwPacket := &firewall.Packet{}
|
||||
nb := make([]byte, 12, 12)
|
||||
|
||||
conntrackCache := firewall.NewConntrackCacheTicker(f.ctx, f.l, f.conntrackCacheTimeout)
|
||||
|
||||
for {
|
||||
n, err := reader.Read(tunPackets, packetMem)
|
||||
n, err := reader.Read(packet)
|
||||
if err != nil {
|
||||
if !f.closed.Load() {
|
||||
f.l.Error("Error while reading outbound packet, closing", "error", err, "reader", q)
|
||||
f.l.Error("Error while reading outbound packet, closing", "error", err, "reader", i)
|
||||
f.onFatal(err)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
ctCache := conntrackCache.Get()
|
||||
for i := range n {
|
||||
f.consumeInsidePacket(tunPackets[i], fwPacket, nb, sb, rejectBuf, q, ctCache)
|
||||
}
|
||||
if err := sb.Flush(); err != nil {
|
||||
f.l.Error("Failed to write outgoing batch", "error", err, "writer", q)
|
||||
}
|
||||
f.consumeInsidePacket(packet[:n], fwPacket, nb, out, i, conntrackCache.Get())
|
||||
}
|
||||
|
||||
f.l.Debug("overlay reader is done", "reader", q)
|
||||
f.l.Debug("overlay reader is done", "reader", i)
|
||||
}
|
||||
|
||||
func (f *Interface) RegisterConfigChangeCallbacks(c *config.C) {
|
||||
@@ -435,7 +360,6 @@ func (f *Interface) RegisterConfigChangeCallbacks(c *config.C) {
|
||||
c.RegisterReloadCallback(f.reloadAcceptRecvError)
|
||||
c.RegisterReloadCallback(f.reloadDisconnectInvalid)
|
||||
c.RegisterReloadCallback(f.reloadMisc)
|
||||
c.RegisterReloadCallback(f.reloadEcn)
|
||||
|
||||
for _, udpConn := range f.writers {
|
||||
c.RegisterReloadCallback(udpConn.ReloadConfig)
|
||||
@@ -453,13 +377,22 @@ func (f *Interface) reloadDisconnectInvalid(c *config.C) {
|
||||
}
|
||||
|
||||
func (f *Interface) reloadFirewall(c *config.C) {
|
||||
//TODO: need to trigger/detect if the certificate changed too
|
||||
if c.HasChanged("firewall") == false {
|
||||
cs := f.pki.getCertState()
|
||||
curCert := cs.getCertificate(cert.Version2)
|
||||
if curCert == nil {
|
||||
curCert = cs.getCertificate(cert.Version1)
|
||||
}
|
||||
|
||||
// The firewall builds its routableNetworks set from the certificate's UnsafeNetworks at construction.
|
||||
// Check to see if that set has changed, and if so, rebuild the firewall.
|
||||
certUnsafeChanged := curCert != nil && !slices.Equal(curCert.UnsafeNetworks(), f.firewall.unsafeNetworks)
|
||||
|
||||
if !c.HasChanged("firewall") && !certUnsafeChanged {
|
||||
f.l.Debug("No firewall config change detected")
|
||||
return
|
||||
}
|
||||
|
||||
fw, err := NewFirewallFromConfig(f.l, f.pki.getCertState(), c)
|
||||
fw, err := NewFirewallFromConfig(f.l, cs, c)
|
||||
if err != nil {
|
||||
f.l.Error("Error while creating firewall during reload", "error", err)
|
||||
return
|
||||
@@ -559,20 +492,6 @@ func (f *Interface) reloadMisc(c *config.C) {
|
||||
}
|
||||
}
|
||||
|
||||
// reloadEcn syncs Interface.ecnEnabled with the tunnels.ecn config knob.
|
||||
// Default is enabled (RFC 6040 normal mode); set false on the rare path
|
||||
// where an underlay middlebox rewrites or drops ECN bits unpredictably.
|
||||
func (f *Interface) reloadEcn(c *config.C) {
|
||||
initial := c.InitialLoad()
|
||||
if initial || c.HasChanged("tunnels.ecn") {
|
||||
v := c.GetBool("tunnels.ecn", true)
|
||||
f.ecnEnabled.Store(v)
|
||||
if !initial {
|
||||
f.l.Info("tunnels.ecn changed", "enabled", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Interface) emitStats(ctx context.Context, i time.Duration) {
|
||||
ticker := time.NewTicker(i)
|
||||
defer ticker.Stop()
|
||||
@@ -583,26 +502,34 @@ func (f *Interface) emitStats(ctx context.Context, i time.Duration) {
|
||||
certInitiatingVersion := metrics.GetOrRegisterGauge("certificate.initiating_version", nil)
|
||||
certMaxVersion := metrics.GetOrRegisterGauge("certificate.max_version", nil)
|
||||
|
||||
emit := func() {
|
||||
f.firewall.EmitStats()
|
||||
f.handshakeManager.EmitStats()
|
||||
udpStats()
|
||||
|
||||
certState := f.pki.getCertState()
|
||||
defaultCrt := certState.GetDefaultCertificate()
|
||||
certExpirationGauge.Update(int64(defaultCrt.NotAfter().Sub(time.Now()) / time.Second))
|
||||
certInitiatingVersion.Update(int64(defaultCrt.Version()))
|
||||
|
||||
// Report the max certificate version we are capable of using
|
||||
if certState.v2Cert != nil {
|
||||
certMaxVersion.Update(int64(certState.v2Cert.Version()))
|
||||
} else {
|
||||
certMaxVersion.Update(int64(certState.v1Cert.Version()))
|
||||
}
|
||||
}
|
||||
|
||||
// Prime gauges so a Prometheus scrape that lands before the first tick
|
||||
// sees real values instead of the zero defaults (issue #907).
|
||||
emit()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
f.firewall.EmitStats()
|
||||
f.handshakeManager.EmitStats()
|
||||
udpStats()
|
||||
|
||||
certState := f.pki.getCertState()
|
||||
defaultCrt := certState.GetDefaultCertificate()
|
||||
certExpirationGauge.Update(int64(defaultCrt.NotAfter().Sub(time.Now()) / time.Second))
|
||||
certInitiatingVersion.Update(int64(defaultCrt.Version()))
|
||||
|
||||
// Report the max certificate version we are capable of using
|
||||
if certState.v2Cert != nil {
|
||||
certMaxVersion.Update(int64(certState.v2Cert.Version()))
|
||||
} else {
|
||||
certMaxVersion.Update(int64(certState.v1Cert.Version()))
|
||||
}
|
||||
emit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
//go:build linux || darwin
|
||||
|
||||
package nebula
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcrowley/go-metrics"
|
||||
"github.com/slackhq/nebula/cert"
|
||||
"github.com/slackhq/nebula/firewall"
|
||||
"github.com/slackhq/nebula/overlay/overlaytest"
|
||||
"github.com/slackhq/nebula/test"
|
||||
"github.com/slackhq/nebula/udp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Test_emitStats_primesGauges covers issue #907: a Prometheus scrape that
|
||||
// landed before the first ticker fire used to read 0 for the cert gauges.
|
||||
// emitStats now primes the gauges before entering the ticker loop. We assert
|
||||
// the gauge is zero before the first call and non-zero after.
|
||||
func Test_emitStats_primesGauges(t *testing.T) {
|
||||
defer metrics.DefaultRegistry.UnregisterAll()
|
||||
|
||||
l := test.NewLogger()
|
||||
hostMap := newHostMap(l)
|
||||
preferredRanges := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}
|
||||
hostMap.preferredRanges.Store(&preferredRanges)
|
||||
|
||||
notAfter := time.Now().Add(time.Hour)
|
||||
cs := &CertState{
|
||||
initiatingVersion: cert.Version1,
|
||||
privateKey: []byte{},
|
||||
v1Cert: &dummyCert{version: cert.Version1, notAfter: notAfter},
|
||||
v1Credential: nil,
|
||||
}
|
||||
|
||||
lh := newTestLighthouse()
|
||||
ifce := &Interface{
|
||||
hostMap: hostMap,
|
||||
inside: &overlaytest.NoopTun{},
|
||||
outside: &udp.NoopConn{},
|
||||
firewall: &Firewall{Conntrack: &FirewallConntrack{Conns: map[firewall.Packet]*conn{}}},
|
||||
lightHouse: lh,
|
||||
pki: &PKI{},
|
||||
handshakeManager: NewHandshakeManager(l, hostMap, lh, &udp.NoopConn{}, defaultHandshakeConfig),
|
||||
l: l,
|
||||
// On linux, udp.NewUDPStatsEmitter indexes writers[0] and asserts to
|
||||
// *udp.StdConn. A zero value works: getMemInfo sees a nil rawConn,
|
||||
// returns an error, and the emitter falls through to a no-op.
|
||||
writers: []udp.Conn{&udp.StdConn{}},
|
||||
}
|
||||
ifce.pki.cs.Store(cs)
|
||||
|
||||
ttlGauge := metrics.GetOrRegisterGauge("certificate.ttl_seconds", nil)
|
||||
require.Zero(t, ttlGauge.Value(), "gauge should be zero before emitStats runs")
|
||||
|
||||
// Pre-cancel the context so emitStats returns after priming the gauges
|
||||
// without ever reading from ticker.C. The one hour interval is just a
|
||||
// belt-and-suspenders, the test does not expect the ticker to fire.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
ifce.emitStats(ctx, time.Hour)
|
||||
|
||||
ttl := ttlGauge.Value()
|
||||
assert.Positive(t, ttl, "ttl gauge should be primed by emitStats before its first tick")
|
||||
assert.LessOrEqual(t, ttl, int64(3600))
|
||||
assert.Equal(t, int64(cert.Version1), metrics.GetOrRegisterGauge("certificate.initiating_version", nil).Value())
|
||||
assert.Equal(t, int64(cert.Version1), metrics.GetOrRegisterGauge("certificate.max_version", nil).Value())
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package nebula
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/slackhq/nebula/cert"
|
||||
"github.com/slackhq/nebula/config"
|
||||
"github.com/slackhq/nebula/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestReloadFirewall_CertUnsafeNetworksChanged verifies that reloadFirewall
|
||||
// rebuilds the firewall when only the certificate's UnsafeNetworks have changed,
|
||||
// even if the firewall section of the YAML has not.
|
||||
func TestReloadFirewall_CertUnsafeNetworksChanged(t *testing.T) {
|
||||
l := test.NewLogger()
|
||||
|
||||
vpnNet := netip.MustParsePrefix("10.0.0.1/24")
|
||||
initialUnsafe := []netip.Prefix{netip.MustParsePrefix("198.51.100.0/24")}
|
||||
|
||||
// dummyCert avoids dragging the real signing pipeline into a unit test.
|
||||
c1 := &dummyCert{
|
||||
version: cert.Version2,
|
||||
networks: []netip.Prefix{vpnNet},
|
||||
unsafeNetworks: initialUnsafe,
|
||||
}
|
||||
pki := &PKI{}
|
||||
pki.cs.Store(&CertState{v2Cert: c1, initiatingVersion: cert.Version2})
|
||||
|
||||
rawYAML := `firewall:
|
||||
outbound:
|
||||
- port: any
|
||||
proto: any
|
||||
host: any
|
||||
inbound:
|
||||
- port: any
|
||||
proto: any
|
||||
host: any
|
||||
`
|
||||
cfg := config.NewC(l)
|
||||
require.NoError(t, cfg.LoadString(rawYAML))
|
||||
|
||||
fw, err := NewFirewallFromConfig(l, pki.getCertState(), cfg)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, initialUnsafe, fw.unsafeNetworks)
|
||||
|
||||
f := &Interface{
|
||||
pki: pki,
|
||||
firewall: fw,
|
||||
l: l,
|
||||
}
|
||||
|
||||
// Swap the cert with a different UnsafeNetworks set.
|
||||
newUnsafe := []netip.Prefix{
|
||||
netip.MustParsePrefix("198.51.100.0/24"),
|
||||
netip.MustParsePrefix("203.0.113.0/24"),
|
||||
}
|
||||
c2 := &dummyCert{
|
||||
version: cert.Version2,
|
||||
networks: []netip.Prefix{vpnNet},
|
||||
unsafeNetworks: newUnsafe,
|
||||
}
|
||||
pki.cs.Store(&CertState{v2Cert: c2, initiatingVersion: cert.Version2})
|
||||
|
||||
// Reload with the same YAML so HasChanged("firewall") reports false.
|
||||
require.NoError(t, cfg.ReloadConfigString(rawYAML))
|
||||
require.False(t, cfg.HasChanged("firewall"))
|
||||
|
||||
f.reloadFirewall(cfg)
|
||||
|
||||
assert.NotSame(t, fw, f.firewall, "firewall pointer should have been replaced")
|
||||
assert.Equal(t, newUnsafe, f.firewall.unsafeNetworks)
|
||||
assert.True(t, f.firewall.routableNetworks.Contains(netip.MustParseAddr("203.0.113.5")))
|
||||
}
|
||||
|
||||
// TestReloadFirewall_NoChange verifies that reloadFirewall is a no-op when
|
||||
// neither the firewall config nor the cert's UnsafeNetworks have changed.
|
||||
func TestReloadFirewall_NoChange(t *testing.T) {
|
||||
l := test.NewLogger()
|
||||
|
||||
vpnNet := netip.MustParsePrefix("10.0.0.1/24")
|
||||
unsafe := []netip.Prefix{netip.MustParsePrefix("198.51.100.0/24")}
|
||||
|
||||
c1 := &dummyCert{
|
||||
version: cert.Version2,
|
||||
networks: []netip.Prefix{vpnNet},
|
||||
unsafeNetworks: unsafe,
|
||||
}
|
||||
pki := &PKI{}
|
||||
pki.cs.Store(&CertState{v2Cert: c1, initiatingVersion: cert.Version2})
|
||||
|
||||
rawYAML := `firewall:
|
||||
outbound:
|
||||
- port: any
|
||||
proto: any
|
||||
host: any
|
||||
inbound:
|
||||
- port: any
|
||||
proto: any
|
||||
host: any
|
||||
`
|
||||
cfg := config.NewC(l)
|
||||
require.NoError(t, cfg.LoadString(rawYAML))
|
||||
|
||||
fw, err := NewFirewallFromConfig(l, pki.getCertState(), cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
f := &Interface{
|
||||
pki: pki,
|
||||
firewall: fw,
|
||||
l: l,
|
||||
}
|
||||
|
||||
require.NoError(t, cfg.ReloadConfigString(rawYAML))
|
||||
f.reloadFirewall(cfg)
|
||||
|
||||
assert.Same(t, fw, f.firewall, "firewall should not have been replaced")
|
||||
}
|
||||
+290
-19
@@ -4,26 +4,54 @@ import (
|
||||
"encoding/binary"
|
||||
|
||||
"golang.org/x/net/ipv4"
|
||||
"golang.org/x/net/ipv6"
|
||||
)
|
||||
|
||||
const (
|
||||
// Need 96 bytes for the largest reject packet:
|
||||
// MaxIPv4RejectPacketSize is the largest IPv4 reject packet:
|
||||
// - 20 byte ipv4 header
|
||||
// - 8 byte icmpv4 header
|
||||
// - 68 byte body (60 byte max orig ipv4 header + 8 byte orig icmpv4 header)
|
||||
MaxRejectPacketSize = ipv4.HeaderLen + 8 + 60 + 8
|
||||
maxIPv4RejectPacketSize = ipv4.HeaderLen + 8 + 60 + 8
|
||||
|
||||
// MaxRejectPacketSize is sized for the largest possible reject packet (IPv6):
|
||||
// - 40 byte ipv6 header
|
||||
// - 8 byte icmpv6 header
|
||||
// - up to 1000 byte body (original packet, possibly truncated. We want to stay
|
||||
// under the MTU with Nebula overhead included)
|
||||
maxIPv6RejectPacketSize = ipv6.HeaderLen + 8 + 1000
|
||||
|
||||
MaxRejectPacketSize = maxIPv6RejectPacketSize
|
||||
)
|
||||
|
||||
func CreateRejectPacket(packet []byte, out []byte) []byte {
|
||||
if len(packet) < ipv4.HeaderLen || int(packet[0]>>4) != ipv4.Version {
|
||||
if len(packet) < 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch packet[9] {
|
||||
case 6: // tcp
|
||||
return ipv4CreateRejectTCPPacket(packet, out)
|
||||
version := int(packet[0] >> 4)
|
||||
switch version {
|
||||
case ipv4.Version:
|
||||
if len(packet) < ipv4.HeaderLen {
|
||||
return nil
|
||||
}
|
||||
// Do not send reject packets for non-first fragments
|
||||
if packet[6]&0x1f != 0 || packet[7] != 0 {
|
||||
return nil
|
||||
}
|
||||
switch packet[9] {
|
||||
case 6: // tcp
|
||||
return ipv4CreateRejectTCPPacket(packet, out)
|
||||
default:
|
||||
return ipv4CreateRejectICMPPacket(packet, out)
|
||||
}
|
||||
case ipv6.Version:
|
||||
if len(packet) < ipv6.HeaderLen {
|
||||
return nil
|
||||
}
|
||||
return ipv6CreateRejectPacket(packet, out)
|
||||
default:
|
||||
return ipv4CreateRejectICMPPacket(packet, out)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,12 +63,17 @@ func ipv4CreateRejectICMPPacket(packet []byte, out []byte) []byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ICMP reply includes original header and first 8 bytes of the packet
|
||||
packetLen := len(packet)
|
||||
if packetLen > ihl+8 {
|
||||
packetLen = ihl + 8
|
||||
// Do not generate ICMP errors in response to ICMP error packets
|
||||
if packet[9] == 1 && len(packet) > ihl {
|
||||
icmpType := packet[ihl]
|
||||
if icmpType == 3 || icmpType == 4 || icmpType == 5 || icmpType == 11 || icmpType == 12 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ICMP reply includes original header and first 8 bytes of the packet
|
||||
packetLen := min(len(packet), ihl+8)
|
||||
|
||||
outLen := ipv4.HeaderLen + 8 + packetLen
|
||||
if outLen > cap(out) {
|
||||
return nil
|
||||
@@ -71,14 +104,14 @@ func ipv4CreateRejectICMPPacket(packet []byte, out []byte) []byte {
|
||||
|
||||
// ICMP Destination Unreachable
|
||||
icmpOut := out[ipv4.HeaderLen:]
|
||||
icmpOut[0] = 3 // type (Destination unreachable)
|
||||
icmpOut[1] = 3 // code (Port unreachable error)
|
||||
icmpOut[2] = 0 // checksum
|
||||
icmpOut[3] = 0 // .
|
||||
icmpOut[4] = 0 // unused
|
||||
icmpOut[5] = 0 // .
|
||||
icmpOut[6] = 0 // .
|
||||
icmpOut[7] = 0 // .
|
||||
icmpOut[0] = 3 // type (Destination unreachable)
|
||||
icmpOut[1] = 13 // code (Communication administratively prohibited)
|
||||
icmpOut[2] = 0 // checksum
|
||||
icmpOut[3] = 0 // .
|
||||
icmpOut[4] = 0 // unused
|
||||
icmpOut[5] = 0 // .
|
||||
icmpOut[6] = 0 // .
|
||||
icmpOut[7] = 0 // .
|
||||
|
||||
// Copy original IP header and first 8 bytes as body
|
||||
copy(icmpOut[8:], packet[:packetLen])
|
||||
@@ -165,7 +198,193 @@ func ipv4CreateRejectTCPPacket(packet []byte, out []byte) []byte {
|
||||
return out
|
||||
}
|
||||
|
||||
func ipv6CreateRejectPacket(packet []byte, out []byte) []byte {
|
||||
proto, offset, isFragment := ipv6FindUpperProtocol(packet)
|
||||
if isFragment {
|
||||
return nil
|
||||
}
|
||||
switch proto {
|
||||
case 6: // tcp
|
||||
return ipv6CreateRejectTCPPacket(packet, out, offset)
|
||||
default:
|
||||
return ipv6CreateRejectICMPPacket(packet, out, proto, offset)
|
||||
}
|
||||
}
|
||||
|
||||
func ipv6CreateRejectICMPPacket(packet []byte, out []byte, proto uint8, offset int) []byte {
|
||||
// Do not generate ICMPv6 errors in response to ICMPv6 error packets
|
||||
if proto == 58 && len(packet) > offset {
|
||||
icmpType := packet[offset]
|
||||
if icmpType >= 1 && icmpType <= 4 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Include as much of the original packet as possible, up to 1000 bytes,
|
||||
// so the response fits comfortably within any tunnel MTU.
|
||||
packetLen := min(len(packet), 1000)
|
||||
|
||||
outLen := ipv6.HeaderLen + 8 + packetLen
|
||||
if outLen > cap(out) {
|
||||
return nil
|
||||
}
|
||||
|
||||
out = out[:outLen]
|
||||
|
||||
// IPv6 header
|
||||
ipHdr := out[0:ipv6.HeaderLen]
|
||||
ipHdr[0] = ipv6.Version << 4 // version, traffic class (high bits)
|
||||
ipHdr[1] = 0 // traffic class (low bits), flow label (high bits)
|
||||
ipHdr[2] = 0 // flow label
|
||||
ipHdr[3] = 0 // flow label
|
||||
|
||||
payloadLen := uint16(outLen - ipv6.HeaderLen)
|
||||
binary.BigEndian.PutUint16(ipHdr[4:], payloadLen) // payload length
|
||||
ipHdr[6] = 58 // next header (ICMPv6)
|
||||
ipHdr[7] = 64 // hop limit
|
||||
|
||||
// Swap dest / src IPs (each 16 bytes, src at 8, dst at 24)
|
||||
copy(ipHdr[8:24], packet[24:40])
|
||||
copy(ipHdr[24:40], packet[8:24])
|
||||
|
||||
// ICMPv6 Destination Unreachable
|
||||
icmpOut := out[ipv6.HeaderLen:]
|
||||
icmpOut[0] = 1 // type (Destination Unreachable)
|
||||
icmpOut[1] = 1 // code (Communication with destination administratively prohibited)
|
||||
icmpOut[2] = 0 // checksum
|
||||
icmpOut[3] = 0 // .
|
||||
icmpOut[4] = 0 // unused
|
||||
icmpOut[5] = 0 // .
|
||||
icmpOut[6] = 0 // .
|
||||
icmpOut[7] = 0 // .
|
||||
|
||||
copy(icmpOut[8:], packet[:packetLen])
|
||||
|
||||
// ICMPv6 checksum uses a pseudo-header
|
||||
csum := ipv6PseudoheaderChecksum(ipHdr[8:24], ipHdr[24:40], 58, uint32(payloadLen))
|
||||
binary.BigEndian.PutUint16(icmpOut[2:], tcpipChecksum(icmpOut, csum))
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func ipv6CreateRejectTCPPacket(packet []byte, out []byte, offset int) []byte {
|
||||
const tcpLen = 20
|
||||
|
||||
if len(packet) < offset+tcpLen {
|
||||
return nil
|
||||
}
|
||||
|
||||
outLen := ipv6.HeaderLen + tcpLen
|
||||
if outLen > cap(out) {
|
||||
return nil
|
||||
}
|
||||
|
||||
out = out[:outLen]
|
||||
|
||||
// IPv6 header
|
||||
ipHdr := out[0:ipv6.HeaderLen]
|
||||
ipHdr[0] = ipv6.Version << 4 // version, traffic class (high bits)
|
||||
ipHdr[1] = 0 // traffic class (low bits), flow label (high bits)
|
||||
ipHdr[2] = 0 // flow label
|
||||
ipHdr[3] = 0 // flow label
|
||||
|
||||
binary.BigEndian.PutUint16(ipHdr[4:], tcpLen) // payload length
|
||||
ipHdr[6] = 6 // next header (TCP)
|
||||
ipHdr[7] = 64 // hop limit
|
||||
|
||||
// Swap dest / src IPs
|
||||
copy(ipHdr[8:24], packet[24:40])
|
||||
copy(ipHdr[24:40], packet[8:24])
|
||||
|
||||
// TCP RST
|
||||
tcpIn := packet[offset:]
|
||||
var ackSeq, seq uint32
|
||||
outFlags := byte(0b00000100) // RST
|
||||
|
||||
inAck := tcpIn[13]&0b00010000 != 0
|
||||
if inAck {
|
||||
seq = binary.BigEndian.Uint32(tcpIn[8:])
|
||||
} else {
|
||||
inSyn := uint32((tcpIn[13] & 0b00000010) >> 1)
|
||||
inFin := uint32(tcpIn[13] & 0b00000001)
|
||||
ackSeq = binary.BigEndian.Uint32(tcpIn[4:]) + inSyn + inFin + uint32(len(tcpIn)) - uint32(tcpIn[12]>>4)<<2
|
||||
outFlags |= 0b00010000 // ACK
|
||||
}
|
||||
|
||||
tcpOut := out[ipv6.HeaderLen:]
|
||||
// Swap dest / src ports
|
||||
copy(tcpOut[0:2], tcpIn[2:4])
|
||||
copy(tcpOut[2:4], tcpIn[0:2])
|
||||
binary.BigEndian.PutUint32(tcpOut[4:], seq)
|
||||
binary.BigEndian.PutUint32(tcpOut[8:], ackSeq)
|
||||
tcpOut[12] = (tcpLen >> 2) << 4 // data offset, reserved, NS
|
||||
tcpOut[13] = outFlags // CWR, ECE, URG, ACK, PSH, RST, SYN, FIN
|
||||
tcpOut[14] = 0 // window size
|
||||
tcpOut[15] = 0 // .
|
||||
tcpOut[16] = 0 // checksum
|
||||
tcpOut[17] = 0 // .
|
||||
tcpOut[18] = 0 // URG Pointer
|
||||
tcpOut[19] = 0 // .
|
||||
|
||||
// Calculate checksum with IPv6 pseudo-header
|
||||
csum := ipv6PseudoheaderChecksum(ipHdr[8:24], ipHdr[24:40], 6, tcpLen)
|
||||
binary.BigEndian.PutUint16(tcpOut[16:], tcpipChecksum(tcpOut, csum))
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func ipv6FindUpperProtocol(packet []byte) (nextHeader uint8, offset int, isFragment bool) {
|
||||
nextHeader = packet[6]
|
||||
offset = ipv6.HeaderLen
|
||||
|
||||
for {
|
||||
switch nextHeader {
|
||||
case 0, 43, 60: // Hop-by-Hop, Routing, Destination
|
||||
if len(packet) < offset+2 {
|
||||
return nextHeader, offset, isFragment
|
||||
}
|
||||
nextHeader = packet[offset]
|
||||
offset += int(packet[offset+1]+1) << 3
|
||||
|
||||
case 44: // Fragment
|
||||
if len(packet) < offset+8 {
|
||||
return nextHeader, offset, isFragment
|
||||
}
|
||||
if packet[offset+2] != 0 || packet[offset+3]&0xf8 != 0 {
|
||||
isFragment = true
|
||||
}
|
||||
nextHeader = packet[offset]
|
||||
offset += 8
|
||||
|
||||
case 51: // AH
|
||||
if len(packet) < offset+2 {
|
||||
return nextHeader, offset, isFragment
|
||||
}
|
||||
nextHeader = packet[offset]
|
||||
offset += int(packet[offset+1]+2) << 2
|
||||
|
||||
default:
|
||||
return nextHeader, offset, isFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func CreateICMPEchoResponse(packet, out []byte) []byte {
|
||||
if len(packet) < 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch packet[0] >> 4 {
|
||||
case 4:
|
||||
return createICMPv4EchoResponse(packet, out)
|
||||
case 6:
|
||||
return createICMPv6EchoResponse(packet, out)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func createICMPv4EchoResponse(packet, out []byte) []byte {
|
||||
// Return early if this is not a simple ICMP Echo Request
|
||||
//TODO: make constants out of these
|
||||
if !(len(packet) >= 28 && len(packet) <= 9001 && packet[0] == 0x45 && packet[9] == 0x01 && packet[20] == 0x08) {
|
||||
@@ -199,6 +418,43 @@ func CreateICMPEchoResponse(packet, out []byte) []byte {
|
||||
return out
|
||||
}
|
||||
|
||||
func createICMPv6EchoResponse(packet, out []byte) []byte {
|
||||
// IPv6 header (40 bytes) + ICMPv6 header (8 bytes minimum)
|
||||
if len(packet) < ipv6.HeaderLen+8 || len(packet) > 9001 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Next Header must be ICMPv6 (58)
|
||||
if packet[6] != 58 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ICMPv6 type must be Echo Request (128)
|
||||
if packet[ipv6.HeaderLen] != 128 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out = out[:len(packet)]
|
||||
copy(out, packet)
|
||||
|
||||
// Swap src/dst addresses (bytes 8-23 and 24-39)
|
||||
copy(out[8:24], packet[24:40])
|
||||
copy(out[24:40], packet[8:24])
|
||||
|
||||
// Change ICMPv6 type to Echo Reply (129)
|
||||
icmp := out[ipv6.HeaderLen:]
|
||||
icmp[0] = 129
|
||||
icmp[2] = 0
|
||||
icmp[3] = 0
|
||||
|
||||
// ICMPv6 checksum uses a pseudo-header with src, dst, length, and next header
|
||||
payloadLen := uint32(len(icmp))
|
||||
csum := ipv6PseudoheaderChecksum(out[8:24], out[24:40], 58, payloadLen)
|
||||
binary.BigEndian.PutUint16(icmp[2:], tcpipChecksum(icmp, csum))
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// calculates the TCP/IP checksum defined in rfc1071. The passed-in
|
||||
// csum is any initial checksum data that's already been computed.
|
||||
//
|
||||
@@ -236,3 +492,18 @@ func ipv4PseudoheaderChecksum(src, dst []byte, proto, length uint32) (csum uint3
|
||||
csum += length >> 16
|
||||
return csum
|
||||
}
|
||||
|
||||
// based on:
|
||||
// - https://github.com/google/gopacket/blob/v1.1.19/layers/tcpip.go#L37-L48
|
||||
func ipv6PseudoheaderChecksum(src, dst []byte, proto, length uint32) (csum uint32) {
|
||||
for i := 0; i < 16; i += 2 {
|
||||
csum += uint32(src[i]) << 8
|
||||
csum += uint32(src[i+1])
|
||||
csum += uint32(dst[i]) << 8
|
||||
csum += uint32(dst[i+1])
|
||||
}
|
||||
csum += proto
|
||||
csum += length & 0xffff
|
||||
csum += length >> 16
|
||||
return csum
|
||||
}
|
||||
|
||||
+404
-1
@@ -1,11 +1,13 @@
|
||||
package iputil
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"golang.org/x/net/ipv4"
|
||||
"golang.org/x/net/ipv6"
|
||||
)
|
||||
|
||||
func Test_CreateRejectPacket(t *testing.T) {
|
||||
@@ -43,7 +45,7 @@ func Test_CreateRejectPacket(t *testing.T) {
|
||||
}
|
||||
b = append(b, []byte{0, 3, 0, 4, 0, 0, 0, 0}...)
|
||||
|
||||
expectedLen = MaxRejectPacketSize
|
||||
expectedLen = maxIPv4RejectPacketSize
|
||||
out = make([]byte, MaxRejectPacketSize)
|
||||
rejectPacket = CreateRejectPacket(b, out)
|
||||
assert.NotNil(t, rejectPacket)
|
||||
@@ -71,3 +73,404 @@ func Test_CreateRejectPacket(t *testing.T) {
|
||||
assert.NotNil(t, rejectPacket)
|
||||
assert.Len(t, rejectPacket, expectedLen)
|
||||
}
|
||||
|
||||
func Test_CreateRejectPacket_NoFragment(t *testing.T) {
|
||||
out := make([]byte, MaxRejectPacketSize)
|
||||
|
||||
// IPv4: non-zero fragment offset should not generate reject packet
|
||||
h := ipv4.Header{
|
||||
Len: 20,
|
||||
Src: net.IPv4(10, 0, 0, 1),
|
||||
Dst: net.IPv4(10, 0, 0, 2),
|
||||
Protocol: 17, // UDP
|
||||
}
|
||||
b, err := h.Marshal()
|
||||
if err != nil {
|
||||
t.Fatalf("h.Marshal: %v", err)
|
||||
}
|
||||
b = append(b, make([]byte, 8)...)
|
||||
// Set fragment offset to non-zero (byte 6-7, offset in 8-byte units)
|
||||
b[6] = 0x00
|
||||
b[7] = 0x01
|
||||
assert.Nil(t, CreateRejectPacket(b, out))
|
||||
|
||||
// MF flag with zero offset (first fragment) should still generate reject
|
||||
b[6] = 0x20 // MF flag set
|
||||
b[7] = 0x00
|
||||
assert.NotNil(t, CreateRejectPacket(b, out))
|
||||
|
||||
// Non-fragment should still generate reject packet
|
||||
b[6] = 0x00
|
||||
b[7] = 0x00
|
||||
assert.NotNil(t, CreateRejectPacket(b, out))
|
||||
|
||||
// DF flag only (not a fragment) should still generate reject packet
|
||||
b[6] = 0x40
|
||||
b[7] = 0x00
|
||||
assert.NotNil(t, CreateRejectPacket(b, out))
|
||||
}
|
||||
|
||||
func Test_CreateRejectPacketIPv6_NoFragment(t *testing.T) {
|
||||
src := net.ParseIP("fd00::1")
|
||||
dst := net.ParseIP("fd00::2")
|
||||
out := make([]byte, MaxRejectPacketSize)
|
||||
|
||||
// IPv6 with Fragment header and non-zero offset should not generate reject
|
||||
fragHeader := []byte{
|
||||
17, // next header: UDP
|
||||
0, // reserved
|
||||
0, 9, // fragment offset=1 (shifted left 3), M=1
|
||||
0, 0, 0, 1, // identification
|
||||
}
|
||||
udpPayload := make([]byte, 8)
|
||||
payload := append(fragHeader, udpPayload...)
|
||||
packet := makeIPv6Packet(src, dst, 44, payload) // next header 44 = Fragment
|
||||
assert.Nil(t, CreateRejectPacket(packet, out))
|
||||
|
||||
// Fragment header with zero offset (first fragment) should still generate reject
|
||||
fragHeader[2] = 0
|
||||
fragHeader[3] = 1 // offset=0, M=1
|
||||
payload = append(fragHeader, udpPayload...)
|
||||
packet = makeIPv6Packet(src, dst, 44, payload)
|
||||
assert.NotNil(t, CreateRejectPacket(packet, out))
|
||||
}
|
||||
|
||||
func Test_CreateRejectPacket_NoICMPError(t *testing.T) {
|
||||
out := make([]byte, MaxRejectPacketSize)
|
||||
|
||||
// ICMP error types should not generate reject packets
|
||||
icmpErrorTypes := []byte{3, 4, 5, 11, 12}
|
||||
for _, icmpType := range icmpErrorTypes {
|
||||
h := ipv4.Header{
|
||||
Len: 20,
|
||||
Src: net.IPv4(10, 0, 0, 1),
|
||||
Dst: net.IPv4(10, 0, 0, 2),
|
||||
Protocol: 1, // ICMP
|
||||
}
|
||||
|
||||
b, err := h.Marshal()
|
||||
if err != nil {
|
||||
t.Fatalf("h.Marshal: %v", err)
|
||||
}
|
||||
b = append(b, icmpType, 0, 0, 0, 0, 0, 0, 0)
|
||||
|
||||
rejectPacket := CreateRejectPacket(b, out)
|
||||
assert.Nil(t, rejectPacket, "ICMP type %d should not generate a reject packet", icmpType)
|
||||
}
|
||||
|
||||
// ICMP non-error types should still generate reject packets
|
||||
icmpNonErrorTypes := []byte{0, 8, 13, 14}
|
||||
for _, icmpType := range icmpNonErrorTypes {
|
||||
h := ipv4.Header{
|
||||
Len: 20,
|
||||
Src: net.IPv4(10, 0, 0, 1),
|
||||
Dst: net.IPv4(10, 0, 0, 2),
|
||||
Protocol: 1, // ICMP
|
||||
}
|
||||
|
||||
b, err := h.Marshal()
|
||||
if err != nil {
|
||||
t.Fatalf("h.Marshal: %v", err)
|
||||
}
|
||||
b = append(b, icmpType, 0, 0, 0, 0, 0, 0, 0)
|
||||
|
||||
rejectPacket := CreateRejectPacket(b, out)
|
||||
assert.NotNil(t, rejectPacket, "ICMP type %d should generate a reject packet", icmpType)
|
||||
}
|
||||
}
|
||||
|
||||
func makeIPv6Packet(src, dst net.IP, nextHeader uint8, payload []byte) []byte {
|
||||
b := make([]byte, ipv6.HeaderLen+len(payload))
|
||||
b[0] = ipv6.Version << 4
|
||||
binary.BigEndian.PutUint16(b[4:], uint16(len(payload)))
|
||||
b[6] = nextHeader
|
||||
b[7] = 64
|
||||
copy(b[8:24], src.To16())
|
||||
copy(b[24:40], dst.To16())
|
||||
copy(b[ipv6.HeaderLen:], payload)
|
||||
return b
|
||||
}
|
||||
|
||||
func Test_CreateRejectPacketIPv6_ICMP(t *testing.T) {
|
||||
src := net.ParseIP("fd00::1")
|
||||
dst := net.ParseIP("fd00::2")
|
||||
|
||||
// Small UDP packet: entire original included in body
|
||||
udpPayload := make([]byte, 20)
|
||||
udpPayload[0] = 0x00 // src port high
|
||||
udpPayload[1] = 0x50 // src port low (80)
|
||||
udpPayload[2] = 0x01 // dst port high
|
||||
udpPayload[3] = 0xBB // dst port low (443)
|
||||
packet := makeIPv6Packet(src, dst, 17, udpPayload)
|
||||
|
||||
out := make([]byte, MaxRejectPacketSize)
|
||||
rejectPacket := CreateRejectPacket(packet, out)
|
||||
assert.NotNil(t, rejectPacket)
|
||||
|
||||
// Small packet fits entirely: 40 (ipv6 hdr) + 8 (icmpv6 hdr) + 60 (original)
|
||||
expectedLen := ipv6.HeaderLen + 8 + len(packet)
|
||||
assert.Len(t, rejectPacket, expectedLen)
|
||||
|
||||
// Verify version
|
||||
assert.Equal(t, byte(ipv6.Version<<4), rejectPacket[0]&0xf0)
|
||||
// Verify next header is ICMPv6 (58)
|
||||
assert.Equal(t, byte(58), rejectPacket[6])
|
||||
// Verify src/dst are swapped
|
||||
assert.Equal(t, dst.To16(), net.IP(rejectPacket[8:24]))
|
||||
assert.Equal(t, src.To16(), net.IP(rejectPacket[24:40]))
|
||||
// Verify ICMPv6 type=1 (Dest Unreachable), code=1 (Administratively prohibited)
|
||||
assert.Equal(t, byte(1), rejectPacket[ipv6.HeaderLen])
|
||||
assert.Equal(t, byte(1), rejectPacket[ipv6.HeaderLen+1])
|
||||
// Verify entire original packet is included in body
|
||||
assert.Equal(t, packet, rejectPacket[ipv6.HeaderLen+8:])
|
||||
|
||||
// Large packet: body is truncated to 1000 bytes
|
||||
largePkt := makeIPv6Packet(src, dst, 17, make([]byte, 1200))
|
||||
rejectPacket = CreateRejectPacket(largePkt, out)
|
||||
assert.NotNil(t, rejectPacket)
|
||||
assert.Len(t, rejectPacket, ipv6.HeaderLen+8+1000)
|
||||
assert.Equal(t, largePkt[:1000], rejectPacket[ipv6.HeaderLen+8:])
|
||||
}
|
||||
|
||||
func Test_CreateRejectPacketIPv6_TCP(t *testing.T) {
|
||||
src := net.ParseIP("fd00::1")
|
||||
dst := net.ParseIP("fd00::2")
|
||||
|
||||
// TCP SYN packet (next header 6)
|
||||
tcpPayload := make([]byte, 20)
|
||||
tcpPayload[0] = 0x00 // src port high
|
||||
tcpPayload[1] = 0x50 // src port low (80)
|
||||
tcpPayload[2] = 0x01 // dst port high
|
||||
tcpPayload[3] = 0xBB // dst port low (443)
|
||||
binary.BigEndian.PutUint32(tcpPayload[4:], 1000) // seq
|
||||
binary.BigEndian.PutUint32(tcpPayload[8:], 0) // ack seq
|
||||
tcpPayload[12] = (20 >> 2) << 4 // data offset
|
||||
tcpPayload[13] = 0b00000010 // SYN flag
|
||||
|
||||
packet := makeIPv6Packet(src, dst, 6, tcpPayload)
|
||||
|
||||
out := make([]byte, MaxRejectPacketSize)
|
||||
rejectPacket := CreateRejectPacket(packet, out)
|
||||
assert.NotNil(t, rejectPacket)
|
||||
|
||||
// Expected: 40 (ipv6 hdr) + 20 (tcp RST)
|
||||
expectedLen := ipv6.HeaderLen + 20
|
||||
assert.Len(t, rejectPacket, expectedLen)
|
||||
|
||||
// Verify version
|
||||
assert.Equal(t, byte(ipv6.Version<<4), rejectPacket[0]&0xf0)
|
||||
// Verify next header is TCP (6)
|
||||
assert.Equal(t, byte(6), rejectPacket[6])
|
||||
// Verify src/dst are swapped
|
||||
assert.Equal(t, dst.To16(), net.IP(rejectPacket[8:24]))
|
||||
assert.Equal(t, src.To16(), net.IP(rejectPacket[24:40]))
|
||||
// Verify ports are swapped
|
||||
tcpOut := rejectPacket[ipv6.HeaderLen:]
|
||||
assert.Equal(t, uint16(443), binary.BigEndian.Uint16(tcpOut[0:2]))
|
||||
assert.Equal(t, uint16(80), binary.BigEndian.Uint16(tcpOut[2:4]))
|
||||
// RST+ACK flags (since input was SYN without ACK)
|
||||
assert.Equal(t, byte(0b00010100), tcpOut[13])
|
||||
// ack_seq = original seq (1000) + SYN (1) + FIN (0) + segment data (0)
|
||||
assert.Equal(t, uint32(1001), binary.BigEndian.Uint32(tcpOut[8:]))
|
||||
}
|
||||
|
||||
func Test_CreateRejectPacketIPv6_TCPWithACK(t *testing.T) {
|
||||
src := net.ParseIP("fd00::1")
|
||||
dst := net.ParseIP("fd00::2")
|
||||
|
||||
// TCP packet with ACK set
|
||||
tcpPayload := make([]byte, 20)
|
||||
tcpPayload[0] = 0x00
|
||||
tcpPayload[1] = 0x50
|
||||
tcpPayload[2] = 0x01
|
||||
tcpPayload[3] = 0xBB
|
||||
binary.BigEndian.PutUint32(tcpPayload[4:], 1000) // seq
|
||||
binary.BigEndian.PutUint32(tcpPayload[8:], 2000) // ack seq
|
||||
tcpPayload[12] = (20 >> 2) << 4 // data offset
|
||||
tcpPayload[13] = 0b00010000 // ACK flag
|
||||
|
||||
packet := makeIPv6Packet(src, dst, 6, tcpPayload)
|
||||
|
||||
out := make([]byte, MaxRejectPacketSize)
|
||||
rejectPacket := CreateRejectPacket(packet, out)
|
||||
assert.NotNil(t, rejectPacket)
|
||||
|
||||
tcpOut := rejectPacket[ipv6.HeaderLen:]
|
||||
// RST only (no ACK) since input had ACK
|
||||
assert.Equal(t, byte(0b00000100), tcpOut[13])
|
||||
// seq = original ack_seq
|
||||
assert.Equal(t, uint32(2000), binary.BigEndian.Uint32(tcpOut[4:]))
|
||||
}
|
||||
|
||||
func Test_CreateRejectPacketIPv6_NoICMPError(t *testing.T) {
|
||||
src := net.ParseIP("fd00::1")
|
||||
dst := net.ParseIP("fd00::2")
|
||||
out := make([]byte, MaxRejectPacketSize)
|
||||
|
||||
// ICMPv6 error types (1-4) should not generate reject packets
|
||||
for icmpType := byte(1); icmpType <= 4; icmpType++ {
|
||||
payload := make([]byte, 8)
|
||||
payload[0] = icmpType
|
||||
packet := makeIPv6Packet(src, dst, 58, payload)
|
||||
|
||||
rejectPacket := CreateRejectPacket(packet, out)
|
||||
assert.Nil(t, rejectPacket, "ICMPv6 type %d should not generate a reject packet", icmpType)
|
||||
}
|
||||
|
||||
// ICMPv6 non-error types should still generate reject packets
|
||||
nonErrorTypes := []byte{128, 129, 133, 134}
|
||||
for _, icmpType := range nonErrorTypes {
|
||||
payload := make([]byte, 8)
|
||||
payload[0] = icmpType
|
||||
packet := makeIPv6Packet(src, dst, 58, payload)
|
||||
|
||||
rejectPacket := CreateRejectPacket(packet, out)
|
||||
assert.NotNil(t, rejectPacket, "ICMPv6 type %d should generate a reject packet", icmpType)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_CreateRejectPacketIPv6_TooShort(t *testing.T) {
|
||||
// Packet too short to be valid IPv6
|
||||
out := make([]byte, MaxRejectPacketSize)
|
||||
assert.Nil(t, CreateRejectPacket([]byte{0x60}, out))
|
||||
assert.Nil(t, CreateRejectPacket(make([]byte, 39), out))
|
||||
}
|
||||
|
||||
func Test_CreateRejectPacketIPv6_ExtensionHeaders(t *testing.T) {
|
||||
src := net.ParseIP("fd00::1")
|
||||
dst := net.ParseIP("fd00::2")
|
||||
|
||||
// IPv6 + Hop-by-Hop extension header + TCP
|
||||
hopByHop := []byte{
|
||||
6, // next header: TCP
|
||||
0, // length (8 bytes total)
|
||||
0, 0, // padding
|
||||
0, 0, 0, 0,
|
||||
}
|
||||
tcpPayload := make([]byte, 20)
|
||||
tcpPayload[0] = 0x00
|
||||
tcpPayload[1] = 0x50
|
||||
tcpPayload[2] = 0x01
|
||||
tcpPayload[3] = 0xBB
|
||||
binary.BigEndian.PutUint32(tcpPayload[4:], 1000)
|
||||
binary.BigEndian.PutUint32(tcpPayload[8:], 2000)
|
||||
tcpPayload[12] = (20 >> 2) << 4
|
||||
tcpPayload[13] = 0b00010000 // ACK
|
||||
|
||||
payload := append(hopByHop, tcpPayload...)
|
||||
packet := makeIPv6Packet(src, dst, 0, payload) // next header 0 = Hop-by-Hop
|
||||
|
||||
out := make([]byte, MaxRejectPacketSize)
|
||||
rejectPacket := CreateRejectPacket(packet, out)
|
||||
assert.NotNil(t, rejectPacket)
|
||||
|
||||
// Should produce TCP RST
|
||||
expectedLen := ipv6.HeaderLen + 20
|
||||
assert.Len(t, rejectPacket, expectedLen)
|
||||
assert.Equal(t, byte(6), rejectPacket[6]) // next header is TCP
|
||||
tcpOut := rejectPacket[ipv6.HeaderLen:]
|
||||
assert.Equal(t, byte(0b00000100), tcpOut[13]) // RST only
|
||||
}
|
||||
|
||||
func TestCreateICMPEchoResponse_IPv4(t *testing.T) {
|
||||
// Build a simple IPv4 ICMP Echo Request
|
||||
packet := make([]byte, 28)
|
||||
packet[0] = 0x45 // version 4, IHL 5
|
||||
binary.BigEndian.PutUint16(packet[2:], uint16(28)) // total length
|
||||
packet[8] = 64 // TTL
|
||||
packet[9] = 1 // protocol ICMP
|
||||
copy(packet[12:16], net.IPv4(10, 0, 0, 1).To4()) // src
|
||||
copy(packet[16:20], net.IPv4(10, 0, 0, 2).To4()) // dst
|
||||
packet[20] = 8 // ICMP Echo Request
|
||||
|
||||
out := make([]byte, len(packet))
|
||||
result := CreateICMPEchoResponse(packet, out)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, byte(0x45), result[0])
|
||||
// src/dst swapped
|
||||
assert.Equal(t, net.IPv4(10, 0, 0, 2).To4(), net.IP(result[12:16]))
|
||||
assert.Equal(t, net.IPv4(10, 0, 0, 1).To4(), net.IP(result[16:20]))
|
||||
// ICMP Echo Reply
|
||||
assert.Equal(t, byte(0), result[20])
|
||||
}
|
||||
|
||||
func TestCreateICMPEchoResponse_IPv6(t *testing.T) {
|
||||
src := net.ParseIP("fd00::1").To16()
|
||||
dst := net.ParseIP("fd00::2").To16()
|
||||
|
||||
// Build an IPv6 ICMPv6 Echo Request packet
|
||||
// IPv6 header (40 bytes) + ICMPv6 (8 bytes)
|
||||
packet := make([]byte, 48)
|
||||
packet[0] = 0x60 // version 6
|
||||
payloadLen := uint16(8) // ICMPv6 header only
|
||||
binary.BigEndian.PutUint16(packet[4:], payloadLen)
|
||||
packet[6] = 58 // Next Header: ICMPv6
|
||||
packet[7] = 64 // Hop Limit
|
||||
copy(packet[8:24], src) // src address
|
||||
copy(packet[24:40], dst) // dst address
|
||||
|
||||
// ICMPv6 Echo Request
|
||||
icmp := packet[40:]
|
||||
icmp[0] = 128 // type: Echo Request
|
||||
icmp[1] = 0 // code
|
||||
binary.BigEndian.PutUint16(icmp[4:], 1) // identifier
|
||||
binary.BigEndian.PutUint16(icmp[6:], 1) // sequence number
|
||||
|
||||
// Compute correct checksum for the request
|
||||
csum := ipv6PseudoheaderChecksum(src, dst, 58, uint32(payloadLen))
|
||||
binary.BigEndian.PutUint16(icmp[2:], tcpipChecksum(icmp, csum))
|
||||
|
||||
out := make([]byte, len(packet))
|
||||
result := CreateICMPEchoResponse(packet, out)
|
||||
assert.NotNil(t, result)
|
||||
|
||||
// Version should still be 6
|
||||
assert.Equal(t, byte(6), result[0]>>4)
|
||||
// src/dst swapped
|
||||
assert.Equal(t, dst, net.IP(result[8:24]))
|
||||
assert.Equal(t, src, net.IP(result[24:40]))
|
||||
// ICMPv6 Echo Reply type
|
||||
assert.Equal(t, byte(129), result[40])
|
||||
|
||||
// Verify checksum is valid (tcpipChecksum returns 0 when data+checksum is correct)
|
||||
respIcmp := result[40:]
|
||||
verifyCsum := ipv6PseudoheaderChecksum(result[8:24], result[24:40], 58, uint32(payloadLen))
|
||||
assert.Equal(t, uint16(0), tcpipChecksum(respIcmp, verifyCsum))
|
||||
}
|
||||
|
||||
func TestCreateICMPEchoResponse_IPv6_NotEchoRequest(t *testing.T) {
|
||||
src := net.ParseIP("fd00::1").To16()
|
||||
dst := net.ParseIP("fd00::2").To16()
|
||||
|
||||
packet := make([]byte, 48)
|
||||
packet[0] = 0x60
|
||||
binary.BigEndian.PutUint16(packet[4:], 8)
|
||||
packet[6] = 58
|
||||
packet[7] = 64
|
||||
copy(packet[8:24], src)
|
||||
copy(packet[24:40], dst)
|
||||
|
||||
// ICMPv6 type 1 (Destination Unreachable) - not Echo Request
|
||||
packet[40] = 1
|
||||
|
||||
out := make([]byte, len(packet))
|
||||
result := CreateICMPEchoResponse(packet, out)
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestCreateICMPEchoResponse_IPv6_NotICMPv6(t *testing.T) {
|
||||
src := net.ParseIP("fd00::1").To16()
|
||||
dst := net.ParseIP("fd00::2").To16()
|
||||
|
||||
packet := make([]byte, 48)
|
||||
packet[0] = 0x60
|
||||
binary.BigEndian.PutUint16(packet[4:], 8)
|
||||
packet[6] = 6 // TCP, not ICMPv6
|
||||
packet[7] = 64
|
||||
copy(packet[8:24], src)
|
||||
copy(packet[24:40], dst)
|
||||
|
||||
out := make([]byte, len(packet))
|
||||
result := CreateICMPEchoResponse(packet, out)
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
+21
-4
@@ -272,16 +272,18 @@ func (lh *LightHouse) reload(c *config.C, initial bool) error {
|
||||
//NOTE: many things will get much simpler when we combine static_host_map and lighthouse.hosts in config
|
||||
if initial || c.HasChanged("static_host_map") || c.HasChanged("static_map.cadence") || c.HasChanged("static_map.network") || c.HasChanged("static_map.lookup_timeout") {
|
||||
// Clean up. Entries still in the static_host_map will be re-built.
|
||||
// Entries no longer present must have their (possible) background DNS goroutines stopped.
|
||||
if existingStaticList := lh.staticList.Load(); existingStaticList != nil {
|
||||
ourselves := lh.myVpnNetworks[0].Addr()
|
||||
oldStaticList := lh.staticList.Load()
|
||||
if oldStaticList != nil {
|
||||
lh.RLock()
|
||||
for staticVpnAddr := range *existingStaticList {
|
||||
for staticVpnAddr := range *oldStaticList {
|
||||
if am, ok := lh.addrMap[staticVpnAddr]; ok && am != nil {
|
||||
am.hr.Cancel()
|
||||
am.ResetForOwner(ourselves)
|
||||
}
|
||||
}
|
||||
lh.RUnlock()
|
||||
}
|
||||
|
||||
// Build a new list based on current config.
|
||||
staticList := make(map[netip.Addr]struct{})
|
||||
err := lh.loadStaticMap(c, staticList)
|
||||
@@ -289,6 +291,21 @@ func (lh *LightHouse) reload(c *config.C, initial bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// For entries removed from static_host_map, stop the DNS goroutine and drop the cached addrs.
|
||||
// All addrs must come from the lighthouses now that it's no longer a static host.
|
||||
if oldStaticList != nil {
|
||||
lh.RLock()
|
||||
for staticVpnAddr := range *oldStaticList {
|
||||
if _, stillStatic := staticList[staticVpnAddr]; stillStatic {
|
||||
continue
|
||||
}
|
||||
if am, ok := lh.addrMap[staticVpnAddr]; ok && am != nil {
|
||||
am.ClearHostnameResults()
|
||||
}
|
||||
}
|
||||
lh.RUnlock()
|
||||
}
|
||||
|
||||
lh.staticList.Store(&staticList)
|
||||
if !initial {
|
||||
if c.HasChanged("static_host_map") {
|
||||
|
||||
@@ -303,6 +303,132 @@ func TestLighthouse_reload(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// TestLighthouse_reloadStaticHostMap verifies that reloading static_host_map applies the new
|
||||
// config rather than appending to it. See issue #718.
|
||||
func TestLighthouse_reloadStaticHostMap(t *testing.T) {
|
||||
l := test.NewLogger()
|
||||
c := config.NewC(l)
|
||||
c.Settings["lighthouse"] = map[string]any{"am_lighthouse": true}
|
||||
c.Settings["listen"] = map[string]any{"port": 4242}
|
||||
c.Settings["static_host_map"] = map[string]any{
|
||||
"10.128.0.2": []any{"1.1.1.1:4242"},
|
||||
}
|
||||
|
||||
myVpnNet := netip.MustParsePrefix("10.128.0.1/24")
|
||||
nt := new(bart.Lite)
|
||||
nt.Insert(myVpnNet)
|
||||
cs := &CertState{
|
||||
myVpnNetworks: []netip.Prefix{myVpnNet},
|
||||
myVpnNetworksTable: nt,
|
||||
}
|
||||
|
||||
lh, err := NewLightHouseFromConfig(t.Context(), l, c, cs, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
staticHost := netip.MustParseAddr("10.128.0.2")
|
||||
otherHost := netip.MustParseAddr("10.128.0.3")
|
||||
|
||||
// Capture the RemoteList pointer up front; an in-flight handshake would hold the same one
|
||||
// on hostinfo.remotes, so it must reflect every reload below.
|
||||
pinned := lh.Query(staticHost)
|
||||
require.NotNil(t, pinned)
|
||||
assert.Equal(t, []netip.AddrPort{netip.MustParseAddrPort("1.1.1.1:4242")}, pinned.CopyAddrs([]netip.Prefix{}))
|
||||
|
||||
// Replace the remote address. The new address should be the only entry.
|
||||
nc := map[string]any{
|
||||
"static_host_map": map[string]any{
|
||||
"10.128.0.2": []any{"2.2.2.2:4242"},
|
||||
},
|
||||
}
|
||||
rc, err := yaml.Marshal(nc)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, c.ReloadConfigString(string(rc)))
|
||||
|
||||
rl := lh.Query(staticHost)
|
||||
require.NotNil(t, rl)
|
||||
assert.Same(t, pinned, rl, "RemoteList pointer must stay stable so in-flight handshakes pick up the change")
|
||||
assert.Equal(t, []netip.AddrPort{netip.MustParseAddrPort("2.2.2.2:4242")}, rl.CopyAddrs([]netip.Prefix{}))
|
||||
|
||||
// Reload back to the original IP. Mirrors the round-trip in issue #718 step 6-8 where
|
||||
// the buggy reload produced [1.1.1.1, 2.2.2.2, 1.1.1.1] instead of [1.1.1.1].
|
||||
nc = map[string]any{
|
||||
"static_host_map": map[string]any{
|
||||
"10.128.0.2": []any{"1.1.1.1:4242"},
|
||||
},
|
||||
}
|
||||
rc, err = yaml.Marshal(nc)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, c.ReloadConfigString(string(rc)))
|
||||
|
||||
rl = lh.Query(staticHost)
|
||||
require.NotNil(t, rl)
|
||||
assert.Same(t, pinned, rl)
|
||||
assert.Equal(t, []netip.AddrPort{netip.MustParseAddrPort("1.1.1.1:4242")}, rl.CopyAddrs([]netip.Prefix{}))
|
||||
|
||||
// Reload with the same config. An unchanged entry must not duplicate.
|
||||
require.NoError(t, c.ReloadConfigString(string(rc)))
|
||||
|
||||
rl = lh.Query(staticHost)
|
||||
require.NotNil(t, rl)
|
||||
assert.Same(t, pinned, rl)
|
||||
assert.Equal(t, []netip.AddrPort{netip.MustParseAddrPort("1.1.1.1:4242")}, rl.CopyAddrs([]netip.Prefix{}))
|
||||
|
||||
// Switch back to 2.2.2.2 so the rest of the test continues against a known address.
|
||||
nc = map[string]any{
|
||||
"static_host_map": map[string]any{
|
||||
"10.128.0.2": []any{"2.2.2.2:4242"},
|
||||
},
|
||||
}
|
||||
rc, err = yaml.Marshal(nc)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, c.ReloadConfigString(string(rc)))
|
||||
|
||||
// Add a second host alongside the first. Both should be present, neither duplicated.
|
||||
nc = map[string]any{
|
||||
"static_host_map": map[string]any{
|
||||
"10.128.0.2": []any{"2.2.2.2:4242"},
|
||||
"10.128.0.3": []any{"3.3.3.3:4242"},
|
||||
},
|
||||
}
|
||||
rc, err = yaml.Marshal(nc)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, c.ReloadConfigString(string(rc)))
|
||||
|
||||
rl = lh.Query(staticHost)
|
||||
require.NotNil(t, rl)
|
||||
assert.Same(t, pinned, rl, "adding a sibling entry must not displace the existing RemoteList")
|
||||
assert.Equal(t, []netip.AddrPort{netip.MustParseAddrPort("2.2.2.2:4242")}, rl.CopyAddrs([]netip.Prefix{}))
|
||||
|
||||
rl = lh.Query(otherHost)
|
||||
require.NotNil(t, rl)
|
||||
assert.Equal(t, []netip.AddrPort{netip.MustParseAddrPort("3.3.3.3:4242")}, rl.CopyAddrs([]netip.Prefix{}))
|
||||
|
||||
// Drop the first host entirely. The vpnAddr is no longer marked static, our owner
|
||||
// contribution is cleared, but the addrMap entry stays in place so non-static cache
|
||||
// data (from lighthouse queries) on the same RemoteList isn't lost. In-flight handshakes
|
||||
// that already had the pointer see an empty address list rather than retrying stale ones.
|
||||
nc = map[string]any{
|
||||
"static_host_map": map[string]any{
|
||||
"10.128.0.3": []any{"3.3.3.3:4242"},
|
||||
},
|
||||
}
|
||||
rc, err = yaml.Marshal(nc)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, c.ReloadConfigString(string(rc)))
|
||||
|
||||
_, isStatic := lh.GetStaticHostList()[staticHost]
|
||||
assert.False(t, isStatic)
|
||||
|
||||
rl = lh.Query(staticHost)
|
||||
require.NotNil(t, rl)
|
||||
assert.Same(t, pinned, rl)
|
||||
assert.Empty(t, rl.CopyAddrs([]netip.Prefix{}))
|
||||
|
||||
rl = lh.Query(otherHost)
|
||||
require.NotNil(t, rl)
|
||||
assert.Equal(t, []netip.AddrPort{netip.MustParseAddrPort("3.3.3.3:4242")}, rl.CopyAddrs([]netip.Prefix{}))
|
||||
}
|
||||
|
||||
func newLHHostRequest(fromAddr netip.AddrPort, myVpnIp, queryVpnIp netip.Addr, lhh *LightHouseHandler) testLhReply {
|
||||
req := &NebulaMeta{
|
||||
Type: NebulaMeta_HostQuery,
|
||||
|
||||
@@ -5,10 +5,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
_ "net/http/pprof"
|
||||
"net/netip"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -36,9 +33,6 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev
|
||||
buildVersion = moduleVersion()
|
||||
}
|
||||
|
||||
//todo no merge
|
||||
go http.ListenAndServe(":6060", nil)
|
||||
|
||||
// Print the config if in test, the exit comes later
|
||||
if configTest {
|
||||
b, err := yaml.Marshal(c.Settings)
|
||||
@@ -200,7 +194,7 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev
|
||||
handshakeManager := NewHandshakeManager(l, hostMap, lightHouse, udpConns[0], handshakeConfig)
|
||||
lightHouse.handshakeTrigger = handshakeManager.trigger
|
||||
|
||||
ds, err := newDnsServerFromConfig(ctx, l, pki.getCertState(), hostMap, c)
|
||||
ds, err := newDnsServerFromConfig(ctx, l, pki, hostMap, c)
|
||||
if err != nil {
|
||||
l.Warn("Failed to start DNS responder", "error", err)
|
||||
}
|
||||
@@ -221,13 +215,11 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev
|
||||
DropLocalBroadcast: c.GetBool("tun.drop_local_broadcast", false),
|
||||
DropMulticast: c.GetBool("tun.drop_multicast", false),
|
||||
routines: routines,
|
||||
batchSize: c.GetInt("listen.batch", 64),
|
||||
MessageMetrics: messageMetrics,
|
||||
version: buildVersion,
|
||||
relayManager: NewRelayManager(ctx, l, hostMap, c),
|
||||
punchy: punchy,
|
||||
ConntrackCacheTimeout: conntrackCacheTimeout,
|
||||
CpuAffinity: parseCpuAffinity(c, l, routines),
|
||||
l: l,
|
||||
}
|
||||
|
||||
@@ -245,7 +237,6 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev
|
||||
ifce.reloadDisconnectInvalid(c)
|
||||
ifce.reloadSendRecvError(c)
|
||||
ifce.reloadAcceptRecvError(c)
|
||||
ifce.reloadEcn(c)
|
||||
|
||||
handshakeManager.f = ifce
|
||||
go handshakeManager.Run(ctx)
|
||||
@@ -280,53 +271,6 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseCpuAffinity reads `tun.cpu_affinity` from the config — a list of
|
||||
// integer CPU IDs, one per TUN reader goroutine. Empty / unset returns nil
|
||||
// (listenIn falls back to its default `i % NumCPU` pinning). Length
|
||||
// mismatch with `routines` is a warning, not an error: shorter lists are
|
||||
// modulo-cycled across queues, longer lists' tail is ignored. Invalid
|
||||
// entries (non-integer, out of range) are also a warning and disable the
|
||||
// override entirely so we don't silently pin to the wrong CPU.
|
||||
func parseCpuAffinity(c *config.C, l *slog.Logger, routines int) []int {
|
||||
raw := c.Get("tun.cpu_affinity")
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
rv, ok := raw.([]any)
|
||||
if !ok {
|
||||
l.Warn("tun.cpu_affinity must be a list of integers; ignoring", "value", raw)
|
||||
return nil
|
||||
}
|
||||
nCPU := runtime.NumCPU()
|
||||
cpus := make([]int, 0, len(rv))
|
||||
for i, e := range rv {
|
||||
var cpu int
|
||||
switch v := e.(type) {
|
||||
case int:
|
||||
cpu = v
|
||||
case int64:
|
||||
cpu = int(v)
|
||||
case float64:
|
||||
cpu = int(v)
|
||||
default:
|
||||
l.Warn("tun.cpu_affinity entry not an integer; ignoring affinity",
|
||||
"index", i, "value", e)
|
||||
return nil
|
||||
}
|
||||
if cpu < 0 || cpu >= nCPU {
|
||||
l.Warn("tun.cpu_affinity entry out of range; ignoring affinity",
|
||||
"index", i, "cpu", cpu, "num_cpu", nCPU)
|
||||
return nil
|
||||
}
|
||||
cpus = append(cpus, cpu)
|
||||
}
|
||||
if len(cpus) != routines {
|
||||
l.Warn("tun.cpu_affinity length doesn't match routines; queues will modulo-cycle through the list",
|
||||
"affinity_len", len(cpus), "routines", routines)
|
||||
}
|
||||
return cpus
|
||||
}
|
||||
|
||||
func moduleVersion() string {
|
||||
info, ok := debug.ReadBuildInfo()
|
||||
if !ok {
|
||||
|
||||
@@ -147,62 +147,6 @@ func buildCipherStatesB(b *testing.B, c noise.CipherFunc) (*noise.CipherState, *
|
||||
return eI, dR
|
||||
}
|
||||
|
||||
// TestDecryptDangerRelayShapeNoAlloc covers the AD-only relay path used in
|
||||
// outside.go's handleOutsideRelayPacket: the body is AD, the trailing 16 bytes
|
||||
// are the AEAD tag, the plaintext is empty, and the caller passes nil as the
|
||||
// destination because it only needs the auth side-effect. The call must
|
||||
// succeed, return an empty plaintext, and not allocate on the hot path.
|
||||
func TestDecryptDangerRelayShapeNoAlloc(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
c noise.CipherFunc
|
||||
wrap func(*noise.CipherState) CipherState
|
||||
}{
|
||||
{"AESGCM", CipherAESGCM, func(cs *noise.CipherState) CipherState { return NewCipherStateAESGCM(cs) }},
|
||||
{"ChaChaPoly", noise.CipherChaChaPoly, func(cs *noise.CipherState) CipherState { return NewCipherStateChaChaPoly(cs) }},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
encCS, decCS := buildCipherStates(t, tc.c)
|
||||
enc, dec := tc.wrap(encCS), tc.wrap(decCS)
|
||||
|
||||
ad := make([]byte, 1200) // typical relay packet body size
|
||||
for i := range ad {
|
||||
ad[i] = byte(i)
|
||||
}
|
||||
nb := make([]byte, 12)
|
||||
|
||||
// Build the "signature value" the way handleOutsideRelayPacket sees it:
|
||||
// empty plaintext encrypted with the body as AD yields just the 16-byte tag.
|
||||
tag, err := enc.EncryptDanger(nil, ad, nil, 1, nb)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, tag, dec.Overhead())
|
||||
|
||||
// Sanity: the relay-shaped call returns empty plaintext, no error.
|
||||
out, err := dec.DecryptDanger(nil, ad, tag, 1, nb)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, out)
|
||||
|
||||
// Tampering with the AD must fail authentication.
|
||||
ad[0] ^= 0xff
|
||||
_, err = dec.DecryptDanger(nil, ad, tag, 1, nb)
|
||||
require.Error(t, err)
|
||||
ad[0] ^= 0xff
|
||||
|
||||
// The hot path must not allocate. AllocsPerRun does a warm-up run, so any
|
||||
// one-time setup is excluded. Counter has to advance so the AEAD nonce is
|
||||
// unique per call, but we don't care whether the auth succeeds — we only
|
||||
// care about whether the call path allocates.
|
||||
var counter uint64 = 2
|
||||
allocs := testing.AllocsPerRun(100, func() {
|
||||
_, _ = dec.DecryptDanger(nil, ad, tag, counter, nb)
|
||||
counter++
|
||||
})
|
||||
assert.Equal(t, 0.0, allocs, "DecryptDanger(nil, ...) must not allocate")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCipherStateNilSafety(t *testing.T) {
|
||||
var aes *CipherStateAESGCM
|
||||
_, err := aes.EncryptDanger(nil, nil, nil, 0, make([]byte, 12))
|
||||
|
||||
+21
-72
@@ -13,7 +13,6 @@ import (
|
||||
|
||||
"github.com/slackhq/nebula/firewall"
|
||||
"github.com/slackhq/nebula/header"
|
||||
"github.com/slackhq/nebula/udp"
|
||||
"golang.org/x/net/ipv4"
|
||||
)
|
||||
|
||||
@@ -23,7 +22,7 @@ const (
|
||||
|
||||
var ErrOutOfWindow = errors.New("out of window packet")
|
||||
|
||||
func (f *Interface) readOutsidePackets(via ViaSender, packet []byte, h *header.H, fwPacket *firewall.Packet, lhf *LightHouseHandler, nb []byte, q int, localCache firewall.ConntrackCache, meta udp.RxMeta) {
|
||||
func (f *Interface) readOutsidePackets(via ViaSender, out []byte, packet []byte, h *header.H, fwPacket *firewall.Packet, lhf *LightHouseHandler, nb []byte, q int, localCache firewall.ConntrackCache) {
|
||||
err := h.Parse(packet)
|
||||
if err != nil {
|
||||
// Hole punch packets are 0 or 1 byte big, so lets ignore printing those errors
|
||||
@@ -111,11 +110,11 @@ func (f *Interface) readOutsidePackets(via ViaSender, packet []byte, h *header.H
|
||||
|
||||
// Relay packets are special
|
||||
if isMessageRelay {
|
||||
f.handleOutsideRelayPacket(hostinfo, via, packet, h, fwPacket, lhf, nb, q, localCache, meta)
|
||||
f.handleOutsideRelayPacket(hostinfo, via, out, packet, h, fwPacket, lhf, nb, q, localCache)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
out := f.batchers[q].Reserve(len(packet))[:0]
|
||||
out, err = f.decrypt(hostinfo, h.MessageCounter, out, packet, h, nb)
|
||||
if err != nil {
|
||||
if f.l.Enabled(context.Background(), slog.LevelDebug) {
|
||||
@@ -136,7 +135,7 @@ func (f *Interface) readOutsidePackets(via ViaSender, packet []byte, h *header.H
|
||||
case header.Message:
|
||||
switch h.Subtype {
|
||||
case header.MessageNone:
|
||||
f.handleOutsideMessagePacket(hostinfo, out, packet, fwPacket, nb, q, localCache, meta)
|
||||
f.handleOutsideMessagePacket(hostinfo, out, packet, fwPacket, nb, q, localCache)
|
||||
default:
|
||||
hostinfo.logger(f.l).Error("IsValidSubType was true, but unexpected message subtype seen", "from", via, "header", h)
|
||||
return
|
||||
@@ -169,7 +168,7 @@ func (f *Interface) readOutsidePackets(via ViaSender, packet []byte, h *header.H
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Interface) handleOutsideRelayPacket(hostinfo *HostInfo, via ViaSender, packet []byte, h *header.H, fwPacket *firewall.Packet, lhf *LightHouseHandler, nb []byte, q int, localCache firewall.ConntrackCache, meta udp.RxMeta) {
|
||||
func (f *Interface) handleOutsideRelayPacket(hostinfo *HostInfo, via ViaSender, out []byte, packet []byte, h *header.H, fwPacket *firewall.Packet, lhf *LightHouseHandler, nb []byte, q int, localCache firewall.ConntrackCache) {
|
||||
// The entire body is sent as AD, not encrypted.
|
||||
// The packet consists of a 16-byte parsed Nebula header, Associated Data-protected payload, and a trailing 16-byte AEAD signature value.
|
||||
// The packet is guaranteed to be at least 16 bytes at this point, b/c it got past the h.Parse() call above. If it's
|
||||
@@ -177,10 +176,16 @@ func (f *Interface) handleOutsideRelayPacket(hostinfo *HostInfo, via ViaSender,
|
||||
// which will gracefully fail in the DecryptDanger call.
|
||||
signedPayload := packet[:len(packet)-hostinfo.ConnectionState.dKey.Overhead()]
|
||||
signatureValue := packet[len(packet)-hostinfo.ConnectionState.dKey.Overhead():]
|
||||
// The decrypted output is empty (relay packets carry their payload as AD) and unused.
|
||||
// The recursive readOutsidePackets call below operates on signedPayload. Passing
|
||||
// nil avoids reserving an arena slot.
|
||||
if _, err := hostinfo.ConnectionState.dKey.DecryptDanger(nil, signedPayload, signatureValue, h.MessageCounter, nb); err != nil {
|
||||
var err error
|
||||
out, err = hostinfo.ConnectionState.dKey.DecryptDanger(out, signedPayload, signatureValue, h.MessageCounter, nb)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Advance the replay window now that the frame is authenticated
|
||||
if !hostinfo.ConnectionState.window.Update(f.l, h.MessageCounter) {
|
||||
if f.l.Enabled(context.Background(), slog.LevelDebug) {
|
||||
hostinfo.logger(f.l).Debug("dropping out of window relay packet", "header", h)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Successfully validated the thing. Get rid of the Relay header.
|
||||
@@ -196,8 +201,7 @@ func (f *Interface) handleOutsideRelayPacket(hostinfo *HostInfo, via ViaSender,
|
||||
// The only way this happens is if hostmap has an index to the correct HostInfo, but the HostInfo is missing
|
||||
// its internal mapping. This should never happen.
|
||||
hostinfo.logger(f.l).Error("HostInfo missing remote relay index",
|
||||
"vpnAddrs", hostinfo.vpnAddrs,
|
||||
"remoteIndex", h.RemoteIndex,
|
||||
"relayRemoteIndex", h.RemoteIndex,
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -213,15 +217,15 @@ func (f *Interface) handleOutsideRelayPacket(hostinfo *HostInfo, via ViaSender,
|
||||
relay: relay,
|
||||
IsRelayed: true,
|
||||
}
|
||||
f.readOutsidePackets(via, signedPayload, h, fwPacket, lhf, nb, q, localCache, meta)
|
||||
f.readOutsidePackets(via, out[:0], signedPayload, h, fwPacket, lhf, nb, q, localCache)
|
||||
case ForwardingType:
|
||||
// Find the target HostInfo relay object
|
||||
targetHI, targetRelay, err := f.hostMap.QueryVpnAddrsRelayFor(hostinfo.vpnAddrs, relay.PeerAddr)
|
||||
if err != nil {
|
||||
hostinfo.logger(f.l).Info("Failed to find target host info by ip",
|
||||
"relayTo", relay.PeerAddr,
|
||||
"relayFrom", hostinfo.vpnAddrs[0],
|
||||
"error", err,
|
||||
"hostinfo.vpnAddrs", hostinfo.vpnAddrs,
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -231,8 +235,7 @@ func (f *Interface) handleOutsideRelayPacket(hostinfo *HostInfo, via ViaSender,
|
||||
switch targetRelay.Type {
|
||||
case ForwardingType:
|
||||
// Forward this packet through the relay tunnel
|
||||
// Find the target HostInfo //todo it would potentially be nice to batch these
|
||||
out := f.batchers[q].Reserve(len(packet) + header.Len + hostinfo.ConnectionState.dKey.Overhead())[:0]
|
||||
// Find the target HostInfo
|
||||
f.SendVia(targetHI, targetRelay, signedPayload, nb, out, false)
|
||||
case TerminalType:
|
||||
hostinfo.logger(f.l).Error("Unexpected Relay Type of Terminal")
|
||||
@@ -515,61 +518,7 @@ func (f *Interface) decrypt(hostinfo *HostInfo, mc uint64, out []byte, packet []
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// 2-bit IP-level ECN codepoints (lower bits of IPv4 ToS / IPv6 TC).
|
||||
const (
|
||||
ecnNotECT = 0x00
|
||||
ecnECT1 = 0x01
|
||||
ecnECT0 = 0x02
|
||||
ecnCE = 0x03
|
||||
)
|
||||
|
||||
// applyOuterECN folds an outer CE mark from the underlay into the inner
|
||||
// IP header per RFC 6040 normal mode. It mutates pkt[1] in place. Other
|
||||
// codepoints are advisory only and leave the inner unchanged.
|
||||
//
|
||||
// Merge cases (outer × inner → action):
|
||||
//
|
||||
// outer != CE : no-op (inner is authoritative)
|
||||
// outer == CE, inner Not-ECT : log; cannot propagate to a non-ECN host
|
||||
// outer == CE, inner ECT/CE : rewrite inner ECN to CE
|
||||
func applyOuterECN(pkt []byte, outerECN byte, hostinfo *HostInfo, l *slog.Logger) {
|
||||
if outerECN&ecnCE != ecnCE || len(pkt) < 2 {
|
||||
return
|
||||
}
|
||||
switch pkt[0] >> 4 {
|
||||
case 4:
|
||||
switch pkt[1] & 0x03 {
|
||||
case ecnNotECT:
|
||||
if l.Enabled(context.Background(), slog.LevelDebug) {
|
||||
hostinfo.logger(l).Debug("RFC 6040: outer CE on inner Not-ECT, leaving inner unchanged")
|
||||
}
|
||||
case ecnCE:
|
||||
// Already CE.
|
||||
default:
|
||||
pkt[1] = (pkt[1] &^ 0x03) | ecnCE
|
||||
}
|
||||
case 6:
|
||||
switch (pkt[1] >> 4) & 0x03 {
|
||||
case ecnNotECT:
|
||||
if l.Enabled(context.Background(), slog.LevelDebug) {
|
||||
hostinfo.logger(l).Debug("RFC 6040: outer CE on inner Not-ECT, leaving inner unchanged")
|
||||
}
|
||||
case ecnCE:
|
||||
// Already CE.
|
||||
default:
|
||||
pkt[1] = (pkt[1] &^ 0x30) | (ecnCE << 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Interface) handleOutsideMessagePacket(hostinfo *HostInfo, out []byte, packet []byte, fwPacket *firewall.Packet, nb []byte, q int, localCache firewall.ConntrackCache, meta udp.RxMeta) {
|
||||
// RFC 6040 normal-mode combine: fold any outer CE mark stamped by the
|
||||
// underlay into the inner header before firewall + TUN write. Other
|
||||
// outer codepoints are advisory only — we keep the inner unchanged.
|
||||
if f.ecnEnabled.Load() {
|
||||
applyOuterECN(out, meta.OuterECN, hostinfo, f.l)
|
||||
}
|
||||
|
||||
func (f *Interface) handleOutsideMessagePacket(hostinfo *HostInfo, out []byte, packet []byte, fwPacket *firewall.Packet, nb []byte, q int, localCache firewall.ConntrackCache) {
|
||||
err := newPacket(out, true, fwPacket)
|
||||
if err != nil {
|
||||
hostinfo.logger(f.l).Warn("Error while validating inbound packet",
|
||||
@@ -593,7 +542,7 @@ func (f *Interface) handleOutsideMessagePacket(hostinfo *HostInfo, out []byte, p
|
||||
return
|
||||
}
|
||||
|
||||
err = f.batchers[q].Commit(out)
|
||||
_, err = f.readers[q].Write(out)
|
||||
if err != nil {
|
||||
f.l.Error("Failed to write to tun", "error", err)
|
||||
}
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
package batch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
)
|
||||
|
||||
// flowKey identifies a transport flow by {src, dst, sport, dport, family}.
|
||||
// Comparable, so map lookups and linear scans over the slot list stay tight.
|
||||
// Shared by the TCP and UDP coalescers; each coalescer keeps its own
|
||||
// openSlots map, so a TCP and UDP flow on the same 5-tuple-without-proto
|
||||
// never alias.
|
||||
type flowKey struct {
|
||||
src, dst [16]byte
|
||||
sport, dport uint16
|
||||
isV6 bool
|
||||
}
|
||||
|
||||
// initialSlots is the starting capacity of the slot pool. One flow per
|
||||
// packet is the worst case so this matches a typical carrier-side
|
||||
// recvmmsg batch on the encrypted UDP socket.
|
||||
const initialSlots = 64
|
||||
|
||||
// parsedIP is the IP-level result of parseIPPrologue. The caller layers
|
||||
// L4-specific parsing (TCP / UDP) on top.
|
||||
type parsedIP struct {
|
||||
fk flowKey
|
||||
ipHdrLen int
|
||||
// pkt is the original buffer trimmed to the IP-declared total length.
|
||||
// Anything below the IP layer (transport parsers) should slice into
|
||||
// pkt rather than the unbounded original.
|
||||
pkt []byte
|
||||
}
|
||||
|
||||
// parseIPPrologue extracts the IP-level fields the coalescers care about:
|
||||
// IHL/payload length, version, src/dst addresses, and the L4 protocol byte.
|
||||
// Returns ok=false for malformed input, IPv4 with options or fragmentation,
|
||||
// or IPv6 with extension headers (all rejected by both coalescers in
|
||||
// identical ways before this refactor).
|
||||
//
|
||||
// On success, p.pkt is len-trimmed to the IP-declared length so callers
|
||||
// don't have to repeat the trim. wantProto is the IANA protocol number to
|
||||
// require (6 for TCP, 17 for UDP); ok=false for any other value.
|
||||
func parseIPPrologue(pkt []byte, wantProto byte) (parsedIP, bool) {
|
||||
var p parsedIP
|
||||
if len(pkt) < 20 {
|
||||
return p, false
|
||||
}
|
||||
v := pkt[0] >> 4
|
||||
switch v {
|
||||
case 4:
|
||||
ihl := int(pkt[0]&0x0f) * 4
|
||||
if ihl != 20 {
|
||||
return p, false
|
||||
}
|
||||
if pkt[9] != wantProto {
|
||||
return p, false
|
||||
}
|
||||
// Reject actual fragmentation (MF or non-zero frag offset).
|
||||
if binary.BigEndian.Uint16(pkt[6:8])&0x3fff != 0 {
|
||||
return p, false
|
||||
}
|
||||
totalLen := int(binary.BigEndian.Uint16(pkt[2:4]))
|
||||
if totalLen > len(pkt) || totalLen < ihl {
|
||||
return p, false
|
||||
}
|
||||
p.ipHdrLen = 20
|
||||
p.fk.isV6 = false
|
||||
copy(p.fk.src[:4], pkt[12:16])
|
||||
copy(p.fk.dst[:4], pkt[16:20])
|
||||
p.pkt = pkt[:totalLen]
|
||||
case 6:
|
||||
if len(pkt) < 40 {
|
||||
return p, false
|
||||
}
|
||||
if pkt[6] != wantProto {
|
||||
return p, false
|
||||
}
|
||||
payloadLen := int(binary.BigEndian.Uint16(pkt[4:6]))
|
||||
if 40+payloadLen > len(pkt) {
|
||||
return p, false
|
||||
}
|
||||
p.ipHdrLen = 40
|
||||
p.fk.isV6 = true
|
||||
copy(p.fk.src[:], pkt[8:24])
|
||||
copy(p.fk.dst[:], pkt[24:40])
|
||||
p.pkt = pkt[:40+payloadLen]
|
||||
default:
|
||||
return p, false
|
||||
}
|
||||
return p, true
|
||||
}
|
||||
|
||||
// ipHeadersMatch compares the IP portion of two packet header prefixes for
|
||||
// byte-for-byte equality on every field that must be identical across
|
||||
// coalesced segments. Size/IPID/IPCsum and the 2-bit IP-level ECN field are
|
||||
// masked out — the appendPayload step merges CE into the seed.
|
||||
//
|
||||
// The transport (L4) portion of the header is checked separately by the
|
||||
// per-protocol matcher.
|
||||
func ipHeadersMatch(a, b []byte, isV6 bool) bool {
|
||||
if isV6 {
|
||||
// IPv6: byte 0 = version/TC[7:4], byte 1 = TC[3:0]/flow[19:16],
|
||||
// bytes [2:4] = flow[15:0], [6:8] = next_hdr/hop, [8:40] = src+dst.
|
||||
// ECN lives in TC[1:0] = byte 1 mask 0x30. Skip [4:6] payload_len.
|
||||
if a[0] != b[0] {
|
||||
return false
|
||||
}
|
||||
if a[1]&^0x30 != b[1]&^0x30 {
|
||||
return false
|
||||
}
|
||||
if !bytes.Equal(a[2:4], b[2:4]) {
|
||||
return false
|
||||
}
|
||||
if !bytes.Equal(a[6:40], b[6:40]) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
// IPv4: byte 0 = version/IHL, byte 1 = DSCP(6)|ECN(2),
|
||||
// [6:10] flags/fragoff/TTL/proto, [12:20] src+dst.
|
||||
// Skip [2:4] total len, [4:6] id, [10:12] csum.
|
||||
if a[0] != b[0] {
|
||||
return false
|
||||
}
|
||||
if a[1]&^0x03 != b[1]&^0x03 {
|
||||
return false
|
||||
}
|
||||
if !bytes.Equal(a[6:10], b[6:10]) {
|
||||
return false
|
||||
}
|
||||
if !bytes.Equal(a[12:20], b[12:20]) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// mergeECNIntoSeed ORs the 2-bit IP-level ECN field of pkt's IP header
|
||||
// onto the seed's IP header, so a CE mark on any coalesced segment
|
||||
// propagates to the final superpacket. (CE is 0b11; ORing yields CE if
|
||||
// any segment carried it.) Used by both TCP and UDP coalescers, so the
|
||||
// invariant lives in one place.
|
||||
func mergeECNIntoSeed(seedHdr, pktHdr []byte, isV6 bool) {
|
||||
if isV6 {
|
||||
seedHdr[1] |= pktHdr[1] & 0x30
|
||||
} else {
|
||||
seedHdr[1] |= pktHdr[1] & 0x03
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
package batch
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"github.com/slackhq/nebula/overlay/tio"
|
||||
"github.com/slackhq/nebula/util"
|
||||
)
|
||||
|
||||
// MultiCoalescer fans plaintext packets out to lane-specific batchers based
|
||||
// on the IP/L4 protocol of the packet, sharing a single Reserve arena
|
||||
// across lanes so the caller's allocation pattern is unchanged.
|
||||
//
|
||||
// Lanes are processed independently: the TCP coalescer only sees TCP, the
|
||||
// UDP coalescer only sees UDP, and the passthrough lane handles everything
|
||||
// else. Per-flow arrival order is preserved because a single 5-tuple only
|
||||
// ever lands in one lane and each lane preserves its own slot order.
|
||||
//
|
||||
// Cross-lane order is NOT preserved across the TCP/UDP/passthrough split.
|
||||
// This is acceptable because the carrier-side recvmmsg path already
|
||||
// stable-sorts by (peer, message counter) before delivering plaintext
|
||||
// here, so replay-window invariants are unaffected, and apps observe
|
||||
// correct per-flow ordering — which is all the IP layer guarantees anyway.
|
||||
// Do not "fix" this by interleaving lane outputs at flush time; that
|
||||
// negates the entire point of coalescing (each lane needs to see runs of
|
||||
// adjacent same-flow packets to coalesce them).
|
||||
type MultiCoalescer struct {
|
||||
tcp *TCPCoalescer
|
||||
udp *UDPCoalescer
|
||||
pt *Passthrough
|
||||
|
||||
// arena is shared across every lane (constructor hands the same
|
||||
// *Arena to TCP, UDP, and Passthrough), so there's exactly one
|
||||
// backing slab per MultiCoalescer instance. Each lane's Flush calls
|
||||
// Reset; the resets are idempotent because Multi.Flush drains lanes
|
||||
// sequentially and never Reserves in between, so a later lane's
|
||||
// slots stay readable across an earlier lane's Reset (the underlying
|
||||
// bytes are still alive — Reset only re-slices len to 0).
|
||||
arena *util.Arena
|
||||
}
|
||||
|
||||
// NewMultiCoalescer builds a multi-lane batcher. tcpEnabled lets the caller
|
||||
// opt out of TCP coalescing (e.g. when the queue can't do TSO); udpEnabled
|
||||
// likewise gates UDP coalescing (only enable when USO was negotiated).
|
||||
// Either lane disabled redirects its traffic into the passthrough lane.
|
||||
// arena is the single backing slab shared across every lane; the caller
|
||||
// pre-sizes it via NewArena so the hot path never allocates.
|
||||
func NewMultiCoalescer(w tio.Queue, l *slog.Logger, arena *util.Arena, tcpEnabled, udpEnabled bool) *MultiCoalescer {
|
||||
m := &MultiCoalescer{
|
||||
pt: NewPassthrough(w, initialSlots, arena),
|
||||
arena: arena,
|
||||
}
|
||||
if tcpEnabled {
|
||||
m.tcp = NewTCPCoalescer(w, l, arena)
|
||||
}
|
||||
if udpEnabled {
|
||||
m.udp = NewUDPCoalescer(w, arena)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *MultiCoalescer) Reserve(sz int) []byte {
|
||||
return m.arena.Reserve(sz)
|
||||
}
|
||||
|
||||
// Commit dispatches pkt to the appropriate lane based on IP version + L4
|
||||
// proto. Borrowed slice contract is identical to the single-lane batchers,
|
||||
// pkt must remain valid until the next Flush.
|
||||
//
|
||||
// On the success path the IP/TCP-or-UDP parse happens here once and the
|
||||
// parsed struct is handed to the lane via commitParsed so the lane doesn't
|
||||
// re-walk the header.
|
||||
func (m *MultiCoalescer) Commit(pkt []byte) error {
|
||||
if len(pkt) < 20 {
|
||||
return m.pt.Commit(pkt)
|
||||
}
|
||||
v := pkt[0] >> 4
|
||||
var proto byte
|
||||
switch v {
|
||||
case 4:
|
||||
proto = pkt[9]
|
||||
case 6:
|
||||
if len(pkt) < 40 {
|
||||
return m.pt.Commit(pkt)
|
||||
}
|
||||
proto = pkt[6]
|
||||
default:
|
||||
return m.pt.Commit(pkt)
|
||||
}
|
||||
switch proto {
|
||||
case ipProtoTCP:
|
||||
if m.tcp != nil {
|
||||
info, ok := parseTCPBase(pkt)
|
||||
if !ok {
|
||||
// Malformed/unsupported TCP shape (IP options, fragments, ...).
|
||||
// Handle this via passthrough support in the TCP coalescer, to attempt to preserve flow order.
|
||||
m.tcp.addPassthrough(pkt)
|
||||
return nil
|
||||
}
|
||||
return m.tcp.commitParsed(pkt, info)
|
||||
}
|
||||
case ipProtoUDP:
|
||||
if m.udp != nil {
|
||||
info, ok := parseUDP(pkt)
|
||||
if !ok {
|
||||
m.udp.addPassthrough(pkt) //we could also m.pt.Commit() here I guess?
|
||||
return nil
|
||||
}
|
||||
return m.udp.commitParsed(pkt, info)
|
||||
}
|
||||
}
|
||||
return m.pt.Commit(pkt)
|
||||
}
|
||||
|
||||
// Flush drains every lane in a fixed order: TCP, UDP, passthrough. Errors
|
||||
// from a lane do not stop subsequent lanes from flushing, we keep
|
||||
// draining and return the first observed error so a single bad packet
|
||||
// doesn't strand the others.
|
||||
func (m *MultiCoalescer) Flush() error {
|
||||
var errs []error
|
||||
if m.tcp != nil {
|
||||
if err := m.tcp.Flush(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
if m.udp != nil {
|
||||
if err := m.udp.Flush(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
if err := m.pt.Flush(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package batch
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/slackhq/nebula/test"
|
||||
"github.com/slackhq/nebula/util"
|
||||
)
|
||||
|
||||
// TestMultiCoalescerRoutesByProto confirms TCP/UDP/other land in the right
|
||||
// lane: TCP and UDP get coalesced when their lanes are enabled, anything
|
||||
// else (ICMP here) falls through to plain Write.
|
||||
func TestMultiCoalescerRoutesByProto(t *testing.T) {
|
||||
w := &fakeTunWriter{gsoEnabled: true}
|
||||
m := NewMultiCoalescer(w, test.NewLogger(), util.NewArena(0), true, true)
|
||||
|
||||
tcpPay := make([]byte, 1200)
|
||||
udpPay := make([]byte, 1200)
|
||||
icmp := make([]byte, 28)
|
||||
icmp[0] = 0x45
|
||||
icmp[2] = 0
|
||||
icmp[3] = 28
|
||||
icmp[9] = 1
|
||||
|
||||
if err := m.Commit(buildTCPv4(1000, tcpAck, tcpPay)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.Commit(buildTCPv4(2200, tcpAck, tcpPay)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.Commit(buildUDPv4(2000, 53, udpPay)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.Commit(buildUDPv4(2000, 53, udpPay)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.Commit(icmp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 1 TCP super (2 segments) + 1 UDP super (2 segments) = 2 gso writes.
|
||||
if len(w.gsoWrites) != 2 {
|
||||
t.Fatalf("want 2 gso writes (one TCP + one UDP), got %d", len(w.gsoWrites))
|
||||
}
|
||||
if len(w.writes) != 1 {
|
||||
t.Fatalf("want 1 plain write (ICMP), got %d", len(w.writes))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultiCoalescerDisabledUDPFallsThrough verifies that when the UDP lane
|
||||
// is disabled (e.g. kernel doesn't support USO), UDP packets still reach
|
||||
// the kernel via the passthrough lane rather than being lost.
|
||||
func TestMultiCoalescerDisabledUDPFallsThrough(t *testing.T) {
|
||||
w := &fakeTunWriter{gsoEnabled: true}
|
||||
m := NewMultiCoalescer(w, test.NewLogger(), util.NewArena(0), true, false) // TSO on, USO off
|
||||
|
||||
if err := m.Commit(buildUDPv4(1000, 53, make([]byte, 800))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.Commit(buildUDPv4(1000, 53, make([]byte, 800))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(w.gsoWrites) != 0 {
|
||||
t.Errorf("UDP must NOT be coalesced when USO disabled, got %d gso writes", len(w.gsoWrites))
|
||||
}
|
||||
if len(w.writes) != 2 {
|
||||
t.Errorf("UDP must pass through as 2 plain writes, got %d", len(w.writes))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultiCoalescerDisabledTCPFallsThrough mirrors the TSO=off case.
|
||||
func TestMultiCoalescerDisabledTCPFallsThrough(t *testing.T) {
|
||||
w := &fakeTunWriter{gsoEnabled: true}
|
||||
m := NewMultiCoalescer(w, test.NewLogger(), util.NewArena(0), false, true) // TSO off, USO on
|
||||
|
||||
pay := make([]byte, 1200)
|
||||
if err := m.Commit(buildTCPv4(1000, tcpAck, pay)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.Commit(buildTCPv4(2200, tcpAck, pay)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(w.gsoWrites) != 0 {
|
||||
t.Errorf("TCP must NOT be coalesced when TSO disabled, got %d gso writes", len(w.gsoWrites))
|
||||
}
|
||||
if len(w.writes) != 2 {
|
||||
t.Errorf("TCP must pass through as 2 plain writes, got %d", len(w.writes))
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package batch
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/slackhq/nebula/util"
|
||||
)
|
||||
|
||||
// Passthrough is a RxBatcher that doesn't batch anything, it just accumulates and then sends packets.
|
||||
type Passthrough struct {
|
||||
out io.Writer
|
||||
slots [][]byte
|
||||
// arena is injected; see TCPCoalescer.arena for the contract.
|
||||
arena *util.Arena
|
||||
cursor int
|
||||
}
|
||||
|
||||
func NewPassthrough(w io.Writer, slots int, arena *util.Arena) *Passthrough {
|
||||
return &Passthrough{
|
||||
out: w,
|
||||
slots: make([][]byte, 0, slots),
|
||||
arena: arena,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Passthrough) Reserve(sz int) []byte {
|
||||
return p.arena.Reserve(sz)
|
||||
}
|
||||
|
||||
func (p *Passthrough) Commit(pkt []byte) error {
|
||||
p.slots = append(p.slots, pkt)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Passthrough) Flush() error {
|
||||
var firstErr error
|
||||
for _, s := range p.slots {
|
||||
_, err := p.out.Write(s)
|
||||
if err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
clear(p.slots)
|
||||
p.slots = p.slots[:0]
|
||||
p.arena.Reset()
|
||||
return firstErr
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package batch
|
||||
|
||||
type RxBatcher interface {
|
||||
// Reserve creates a pkt to borrow
|
||||
Reserve(sz int) []byte
|
||||
// Commit borrows pkt. The caller must keep pkt valid until the next Flush
|
||||
Commit(pkt []byte) error
|
||||
// Flush emits every queued packet in arrival order. Returns the
|
||||
// first error observed; keeps draining so one bad packet doesn't hold up
|
||||
// the rest. After Flush returns, borrowed payload slices may be recycled.
|
||||
Flush() error
|
||||
}
|
||||
@@ -1,743 +0,0 @@
|
||||
package batch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"slices"
|
||||
|
||||
"github.com/slackhq/nebula/overlay/tio"
|
||||
"github.com/slackhq/nebula/util"
|
||||
"github.com/slackhq/nebula/wire"
|
||||
)
|
||||
|
||||
// ipProtoTCP is the IANA protocol number for TCP. Hardcoded instead of
|
||||
// reaching for golang.org/x/sys/unix — that package doesn't define the
|
||||
// constant on Windows, which would break cross-compiles even though this
|
||||
// file runs unchanged on every platform.
|
||||
const ipProtoTCP = 6
|
||||
|
||||
// tcpCoalesceBufSize caps total bytes per superpacket. Mirrors the kernel's
|
||||
// sk_gso_max_size of ~64KiB; anything beyond this would be rejected anyway.
|
||||
const tcpCoalesceBufSize = 65535
|
||||
|
||||
// tcpCoalesceMaxSegs caps how many segments we'll coalesce into a single
|
||||
// superpacket. Keeping this well below the kernel's TSO ceiling bounds
|
||||
// latency.
|
||||
const tcpCoalesceMaxSegs = 64
|
||||
|
||||
// tcpCoalesceHdrCap is the scratch space we copy a seed's IP+TCP header
|
||||
// into. IPv6 (40) + TCP with full options (60) = 100 bytes.
|
||||
const tcpCoalesceHdrCap = 100
|
||||
|
||||
// coalesceSlot is one entry in the coalescer's ordered event queue. When
|
||||
// passthrough is true the slot holds a single borrowed packet that must be
|
||||
// emitted verbatim (non-TCP, non-admissible TCP, or oversize seed). When
|
||||
// passthrough is false the slot is an in-progress coalesced superpacket:
|
||||
// hdrBuf is a mutable copy of the seed's IP+TCP header (we patch total
|
||||
// length and pseudo-header partial at flush), and payIovs are *borrowed*
|
||||
// slices from the caller's plaintext buffers — no payload is ever copied.
|
||||
// The caller (listenOut) must keep those buffers alive until Flush.
|
||||
type coalesceSlot struct {
|
||||
passthrough bool
|
||||
rawPkt []byte // borrowed when passthrough
|
||||
|
||||
fk flowKey
|
||||
hdrBuf [tcpCoalesceHdrCap]byte
|
||||
hdrLen int
|
||||
ipHdrLen int
|
||||
isV6 bool
|
||||
gsoSize int
|
||||
numSeg int
|
||||
totalPay int
|
||||
nextSeq uint32
|
||||
// psh closes the chain: set when the last-accepted segment had PSH or
|
||||
// was sub-gsoSize. No further appends after that.
|
||||
psh bool
|
||||
payIovs [][]byte
|
||||
}
|
||||
|
||||
// TCPCoalescer accumulates adjacent in-flow TCP data segments across
|
||||
// multiple concurrent flows and emits each flow's run as a single TSO
|
||||
// superpacket via tio.GSOWriter. All output — coalesced or not — is
|
||||
// deferred until Flush so arrival order is preserved on the wire. Owns
|
||||
// no locks; one coalescer per TUN write queue.
|
||||
type TCPCoalescer struct {
|
||||
plainW io.Writer
|
||||
gsoW tio.GSOWriter // nil when the queue doesn't support TSO
|
||||
|
||||
// slots is the ordered event queue. Flush walks it once and emits each
|
||||
// entry as either a WriteGSO (coalesced) or a plainW.Write (passthrough).
|
||||
slots []*coalesceSlot
|
||||
// openSlots maps a flow key to its most recent non-sealed slot, so new
|
||||
// segments can extend an in-progress superpacket in O(1). Slots are
|
||||
// removed from this map when they close (PSH or short-last-segment),
|
||||
// when a non-admissible packet for that flow arrives, or in Flush.
|
||||
openSlots map[flowKey]*coalesceSlot
|
||||
// lastSlot caches the most recently touched open slot. Steady-state
|
||||
// bulk traffic is dominated by a single flow, so comparing the
|
||||
// incoming key against the cached slot's own fk lets the hot path
|
||||
// skip the map lookup (and the aeshash of a 38-byte key) entirely.
|
||||
// Kept in lockstep with openSlots: nil whenever the slot it pointed
|
||||
// at is removed/sealed.
|
||||
lastSlot *coalesceSlot
|
||||
pool []*coalesceSlot // free list for reuse
|
||||
|
||||
// arena is injected; the coalescer borrows slices from it via Reserve
|
||||
// and tells it to release them via Reset on Flush. When wrapped in
|
||||
// MultiCoalescer the same *Arena is shared with the other lanes so
|
||||
// there's exactly one backing slab per Multi instance.
|
||||
arena *util.Arena
|
||||
l *slog.Logger
|
||||
}
|
||||
|
||||
func NewTCPCoalescer(w tio.Queue, l *slog.Logger, arena *util.Arena) *TCPCoalescer {
|
||||
c := &TCPCoalescer{
|
||||
plainW: w,
|
||||
slots: make([]*coalesceSlot, 0, initialSlots),
|
||||
openSlots: make(map[flowKey]*coalesceSlot, initialSlots),
|
||||
pool: make([]*coalesceSlot, 0, initialSlots),
|
||||
arena: arena,
|
||||
l: l,
|
||||
}
|
||||
if gw, ok := tio.SupportsGSO(w, wire.GSOProtoTCP); ok {
|
||||
c.gsoW = gw
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// parsedTCP holds the fields extracted from a single parse so later steps
|
||||
// (admission, slot lookup, canAppend) don't re-walk the header.
|
||||
type parsedTCP struct {
|
||||
fk flowKey
|
||||
ipHdrLen int
|
||||
tcpHdrLen int
|
||||
hdrLen int
|
||||
payLen int
|
||||
seq uint32
|
||||
flags byte
|
||||
}
|
||||
|
||||
// parseTCPBase extracts the flow key and IP/TCP offsets for any TCP packet,
|
||||
// regardless of whether it's admissible for coalescing. Returns ok=false
|
||||
// for non-TCP or malformed input. Accepts IPv4 (no options, no fragmentation)
|
||||
// and IPv6 (no extension headers).
|
||||
func parseTCPBase(pkt []byte) (parsedTCP, bool) {
|
||||
var p parsedTCP
|
||||
ip, ok := parseIPPrologue(pkt, ipProtoTCP)
|
||||
if !ok {
|
||||
return p, false
|
||||
}
|
||||
pkt = ip.pkt
|
||||
p.fk = ip.fk
|
||||
p.ipHdrLen = ip.ipHdrLen
|
||||
|
||||
if len(pkt) < p.ipHdrLen+20 {
|
||||
return p, false
|
||||
}
|
||||
tcpOff := int(pkt[p.ipHdrLen+12]>>4) * 4
|
||||
if tcpOff < 20 || tcpOff > 60 {
|
||||
return p, false
|
||||
}
|
||||
if len(pkt) < p.ipHdrLen+tcpOff {
|
||||
return p, false
|
||||
}
|
||||
p.tcpHdrLen = tcpOff
|
||||
p.hdrLen = p.ipHdrLen + tcpOff
|
||||
p.payLen = len(pkt) - p.hdrLen
|
||||
p.seq = binary.BigEndian.Uint32(pkt[p.ipHdrLen+4 : p.ipHdrLen+8])
|
||||
p.flags = pkt[p.ipHdrLen+13]
|
||||
p.fk.sport = binary.BigEndian.Uint16(pkt[p.ipHdrLen : p.ipHdrLen+2])
|
||||
p.fk.dport = binary.BigEndian.Uint16(pkt[p.ipHdrLen+2 : p.ipHdrLen+4])
|
||||
return p, true
|
||||
}
|
||||
|
||||
// TCP flag bits (byte 13 of the TCP header). Only the bits actually consulted
|
||||
// by the coalescer are named; FIN/SYN/RST/URG/CWR are rejected via the
|
||||
// negative mask in coalesceable, not by name.
|
||||
const (
|
||||
tcpFlagPsh = 0x08
|
||||
tcpFlagAck = 0x10
|
||||
tcpFlagEce = 0x40
|
||||
)
|
||||
|
||||
// coalesceable reports whether a parsed TCP segment is eligible for
|
||||
// coalescing. Accepts ACK, ACK|PSH, ACK|ECE, ACK|PSH|ECE with a
|
||||
// non-empty payload. CWR is excluded because it marks a one-shot
|
||||
// congestion-window-reduced transition the receiver must observe at a
|
||||
// segment boundary.
|
||||
func (p parsedTCP) coalesceable() bool {
|
||||
if p.flags&tcpFlagAck == 0 {
|
||||
return false
|
||||
}
|
||||
if p.flags&^(tcpFlagAck|tcpFlagPsh|tcpFlagEce) != 0 {
|
||||
return false
|
||||
}
|
||||
return p.payLen > 0
|
||||
}
|
||||
|
||||
func (c *TCPCoalescer) Reserve(sz int) []byte {
|
||||
return c.arena.Reserve(sz)
|
||||
}
|
||||
|
||||
// Commit borrows pkt. The caller must keep pkt valid until the next Flush,
|
||||
// whether or not the packet was coalesced — passthrough (non-admissible)
|
||||
// packets are queued and written at Flush time, not synchronously.
|
||||
func (c *TCPCoalescer) Commit(pkt []byte) error {
|
||||
if c.gsoW == nil {
|
||||
c.addPassthrough(pkt)
|
||||
return nil
|
||||
}
|
||||
info, ok := parseTCPBase(pkt)
|
||||
if !ok {
|
||||
c.addPassthrough(pkt)
|
||||
return nil
|
||||
}
|
||||
return c.commitParsed(pkt, info)
|
||||
}
|
||||
|
||||
// commitParsed is the post-parse half of Commit. The caller must have
|
||||
// already verified parseTCPBase succeeded (info is a valid TCP parse).
|
||||
// Used by MultiCoalescer.Commit to avoid re-walking the IP/TCP header
|
||||
// after the dispatcher has already done so.
|
||||
func (c *TCPCoalescer) commitParsed(pkt []byte, info parsedTCP) error {
|
||||
if c.gsoW == nil {
|
||||
c.addPassthrough(pkt)
|
||||
return nil
|
||||
}
|
||||
if !info.coalesceable() {
|
||||
// TCP but not admissible (SYN/FIN/RST/URG/CWR or zero-payload).
|
||||
// Seal this flow's open slot so later in-flow packets don't extend
|
||||
// it and accidentally reorder past this passthrough.
|
||||
if last := c.lastSlot; last != nil && last.fk == info.fk {
|
||||
c.lastSlot = nil
|
||||
}
|
||||
delete(c.openSlots, info.fk)
|
||||
c.addPassthrough(pkt)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Single-flow fast path: with only one open flow the cache hits every
|
||||
// packet, and len(openSlots)==1 lets us skip the 38-byte fk compare
|
||||
// when there are multiple flows in flight (where the hit rate would
|
||||
// be ~0 and the compare is pure overhead).
|
||||
var open *coalesceSlot
|
||||
if last := c.lastSlot; last != nil && len(c.openSlots) == 1 && last.fk == info.fk {
|
||||
open = last
|
||||
} else {
|
||||
open = c.openSlots[info.fk]
|
||||
}
|
||||
if open != nil {
|
||||
if c.canAppend(open, pkt, info) {
|
||||
c.appendPayload(open, pkt, info)
|
||||
if open.psh {
|
||||
delete(c.openSlots, info.fk)
|
||||
c.lastSlot = nil
|
||||
} else {
|
||||
c.lastSlot = open
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Can't extend — seal it and fall through to seed a fresh slot.
|
||||
delete(c.openSlots, info.fk)
|
||||
if c.lastSlot == open {
|
||||
c.lastSlot = nil
|
||||
}
|
||||
}
|
||||
c.seed(pkt, info)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Flush emits every queued event in (per-flow) seq order. Coalesced slots
|
||||
// go out via WriteGSO; passthrough slots go out via plainW.Write.
|
||||
// reorderForFlush first sorts each flow's slots into TCP-seq order within
|
||||
// passthrough-bounded segments and merges contiguous adjacent slots, so
|
||||
// any wire-side reorder that crossed an rxOrder batch boundary doesn't
|
||||
// get amplified into kernel-visible reorder by the slot machinery.
|
||||
// Returns the first error observed; keeps draining so one bad packet
|
||||
// doesn't hold up the rest. After Flush returns, borrowed payload slices
|
||||
// may be recycled.
|
||||
func (c *TCPCoalescer) Flush() error {
|
||||
c.reorderForFlush()
|
||||
var first error
|
||||
for _, s := range c.slots {
|
||||
var err error
|
||||
if s.passthrough {
|
||||
_, err = c.plainW.Write(s.rawPkt)
|
||||
} else {
|
||||
err = c.flushSlot(s)
|
||||
}
|
||||
if err != nil && first == nil {
|
||||
first = err
|
||||
}
|
||||
c.release(s)
|
||||
}
|
||||
clear(c.slots)
|
||||
c.slots = c.slots[:0]
|
||||
clear(c.openSlots)
|
||||
c.lastSlot = nil
|
||||
|
||||
c.arena.Reset()
|
||||
return first
|
||||
}
|
||||
|
||||
func (c *TCPCoalescer) addPassthrough(pkt []byte) {
|
||||
s := c.take()
|
||||
s.passthrough = true
|
||||
s.rawPkt = pkt
|
||||
c.slots = append(c.slots, s)
|
||||
}
|
||||
|
||||
func (c *TCPCoalescer) seed(pkt []byte, info parsedTCP) {
|
||||
if info.hdrLen > tcpCoalesceHdrCap || info.hdrLen+info.payLen > tcpCoalesceBufSize {
|
||||
// Pathological shape — can't fit our scratch, emit as-is.
|
||||
c.addPassthrough(pkt)
|
||||
return
|
||||
}
|
||||
s := c.take()
|
||||
s.passthrough = false
|
||||
s.rawPkt = nil
|
||||
copy(s.hdrBuf[:], pkt[:info.hdrLen])
|
||||
s.hdrLen = info.hdrLen
|
||||
s.ipHdrLen = info.ipHdrLen
|
||||
s.isV6 = info.fk.isV6
|
||||
s.fk = info.fk
|
||||
s.gsoSize = info.payLen
|
||||
s.numSeg = 1
|
||||
s.totalPay = info.payLen
|
||||
s.nextSeq = info.seq + uint32(info.payLen)
|
||||
s.psh = info.flags&tcpFlagPsh != 0
|
||||
s.payIovs = append(s.payIovs[:0], pkt[info.hdrLen:info.hdrLen+info.payLen])
|
||||
c.slots = append(c.slots, s)
|
||||
if !s.psh {
|
||||
c.openSlots[info.fk] = s
|
||||
c.lastSlot = s
|
||||
} else if last := c.lastSlot; last != nil && last.fk == info.fk {
|
||||
// PSH-on-seed seals the slot immediately. Any prior cached open
|
||||
// slot for this flow has just been sealed-and-replaced by this
|
||||
// passthrough-shaped seed, so drop the cache too.
|
||||
c.lastSlot = nil
|
||||
}
|
||||
}
|
||||
|
||||
// canAppend reports whether info's packet extends the slot's seed: same
|
||||
// header shape and stable contents, adjacent seq, not oversized, chain not
|
||||
// closed.
|
||||
func (c *TCPCoalescer) canAppend(s *coalesceSlot, pkt []byte, info parsedTCP) bool {
|
||||
if s.psh {
|
||||
return false
|
||||
}
|
||||
if info.hdrLen != s.hdrLen {
|
||||
return false
|
||||
}
|
||||
if info.seq != s.nextSeq {
|
||||
return false
|
||||
}
|
||||
if s.numSeg >= tcpCoalesceMaxSegs {
|
||||
return false
|
||||
}
|
||||
if info.payLen > s.gsoSize {
|
||||
return false
|
||||
}
|
||||
if s.hdrLen+s.totalPay+info.payLen > tcpCoalesceBufSize {
|
||||
return false
|
||||
}
|
||||
// ECE state must be stable across a burst — receivers expect the
|
||||
// flag set on every segment of a CE-echoing window or none.
|
||||
seedFlags := s.hdrBuf[s.ipHdrLen+13]
|
||||
if (seedFlags^info.flags)&tcpFlagEce != 0 {
|
||||
return false
|
||||
}
|
||||
if !headersMatch(s.hdrBuf[:s.hdrLen], pkt[:info.hdrLen], s.isV6, s.ipHdrLen) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *TCPCoalescer) appendPayload(s *coalesceSlot, pkt []byte, info parsedTCP) {
|
||||
s.payIovs = append(s.payIovs, pkt[info.hdrLen:info.hdrLen+info.payLen])
|
||||
s.numSeg++
|
||||
s.totalPay += info.payLen
|
||||
s.nextSeq = info.seq + uint32(info.payLen)
|
||||
if info.flags&tcpFlagPsh != 0 {
|
||||
// Propagate PSH into the seed header so kernel TSO sets it on the
|
||||
// last segment. Without this the sender's push signal is dropped.
|
||||
s.hdrBuf[s.ipHdrLen+13] |= tcpFlagPsh
|
||||
}
|
||||
// Merge IP-level CE marks into the seed: headersMatch ignores ECN, so
|
||||
// this is the one place the signal is preserved.
|
||||
mergeECNIntoSeed(s.hdrBuf[:s.ipHdrLen], pkt[:s.ipHdrLen], s.isV6)
|
||||
if info.payLen < s.gsoSize || info.flags&tcpFlagPsh != 0 {
|
||||
s.psh = true
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TCPCoalescer) take() *coalesceSlot {
|
||||
if n := len(c.pool); n > 0 {
|
||||
s := c.pool[n-1]
|
||||
c.pool[n-1] = nil
|
||||
c.pool = c.pool[:n-1]
|
||||
return s
|
||||
}
|
||||
return &coalesceSlot{}
|
||||
}
|
||||
|
||||
func (c *TCPCoalescer) release(s *coalesceSlot) {
|
||||
s.passthrough = false
|
||||
s.rawPkt = nil
|
||||
clear(s.payIovs)
|
||||
s.payIovs = s.payIovs[:0]
|
||||
s.numSeg = 0
|
||||
s.totalPay = 0
|
||||
s.psh = false
|
||||
c.pool = append(c.pool, s)
|
||||
}
|
||||
|
||||
// flushSlot patches the header and calls WriteGSO. Does not remove the
|
||||
// slot from c.slots.
|
||||
func (c *TCPCoalescer) flushSlot(s *coalesceSlot) error {
|
||||
total := s.hdrLen + s.totalPay
|
||||
l4Len := total - s.ipHdrLen
|
||||
hdr := s.hdrBuf[:s.hdrLen]
|
||||
|
||||
if s.isV6 {
|
||||
binary.BigEndian.PutUint16(hdr[4:6], uint16(l4Len))
|
||||
} else {
|
||||
binary.BigEndian.PutUint16(hdr[2:4], uint16(total))
|
||||
hdr[10] = 0
|
||||
hdr[11] = 0
|
||||
binary.BigEndian.PutUint16(hdr[10:12], ipv4HdrChecksum(hdr[:s.ipHdrLen]))
|
||||
}
|
||||
|
||||
var psum uint32
|
||||
if s.isV6 {
|
||||
psum = pseudoSumIPv6(hdr[8:24], hdr[24:40], ipProtoTCP, l4Len)
|
||||
} else {
|
||||
psum = pseudoSumIPv4(hdr[12:16], hdr[16:20], ipProtoTCP, l4Len)
|
||||
}
|
||||
tcsum := s.ipHdrLen + 16
|
||||
binary.BigEndian.PutUint16(hdr[tcsum:tcsum+2], foldOnceNoInvert(psum))
|
||||
|
||||
return c.gsoW.WriteGSO(hdr[:s.ipHdrLen], hdr[s.ipHdrLen:], s.payIovs, wire.GSOProtoTCP)
|
||||
}
|
||||
|
||||
// headersMatch compares two IP+TCP header prefixes for byte-for-byte
|
||||
// equality on every field that must be identical across coalesced
|
||||
// segments. Size/IPID/IPCsum/seq/flags/tcpCsum are masked out, as is the
|
||||
// 2-bit IP-level ECN field — appendPayload merges CE into the seed.
|
||||
func headersMatch(a, b []byte, isV6 bool, ipHdrLen int) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
if !ipHeadersMatch(a, b, isV6) {
|
||||
return false
|
||||
}
|
||||
// TCP: compare [0:4] ports, [8:13] ack+dataoff, [14:16] window,
|
||||
// [18:tcpHdrLen] options (incl. urgent).
|
||||
tcp := ipHdrLen
|
||||
if !bytes.Equal(a[tcp:tcp+4], b[tcp:tcp+4]) {
|
||||
return false
|
||||
}
|
||||
if !bytes.Equal(a[tcp+8:tcp+13], b[tcp+8:tcp+13]) {
|
||||
return false
|
||||
}
|
||||
if !bytes.Equal(a[tcp+14:tcp+16], b[tcp+14:tcp+16]) {
|
||||
return false
|
||||
}
|
||||
if !bytes.Equal(a[tcp+18:], b[tcp+18:]) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// reorderForFlush neutralizes wire-side reorder that the rxOrder buffer
|
||||
// couldn't catch (anything crossing a recvmmsg batch boundary). Without
|
||||
// this pass a small wire reorder — counter 250 arriving in batch K when
|
||||
// 200..249 are coming in batch K+1 — would seed an out-of-seq slot first
|
||||
// and emit it ahead of the lower-seq slot, manifesting at the inner TCP
|
||||
// receiver as a much larger reorder than the wire actually had.
|
||||
//
|
||||
// Two phases:
|
||||
// 1. Sort each passthrough-bounded segment of c.slots by (flow, seq).
|
||||
// Cross-flow ordering inside a segment isn't preserved (it never was
|
||||
// and doesn't matter for any single flow's TCP correctness).
|
||||
// 2. Sweep once and merge adjacent same-flow slots whose ranges are now
|
||||
// contiguous AND whose tail is gsoSize-aligned. The tail constraint
|
||||
// matters because the kernel TSO splitter chops at gsoSize from the
|
||||
// start of the merged payload — a short segment in the middle would
|
||||
// desynchronize every later segment.
|
||||
//
|
||||
// Passthrough slots act as barriers: the merge check skips them on either
|
||||
// side, so a SYN/FIN/RST/CWR is never reordered relative to its flow's
|
||||
// data.
|
||||
func (c *TCPCoalescer) reorderForFlush() {
|
||||
if len(c.slots) <= 1 {
|
||||
return
|
||||
}
|
||||
runStart := 0
|
||||
for i := 0; i <= len(c.slots); i++ {
|
||||
if i < len(c.slots) && !c.slots[i].passthrough {
|
||||
continue
|
||||
}
|
||||
c.sortRun(c.slots[runStart:i])
|
||||
runStart = i + 1
|
||||
}
|
||||
out := c.slots[:0]
|
||||
logged := false
|
||||
for _, s := range c.slots {
|
||||
if n := len(out); n > 0 {
|
||||
prev := out[n-1]
|
||||
if !prev.passthrough && !s.passthrough && prev.fk == s.fk {
|
||||
// Same-flow neighbors after sort. If they aren't seq-
|
||||
// contiguous it's a real gap — packets the wire reordered
|
||||
// across batches, or actual loss before nebula. Log it so
|
||||
// the operator can quantify how often it happens; the data
|
||||
// itself still emits in seq order, kernel TCP handles the
|
||||
// gap via its OOO queue.
|
||||
if c.l.Enabled(context.Background(), slog.LevelDebug) {
|
||||
if prev.nextSeq != slotSeedSeq(s) {
|
||||
logged = true
|
||||
gap := int64(slotSeedSeq(s)) - int64(prev.nextSeq)
|
||||
c.l.Debug("tcp coalesce: cross-slot seq gap",
|
||||
"src", flowKeyAddr(s.fk, false),
|
||||
"dst", flowKeyAddr(s.fk, true),
|
||||
"sport", s.fk.sport,
|
||||
"dport", s.fk.dport,
|
||||
"prev_seed_seq", slotSeedSeq(prev),
|
||||
"prev_next_seq", prev.nextSeq,
|
||||
"this_seed_seq", slotSeedSeq(s),
|
||||
"gap_bytes", gap,
|
||||
"prev_seg_count", prev.numSeg,
|
||||
"prev_total_pay", prev.totalPay,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if canMergeSlots(prev, s) {
|
||||
mergeSlots(prev, s)
|
||||
c.release(s)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
if logged {
|
||||
c.l.Warn("==== end of batch ====")
|
||||
}
|
||||
c.slots = out
|
||||
}
|
||||
|
||||
// flowKeyAddr returns the src or dst address from fk as a netip.Addr for
|
||||
// logging. Only used on the cold gap-log path so the netip allocation
|
||||
// doesn't matter.
|
||||
func flowKeyAddr(fk flowKey, dst bool) netip.Addr {
|
||||
src := fk.src
|
||||
if dst {
|
||||
src = fk.dst
|
||||
}
|
||||
if fk.isV6 {
|
||||
return netip.AddrFrom16(src)
|
||||
}
|
||||
var v4 [4]byte
|
||||
copy(v4[:], src[:4])
|
||||
return netip.AddrFrom4(v4)
|
||||
}
|
||||
|
||||
// sortRun stable-sorts run by (flowKey, seedSeq) so each flow's slots
|
||||
// cluster together in seq order, ready for the merge sweep. Stable so
|
||||
// equal-key slots keep their original relative position (defensive — a
|
||||
// duplicate seedSeq would already mean something's wrong upstream).
|
||||
func (c *TCPCoalescer) sortRun(run []*coalesceSlot) {
|
||||
if len(run) <= 1 {
|
||||
return
|
||||
}
|
||||
// slices.SortStableFunc with a free, non-capturing comparator avoids the
|
||||
// reflection + closure-escape allocations that sort.SliceStable forces.
|
||||
slices.SortStableFunc(run, compareCoalesceSlots)
|
||||
}
|
||||
|
||||
func compareCoalesceSlots(a, b *coalesceSlot) int {
|
||||
if cmp := flowKeyCompare(a.fk, b.fk); cmp != 0 {
|
||||
return cmp
|
||||
}
|
||||
aSeq, bSeq := slotSeedSeq(a), slotSeedSeq(b)
|
||||
if aSeq == bSeq {
|
||||
return 0
|
||||
}
|
||||
if tcpSeqLess(aSeq, bSeq) {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// slotSeedSeq returns the TCP seq of the slot's seed (first segment).
|
||||
// nextSeq tracks the seq just past the last appended byte; subtracting
|
||||
// totalPay walks back to the seed. uint32 wraparound is the right TCP
|
||||
// arithmetic so no special-casing is needed.
|
||||
func slotSeedSeq(s *coalesceSlot) uint32 {
|
||||
return s.nextSeq - uint32(s.totalPay)
|
||||
}
|
||||
|
||||
// tcpSeqLess reports whether a precedes b in TCP serial-number arithmetic
|
||||
// (RFC 1323 §2.3). The signed int32 cast turns the modular subtraction
|
||||
// into the right comparison even across the 2^32 wrap.
|
||||
func tcpSeqLess(a, b uint32) bool {
|
||||
return int32(a-b) < 0
|
||||
}
|
||||
|
||||
// flowKeyCompare orders flowKeys deterministically. The exact ordering
|
||||
// is irrelevant — only that same-flow slots cluster together so the
|
||||
// post-sort sweep can merge contiguous pairs.
|
||||
func flowKeyCompare(a, b flowKey) int {
|
||||
// Cheap scalar fields first so most non-matching keys short-circuit
|
||||
// without ever calling bytes.Compare. sport is the ephemeral port on
|
||||
// egress flows and discriminates fastest. For matching keys (same
|
||||
// flow), array equality on src/dst inlines to word-sized compares,
|
||||
// so we only pay bytes.Compare when the arrays actually differ.
|
||||
if a.sport != b.sport {
|
||||
if a.sport < b.sport {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if a.dport != b.dport {
|
||||
if a.dport < b.dport {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if a.dst != b.dst {
|
||||
return bytes.Compare(a.dst[:], b.dst[:])
|
||||
}
|
||||
if a.src != b.src {
|
||||
return bytes.Compare(a.src[:], b.src[:])
|
||||
}
|
||||
if a.isV6 != b.isV6 {
|
||||
if !a.isV6 {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// canMergeSlots reports whether s can fold into prev as one merged TSO
|
||||
// superpacket. Same flow, contiguous TCP byte range, equal gsoSize, and
|
||||
// fits within the kernel TSO limits. The tail-of-prev check rejects any
|
||||
// merge whose first slot ended on a sub-gsoSize segment — kernel TSO
|
||||
// would split the merged skb at gsoSize boundaries from the start, so a
|
||||
// short segment in the middle would corrupt every later segment. PSH and
|
||||
// ECE state must agree across both slots: PSH is a semantic delimiter
|
||||
// (preserving the sender's push boundary) and ECE state must be uniform
|
||||
// across a window (the same rule canAppend enforces for in-flow appends).
|
||||
//
|
||||
// Note: a slot sealed by reorder (canAppend returned false on seq
|
||||
// mismatch) keeps psh=false, so this restriction does not block the
|
||||
// reorder-fix merge — only legitimate PSH-set seals.
|
||||
func canMergeSlots(prev, s *coalesceSlot) bool {
|
||||
if prev.psh {
|
||||
return false
|
||||
}
|
||||
if prev.fk != s.fk {
|
||||
return false
|
||||
}
|
||||
if prev.gsoSize != s.gsoSize {
|
||||
return false
|
||||
}
|
||||
if prev.nextSeq != slotSeedSeq(s) {
|
||||
return false
|
||||
}
|
||||
if prev.numSeg+s.numSeg > tcpCoalesceMaxSegs {
|
||||
return false
|
||||
}
|
||||
if prev.hdrLen+prev.totalPay+s.totalPay > tcpCoalesceBufSize {
|
||||
return false
|
||||
}
|
||||
if len(prev.payIovs[len(prev.payIovs)-1]) != prev.gsoSize {
|
||||
return false
|
||||
}
|
||||
prevFlags := prev.hdrBuf[prev.ipHdrLen+13]
|
||||
sFlags := s.hdrBuf[s.ipHdrLen+13]
|
||||
if (prevFlags^sFlags)&tcpFlagEce != 0 {
|
||||
return false
|
||||
}
|
||||
if !headersMatch(prev.hdrBuf[:prev.hdrLen], s.hdrBuf[:s.hdrLen], prev.isV6, prev.ipHdrLen) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// mergeSlots folds src into dst in place: payIovs concatenated, counters
|
||||
// and totals updated, PSH and IP-level CE bits OR'd into the seed header
|
||||
// so neither the push signal nor a CE mark is lost. The seed header's
|
||||
// seq, gsoSize, and fk are unchanged. Caller is responsible for releasing
|
||||
// src (it's no longer in c.slots after this call).
|
||||
func mergeSlots(dst, src *coalesceSlot) {
|
||||
dst.payIovs = append(dst.payIovs, src.payIovs...)
|
||||
dst.numSeg += src.numSeg
|
||||
dst.totalPay += src.totalPay
|
||||
dst.nextSeq = src.nextSeq
|
||||
if src.psh {
|
||||
dst.psh = true
|
||||
dst.hdrBuf[dst.ipHdrLen+13] |= tcpFlagPsh
|
||||
}
|
||||
mergeECNIntoSeed(dst.hdrBuf[:dst.ipHdrLen], src.hdrBuf[:src.ipHdrLen], dst.isV6)
|
||||
}
|
||||
|
||||
// ipv4HdrChecksum computes the IPv4 header checksum over hdr (which must
|
||||
// already have its checksum field zeroed) and returns the folded/inverted
|
||||
// 16-bit value to store.
|
||||
func ipv4HdrChecksum(hdr []byte) uint16 {
|
||||
var sum uint32
|
||||
for i := 0; i+1 < len(hdr); i += 2 {
|
||||
sum += uint32(binary.BigEndian.Uint16(hdr[i : i+2]))
|
||||
}
|
||||
if len(hdr)%2 == 1 {
|
||||
sum += uint32(hdr[len(hdr)-1]) << 8
|
||||
}
|
||||
for sum>>16 != 0 {
|
||||
sum = (sum & 0xffff) + (sum >> 16)
|
||||
}
|
||||
return ^uint16(sum)
|
||||
}
|
||||
|
||||
// pseudoSumIPv4 / pseudoSumIPv6 build the L4 pseudo-header partial sum
|
||||
// expected by the virtio NEEDS_CSUM kernel path: the 32-bit accumulator
|
||||
// before folding. proto selects the L4 (TCP or UDP); the UDP coalescer
|
||||
// reuses these helpers.
|
||||
func pseudoSumIPv4(src, dst []byte, proto byte, l4Len int) uint32 {
|
||||
var sum uint32
|
||||
sum += uint32(binary.BigEndian.Uint16(src[0:2]))
|
||||
sum += uint32(binary.BigEndian.Uint16(src[2:4]))
|
||||
sum += uint32(binary.BigEndian.Uint16(dst[0:2]))
|
||||
sum += uint32(binary.BigEndian.Uint16(dst[2:4]))
|
||||
sum += uint32(proto)
|
||||
sum += uint32(l4Len)
|
||||
return sum
|
||||
}
|
||||
|
||||
func pseudoSumIPv6(src, dst []byte, proto byte, l4Len int) uint32 {
|
||||
var sum uint32
|
||||
for i := 0; i < 16; i += 2 {
|
||||
sum += uint32(binary.BigEndian.Uint16(src[i : i+2]))
|
||||
sum += uint32(binary.BigEndian.Uint16(dst[i : i+2]))
|
||||
}
|
||||
sum += uint32(l4Len >> 16)
|
||||
sum += uint32(l4Len & 0xffff)
|
||||
sum += uint32(proto)
|
||||
return sum
|
||||
}
|
||||
|
||||
// foldOnceNoInvert folds the 32-bit accumulator to 16 bits and returns it
|
||||
// unchanged (no one's complement). This is what virtio NEEDS_CSUM wants in
|
||||
// the L4 checksum field — the kernel will add the payload sum and invert.
|
||||
func foldOnceNoInvert(sum uint32) uint16 {
|
||||
for sum>>16 != 0 {
|
||||
sum = (sum & 0xffff) + (sum >> 16)
|
||||
}
|
||||
return uint16(sum)
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
package batch
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/slackhq/nebula/overlay/tio"
|
||||
"github.com/slackhq/nebula/test"
|
||||
"github.com/slackhq/nebula/util"
|
||||
"github.com/slackhq/nebula/wire"
|
||||
)
|
||||
|
||||
// nopTunWriter is a zero-alloc tio.GSOWriter for benchmarks. Discards
|
||||
// everything but satisfies the interface the coalescer detects.
|
||||
type nopTunWriter struct{}
|
||||
|
||||
func (nopTunWriter) Write(p []byte) (int, error) { return len(p), nil }
|
||||
func (nopTunWriter) Read(_ []wire.TunPacket, _ []byte) (int, error) { return 0, nil }
|
||||
func (nopTunWriter) Close() error { return nil }
|
||||
func (nopTunWriter) WriteGSO(hdr []byte, transportHdr []byte, pays [][]byte, _ wire.GSOProto) error {
|
||||
return nil
|
||||
}
|
||||
func (nopTunWriter) Capabilities() tio.Capabilities {
|
||||
return tio.Capabilities{TSO: true, USO: true}
|
||||
}
|
||||
|
||||
// buildTCPv4BulkFlow returns a slice of N adjacent ACK-only TCP segments
|
||||
// on a single 5-tuple, each carrying payloadLen bytes. Seq numbers are
|
||||
// contiguous so every packet is coalesceable onto the previous one.
|
||||
func buildTCPv4BulkFlow(n, payloadLen int) [][]byte {
|
||||
pkts := make([][]byte, n)
|
||||
pay := make([]byte, payloadLen)
|
||||
seq := uint32(1000)
|
||||
for i := range n {
|
||||
pkts[i] = buildTCPv4(seq, tcpAck, pay)
|
||||
seq += uint32(payloadLen)
|
||||
}
|
||||
return pkts
|
||||
}
|
||||
|
||||
// buildTCPv4Interleaved returns nFlows * perFlow packets with per-flow
|
||||
// seq continuity but round-robin across flows — worst case for any
|
||||
// "last-slot" cache.
|
||||
func buildTCPv4Interleaved(nFlows, perFlow, payloadLen int) [][]byte {
|
||||
pay := make([]byte, payloadLen)
|
||||
seqs := make([]uint32, nFlows)
|
||||
for i := range seqs {
|
||||
seqs[i] = uint32(1000 + i*1000000)
|
||||
}
|
||||
pkts := make([][]byte, 0, nFlows*perFlow)
|
||||
for range perFlow {
|
||||
for f := range nFlows {
|
||||
sport := uint16(10000 + f)
|
||||
pkts = append(pkts, buildTCPv4Ports(sport, 2000, seqs[f], tcpAck, pay))
|
||||
seqs[f] += uint32(payloadLen)
|
||||
}
|
||||
}
|
||||
return pkts
|
||||
}
|
||||
|
||||
// buildICMPv4 returns a minimal non-TCP packet that takes the passthrough
|
||||
// branch in Commit.
|
||||
func buildICMPv4() []byte {
|
||||
pkt := make([]byte, 28)
|
||||
pkt[0] = 0x45
|
||||
binary.BigEndian.PutUint16(pkt[2:4], 28)
|
||||
pkt[9] = 1 // ICMP
|
||||
copy(pkt[12:16], []byte{10, 0, 0, 1})
|
||||
copy(pkt[16:20], []byte{10, 0, 0, 2})
|
||||
return pkt
|
||||
}
|
||||
|
||||
// runCommitBench drives Commit over pkts batchSize at a time, flushing
|
||||
// between batches, and reports per-packet cost.
|
||||
func runCommitBench(b *testing.B, pkts [][]byte, batchSize int) {
|
||||
b.Helper()
|
||||
c := NewTCPCoalescer(nopTunWriter{}, test.NewLogger(), util.NewArena(0))
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(pkts[0])))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
pkt := pkts[i%len(pkts)]
|
||||
if err := c.Commit(pkt); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if (i+1)%batchSize == 0 {
|
||||
if err := c.Flush(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Drain any trailing partial batch so slot state doesn't leak across runs.
|
||||
_ = c.Flush()
|
||||
}
|
||||
|
||||
// BenchmarkCommitSingleFlow is the bulk-TCP steady state: one flow,
|
||||
// contiguous seq, 1200-byte payloads. Every packet past the seed should
|
||||
// append onto the open slot. This is the case we most care about.
|
||||
func BenchmarkCommitSingleFlow(b *testing.B) {
|
||||
pkts := buildTCPv4BulkFlow(tcpCoalesceMaxSegs, 1200)
|
||||
runCommitBench(b, pkts, tcpCoalesceMaxSegs)
|
||||
}
|
||||
|
||||
// BenchmarkCommitInterleaved4 has 4 concurrent bulk flows round-robined.
|
||||
// A single-entry fast-path cache will miss on every packet; an N-way
|
||||
// cache or map lookup carries the weight.
|
||||
func BenchmarkCommitInterleaved4(b *testing.B) {
|
||||
pkts := buildTCPv4Interleaved(4, tcpCoalesceMaxSegs, 1200)
|
||||
runCommitBench(b, pkts, len(pkts))
|
||||
}
|
||||
|
||||
// BenchmarkCommitInterleaved16 stresses the map at higher flow counts.
|
||||
func BenchmarkCommitInterleaved16(b *testing.B) {
|
||||
pkts := buildTCPv4Interleaved(16, tcpCoalesceMaxSegs, 1200)
|
||||
runCommitBench(b, pkts, len(pkts))
|
||||
}
|
||||
|
||||
// BenchmarkCommitPassthrough exercises the non-TCP branch: parseTCPBase
|
||||
// bails early and addPassthrough is the only work.
|
||||
func BenchmarkCommitPassthrough(b *testing.B) {
|
||||
pkt := buildICMPv4()
|
||||
pkts := make([][]byte, 64)
|
||||
for i := range pkts {
|
||||
pkts[i] = pkt
|
||||
}
|
||||
runCommitBench(b, pkts, 64)
|
||||
}
|
||||
|
||||
// BenchmarkCommitNonCoalesceableTCP sends SYN|ACK packets on one flow.
|
||||
// Each packet takes the "TCP but not admissible" branch which does a
|
||||
// map delete + passthrough. Measures the seal-without-slot cost.
|
||||
func BenchmarkCommitNonCoalesceableTCP(b *testing.B) {
|
||||
pay := make([]byte, 0)
|
||||
pkts := make([][]byte, 64)
|
||||
for i := range pkts {
|
||||
pkts[i] = buildTCPv4(uint32(1000+i), tcpSyn|tcpAck, pay)
|
||||
}
|
||||
runCommitBench(b, pkts, 64)
|
||||
}
|
||||
|
||||
// runMultiCommitBench drives MultiCoalescer.Commit. The dispatcher does
|
||||
// the IP/L4 parse once and passes the parsed struct to the lane, so this
|
||||
// is the bench that shows the savings of skipping the lane's re-parse.
|
||||
func runMultiCommitBench(b *testing.B, pkts [][]byte, batchSize int) {
|
||||
b.Helper()
|
||||
m := NewMultiCoalescer(nopTunWriter{}, test.NewLogger(), util.NewArena(0), true, true)
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(pkts[0])))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
pkt := pkts[i%len(pkts)]
|
||||
if err := m.Commit(pkt); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if (i+1)%batchSize == 0 {
|
||||
if err := m.Flush(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = m.Flush()
|
||||
}
|
||||
|
||||
// BenchmarkMultiCommitSingleFlow is the multi-lane analogue of
|
||||
// BenchmarkCommitSingleFlow — same workload but routed through the
|
||||
// dispatcher. The delta vs the single-lane bench measures dispatcher
|
||||
// overhead.
|
||||
func BenchmarkMultiCommitSingleFlow(b *testing.B) {
|
||||
pkts := buildTCPv4BulkFlow(tcpCoalesceMaxSegs, 1200)
|
||||
runMultiCommitBench(b, pkts, tcpCoalesceMaxSegs)
|
||||
}
|
||||
|
||||
// BenchmarkMultiCommitInterleaved4 mirrors BenchmarkCommitInterleaved4
|
||||
// through the dispatcher.
|
||||
func BenchmarkMultiCommitInterleaved4(b *testing.B) {
|
||||
pkts := buildTCPv4Interleaved(4, tcpCoalesceMaxSegs, 1200)
|
||||
runMultiCommitBench(b, pkts, len(pkts))
|
||||
}
|
||||
|
||||
// flowKeyPair is one comparison input for the flowKeyCompare bench.
|
||||
type flowKeyPair struct{ a, b flowKey }
|
||||
|
||||
// makeFlowKey builds an IPv4 flowKey from compact inputs.
|
||||
func makeFlowKey(srcLow, dstLow uint32, sport, dport uint16) flowKey {
|
||||
var fk flowKey
|
||||
binary.BigEndian.PutUint32(fk.src[12:16], srcLow)
|
||||
binary.BigEndian.PutUint32(fk.dst[12:16], dstLow)
|
||||
fk.sport = sport
|
||||
fk.dport = dport
|
||||
return fk
|
||||
}
|
||||
|
||||
// flowKeyCases are the workload mixes flowKeyCompare sees in practice.
|
||||
// - sameFlow: equal keys; tests the equal-path cost (sort runs hit this
|
||||
// repeatedly when many segments share a flow).
|
||||
// - sportDiffers: same src/dst/dport, different sport — the typical
|
||||
// "sibling flows from one host to one server" pattern.
|
||||
// - dstDiffers: same src/sport/dport, different dst — outbound to many
|
||||
// servers from a fixed local port.
|
||||
// - allDiffer: every field differs; worst case for short-circuiting.
|
||||
func flowKeyCases() map[string][]flowKeyPair {
|
||||
const n = 64
|
||||
cases := map[string][]flowKeyPair{
|
||||
"sameFlow": make([]flowKeyPair, n),
|
||||
"sportDiffers": make([]flowKeyPair, n),
|
||||
"dstDiffers": make([]flowKeyPair, n),
|
||||
"allDiffer": make([]flowKeyPair, n),
|
||||
}
|
||||
for i := range n {
|
||||
base := makeFlowKey(0x0a000001, 0x0a000002, 40000, 443)
|
||||
cases["sameFlow"][i] = flowKeyPair{a: base, b: base}
|
||||
cases["sportDiffers"][i] = flowKeyPair{
|
||||
a: base,
|
||||
b: makeFlowKey(0x0a000001, 0x0a000002, uint16(40001+i), 443),
|
||||
}
|
||||
cases["dstDiffers"][i] = flowKeyPair{
|
||||
a: base,
|
||||
b: makeFlowKey(0x0a000001, uint32(0x0a000002+i+1), 40000, 443),
|
||||
}
|
||||
cases["allDiffer"][i] = flowKeyPair{
|
||||
a: makeFlowKey(uint32(0x0a000001+i), uint32(0x0a000002+i), uint16(40000+i), uint16(80+i)),
|
||||
b: makeFlowKey(uint32(0x0b000001+i), uint32(0x0b000002+i), uint16(50000+i), uint16(443+i)),
|
||||
}
|
||||
}
|
||||
return cases
|
||||
}
|
||||
|
||||
// BenchmarkFlowKeyCompare measures flowKeyCompare across the workloads
|
||||
// the sort step actually sees. Use this to compare reorderings.
|
||||
func BenchmarkFlowKeyCompare(b *testing.B) {
|
||||
for name, pairs := range flowKeyCases() {
|
||||
b.Run(name, func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
var sink int
|
||||
for i := 0; i < b.N; i++ {
|
||||
p := pairs[i&(len(pairs)-1)]
|
||||
sink += flowKeyCompare(p.a, p.b)
|
||||
}
|
||||
runtime.KeepAlive(sink)
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,60 +0,0 @@
|
||||
package batch
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
|
||||
"github.com/slackhq/nebula/util"
|
||||
)
|
||||
|
||||
const SendBatchCap = 128
|
||||
|
||||
// batchWriter is the minimal subset of udp.Conn needed by SendBatch to flush.
|
||||
type batchWriter interface {
|
||||
WriteBatch(bufs [][]byte, addrs []netip.AddrPort, outerECNs []byte) error
|
||||
}
|
||||
|
||||
// SendBatch accumulates encrypted UDP packets and flushes them via WriteBatch.
|
||||
// One SendBatch is owned by each listenIn goroutine; no locking is needed.
|
||||
// Slot bytes are borrowed from the injected Arena and remain valid until
|
||||
// Flush, which Resets the arena.
|
||||
type SendBatch struct {
|
||||
out batchWriter
|
||||
bufs [][]byte
|
||||
dsts []netip.AddrPort
|
||||
ecns []byte
|
||||
arena *util.Arena
|
||||
}
|
||||
|
||||
// NewSendBatch makes a SendBatch with batchCap slots backed by arena.
|
||||
func NewSendBatch(out batchWriter, batchCap int, arena *util.Arena) *SendBatch {
|
||||
return &SendBatch{
|
||||
out: out,
|
||||
bufs: make([][]byte, 0, batchCap),
|
||||
dsts: make([]netip.AddrPort, 0, batchCap),
|
||||
ecns: make([]byte, 0, batchCap),
|
||||
arena: arena,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *SendBatch) Reserve(sz int) []byte {
|
||||
return b.arena.Reserve(sz)
|
||||
}
|
||||
|
||||
func (b *SendBatch) Commit(pkt []byte, dst netip.AddrPort, outerECN byte) {
|
||||
b.bufs = append(b.bufs, pkt)
|
||||
b.dsts = append(b.dsts, dst)
|
||||
b.ecns = append(b.ecns, outerECN)
|
||||
}
|
||||
|
||||
func (b *SendBatch) Flush() error {
|
||||
var err error
|
||||
if len(b.bufs) > 0 {
|
||||
err = b.out.WriteBatch(b.bufs, b.dsts, b.ecns)
|
||||
}
|
||||
clear(b.bufs)
|
||||
b.bufs = b.bufs[:0]
|
||||
b.dsts = b.dsts[:0]
|
||||
b.ecns = b.ecns[:0]
|
||||
b.arena.Reset()
|
||||
return err
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
package batch
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/slackhq/nebula/util"
|
||||
)
|
||||
|
||||
type fakeBatchWriter struct {
|
||||
bufs [][]byte
|
||||
addrs []netip.AddrPort
|
||||
ecns []byte
|
||||
}
|
||||
|
||||
func (w *fakeBatchWriter) WriteBatch(bufs [][]byte, addrs []netip.AddrPort, ecns []byte) error {
|
||||
// Snapshot — SendBatch.Flush nils its slot pointers right after WriteBatch
|
||||
// returns, so tests must capture data before that happens.
|
||||
w.bufs = make([][]byte, len(bufs))
|
||||
for i, b := range bufs {
|
||||
cp := make([]byte, len(b))
|
||||
copy(cp, b)
|
||||
w.bufs[i] = cp
|
||||
}
|
||||
w.addrs = append(w.addrs[:0], addrs...)
|
||||
w.ecns = append(w.ecns[:0], ecns...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSendBatchReserveCommitFlush(t *testing.T) {
|
||||
fw := &fakeBatchWriter{}
|
||||
b := NewSendBatch(fw, 4, util.NewArena(32))
|
||||
|
||||
ap := netip.MustParseAddrPort("10.0.0.1:4242")
|
||||
for i := 0; i < 4; i++ {
|
||||
slot := b.Reserve(32)
|
||||
if cap(slot) != 32 {
|
||||
t.Fatalf("slot %d: cap=%d want 32", i, cap(slot))
|
||||
}
|
||||
pkt := append(slot[:0], byte(i), byte(i+1), byte(i+2))
|
||||
b.Commit(pkt, ap, 0)
|
||||
}
|
||||
if err := b.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
if len(fw.bufs) != 4 {
|
||||
t.Fatalf("WriteBatch got %d bufs want 4", len(fw.bufs))
|
||||
}
|
||||
for i, buf := range fw.bufs {
|
||||
if len(buf) != 3 || buf[0] != byte(i) {
|
||||
t.Errorf("buf %d: %x", i, buf)
|
||||
}
|
||||
if fw.addrs[i] != ap {
|
||||
t.Errorf("addr %d: got %v want %v", i, fw.addrs[i], ap)
|
||||
}
|
||||
}
|
||||
|
||||
// Flush again with nothing committed — should be a no-op.
|
||||
fw.bufs = nil
|
||||
if err := b.Flush(); err != nil {
|
||||
t.Fatalf("empty Flush: %v", err)
|
||||
}
|
||||
if fw.bufs != nil {
|
||||
t.Fatalf("empty Flush triggered WriteBatch")
|
||||
}
|
||||
|
||||
// Reuse after Flush.
|
||||
slot := b.Reserve(32)
|
||||
if cap(slot) != 32 {
|
||||
t.Fatalf("after Flush Reserve wrong cap: %d", cap(slot))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendBatchSlotsDoNotOverlap(t *testing.T) {
|
||||
fw := &fakeBatchWriter{}
|
||||
b := NewSendBatch(fw, 3, util.NewArena(8))
|
||||
ap := netip.MustParseAddrPort("10.0.0.1:80")
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
s := b.Reserve(8)
|
||||
pkt := append(s[:0], byte(0xA0+i), byte(0xB0+i))
|
||||
b.Commit(pkt, ap, 0)
|
||||
}
|
||||
if err := b.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
|
||||
for i, buf := range fw.bufs {
|
||||
if buf[0] != byte(0xA0+i) || buf[1] != byte(0xB0+i) {
|
||||
t.Errorf("slot %d corrupted: %x", i, buf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendBatchGrowPreservesCommitted(t *testing.T) {
|
||||
fw := &fakeBatchWriter{}
|
||||
// Tiny initial backing forces a grow on the second Reserve.
|
||||
b := NewSendBatch(fw, 1, util.NewArena(4))
|
||||
ap := netip.MustParseAddrPort("10.0.0.1:80")
|
||||
|
||||
s1 := b.Reserve(4)
|
||||
pkt1 := append(s1[:0], 0x11, 0x22, 0x33, 0x44)
|
||||
b.Commit(pkt1, ap, 0)
|
||||
|
||||
s2 := b.Reserve(8) // exceeds remaining cap, triggers grow
|
||||
pkt2 := append(s2[:0], 0xA, 0xB, 0xC, 0xD, 0xE)
|
||||
b.Commit(pkt2, ap, 0)
|
||||
|
||||
// pkt1 must still be intact even though backing reallocated.
|
||||
if pkt1[0] != 0x11 || pkt1[3] != 0x44 {
|
||||
t.Fatalf("first packet corrupted by grow: %x", pkt1)
|
||||
}
|
||||
|
||||
if err := b.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
if len(fw.bufs) != 2 {
|
||||
t.Fatalf("got %d bufs want 2", len(fw.bufs))
|
||||
}
|
||||
if fw.bufs[0][0] != 0x11 || fw.bufs[0][3] != 0x44 {
|
||||
t.Errorf("first packet on the wire: %x", fw.bufs[0])
|
||||
}
|
||||
if fw.bufs[1][0] != 0xA || fw.bufs[1][4] != 0xE {
|
||||
t.Errorf("second packet on the wire: %x", fw.bufs[1])
|
||||
}
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
package batch
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"io"
|
||||
|
||||
"github.com/slackhq/nebula/overlay/tio"
|
||||
"github.com/slackhq/nebula/util"
|
||||
"github.com/slackhq/nebula/wire"
|
||||
)
|
||||
|
||||
// ipProtoUDP is the IANA protocol number for UDP.
|
||||
const ipProtoUDP = 17
|
||||
|
||||
// udpCoalesceBufSize caps total bytes per UDP superpacket. Mirrors the
|
||||
// kernel's gso_max_size; payloads beyond this are emitted as-is.
|
||||
const udpCoalesceBufSize = 65535
|
||||
|
||||
// udpCoalesceMaxSegs caps how many segments we'll coalesce. Kernel UDP-GSO
|
||||
// accepts up to 64 segments per skb (UDP_MAX_SEGMENTS); stay under that.
|
||||
const udpCoalesceMaxSegs = 64
|
||||
|
||||
// udpCoalesceHdrCap is the scratch space we copy a seed's IP+UDP header
|
||||
// into. IPv6 (40) + UDP (8) = 48; round up for safety.
|
||||
const udpCoalesceHdrCap = 64
|
||||
|
||||
// udpSlot is one entry in the UDPCoalescer's ordered event queue. Same
|
||||
// passthrough-vs-coalesced shape as the TCP coalescer's slot, but no
|
||||
// seq/PSH/CWR bookkeeping — UDP segments only need 5-tuple + length
|
||||
// matching to coalesce.
|
||||
type udpSlot struct {
|
||||
passthrough bool
|
||||
rawPkt []byte // borrowed when passthrough
|
||||
|
||||
fk flowKey
|
||||
hdrBuf [udpCoalesceHdrCap]byte
|
||||
hdrLen int
|
||||
ipHdrLen int
|
||||
isV6 bool
|
||||
gsoSize int // per-segment UDP payload length
|
||||
numSeg int
|
||||
totalPay int
|
||||
// sealed closes the chain: set when a sub-gsoSize segment is appended
|
||||
// (kernel UDP-GSO requires every segment but the last to be exactly
|
||||
// gsoSize) or when limits are hit. No further appends after.
|
||||
sealed bool
|
||||
payIovs [][]byte
|
||||
}
|
||||
|
||||
// UDPCoalescer accumulates adjacent in-flow UDP datagrams across multiple
|
||||
// concurrent flows and emits each flow's run as a single GSO_UDP_L4
|
||||
// superpacket via tio.GSOWriter. Falls back to per-packet writes when the
|
||||
// underlying writer doesn't support USO.
|
||||
//
|
||||
// All output — coalesced or not — is deferred until Flush so per-flow
|
||||
// arrival order is preserved on the wire. Cross-flow order is NOT preserved
|
||||
// across the TCP/UDP/passthrough split when this coalescer runs alongside
|
||||
// others — see multi_coalesce.go. Per-flow order is preserved because a
|
||||
// single 5-tuple only ever lands in one lane and each lane preserves its
|
||||
// own slot order.
|
||||
//
|
||||
// Owns no locks; one coalescer per TUN write queue.
|
||||
type UDPCoalescer struct {
|
||||
plainW io.Writer
|
||||
gsoW tio.GSOWriter // nil when the queue can't accept GSO_UDP_L4
|
||||
|
||||
slots []*udpSlot
|
||||
openSlots map[flowKey]*udpSlot
|
||||
pool []*udpSlot
|
||||
|
||||
// arena is injected; see TCPCoalescer.arena for the contract.
|
||||
arena *util.Arena
|
||||
}
|
||||
|
||||
// NewUDPCoalescer wraps w. The caller is responsible for only constructing
|
||||
// this when the underlying Queue's Capabilities advertise USO; otherwise
|
||||
// the kernel may reject GSO_UDP_L4 writes. If w does not implement
|
||||
// tio.GSOWriter at all (single-packet Queue), the coalescer degrades to
|
||||
// plain Writes — same defensive shape as the TCP coalescer.
|
||||
func NewUDPCoalescer(w tio.Queue, arena *util.Arena) *UDPCoalescer {
|
||||
c := &UDPCoalescer{
|
||||
plainW: w,
|
||||
slots: make([]*udpSlot, 0, initialSlots),
|
||||
openSlots: make(map[flowKey]*udpSlot, initialSlots),
|
||||
pool: make([]*udpSlot, 0, initialSlots),
|
||||
arena: arena,
|
||||
}
|
||||
if gw, ok := tio.SupportsGSO(w, wire.GSOProtoUDP); ok {
|
||||
c.gsoW = gw
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// parsedUDP holds the fields extracted from a single parse so later steps
|
||||
// (admission, slot lookup, canAppend) don't re-walk the header.
|
||||
type parsedUDP struct {
|
||||
fk flowKey
|
||||
ipHdrLen int
|
||||
hdrLen int // ipHdrLen + 8
|
||||
payLen int
|
||||
}
|
||||
|
||||
// parseUDP extracts the flow key and IP/UDP offsets for a UDP packet.
|
||||
// Returns ok=false for non-UDP, malformed, or unsupported header shapes
|
||||
// (IPv4 with options/fragmentation, IPv6 with extension headers).
|
||||
func parseUDP(pkt []byte) (parsedUDP, bool) {
|
||||
var p parsedUDP
|
||||
ip, ok := parseIPPrologue(pkt, ipProtoUDP)
|
||||
if !ok {
|
||||
return p, false
|
||||
}
|
||||
pkt = ip.pkt
|
||||
p.fk = ip.fk
|
||||
p.ipHdrLen = ip.ipHdrLen
|
||||
|
||||
if len(pkt) < p.ipHdrLen+8 {
|
||||
return p, false
|
||||
}
|
||||
p.hdrLen = p.ipHdrLen + 8
|
||||
// UDP `length` field: must equal IP-derived length-of-UDP-header-plus-payload.
|
||||
udpLen := int(binary.BigEndian.Uint16(pkt[p.ipHdrLen+4 : p.ipHdrLen+6]))
|
||||
if udpLen < 8 || udpLen > len(pkt)-p.ipHdrLen {
|
||||
return p, false
|
||||
}
|
||||
p.payLen = udpLen - 8
|
||||
p.fk.sport = binary.BigEndian.Uint16(pkt[p.ipHdrLen : p.ipHdrLen+2])
|
||||
p.fk.dport = binary.BigEndian.Uint16(pkt[p.ipHdrLen+2 : p.ipHdrLen+4])
|
||||
return p, true
|
||||
}
|
||||
|
||||
func (c *UDPCoalescer) Reserve(sz int) []byte {
|
||||
return c.arena.Reserve(sz)
|
||||
}
|
||||
|
||||
// Commit borrows pkt. The caller must keep pkt valid until the next Flush.
|
||||
func (c *UDPCoalescer) Commit(pkt []byte) error {
|
||||
if c.gsoW == nil {
|
||||
c.addPassthrough(pkt)
|
||||
return nil
|
||||
}
|
||||
info, ok := parseUDP(pkt)
|
||||
if !ok {
|
||||
c.addPassthrough(pkt)
|
||||
return nil
|
||||
}
|
||||
return c.commitParsed(pkt, info)
|
||||
}
|
||||
|
||||
// commitParsed is the post-parse half of Commit. The caller must have
|
||||
// already verified parseUDP succeeded. Used by MultiCoalescer.Commit to
|
||||
// avoid re-walking the IP/UDP header.
|
||||
func (c *UDPCoalescer) commitParsed(pkt []byte, info parsedUDP) error {
|
||||
if c.gsoW == nil {
|
||||
c.addPassthrough(pkt)
|
||||
return nil
|
||||
}
|
||||
if open := c.openSlots[info.fk]; open != nil {
|
||||
if c.canAppend(open, pkt, info) {
|
||||
c.appendPayload(open, pkt, info)
|
||||
if open.sealed {
|
||||
delete(c.openSlots, info.fk)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Can't extend — seal it and fall through to seed a fresh slot.
|
||||
delete(c.openSlots, info.fk)
|
||||
}
|
||||
c.seed(pkt, info)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *UDPCoalescer) Flush() error {
|
||||
var first error
|
||||
for _, s := range c.slots {
|
||||
var err error
|
||||
if s.passthrough {
|
||||
_, err = c.plainW.Write(s.rawPkt)
|
||||
} else {
|
||||
err = c.flushSlot(s)
|
||||
}
|
||||
if err != nil && first == nil {
|
||||
first = err
|
||||
}
|
||||
c.release(s)
|
||||
}
|
||||
clear(c.slots)
|
||||
c.slots = c.slots[:0]
|
||||
clear(c.openSlots)
|
||||
c.arena.Reset()
|
||||
return first
|
||||
}
|
||||
|
||||
func (c *UDPCoalescer) addPassthrough(pkt []byte) {
|
||||
s := c.take()
|
||||
s.passthrough = true
|
||||
s.rawPkt = pkt
|
||||
c.slots = append(c.slots, s)
|
||||
}
|
||||
|
||||
func (c *UDPCoalescer) seed(pkt []byte, info parsedUDP) {
|
||||
if info.hdrLen > udpCoalesceHdrCap || info.hdrLen+info.payLen > udpCoalesceBufSize {
|
||||
c.addPassthrough(pkt)
|
||||
return
|
||||
}
|
||||
s := c.take()
|
||||
s.passthrough = false
|
||||
s.rawPkt = nil
|
||||
copy(s.hdrBuf[:], pkt[:info.hdrLen])
|
||||
s.hdrLen = info.hdrLen
|
||||
s.ipHdrLen = info.ipHdrLen
|
||||
s.isV6 = info.fk.isV6
|
||||
s.fk = info.fk
|
||||
s.gsoSize = info.payLen
|
||||
s.numSeg = 1
|
||||
s.totalPay = info.payLen
|
||||
s.sealed = false
|
||||
s.payIovs = append(s.payIovs[:0], pkt[info.hdrLen:info.hdrLen+info.payLen])
|
||||
c.slots = append(c.slots, s)
|
||||
c.openSlots[info.fk] = s
|
||||
}
|
||||
|
||||
// canAppend reports whether info's packet extends the slot's seed.
|
||||
// Kernel UDP-GSO requires every segment except possibly the last to be
|
||||
// exactly gsoSize, and the last may be shorter (≤ gsoSize).
|
||||
func (c *UDPCoalescer) canAppend(s *udpSlot, pkt []byte, info parsedUDP) bool {
|
||||
if s.sealed {
|
||||
return false
|
||||
}
|
||||
if info.hdrLen != s.hdrLen {
|
||||
return false
|
||||
}
|
||||
if s.numSeg >= udpCoalesceMaxSegs {
|
||||
return false
|
||||
}
|
||||
if info.payLen > s.gsoSize {
|
||||
return false
|
||||
}
|
||||
if s.hdrLen+s.totalPay+info.payLen > udpCoalesceBufSize {
|
||||
return false
|
||||
}
|
||||
if !udpHeadersMatch(s.hdrBuf[:s.hdrLen], pkt[:info.hdrLen], s.isV6, s.ipHdrLen) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *UDPCoalescer) appendPayload(s *udpSlot, pkt []byte, info parsedUDP) {
|
||||
s.payIovs = append(s.payIovs, pkt[info.hdrLen:info.hdrLen+info.payLen])
|
||||
s.numSeg++
|
||||
s.totalPay += info.payLen
|
||||
// Merge IP-level CE marks into the seed (same trick TCP coalescer uses).
|
||||
mergeECNIntoSeed(s.hdrBuf[:s.ipHdrLen], pkt[:s.ipHdrLen], s.isV6)
|
||||
if info.payLen < s.gsoSize {
|
||||
// Last-segment-can-be-shorter: this seals the chain.
|
||||
s.sealed = true
|
||||
}
|
||||
}
|
||||
|
||||
func (c *UDPCoalescer) take() *udpSlot {
|
||||
if n := len(c.pool); n > 0 {
|
||||
s := c.pool[n-1]
|
||||
c.pool[n-1] = nil
|
||||
c.pool = c.pool[:n-1]
|
||||
return s
|
||||
}
|
||||
return &udpSlot{}
|
||||
}
|
||||
|
||||
func (c *UDPCoalescer) release(s *udpSlot) {
|
||||
s.passthrough = false
|
||||
s.rawPkt = nil
|
||||
clear(s.payIovs)
|
||||
s.payIovs = s.payIovs[:0]
|
||||
s.numSeg = 0
|
||||
s.totalPay = 0
|
||||
s.sealed = false
|
||||
c.pool = append(c.pool, s)
|
||||
}
|
||||
|
||||
// flushSlot patches the IP header total length / IPv6 payload length and
|
||||
// the UDP length to the *total* across all coalesced segments, then seeds
|
||||
// the UDP checksum field with the pseudo-header partial (single-fold, not
|
||||
// inverted) per virtio NEEDS_CSUM. The kernel's ip_rcv_core (v4) and
|
||||
// ip6_rcv_core (v6) trim the skb to those length fields, so per-segment
|
||||
// values would silently drop everything but the first segment. The kernel
|
||||
// then walks each segment in __udp_gso_segment, recomputing per-segment
|
||||
// uh->len / iph->tot_len / IPv6 plen and adjusting the checksum via
|
||||
// `check = csum16_add(csum16_sub(uh->check, uh->len), newlen)` — meaning
|
||||
// our seed's uh->check must be consistent with the seed's uh->len, which
|
||||
// is what passing the total to both pseudoSum and the UDP length field
|
||||
// guarantees.
|
||||
func (c *UDPCoalescer) flushSlot(s *udpSlot) error {
|
||||
hdr := s.hdrBuf[:s.hdrLen]
|
||||
total := s.hdrLen + s.totalPay // full IP+UDP+all_payloads bytes
|
||||
l4Len := total - s.ipHdrLen // total UDP (8 + sum of payloads)
|
||||
|
||||
if s.isV6 {
|
||||
binary.BigEndian.PutUint16(hdr[4:6], uint16(l4Len))
|
||||
} else {
|
||||
binary.BigEndian.PutUint16(hdr[2:4], uint16(total))
|
||||
hdr[10] = 0
|
||||
hdr[11] = 0
|
||||
binary.BigEndian.PutUint16(hdr[10:12], ipv4HdrChecksum(hdr[:s.ipHdrLen]))
|
||||
}
|
||||
|
||||
// UDP length field (offset 4 inside the UDP header) = total UDP size.
|
||||
binary.BigEndian.PutUint16(hdr[s.ipHdrLen+4:s.ipHdrLen+6], uint16(l4Len))
|
||||
|
||||
var psum uint32
|
||||
if s.isV6 {
|
||||
psum = pseudoSumIPv6(hdr[8:24], hdr[24:40], ipProtoUDP, l4Len)
|
||||
} else {
|
||||
psum = pseudoSumIPv4(hdr[12:16], hdr[16:20], ipProtoUDP, l4Len)
|
||||
}
|
||||
udpCsumOff := s.ipHdrLen + 6
|
||||
binary.BigEndian.PutUint16(hdr[udpCsumOff:udpCsumOff+2], foldOnceNoInvert(psum))
|
||||
|
||||
return c.gsoW.WriteGSO(hdr[:s.ipHdrLen], hdr[s.ipHdrLen:], s.payIovs, wire.GSOProtoUDP)
|
||||
}
|
||||
|
||||
// udpHeadersMatch compares two IP+UDP header prefixes for byte-equality on
|
||||
// every field that must be identical across coalesced segments. Length
|
||||
// fields and the ECN bits in IP TOS/TC are masked out — appendPayload
|
||||
// merges CE into the seed; flushSlot rewrites lengths.
|
||||
func udpHeadersMatch(a, b []byte, isV6 bool, ipHdrLen int) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
if !ipHeadersMatch(a, b, isV6) {
|
||||
return false
|
||||
}
|
||||
// UDP: compare sport+dport ([0:4]). Skip length [4:6] and checksum [6:8] —
|
||||
// length varies (we rewrite at flush) and the checksum will be redone.
|
||||
udp := ipHdrLen
|
||||
if a[udp] != b[udp] || a[udp+1] != b[udp+1] || a[udp+2] != b[udp+2] || a[udp+3] != b[udp+3] {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
package batch
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
|
||||
"github.com/slackhq/nebula/util"
|
||||
)
|
||||
|
||||
// buildUDPv4 builds a minimal IPv4+UDP packet with the given payload and ports.
|
||||
func buildUDPv4(sport, dport uint16, payload []byte) []byte {
|
||||
const ipHdrLen = 20
|
||||
const udpHdrLen = 8
|
||||
total := ipHdrLen + udpHdrLen + len(payload)
|
||||
pkt := make([]byte, total)
|
||||
|
||||
pkt[0] = 0x45
|
||||
pkt[1] = 0x00
|
||||
binary.BigEndian.PutUint16(pkt[2:4], uint16(total))
|
||||
binary.BigEndian.PutUint16(pkt[4:6], 0)
|
||||
binary.BigEndian.PutUint16(pkt[6:8], 0x4000)
|
||||
pkt[8] = 64
|
||||
pkt[9] = ipProtoUDP
|
||||
copy(pkt[12:16], []byte{10, 0, 0, 1})
|
||||
copy(pkt[16:20], []byte{10, 0, 0, 2})
|
||||
|
||||
binary.BigEndian.PutUint16(pkt[20:22], sport)
|
||||
binary.BigEndian.PutUint16(pkt[22:24], dport)
|
||||
binary.BigEndian.PutUint16(pkt[24:26], uint16(udpHdrLen+len(payload)))
|
||||
binary.BigEndian.PutUint16(pkt[26:28], 0)
|
||||
|
||||
copy(pkt[28:], payload)
|
||||
return pkt
|
||||
}
|
||||
|
||||
// buildUDPv6 builds a minimal IPv6+UDP packet.
|
||||
func buildUDPv6(sport, dport uint16, payload []byte) []byte {
|
||||
const ipHdrLen = 40
|
||||
const udpHdrLen = 8
|
||||
total := ipHdrLen + udpHdrLen + len(payload)
|
||||
pkt := make([]byte, total)
|
||||
|
||||
pkt[0] = 0x60
|
||||
binary.BigEndian.PutUint16(pkt[4:6], uint16(udpHdrLen+len(payload)))
|
||||
pkt[6] = ipProtoUDP
|
||||
pkt[7] = 64
|
||||
pkt[8] = 0xfe
|
||||
pkt[9] = 0x80
|
||||
pkt[23] = 1
|
||||
pkt[24] = 0xfe
|
||||
pkt[25] = 0x80
|
||||
pkt[39] = 2
|
||||
|
||||
binary.BigEndian.PutUint16(pkt[40:42], sport)
|
||||
binary.BigEndian.PutUint16(pkt[42:44], dport)
|
||||
binary.BigEndian.PutUint16(pkt[44:46], uint16(udpHdrLen+len(payload)))
|
||||
binary.BigEndian.PutUint16(pkt[46:48], 0)
|
||||
|
||||
copy(pkt[48:], payload)
|
||||
return pkt
|
||||
}
|
||||
|
||||
func TestUDPCoalescerPassthroughWhenGSOUnavailable(t *testing.T) {
|
||||
w := &fakeTunWriter{gsoEnabled: false}
|
||||
c := NewUDPCoalescer(w, util.NewArena(0))
|
||||
pkt := buildUDPv4(1000, 53, make([]byte, 100))
|
||||
if err := c.Commit(pkt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(w.writes) != 0 || len(w.gsoWrites) != 0 {
|
||||
t.Fatalf("no Add-time writes: writes=%d gso=%d", len(w.writes), len(w.gsoWrites))
|
||||
}
|
||||
if err := c.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(w.writes) != 1 || len(w.gsoWrites) != 0 {
|
||||
t.Fatalf("want single plain write, got writes=%d gso=%d", len(w.writes), len(w.gsoWrites))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUDPCoalescerNonUDPPassthrough(t *testing.T) {
|
||||
w := &fakeTunWriter{gsoEnabled: true}
|
||||
c := NewUDPCoalescer(w, util.NewArena(0))
|
||||
// ICMP packet
|
||||
pkt := make([]byte, 28)
|
||||
pkt[0] = 0x45
|
||||
binary.BigEndian.PutUint16(pkt[2:4], 28)
|
||||
pkt[9] = 1
|
||||
copy(pkt[12:16], []byte{10, 0, 0, 1})
|
||||
copy(pkt[16:20], []byte{10, 0, 0, 2})
|
||||
if err := c.Commit(pkt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(w.writes) != 1 || len(w.gsoWrites) != 0 {
|
||||
t.Fatalf("ICMP must pass through unchanged: writes=%d gso=%d", len(w.writes), len(w.gsoWrites))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUDPCoalescerSeedThenFlushAlone(t *testing.T) {
|
||||
w := &fakeTunWriter{gsoEnabled: true}
|
||||
c := NewUDPCoalescer(w, util.NewArena(0))
|
||||
pkt := buildUDPv4(1000, 53, make([]byte, 800))
|
||||
if err := c.Commit(pkt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Single-segment flush goes through WriteGSO; the writer infers GSO_NONE
|
||||
// from len(pays)==1 and the kernel fills in the UDP csum (NEEDS_CSUM).
|
||||
if len(w.gsoWrites) != 1 || len(w.writes) != 0 {
|
||||
t.Fatalf("single-seg flush: writes=%d gso=%d", len(w.writes), len(w.gsoWrites))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUDPCoalescerCoalescesEqualSized(t *testing.T) {
|
||||
w := &fakeTunWriter{gsoEnabled: true}
|
||||
c := NewUDPCoalescer(w, util.NewArena(0))
|
||||
pay := make([]byte, 1200)
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := c.Commit(buildUDPv4(1000, 53, pay)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := c.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(w.gsoWrites) != 1 {
|
||||
t.Fatalf("want 1 gso write, got %d (plain=%d)", len(w.gsoWrites), len(w.writes))
|
||||
}
|
||||
g := w.gsoWrites[0]
|
||||
if g.gsoSize != 1200 {
|
||||
t.Errorf("gsoSize=%d want 1200", g.gsoSize)
|
||||
}
|
||||
if len(g.pays) != 3 {
|
||||
t.Errorf("pay count=%d want 3", len(g.pays))
|
||||
}
|
||||
if g.csumStart != 20 {
|
||||
t.Errorf("csumStart=%d want 20", g.csumStart)
|
||||
}
|
||||
// IP totalLen and UDP length must be the TOTAL across all segments —
|
||||
// the kernel's ip_rcv_core trims skbs to iph->tot_len, so a per-segment
|
||||
// value would silently drop everything but the first segment. Total =
|
||||
// IP(20) + UDP(8) + 3*1200 = 3628.
|
||||
gotTotalLen := binary.BigEndian.Uint16(g.hdr[2:4])
|
||||
if gotTotalLen != 3628 {
|
||||
t.Errorf("ipv4 total_len=%d want 3628 (must be total across segments)", gotTotalLen)
|
||||
}
|
||||
gotUDPLen := binary.BigEndian.Uint16(g.hdr[20+4 : 20+6])
|
||||
if gotUDPLen != 8+3*1200 {
|
||||
t.Errorf("udp len=%d want %d", gotUDPLen, 8+3*1200)
|
||||
}
|
||||
}
|
||||
|
||||
// Last segment may be shorter, sealing the chain.
|
||||
func TestUDPCoalescerShortLastSegmentSeals(t *testing.T) {
|
||||
w := &fakeTunWriter{gsoEnabled: true}
|
||||
c := NewUDPCoalescer(w, util.NewArena(0))
|
||||
full := make([]byte, 1200)
|
||||
tail := make([]byte, 600)
|
||||
if err := c.Commit(buildUDPv4(1000, 53, full)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Commit(buildUDPv4(1000, 53, full)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Commit(buildUDPv4(1000, 53, tail)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A 4th packet, even same-sized, must NOT join — chain is sealed.
|
||||
if err := c.Commit(buildUDPv4(1000, 53, full)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(w.gsoWrites) != 2 {
|
||||
t.Fatalf("want 2 gso writes (sealed + new seed), got %d", len(w.gsoWrites))
|
||||
}
|
||||
if len(w.gsoWrites[0].pays) != 3 {
|
||||
t.Errorf("first super: want 3 pays, got %d", len(w.gsoWrites[0].pays))
|
||||
}
|
||||
if len(w.gsoWrites[1].pays) != 1 {
|
||||
t.Errorf("second super: want 1 pay (re-seed), got %d", len(w.gsoWrites[1].pays))
|
||||
}
|
||||
}
|
||||
|
||||
// A larger-than-gsoSize packet cannot extend the slot — it reseeds.
|
||||
func TestUDPCoalescerLargerThanSeedReseeds(t *testing.T) {
|
||||
w := &fakeTunWriter{gsoEnabled: true}
|
||||
c := NewUDPCoalescer(w, util.NewArena(0))
|
||||
if err := c.Commit(buildUDPv4(1000, 53, make([]byte, 800))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Commit(buildUDPv4(1000, 53, make([]byte, 1200))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(w.gsoWrites) != 2 {
|
||||
t.Fatalf("want 2 separate seeds, got %d", len(w.gsoWrites))
|
||||
}
|
||||
}
|
||||
|
||||
// Different 5-tuples must not coalesce.
|
||||
func TestUDPCoalescerDifferentFlowsKeepSeparate(t *testing.T) {
|
||||
w := &fakeTunWriter{gsoEnabled: true}
|
||||
c := NewUDPCoalescer(w, util.NewArena(0))
|
||||
pay := make([]byte, 800)
|
||||
if err := c.Commit(buildUDPv4(1000, 53, pay)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Commit(buildUDPv4(2000, 53, pay)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Commit(buildUDPv4(1000, 53, pay)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Commit(buildUDPv4(2000, 53, pay)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Two flows × 2 datagrams each = 2 superpackets of 2 segments.
|
||||
if len(w.gsoWrites) != 2 {
|
||||
t.Fatalf("want 2 gso writes (one per flow), got %d", len(w.gsoWrites))
|
||||
}
|
||||
for i, g := range w.gsoWrites {
|
||||
if len(g.pays) != 2 {
|
||||
t.Errorf("super %d: want 2 pays, got %d", i, len(g.pays))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Caps at udpCoalesceMaxSegs.
|
||||
func TestUDPCoalescerCapsAtMaxSegs(t *testing.T) {
|
||||
w := &fakeTunWriter{gsoEnabled: true}
|
||||
c := NewUDPCoalescer(w, util.NewArena(0))
|
||||
pay := make([]byte, 100)
|
||||
for i := 0; i < udpCoalesceMaxSegs+5; i++ {
|
||||
if err := c.Commit(buildUDPv4(1000, 53, pay)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := c.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// First superpacket holds udpCoalesceMaxSegs segments; the spillover
|
||||
// reseeds a new one.
|
||||
if len(w.gsoWrites) != 2 {
|
||||
t.Fatalf("want 2 gso writes (cap then reseed), got %d", len(w.gsoWrites))
|
||||
}
|
||||
if len(w.gsoWrites[0].pays) != udpCoalesceMaxSegs {
|
||||
t.Errorf("first super: pays=%d want %d", len(w.gsoWrites[0].pays), udpCoalesceMaxSegs)
|
||||
}
|
||||
if len(w.gsoWrites[1].pays) != 5 {
|
||||
t.Errorf("second super: pays=%d want 5", len(w.gsoWrites[1].pays))
|
||||
}
|
||||
}
|
||||
|
||||
// CE marks on appended segments must be merged into the seed's IP TOS.
|
||||
func TestUDPCoalescerMergesCEMark(t *testing.T) {
|
||||
w := &fakeTunWriter{gsoEnabled: true}
|
||||
c := NewUDPCoalescer(w, util.NewArena(0))
|
||||
pay := make([]byte, 800)
|
||||
pkt0 := buildUDPv4(1000, 53, pay) // ECN=00
|
||||
pkt1 := buildUDPv4(1000, 53, pay)
|
||||
pkt1[1] = 0x03 // CE
|
||||
pkt2 := buildUDPv4(1000, 53, pay)
|
||||
if err := c.Commit(pkt0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Commit(pkt1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Commit(pkt2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(w.gsoWrites) != 1 {
|
||||
t.Fatalf("want 1 merged gso write, got %d (plain=%d)", len(w.gsoWrites), len(w.writes))
|
||||
}
|
||||
if w.gsoWrites[0].hdr[1]&0x03 != 0x03 {
|
||||
t.Errorf("CE not merged into seed (tos=%#x)", w.gsoWrites[0].hdr[1])
|
||||
}
|
||||
}
|
||||
|
||||
// IPv6 path: same flow, equal-sized → coalesced.
|
||||
func TestUDPCoalescerIPv6Coalesces(t *testing.T) {
|
||||
w := &fakeTunWriter{gsoEnabled: true}
|
||||
c := NewUDPCoalescer(w, util.NewArena(0))
|
||||
pay := make([]byte, 1200)
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := c.Commit(buildUDPv6(1000, 53, pay)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := c.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(w.gsoWrites) != 1 {
|
||||
t.Fatalf("want 1 gso write, got %d", len(w.gsoWrites))
|
||||
}
|
||||
g := w.gsoWrites[0]
|
||||
if !g.isV6 {
|
||||
t.Errorf("expected v6 write")
|
||||
}
|
||||
if g.csumStart != 40 {
|
||||
t.Errorf("csumStart=%d want 40", g.csumStart)
|
||||
}
|
||||
// IPv6 payload_len and UDP length must be TOTAL — kernel's
|
||||
// ip6_rcv_core trims to payload_len + ipv6 hdr size. Total UDP = 8 +
|
||||
// 3*1200 = 3608.
|
||||
gotPlen := binary.BigEndian.Uint16(g.hdr[4:6])
|
||||
if gotPlen != 8+3*1200 {
|
||||
t.Errorf("ipv6 payload_len=%d want %d (must be total)", gotPlen, 8+3*1200)
|
||||
}
|
||||
gotUDPLen := binary.BigEndian.Uint16(g.hdr[40+4 : 40+6])
|
||||
if gotUDPLen != 8+3*1200 {
|
||||
t.Errorf("udp len=%d want %d", gotUDPLen, 8+3*1200)
|
||||
}
|
||||
}
|
||||
|
||||
// DSCP differences must reseed (headers don't match outside ECN).
|
||||
func TestUDPCoalescerDSCPMismatchReseeds(t *testing.T) {
|
||||
w := &fakeTunWriter{gsoEnabled: true}
|
||||
c := NewUDPCoalescer(w, util.NewArena(0))
|
||||
pay := make([]byte, 800)
|
||||
pkt0 := buildUDPv4(1000, 53, pay)
|
||||
pkt1 := buildUDPv4(1000, 53, pay)
|
||||
pkt1[1] = 0xb8 // EF DSCP, ECN=0
|
||||
if err := c.Commit(pkt0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Commit(pkt1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(w.gsoWrites) != 2 {
|
||||
t.Fatalf("want 2 separate seeds (different DSCP), got %d", len(w.gsoWrites))
|
||||
}
|
||||
}
|
||||
|
||||
// Fragmented IPv4 must not be coalesced.
|
||||
func TestUDPCoalescerFragmentedIPv4PassesThrough(t *testing.T) {
|
||||
w := &fakeTunWriter{gsoEnabled: true}
|
||||
c := NewUDPCoalescer(w, util.NewArena(0))
|
||||
pkt := buildUDPv4(1000, 53, make([]byte, 200))
|
||||
binary.BigEndian.PutUint16(pkt[6:8], 0x2000) // MF=1
|
||||
if err := c.Commit(pkt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(w.writes) != 1 || len(w.gsoWrites) != 0 {
|
||||
t.Fatalf("frag must pass through plain, got writes=%d gso=%d", len(w.writes), len(w.gsoWrites))
|
||||
}
|
||||
}
|
||||
|
||||
// IPv4 with options is not admissible (we require IHL=5).
|
||||
func TestUDPCoalescerIPv4WithOptionsPassesThrough(t *testing.T) {
|
||||
w := &fakeTunWriter{gsoEnabled: true}
|
||||
c := NewUDPCoalescer(w, util.NewArena(0))
|
||||
pkt := buildUDPv4(1000, 53, make([]byte, 200))
|
||||
pkt[0] = 0x46 // IHL = 6 (24-byte IPv4 header — has options)
|
||||
if err := c.Commit(pkt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(w.writes) != 1 || len(w.gsoWrites) != 0 {
|
||||
t.Fatalf("ipv4-with-options must pass through plain, got writes=%d gso=%d", len(w.writes), len(w.gsoWrites))
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package checksum
|
||||
|
||||
import (
|
||||
"golang.org/x/sys/cpu"
|
||||
gvisorchecksum "gvisor.dev/gvisor/pkg/tcpip/checksum"
|
||||
)
|
||||
|
||||
//go:noescape
|
||||
func checksumAVX2(buf []byte, initial uint16) uint16
|
||||
|
||||
var hasAVX2 = cpu.X86.HasAVX2
|
||||
|
||||
// Checksum computes the RFC 1071 ones-complement sum of buf, seeded with
|
||||
// initial. It is a drop-in replacement for gvisor's checksum.Checksum that
|
||||
// dispatches to a hand-written AVX2 routine on amd64 CPUs that support it,
|
||||
// falling back to gvisor's pure-Go implementation otherwise. The result
|
||||
// matches gvisor's bit-for-bit for any buffer length and initial seed.
|
||||
func Checksum(buf []byte, initial uint16) uint16 {
|
||||
if hasAVX2 {
|
||||
return checksumAVX2(buf, initial)
|
||||
}
|
||||
return gvisorchecksum.Checksum(buf, initial)
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
#include "textflag.h"
|
||||
|
||||
// func checksumAVX2(buf []byte, initial uint16) uint16
|
||||
//
|
||||
// Computes the RFC 1071 ones-complement sum of buf, seeded with initial.
|
||||
//
|
||||
// Algorithm: sum the buffer treating it as a stream of uint32s in machine
|
||||
// (little-endian) byte order, accumulating into 64-bit lanes (top 32 bits
|
||||
// hold cross-add carries — at 1 byte / lane / iter we have 32 bits of
|
||||
// headroom which is far more than the 16 KB/64 KB max practical inputs).
|
||||
// At the end we fold to 16 bits and byte-swap once to recover the on-wire
|
||||
// (big-endian) result. RFC 1071 §1.2.B byte-order independence makes this
|
||||
// equivalent to summing as 16-bit big-endian words.
|
||||
//
|
||||
// The ymm accumulators (Y4..Y7) hold 4 uint64 lanes each = 16 parallel
|
||||
// partial sums. The main loop loads 64 bytes per iter as four 16-byte
|
||||
// chunks, zero-extending each chunk's four uint32s into a ymm via
|
||||
// VPMOVZXDQ-from-memory, then VPADDQ into a separate accumulator per
|
||||
// chunk to break the dep chain. After the vector loop the lane sums are
|
||||
// horizontally reduced and merged with a scalar accumulator that handles
|
||||
// the trailing 0..63 bytes plus the (byte-swapped) initial seed.
|
||||
TEXT ·checksumAVX2(SB), NOSPLIT, $0-34
|
||||
MOVQ buf_base+0(FP), SI
|
||||
MOVQ buf_len+8(FP), CX
|
||||
MOVWQZX initial+24(FP), AX
|
||||
|
||||
// Pre-byteswap initial into the LE-summing space so it merges directly
|
||||
// with the rest of the accumulator. The final fold's bswap16 will undo
|
||||
// this and convert the whole result back to BE.
|
||||
XCHGB AH, AL
|
||||
|
||||
CMPQ CX, $32
|
||||
JLT scalar_tail
|
||||
|
||||
VPXOR Y4, Y4, Y4
|
||||
VPXOR Y5, Y5, Y5
|
||||
VPXOR Y6, Y6, Y6
|
||||
VPXOR Y7, Y7, Y7
|
||||
|
||||
CMPQ CX, $64
|
||||
JLT loop32
|
||||
|
||||
loop64:
|
||||
VPMOVZXDQ (SI), Y0
|
||||
VPMOVZXDQ 16(SI), Y1
|
||||
VPMOVZXDQ 32(SI), Y2
|
||||
VPMOVZXDQ 48(SI), Y3
|
||||
VPADDQ Y0, Y4, Y4
|
||||
VPADDQ Y1, Y5, Y5
|
||||
VPADDQ Y2, Y6, Y6
|
||||
VPADDQ Y3, Y7, Y7
|
||||
ADDQ $64, SI
|
||||
SUBQ $64, CX
|
||||
CMPQ CX, $64
|
||||
JGE loop64
|
||||
|
||||
loop32:
|
||||
CMPQ CX, $32
|
||||
JLT reduce_vec
|
||||
VPMOVZXDQ (SI), Y0
|
||||
VPMOVZXDQ 16(SI), Y1
|
||||
VPADDQ Y0, Y4, Y4
|
||||
VPADDQ Y1, Y5, Y5
|
||||
ADDQ $32, SI
|
||||
SUBQ $32, CX
|
||||
JMP loop32
|
||||
|
||||
reduce_vec:
|
||||
// Combine the four ymm accumulators into Y4.
|
||||
VPADDQ Y5, Y4, Y4
|
||||
VPADDQ Y7, Y6, Y6
|
||||
VPADDQ Y6, Y4, Y4
|
||||
|
||||
// Horizontally reduce Y4's four uint64 lanes to a single scalar.
|
||||
VEXTRACTI128 $1, Y4, X5
|
||||
VPADDQ X5, X4, X4
|
||||
VPSHUFD $0x4e, X4, X5
|
||||
VPADDQ X5, X4, X4
|
||||
VMOVQ X4, R8
|
||||
VZEROUPPER
|
||||
|
||||
ADDQ R8, AX
|
||||
ADCQ $0, AX
|
||||
|
||||
scalar_tail:
|
||||
// Handle remaining 0..63 bytes (or the entire buffer if it was < 32).
|
||||
CMPQ CX, $8
|
||||
JLT tail4
|
||||
|
||||
loop8:
|
||||
ADDQ (SI), AX
|
||||
ADCQ $0, AX
|
||||
ADDQ $8, SI
|
||||
SUBQ $8, CX
|
||||
CMPQ CX, $8
|
||||
JGE loop8
|
||||
|
||||
tail4:
|
||||
CMPQ CX, $4
|
||||
JLT tail2
|
||||
MOVL (SI), R8
|
||||
ADDQ R8, AX
|
||||
ADCQ $0, AX
|
||||
ADDQ $4, SI
|
||||
SUBQ $4, CX
|
||||
|
||||
tail2:
|
||||
CMPQ CX, $2
|
||||
JLT tail1
|
||||
MOVWQZX (SI), R8
|
||||
ADDQ R8, AX
|
||||
ADCQ $0, AX
|
||||
ADDQ $2, SI
|
||||
SUBQ $2, CX
|
||||
|
||||
tail1:
|
||||
TESTQ CX, CX
|
||||
JZ fold
|
||||
MOVBQZX (SI), R8
|
||||
ADDQ R8, AX
|
||||
ADCQ $0, AX
|
||||
|
||||
fold:
|
||||
// Fold the 64-bit accumulator to 16 bits via four rounds, mirroring
|
||||
// gvisor's reduce(). Each pair (split, add) halves the live width;
|
||||
// the truncation steps absorb the single bit that may be left over
|
||||
// after each add so the next round's bound holds.
|
||||
|
||||
// 64 → 33 bits.
|
||||
MOVQ AX, R8
|
||||
SHRQ $32, R8
|
||||
MOVL AX, AX
|
||||
ADDQ R8, AX
|
||||
|
||||
// 33 → 32 bits. AX += (AX>>32); truncate to 32. AX is now ≤ 0xFFFF_FFFF.
|
||||
MOVQ AX, R8
|
||||
SHRQ $32, R8
|
||||
ADDQ R8, AX
|
||||
MOVL AX, AX
|
||||
|
||||
// 32 → 17 bits.
|
||||
MOVQ AX, R8
|
||||
SHRQ $16, R8
|
||||
MOVWQZX AX, AX
|
||||
ADDQ R8, AX
|
||||
|
||||
// 17 → 16 bits. AX += (AX>>16); the trailing MOVW truncates bit 16.
|
||||
MOVQ AX, R8
|
||||
SHRQ $16, R8
|
||||
ADDQ R8, AX
|
||||
|
||||
// AX low 16 bits hold the 16-bit sum in machine (LE) byte order; flip
|
||||
// to big-endian to match the gvisor API contract.
|
||||
XCHGB AH, AL
|
||||
|
||||
MOVW AX, ret+32(FP)
|
||||
RET
|
||||
@@ -1,12 +0,0 @@
|
||||
package checksum
|
||||
|
||||
//go:noescape
|
||||
func checksumNEON(buf []byte, initial uint16) uint16
|
||||
|
||||
// Checksum computes the RFC 1071 ones-complement sum of buf, seeded with
|
||||
// initial. It is a drop-in replacement for gvisor's checksum.Checksum
|
||||
// that dispatches to a hand-written NEON routine. NEON is mandatory in
|
||||
// armv8 so no feature check is needed.
|
||||
func Checksum(buf []byte, initial uint16) uint16 {
|
||||
return checksumNEON(buf, initial)
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
#include "textflag.h"
|
||||
|
||||
// func checksumNEON(buf []byte, initial uint16) uint16
|
||||
//
|
||||
// Mirrors the algorithm in checksum_amd64.s: sum the buffer treating it as
|
||||
// a stream of uint32s in machine (little-endian) byte order, accumulating
|
||||
// into 64-bit lanes that have ample carry headroom; fold and byte-swap once
|
||||
// at the very end to recover the on-wire (big-endian) result.
|
||||
//
|
||||
// Each loop iteration loads 64 bytes via VLD1.P into V0..V3 (4 Q regs).
|
||||
// VUADDW takes the low two uint32 lanes of a Q reg, zero-extends them to
|
||||
// uint64, and adds them into a 2×uint64 accumulator; VUADDW2 does the same
|
||||
// for the high two lanes. Four ymm-equivalent accumulators (V8..V11) get
|
||||
// updated twice per iter to break the dep chain. Tail bytes go through a
|
||||
// scalar ADCS chain seeded with the byte-swapped initial.
|
||||
TEXT ·checksumNEON(SB), NOSPLIT, $0-34
|
||||
MOVD buf_base+0(FP), R0
|
||||
MOVD buf_len+8(FP), R1
|
||||
MOVHU initial+24(FP), R2
|
||||
|
||||
// Pre-byteswap initial into the LE-summing space so it merges directly
|
||||
// with the rest of the accumulator.
|
||||
REV16W R2, R2
|
||||
|
||||
MOVD ZR, R3 // scalar accumulator
|
||||
|
||||
CMP $32, R1
|
||||
BLT scalar_tail
|
||||
|
||||
VEOR V8.B16, V8.B16, V8.B16
|
||||
VEOR V9.B16, V9.B16, V9.B16
|
||||
VEOR V10.B16, V10.B16, V10.B16
|
||||
VEOR V11.B16, V11.B16, V11.B16
|
||||
|
||||
CMP $64, R1
|
||||
BLT loop16_init
|
||||
|
||||
loop64:
|
||||
VLD1.P 64(R0), [V0.B16, V1.B16, V2.B16, V3.B16]
|
||||
VUADDW V0.S2, V8.D2, V8.D2
|
||||
VUADDW2 V0.S4, V9.D2, V9.D2
|
||||
VUADDW V1.S2, V10.D2, V10.D2
|
||||
VUADDW2 V1.S4, V11.D2, V11.D2
|
||||
VUADDW V2.S2, V8.D2, V8.D2
|
||||
VUADDW2 V2.S4, V9.D2, V9.D2
|
||||
VUADDW V3.S2, V10.D2, V10.D2
|
||||
VUADDW2 V3.S4, V11.D2, V11.D2
|
||||
SUB $64, R1, R1
|
||||
CMP $64, R1
|
||||
BGE loop64
|
||||
|
||||
loop16_init:
|
||||
CMP $16, R1
|
||||
BLT reduce_vec
|
||||
|
||||
loop16:
|
||||
VLD1.P 16(R0), [V0.B16]
|
||||
VUADDW V0.S2, V8.D2, V8.D2
|
||||
VUADDW2 V0.S4, V9.D2, V9.D2
|
||||
SUB $16, R1, R1
|
||||
CMP $16, R1
|
||||
BGE loop16
|
||||
|
||||
reduce_vec:
|
||||
// Combine the four accumulators into V8.
|
||||
VADD V9.D2, V8.D2, V8.D2
|
||||
VADD V11.D2, V10.D2, V10.D2
|
||||
VADD V10.D2, V8.D2, V8.D2
|
||||
|
||||
// Horizontal-add the two lanes of V8.D2 into a single uint64.
|
||||
VADDP V8.D2, V8.D2, V8.D2
|
||||
VMOV V8.D[0], R8
|
||||
|
||||
ADDS R8, R3, R3
|
||||
ADC ZR, R3, R3
|
||||
|
||||
scalar_tail:
|
||||
CMP $8, R1
|
||||
BLT tail4
|
||||
|
||||
loop8:
|
||||
MOVD.P 8(R0), R8
|
||||
ADDS R8, R3, R3
|
||||
ADC ZR, R3, R3
|
||||
SUB $8, R1, R1
|
||||
CMP $8, R1
|
||||
BGE loop8
|
||||
|
||||
tail4:
|
||||
CMP $4, R1
|
||||
BLT tail2
|
||||
MOVWU.P 4(R0), R8
|
||||
ADDS R8, R3, R3
|
||||
ADC ZR, R3, R3
|
||||
SUB $4, R1, R1
|
||||
|
||||
tail2:
|
||||
CMP $2, R1
|
||||
BLT tail1
|
||||
MOVHU.P 2(R0), R8
|
||||
ADDS R8, R3, R3
|
||||
ADC ZR, R3, R3
|
||||
SUB $2, R1, R1
|
||||
|
||||
tail1:
|
||||
CBZ R1, fold
|
||||
MOVBU (R0), R8
|
||||
ADDS R8, R3, R3
|
||||
ADC ZR, R3, R3
|
||||
|
||||
fold:
|
||||
// Merge the byte-swapped initial into our LE-form accumulator.
|
||||
ADDS R2, R3, R3
|
||||
ADC ZR, R3, R3
|
||||
|
||||
// 64 → 33 bits.
|
||||
LSR $32, R3, R8
|
||||
AND $0xffffffff, R3, R3
|
||||
ADD R8, R3, R3
|
||||
|
||||
// 33 → 32 (truncate after adding bit 32 back).
|
||||
LSR $32, R3, R8
|
||||
ADD R8, R3, R3
|
||||
AND $0xffffffff, R3, R3
|
||||
|
||||
// 32 → 17.
|
||||
LSR $16, R3, R8
|
||||
AND $0xffff, R3, R3
|
||||
ADD R8, R3, R3
|
||||
|
||||
// 17 → 16 (truncation absorbs bit 16 below).
|
||||
LSR $16, R3, R8
|
||||
ADD R8, R3, R3
|
||||
|
||||
// AX low 16 bits hold the 16-bit sum in machine (LE) byte order; flip
|
||||
// to big-endian to match the gvisor API contract. REV16W swaps bytes
|
||||
// within each 16-bit halfword of the low 32 bits, so it acts as a
|
||||
// 16-bit byte-swap on the live low 16.
|
||||
REV16W R3, R3
|
||||
AND $0xffff, R3, R3
|
||||
|
||||
MOVH R3, ret+32(FP)
|
||||
RET
|
||||
@@ -1,10 +0,0 @@
|
||||
//go:build !amd64 && !arm64
|
||||
|
||||
package checksum
|
||||
|
||||
import gvisorchecksum "gvisor.dev/gvisor/pkg/tcpip/checksum"
|
||||
|
||||
// Checksum delegates to gvisor on architectures without a hand-written body.
|
||||
func Checksum(buf []byte, initial uint16) uint16 {
|
||||
return gvisorchecksum.Checksum(buf, initial)
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
package checksum
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
|
||||
gvisorchecksum "gvisor.dev/gvisor/pkg/tcpip/checksum"
|
||||
)
|
||||
|
||||
// TestChecksumMatchesGvisor walks lengths from 0 to 4096, with several initial
|
||||
// seeds and a handful of starting alignments, asserting that our local
|
||||
// Checksum matches gvisor's reference bit-for-bit.
|
||||
func TestChecksumMatchesGvisor(t *testing.T) {
|
||||
rng := rand.New(rand.NewPCG(1, 2))
|
||||
const padFront = 16
|
||||
|
||||
// Random pool large enough for the longest case + alignment slop.
|
||||
pool := make([]byte, 4096+padFront)
|
||||
for i := range pool {
|
||||
pool[i] = byte(rng.Uint32())
|
||||
}
|
||||
|
||||
seeds := []uint16{0, 0x0001, 0xabcd, 0xffff, 0x1234, 0xfedc}
|
||||
offsets := []int{0, 1, 2, 3, 4, 5, 7, 8, 15, 16}
|
||||
|
||||
for length := 0; length <= 4096; length++ {
|
||||
for _, seed := range seeds {
|
||||
for _, off := range offsets {
|
||||
if off+length > len(pool) {
|
||||
continue
|
||||
}
|
||||
buf := pool[off : off+length]
|
||||
want := gvisorchecksum.Checksum(buf, seed)
|
||||
got := Checksum(buf, seed)
|
||||
if got != want {
|
||||
t.Fatalf("len=%d off=%d seed=%#x: got %#04x want %#04x",
|
||||
length, off, seed, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestChecksumPatternedBuffers exercises specific byte patterns that have
|
||||
// historically tripped up checksum implementations: all-zero, all-0xff,
|
||||
// alternating, and ascending sequences.
|
||||
func TestChecksumPatternedBuffers(t *testing.T) {
|
||||
for length := 0; length <= 256; length++ {
|
||||
patterns := map[string][]byte{
|
||||
"zeros": make([]byte, length),
|
||||
"ones": bytes(length, 0xff),
|
||||
"alternating": pattern(length, []byte{0xa5, 0x5a}),
|
||||
"ascending": ascending(length),
|
||||
}
|
||||
for name, buf := range patterns {
|
||||
for _, seed := range []uint16{0, 0xffff, 0x8000} {
|
||||
want := gvisorchecksum.Checksum(buf, seed)
|
||||
got := Checksum(buf, seed)
|
||||
if got != want {
|
||||
t.Fatalf("%s len=%d seed=%#x: got %#04x want %#04x",
|
||||
name, length, seed, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func bytes(n int, v byte) []byte {
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = v
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func pattern(n int, p []byte) []byte {
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = p[i%len(p)]
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func ascending(n int) []byte {
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = byte(i)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// TestChecksumTailPaths targets every combination of (SIMD body iterations,
|
||||
// trailing tail bytes) the asm handlers walk through. The tail handlers
|
||||
// peel off 8 → 4 → 2 → 1 byte chunks in turn; this test exercises each by
|
||||
// constructing lengths of the form 64*k + tail for tail ∈ [0, 63] and a
|
||||
// representative spread of k values, including k=0 (no main loop, all tail)
|
||||
// and k=1 (one main loop iter, then tail). It's explicit coverage for
|
||||
// payload sizes that are odd, not divisible by 4, by 8, or by 32.
|
||||
func TestChecksumTailPaths(t *testing.T) {
|
||||
rng := rand.New(rand.NewPCG(42, 17))
|
||||
const padFront = 16
|
||||
const maxK = 8
|
||||
|
||||
pool := make([]byte, 64*maxK+padFront+64)
|
||||
for i := range pool {
|
||||
pool[i] = byte(rng.Uint32())
|
||||
}
|
||||
|
||||
seeds := []uint16{0, 0xffff, 0xabcd}
|
||||
offsets := []int{0, 1, 3, 7, 15} // mix of aligned and odd starts
|
||||
|
||||
for k := 0; k <= maxK; k++ {
|
||||
for tail := 0; tail < 64; tail++ {
|
||||
length := 64*k + tail
|
||||
for _, seed := range seeds {
|
||||
for _, off := range offsets {
|
||||
if off+length > len(pool) {
|
||||
continue
|
||||
}
|
||||
buf := pool[off : off+length]
|
||||
want := gvisorchecksum.Checksum(buf, seed)
|
||||
got := Checksum(buf, seed)
|
||||
if got != want {
|
||||
t.Fatalf("k=%d tail=%d (len=%d) off=%d seed=%#x: got %#04x want %#04x",
|
||||
k, tail, length, off, seed, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkChecksumTailSizes covers payload sizes that aren't clean multiples
|
||||
// of the SIMD body's 32-byte (amd64) or 16-byte (arm64) chunks, so the tail
|
||||
// handler is meaningfully on the hot path. Sizes are picked to either exercise
|
||||
// every tail branch (tiny lengths) or sit slightly off realistic packet
|
||||
// boundaries (e.g. 1499 = MTU − 1).
|
||||
func BenchmarkChecksumTailSizes(b *testing.B) {
|
||||
sizes := []int{
|
||||
1, 3, 7, 15, 31, // sub-SIMD; entire work is scalar tail
|
||||
33, 35, 47, 63, // one loop32 + assorted tails
|
||||
65, 95, 127, // one loop64 + assorted tails
|
||||
1447, 1471, 1499, 1501, // around MTU
|
||||
8191, 8193, // around USO
|
||||
65531, 65533, // near the kernel max
|
||||
}
|
||||
for _, size := range sizes {
|
||||
buf := make([]byte, size)
|
||||
for i := range buf {
|
||||
buf[i] = byte(i)
|
||||
}
|
||||
b.Run(fmt.Sprintf("size=%d/local", size), func(b *testing.B) {
|
||||
b.SetBytes(int64(size))
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = Checksum(buf, 0)
|
||||
}
|
||||
})
|
||||
b.Run(fmt.Sprintf("size=%d/gvisor", size), func(b *testing.B) {
|
||||
b.SetBytes(int64(size))
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = gvisorchecksum.Checksum(buf, 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkChecksum compares the local Checksum to gvisor's at sizes that
|
||||
// match real traffic: a TCP/IP header (60), a typical MSS (1448), a typical
|
||||
// USO size (8192), and the kernel's max GSO superpacket (65535).
|
||||
func BenchmarkChecksum(b *testing.B) {
|
||||
for _, size := range []int{60, 1448, 8192, 65535} {
|
||||
buf := make([]byte, size)
|
||||
for i := range buf {
|
||||
buf[i] = byte(i)
|
||||
}
|
||||
b.Run(fmt.Sprintf("size=%d/local", size), func(b *testing.B) {
|
||||
b.SetBytes(int64(size))
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = Checksum(buf, 0)
|
||||
}
|
||||
})
|
||||
b.Run(fmt.Sprintf("size=%d/gvisor", size), func(b *testing.B) {
|
||||
b.SetBytes(int64(size))
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = gvisorchecksum.Checksum(buf, 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+2
-8
@@ -4,21 +4,15 @@ import (
|
||||
"io"
|
||||
"net/netip"
|
||||
|
||||
"github.com/slackhq/nebula/overlay/tio"
|
||||
"github.com/slackhq/nebula/routing"
|
||||
)
|
||||
|
||||
// defaultBatchBufSize is the per-Queue scratch size for Read on backends
|
||||
// that don't do TSO segmentation. 65535 covers any single IP packet.
|
||||
const defaultBatchBufSize = 65535
|
||||
|
||||
type Device interface {
|
||||
io.Closer
|
||||
io.ReadWriteCloser
|
||||
Activate() error
|
||||
Networks() []netip.Prefix
|
||||
Name() string
|
||||
RoutesFor(netip.Addr) routing.Gateways
|
||||
SupportsMultiqueue() bool
|
||||
NewMultiQueueReader() error
|
||||
Readers() []tio.Queue
|
||||
NewMultiQueueReader() (io.ReadWriteCloser, error)
|
||||
}
|
||||
|
||||
@@ -4,11 +4,10 @@ package overlaytest
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/netip"
|
||||
|
||||
"github.com/slackhq/nebula/overlay/tio"
|
||||
"github.com/slackhq/nebula/routing"
|
||||
"github.com/slackhq/nebula/wire"
|
||||
)
|
||||
|
||||
// NoopTun is an overlay.Device that silently discards every read and write.
|
||||
@@ -16,10 +15,6 @@ import (
|
||||
// exercise the datapath.
|
||||
type NoopTun struct{}
|
||||
|
||||
func (NoopTun) Capabilities() tio.Capabilities {
|
||||
return tio.Capabilities{}
|
||||
}
|
||||
|
||||
func (NoopTun) RoutesFor(addr netip.Addr) routing.Gateways {
|
||||
return routing.Gateways{}
|
||||
}
|
||||
@@ -36,7 +31,7 @@ func (NoopTun) Name() string {
|
||||
return "noop"
|
||||
}
|
||||
|
||||
func (NoopTun) Read(p []wire.TunPacket, mem []byte) (int, error) {
|
||||
func (NoopTun) Read([]byte) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -48,12 +43,8 @@ func (NoopTun) SupportsMultiqueue() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (NoopTun) NewMultiQueueReader() error {
|
||||
return errors.New("unsupported")
|
||||
}
|
||||
|
||||
func (NoopTun) Readers() []tio.Queue {
|
||||
return []tio.Queue{NoopTun{}}
|
||||
func (NoopTun) NewMultiQueueReader() (io.ReadWriteCloser, error) {
|
||||
return nil, errors.New("unsupported")
|
||||
}
|
||||
|
||||
func (NoopTun) Close() error {
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
package tio
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
type offloadQueueSet struct {
|
||||
pq []*Offload
|
||||
// pqi is exactly the same as pq, but stored as the interface type
|
||||
pqi []Queue
|
||||
shutdownFd int
|
||||
// usoEnabled is true when newTun successfully negotiated TUN_F_USO4|6
|
||||
// with the kernel. Queues created by Add inherit this and surface it
|
||||
// via Offload.USOSupported so coalescers can gate USO emission.
|
||||
usoEnabled bool
|
||||
}
|
||||
|
||||
// NewOffloadQueueSet creates a QueueSet that uses virtio_net_hdr to do
|
||||
// TSO segmentation in userspace. usoEnabled tells downstream queues whether
|
||||
// the kernel agreed to deliver/accept GSO_UDP_L4 superpackets — coalescers
|
||||
// should fall back to per-packet writes when this is false.
|
||||
func NewOffloadQueueSet(usoEnabled bool) (QueueSet, error) {
|
||||
shutdownFd, err := unix.Eventfd(0, unix.EFD_NONBLOCK|unix.EFD_CLOEXEC)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create eventfd: %w", err)
|
||||
}
|
||||
|
||||
out := &offloadQueueSet{
|
||||
pq: []*Offload{},
|
||||
pqi: []Queue{},
|
||||
shutdownFd: shutdownFd,
|
||||
usoEnabled: usoEnabled,
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *offloadQueueSet) Queues() []Queue {
|
||||
return c.pqi
|
||||
}
|
||||
|
||||
func (c *offloadQueueSet) Add(fd int) error {
|
||||
x, err := newOffload(fd, c.shutdownFd, c.usoEnabled)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.pq = append(c.pq, x)
|
||||
c.pqi = append(c.pqi, x)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *offloadQueueSet) wakeForShutdown() error {
|
||||
var buf [8]byte
|
||||
binary.NativeEndian.PutUint64(buf[:], 1)
|
||||
_, err := unix.Write(c.shutdownFd, buf[:])
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *offloadQueueSet) Close() error {
|
||||
if c.shutdownFd < 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
errs := []error{}
|
||||
|
||||
// Signal all readers blocked in poll to wake up and exit
|
||||
if err := c.wakeForShutdown(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
for _, x := range c.pq {
|
||||
if err := x.Close(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
// All Offloads reference shutdownFd in their pollfd arrays, so close it
|
||||
// only after every Offload.Close has returned.
|
||||
if err := unix.Close(c.shutdownFd); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
c.shutdownFd = -1
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package tio
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
type pollQueueSet struct {
|
||||
pq []*Poll
|
||||
// pqi is exactly the same as pq, but stored as the interface type
|
||||
pqi []Queue
|
||||
shutdownFd int
|
||||
}
|
||||
|
||||
func NewPollQueueSet() (QueueSet, error) {
|
||||
shutdownFd, err := unix.Eventfd(0, unix.EFD_NONBLOCK|unix.EFD_CLOEXEC)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create eventfd: %w", err)
|
||||
}
|
||||
|
||||
out := &pollQueueSet{
|
||||
pq: []*Poll{},
|
||||
pqi: []Queue{},
|
||||
shutdownFd: shutdownFd,
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *pollQueueSet) Queues() []Queue {
|
||||
return c.pqi
|
||||
}
|
||||
|
||||
func (c *pollQueueSet) Add(fd int) error {
|
||||
x, err := newPoll(fd, c.shutdownFd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.pq = append(c.pq, x)
|
||||
c.pqi = append(c.pqi, x)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *pollQueueSet) wakeForShutdown() error {
|
||||
var buf [8]byte
|
||||
binary.NativeEndian.PutUint64(buf[:], 1)
|
||||
_, err := unix.Write(c.shutdownFd, buf[:])
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *pollQueueSet) Close() error {
|
||||
if c.shutdownFd < 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
errs := []error{}
|
||||
|
||||
if err := c.wakeForShutdown(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
for _, x := range c.pq {
|
||||
if err := x.Close(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
// All Polls reference shutdownFd in their pollfd arrays, so close it
|
||||
// only after every Poll.Close has returned.
|
||||
if err := unix.Close(c.shutdownFd); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
c.shutdownFd = -1
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
//go:build linux && !android && !e2e_testing
|
||||
|
||||
package tio
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/slackhq/nebula/wire"
|
||||
)
|
||||
|
||||
// fakeBatch stands in for batch.TxBatcher inside the bench — same shape
|
||||
// of pointer-capturing closure that sendInsideMessage builds.
|
||||
type fakeBatch struct{ buf [65536]byte }
|
||||
|
||||
func (b *fakeBatch) Reserve(sz int) []byte { return b.buf[:sz] }
|
||||
func (b *fakeBatch) Commit([]byte) {}
|
||||
|
||||
type fakeHostInfo struct {
|
||||
remoteIndexId uint32
|
||||
counter uint64
|
||||
}
|
||||
type fakeIface struct {
|
||||
rebindCount uint8
|
||||
hi *fakeHostInfo
|
||||
}
|
||||
|
||||
// BenchmarkSegmentSuperpacketAllocsTSO measures allocation per
|
||||
// PerSegment call when a closure captures pointer-bearing receivers — the
|
||||
// realistic shape of sendInsideMessage's closure.
|
||||
func BenchmarkSegmentSuperpacketAllocsTSO(b *testing.B) {
|
||||
const mss = 1400
|
||||
const numSeg = 32
|
||||
pkt := buildTSOv6(mss*numSeg, mss)
|
||||
gso := wire.GSOInfo{
|
||||
Size: mss,
|
||||
HdrLen: 60, // 40 (IPv6) + 20 (TCP)
|
||||
CsumStart: 40,
|
||||
Proto: wire.GSOProtoTCP,
|
||||
}
|
||||
p := wire.TunPacket{Bytes: pkt, Meta: gso}
|
||||
|
||||
hi := &fakeHostInfo{remoteIndexId: 0xdeadbeef}
|
||||
f := &fakeIface{rebindCount: 7, hi: hi}
|
||||
fb := &fakeBatch{}
|
||||
|
||||
// PerSegment consumes pkt destructively; refresh from a master copy
|
||||
// each iter (matches the production pattern where every TUN read hands
|
||||
// the segmenter a fresh kernel-supplied buffer).
|
||||
master := append([]byte(nil), pkt...)
|
||||
work := make([]byte, len(pkt))
|
||||
p.Bytes = work
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
copy(work, master)
|
||||
err := p.PerSegment(func(seg []byte) error {
|
||||
out := fb.Reserve(16 + len(seg) + 16)
|
||||
out[0] = byte(f.rebindCount)
|
||||
out[1] = byte(hi.counter)
|
||||
hi.counter++
|
||||
fb.Commit(out)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
b.Fatalf("PerSegment: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package tio
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/slackhq/nebula/wire"
|
||||
)
|
||||
|
||||
// QueueSet holds one or many Queue objects and helps close them in an orderly way.
|
||||
type QueueSet interface {
|
||||
io.Closer
|
||||
Queues() []Queue
|
||||
|
||||
// Add takes a tun fd, adds it to the set, and prepares it for use as a Queue.
|
||||
Add(fd int) error
|
||||
}
|
||||
|
||||
// Capabilities advertises which kernel offload features a Queue successfully negotiated.
|
||||
// Callers consult this to decide which coalescers to wire onto the write path.
|
||||
type Capabilities struct {
|
||||
// TSO means the FD was opened with IFF_VNET_HDR and the kernel agreed
|
||||
// to TUN_F_TSO4|TSO6 — i.e. WriteGSO with GSOProtoTCP is safe.
|
||||
TSO bool
|
||||
// USO means the kernel additionally agreed to TUN_F_USO4|USO6, so
|
||||
// WriteGSO with GSOProtoUDP is safe. Linux ≥ 6.2.
|
||||
USO bool
|
||||
}
|
||||
|
||||
// Queue is a readable/writable Poll queue. One Queue is driven by a single
|
||||
// read goroutine plus a single writer (see Write below).
|
||||
type Queue interface {
|
||||
io.Closer
|
||||
|
||||
// Read will read at least 1 packet from the tun (up to len(p)).
|
||||
// mem will be used to provide the backing for each of p[n].Bytes.
|
||||
// Callers should size mem and p to avoid exhausting mem before p.
|
||||
// Returns the number of packets actually read, or error.
|
||||
Read(p []wire.TunPacket, mem []byte) (int, error)
|
||||
|
||||
// Write emits a single packet on the plaintext (outside→inside)
|
||||
// delivery path.
|
||||
Write(p []byte) (int, error)
|
||||
|
||||
// Capabilities returns the Queue's negotiated offload capabilities,
|
||||
// or the zero value when q does not advertise any.
|
||||
Capabilities() Capabilities
|
||||
}
|
||||
|
||||
// GSOWriter is implemented by Queues that can emit a TCP or UDP superpacket
|
||||
// assembled from a header prefix plus one or more borrowed payload
|
||||
// fragments, in a single vectored write (writev with a leading
|
||||
// virtio_net_hdr). This lets the coalescer avoid copying payload bytes
|
||||
// between the caller's decrypt buffer and the TUN. Backends without GSO
|
||||
// support do not implement this interface and coalescing is skipped.
|
||||
//
|
||||
// hdr contains the IPv4/IPv6 header prefix (mutable - callers will have
|
||||
// filled in total length and IP csum). transportHdr is the TCP or UDP
|
||||
// header (mutable - the L4 checksum field must hold the pseudo-header
|
||||
// partial, single-fold not inverted, per virtio NEEDS_CSUM semantics).
|
||||
// pays are non-overlapping payload fragments whose concatenation is the
|
||||
// full superpacket payload; they are read-only from the writer's
|
||||
// perspective and must remain valid until the call returns. Every segment
|
||||
// in pays except possibly the last is exactly the same size. proto picks
|
||||
// the L4 protocol so the writer knows which GSOType / CsumOffset to set.
|
||||
//
|
||||
// Callers should also consult Queue.Capabilities (via SupportsGSO) for
|
||||
// the per-protocol negotiated capability; an implementation of GSOWriter
|
||||
// is necessary but not sufficient since USO may not have been negotiated
|
||||
// even when TSO was.
|
||||
type GSOWriter interface {
|
||||
WriteGSO(hdr []byte, transportHdr []byte, pays [][]byte, proto wire.GSOProto) error
|
||||
}
|
||||
|
||||
// SupportsGSO reports whether w implements GSOWriter and the underlying
|
||||
// queue advertises the negotiated capability for `want` via Capabilities.
|
||||
func SupportsGSO(w Queue, want wire.GSOProto) (GSOWriter, bool) {
|
||||
gw, ok := w.(GSOWriter)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
caps := w.Capabilities()
|
||||
switch want {
|
||||
case wire.GSOProtoTCP:
|
||||
return gw, caps.TSO
|
||||
case wire.GSOProtoUDP:
|
||||
return gw, caps.USO
|
||||
default:
|
||||
return gw, false
|
||||
}
|
||||
}
|
||||
@@ -1,447 +0,0 @@
|
||||
package tio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"github.com/slackhq/nebula/wire"
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"github.com/slackhq/nebula/overlay/tio/virtio"
|
||||
)
|
||||
|
||||
// tunRxBufSize is the per-Read worst-case footprint for one kernel-supplied
|
||||
// packet body, which is at most ~64 KiB (tunReadBufSize). Segmentation
|
||||
// happens at encrypt time via wire.TunPacket.PerSegment on a per-routine
|
||||
// MTU-sized scratch, so the caller-supplied read buffer only holds raw
|
||||
// kernel-supplied bytes. Used by Read's drain loop to gate further reads
|
||||
// on whether the remaining buffer can still hold one worst-case packet.
|
||||
const tunRxBufSize = 64 * 1024
|
||||
|
||||
// gsoMaxIovs caps the iovec budget WriteGSO assembles per call: 3 fixed
|
||||
// entries (virtio_net_hdr, IP hdr, transport hdr) plus up to gsoMaxIovs-3
|
||||
// payload fragments. Sized comfortably above the typical kernel GSO
|
||||
// segment cap (Linux UDP_GRO is 64) so realistic coalesced bursts never
|
||||
// touch the limit. iovecs are tiny (16 bytes), so the entire scratch is
|
||||
// 4 KiB — fine to keep resident on every queue. WriteGSO returns an error
|
||||
// rather than reallocating when a caller exceeds this budget.
|
||||
const gsoMaxIovs = 256
|
||||
|
||||
// validVnetHdr is the 10-byte virtio_net_hdr we prepend to every non-GSO TUN
|
||||
// write. Only flag set is VIRTIO_NET_HDR_F_DATA_VALID, which marks the skb
|
||||
// CHECKSUM_UNNECESSARY so the receiving network stack skips L4 checksum
|
||||
// verification. All packets that reach the plain Write paths already carry
|
||||
// a valid L4 checksum (either supplied by a remote peer whose ciphertext we
|
||||
// AEAD-authenticated, produced by virtio.SegmentTCP/SegmentUDP during
|
||||
// superpacket segmentation, or built locally by CreateRejectPacket), so
|
||||
// trusting them is safe.
|
||||
var validVnetHdr = [virtio.Size]byte{unix.VIRTIO_NET_HDR_F_DATA_VALID}
|
||||
|
||||
// Offload wraps a TUN file descriptor with poll-based reads. The FD provided will be changed to non-blocking.
|
||||
// A shared eventfd allows Close to wake all readers blocked in poll.
|
||||
type Offload struct {
|
||||
fd int
|
||||
shutdownFd int
|
||||
readPoll [2]unix.PollFd
|
||||
writePoll [2]unix.PollFd
|
||||
// writeLock serializes blockOnWrite's read+clear of writePoll[*].Revents.
|
||||
// Any goroutine that calls Write may end up parked in poll(2); without
|
||||
// the lock concurrent waiters could race the Revents reset and lose
|
||||
// events.
|
||||
writeLock sync.Mutex
|
||||
closed atomic.Bool
|
||||
|
||||
// readVnetScratch holds the 10-byte virtio_net_hdr split off the front of
|
||||
// every TUN read via readv(2). Decoupling the header from the packet body
|
||||
// lets us read the body directly into the caller-supplied mem at the
|
||||
// current rxOff with no userspace copy on the GSO_NONE fast path.
|
||||
readVnetScratch [virtio.Size]byte
|
||||
// readIovs is the readv(2) iovec scratch wired once at construction —
|
||||
// iovec[0] points at readVnetScratch; iovec[1].Base/Len is updated per
|
||||
// read to address the caller-supplied mem slot.
|
||||
readIovs [2]unix.Iovec
|
||||
|
||||
// usoEnabled records whether the kernel agreed to TUN_F_USO* on this FD,
|
||||
// so writers can decide whether emitting GSO_UDP_L4 superpackets is safe.
|
||||
usoEnabled bool
|
||||
|
||||
// gsoHdrBuf is a per-queue 10-byte scratch for the virtio_net_hdr emitted
|
||||
// by WriteGSO. Kept separate from the read-only package-level validVnetHdr
|
||||
// so non-GSO Writes can ship that constant directly while WriteGSO
|
||||
// rewrites this scratch on every call.
|
||||
gsoHdrBuf [virtio.Size]byte
|
||||
// gsoIovs is the writev iovec scratch for WriteGSO. Pre-sized to
|
||||
// gsoMaxIovs at construction; never grown. WriteGSO returns an error
|
||||
// (and drops the call) if a caller hands it more fragments than fit.
|
||||
gsoIovs []unix.Iovec
|
||||
}
|
||||
|
||||
func newOffload(fd int, shutdownFd int, usoEnabled bool) (*Offload, error) {
|
||||
if err := unix.SetNonblock(fd, true); err != nil {
|
||||
return nil, fmt.Errorf("failed to set tun fd non-blocking: %w", err)
|
||||
}
|
||||
|
||||
out := &Offload{
|
||||
fd: fd,
|
||||
shutdownFd: shutdownFd,
|
||||
usoEnabled: usoEnabled,
|
||||
closed: atomic.Bool{},
|
||||
readPoll: [2]unix.PollFd{
|
||||
{Fd: int32(fd), Events: unix.POLLIN},
|
||||
{Fd: int32(shutdownFd), Events: unix.POLLIN},
|
||||
},
|
||||
writePoll: [2]unix.PollFd{
|
||||
{Fd: int32(fd), Events: unix.POLLOUT},
|
||||
{Fd: int32(shutdownFd), Events: unix.POLLIN},
|
||||
},
|
||||
writeLock: sync.Mutex{},
|
||||
gsoIovs: make([]unix.Iovec, 2, gsoMaxIovs),
|
||||
}
|
||||
|
||||
out.gsoIovs[0].Base = &out.gsoHdrBuf[0]
|
||||
out.gsoIovs[0].SetLen(virtio.Size)
|
||||
|
||||
// readIovs[0] is wired once to the virtio_net_hdr scratch; per-read we
|
||||
// only repoint readIovs[1] at the next caller-supplied mem slot
|
||||
// (see readPacket).
|
||||
out.readIovs[0].Base = &out.readVnetScratch[0]
|
||||
out.readIovs[0].SetLen(virtio.Size)
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Offload) blockOnRead() error {
|
||||
const problemFlags = unix.POLLHUP | unix.POLLNVAL | unix.POLLERR
|
||||
var err error
|
||||
for {
|
||||
_, err = unix.Poll(r.readPoll[:], -1)
|
||||
if err != unix.EINTR {
|
||||
break
|
||||
}
|
||||
}
|
||||
//always reset these!
|
||||
tunEvents := r.readPoll[0].Revents
|
||||
shutdownEvents := r.readPoll[1].Revents
|
||||
r.readPoll[0].Revents = 0
|
||||
r.readPoll[1].Revents = 0
|
||||
//do the err check before trusting the potentially bogus bits we just got
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if shutdownEvents&(unix.POLLIN|problemFlags) != 0 {
|
||||
return os.ErrClosed
|
||||
} else if tunEvents&problemFlags != 0 {
|
||||
return os.ErrClosed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Offload) blockOnWrite() error {
|
||||
const problemFlags = unix.POLLHUP | unix.POLLNVAL | unix.POLLERR
|
||||
var err error
|
||||
for {
|
||||
_, err = unix.Poll(r.writePoll[:], -1)
|
||||
if err != unix.EINTR {
|
||||
break
|
||||
}
|
||||
}
|
||||
//always reset these!
|
||||
r.writeLock.Lock()
|
||||
tunEvents := r.writePoll[0].Revents
|
||||
shutdownEvents := r.writePoll[1].Revents
|
||||
r.writePoll[0].Revents = 0
|
||||
r.writePoll[1].Revents = 0
|
||||
r.writeLock.Unlock()
|
||||
//do the err check before trusting the potentially bogus bits we just got
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if shutdownEvents&(unix.POLLIN|problemFlags) != 0 {
|
||||
return os.ErrClosed
|
||||
} else if tunEvents&problemFlags != 0 {
|
||||
return os.ErrClosed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readPacket issues a single readv(2) splitting the virtio_net_hdr off
|
||||
// into readVnetScratch and reading the packet body directly into mem.
|
||||
// Returns the body length (zero virtio header bytes, just the IP
|
||||
// packet/superpacket). block controls whether EAGAIN is retried via poll:
|
||||
// the initial read of a drain blocks; subsequent drain reads do not.
|
||||
//
|
||||
// The body iovec capacity is always tunReadBufSize; the Read drain loop
|
||||
// gates entry on len(mem)-rxOff >= tunRxBufSize, sized to hold one
|
||||
// worst-case kernel-supplied packet body. Without that gate the body
|
||||
// iovec could be smaller than the next inbound packet and the kernel
|
||||
// would truncate.
|
||||
func (r *Offload) readPacket(mem []byte, block bool) (int, error) {
|
||||
for {
|
||||
r.readIovs[1].Base = &mem[0]
|
||||
r.readIovs[1].SetLen(tunReadBufSize)
|
||||
n, _, errno := syscall.Syscall(unix.SYS_READV, uintptr(r.fd), uintptr(unsafe.Pointer(&r.readIovs[0])), uintptr(len(r.readIovs)))
|
||||
if errno == 0 {
|
||||
if int(n) < virtio.Size {
|
||||
return 0, io.ErrShortWrite
|
||||
}
|
||||
return int(n) - virtio.Size, nil
|
||||
}
|
||||
if errno == unix.EAGAIN {
|
||||
if !block {
|
||||
return 0, errno
|
||||
}
|
||||
if err := r.blockOnRead(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if errno == unix.EINTR {
|
||||
continue
|
||||
}
|
||||
if errno == unix.EBADF {
|
||||
return 0, os.ErrClosed
|
||||
}
|
||||
return 0, errno
|
||||
}
|
||||
}
|
||||
|
||||
// Read returns one or more packets from the tun. Each wire.TunPacket
|
||||
// either carries a single ready-to-use IP datagram (GSO zero) or a TSO/USO
|
||||
// superpacket plus the wire.GSOInfo a caller needs to segment it (see
|
||||
// wire.TunPacket.PerSegment). The first read blocks via poll; once the fd
|
||||
// is known readable we drain additional packets non-blocking until the
|
||||
// kernel queue is empty (EAGAIN), p is full, or mem no longer has room
|
||||
// for another worst-case packet (tunRxBufSize). This amortizes the poll
|
||||
// wake over bursts of small packets (e.g. TCP ACKs). The Bytes slices on
|
||||
// returned packets point into the caller-supplied mem and are only valid
|
||||
// until the next Read or Close on this Queue.
|
||||
func (r *Offload) Read(p []wire.TunPacket, mem []byte) (int, error) {
|
||||
maxP := len(p)
|
||||
maxM := len(mem)
|
||||
p = p[:0]
|
||||
rxOff := 0
|
||||
|
||||
// Initial (blocking) read. Retry on decode errors so a single bad
|
||||
// packet does not stall the reader.
|
||||
for {
|
||||
n, err := r.readPacket(mem, true)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if p, err = r.decodeRead(p, mem, n); err != nil {
|
||||
// Drop and read again — a bad packet should not kill the reader.
|
||||
continue
|
||||
}
|
||||
|
||||
rxOff += n
|
||||
break
|
||||
}
|
||||
|
||||
// Drain: non-blocking reads until the kernel queue is empty, p is full,
|
||||
// or mem no longer has room for another worst-case kernel-supplied
|
||||
// packet (tunRxBufSize).
|
||||
for len(p) < maxP && maxM-rxOff >= tunRxBufSize {
|
||||
n, err := r.readPacket(mem[rxOff:], false)
|
||||
if err != nil {
|
||||
// EAGAIN / EINTR / anything else: stop draining. We already
|
||||
// have a valid batch from the first read.
|
||||
break
|
||||
}
|
||||
if n <= 0 {
|
||||
break
|
||||
}
|
||||
if p, err = r.decodeRead(p, mem[rxOff:], n); err != nil {
|
||||
// Drop this packet and stop the drain; we'd rather hand off
|
||||
// what we have than keep spinning here.
|
||||
break
|
||||
}
|
||||
rxOff += n
|
||||
}
|
||||
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// decodeRead processes the packet sitting at mem[:pktLen]. The bytes stay
|
||||
// in mem — for GSO_NONE we slice them as a regular IP datagram (running
|
||||
// finishChecksum if NEEDS_CSUM is set); for TSO/USO superpackets we attach
|
||||
// the corrected GSO metadata so the caller can segment lazily at encrypt
|
||||
// time. The caller advances its own rxOff past the kernel-supplied body
|
||||
// and nothing else, since segmentation no longer writes back into mem.
|
||||
func (r *Offload) decodeRead(p []wire.TunPacket, mem []byte, pktLen int) ([]wire.TunPacket, error) {
|
||||
if pktLen <= 0 {
|
||||
return p, fmt.Errorf("short tun read: %d", pktLen)
|
||||
}
|
||||
var hdr virtio.Hdr
|
||||
hdr.Decode(r.readVnetScratch[:])
|
||||
|
||||
body := mem[:pktLen]
|
||||
|
||||
if hdr.GSOType == unix.VIRTIO_NET_HDR_GSO_NONE {
|
||||
if hdr.Flags&unix.VIRTIO_NET_HDR_F_NEEDS_CSUM != 0 {
|
||||
if err := virtio.FinishChecksum(body, hdr); err != nil {
|
||||
return p, err
|
||||
}
|
||||
}
|
||||
p = append(p, wire.TunPacket{Bytes: body})
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// GSO superpacket: validate, fix the kernel-supplied HdrLen on the
|
||||
// FORWARD path (CorrectHdrLen), pick the L4 protocol, and attach
|
||||
// the metadata. The bytes stay in mem untouched; segmentation
|
||||
// happens in wire.TunPacket.PerSegment at encrypt time.
|
||||
if err := virtio.CheckValid(body, hdr); err != nil {
|
||||
return p, err
|
||||
}
|
||||
if err := virtio.CorrectHdrLen(body, &hdr); err != nil {
|
||||
return p, err
|
||||
}
|
||||
proto, err := protoFromGSOType(hdr.GSOType)
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
p = append(p, wire.TunPacket{
|
||||
Bytes: body,
|
||||
Meta: wire.GSOInfo{
|
||||
Size: hdr.GSOSize,
|
||||
HdrLen: hdr.HdrLen,
|
||||
CsumStart: hdr.CsumStart,
|
||||
Proto: proto,
|
||||
},
|
||||
})
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (r *Offload) Write(buf []byte) (int, error) {
|
||||
iovs := [2]unix.Iovec{
|
||||
{Base: &validVnetHdr[0]},
|
||||
{Base: &buf[0]},
|
||||
}
|
||||
iovs[0].SetLen(virtio.Size)
|
||||
iovs[1].SetLen(len(buf))
|
||||
return r.writeWithScratch(buf, &iovs)
|
||||
}
|
||||
|
||||
func (r *Offload) writeWithScratch(buf []byte, iovs *[2]unix.Iovec) (int, error) {
|
||||
if len(buf) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
iovs[1].Base = &buf[0]
|
||||
iovs[1].SetLen(len(buf))
|
||||
return r.rawWrite(unsafe.Slice(&iovs[0], len(iovs)))
|
||||
}
|
||||
|
||||
func (r *Offload) rawWrite(iovs []unix.Iovec) (int, error) {
|
||||
for {
|
||||
n, _, errno := syscall.Syscall(unix.SYS_WRITEV, uintptr(r.fd), uintptr(unsafe.Pointer(&iovs[0])), uintptr(len(iovs)))
|
||||
if errno == 0 {
|
||||
if int(n) < virtio.Size {
|
||||
return 0, io.ErrShortWrite
|
||||
}
|
||||
return int(n) - virtio.Size, nil
|
||||
}
|
||||
if errno == unix.EAGAIN {
|
||||
if err := r.blockOnWrite(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if errno == unix.EINTR {
|
||||
continue
|
||||
}
|
||||
if errno == unix.EBADF {
|
||||
return 0, os.ErrClosed
|
||||
}
|
||||
return 0, errno
|
||||
}
|
||||
}
|
||||
|
||||
// Capabilities reports the offload features negotiated for this Queue. TSO
|
||||
// is always true for Offload (we only construct it on IFF_VNET_HDR FDs);
|
||||
// USO is true only when the kernel agreed to TUN_F_USO4|6 at open time
|
||||
// (Linux ≥ 6.2).
|
||||
func (r *Offload) Capabilities() Capabilities {
|
||||
return Capabilities{TSO: true, USO: r.usoEnabled}
|
||||
}
|
||||
|
||||
func (r *Offload) WriteGSO(hdr []byte, transportHdr []byte, pays [][]byte, proto wire.GSOProto) error {
|
||||
if len(hdr) == 0 || len(pays) == 0 || len(transportHdr) == 0 {
|
||||
return nil
|
||||
}
|
||||
// L4 checksum offset inside transportHdr: TCP=16 (the `check` field after
|
||||
// seq/ack/dataoff/flags/window), UDP=6 (after sport/dport/length).
|
||||
var csumOff uint16
|
||||
switch proto {
|
||||
case wire.GSOProtoUDP:
|
||||
csumOff = 6
|
||||
default:
|
||||
csumOff = 16
|
||||
}
|
||||
vhdr := virtio.Hdr{
|
||||
Flags: unix.VIRTIO_NET_HDR_F_NEEDS_CSUM,
|
||||
HdrLen: uint16(len(hdr) + len(transportHdr)),
|
||||
GSOSize: uint16(len(pays[0])),
|
||||
CsumStart: uint16(len(hdr)),
|
||||
CsumOffset: csumOff,
|
||||
}
|
||||
if len(pays) > 1 {
|
||||
ipVer := hdr[0] >> 4
|
||||
switch {
|
||||
case proto == wire.GSOProtoUDP && (ipVer == 4 || ipVer == 6):
|
||||
vhdr.GSOType = unix.VIRTIO_NET_HDR_GSO_UDP_L4
|
||||
case ipVer == 6:
|
||||
vhdr.GSOType = unix.VIRTIO_NET_HDR_GSO_TCPV6
|
||||
case ipVer == 4:
|
||||
vhdr.GSOType = unix.VIRTIO_NET_HDR_GSO_TCPV4
|
||||
default:
|
||||
vhdr.GSOType = unix.VIRTIO_NET_HDR_GSO_NONE
|
||||
vhdr.GSOSize = 0
|
||||
}
|
||||
} else {
|
||||
vhdr.GSOType = unix.VIRTIO_NET_HDR_GSO_NONE
|
||||
vhdr.GSOSize = 0
|
||||
}
|
||||
vhdr.Encode(r.gsoHdrBuf[:])
|
||||
|
||||
// Build the iovec array: [virtio_hdr, hdr, transportHdr, pays...]. r.gsoIovs[0] is
|
||||
// wired to gsoHdrBuf at construction and never changes.
|
||||
need := 3 + len(pays)
|
||||
if need > cap(r.gsoIovs) {
|
||||
slog.Default().Warn("tio: WriteGSO iovec budget exceeded; dropping superpacket",
|
||||
"need", need, "cap", cap(r.gsoIovs), "segments", len(pays))
|
||||
return fmt.Errorf("tio: WriteGSO needs %d iovecs but cap is %d", need, cap(r.gsoIovs))
|
||||
}
|
||||
r.gsoIovs = r.gsoIovs[:need]
|
||||
r.gsoIovs[1].Base = &hdr[0]
|
||||
r.gsoIovs[1].SetLen(len(hdr))
|
||||
r.gsoIovs[2].Base = &transportHdr[0]
|
||||
r.gsoIovs[2].SetLen(len(transportHdr))
|
||||
for i, p := range pays {
|
||||
r.gsoIovs[3+i].Base = &p[0]
|
||||
r.gsoIovs[3+i].SetLen(len(p))
|
||||
}
|
||||
|
||||
_, err := r.rawWrite(r.gsoIovs)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Offload) Close() error {
|
||||
if r.closed.Swap(true) {
|
||||
return nil
|
||||
}
|
||||
|
||||
//shutdownFd is owned by the container, so we should not close it
|
||||
var err error
|
||||
if r.fd >= 0 {
|
||||
err = unix.Close(r.fd)
|
||||
r.fd = -1
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
package tio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/slackhq/nebula/wire"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// Maximum size we accept for a single read from a TUN with IFF_VNET_HDR. A
|
||||
// TSO superpacket can be up to 64KiB of payload plus a single L2/L3/L4 header
|
||||
// prefix plus the virtio header.
|
||||
const tunReadBufSize = 65535
|
||||
|
||||
type Poll struct {
|
||||
fd int
|
||||
|
||||
readPoll [2]unix.PollFd
|
||||
writePoll [2]unix.PollFd
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
func newPoll(fd int, shutdownFd int) (*Poll, error) {
|
||||
if err := unix.SetNonblock(fd, true); err != nil {
|
||||
_ = unix.Close(fd)
|
||||
return nil, fmt.Errorf("failed to set Poll device as nonblocking: %w", err)
|
||||
}
|
||||
|
||||
out := &Poll{
|
||||
fd: fd,
|
||||
readPoll: [2]unix.PollFd{
|
||||
{Fd: int32(fd), Events: unix.POLLIN},
|
||||
{Fd: int32(shutdownFd), Events: unix.POLLIN},
|
||||
},
|
||||
writePoll: [2]unix.PollFd{
|
||||
{Fd: int32(fd), Events: unix.POLLOUT},
|
||||
{Fd: int32(shutdownFd), Events: unix.POLLIN},
|
||||
},
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// blockOnRead waits until the Poll fd is readable or shutdown has been signaled.
|
||||
// Returns os.ErrClosed if Close was called.
|
||||
func (t *Poll) blockOnRead() error {
|
||||
const problemFlags = unix.POLLHUP | unix.POLLNVAL | unix.POLLERR
|
||||
var err error
|
||||
for {
|
||||
_, err = unix.Poll(t.readPoll[:], -1)
|
||||
if err != unix.EINTR {
|
||||
break
|
||||
}
|
||||
}
|
||||
tunEvents := t.readPoll[0].Revents
|
||||
shutdownEvents := t.readPoll[1].Revents
|
||||
t.readPoll[0].Revents = 0
|
||||
t.readPoll[1].Revents = 0
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if shutdownEvents&(unix.POLLIN|problemFlags) != 0 {
|
||||
return os.ErrClosed
|
||||
}
|
||||
if tunEvents&problemFlags != 0 {
|
||||
return os.ErrClosed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Poll) blockOnWrite() error {
|
||||
const problemFlags = unix.POLLHUP | unix.POLLNVAL | unix.POLLERR
|
||||
var err error
|
||||
for {
|
||||
_, err = unix.Poll(t.writePoll[:], -1)
|
||||
if err != unix.EINTR {
|
||||
break
|
||||
}
|
||||
}
|
||||
tunEvents := t.writePoll[0].Revents
|
||||
shutdownEvents := t.writePoll[1].Revents
|
||||
t.writePoll[0].Revents = 0
|
||||
t.writePoll[1].Revents = 0
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if shutdownEvents&(unix.POLLIN|problemFlags) != 0 {
|
||||
return os.ErrClosed
|
||||
}
|
||||
if tunEvents&problemFlags != 0 {
|
||||
return os.ErrClosed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Poll) Read(p []wire.TunPacket, mem []byte) (int, error) {
|
||||
if len(p) == 0 || len(mem) == 0 {
|
||||
return 0, nil //todo should this be an err?
|
||||
}
|
||||
p[0].Meta = wire.GSOInfo{}
|
||||
n, err := t.readOne(mem)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
p[0].Bytes = mem[:n]
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (t *Poll) readOne(to []byte) (int, error) {
|
||||
for {
|
||||
n, errno := unix.Read(t.fd, to)
|
||||
if errno == nil {
|
||||
return n, nil
|
||||
}
|
||||
switch errno {
|
||||
case unix.EAGAIN:
|
||||
if err := t.blockOnRead(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
case unix.EINTR:
|
||||
// retry
|
||||
case unix.EBADF:
|
||||
return 0, os.ErrClosed
|
||||
default:
|
||||
return 0, errno
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write is only valid for single threaded use
|
||||
func (t *Poll) Write(from []byte) (int, error) {
|
||||
for {
|
||||
n, errno := unix.Write(t.fd, from)
|
||||
if errno == nil {
|
||||
return n, nil
|
||||
}
|
||||
switch errno {
|
||||
case unix.EAGAIN:
|
||||
if err := t.blockOnWrite(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
case unix.EINTR:
|
||||
// retry
|
||||
case unix.EBADF:
|
||||
return 0, os.ErrClosed
|
||||
default:
|
||||
return 0, errno
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Poll) Close() error {
|
||||
if t.closed.Swap(true) {
|
||||
return nil
|
||||
}
|
||||
//shutdownFd is owned by the container, so we should not close it
|
||||
var err error
|
||||
if t.fd >= 0 {
|
||||
err = unix.Close(t.fd)
|
||||
t.fd = -1
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *Poll) Capabilities() Capabilities {
|
||||
return Capabilities{}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
//go:build linux && !android && !e2e_testing
|
||||
// +build linux,!android,!e2e_testing
|
||||
|
||||
package tio
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/slackhq/nebula/wire"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// newReadPipe returns a read fd. The matching write fd is registered for cleanup.
|
||||
// The caller takes ownership of the read fd (pass it into a QueueSet).
|
||||
func newReadPipe(t *testing.T) int {
|
||||
t.Helper()
|
||||
var fds [2]int
|
||||
if err := unix.Pipe2(fds[:], unix.O_CLOEXEC); err != nil {
|
||||
t.Fatalf("pipe2: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = unix.Close(fds[1]) })
|
||||
return fds[0]
|
||||
}
|
||||
|
||||
func TestPoll_WakeForShutdown_WakesFriends(t *testing.T) {
|
||||
parent, err := NewPollQueueSet()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, parent.Add(newReadPipe(t)))
|
||||
require.NoError(t, parent.Add(newReadPipe(t)))
|
||||
// QueueSet.Close owns the read fds we Added — don't register a separate
|
||||
// Cleanup to close them or we'll double-close whatever fd the kernel
|
||||
// has since reused.
|
||||
|
||||
readers := parent.Queues()
|
||||
errs := make([]error, len(readers))
|
||||
var wg sync.WaitGroup
|
||||
for i, r := range readers {
|
||||
wg.Add(1)
|
||||
go func(i int, r Queue) {
|
||||
defer wg.Done()
|
||||
pkts := make([]wire.TunPacket, 1)
|
||||
_, errs[i] = r.Read(pkts, make([]byte, 64))
|
||||
}(i, r)
|
||||
}
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
if err := parent.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() { wg.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("readers did not wake")
|
||||
}
|
||||
|
||||
for i, err := range errs {
|
||||
if !errors.Is(err, os.ErrClosed) {
|
||||
t.Errorf("reader %d: expected os.ErrClosed, got %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoll_Close_Idempotent(t *testing.T) {
|
||||
shutdownFd, err := unix.Eventfd(0, unix.EFD_NONBLOCK|unix.EFD_CLOEXEC)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = unix.Close(shutdownFd) })
|
||||
|
||||
tf, err := newPoll(newReadPipe(t), shutdownFd)
|
||||
require.NoError(t, err)
|
||||
if err := tf.Close(); err != nil {
|
||||
t.Fatalf("first Close: %v", err)
|
||||
}
|
||||
if err := tf.Close(); err != nil {
|
||||
t.Fatalf("second Close should be a no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollQueueSet_Close_ClosesEventfd(t *testing.T) {
|
||||
qs, err := NewPollQueueSet()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, qs.Add(newReadPipe(t)))
|
||||
|
||||
fd := qs.(*pollQueueSet).shutdownFd
|
||||
require.NoError(t, qs.Close())
|
||||
|
||||
// Closing the eventfd again should fail with EBADF, proving Close
|
||||
// actually released it.
|
||||
if err := unix.Close(fd); err == nil {
|
||||
t.Fatalf("eventfd %d still open after QueueSet.Close", fd)
|
||||
}
|
||||
|
||||
// Second Close must be a no-op (and must not double-close the eventfd
|
||||
// in case the kernel handed it out to another caller in the meantime).
|
||||
if err := qs.Close(); err != nil {
|
||||
t.Fatalf("second Close: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
//go:build linux && !android && !e2e_testing
|
||||
// +build linux,!android,!e2e_testing
|
||||
|
||||
package tio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/slackhq/nebula/wire"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// protoFromGSOType maps a virtio_net_hdr GSOType to the GSOProto value the
|
||||
// segment-time helpers use. Returns an error for GSO_NONE or any unknown
|
||||
// value — the caller should only invoke this on a confirmed superpacket.
|
||||
func protoFromGSOType(t uint8) (wire.GSOProto, error) {
|
||||
switch t {
|
||||
case unix.VIRTIO_NET_HDR_GSO_TCPV4, unix.VIRTIO_NET_HDR_GSO_TCPV6:
|
||||
return wire.GSOProtoTCP, nil
|
||||
case unix.VIRTIO_NET_HDR_GSO_UDP_L4:
|
||||
return wire.GSOProtoUDP, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unsupported virtio gso type: %d", t)
|
||||
}
|
||||
}
|
||||
@@ -1,782 +0,0 @@
|
||||
//go:build linux && !android && !e2e_testing
|
||||
// +build linux,!android,!e2e_testing
|
||||
|
||||
package tio
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/checksum"
|
||||
|
||||
"github.com/slackhq/nebula/overlay/tio/virtio"
|
||||
"github.com/slackhq/nebula/wire"
|
||||
)
|
||||
|
||||
// testSegScratchSize is a generous segmentation scratch sized to fit any
|
||||
// of the synthetic TSO/USO superpackets these tests generate (one
|
||||
// worst-case 64 KiB superpacket plus replicated per-segment headers).
|
||||
const testSegScratchSize = 192 * 1024
|
||||
|
||||
// verifyChecksum confirms that the one's-complement sum across `b`, seeded
|
||||
// with a folded pseudo-header sum, equals all-ones (valid).
|
||||
func verifyChecksum(b []byte, pseudo uint16) bool {
|
||||
return checksum.Checksum(b, pseudo) == 0xffff
|
||||
}
|
||||
|
||||
// segmentForTest is the test-only counterpart to the production
|
||||
// wire.TunPacket.PerSegment path. It handles GSO_NONE (with optional
|
||||
// finishChecksum) inline and dispatches GSO superpackets through
|
||||
// PerSegment, draining each yielded segment into a freshly-copied [][]byte
|
||||
// slot so callers can iterate after the call returns. Tests pre-set
|
||||
// hdr.HdrLen correctly, so correctHdrLen is not invoked here.
|
||||
func segmentForTest(pkt []byte, hdr virtio.Hdr, out *[][]byte, scratch []byte) error {
|
||||
if hdr.GSOType == unix.VIRTIO_NET_HDR_GSO_NONE {
|
||||
cp := append([]byte(nil), pkt...)
|
||||
if hdr.Flags&unix.VIRTIO_NET_HDR_F_NEEDS_CSUM != 0 {
|
||||
if err := virtio.FinishChecksum(cp, hdr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
*out = append(*out, cp)
|
||||
return nil
|
||||
}
|
||||
proto, err := protoFromGSOType(hdr.GSOType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p := wire.TunPacket{
|
||||
Bytes: pkt,
|
||||
Meta: wire.GSOInfo{
|
||||
Size: hdr.GSOSize,
|
||||
HdrLen: hdr.HdrLen,
|
||||
CsumStart: hdr.CsumStart,
|
||||
Proto: proto,
|
||||
},
|
||||
}
|
||||
return p.PerSegment(func(seg []byte) error {
|
||||
*out = append(*out, append([]byte(nil), seg...))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// pseudoHeaderIPv4 returns the folded pseudo-header sum used to verify a
|
||||
// TCP/UDP segment's checksum in tests. src/dst are 4 bytes each.
|
||||
func pseudoHeaderIPv4(src, dst []byte, proto byte, l4Len int) uint16 {
|
||||
s := uint32(checksum.Checksum(src, 0)) + uint32(checksum.Checksum(dst, 0))
|
||||
s += uint32(proto) + uint32(l4Len)
|
||||
s = (s & 0xffff) + (s >> 16)
|
||||
s = (s & 0xffff) + (s >> 16)
|
||||
return uint16(s)
|
||||
}
|
||||
|
||||
// pseudoHeaderIPv6 returns the folded pseudo-header sum used to verify a
|
||||
// TCP/UDP segment's checksum in tests. src/dst are 16 bytes each.
|
||||
func pseudoHeaderIPv6(src, dst []byte, proto byte, l4Len int) uint16 {
|
||||
s := uint32(checksum.Checksum(src, 0)) + uint32(checksum.Checksum(dst, 0))
|
||||
s += uint32(l4Len>>16) + uint32(l4Len&0xffff) + uint32(proto)
|
||||
s = (s & 0xffff) + (s >> 16)
|
||||
s = (s & 0xffff) + (s >> 16)
|
||||
return uint16(s)
|
||||
}
|
||||
|
||||
// buildTSOv4 builds a synthetic IPv4/TCP TSO superpacket with a payload of
|
||||
// `payLen` bytes split at `mss`.
|
||||
func buildTSOv4(t *testing.T, payLen, mss int) ([]byte, virtio.Hdr) {
|
||||
t.Helper()
|
||||
const ipLen = 20
|
||||
const tcpLen = 20
|
||||
pkt := make([]byte, ipLen+tcpLen+payLen)
|
||||
|
||||
// IPv4 header
|
||||
pkt[0] = 0x45 // version 4, IHL 5
|
||||
// total length is meaningless for TSO but set it anyway
|
||||
binary.BigEndian.PutUint16(pkt[2:4], uint16(ipLen+tcpLen+payLen))
|
||||
binary.BigEndian.PutUint16(pkt[4:6], 0x4242) // original ID
|
||||
pkt[8] = 64 // TTL
|
||||
pkt[9] = unix.IPPROTO_TCP
|
||||
copy(pkt[12:16], []byte{10, 0, 0, 1}) // src
|
||||
copy(pkt[16:20], []byte{10, 0, 0, 2}) // dst
|
||||
|
||||
// TCP header
|
||||
binary.BigEndian.PutUint16(pkt[20:22], 12345) // sport
|
||||
binary.BigEndian.PutUint16(pkt[22:24], 80) // dport
|
||||
binary.BigEndian.PutUint32(pkt[24:28], 10000) // seq
|
||||
binary.BigEndian.PutUint32(pkt[28:32], 20000) // ack
|
||||
pkt[32] = 0x50 // data offset 5 words
|
||||
pkt[33] = 0x18 // ACK | PSH
|
||||
binary.BigEndian.PutUint16(pkt[34:36], 65535) // window
|
||||
|
||||
// payload
|
||||
for i := 0; i < payLen; i++ {
|
||||
pkt[ipLen+tcpLen+i] = byte(i & 0xff)
|
||||
}
|
||||
|
||||
return pkt, virtio.Hdr{
|
||||
Flags: unix.VIRTIO_NET_HDR_F_NEEDS_CSUM,
|
||||
GSOType: unix.VIRTIO_NET_HDR_GSO_TCPV4,
|
||||
HdrLen: uint16(ipLen + tcpLen),
|
||||
GSOSize: uint16(mss),
|
||||
CsumStart: uint16(ipLen),
|
||||
CsumOffset: 16,
|
||||
}
|
||||
}
|
||||
|
||||
func TestSegmentTCPv4(t *testing.T) {
|
||||
const mss = 100
|
||||
const numSeg = 3
|
||||
pkt, hdr := buildTSOv4(t, mss*numSeg, mss)
|
||||
|
||||
scratch := make([]byte, testSegScratchSize)
|
||||
var out [][]byte
|
||||
if err := segmentForTest(pkt, hdr, &out, scratch); err != nil {
|
||||
t.Fatalf("segmentForTest: %v", err)
|
||||
}
|
||||
if len(out) != numSeg {
|
||||
t.Fatalf("expected %d segments, got %d", numSeg, len(out))
|
||||
}
|
||||
|
||||
for i, seg := range out {
|
||||
if len(seg) != 40+mss {
|
||||
t.Errorf("seg %d: unexpected len %d", i, len(seg))
|
||||
}
|
||||
totalLen := binary.BigEndian.Uint16(seg[2:4])
|
||||
if totalLen != uint16(40+mss) {
|
||||
t.Errorf("seg %d: total_len=%d want %d", i, totalLen, 40+mss)
|
||||
}
|
||||
id := binary.BigEndian.Uint16(seg[4:6])
|
||||
if id != 0x4242+uint16(i) {
|
||||
t.Errorf("seg %d: ip id=%#x want %#x", i, id, 0x4242+uint16(i))
|
||||
}
|
||||
seq := binary.BigEndian.Uint32(seg[24:28])
|
||||
wantSeq := uint32(10000 + i*mss)
|
||||
if seq != wantSeq {
|
||||
t.Errorf("seg %d: seq=%d want %d", i, seq, wantSeq)
|
||||
}
|
||||
flags := seg[33]
|
||||
wantFlags := byte(0x10) // ACK only, PSH cleared
|
||||
if i == numSeg-1 {
|
||||
wantFlags = 0x18 // ACK | PSH preserved on last
|
||||
}
|
||||
if flags != wantFlags {
|
||||
t.Errorf("seg %d: flags=%#x want %#x", i, flags, wantFlags)
|
||||
}
|
||||
// IPv4 header checksum must verify against itself.
|
||||
if !verifyChecksum(seg[:20], 0) {
|
||||
t.Errorf("seg %d: bad IPv4 header checksum", i)
|
||||
}
|
||||
// TCP checksum must verify against the pseudo-header.
|
||||
psum := pseudoHeaderIPv4(seg[12:16], seg[16:20], unix.IPPROTO_TCP, 20+mss)
|
||||
if !verifyChecksum(seg[20:], psum) {
|
||||
t.Errorf("seg %d: bad TCP checksum", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSegmentTCPv4OddTail(t *testing.T) {
|
||||
// Payload of 250 bytes with MSS 100 → segments of 100, 100, 50.
|
||||
pkt, hdr := buildTSOv4(t, 250, 100)
|
||||
scratch := make([]byte, testSegScratchSize)
|
||||
var out [][]byte
|
||||
if err := segmentForTest(pkt, hdr, &out, scratch); err != nil {
|
||||
t.Fatalf("segmentForTest: %v", err)
|
||||
}
|
||||
if len(out) != 3 {
|
||||
t.Fatalf("want 3 segments, got %d", len(out))
|
||||
}
|
||||
wantPayLens := []int{100, 100, 50}
|
||||
for i, seg := range out {
|
||||
if len(seg)-40 != wantPayLens[i] {
|
||||
t.Errorf("seg %d: pay len %d want %d", i, len(seg)-40, wantPayLens[i])
|
||||
}
|
||||
if !verifyChecksum(seg[:20], 0) {
|
||||
t.Errorf("seg %d: bad IPv4 header checksum", i)
|
||||
}
|
||||
psum := pseudoHeaderIPv4(seg[12:16], seg[16:20], unix.IPPROTO_TCP, 20+wantPayLens[i])
|
||||
if !verifyChecksum(seg[20:], psum) {
|
||||
t.Errorf("seg %d: bad TCP checksum", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSegmentTCPv6(t *testing.T) {
|
||||
const ipLen = 40
|
||||
const tcpLen = 20
|
||||
const mss = 120
|
||||
const numSeg = 2
|
||||
payLen := mss * numSeg
|
||||
pkt := make([]byte, ipLen+tcpLen+payLen)
|
||||
|
||||
// IPv6 header
|
||||
pkt[0] = 0x60 // version 6
|
||||
binary.BigEndian.PutUint16(pkt[4:6], uint16(tcpLen+payLen))
|
||||
pkt[6] = unix.IPPROTO_TCP
|
||||
pkt[7] = 64
|
||||
// src/dst fe80::1 / fe80::2
|
||||
pkt[8] = 0xfe
|
||||
pkt[9] = 0x80
|
||||
pkt[23] = 1
|
||||
pkt[24] = 0xfe
|
||||
pkt[25] = 0x80
|
||||
pkt[39] = 2
|
||||
|
||||
// TCP header
|
||||
binary.BigEndian.PutUint16(pkt[40:42], 12345)
|
||||
binary.BigEndian.PutUint16(pkt[42:44], 80)
|
||||
binary.BigEndian.PutUint32(pkt[44:48], 7)
|
||||
binary.BigEndian.PutUint32(pkt[48:52], 99)
|
||||
pkt[52] = 0x50
|
||||
pkt[53] = 0x19 // FIN | ACK | PSH — exercise FIN clearing too
|
||||
binary.BigEndian.PutUint16(pkt[54:56], 65535)
|
||||
|
||||
for i := 0; i < payLen; i++ {
|
||||
pkt[ipLen+tcpLen+i] = byte(i)
|
||||
}
|
||||
|
||||
hdr := virtio.Hdr{
|
||||
Flags: unix.VIRTIO_NET_HDR_F_NEEDS_CSUM,
|
||||
GSOType: unix.VIRTIO_NET_HDR_GSO_TCPV6,
|
||||
HdrLen: uint16(ipLen + tcpLen),
|
||||
GSOSize: uint16(mss),
|
||||
CsumStart: uint16(ipLen),
|
||||
CsumOffset: 16,
|
||||
}
|
||||
|
||||
scratch := make([]byte, testSegScratchSize)
|
||||
var out [][]byte
|
||||
if err := segmentForTest(pkt, hdr, &out, scratch); err != nil {
|
||||
t.Fatalf("segmentForTest: %v", err)
|
||||
}
|
||||
if len(out) != numSeg {
|
||||
t.Fatalf("want %d segments, got %d", numSeg, len(out))
|
||||
}
|
||||
|
||||
for i, seg := range out {
|
||||
if len(seg) != ipLen+tcpLen+mss {
|
||||
t.Errorf("seg %d: len %d want %d", i, len(seg), ipLen+tcpLen+mss)
|
||||
}
|
||||
pl := binary.BigEndian.Uint16(seg[4:6])
|
||||
if pl != uint16(tcpLen+mss) {
|
||||
t.Errorf("seg %d: payload_length=%d want %d", i, pl, tcpLen+mss)
|
||||
}
|
||||
seq := binary.BigEndian.Uint32(seg[44:48])
|
||||
if seq != uint32(7+i*mss) {
|
||||
t.Errorf("seg %d: seq=%d want %d", i, seq, 7+i*mss)
|
||||
}
|
||||
flags := seg[53]
|
||||
// Original flags = 0x19 (FIN|ACK|PSH). FIN(0x01)+PSH(0x08) should be
|
||||
// cleared on all but the last; ACK(0x10) always preserved.
|
||||
wantFlags := byte(0x10)
|
||||
if i == numSeg-1 {
|
||||
wantFlags = 0x19
|
||||
}
|
||||
if flags != wantFlags {
|
||||
t.Errorf("seg %d: flags=%#x want %#x", i, flags, wantFlags)
|
||||
}
|
||||
psum := pseudoHeaderIPv6(seg[8:24], seg[24:40], unix.IPPROTO_TCP, tcpLen+mss)
|
||||
if !verifyChecksum(seg[ipLen:], psum) {
|
||||
t.Errorf("seg %d: bad TCP checksum", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSegmentGSONonePassesThrough(t *testing.T) {
|
||||
pkt, hdr := buildTSOv4(t, 100, 100)
|
||||
hdr.GSOType = unix.VIRTIO_NET_HDR_GSO_NONE
|
||||
hdr.Flags = 0 // no NEEDS_CSUM, leave packet untouched
|
||||
|
||||
scratch := make([]byte, testSegScratchSize)
|
||||
var out [][]byte
|
||||
if err := segmentForTest(pkt, hdr, &out, scratch); err != nil {
|
||||
t.Fatalf("segmentForTest: %v", err)
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("want 1 segment, got %d", len(out))
|
||||
}
|
||||
if len(out[0]) != len(pkt) {
|
||||
t.Fatalf("unexpected length: %d vs %d", len(out[0]), len(pkt))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSegmentRejectsLegacyUDPGSO ensures the legacy GSO_UDP (UFO) marker is
|
||||
// still rejected; only modern GSO_UDP_L4 (USO) is supported.
|
||||
func TestSegmentRejectsLegacyUDPGSO(t *testing.T) {
|
||||
hdr := virtio.Hdr{GSOType: unix.VIRTIO_NET_HDR_GSO_UDP}
|
||||
var out [][]byte
|
||||
if err := segmentForTest(nil, hdr, &out, nil); err == nil {
|
||||
t.Fatalf("expected rejection for legacy UDP GSO")
|
||||
}
|
||||
}
|
||||
|
||||
// buildUSOv4 builds a synthetic IPv4/UDP USO superpacket with payload of
|
||||
// payLen bytes, segmented at gsoSize.
|
||||
func buildUSOv4(t *testing.T, payLen, gsoSize int) ([]byte, virtio.Hdr) {
|
||||
t.Helper()
|
||||
const ipLen = 20
|
||||
const udpLen = 8
|
||||
pkt := make([]byte, ipLen+udpLen+payLen)
|
||||
|
||||
// IPv4 header
|
||||
pkt[0] = 0x45 // version 4, IHL 5
|
||||
binary.BigEndian.PutUint16(pkt[2:4], uint16(ipLen+udpLen+payLen))
|
||||
binary.BigEndian.PutUint16(pkt[4:6], 0x4242)
|
||||
pkt[8] = 64
|
||||
pkt[9] = unix.IPPROTO_UDP
|
||||
copy(pkt[12:16], []byte{10, 0, 0, 1})
|
||||
copy(pkt[16:20], []byte{10, 0, 0, 2})
|
||||
|
||||
// UDP header (length + checksum filled in per segment by segmentUDPYield)
|
||||
binary.BigEndian.PutUint16(pkt[20:22], 12345) // sport
|
||||
binary.BigEndian.PutUint16(pkt[22:24], 53) // dport
|
||||
|
||||
for i := 0; i < payLen; i++ {
|
||||
pkt[ipLen+udpLen+i] = byte(i & 0xff)
|
||||
}
|
||||
|
||||
return pkt, virtio.Hdr{
|
||||
Flags: unix.VIRTIO_NET_HDR_F_NEEDS_CSUM,
|
||||
GSOType: unix.VIRTIO_NET_HDR_GSO_UDP_L4,
|
||||
HdrLen: uint16(ipLen + udpLen),
|
||||
GSOSize: uint16(gsoSize),
|
||||
CsumStart: uint16(ipLen),
|
||||
CsumOffset: 6,
|
||||
}
|
||||
}
|
||||
|
||||
func TestSegmentUDPv4(t *testing.T) {
|
||||
const gso = 100
|
||||
const numSeg = 3
|
||||
pkt, hdr := buildUSOv4(t, gso*numSeg, gso)
|
||||
|
||||
scratch := make([]byte, testSegScratchSize)
|
||||
var out [][]byte
|
||||
if err := segmentForTest(pkt, hdr, &out, scratch); err != nil {
|
||||
t.Fatalf("segmentForTest: %v", err)
|
||||
}
|
||||
if len(out) != numSeg {
|
||||
t.Fatalf("expected %d segments, got %d", numSeg, len(out))
|
||||
}
|
||||
|
||||
for i, seg := range out {
|
||||
if len(seg) != 28+gso {
|
||||
t.Errorf("seg %d: len %d want %d", i, len(seg), 28+gso)
|
||||
}
|
||||
totalLen := binary.BigEndian.Uint16(seg[2:4])
|
||||
if totalLen != uint16(28+gso) {
|
||||
t.Errorf("seg %d: total_len=%d want %d", i, totalLen, 28+gso)
|
||||
}
|
||||
// kernel UDP-GSO does NOT bump the IPv4 ID across segments; every
|
||||
// segment carries the same ID as the seed.
|
||||
id := binary.BigEndian.Uint16(seg[4:6])
|
||||
if id != 0x4242 {
|
||||
t.Errorf("seg %d: ip id=%#x want %#x", i, id, 0x4242)
|
||||
}
|
||||
udpLen := binary.BigEndian.Uint16(seg[24:26])
|
||||
if udpLen != uint16(8+gso) {
|
||||
t.Errorf("seg %d: udp len=%d want %d", i, udpLen, 8+gso)
|
||||
}
|
||||
if !verifyChecksum(seg[:20], 0) {
|
||||
t.Errorf("seg %d: bad IPv4 header checksum", i)
|
||||
}
|
||||
psum := pseudoHeaderIPv4(seg[12:16], seg[16:20], unix.IPPROTO_UDP, 8+gso)
|
||||
if !verifyChecksum(seg[20:], psum) {
|
||||
t.Errorf("seg %d: bad UDP checksum", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSegmentUDPv4OddTail(t *testing.T) {
|
||||
// 250 bytes payload, gsoSize=100 → segments of 100, 100, 50.
|
||||
pkt, hdr := buildUSOv4(t, 250, 100)
|
||||
scratch := make([]byte, testSegScratchSize)
|
||||
var out [][]byte
|
||||
if err := segmentForTest(pkt, hdr, &out, scratch); err != nil {
|
||||
t.Fatalf("segmentForTest: %v", err)
|
||||
}
|
||||
if len(out) != 3 {
|
||||
t.Fatalf("want 3 segments, got %d", len(out))
|
||||
}
|
||||
wantPay := []int{100, 100, 50}
|
||||
for i, seg := range out {
|
||||
if len(seg)-28 != wantPay[i] {
|
||||
t.Errorf("seg %d: pay len %d want %d", i, len(seg)-28, wantPay[i])
|
||||
}
|
||||
udpLen := binary.BigEndian.Uint16(seg[24:26])
|
||||
if udpLen != uint16(8+wantPay[i]) {
|
||||
t.Errorf("seg %d: udp len=%d want %d", i, udpLen, 8+wantPay[i])
|
||||
}
|
||||
if !verifyChecksum(seg[:20], 0) {
|
||||
t.Errorf("seg %d: bad IPv4 header checksum", i)
|
||||
}
|
||||
psum := pseudoHeaderIPv4(seg[12:16], seg[16:20], unix.IPPROTO_UDP, 8+wantPay[i])
|
||||
if !verifyChecksum(seg[20:], psum) {
|
||||
t.Errorf("seg %d: bad UDP checksum", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSegmentUDPv6(t *testing.T) {
|
||||
const ipLen = 40
|
||||
const udpLen = 8
|
||||
const gso = 120
|
||||
const numSeg = 2
|
||||
payLen := gso * numSeg
|
||||
pkt := make([]byte, ipLen+udpLen+payLen)
|
||||
|
||||
// IPv6 header
|
||||
pkt[0] = 0x60
|
||||
binary.BigEndian.PutUint16(pkt[4:6], uint16(udpLen+payLen))
|
||||
pkt[6] = unix.IPPROTO_UDP
|
||||
pkt[7] = 64
|
||||
pkt[8] = 0xfe
|
||||
pkt[9] = 0x80
|
||||
pkt[23] = 1
|
||||
pkt[24] = 0xfe
|
||||
pkt[25] = 0x80
|
||||
pkt[39] = 2
|
||||
|
||||
binary.BigEndian.PutUint16(pkt[40:42], 12345)
|
||||
binary.BigEndian.PutUint16(pkt[42:44], 53)
|
||||
|
||||
for i := 0; i < payLen; i++ {
|
||||
pkt[ipLen+udpLen+i] = byte(i)
|
||||
}
|
||||
|
||||
hdr := virtio.Hdr{
|
||||
Flags: unix.VIRTIO_NET_HDR_F_NEEDS_CSUM,
|
||||
GSOType: unix.VIRTIO_NET_HDR_GSO_UDP_L4,
|
||||
HdrLen: uint16(ipLen + udpLen),
|
||||
GSOSize: uint16(gso),
|
||||
CsumStart: uint16(ipLen),
|
||||
CsumOffset: 6,
|
||||
}
|
||||
|
||||
scratch := make([]byte, testSegScratchSize)
|
||||
var out [][]byte
|
||||
if err := segmentForTest(pkt, hdr, &out, scratch); err != nil {
|
||||
t.Fatalf("segmentForTest: %v", err)
|
||||
}
|
||||
if len(out) != numSeg {
|
||||
t.Fatalf("want %d segments, got %d", numSeg, len(out))
|
||||
}
|
||||
|
||||
for i, seg := range out {
|
||||
if len(seg) != ipLen+udpLen+gso {
|
||||
t.Errorf("seg %d: len %d want %d", i, len(seg), ipLen+udpLen+gso)
|
||||
}
|
||||
pl := binary.BigEndian.Uint16(seg[4:6])
|
||||
if pl != uint16(udpLen+gso) {
|
||||
t.Errorf("seg %d: payload_length=%d want %d", i, pl, udpLen+gso)
|
||||
}
|
||||
ul := binary.BigEndian.Uint16(seg[ipLen+4 : ipLen+6])
|
||||
if ul != uint16(udpLen+gso) {
|
||||
t.Errorf("seg %d: udp len=%d want %d", i, ul, udpLen+gso)
|
||||
}
|
||||
psum := pseudoHeaderIPv6(seg[8:24], seg[24:40], unix.IPPROTO_UDP, udpLen+gso)
|
||||
if !verifyChecksum(seg[ipLen:], psum) {
|
||||
t.Errorf("seg %d: bad UDP checksum", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSegmentUDPCEPropagates confirms IP-level CE marks on the seed appear on
|
||||
// every segment. UDP has no transport-level CWR/ECE: the IP TOS/TC byte is
|
||||
// copied verbatim into every segment by the segment-prefix copy.
|
||||
func TestSegmentUDPCEPropagates(t *testing.T) {
|
||||
pkt, hdr := buildUSOv4(t, 200, 100)
|
||||
pkt[1] = 0x03 // CE codepoint in IP-ECN
|
||||
|
||||
scratch := make([]byte, testSegScratchSize)
|
||||
var out [][]byte
|
||||
if err := segmentForTest(pkt, hdr, &out, scratch); err != nil {
|
||||
t.Fatalf("segmentForTest: %v", err)
|
||||
}
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("want 2 segments, got %d", len(out))
|
||||
}
|
||||
for i, seg := range out {
|
||||
if seg[1]&0x03 != 0x03 {
|
||||
t.Errorf("seg %d: CE missing (tos=%#x)", i, seg[1])
|
||||
}
|
||||
if !verifyChecksum(seg[:20], 0) {
|
||||
t.Errorf("seg %d: bad IPv4 header checksum", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSegmentTCPCwrFirstSegmentOnly confirms RFC 3168 §6.1.2: when a TSO
|
||||
// burst's seed has CWR set, only the first emitted segment carries CWR.
|
||||
// ECE is preserved on every segment (different signal, persistent state).
|
||||
func TestSegmentTCPCwrFirstSegmentOnly(t *testing.T) {
|
||||
const mss = 100
|
||||
const numSeg = 3
|
||||
pkt, hdr := buildTSOv4(t, mss*numSeg, mss)
|
||||
// Seed flags: CWR | ECE | ACK | PSH.
|
||||
pkt[33] = 0x80 | 0x40 | 0x10 | 0x08
|
||||
|
||||
scratch := make([]byte, testSegScratchSize)
|
||||
var out [][]byte
|
||||
if err := segmentForTest(pkt, hdr, &out, scratch); err != nil {
|
||||
t.Fatalf("segmentForTest: %v", err)
|
||||
}
|
||||
if len(out) != numSeg {
|
||||
t.Fatalf("expected %d segments, got %d", numSeg, len(out))
|
||||
}
|
||||
for i, seg := range out {
|
||||
flags := seg[33]
|
||||
hasCwr := flags&0x80 != 0
|
||||
hasEce := flags&0x40 != 0
|
||||
hasPsh := flags&0x08 != 0
|
||||
wantCwr := i == 0
|
||||
wantPsh := i == numSeg-1
|
||||
if hasCwr != wantCwr {
|
||||
t.Errorf("seg %d: CWR=%v want %v (flags=%#x)", i, hasCwr, wantCwr, flags)
|
||||
}
|
||||
if !hasEce {
|
||||
t.Errorf("seg %d: ECE missing (flags=%#x)", i, flags)
|
||||
}
|
||||
if hasPsh != wantPsh {
|
||||
t.Errorf("seg %d: PSH=%v want %v (flags=%#x)", i, hasPsh, wantPsh, flags)
|
||||
}
|
||||
// IP and TCP checksums must still verify after the flag rewrite.
|
||||
if !verifyChecksum(seg[:20], 0) {
|
||||
t.Errorf("seg %d: bad IPv4 header checksum", i)
|
||||
}
|
||||
psum := pseudoHeaderIPv4(seg[12:16], seg[16:20], unix.IPPROTO_TCP, 20+mss)
|
||||
if !verifyChecksum(seg[20:], psum) {
|
||||
t.Errorf("seg %d: bad TCP checksum", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSegmentTCPv4(b *testing.B) {
|
||||
sizes := []struct {
|
||||
name string
|
||||
payLen int
|
||||
mss int
|
||||
}{
|
||||
{"64KiB_MSS1460", 65000, 1460},
|
||||
{"16KiB_MSS1460", 16384, 1460},
|
||||
{"4KiB_MSS1460", 4096, 1460},
|
||||
}
|
||||
for _, sz := range sizes {
|
||||
b.Run(sz.name, func(b *testing.B) {
|
||||
const ipLen = 20
|
||||
const tcpLen = 20
|
||||
pkt := make([]byte, ipLen+tcpLen+sz.payLen)
|
||||
pkt[0] = 0x45
|
||||
binary.BigEndian.PutUint16(pkt[2:4], uint16(ipLen+tcpLen+sz.payLen))
|
||||
binary.BigEndian.PutUint16(pkt[4:6], 0x4242)
|
||||
pkt[8] = 64
|
||||
pkt[9] = unix.IPPROTO_TCP
|
||||
copy(pkt[12:16], []byte{10, 0, 0, 1})
|
||||
copy(pkt[16:20], []byte{10, 0, 0, 2})
|
||||
binary.BigEndian.PutUint16(pkt[20:22], 12345)
|
||||
binary.BigEndian.PutUint16(pkt[22:24], 80)
|
||||
binary.BigEndian.PutUint32(pkt[24:28], 10000)
|
||||
binary.BigEndian.PutUint32(pkt[28:32], 20000)
|
||||
pkt[32] = 0x50
|
||||
pkt[33] = 0x18
|
||||
binary.BigEndian.PutUint16(pkt[34:36], 65535)
|
||||
for i := 0; i < sz.payLen; i++ {
|
||||
pkt[ipLen+tcpLen+i] = byte(i)
|
||||
}
|
||||
hdr := virtio.Hdr{
|
||||
Flags: unix.VIRTIO_NET_HDR_F_NEEDS_CSUM,
|
||||
GSOType: unix.VIRTIO_NET_HDR_GSO_TCPV4,
|
||||
HdrLen: uint16(ipLen + tcpLen),
|
||||
GSOSize: uint16(sz.mss),
|
||||
CsumStart: uint16(ipLen),
|
||||
CsumOffset: 16,
|
||||
}
|
||||
|
||||
scratch := make([]byte, testSegScratchSize)
|
||||
out := make([][]byte, 0, 64)
|
||||
|
||||
// PerSegment consumes its input destructively; restore pkt from
|
||||
// a master copy each iteration. The restore mirrors the
|
||||
// kernel→userspace copy that hands a fresh GSO blob to the
|
||||
// segmenter in production, so it's representative cost rather
|
||||
// than bench overhead.
|
||||
master := append([]byte(nil), pkt...)
|
||||
work := make([]byte, len(pkt))
|
||||
|
||||
b.SetBytes(int64(len(pkt)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
copy(work, master)
|
||||
out = out[:0]
|
||||
if err := segmentForTest(work, hdr, &out, scratch); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTunFileWriteVnetHdrNoAlloc verifies the IFF_VNET_HDR fast-path write is
|
||||
// allocation-free. We write to /dev/null so every call succeeds synchronously.
|
||||
func TestTunFileWriteVnetHdrNoAlloc(t *testing.T) {
|
||||
fd, err := unix.Open("/dev/null", os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("open /dev/null: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = unix.Close(fd) })
|
||||
|
||||
tf := &Offload{fd: fd}
|
||||
|
||||
payload := make([]byte, 1400)
|
||||
// Warm up (first call may trigger one-time internal allocations elsewhere).
|
||||
if _, err := tf.Write(payload); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
|
||||
allocs := testing.AllocsPerRun(1000, func() {
|
||||
if _, err := tf.Write(payload); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
})
|
||||
if allocs != 0 {
|
||||
t.Fatalf("Write allocated %.1f times per call, want 0", allocs)
|
||||
}
|
||||
}
|
||||
|
||||
// buildTSOv6 builds a synthetic IPv6/TCP TSO superpacket with payLen bytes
|
||||
// of payload, segmented at gso. Returns the packet bytes only; the
|
||||
// virtio_net_hdr is the caller's responsibility.
|
||||
func buildTSOv6(payLen, gso int) []byte {
|
||||
const ipLen = 40
|
||||
const tcpLen = 20
|
||||
pkt := make([]byte, ipLen+tcpLen+payLen)
|
||||
|
||||
pkt[0] = 0x60 // version 6
|
||||
binary.BigEndian.PutUint16(pkt[4:6], uint16(tcpLen+payLen))
|
||||
pkt[6] = unix.IPPROTO_TCP
|
||||
pkt[7] = 64
|
||||
pkt[8] = 0xfe
|
||||
pkt[9] = 0x80
|
||||
pkt[23] = 1
|
||||
pkt[24] = 0xfe
|
||||
pkt[25] = 0x80
|
||||
pkt[39] = 2
|
||||
|
||||
binary.BigEndian.PutUint16(pkt[40:42], 12345)
|
||||
binary.BigEndian.PutUint16(pkt[42:44], 80)
|
||||
binary.BigEndian.PutUint32(pkt[44:48], 7)
|
||||
binary.BigEndian.PutUint32(pkt[48:52], 99)
|
||||
pkt[52] = 0x50
|
||||
pkt[53] = 0x10 // ACK only
|
||||
binary.BigEndian.PutUint16(pkt[54:56], 65535)
|
||||
|
||||
for i := 0; i < payLen; i++ {
|
||||
pkt[ipLen+tcpLen+i] = byte(i)
|
||||
}
|
||||
return pkt
|
||||
}
|
||||
|
||||
// TestDecodeReadFitsMaxTSO proves decodeRead can absorb a worst-case
|
||||
// 64KiB TSO superpacket without dropping it. With segmentation deferred to
|
||||
// encrypt time, decodeRead writes nothing — it just slices the
|
||||
// caller-supplied mem and attaches GSO metadata — so the size requirement
|
||||
// is just "fit one worst-case input."
|
||||
//
|
||||
// Regression history: in a prior layout the rx buffer doubled as the
|
||||
// segmentation output, a near-threshold drain read returned "scratch too
|
||||
// small", the whole 45-segment TSO burst was dropped, and the remote's TCP
|
||||
// fast-retransmit collapsed cwnd. Keeping this test guards against
|
||||
// re-introducing per-call sizing assumptions inside decodeRead.
|
||||
func TestDecodeReadFitsMaxTSO(t *testing.T) {
|
||||
const ipv6HdrLen = 40
|
||||
const tcpHdrLen = 20
|
||||
const headerLen = ipv6HdrLen + tcpHdrLen
|
||||
pktLen := tunReadBufSize
|
||||
payLen := pktLen - headerLen
|
||||
const targetSegs = 64
|
||||
gsoSize := (payLen + targetSegs - 1) / targetSegs
|
||||
|
||||
pkt := buildTSOv6(payLen, gsoSize)
|
||||
if len(pkt) != pktLen {
|
||||
t.Fatalf("buildTSOv6 produced %d bytes, want %d", len(pkt), pktLen)
|
||||
}
|
||||
|
||||
o := &Offload{}
|
||||
// mem is sized exactly to one worst-case packet — the caller-side
|
||||
// invariant the drain loop in Read enforces. decodeRead must process
|
||||
// the burst within that window.
|
||||
mem := make([]byte, pktLen)
|
||||
copy(mem, pkt)
|
||||
|
||||
// Encode the matching virtio_net_hdr.
|
||||
hdr := virtio.Hdr{
|
||||
Flags: unix.VIRTIO_NET_HDR_F_NEEDS_CSUM,
|
||||
GSOType: unix.VIRTIO_NET_HDR_GSO_TCPV6,
|
||||
HdrLen: uint16(headerLen),
|
||||
GSOSize: uint16(gsoSize),
|
||||
CsumStart: uint16(ipv6HdrLen),
|
||||
CsumOffset: 16,
|
||||
}
|
||||
hdr.Encode(o.readVnetScratch[:])
|
||||
|
||||
var pkts []wire.TunPacket
|
||||
pkts, err := o.decodeRead(pkts, mem, pktLen)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeRead returned %v — sizing regression: "+
|
||||
"tunRxBufSize=%d must hold one worst-case input (%d)",
|
||||
err, tunRxBufSize, pktLen)
|
||||
}
|
||||
|
||||
if len(pkts) != 1 {
|
||||
t.Fatalf("got %d packets, want 1 superpacket entry", len(pkts))
|
||||
}
|
||||
got := pkts[0]
|
||||
if !got.Meta.IsSuperpacket() {
|
||||
t.Fatalf("expected superpacket GSO metadata, got %+v", got.Meta)
|
||||
}
|
||||
if got.Meta.Proto != wire.GSOProtoTCP {
|
||||
t.Errorf("Meta.Proto=%d want TCP", got.Meta.Proto)
|
||||
}
|
||||
if got.Meta.Size != uint16(gsoSize) {
|
||||
t.Errorf("Meta.Size=%d want %d", got.Meta.Size, gsoSize)
|
||||
}
|
||||
if got.Meta.HdrLen != uint16(headerLen) {
|
||||
t.Errorf("Meta.HdrLen=%d want %d", got.Meta.HdrLen, headerLen)
|
||||
}
|
||||
if got.Meta.CsumStart != uint16(ipv6HdrLen) {
|
||||
t.Errorf("Meta.CsumStart=%d want %d", got.Meta.CsumStart, ipv6HdrLen)
|
||||
}
|
||||
if len(got.Bytes) != pktLen {
|
||||
t.Errorf("len(Bytes)=%d want %d", len(got.Bytes), pktLen)
|
||||
}
|
||||
|
||||
// Validate that segmenting the returned superpacket reproduces the
|
||||
// expected per-segment IPv6 payload length and TCP checksum.
|
||||
wantSegs := (payLen + gsoSize - 1) / gsoSize
|
||||
gotSegs := 0
|
||||
if err := got.PerSegment(func(seg []byte) error {
|
||||
defer func() { gotSegs++ }()
|
||||
if len(seg) < headerLen+1 {
|
||||
t.Errorf("seg %d too short: %d", gotSegs, len(seg))
|
||||
return nil
|
||||
}
|
||||
if seg[0]>>4 != 6 {
|
||||
t.Errorf("seg %d: bad IP version %#x", gotSegs, seg[0])
|
||||
}
|
||||
segPay := len(seg) - headerLen
|
||||
gotPL := binary.BigEndian.Uint16(seg[4:6])
|
||||
if gotPL != uint16(tcpHdrLen+segPay) {
|
||||
t.Errorf("seg %d: payload_len=%d want %d", gotSegs, gotPL, tcpHdrLen+segPay)
|
||||
}
|
||||
psum := pseudoHeaderIPv6(seg[8:24], seg[24:40], unix.IPPROTO_TCP, tcpHdrLen+segPay)
|
||||
if !verifyChecksum(seg[ipv6HdrLen:], psum) {
|
||||
t.Errorf("seg %d: bad TCP checksum", gotSegs)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("PerSegment: %v", err)
|
||||
}
|
||||
if gotSegs != wantSegs {
|
||||
t.Fatalf("got %d segments, want %d", gotSegs, wantSegs)
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
//go:build linux && !android
|
||||
// +build linux,!android
|
||||
|
||||
package virtio
|
||||
|
||||
import "encoding/binary"
|
||||
|
||||
// Size is the on-wire length of struct virtio_net_hdr the kernel
|
||||
// prepends/expects on a TUN opened with IFF_VNET_HDR (TUNSETVNETHDRSZ
|
||||
// not set).
|
||||
const Size = 10
|
||||
|
||||
// Hdr is the Go view of the legacy virtio_net_hdr.
|
||||
type Hdr struct {
|
||||
Flags uint8
|
||||
GSOType uint8
|
||||
HdrLen uint16
|
||||
GSOSize uint16
|
||||
CsumStart uint16
|
||||
CsumOffset uint16
|
||||
}
|
||||
|
||||
// Decode reads a virtio_net_hdr in host byte order (TUN default; we never
|
||||
// call TUNSETVNETLE so the kernel matches our endianness).
|
||||
func (h *Hdr) Decode(b []byte) {
|
||||
h.Flags = b[0]
|
||||
h.GSOType = b[1]
|
||||
h.HdrLen = binary.NativeEndian.Uint16(b[2:4])
|
||||
h.GSOSize = binary.NativeEndian.Uint16(b[4:6])
|
||||
h.CsumStart = binary.NativeEndian.Uint16(b[6:8])
|
||||
h.CsumOffset = binary.NativeEndian.Uint16(b[8:10])
|
||||
}
|
||||
|
||||
// Encode is the inverse of Decode: writes the virtio_net_hdr fields into b
|
||||
// (must be at least Size bytes). Used to emit a TSO superpacket on egress.
|
||||
func (h *Hdr) Encode(b []byte) {
|
||||
b[0] = h.Flags
|
||||
b[1] = h.GSOType
|
||||
binary.NativeEndian.PutUint16(b[2:4], h.HdrLen)
|
||||
binary.NativeEndian.PutUint16(b[4:6], h.GSOSize)
|
||||
binary.NativeEndian.PutUint16(b[6:8], h.CsumStart)
|
||||
binary.NativeEndian.PutUint16(b[8:10], h.CsumOffset)
|
||||
}
|
||||
@@ -1,402 +0,0 @@
|
||||
//go:build linux && !android
|
||||
// +build linux,!android
|
||||
|
||||
// Package virtio implements the pure validation, header-correction, and
|
||||
// per-segment slicing logic for kernel-supplied TSO/USO superpackets on
|
||||
// IFF_VNET_HDR TUN devices. It is FD-free and depends only on the byte
|
||||
// layout of the virtio_net_hdr and the IP/TCP/UDP headers it describes,
|
||||
// so it can be unit-tested in isolation from the tio Queue runtime.
|
||||
package virtio
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"github.com/slackhq/nebula/overlay/checksum"
|
||||
)
|
||||
|
||||
// Protocol header size bounds used to validate / cap kernel-supplied offsets.
|
||||
const (
|
||||
ipv4HeaderMinLen = 20 // IHL=5, no options
|
||||
ipv4HeaderMaxLen = 60 // IHL=15, max options
|
||||
ipv6FixedLen = 40 // IPv6 base header; extensions would extend this
|
||||
tcpHeaderMinLen = 20 // data-offset=5, no options
|
||||
tcpHeaderMaxLen = 60 // data-offset=15, max options
|
||||
)
|
||||
|
||||
// Byte offsets inside an IPv4 header.
|
||||
const (
|
||||
ipv4TotalLenOff = 2
|
||||
ipv4IDOff = 4
|
||||
ipv4ChecksumOff = 10
|
||||
ipv4SrcOff = 12
|
||||
ipv4AddrsEnd = 20 // end of dst address (ipv4SrcOff + 2*4)
|
||||
)
|
||||
|
||||
// Byte offsets inside an IPv6 header.
|
||||
const (
|
||||
ipv6PayloadLenOff = 4
|
||||
ipv6SrcOff = 8
|
||||
ipv6AddrsEnd = 40 // end of dst address (ipv6SrcOff + 2*16)
|
||||
)
|
||||
|
||||
// Byte offsets inside a TCP header (relative to its start, i.e. csumStart).
|
||||
const (
|
||||
tcpSeqOff = 4
|
||||
tcpDataOffOff = 12 // upper nibble is header len in 32-bit words
|
||||
tcpFlagsOff = 13
|
||||
tcpChecksumOff = 16
|
||||
)
|
||||
|
||||
// UDP header is fixed at 8 bytes: {sport, dport, length, checksum}.
|
||||
const (
|
||||
udpHeaderLen = 8
|
||||
udpLengthOff = 4
|
||||
udpChecksumOff = 6
|
||||
)
|
||||
|
||||
// tcpFinPshMask is cleared on every segment except the last of a TSO burst.
|
||||
const tcpFinPshMask = 0x09 // FIN(0x01) | PSH(0x08)
|
||||
|
||||
// tcpCwrFlag is cleared on every segment except the first. Per RFC 3168
|
||||
// §6.1.2 the CWR bit signals a one-shot transition (the sender just halved
|
||||
// its window) and must appear on the first segment of a TSO burst only.
|
||||
const tcpCwrFlag = 0x80
|
||||
|
||||
// CheckValid rejects packets whose virtio_net_hdr/IP combination would
|
||||
// cause a downstream miscompute. The TUN should never emit RSC_INFO and
|
||||
// the GSO type must agree with the IP version nibble.
|
||||
func CheckValid(pkt []byte, hdr Hdr) error {
|
||||
// When RSC_INFO is set the csum_start/csum_offset fields are repurposed to
|
||||
// carry coalescing info rather than checksum offsets. A TUN writing via
|
||||
// IFF_VNET_HDR should never emit this, but if it did we would silently
|
||||
// miscompute the segment checksums — refuse the packet instead.
|
||||
if hdr.Flags&unix.VIRTIO_NET_HDR_F_RSC_INFO != 0 {
|
||||
return fmt.Errorf("virtio RSC_INFO flag not supported on TUN reads")
|
||||
}
|
||||
if len(pkt) < ipv4HeaderMinLen {
|
||||
return fmt.Errorf("packet too short")
|
||||
}
|
||||
ipVersion := pkt[0] >> 4
|
||||
switch hdr.GSOType {
|
||||
case unix.VIRTIO_NET_HDR_GSO_TCPV4:
|
||||
if ipVersion != 4 {
|
||||
return fmt.Errorf("invalid IP version %d for GSO type %d", ipVersion, hdr.GSOType)
|
||||
}
|
||||
case unix.VIRTIO_NET_HDR_GSO_TCPV6:
|
||||
if ipVersion != 6 {
|
||||
return fmt.Errorf("invalid IP version %d for GSO type %d", ipVersion, hdr.GSOType)
|
||||
}
|
||||
case unix.VIRTIO_NET_HDR_GSO_UDP_L4:
|
||||
// USO carries either v4 or v6; the leading nibble disambiguates.
|
||||
if !(ipVersion == 4 || ipVersion == 6) {
|
||||
return fmt.Errorf("invalid IP version %d for GSO type %d", ipVersion, hdr.GSOType)
|
||||
}
|
||||
default:
|
||||
if !(ipVersion == 6 || ipVersion == 4) {
|
||||
return fmt.Errorf("invalid IP version %d for GSO type %d", ipVersion, hdr.GSOType)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CorrectHdrLen rewrites hdr.HdrLen based on the actual transport header
|
||||
// length read out of pkt. The kernel's hdr.HdrLen on the FORWARD path can
|
||||
// be the length of the entire first packet, so we don't trust it.
|
||||
func CorrectHdrLen(pkt []byte, hdr *Hdr) error {
|
||||
// Thank you wireguard-go for documenting these edge-cases
|
||||
// Don't trust hdr.hdrLen from the kernel as it can be equal to the length
|
||||
// of the entire first packet when the kernel is handling it as part of a
|
||||
// FORWARD path. Instead, parse the transport header length and add it onto
|
||||
// csumStart, which is synonymous for IP header length.
|
||||
|
||||
if hdr.GSOType == unix.VIRTIO_NET_HDR_GSO_UDP_L4 {
|
||||
hdr.HdrLen = hdr.CsumStart + 8
|
||||
} else {
|
||||
if len(pkt) <= int(hdr.CsumStart+tcpDataOffOff) {
|
||||
return errors.New("packet is too short")
|
||||
}
|
||||
|
||||
tcpHLen := uint16(pkt[hdr.CsumStart+tcpDataOffOff] >> 4 * 4)
|
||||
if tcpHLen < 20 || tcpHLen > 60 {
|
||||
// A TCP header must be between 20 and 60 bytes in length.
|
||||
return fmt.Errorf("tcp header len is invalid: %d", tcpHLen)
|
||||
}
|
||||
hdr.HdrLen = hdr.CsumStart + tcpHLen
|
||||
}
|
||||
|
||||
if len(pkt) < int(hdr.HdrLen) {
|
||||
return fmt.Errorf("length of packet (%d) < virtioNetHdr.HdrLen (%d)", len(pkt), hdr.HdrLen)
|
||||
}
|
||||
|
||||
if hdr.HdrLen < hdr.CsumStart {
|
||||
return fmt.Errorf("virtioNetHdr.HdrLen (%d) < virtioNetHdr.CsumStart (%d)", hdr.HdrLen, hdr.CsumStart)
|
||||
}
|
||||
cSumAt := int(hdr.CsumStart + hdr.CsumStart)
|
||||
if cSumAt+1 >= len(pkt) {
|
||||
return fmt.Errorf("end of checksum offset (%d) exceeds packet length (%d)", cSumAt+1, len(pkt))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SegmentTCP walks a TSO superpacket pkt, yielding each segment as a
|
||||
// slice into pkt itself. Per-segment plaintext is laid out by sliding a
|
||||
// freshly-patched copy of the L3+L4 header into pkt at offset i*gsoSize,
|
||||
// where it sits immediately before that segment's payload chunk in the
|
||||
// original buffer. The slide is destructive: iter i's header write overwrites
|
||||
// the last hdrLen bytes of seg_{i-1}'s payload, which is dead by the time
|
||||
// the next iteration begins. pkt is consumed by this call and must not be
|
||||
// inspected by the caller after the final yield.
|
||||
func SegmentTCP(pkt []byte, hdrLenU, csumStartU, gsoSizeU uint16, yield func(seg []byte) error) error {
|
||||
if gsoSizeU == 0 {
|
||||
return fmt.Errorf("gso_size is zero")
|
||||
}
|
||||
if csumStartU == 0 {
|
||||
return fmt.Errorf("csum_start is zero")
|
||||
}
|
||||
|
||||
headerLen := int(hdrLenU)
|
||||
csumStart := int(csumStartU)
|
||||
isV4 := pkt[0]>>4 == 4
|
||||
|
||||
tcpHdrLen := int(pkt[csumStart+tcpDataOffOff]>>4) * 4
|
||||
payLen := len(pkt) - headerLen
|
||||
gsoSize := int(gsoSizeU)
|
||||
numSeg := (payLen + gsoSize - 1) / gsoSize
|
||||
if numSeg == 0 {
|
||||
numSeg = 1
|
||||
}
|
||||
|
||||
origSeq := binary.BigEndian.Uint32(pkt[csumStart+tcpSeqOff : csumStart+tcpSeqOff+4])
|
||||
origFlags := pkt[csumStart+tcpFlagsOff]
|
||||
|
||||
var tmp [tcpHeaderMaxLen]byte
|
||||
copy(tmp[:tcpHdrLen], pkt[csumStart:headerLen])
|
||||
tmp[tcpSeqOff], tmp[tcpSeqOff+1], tmp[tcpSeqOff+2], tmp[tcpSeqOff+3] = 0, 0, 0, 0
|
||||
tmp[tcpFlagsOff] = 0
|
||||
tmp[tcpChecksumOff], tmp[tcpChecksumOff+1] = 0, 0
|
||||
baseTcpHdrSum := uint32(checksum.Checksum(tmp[:tcpHdrLen], 0))
|
||||
|
||||
var baseProtoSum uint32
|
||||
if isV4 {
|
||||
baseProtoSum = uint32(checksum.Checksum(pkt[ipv4SrcOff:ipv4AddrsEnd], 0))
|
||||
} else {
|
||||
baseProtoSum = uint32(checksum.Checksum(pkt[ipv6SrcOff:ipv6AddrsEnd], 0))
|
||||
}
|
||||
baseProtoSum += uint32(unix.IPPROTO_TCP)
|
||||
|
||||
var origIPID uint16
|
||||
var baseIPHdrSum uint32
|
||||
if isV4 {
|
||||
origIPID = binary.BigEndian.Uint16(pkt[ipv4IDOff : ipv4IDOff+2])
|
||||
ihl := int(pkt[0]&0x0f) * 4
|
||||
if ihl < ipv4HeaderMinLen || ihl > csumStart {
|
||||
return fmt.Errorf("bad IPv4 IHL: %d", ihl)
|
||||
}
|
||||
var ipTmp [ipv4HeaderMaxLen]byte
|
||||
copy(ipTmp[:ihl], pkt[:ihl])
|
||||
ipTmp[ipv4TotalLenOff], ipTmp[ipv4TotalLenOff+1] = 0, 0
|
||||
ipTmp[ipv4IDOff], ipTmp[ipv4IDOff+1] = 0, 0
|
||||
ipTmp[ipv4ChecksumOff], ipTmp[ipv4ChecksumOff+1] = 0, 0
|
||||
baseIPHdrSum = uint32(checksum.Checksum(ipTmp[:ihl], 0))
|
||||
}
|
||||
|
||||
for i := 0; i < numSeg; i++ {
|
||||
segStart := i * gsoSize
|
||||
segEnd := segStart + gsoSize
|
||||
if segEnd > payLen {
|
||||
segEnd = payLen
|
||||
}
|
||||
segPayLen := segEnd - segStart
|
||||
segLen := headerLen + segPayLen
|
||||
headerOff := i * gsoSize
|
||||
|
||||
// Slide the header into place immediately before this segment's
|
||||
// payload. Iter 0's header is already at pkt[:headerLen]; for
|
||||
// i ≥ 1 we copy from there. The constant-byte fields of pkt[:headerLen]
|
||||
// survive iter 0's in-place patches (only seq/flags/cksum/totalLen/id
|
||||
// are touched), and iter 0's stale variable-field values are
|
||||
// overwritten by the per-segment patches below.
|
||||
if i > 0 {
|
||||
copy(pkt[headerOff:headerOff+headerLen], pkt[:headerLen])
|
||||
}
|
||||
seg := pkt[headerOff : headerOff+segLen]
|
||||
|
||||
segSeq := origSeq + uint32(segStart)
|
||||
segFlags := origFlags
|
||||
if i != 0 {
|
||||
segFlags &^= tcpCwrFlag
|
||||
}
|
||||
if i != numSeg-1 {
|
||||
segFlags &^= tcpFinPshMask
|
||||
}
|
||||
totalLen := segLen
|
||||
|
||||
if isV4 {
|
||||
segID := origIPID + uint16(i)
|
||||
binary.BigEndian.PutUint16(seg[ipv4TotalLenOff:ipv4TotalLenOff+2], uint16(totalLen))
|
||||
binary.BigEndian.PutUint16(seg[ipv4IDOff:ipv4IDOff+2], segID)
|
||||
ipSum := baseIPHdrSum + uint32(totalLen) + uint32(segID)
|
||||
binary.BigEndian.PutUint16(seg[ipv4ChecksumOff:ipv4ChecksumOff+2], foldComplement(ipSum))
|
||||
} else {
|
||||
binary.BigEndian.PutUint16(seg[ipv6PayloadLenOff:ipv6PayloadLenOff+2], uint16(headerLen-ipv6FixedLen+segPayLen))
|
||||
}
|
||||
|
||||
binary.BigEndian.PutUint32(seg[csumStart+tcpSeqOff:csumStart+tcpSeqOff+4], segSeq)
|
||||
seg[csumStart+tcpFlagsOff] = segFlags
|
||||
|
||||
tcpLen := tcpHdrLen + segPayLen
|
||||
// Payload bytes still live at their original offset in pkt. The
|
||||
// header slide above only writes into pkt[i*G : i*G+H], which is
|
||||
// the tail of seg_{i-1}'s payload (already consumed) and never
|
||||
// overlaps seg_i's own payload at pkt[H+i*G : H+(i+1)*G].
|
||||
paySum := uint32(checksum.Checksum(pkt[headerLen+segStart:headerLen+segEnd], 0))
|
||||
wide := uint64(baseTcpHdrSum) + uint64(paySum) + uint64(baseProtoSum)
|
||||
wide += uint64(segSeq) + uint64(segFlags) + uint64(tcpLen)
|
||||
wide = (wide & 0xffffffff) + (wide >> 32)
|
||||
wide = (wide & 0xffffffff) + (wide >> 32)
|
||||
binary.BigEndian.PutUint16(seg[csumStart+tcpChecksumOff:csumStart+tcpChecksumOff+2], foldComplement(uint32(wide)))
|
||||
|
||||
if err := yield(seg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SegmentUDP walks a USO superpacket, sliding a per-segment-patched
|
||||
// L3+L4 header into pkt at offset i*gsoSize and yielding pkt[i*G:i*G+segLen]
|
||||
// to the caller. Per-segment patches are total_len + IPv4 csum (or IPv6
|
||||
// payload_len) plus the UDP length and checksum. pkt is consumed
|
||||
// destructively; see SegmentTCP for the layout reasoning.
|
||||
//
|
||||
// UDP-GSO leaves the IPv4 ID identical across segments (the kernel does not
|
||||
// bump it), which is why the IP-level per-segment work is limited to
|
||||
// total_len + IPv4 header checksum (v4) or payload_len (v6).
|
||||
func SegmentUDP(pkt []byte, hdrLenU, csumStartU, gsoSizeU uint16, yield func(seg []byte) error) error {
|
||||
if gsoSizeU == 0 {
|
||||
return fmt.Errorf("gso_size is zero")
|
||||
}
|
||||
if csumStartU == 0 {
|
||||
return fmt.Errorf("csum_start is zero")
|
||||
}
|
||||
|
||||
isV4 := pkt[0]>>4 == 4
|
||||
headerLen := int(hdrLenU)
|
||||
csumStart := int(csumStartU)
|
||||
if headerLen-csumStart != udpHeaderLen {
|
||||
return fmt.Errorf("udp header len mismatch: %d", headerLen-csumStart)
|
||||
}
|
||||
|
||||
payLen := len(pkt) - headerLen
|
||||
gsoSize := int(gsoSizeU)
|
||||
numSeg := (payLen + gsoSize - 1) / gsoSize
|
||||
if numSeg == 0 {
|
||||
numSeg = 1
|
||||
}
|
||||
|
||||
var udpTmp [udpHeaderLen]byte
|
||||
copy(udpTmp[:], pkt[csumStart:headerLen])
|
||||
udpTmp[udpLengthOff], udpTmp[udpLengthOff+1] = 0, 0
|
||||
udpTmp[udpChecksumOff], udpTmp[udpChecksumOff+1] = 0, 0
|
||||
baseUDPHdrSum := uint32(checksum.Checksum(udpTmp[:], 0))
|
||||
|
||||
var baseProtoSum uint32
|
||||
if isV4 {
|
||||
baseProtoSum = uint32(checksum.Checksum(pkt[ipv4SrcOff:ipv4AddrsEnd], 0))
|
||||
} else {
|
||||
baseProtoSum = uint32(checksum.Checksum(pkt[ipv6SrcOff:ipv6AddrsEnd], 0))
|
||||
}
|
||||
baseProtoSum += uint32(unix.IPPROTO_UDP)
|
||||
|
||||
var baseIPHdrSum uint32
|
||||
if isV4 {
|
||||
ihl := int(pkt[0]&0x0f) * 4
|
||||
if ihl < ipv4HeaderMinLen || ihl > csumStart {
|
||||
return fmt.Errorf("bad IPv4 IHL: %d", ihl)
|
||||
}
|
||||
var ipTmp [ipv4HeaderMaxLen]byte
|
||||
copy(ipTmp[:ihl], pkt[:ihl])
|
||||
ipTmp[ipv4TotalLenOff], ipTmp[ipv4TotalLenOff+1] = 0, 0
|
||||
ipTmp[ipv4ChecksumOff], ipTmp[ipv4ChecksumOff+1] = 0, 0
|
||||
baseIPHdrSum = uint32(checksum.Checksum(ipTmp[:ihl], 0))
|
||||
}
|
||||
|
||||
for i := 0; i < numSeg; i++ {
|
||||
segStart := i * gsoSize
|
||||
segEnd := segStart + gsoSize
|
||||
if segEnd > payLen {
|
||||
segEnd = payLen
|
||||
}
|
||||
segPayLen := segEnd - segStart
|
||||
segLen := headerLen + segPayLen
|
||||
headerOff := i * gsoSize
|
||||
|
||||
if i > 0 {
|
||||
copy(pkt[headerOff:headerOff+headerLen], pkt[:headerLen])
|
||||
}
|
||||
seg := pkt[headerOff : headerOff+segLen]
|
||||
|
||||
totalLen := segLen
|
||||
udpLen := udpHeaderLen + segPayLen
|
||||
|
||||
if isV4 {
|
||||
binary.BigEndian.PutUint16(seg[ipv4TotalLenOff:ipv4TotalLenOff+2], uint16(totalLen))
|
||||
ipSum := baseIPHdrSum + uint32(totalLen)
|
||||
binary.BigEndian.PutUint16(seg[ipv4ChecksumOff:ipv4ChecksumOff+2], foldComplement(ipSum))
|
||||
} else {
|
||||
binary.BigEndian.PutUint16(seg[ipv6PayloadLenOff:ipv6PayloadLenOff+2], uint16(headerLen-ipv6FixedLen+segPayLen))
|
||||
}
|
||||
|
||||
binary.BigEndian.PutUint16(seg[csumStart+udpLengthOff:csumStart+udpLengthOff+2], uint16(udpLen))
|
||||
|
||||
paySum := uint32(checksum.Checksum(pkt[headerLen+segStart:headerLen+segEnd], 0))
|
||||
wide := uint64(baseUDPHdrSum) + uint64(paySum) + uint64(baseProtoSum)
|
||||
wide += uint64(udpLen) + uint64(udpLen)
|
||||
wide = (wide & 0xffffffff) + (wide >> 32)
|
||||
wide = (wide & 0xffffffff) + (wide >> 32)
|
||||
csum := foldComplement(uint32(wide))
|
||||
if csum == 0 {
|
||||
csum = 0xffff
|
||||
}
|
||||
binary.BigEndian.PutUint16(seg[csumStart+udpChecksumOff:csumStart+udpChecksumOff+2], csum)
|
||||
|
||||
if err := yield(seg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FinishChecksum computes the L4 checksum for a non-GSO packet that the kernel
|
||||
// handed us with NEEDS_CSUM set. csum_start / csum_offset point at the 16-bit
|
||||
// checksum field; we zero it, fold a full sum (the field was pre-loaded with
|
||||
// the pseudo-header partial sum by the kernel), and store the result.
|
||||
func FinishChecksum(seg []byte, hdr Hdr) error {
|
||||
cs := int(hdr.CsumStart)
|
||||
co := int(hdr.CsumOffset)
|
||||
if cs+co+2 > len(seg) {
|
||||
return fmt.Errorf("csum offsets out of range: start=%d offset=%d len=%d", cs, co, len(seg))
|
||||
}
|
||||
// The kernel stores a partial pseudo-header sum at [cs+co:]; sum over the
|
||||
// L4 region starting at cs, folding the prior partial in as the seed.
|
||||
partial := binary.BigEndian.Uint16(seg[cs+co : cs+co+2])
|
||||
seg[cs+co] = 0
|
||||
seg[cs+co+1] = 0
|
||||
binary.BigEndian.PutUint16(seg[cs+co:cs+co+2], ^checksum.Checksum(seg[cs:], partial))
|
||||
return nil
|
||||
}
|
||||
|
||||
// foldComplement folds a 32-bit one's-complement partial sum to 16 bits and
|
||||
// complements it, yielding the on-wire Internet checksum value.
|
||||
func foldComplement(sum uint32) uint16 {
|
||||
sum = (sum & 0xffff) + (sum >> 16)
|
||||
sum = (sum & 0xffff) + (sum >> 16)
|
||||
return ^uint16(sum)
|
||||
}
|
||||
+8
-39
@@ -13,14 +13,12 @@ import (
|
||||
|
||||
"github.com/gaissmai/bart"
|
||||
"github.com/slackhq/nebula/config"
|
||||
"github.com/slackhq/nebula/overlay/tio"
|
||||
"github.com/slackhq/nebula/routing"
|
||||
"github.com/slackhq/nebula/util"
|
||||
"github.com/slackhq/nebula/wire"
|
||||
)
|
||||
|
||||
type tun struct {
|
||||
rwc io.ReadWriteCloser
|
||||
io.ReadWriteCloser
|
||||
fd int
|
||||
vpnNetworks []netip.Prefix
|
||||
Routes atomic.Pointer[[]Route]
|
||||
@@ -28,37 +26,16 @@ type tun struct {
|
||||
l *slog.Logger
|
||||
}
|
||||
|
||||
func (t *tun) Read(p []wire.TunPacket, mem []byte) (int, error) {
|
||||
if len(p) == 0 || len(mem) == 0 {
|
||||
return 0, nil //todo should this be an err?
|
||||
}
|
||||
p[0].Meta = struct{}{}
|
||||
n, err := t.rwc.Read(mem)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
p[0].Bytes = mem[:n]
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (t *tun) Write(p []byte) (int, error) {
|
||||
return t.rwc.Write(p)
|
||||
}
|
||||
|
||||
func (t *tun) Close() error {
|
||||
return t.rwc.Close()
|
||||
}
|
||||
|
||||
func newTunFromFd(c *config.C, l *slog.Logger, deviceFd int, vpnNetworks []netip.Prefix) (*tun, error) {
|
||||
// XXX Android returns an fd in non-blocking mode which is necessary for shutdown to work properly.
|
||||
// Be sure not to call file.Fd() as it will set the fd to blocking mode.
|
||||
file := os.NewFile(uintptr(deviceFd), "/dev/net/tun")
|
||||
|
||||
t := &tun{
|
||||
rwc: file,
|
||||
fd: deviceFd,
|
||||
vpnNetworks: vpnNetworks,
|
||||
l: l,
|
||||
ReadWriteCloser: file,
|
||||
fd: deviceFd,
|
||||
vpnNetworks: vpnNetworks,
|
||||
l: l,
|
||||
}
|
||||
|
||||
err := t.reload(c, true)
|
||||
@@ -85,7 +62,7 @@ func (t *tun) RoutesFor(ip netip.Addr) routing.Gateways {
|
||||
return r
|
||||
}
|
||||
|
||||
func (t *tun) Activate() error {
|
||||
func (t tun) Activate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -122,14 +99,6 @@ func (t *tun) SupportsMultiqueue() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *tun) NewMultiQueueReader() error {
|
||||
return fmt.Errorf("TODO: multiqueue not implemented for android")
|
||||
}
|
||||
|
||||
func (t *tun) Readers() []tio.Queue {
|
||||
return []tio.Queue{t}
|
||||
}
|
||||
|
||||
func (t *tun) Capabilities() tio.Capabilities {
|
||||
return tio.Capabilities{}
|
||||
func (t *tun) NewMultiQueueReader() (io.ReadWriteCloser, error) {
|
||||
return nil, fmt.Errorf("TODO: multiqueue not implemented for android")
|
||||
}
|
||||
|
||||
+18
-32
@@ -16,16 +16,14 @@ import (
|
||||
|
||||
"github.com/gaissmai/bart"
|
||||
"github.com/slackhq/nebula/config"
|
||||
"github.com/slackhq/nebula/overlay/tio"
|
||||
"github.com/slackhq/nebula/routing"
|
||||
"github.com/slackhq/nebula/util"
|
||||
"github.com/slackhq/nebula/wire"
|
||||
netroute "golang.org/x/net/route"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
type tun struct {
|
||||
rwc io.ReadWriteCloser
|
||||
io.ReadWriteCloser
|
||||
Device string
|
||||
vpnNetworks []netip.Prefix
|
||||
DefaultMTU int
|
||||
@@ -126,11 +124,11 @@ func newTun(c *config.C, l *slog.Logger, vpnNetworks []netip.Prefix, _ bool) (*t
|
||||
}
|
||||
|
||||
t := &tun{
|
||||
rwc: os.NewFile(uintptr(fd), ""),
|
||||
Device: name,
|
||||
vpnNetworks: vpnNetworks,
|
||||
DefaultMTU: c.GetInt("tun.mtu", DefaultMTU),
|
||||
l: l,
|
||||
ReadWriteCloser: os.NewFile(uintptr(fd), ""),
|
||||
Device: name,
|
||||
vpnNetworks: vpnNetworks,
|
||||
DefaultMTU: c.GetInt("tun.mtu", DefaultMTU),
|
||||
l: l,
|
||||
}
|
||||
|
||||
err = t.reload(c, true)
|
||||
@@ -160,8 +158,8 @@ func newTunFromFd(_ *config.C, _ *slog.Logger, _ int, _ []netip.Prefix) (*tun, e
|
||||
}
|
||||
|
||||
func (t *tun) Close() error {
|
||||
if t.rwc != nil {
|
||||
return t.rwc.Close()
|
||||
if t.ReadWriteCloser != nil {
|
||||
return t.ReadWriteCloser.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -504,17 +502,13 @@ func delRoute(prefix netip.Prefix, gateway netroute.Addr) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *tun) Read(p []wire.TunPacket, mem []byte) (int, error) {
|
||||
if len(p) == 0 || len(mem) <= 4 {
|
||||
return 0, nil //todo should this be an err?
|
||||
}
|
||||
p[0].Meta = struct{}{}
|
||||
n, err := t.rwc.Read(mem)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
p[0].Bytes = mem[4:n]
|
||||
return 1, nil
|
||||
func (t *tun) Read(to []byte) (int, error) {
|
||||
buf := make([]byte, len(to)+4)
|
||||
|
||||
n, err := t.ReadWriteCloser.Read(buf)
|
||||
|
||||
copy(to, buf[4:])
|
||||
return n - 4, err
|
||||
}
|
||||
|
||||
// Write is only valid for single threaded use
|
||||
@@ -542,7 +536,7 @@ func (t *tun) Write(from []byte) (int, error) {
|
||||
|
||||
copy(buf[4:], from)
|
||||
|
||||
n, err := t.rwc.Write(buf)
|
||||
n, err := t.ReadWriteCloser.Write(buf)
|
||||
return n - 4, err
|
||||
}
|
||||
|
||||
@@ -558,14 +552,6 @@ func (t *tun) SupportsMultiqueue() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *tun) NewMultiQueueReader() error {
|
||||
return fmt.Errorf("TODO: multiqueue not implemented for darwin")
|
||||
}
|
||||
|
||||
func (t *tun) Readers() []tio.Queue {
|
||||
return []tio.Queue{t}
|
||||
}
|
||||
|
||||
func (t *tun) Capabilities() tio.Capabilities {
|
||||
return tio.Capabilities{}
|
||||
func (t *tun) NewMultiQueueReader() (io.ReadWriteCloser, error) {
|
||||
return nil, fmt.Errorf("TODO: multiqueue not implemented for darwin")
|
||||
}
|
||||
|
||||
+6
-36
@@ -10,9 +10,7 @@ import (
|
||||
|
||||
"github.com/rcrowley/go-metrics"
|
||||
"github.com/slackhq/nebula/iputil"
|
||||
"github.com/slackhq/nebula/overlay/tio"
|
||||
"github.com/slackhq/nebula/routing"
|
||||
"github.com/slackhq/nebula/wire"
|
||||
)
|
||||
|
||||
type disabledTun struct {
|
||||
@@ -20,10 +18,9 @@ type disabledTun struct {
|
||||
vpnNetworks []netip.Prefix
|
||||
|
||||
// Track these metrics since we don't have the tun device to do it for us
|
||||
tx metrics.Counter
|
||||
rx metrics.Counter
|
||||
numReaders int
|
||||
l *slog.Logger
|
||||
tx metrics.Counter
|
||||
rx metrics.Counter
|
||||
l *slog.Logger
|
||||
}
|
||||
|
||||
func newDisabledTun(vpnNetworks []netip.Prefix, queueLen int, metricsEnabled bool, l *slog.Logger) *disabledTun {
|
||||
@@ -31,7 +28,6 @@ func newDisabledTun(vpnNetworks []netip.Prefix, queueLen int, metricsEnabled boo
|
||||
vpnNetworks: vpnNetworks,
|
||||
read: make(chan []byte, queueLen),
|
||||
l: l,
|
||||
numReaders: 1,
|
||||
}
|
||||
|
||||
if metricsEnabled {
|
||||
@@ -61,7 +57,7 @@ func (*disabledTun) Name() string {
|
||||
return "disabled"
|
||||
}
|
||||
|
||||
func (t *disabledTun) readOne(b []byte) (int, error) {
|
||||
func (t *disabledTun) Read(b []byte) (int, error) {
|
||||
r, ok := <-t.read
|
||||
if !ok {
|
||||
return 0, io.EOF
|
||||
@@ -79,19 +75,6 @@ func (t *disabledTun) readOne(b []byte) (int, error) {
|
||||
return copy(b, r), nil
|
||||
}
|
||||
|
||||
func (t *disabledTun) Read(p []wire.TunPacket, mem []byte) (int, error) {
|
||||
if len(p) == 0 || len(mem) == 0 {
|
||||
return 0, nil //todo should this be an err?
|
||||
}
|
||||
p[0].Meta = wire.GSOInfo{}
|
||||
n, err := t.readOne(mem)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
p[0].Bytes = mem[:n]
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (t *disabledTun) handleICMPEchoRequest(b []byte) bool {
|
||||
out := make([]byte, len(b))
|
||||
out = iputil.CreateICMPEchoResponse(b, out)
|
||||
@@ -127,21 +110,8 @@ func (t *disabledTun) SupportsMultiqueue() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *disabledTun) NewMultiQueueReader() error {
|
||||
t.numReaders++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *disabledTun) Readers() []tio.Queue {
|
||||
out := make([]tio.Queue, t.numReaders)
|
||||
for i := range t.numReaders {
|
||||
out[i] = t
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (t *disabledTun) Capabilities() tio.Capabilities {
|
||||
return tio.Capabilities{}
|
||||
func (t *disabledTun) NewMultiQueueReader() (io.ReadWriteCloser, error) {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *disabledTun) Close() error {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
//go:build linux && !android && !e2e_testing
|
||||
// +build linux,!android,!e2e_testing
|
||||
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// newReadPipe returns a read fd. The matching write fd is registered for cleanup.
|
||||
// The caller takes ownership of the read fd (pass it to newTunFd / newFriend).
|
||||
func newReadPipe(t *testing.T) int {
|
||||
t.Helper()
|
||||
var fds [2]int
|
||||
if err := unix.Pipe2(fds[:], unix.O_CLOEXEC); err != nil {
|
||||
t.Fatalf("pipe2: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = unix.Close(fds[1]) })
|
||||
return fds[0]
|
||||
}
|
||||
|
||||
func TestTunFile_WakeForShutdown_UnblocksRead(t *testing.T) {
|
||||
tf, err := newTunFd(newReadPipe(t))
|
||||
if err != nil {
|
||||
t.Fatalf("newTunFd: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = tf.Close() })
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := tf.Read(make([]byte, 64))
|
||||
done <- err
|
||||
}()
|
||||
|
||||
// Verify Read is actually blocked in poll.
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatalf("Read returned before shutdown signal: %v", err)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
if err := tf.wakeForShutdown(); err != nil {
|
||||
t.Fatalf("wakeForShutdown: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, os.ErrClosed) {
|
||||
t.Fatalf("expected os.ErrClosed, got %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Read did not wake on shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunFile_WakeForShutdown_WakesFriends(t *testing.T) {
|
||||
parent, err := newTunFd(newReadPipe(t))
|
||||
if err != nil {
|
||||
t.Fatalf("newTunFd: %v", err)
|
||||
}
|
||||
friend, err := parent.newFriend(newReadPipe(t))
|
||||
if err != nil {
|
||||
_ = parent.Close()
|
||||
t.Fatalf("newFriend: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = friend.Close()
|
||||
_ = parent.Close()
|
||||
})
|
||||
|
||||
readers := []*tunFile{parent, friend}
|
||||
errs := make([]error, len(readers))
|
||||
var wg sync.WaitGroup
|
||||
for i, r := range readers {
|
||||
wg.Add(1)
|
||||
go func(i int, r *tunFile) {
|
||||
defer wg.Done()
|
||||
_, errs[i] = r.Read(make([]byte, 64))
|
||||
}(i, r)
|
||||
}
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
if err := parent.wakeForShutdown(); err != nil {
|
||||
t.Fatalf("wakeForShutdown: %v", err)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() { wg.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("readers did not wake")
|
||||
}
|
||||
|
||||
for i, err := range errs {
|
||||
if !errors.Is(err, os.ErrClosed) {
|
||||
t.Errorf("reader %d: expected os.ErrClosed, got %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunFile_Close_Idempotent(t *testing.T) {
|
||||
tf, err := newTunFd(newReadPipe(t))
|
||||
if err != nil {
|
||||
t.Fatalf("newTunFd: %v", err)
|
||||
}
|
||||
if err := tf.Close(); err != nil {
|
||||
t.Fatalf("first Close: %v", err)
|
||||
}
|
||||
if err := tf.Close(); err != nil {
|
||||
t.Fatalf("second Close should be a no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
+5
-26
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
@@ -17,10 +18,9 @@ import (
|
||||
"unsafe"
|
||||
|
||||
"github.com/gaissmai/bart"
|
||||
"github.com/slackhq/nebula/wire"
|
||||
|
||||
"github.com/slackhq/nebula/config"
|
||||
"github.com/slackhq/nebula/overlay/tio"
|
||||
|
||||
"github.com/slackhq/nebula/routing"
|
||||
"github.com/slackhq/nebula/util"
|
||||
netroute "golang.org/x/net/route"
|
||||
@@ -157,20 +157,7 @@ func (t *tun) blockOnWrite() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *tun) Read(p []wire.TunPacket, mem []byte) (int, error) {
|
||||
if len(p) == 0 || len(mem) == 0 {
|
||||
return 0, nil //todo should this be an err?
|
||||
}
|
||||
p[0].Meta = struct{}{}
|
||||
n, err := t.readOne(mem)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
p[0].Bytes = mem[:n]
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (t *tun) readOne(to []byte) (int, error) {
|
||||
func (t *tun) Read(to []byte) (int, error) {
|
||||
// first 4 bytes is protocol family, in network byte order
|
||||
var head [4]byte
|
||||
iovecs := [2]syscall.Iovec{
|
||||
@@ -578,8 +565,8 @@ func (t *tun) SupportsMultiqueue() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *tun) NewMultiQueueReader() error {
|
||||
return fmt.Errorf("TODO: multiqueue not implemented for freebsd")
|
||||
func (t *tun) NewMultiQueueReader() (io.ReadWriteCloser, error) {
|
||||
return nil, fmt.Errorf("TODO: multiqueue not implemented for freebsd")
|
||||
}
|
||||
|
||||
func (t *tun) addRoutes(logErrors bool) error {
|
||||
@@ -606,14 +593,6 @@ func (t *tun) addRoutes(logErrors bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *tun) Readers() []tio.Queue {
|
||||
return []tio.Queue{t}
|
||||
}
|
||||
|
||||
func (t *tun) Capabilities() tio.Capabilities {
|
||||
return tio.Capabilities{}
|
||||
}
|
||||
|
||||
func (t *tun) removeRoutes(routes []Route) error {
|
||||
for _, r := range routes {
|
||||
if !r.Install {
|
||||
|
||||
+17
-39
@@ -16,41 +16,18 @@ import (
|
||||
|
||||
"github.com/gaissmai/bart"
|
||||
"github.com/slackhq/nebula/config"
|
||||
"github.com/slackhq/nebula/overlay/tio"
|
||||
"github.com/slackhq/nebula/routing"
|
||||
"github.com/slackhq/nebula/util"
|
||||
"github.com/slackhq/nebula/wire"
|
||||
)
|
||||
|
||||
type tun struct {
|
||||
rwc io.ReadWriteCloser
|
||||
io.ReadWriteCloser
|
||||
vpnNetworks []netip.Prefix
|
||||
Routes atomic.Pointer[[]Route]
|
||||
routeTree atomic.Pointer[bart.Table[routing.Gateways]]
|
||||
l *slog.Logger
|
||||
}
|
||||
|
||||
func (t *tun) Read(p []wire.TunPacket, mem []byte) (int, error) {
|
||||
if len(p) == 0 || len(mem) <= 4 {
|
||||
return 0, nil //todo should this be an err?
|
||||
}
|
||||
p[0].Meta = struct{}{}
|
||||
n, err := t.rwc.Read(mem)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
p[0].Bytes = mem[4:n]
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (t *tun) Write(p []byte) (int, error) {
|
||||
return t.rwc.Write(p)
|
||||
}
|
||||
|
||||
func (t *tun) Close() error {
|
||||
return t.rwc.Close()
|
||||
}
|
||||
|
||||
func newTun(_ *config.C, _ *slog.Logger, _ []netip.Prefix, _ bool) (*tun, error) {
|
||||
return nil, fmt.Errorf("newTun not supported in iOS")
|
||||
}
|
||||
@@ -58,9 +35,9 @@ func newTun(_ *config.C, _ *slog.Logger, _ []netip.Prefix, _ bool) (*tun, error)
|
||||
func newTunFromFd(c *config.C, l *slog.Logger, deviceFd int, vpnNetworks []netip.Prefix) (*tun, error) {
|
||||
file := os.NewFile(uintptr(deviceFd), "/dev/tun")
|
||||
t := &tun{
|
||||
vpnNetworks: vpnNetworks,
|
||||
rwc: &tunReadCloser{f: file},
|
||||
l: l,
|
||||
vpnNetworks: vpnNetworks,
|
||||
ReadWriteCloser: &tunReadCloser{f: file},
|
||||
l: l,
|
||||
}
|
||||
|
||||
err := t.reload(c, true)
|
||||
@@ -119,9 +96,18 @@ type tunReadCloser struct {
|
||||
wBuf []byte
|
||||
}
|
||||
|
||||
// Read returns a packet with the BSD 4-byte header, watch out!
|
||||
func (tr *tunReadCloser) Read(to []byte) (int, error) {
|
||||
return tr.f.Read(to)
|
||||
tr.rMu.Lock()
|
||||
defer tr.rMu.Unlock()
|
||||
|
||||
if cap(tr.rBuf) < len(to)+4 {
|
||||
tr.rBuf = make([]byte, len(to)+4)
|
||||
}
|
||||
tr.rBuf = tr.rBuf[:len(to)+4]
|
||||
|
||||
n, err := tr.f.Read(tr.rBuf)
|
||||
copy(to, tr.rBuf[4:])
|
||||
return n - 4, err
|
||||
}
|
||||
|
||||
func (tr *tunReadCloser) Write(from []byte) (int, error) {
|
||||
@@ -169,14 +155,6 @@ func (t *tun) SupportsMultiqueue() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *tun) NewMultiQueueReader() error {
|
||||
return fmt.Errorf("TODO: multiqueue not implemented for ios")
|
||||
}
|
||||
|
||||
func (t *tun) Readers() []tio.Queue {
|
||||
return []tio.Queue{t}
|
||||
}
|
||||
|
||||
func (t *tun) Capabilities() tio.Capabilities {
|
||||
return tio.Capabilities{}
|
||||
func (t *tun) NewMultiQueueReader() (io.ReadWriteCloser, error) {
|
||||
return nil, fmt.Errorf("TODO: multiqueue not implemented for ios")
|
||||
}
|
||||
|
||||
+241
-160
@@ -4,7 +4,9 @@
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/netip"
|
||||
@@ -17,15 +19,180 @@ import (
|
||||
|
||||
"github.com/gaissmai/bart"
|
||||
"github.com/slackhq/nebula/config"
|
||||
"github.com/slackhq/nebula/overlay/tio"
|
||||
"github.com/slackhq/nebula/routing"
|
||||
"github.com/slackhq/nebula/util"
|
||||
"github.com/vishvananda/netlink"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// tunFile wraps a TUN file descriptor with poll-based reads. The FD provided will be changed to non-blocking.
|
||||
// A shared eventfd allows Close to wake all readers blocked in poll.
|
||||
type tunFile struct {
|
||||
fd int
|
||||
shutdownFd int
|
||||
lastOne bool
|
||||
readPoll [2]unix.PollFd
|
||||
writePoll [2]unix.PollFd
|
||||
closed bool
|
||||
}
|
||||
|
||||
// newFriend makes a tunFile for a MultiQueueReader that copies the shutdown eventfd from the parent tun
|
||||
func (r *tunFile) newFriend(fd int) (*tunFile, error) {
|
||||
if err := unix.SetNonblock(fd, true); err != nil {
|
||||
return nil, fmt.Errorf("failed to set tun fd non-blocking: %w", err)
|
||||
}
|
||||
return &tunFile{
|
||||
fd: fd,
|
||||
shutdownFd: r.shutdownFd,
|
||||
readPoll: [2]unix.PollFd{
|
||||
{Fd: int32(fd), Events: unix.POLLIN},
|
||||
{Fd: int32(r.shutdownFd), Events: unix.POLLIN},
|
||||
},
|
||||
writePoll: [2]unix.PollFd{
|
||||
{Fd: int32(fd), Events: unix.POLLOUT},
|
||||
{Fd: int32(r.shutdownFd), Events: unix.POLLIN},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newTunFd(fd int) (*tunFile, error) {
|
||||
if err := unix.SetNonblock(fd, true); err != nil {
|
||||
return nil, fmt.Errorf("failed to set tun fd non-blocking: %w", err)
|
||||
}
|
||||
|
||||
shutdownFd, err := unix.Eventfd(0, unix.EFD_NONBLOCK|unix.EFD_CLOEXEC)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create eventfd: %w", err)
|
||||
}
|
||||
|
||||
out := &tunFile{
|
||||
fd: fd,
|
||||
shutdownFd: shutdownFd,
|
||||
lastOne: true,
|
||||
readPoll: [2]unix.PollFd{
|
||||
{Fd: int32(fd), Events: unix.POLLIN},
|
||||
{Fd: int32(shutdownFd), Events: unix.POLLIN},
|
||||
},
|
||||
writePoll: [2]unix.PollFd{
|
||||
{Fd: int32(fd), Events: unix.POLLOUT},
|
||||
{Fd: int32(shutdownFd), Events: unix.POLLIN},
|
||||
},
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *tunFile) blockOnRead() error {
|
||||
const problemFlags = unix.POLLHUP | unix.POLLNVAL | unix.POLLERR
|
||||
var err error
|
||||
for {
|
||||
_, err = unix.Poll(r.readPoll[:], -1)
|
||||
if err != unix.EINTR {
|
||||
break
|
||||
}
|
||||
}
|
||||
//always reset these!
|
||||
tunEvents := r.readPoll[0].Revents
|
||||
shutdownEvents := r.readPoll[1].Revents
|
||||
r.readPoll[0].Revents = 0
|
||||
r.readPoll[1].Revents = 0
|
||||
//do the err check before trusting the potentially bogus bits we just got
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if shutdownEvents&(unix.POLLIN|problemFlags) != 0 {
|
||||
return os.ErrClosed
|
||||
} else if tunEvents&problemFlags != 0 {
|
||||
return os.ErrClosed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *tunFile) blockOnWrite() error {
|
||||
const problemFlags = unix.POLLHUP | unix.POLLNVAL | unix.POLLERR
|
||||
var err error
|
||||
for {
|
||||
_, err = unix.Poll(r.writePoll[:], -1)
|
||||
if err != unix.EINTR {
|
||||
break
|
||||
}
|
||||
}
|
||||
//always reset these!
|
||||
tunEvents := r.writePoll[0].Revents
|
||||
shutdownEvents := r.writePoll[1].Revents
|
||||
r.writePoll[0].Revents = 0
|
||||
r.writePoll[1].Revents = 0
|
||||
//do the err check before trusting the potentially bogus bits we just got
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if shutdownEvents&(unix.POLLIN|problemFlags) != 0 {
|
||||
return os.ErrClosed
|
||||
} else if tunEvents&problemFlags != 0 {
|
||||
return os.ErrClosed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *tunFile) Read(buf []byte) (int, error) {
|
||||
for {
|
||||
if n, err := unix.Read(r.fd, buf); err == nil {
|
||||
return n, nil
|
||||
} else if err == unix.EAGAIN {
|
||||
if err = r.blockOnRead(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
continue
|
||||
} else if err == unix.EINTR {
|
||||
continue
|
||||
} else if err == unix.EBADF {
|
||||
return 0, os.ErrClosed
|
||||
} else {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *tunFile) Write(buf []byte) (int, error) {
|
||||
for {
|
||||
if n, err := unix.Write(r.fd, buf); err == nil {
|
||||
return n, nil
|
||||
} else if err == unix.EAGAIN {
|
||||
if err = r.blockOnWrite(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
continue
|
||||
} else if err == unix.EINTR {
|
||||
continue
|
||||
} else if err == unix.EBADF {
|
||||
return 0, os.ErrClosed
|
||||
} else {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *tunFile) wakeForShutdown() error {
|
||||
var buf [8]byte
|
||||
binary.NativeEndian.PutUint64(buf[:], 1)
|
||||
_, err := unix.Write(int(r.readPoll[1].Fd), buf[:])
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *tunFile) Close() error {
|
||||
if r.closed { // avoid closing more than once. Technically a fd could get re-used, which would be a problem
|
||||
return nil
|
||||
}
|
||||
r.closed = true
|
||||
if r.lastOne {
|
||||
_ = unix.Close(r.shutdownFd)
|
||||
}
|
||||
return unix.Close(r.fd)
|
||||
}
|
||||
|
||||
type tun struct {
|
||||
readers tio.QueueSet
|
||||
*tunFile
|
||||
readers []*tunFile
|
||||
closeLock sync.Mutex
|
||||
Device string
|
||||
vpnNetworks []netip.Prefix
|
||||
@@ -34,14 +201,6 @@ type tun struct {
|
||||
TXQueueLen int
|
||||
deviceIndex int
|
||||
ioctlFd uintptr
|
||||
vnetHdr bool
|
||||
// routeFeatureECN, when true, sets RTAX_FEATURE_ECN on every route we
|
||||
// install for the tun. The kernel then actively negotiates ECN for
|
||||
// connections destined to those prefixes (equivalent to `ip route
|
||||
// change ... features ecn`) regardless of net.ipv4.tcp_ecn, so flows
|
||||
// across the nebula mesh use ECN even when the host default is the
|
||||
// passive setting (=2). Disable via tunnels.ecn=false.
|
||||
routeFeatureECN bool
|
||||
|
||||
Routes atomic.Pointer[[]Route]
|
||||
routeTree atomic.Pointer[bart.Table[routing.Gateways]]
|
||||
@@ -80,9 +239,7 @@ type ifreqQLEN struct {
|
||||
}
|
||||
|
||||
func newTunFromFd(c *config.C, l *slog.Logger, deviceFd int, vpnNetworks []netip.Prefix) (*tun, error) {
|
||||
// We don't know what flags the caller opened this fd with and can't turn
|
||||
// on IFF_VNET_HDR after TUNSETIFF, so skip offload on inherited fds.
|
||||
t, err := newTunGeneric(c, l, deviceFd, false, false, vpnNetworks)
|
||||
t, err := newTunGeneric(c, l, deviceFd, vpnNetworks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -92,105 +249,46 @@ func newTunFromFd(c *config.C, l *slog.Logger, deviceFd int, vpnNetworks []netip
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// openTunDev opens /dev/net/tun, creating the device node first if it's
|
||||
// missing (docker containers occasionally omit it).
|
||||
func openTunDev() (int, error) {
|
||||
fd, err := unix.Open("/dev/net/tun", os.O_RDWR, 0)
|
||||
if err == nil {
|
||||
return fd, nil
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return -1, err
|
||||
}
|
||||
if err = os.MkdirAll("/dev/net", 0755); err != nil {
|
||||
return -1, fmt.Errorf("/dev/net/tun doesn't exist, failed to mkdir -p /dev/net: %w", err)
|
||||
}
|
||||
if err = unix.Mknod("/dev/net/tun", unix.S_IFCHR|0600, int(unix.Mkdev(10, 200))); err != nil {
|
||||
return -1, fmt.Errorf("failed to create /dev/net/tun: %w", err)
|
||||
}
|
||||
fd, err = unix.Open("/dev/net/tun", os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return -1, fmt.Errorf("created /dev/net/tun, but still failed: %w", err)
|
||||
}
|
||||
return fd, nil
|
||||
}
|
||||
|
||||
// tunSetIff runs TUNSETIFF with the given flags and returns the kernel-chosen
|
||||
// device name on success.
|
||||
func tunSetIff(fd int, name string, flags uint16) (string, error) {
|
||||
var req ifReq
|
||||
req.Flags = flags
|
||||
copy(req.Name[:], name)
|
||||
if err := ioctl(uintptr(fd), uintptr(unix.TUNSETIFF), uintptr(unsafe.Pointer(&req))); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.Trim(string(req.Name[:]), "\x00"), nil
|
||||
}
|
||||
|
||||
// tsoOffloadFlags are the TUN_F_* bits we ask the kernel to enable when a
|
||||
// TSO-capable TUN is available. CSUM is required as a prerequisite for TSO.
|
||||
// TSO_ECN tells the kernel we propagate ECN correctly through coalesce and
|
||||
// segmentation, so it can deliver superpackets whose seed has CWR/ECE set
|
||||
// or whose IP-level codepoint is CE.
|
||||
const tsoOffloadFlags = unix.TUN_F_CSUM | unix.TUN_F_TSO4 | unix.TUN_F_TSO6 | unix.TUN_F_TSO_ECN
|
||||
|
||||
// usoOffloadFlags adds UDP Segmentation Offload to tsoOffloadFlags. Requires
|
||||
// Linux ≥ 6.2; older kernels reject it and we fall back to TCP-only TSO via
|
||||
// tsoOffloadFlags.
|
||||
const usoOffloadFlags = tsoOffloadFlags | unix.TUN_F_USO4 | unix.TUN_F_USO6
|
||||
|
||||
func newTun(c *config.C, l *slog.Logger, vpnNetworks []netip.Prefix, multiqueue bool) (*tun, error) {
|
||||
baseFlags := uint16(unix.IFF_TUN | unix.IFF_NO_PI)
|
||||
if multiqueue {
|
||||
baseFlags |= unix.IFF_MULTI_QUEUE
|
||||
}
|
||||
nameStr := c.GetString("tun.dev", "")
|
||||
|
||||
// First try to enable IFF_VNET_HDR via TUNSETIFF and negotiate TUN_F_*
|
||||
// offloads via TUNSETOFFLOAD so we can receive TSO/USO superpackets.
|
||||
// We try TSO+USO first, fall back to TSO-only on kernels without USO
|
||||
// (Linux < 6.2), and finally give up on virtio headers entirely and
|
||||
// reopen as a plain TUN if neither offload mask is accepted.
|
||||
fd, err := openTunDev()
|
||||
fd, err := unix.Open("/dev/net/tun", os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vnetHdr := true
|
||||
usoEnabled := false
|
||||
name, err := tunSetIff(fd, nameStr, baseFlags|unix.IFF_VNET_HDR)
|
||||
if err != nil {
|
||||
_ = unix.Close(fd)
|
||||
vnetHdr = false
|
||||
} else {
|
||||
// Try TSO+USO first. On kernels without USO support (Linux < 6.2)
|
||||
// the ioctl returns EINVAL; fall back to the TCP-only mask before
|
||||
// giving up on VNET_HDR entirely.
|
||||
if err = ioctl(uintptr(fd), unix.TUNSETOFFLOAD, uintptr(usoOffloadFlags)); err == nil {
|
||||
usoEnabled = true
|
||||
} else if err = ioctl(uintptr(fd), unix.TUNSETOFFLOAD, uintptr(tsoOffloadFlags)); err != nil {
|
||||
l.Warn("Failed to enable TUN offload (TSO); proceeding without virtio headers", "error", err)
|
||||
_ = unix.Close(fd)
|
||||
vnetHdr = false
|
||||
}
|
||||
}
|
||||
// If /dev/net/tun doesn't exist, try to create it (will happen in docker)
|
||||
if os.IsNotExist(err) {
|
||||
err = os.MkdirAll("/dev/net", 0755)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("/dev/net/tun doesn't exist, failed to mkdir -p /dev/net: %w", err)
|
||||
}
|
||||
err = unix.Mknod("/dev/net/tun", unix.S_IFCHR|0600, int(unix.Mkdev(10, 200)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create /dev/net/tun: %w", err)
|
||||
}
|
||||
|
||||
if !vnetHdr {
|
||||
fd, err = openTunDev()
|
||||
if err != nil {
|
||||
fd, err = unix.Open("/dev/net/tun", os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("created /dev/net/tun, but still failed: %w", err)
|
||||
}
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
name, err = tunSetIff(fd, nameStr, baseFlags)
|
||||
if err != nil {
|
||||
_ = unix.Close(fd)
|
||||
return nil, &NameError{Name: nameStr, Underlying: err}
|
||||
}
|
||||
|
||||
var req ifReq
|
||||
req.Flags = uint16(unix.IFF_TUN | unix.IFF_NO_PI)
|
||||
if multiqueue {
|
||||
req.Flags |= unix.IFF_MULTI_QUEUE
|
||||
}
|
||||
nameStr := c.GetString("tun.dev", "")
|
||||
copy(req.Name[:], nameStr)
|
||||
if err = ioctl(uintptr(fd), uintptr(unix.TUNSETIFF), uintptr(unsafe.Pointer(&req))); err != nil {
|
||||
_ = unix.Close(fd)
|
||||
return nil, &NameError{
|
||||
Name: nameStr,
|
||||
Underlying: err,
|
||||
}
|
||||
}
|
||||
name := strings.Trim(string(req.Name[:]), "\x00")
|
||||
|
||||
if vnetHdr {
|
||||
l.Info("TUN offload enabled", "tso", true, "uso", usoEnabled)
|
||||
}
|
||||
|
||||
t, err := newTunGeneric(c, l, fd, vnetHdr, usoEnabled, vpnNetworks)
|
||||
t, err := newTunGeneric(c, l, fd, vpnNetworks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -201,34 +299,20 @@ func newTun(c *config.C, l *slog.Logger, vpnNetworks []netip.Prefix, multiqueue
|
||||
}
|
||||
|
||||
// newTunGeneric does all the stuff common to different tun initialization paths. It will close your files on error.
|
||||
func newTunGeneric(c *config.C, l *slog.Logger, fd int, vnetHdr, usoEnabled bool, vpnNetworks []netip.Prefix) (*tun, error) {
|
||||
var qs tio.QueueSet
|
||||
var err error
|
||||
if vnetHdr {
|
||||
qs, err = tio.NewOffloadQueueSet(usoEnabled)
|
||||
} else {
|
||||
qs, err = tio.NewPollQueueSet()
|
||||
}
|
||||
|
||||
func newTunGeneric(c *config.C, l *slog.Logger, fd int, vpnNetworks []netip.Prefix) (*tun, error) {
|
||||
tfd, err := newTunFd(fd)
|
||||
if err != nil {
|
||||
_ = unix.Close(fd)
|
||||
return nil, err
|
||||
}
|
||||
err = qs.Add(fd)
|
||||
if err != nil {
|
||||
_ = unix.Close(fd)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
t := &tun{
|
||||
readers: qs,
|
||||
tunFile: tfd,
|
||||
readers: []*tunFile{tfd},
|
||||
closeLock: sync.Mutex{},
|
||||
vnetHdr: vnetHdr,
|
||||
vpnNetworks: vpnNetworks,
|
||||
TXQueueLen: c.GetInt("tun.tx_queue", 500),
|
||||
useSystemRoutes: c.GetBool("tun.use_system_route_table", false),
|
||||
useSystemRoutesBufferSize: c.GetInt("tun.use_system_route_table_buffer_size", 0),
|
||||
routeFeatureECN: c.GetBool("tunnels.ecn", true),
|
||||
routesFromSystem: map[netip.Prefix]routing.Gateways{},
|
||||
l: l,
|
||||
}
|
||||
@@ -326,38 +410,32 @@ func (t *tun) SupportsMultiqueue() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *tun) NewMultiQueueReader() error {
|
||||
func (t *tun) NewMultiQueueReader() (io.ReadWriteCloser, error) {
|
||||
t.closeLock.Lock()
|
||||
defer t.closeLock.Unlock()
|
||||
|
||||
fd, err := unix.Open("/dev/net/tun", os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
flags := uint16(unix.IFF_TUN | unix.IFF_NO_PI | unix.IFF_MULTI_QUEUE)
|
||||
if t.vnetHdr {
|
||||
flags |= unix.IFF_VNET_HDR
|
||||
}
|
||||
if _, err = tunSetIff(fd, t.Device, flags); err != nil {
|
||||
var req ifReq
|
||||
req.Flags = uint16(unix.IFF_TUN | unix.IFF_NO_PI | unix.IFF_MULTI_QUEUE)
|
||||
copy(req.Name[:], t.Device)
|
||||
if err = ioctl(uintptr(fd), uintptr(unix.TUNSETIFF), uintptr(unsafe.Pointer(&req))); err != nil {
|
||||
_ = unix.Close(fd)
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if t.vnetHdr {
|
||||
if err = ioctl(uintptr(fd), unix.TUNSETOFFLOAD, uintptr(tsoOffloadFlags)); err != nil {
|
||||
_ = unix.Close(fd)
|
||||
return fmt.Errorf("failed to enable offload on multiqueue tun fd: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = t.readers.Add(fd)
|
||||
out, err := t.tunFile.newFriend(fd)
|
||||
if err != nil {
|
||||
_ = unix.Close(fd)
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil
|
||||
t.readers = append(t.readers, out)
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (t *tun) RoutesFor(ip netip.Addr) routing.Gateways {
|
||||
@@ -525,18 +603,6 @@ func (t *tun) setDefaultRoute(cidr netip.Prefix) error {
|
||||
Table: unix.RT_TABLE_MAIN,
|
||||
Type: unix.RTN_UNICAST,
|
||||
}
|
||||
// Match the metric the kernel uses for its auto-installed connected
|
||||
// route, so RouteReplace overwrites it in place instead of adding a
|
||||
// second route at a worse metric. IPv6 connected routes are installed
|
||||
// at metric 256 (IP6_RT_PRIO_KERN); IPv4 uses 0. Without this, the
|
||||
// kernel route wins lookups and our MTU / AdvMSS / Features never
|
||||
// apply on v6.
|
||||
if cidr.Addr().Is6() {
|
||||
nr.Priority = 256
|
||||
}
|
||||
if t.routeFeatureECN {
|
||||
nr.Features |= unix.RTAX_FEATURE_ECN
|
||||
}
|
||||
err := netlink.RouteReplace(&nr)
|
||||
if err != nil {
|
||||
t.l.Warn("Failed to set default route MTU, retrying", "error", err, "cidr", cidr)
|
||||
@@ -586,9 +652,6 @@ func (t *tun) addRoutes(logErrors bool) error {
|
||||
if r.Metric > 0 {
|
||||
nr.Priority = r.Metric
|
||||
}
|
||||
if t.routeFeatureECN {
|
||||
nr.Features |= unix.RTAX_FEATURE_ECN
|
||||
}
|
||||
|
||||
err := netlink.RouteReplace(&nr)
|
||||
if err != nil {
|
||||
@@ -806,10 +869,6 @@ func (t *tun) updateRoutes(r netlink.RouteUpdate) {
|
||||
t.routeTree.Store(newTree)
|
||||
}
|
||||
|
||||
func (t *tun) Readers() []tio.Queue {
|
||||
return t.readers.Queues()
|
||||
}
|
||||
|
||||
func (t *tun) Close() error {
|
||||
t.closeLock.Lock()
|
||||
defer t.closeLock.Unlock()
|
||||
@@ -819,10 +878,32 @@ func (t *tun) Close() error {
|
||||
t.routeChan = nil
|
||||
}
|
||||
|
||||
// Signal all readers blocked in poll to wake up and exit
|
||||
_ = t.tunFile.wakeForShutdown()
|
||||
|
||||
if t.ioctlFd > 0 {
|
||||
_ = unix.Close(int(t.ioctlFd))
|
||||
t.ioctlFd = 0
|
||||
}
|
||||
|
||||
return t.readers.Close()
|
||||
for i := range t.readers {
|
||||
if i == 0 {
|
||||
continue //we want to close the zeroth reader last
|
||||
}
|
||||
err := t.readers[i].Close()
|
||||
if err != nil {
|
||||
t.l.Error("error closing tun reader", "reader", i, "error", err)
|
||||
} else {
|
||||
t.l.Info("closed tun reader", "reader", i)
|
||||
}
|
||||
}
|
||||
|
||||
//this is t.readers[0] too
|
||||
err := t.tunFile.Close()
|
||||
if err != nil {
|
||||
t.l.Error("error closing tun reader", "reader", 0, "error", err)
|
||||
} else {
|
||||
t.l.Info("closed tun reader", "reader", 0)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
var runAdvMSSTests = []struct {
|
||||
name string
|
||||
|
||||
+4
-26
@@ -6,6 +6,7 @@ package overlay
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"os"
|
||||
@@ -16,10 +17,8 @@ import (
|
||||
|
||||
"github.com/gaissmai/bart"
|
||||
"github.com/slackhq/nebula/config"
|
||||
"github.com/slackhq/nebula/overlay/tio"
|
||||
"github.com/slackhq/nebula/routing"
|
||||
"github.com/slackhq/nebula/util"
|
||||
"github.com/slackhq/nebula/wire"
|
||||
netroute "golang.org/x/net/route"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
@@ -69,27 +68,6 @@ type tun struct {
|
||||
fd int
|
||||
}
|
||||
|
||||
func (t *tun) Read(p []wire.TunPacket, mem []byte) (int, error) {
|
||||
if len(p) == 0 || len(mem) == 0 {
|
||||
return 0, nil //todo should this be an err?
|
||||
}
|
||||
p[0].Meta = struct{}{}
|
||||
n, err := t.readOne(mem)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
p[0].Bytes = mem[:n]
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (t *tun) Readers() []tio.Queue {
|
||||
return []tio.Queue{t}
|
||||
}
|
||||
|
||||
func (t *tun) Capabilities() tio.Capabilities {
|
||||
return tio.Capabilities{}
|
||||
}
|
||||
|
||||
var deviceNameRE = regexp.MustCompile(`^tun[0-9]+$`)
|
||||
|
||||
func newTunFromFd(_ *config.C, _ *slog.Logger, _ int, _ []netip.Prefix) (*tun, error) {
|
||||
@@ -163,7 +141,7 @@ func (t *tun) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *tun) readOne(to []byte) (int, error) {
|
||||
func (t *tun) Read(to []byte) (int, error) {
|
||||
rc, err := t.f.SyscallConn()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get syscall conn for tun: %w", err)
|
||||
@@ -416,8 +394,8 @@ func (t *tun) SupportsMultiqueue() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *tun) NewMultiQueueReader() error {
|
||||
return fmt.Errorf("TODO: multiqueue not implemented for netbsd")
|
||||
func (t *tun) NewMultiQueueReader() (io.ReadWriteCloser, error) {
|
||||
return nil, fmt.Errorf("TODO: multiqueue not implemented for netbsd")
|
||||
}
|
||||
|
||||
func (t *tun) addRoutes(logErrors bool) error {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user