mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-21 12:59:08 +02:00
Merge remote-tracking branch 'origin/main' into feat-post_quantum_ml_kem
# Conflicts: # client/internal/peer/status.go # client/status/status.go
This commit is contained in:
@@ -52,7 +52,7 @@ jobs:
|
|||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: /tmp/.buildx-cache
|
path: /tmp/.buildx-cache
|
||||||
key: ${{ runner.os }}-anet-e2e-buildx-${{ hashFiles('go.sum', 'combined/Dockerfile.multistage', 'proxy/Dockerfile.multistage', 'e2e/harness/Dockerfile.client') }}
|
key: ${{ runner.os }}-anet-e2e-buildx-${{ hashFiles('go.sum', 'combined/Dockerfile.multistage', 'proxy/Dockerfile.multistage', 'e2e/harness/Dockerfile.client', 'e2e/harness/Dockerfile.nblink') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-anet-e2e-buildx-
|
${{ runner.os }}-anet-e2e-buildx-
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ jobs:
|
|||||||
while IFS= read -r dir; do
|
while IFS= read -r dir; do
|
||||||
echo "=== Checking $dir ==="
|
echo "=== Checking $dir ==="
|
||||||
# Search for problematic imports, excluding test files
|
# Search for problematic imports, excluding test files
|
||||||
RESULTS=$(grep -r "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\)" "$dir" --include="*.go" 2>/dev/null | grep -v "_test.go" | grep -v "test_" | grep -v "/test/" | grep -v "tools/idp-migrate/" || true)
|
RESULTS=$(grep -r "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\)" "$dir" --include="*.go" 2>/dev/null | grep -v "_test.go" | grep -v "test_" | grep -v "/test/" | grep -v "tools/idp-migrate/" | grep -v "tools/mysql-migrate/" || true)
|
||||||
if [ -n "$RESULTS" ]; then
|
if [ -n "$RESULTS" ]; then
|
||||||
echo "❌ Found problematic dependencies:"
|
echo "❌ Found problematic dependencies:"
|
||||||
echo "$RESULTS"
|
echo "$RESULTS"
|
||||||
@@ -93,7 +93,7 @@ jobs:
|
|||||||
IMPORTERS=$(go list -json -deps ./... 2>/dev/null | jq -r "select(.Imports[]? == \"$package\") | .ImportPath")
|
IMPORTERS=$(go list -json -deps ./... 2>/dev/null | jq -r "select(.Imports[]? == \"$package\") | .ImportPath")
|
||||||
|
|
||||||
# Check if any importer is NOT in management/signal/relay
|
# Check if any importer is NOT in management/signal/relay
|
||||||
BSD_IMPORTER=$(echo "$IMPORTERS" | grep -v "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\|combined\|tools/idp-migrate\)" | head -1)
|
BSD_IMPORTER=$(echo "$IMPORTERS" | grep -v "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\|combined\|tools/idp-migrate\|tools/mysql-migrate\)" | head -1)
|
||||||
|
|
||||||
if [ -n "$BSD_IMPORTER" ]; then
|
if [ -n "$BSD_IMPORTER" ]; then
|
||||||
echo "❌ $package ($license) is imported by BSD-licensed code: $BSD_IMPORTER"
|
echo "❌ $package ($license) is imported by BSD-licensed code: $BSD_IMPORTER"
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ jobs:
|
|||||||
docs-ack:
|
docs-ack:
|
||||||
name: Require docs PR URL or explicit "not needed"
|
name: Require docs PR URL or explicit "not needed"
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
# Crowdin's translation-sync service PRs are auto-generated without the PR template.
|
||||||
|
if: github.event.pull_request.user.login != 'netbirddev'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Read PR body
|
- name: Read PR body
|
||||||
|
|||||||
@@ -207,6 +207,43 @@ jobs:
|
|||||||
# regression test, and need no frontend bundle.
|
# regression test, and need no frontend bundle.
|
||||||
run: CGO_ENABLED=1 go test -timeout 5m ./client/ui/authsession/... ./client/ui/i18n/... ./client/ui/preferences/... ./client/ui/services/...
|
run: CGO_ENABLED=1 go test -timeout 5m ./client/ui/authsession/... ./client/ui/i18n/... ./client/ui/preferences/... ./client/ui/services/...
|
||||||
|
|
||||||
|
test_client_pkcs11:
|
||||||
|
name: "Client PKCS#11 / Unit"
|
||||||
|
# The deb and rpm packages ship the client built with the pkcs11 tag, which loads
|
||||||
|
# PKCS#11 modules through purego. The other jobs build without the tag, so this one
|
||||||
|
# compiles that driver and signs with a real SoftHSM token, the build release ships.
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Install Go
|
||||||
|
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||||
|
with:
|
||||||
|
go-version-file: "go.mod"
|
||||||
|
cache: false
|
||||||
|
|
||||||
|
- name: Install SoftHSM
|
||||||
|
run: sudo apt update && sudo apt install -y -q softhsm2
|
||||||
|
|
||||||
|
- name: Initialise token
|
||||||
|
run: |
|
||||||
|
mkdir -p "$RUNNER_TEMP/softhsm/tokens"
|
||||||
|
printf 'directories.tokendir = %s\nobjectstore.backend = file\n' "$RUNNER_TEMP/softhsm/tokens" > "$RUNNER_TEMP/softhsm/softhsm2.conf"
|
||||||
|
echo "SOFTHSM2_CONF=$RUNNER_TEMP/softhsm/softhsm2.conf" >> "$GITHUB_ENV"
|
||||||
|
SOFTHSM2_CONF="$RUNNER_TEMP/softhsm/softhsm2.conf" softhsm2-util --init-token --free --label netbird --pin 1234 --so-pin 1234
|
||||||
|
|
||||||
|
- name: Vet
|
||||||
|
run: CGO_ENABLED=0 go vet -tags pkcs11 ./client/internal/pkcs11/... ./client/internal/certproof/...
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
env:
|
||||||
|
NB_TEST_PKCS11_URI: "pkcs11:token=netbird?module-path=/usr/lib/softhsm/libsofthsm2.so&pin-value=1234"
|
||||||
|
NB_TEST_PKCS11_DISPOSABLE: "1"
|
||||||
|
run: CGO_ENABLED=0 go test -tags pkcs11 -timeout 5m ./client/internal/pkcs11/... ./client/internal/certproof/...
|
||||||
|
|
||||||
test_client_on_docker:
|
test_client_on_docker:
|
||||||
name: "Client (Docker) / Unit"
|
name: "Client (Docker) / Unit"
|
||||||
needs: [build-cache]
|
needs: [build-cache]
|
||||||
@@ -270,7 +307,7 @@ jobs:
|
|||||||
sh -c ' \
|
sh -c ' \
|
||||||
apk update; apk add --no-cache \
|
apk update; apk add --no-cache \
|
||||||
ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \
|
ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \
|
||||||
go test -buildvcs=false -tags "devcert privileged" -v -timeout 10m -p 1 $(go list -e -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server -e /client/testutil/privileged)
|
go test -buildvcs=false -tags "devcert privileged" -v -timeout 10m -p 1 $(go list -e -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server -e /client/testutil/privileged -e tools/mysql-migrate)
|
||||||
'
|
'
|
||||||
|
|
||||||
test_relay:
|
test_relay:
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ jobs:
|
|||||||
# resolve; the Where-Object pipeline then drops the broken package by
|
# resolve; the Where-Object pipeline then drops the broken package by
|
||||||
# path. Without -e, go list aborts with empty stdout.
|
# path. Without -e, go list aborts with empty stdout.
|
||||||
run: |
|
run: |
|
||||||
$packages = go list -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui$' }
|
$packages = go list -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui$' } | Where-Object { $_ -notmatch '/tools/mysql-migrate' }
|
||||||
$goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe"
|
$goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe"
|
||||||
$cmd = "$goExe test -tags `"devcert privileged`" -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1"
|
$cmd = "$goExe test -tags `"devcert privileged`" -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1"
|
||||||
Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd
|
Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
# segment by codespell and behave the same across versions; the
|
# segment by codespell and behave the same across versions; the
|
||||||
# recursive "**" form did not take effect with the codespell shipped
|
# recursive "**" form did not take effect with the codespell shipped
|
||||||
# by this action.
|
# by this action.
|
||||||
skip: go.mod,go.sum,*/proxy/web/*,*pnpm-lock.yaml,*package-lock.json,*/locales/de/*,*/locales/es/*,*/locales/fr/*,*/locales/hu/*,*/locales/it/*,*/locales/pt/*,*/locales/ru/*,*/locales/zh-CN/*,*/i18n/TRANSLATING.md
|
skip: go.mod,go.sum,*/proxy/web/*,*pnpm-lock.yaml,*package-lock.json,*/locales/de/*,*/locales/es/*,*/locales/fr/*,*/locales/gl/*,*/locales/hu/*,*/locales/it/*,*/locales/pt/*,*/locales/ru/*,*/locales/zh-CN/*,*/i18n/TRANSLATING.md
|
||||||
golangci:
|
golangci:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
check-title:
|
check-title:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
# Crowdin's translation-sync service PRs are auto-generated with a fixed title.
|
||||||
|
if: github.event.pull_request.user.login != 'netbirddev'
|
||||||
steps:
|
steps:
|
||||||
- name: Validate PR title prefix
|
- name: Validate PR title prefix
|
||||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ on:
|
|||||||
- all
|
- all
|
||||||
- client-rootless
|
- client-rootless
|
||||||
- reverse-proxy
|
- reverse-proxy
|
||||||
|
- netbird-server
|
||||||
|
- management
|
||||||
|
- signal
|
||||||
|
- relay
|
||||||
version:
|
version:
|
||||||
description: "Released version, e.g. v0.80.0"
|
description: "Released version, e.g. v0.80.0"
|
||||||
type: string
|
type: string
|
||||||
@@ -66,6 +70,10 @@ jobs:
|
|||||||
components=(
|
components=(
|
||||||
"client-rootless ghcr.io/netbirdio/netbird -rootless-ubi"
|
"client-rootless ghcr.io/netbirdio/netbird -rootless-ubi"
|
||||||
"reverse-proxy ghcr.io/netbirdio/reverse-proxy -ubi"
|
"reverse-proxy ghcr.io/netbirdio/reverse-proxy -ubi"
|
||||||
|
"netbird-server ghcr.io/netbirdio/netbird-server -ubi"
|
||||||
|
"management ghcr.io/netbirdio/management -ubi"
|
||||||
|
"signal ghcr.io/netbirdio/signal -ubi"
|
||||||
|
"relay ghcr.io/netbirdio/relay -ubi"
|
||||||
)
|
)
|
||||||
matrix="[]"
|
matrix="[]"
|
||||||
missing=()
|
missing=()
|
||||||
@@ -152,8 +160,8 @@ jobs:
|
|||||||
- name: Fail if any check did not pass
|
- name: Fail if any check did not pass
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
shopt -s nullglob
|
shopt -s nullglob globstar
|
||||||
results=(artifacts/results.json artifacts/*/results.json)
|
results=(artifacts/**/results.json)
|
||||||
if [[ ${#results[@]} -eq 0 ]]; then
|
if [[ ${#results[@]} -eq 0 ]]; then
|
||||||
echo "::error::preflight produced no results.json"
|
echo "::error::preflight produced no results.json"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -23,8 +23,50 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
freebsd_port_trigger:
|
||||||
|
name: "FreeBSD Port / Trigger"
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
outputs:
|
||||||
|
run: ${{ steps.decide.outputs.run }}
|
||||||
|
version: ${{ steps.decide.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Decide whether the port job runs
|
||||||
|
id: decide
|
||||||
|
env:
|
||||||
|
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
run=false
|
||||||
|
case "$GITHUB_EVENT_NAME" in
|
||||||
|
push)
|
||||||
|
if [[ "$GITHUB_REF" == "refs/heads/main" ]]; then
|
||||||
|
run=true
|
||||||
|
elif [[ "$GITHUB_REF" == refs/tags/* && "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
# The port tracks stable releases only, so a canary or rc tag has nothing to submit.
|
||||||
|
run=true
|
||||||
|
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
pull_request)
|
||||||
|
git fetch --depth=1 origin "$BASE_SHA"
|
||||||
|
if git diff --name-only "$BASE_SHA" HEAD | \
|
||||||
|
grep -qE '^(release_files/freebsd-port-.*\.sh|\.github/workflows/release\.yml)$'; then
|
||||||
|
run=true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
echo "run=$run" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "FreeBSD port job runs: $run"
|
||||||
|
|
||||||
release_freebsd_port:
|
release_freebsd_port:
|
||||||
name: "FreeBSD Port / Build & Test"
|
name: "FreeBSD Port / Build & Test"
|
||||||
|
needs: freebsd_port_trigger
|
||||||
|
if: needs.freebsd_port_trigger.outputs.run == 'true'
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -33,18 +75,19 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Generate FreeBSD port diff
|
- name: Generate FreeBSD port diff
|
||||||
run: bash -x release_files/freebsd-port-diff.sh
|
env:
|
||||||
|
TARGET_VERSION: ${{ needs.freebsd_port_trigger.outputs.version }}
|
||||||
- name: Generate FreeBSD port issue body
|
run: bash -x release_files/freebsd-port-diff.sh "$TARGET_VERSION"
|
||||||
run: bash -x release_files/freebsd-port-issue-body.sh
|
|
||||||
|
|
||||||
- name: Check if diff was generated
|
- name: Check if diff was generated
|
||||||
id: check_diff
|
id: check_diff
|
||||||
run: |
|
run: |
|
||||||
if ls netbird-*.diff 1> /dev/null 2>&1; then
|
shopt -s nullglob
|
||||||
echo "diff_exists=true" >> $GITHUB_OUTPUT
|
diffs=(netbird-*.diff)
|
||||||
|
if [[ ${#diffs[@]} -gt 0 ]]; then
|
||||||
|
echo "diff_exists=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "diff_exists=false" >> $GITHUB_OUTPUT
|
echo "diff_exists=false" >> "$GITHUB_OUTPUT"
|
||||||
echo "No diff file generated (port may already be up to date)"
|
echo "No diff file generated (port may already be up to date)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -52,10 +95,18 @@ jobs:
|
|||||||
if: steps.check_diff.outputs.diff_exists == 'true'
|
if: steps.check_diff.outputs.diff_exists == 'true'
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(ls netbird-*.diff | sed 's/netbird-\(.*\)\.diff/\1/')
|
diffs=(netbird-*.diff)
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
VERSION="${diffs[0]#netbird-}"
|
||||||
|
VERSION="${VERSION%.diff}"
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
echo "Generated files for version: $VERSION"
|
echo "Generated files for version: $VERSION"
|
||||||
cat netbird-*.diff
|
cat "${diffs[0]}"
|
||||||
|
|
||||||
|
- name: Generate FreeBSD port issue body
|
||||||
|
if: steps.check_diff.outputs.diff_exists == 'true'
|
||||||
|
env:
|
||||||
|
TARGET_VERSION: ${{ steps.version.outputs.version }}
|
||||||
|
run: bash -x release_files/freebsd-port-issue-body.sh "" "$TARGET_VERSION"
|
||||||
|
|
||||||
- name: Read Go version from go.mod
|
- name: Read Go version from go.mod
|
||||||
id: goversion
|
id: goversion
|
||||||
@@ -199,7 +250,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
- name: Install proxy web dependencies for license collection
|
- name: Install proxy web dependencies for license collection
|
||||||
# proxy/collect-licenses.sh reads the UI's license terms from node_modules.
|
# release_files/collect-licenses.sh -w reads the proxy UI's license terms from node_modules.
|
||||||
working-directory: proxy/web
|
working-directory: proxy/web
|
||||||
run: npm ci --ignore-scripts
|
run: npm ci --ignore-scripts
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
@@ -363,7 +414,11 @@ jobs:
|
|||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||||
with:
|
with:
|
||||||
name: linux-packages
|
name: linux-packages
|
||||||
path: dist/netbird_linux**
|
path: |
|
||||||
|
dist/netbird_linux**
|
||||||
|
dist/netbird-pkcs11_linux**
|
||||||
|
dist/netbird-deb-32bit_linux**
|
||||||
|
dist/netbird-rpm-*_linux**
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
- name: upload windows packages
|
- name: upload windows packages
|
||||||
id: upload_windows_packages
|
id: upload_windows_packages
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
name: Reverse Proxy E2E
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- '.github/workflows/reverse-proxy-e2e.yml'
|
||||||
|
- '**/*.go'
|
||||||
|
- 'e2e/**'
|
||||||
|
- 'shared/management/**'
|
||||||
|
- 'combined/Dockerfile*'
|
||||||
|
- 'proxy/Dockerfile*'
|
||||||
|
- '.dockerignore'
|
||||||
|
- 'go.mod'
|
||||||
|
- 'go.sum'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
reverse-proxy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Install Go
|
||||||
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Set up Buildx
|
||||||
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||||
|
|
||||||
|
- name: Cache Docker layers
|
||||||
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
|
with:
|
||||||
|
path: /tmp/.reverse-proxy-buildx-cache
|
||||||
|
key: ${{ runner.os }}-reverse-proxy-e2e-${{ hashFiles('go.sum', 'combined/Dockerfile.multistage', 'proxy/Dockerfile.multistage') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-reverse-proxy-e2e-
|
||||||
|
|
||||||
|
- name: Run reverse-proxy E2E
|
||||||
|
env:
|
||||||
|
NB_E2E_BUILDX_CACHE: /tmp/.reverse-proxy-buildx-cache
|
||||||
|
run: go test -tags e2e -timeout 25m -v ./e2e/reverseproxy/...
|
||||||
@@ -258,6 +258,18 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Verify enterprise Traefik tag validation
|
||||||
|
run: bash infrastructure_files/tests/test-getting-started-enterprise.sh
|
||||||
|
|
||||||
|
- name: Verify community Traefik image validation
|
||||||
|
run: bash infrastructure_files/tests/test-getting-started.sh
|
||||||
|
|
||||||
|
- name: Verify community Compose network scoping
|
||||||
|
run: bash infrastructure_files/tests/test-getting-started-networks.sh
|
||||||
|
|
||||||
|
- name: Verify migration Compose network scoping
|
||||||
|
run: bash infrastructure_files/tests/test-getting-started-networks.sh --migration
|
||||||
|
|
||||||
- name: Verify fresh-install session cookie key hardening
|
- name: Verify fresh-install session cookie key hardening
|
||||||
run: |
|
run: |
|
||||||
grep -Fxq ' SESSION_COOKIE_ENCRYPTION_KEY=$(openssl rand -base64 32)' infrastructure_files/getting-started.sh
|
grep -Fxq ' SESSION_COOKIE_ENCRYPTION_KEY=$(openssl rand -base64 32)' infrastructure_files/getting-started.sh
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
|
|
||||||
# English (en) is the source of truth for translation keys; every other
|
# English (en) is the source of truth for translation keys. Locales declared
|
||||||
# locale declared in _index.json must carry the exact same key set.
|
# in _index.json fail on orphaned keys or placeholder mismatches; missing
|
||||||
|
# keys only warn, since they fall back to English at runtime.
|
||||||
- name: Check translation key parity
|
- name: Check translation key parity
|
||||||
run: node client/ui/i18n/check-translations.mjs
|
run: node client/ui/i18n/check-translations.mjs
|
||||||
|
|||||||
+269
-4
@@ -40,7 +40,43 @@ builds:
|
|||||||
tags:
|
tags:
|
||||||
- load_wgnt_from_rsrc
|
- load_wgnt_from_rsrc
|
||||||
|
|
||||||
# Single-arch builds: nfpm provides is not templated, so the RPM splits per arch.
|
# deb and rpm packages target glibc distributions, so on amd64 and arm64, the only
|
||||||
|
# architectures with a PKCS#11 driver, they carry the PKCS#11 store, which links libc.
|
||||||
|
# Tarballs, the Alpine-based images and the other architectures keep the static build.
|
||||||
|
- id: netbird-pkcs11
|
||||||
|
dir: client
|
||||||
|
binary: netbird
|
||||||
|
env: [CGO_ENABLED=0]
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
ldflags:
|
||||||
|
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||||
|
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||||
|
tags:
|
||||||
|
- load_wgnt_from_rsrc
|
||||||
|
- pkcs11
|
||||||
|
|
||||||
|
# The 32-bit deb architectures, which have no PKCS#11 driver.
|
||||||
|
- id: netbird-deb-32bit
|
||||||
|
dir: client
|
||||||
|
binary: netbird
|
||||||
|
env: [CGO_ENABLED=0]
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
goarch:
|
||||||
|
- arm
|
||||||
|
- 386
|
||||||
|
ldflags:
|
||||||
|
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||||
|
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||||
|
tags:
|
||||||
|
- load_wgnt_from_rsrc
|
||||||
|
|
||||||
|
# Single-arch builds: nfpm provides is not templated, so the RPM splits per arch. The
|
||||||
|
# ones with a PKCS#11 driver carry the store like the deb build above.
|
||||||
- &netbird_rpm_build
|
- &netbird_rpm_build
|
||||||
id: netbird-rpm-amd64
|
id: netbird-rpm-amd64
|
||||||
dir: client
|
dir: client
|
||||||
@@ -53,6 +89,7 @@ builds:
|
|||||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||||
tags:
|
tags:
|
||||||
- load_wgnt_from_rsrc
|
- load_wgnt_from_rsrc
|
||||||
|
- pkcs11
|
||||||
|
|
||||||
- <<: *netbird_rpm_build
|
- <<: *netbird_rpm_build
|
||||||
id: netbird-rpm-arm64
|
id: netbird-rpm-arm64
|
||||||
@@ -61,10 +98,14 @@ builds:
|
|||||||
- <<: *netbird_rpm_build
|
- <<: *netbird_rpm_build
|
||||||
id: netbird-rpm-arm
|
id: netbird-rpm-arm
|
||||||
goarch: [arm]
|
goarch: [arm]
|
||||||
|
tags:
|
||||||
|
- load_wgnt_from_rsrc
|
||||||
|
|
||||||
- <<: *netbird_rpm_build
|
- <<: *netbird_rpm_build
|
||||||
id: netbird-rpm-386
|
id: netbird-rpm-386
|
||||||
goarch: [386]
|
goarch: [386]
|
||||||
|
tags:
|
||||||
|
- load_wgnt_from_rsrc
|
||||||
|
|
||||||
- id: netbird-static
|
- id: netbird-static
|
||||||
dir: client
|
dir: client
|
||||||
@@ -194,6 +235,29 @@ builds:
|
|||||||
- -s -w -X main.Version={{.Version}} -X main.Commit={{.Commit}} -X main.BuildDate={{.CommitDate}}
|
- -s -w -X main.Version={{.Version}} -X main.Commit={{.Commit}} -X main.BuildDate={{.CommitDate}}
|
||||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||||
|
|
||||||
|
- id: nblink
|
||||||
|
dir: client/link/cmd/nblink
|
||||||
|
env: [CGO_ENABLED=0]
|
||||||
|
binary: nblink
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
- darwin
|
||||||
|
- windows
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
- arm
|
||||||
|
goarm:
|
||||||
|
- 7
|
||||||
|
ignore:
|
||||||
|
- goos: windows
|
||||||
|
goarch: arm
|
||||||
|
- goos: darwin
|
||||||
|
goarch: arm
|
||||||
|
ldflags:
|
||||||
|
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}}
|
||||||
|
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||||
|
|
||||||
- id: netbird-idp-migrate
|
- id: netbird-idp-migrate
|
||||||
dir: tools/idp-migrate
|
dir: tools/idp-migrate
|
||||||
env:
|
env:
|
||||||
@@ -216,6 +280,28 @@ builds:
|
|||||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||||
|
|
||||||
|
- id: netbird-mysql-migrate
|
||||||
|
dir: tools/mysql-migrate
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=1
|
||||||
|
- >-
|
||||||
|
{{- if eq .Runtime.Goos "linux" }}
|
||||||
|
{{- if eq .Arch "arm64"}}CC=aarch64-linux-gnu-gcc{{- end }}
|
||||||
|
{{- if eq .Arch "arm"}}CC=arm-linux-gnueabihf-gcc{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
binary: netbird-mysql-migrate
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
- arm
|
||||||
|
goarm:
|
||||||
|
- 7
|
||||||
|
ldflags:
|
||||||
|
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||||
|
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||||
|
|
||||||
universal_binaries:
|
universal_binaries:
|
||||||
- id: netbird
|
- id: netbird
|
||||||
|
|
||||||
@@ -232,6 +318,19 @@ archives:
|
|||||||
builds:
|
builds:
|
||||||
- netbird-idp-migrate
|
- netbird-idp-migrate
|
||||||
name_template: "netbird-idp-migrate_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
name_template: "netbird-idp-migrate_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||||
|
- id: netbird-mysql-migrate
|
||||||
|
builds:
|
||||||
|
- netbird-mysql-migrate
|
||||||
|
name_template: "netbird-mysql-migrate_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||||
|
- id: nblink
|
||||||
|
builds:
|
||||||
|
- nblink
|
||||||
|
name_template: "nblink_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||||
|
# goreleaser archives as tar.gz everywhere unless told otherwise, and
|
||||||
|
# nblink is downloaded and extracted by hand on Windows too.
|
||||||
|
format_overrides:
|
||||||
|
- goos: windows
|
||||||
|
format: zip
|
||||||
|
|
||||||
nfpms:
|
nfpms:
|
||||||
- maintainer: Netbird <dev@netbird.io>
|
- maintainer: Netbird <dev@netbird.io>
|
||||||
@@ -242,7 +341,8 @@ nfpms:
|
|||||||
id: netbird_deb
|
id: netbird_deb
|
||||||
bindir: /usr/bin
|
bindir: /usr/bin
|
||||||
builds:
|
builds:
|
||||||
- netbird
|
- netbird-pkcs11
|
||||||
|
- netbird-deb-32bit
|
||||||
formats:
|
formats:
|
||||||
- deb
|
- deb
|
||||||
scripts:
|
scripts:
|
||||||
@@ -285,6 +385,9 @@ nfpms:
|
|||||||
- src: release_files/netbird.sysconfig
|
- src: release_files/netbird.sysconfig
|
||||||
dst: /etc/sysconfig/netbird
|
dst: /etc/sysconfig/netbird
|
||||||
type: config|noreplace
|
type: config|noreplace
|
||||||
|
# May hold the PKCS#11 token PIN (NB_TPM_PIN), so readable by root alone.
|
||||||
|
file_info:
|
||||||
|
mode: 0600
|
||||||
scripts:
|
scripts:
|
||||||
postinstall: "release_files/post_install.sh"
|
postinstall: "release_files/post_install.sh"
|
||||||
preremove: "release_files/pre_remove.sh"
|
preremove: "release_files/pre_remove.sh"
|
||||||
@@ -385,7 +488,7 @@ dockers_v2:
|
|||||||
RELEASE: "{{ .Timestamp }}"
|
RELEASE: "{{ .Timestamp }}"
|
||||||
hooks:
|
hooks:
|
||||||
pre:
|
pre:
|
||||||
- cmd: 'sh client/collect-licenses.sh "{{ .ContextDir }}/licenses" amd64 arm64'
|
- cmd: 'sh release_files/collect-licenses.sh -t load_wgnt_from_rsrc "{{ .ContextDir }}/licenses" ./client amd64 arm64'
|
||||||
env:
|
env:
|
||||||
- GOOS=linux
|
- GOOS=linux
|
||||||
- CGO_ENABLED=0
|
- CGO_ENABLED=0
|
||||||
@@ -423,6 +526,41 @@ dockers_v2:
|
|||||||
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||||
"org.opencontainers.image.source": "{{.GitURL}}"
|
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||||
"maintainer": "dev@netbird.io"
|
"maintainer": "dev@netbird.io"
|
||||||
|
- id: relay-ubi
|
||||||
|
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||||
|
ids:
|
||||||
|
- netbird-relay
|
||||||
|
images:
|
||||||
|
- netbirdio/relay
|
||||||
|
- ghcr.io/netbirdio/relay
|
||||||
|
tags:
|
||||||
|
- "{{ .Version }}-ubi"
|
||||||
|
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}ubi-latest{{ end }}"
|
||||||
|
dockerfile: relay/Dockerfile.ubi
|
||||||
|
platforms:
|
||||||
|
- linux/amd64
|
||||||
|
- linux/arm64
|
||||||
|
build_args:
|
||||||
|
VERSION: "{{ .Version }}"
|
||||||
|
RELEASE: "{{ .Timestamp }}"
|
||||||
|
hooks:
|
||||||
|
pre:
|
||||||
|
- cmd: 'sh release_files/collect-licenses.sh -l relay/LICENSE "{{ .ContextDir }}/licenses" ./relay amd64 arm64'
|
||||||
|
env:
|
||||||
|
- GOOS=linux
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
labels:
|
||||||
|
"org.opencontainers.image.created": "{{.Date}}"
|
||||||
|
"org.opencontainers.image.version": "{{.Version}}"
|
||||||
|
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||||
|
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||||
|
annotations:
|
||||||
|
"org.opencontainers.image.created": "{{.Date}}"
|
||||||
|
"org.opencontainers.image.title": "{{.ProjectName}}"
|
||||||
|
"org.opencontainers.image.version": "{{.Version}}"
|
||||||
|
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||||
|
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||||
|
"maintainer": "dev@netbird.io"
|
||||||
- id: signal
|
- id: signal
|
||||||
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||||
ids:
|
ids:
|
||||||
@@ -445,6 +583,41 @@ dockers_v2:
|
|||||||
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||||
"org.opencontainers.image.source": "{{.GitURL}}"
|
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||||
"maintainer": "dev@netbird.io"
|
"maintainer": "dev@netbird.io"
|
||||||
|
- id: signal-ubi
|
||||||
|
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||||
|
ids:
|
||||||
|
- netbird-signal
|
||||||
|
images:
|
||||||
|
- netbirdio/signal
|
||||||
|
- ghcr.io/netbirdio/signal
|
||||||
|
tags:
|
||||||
|
- "{{ .Version }}-ubi"
|
||||||
|
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}ubi-latest{{ end }}"
|
||||||
|
dockerfile: signal/Dockerfile.ubi
|
||||||
|
platforms:
|
||||||
|
- linux/amd64
|
||||||
|
- linux/arm64
|
||||||
|
build_args:
|
||||||
|
VERSION: "{{ .Version }}"
|
||||||
|
RELEASE: "{{ .Timestamp }}"
|
||||||
|
hooks:
|
||||||
|
pre:
|
||||||
|
- cmd: 'sh release_files/collect-licenses.sh -l signal/LICENSE "{{ .ContextDir }}/licenses" ./signal amd64 arm64'
|
||||||
|
env:
|
||||||
|
- GOOS=linux
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
labels:
|
||||||
|
"org.opencontainers.image.created": "{{.Date}}"
|
||||||
|
"org.opencontainers.image.version": "{{.Version}}"
|
||||||
|
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||||
|
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||||
|
annotations:
|
||||||
|
"org.opencontainers.image.created": "{{.Date}}"
|
||||||
|
"org.opencontainers.image.title": "{{.ProjectName}}"
|
||||||
|
"org.opencontainers.image.version": "{{.Version}}"
|
||||||
|
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||||
|
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||||
|
"maintainer": "dev@netbird.io"
|
||||||
- id: management
|
- id: management
|
||||||
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||||
ids:
|
ids:
|
||||||
@@ -467,6 +640,41 @@ dockers_v2:
|
|||||||
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||||
"org.opencontainers.image.source": "{{.GitURL}}"
|
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||||
"maintainer": "dev@netbird.io"
|
"maintainer": "dev@netbird.io"
|
||||||
|
- id: management-ubi
|
||||||
|
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||||
|
ids:
|
||||||
|
- netbird-mgmt
|
||||||
|
images:
|
||||||
|
- netbirdio/management
|
||||||
|
- ghcr.io/netbirdio/management
|
||||||
|
tags:
|
||||||
|
- "{{ .Version }}-ubi"
|
||||||
|
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}ubi-latest{{ end }}"
|
||||||
|
dockerfile: management/Dockerfile.ubi
|
||||||
|
platforms:
|
||||||
|
- linux/amd64
|
||||||
|
- linux/arm64
|
||||||
|
build_args:
|
||||||
|
VERSION: "{{ .Version }}"
|
||||||
|
RELEASE: "{{ .Timestamp }}"
|
||||||
|
hooks:
|
||||||
|
pre:
|
||||||
|
- cmd: 'sh release_files/collect-licenses.sh -l management/LICENSE "{{ .ContextDir }}/licenses" ./management amd64 arm64'
|
||||||
|
env:
|
||||||
|
- GOOS=linux
|
||||||
|
- CGO_ENABLED=1
|
||||||
|
labels:
|
||||||
|
"org.opencontainers.image.created": "{{.Date}}"
|
||||||
|
"org.opencontainers.image.version": "{{.Version}}"
|
||||||
|
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||||
|
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||||
|
annotations:
|
||||||
|
"org.opencontainers.image.created": "{{.Date}}"
|
||||||
|
"org.opencontainers.image.title": "{{.ProjectName}}"
|
||||||
|
"org.opencontainers.image.version": "{{.Version}}"
|
||||||
|
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||||
|
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||||
|
"maintainer": "dev@netbird.io"
|
||||||
- id: upload
|
- id: upload
|
||||||
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||||
ids:
|
ids:
|
||||||
@@ -511,6 +719,41 @@ dockers_v2:
|
|||||||
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||||
"org.opencontainers.image.source": "{{.GitURL}}"
|
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||||
"maintainer": "dev@netbird.io"
|
"maintainer": "dev@netbird.io"
|
||||||
|
- id: netbird-server-ubi
|
||||||
|
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||||
|
ids:
|
||||||
|
- netbird-server
|
||||||
|
images:
|
||||||
|
- netbirdio/netbird-server
|
||||||
|
- ghcr.io/netbirdio/netbird-server
|
||||||
|
tags:
|
||||||
|
- "{{ .Version }}-ubi"
|
||||||
|
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}ubi-latest{{ end }}"
|
||||||
|
dockerfile: combined/Dockerfile.ubi
|
||||||
|
platforms:
|
||||||
|
- linux/amd64
|
||||||
|
- linux/arm64
|
||||||
|
build_args:
|
||||||
|
VERSION: "{{ .Version }}"
|
||||||
|
RELEASE: "{{ .Timestamp }}"
|
||||||
|
hooks:
|
||||||
|
pre:
|
||||||
|
- cmd: 'sh release_files/collect-licenses.sh -l combined/LICENSE "{{ .ContextDir }}/licenses" ./combined amd64 arm64'
|
||||||
|
env:
|
||||||
|
- GOOS=linux
|
||||||
|
- CGO_ENABLED=1
|
||||||
|
labels:
|
||||||
|
"org.opencontainers.image.created": "{{.Date}}"
|
||||||
|
"org.opencontainers.image.version": "{{.Version}}"
|
||||||
|
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||||
|
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||||
|
annotations:
|
||||||
|
"org.opencontainers.image.created": "{{.Date}}"
|
||||||
|
"org.opencontainers.image.title": "{{.ProjectName}}"
|
||||||
|
"org.opencontainers.image.version": "{{.Version}}"
|
||||||
|
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||||
|
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||||
|
"maintainer": "dev@netbird.io"
|
||||||
- id: netbird-proxy
|
- id: netbird-proxy
|
||||||
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||||
ids:
|
ids:
|
||||||
@@ -533,6 +776,28 @@ dockers_v2:
|
|||||||
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||||
"org.opencontainers.image.source": "{{.GitURL}}"
|
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||||
"maintainer": "dev@netbird.io"
|
"maintainer": "dev@netbird.io"
|
||||||
|
- id: nblink
|
||||||
|
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||||
|
ids:
|
||||||
|
- nblink
|
||||||
|
images:
|
||||||
|
- netbirdio/nblink
|
||||||
|
- ghcr.io/netbirdio/nblink
|
||||||
|
tags:
|
||||||
|
- "{{ .Version }}"
|
||||||
|
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
|
||||||
|
dockerfile: client/Dockerfile-nblink
|
||||||
|
platforms:
|
||||||
|
- linux/amd64
|
||||||
|
- linux/arm64
|
||||||
|
- linux/arm
|
||||||
|
annotations:
|
||||||
|
"org.opencontainers.image.created": "{{.Date}}"
|
||||||
|
"org.opencontainers.image.title": "nblink"
|
||||||
|
"org.opencontainers.image.version": "{{.Version}}"
|
||||||
|
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||||
|
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||||
|
"maintainer": "dev@netbird.io"
|
||||||
- id: proxy-ubi
|
- id: proxy-ubi
|
||||||
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||||
ids:
|
ids:
|
||||||
@@ -552,7 +817,7 @@ dockers_v2:
|
|||||||
RELEASE: "{{ .Timestamp }}"
|
RELEASE: "{{ .Timestamp }}"
|
||||||
hooks:
|
hooks:
|
||||||
pre:
|
pre:
|
||||||
- cmd: 'sh proxy/collect-licenses.sh "{{ .ContextDir }}/licenses" amd64 arm64'
|
- cmd: 'sh release_files/collect-licenses.sh -l proxy/LICENSE -w "{{ .ContextDir }}/licenses" ./proxy/cmd/proxy amd64 arm64'
|
||||||
env:
|
env:
|
||||||
- GOOS=linux
|
- GOOS=linux
|
||||||
- CGO_ENABLED=0
|
- CGO_ENABLED=0
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
This BSD‑3‑Clause license applies to all parts of the repository except for the directories management/, signal/, relay/ and combined/.
|
This BSD-3-Clause license applies to all parts of the repository except for the directories management/, signal/, relay/ and combined/.
|
||||||
Those directories are licensed under the GNU Affero General Public License version 3.0 (AGPLv3). See the respective LICENSE files inside each directory.
|
Those directories are licensed under the GNU Affero General Public License version 3.0 (AGPLv3). See the respective LICENSE files inside each directory.
|
||||||
|
|
||||||
BSD 3-Clause License
|
BSD 3-Clause License
|
||||||
|
|
||||||
Copyright (c) 2022 NetBird GmbH & AUTHORS
|
Copyright (c) 2022-2026 NetBird GmbH & AUTHORS
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
BSD 3-Clause License
|
BSD 3-Clause License
|
||||||
|
|
||||||
Copyright (c) 2022 NetBird GmbH & AUTHORS
|
Copyright (c) 2022-2026 NetBird GmbH & AUTHORS
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# nblink runs unprivileged: no TUN device, no capabilities, no host networking.
|
||||||
|
#
|
||||||
|
# The image is built for platforms that assign an arbitrary UID at run time, so
|
||||||
|
# it must not depend on a passwd entry and its state directory has to be
|
||||||
|
# writable by the root group rather than by one named user.
|
||||||
|
FROM alpine:3.22 AS build
|
||||||
|
|
||||||
|
# Group 0 owns the state directory so a container started as any UID in that
|
||||||
|
# group can still persist its identity. Mode 770 rather than g=u, because the
|
||||||
|
# directory holds the peer's keys and nothing outside owner and group should
|
||||||
|
# read them, including when the path is bind-mounted from a host.
|
||||||
|
RUN mkdir -p /tmp/state/nblink \
|
||||||
|
&& chgrp -R 0 /tmp/state/nblink \
|
||||||
|
&& chmod -R 770 /tmp/state/nblink
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/static-debian13:nonroot
|
||||||
|
|
||||||
|
ARG TARGETPLATFORM
|
||||||
|
COPY ${TARGETPLATFORM}/nblink /usr/local/bin/nblink
|
||||||
|
# COPY of a directory copies its contents and creates the target with mode
|
||||||
|
# 755, which group 0 cannot write. Copying the parent makes nblink/ itself the
|
||||||
|
# copied entry, so its mode survives.
|
||||||
|
COPY --from=build --chown=65532:0 /tmp/state/ /var/lib/
|
||||||
|
|
||||||
|
# Numeric so the runtime can substitute its own UID without a passwd lookup.
|
||||||
|
USER 65532:0
|
||||||
|
|
||||||
|
# No NB_STATE_DIR by default: state stays in memory, so the image runs on a
|
||||||
|
# read-only root filesystem without a volume. Set NB_STATE_DIR=/var/lib/nblink
|
||||||
|
# and mount a volume there to keep the peer identity; a volume mounted over
|
||||||
|
# the directory starts out with its group-0 ownership.
|
||||||
|
ENV NB_LOG_LEVEL=info
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/nblink"]
|
||||||
@@ -104,8 +104,7 @@ type Client struct {
|
|||||||
|
|
||||||
stateChangeMu sync.Mutex
|
stateChangeMu sync.Mutex
|
||||||
stateChangeSubID string
|
stateChangeSubID string
|
||||||
eventSub *peer.EventSubscription
|
// Closed to stop the watch goroutine from delivering buffered ticks to a
|
||||||
// Closed to stop the watch goroutines from delivering buffered items to a
|
|
||||||
// listener that has been removed or replaced. See stopStateChangeWatchLocked.
|
// listener that has been removed or replaced. See stopStateChangeWatchLocked.
|
||||||
stateChangeDone chan struct{}
|
stateChangeDone chan struct{}
|
||||||
|
|
||||||
|
|||||||
+39
-2
@@ -194,12 +194,49 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool) (*auth.TokenInfo, error) {
|
func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool) (*auth.TokenInfo, error) {
|
||||||
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV, profileLoginHint(a.cfgPath))
|
return a.foregroundGetTokenInfoFlow(authClient, urlOpener, isAndroidTV, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// foregroundGetTokenInfoFlow runs the interactive flow. sessionExtend tells the
|
||||||
|
// server the token will renew this peer's session rather than log a peer in, so
|
||||||
|
// it can rule out a silent authorization the IdP could answer from an unrelated
|
||||||
|
// account. See PKCEAuthorizationFlowRequest.
|
||||||
|
func (a *Auth) foregroundGetTokenInfoFlow(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool, sessionExtend bool) (*auth.TokenInfo, error) {
|
||||||
|
hint := profileLoginHint(a.cfgPath)
|
||||||
|
|
||||||
|
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV, sessionExtend, hint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
|
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return runOAuthFlow(a.ctx, oAuthFlow, urlOpener, nil)
|
tokenInfo, err := runOAuthFlow(a.ctx, oAuthFlow, urlOpener, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokenInfo.MatchesAccount(hint) {
|
||||||
|
return tokenInfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The IdP answered from a session belonging to another account. Retrying is
|
||||||
|
// what makes this recoverable: on a peer already registered the server would
|
||||||
|
// reject the token, and on a fresh one it would silently register the peer
|
||||||
|
// under the wrong account and bind the profile to it.
|
||||||
|
log.Infof("login returned an account other than the one this profile is bound to, retrying with an account prompt")
|
||||||
|
retryFlow := auth.RetryFlowForAccount(oAuthFlow)
|
||||||
|
if retryFlow == nil {
|
||||||
|
return tokenInfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
retryToken, err := runOAuthFlow(a.ctx, retryFlow, urlOpener, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !retryToken.MatchesAccount(hint) {
|
||||||
|
log.Warnf("login still returned a different account after the prompt, continuing with it")
|
||||||
|
}
|
||||||
|
|
||||||
|
return retryToken, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// profileLoginHint returns the stored account email for the profile at cfgPath.
|
// profileLoginHint returns the stored account email for the profile at cfgPath.
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func (p *Preferences) GetManagementURL() (string, error) {
|
|||||||
return p.configInput.ManagementURL, nil
|
return p.configInput.ManagementURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@ func (p *Preferences) GetAdminURL() (string, error) {
|
|||||||
return p.configInput.AdminURL, nil
|
return p.configInput.AdminURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -86,7 +86,7 @@ func (p *Preferences) HasPreSharedKey() (bool, error) {
|
|||||||
return *p.configInput.PreSharedKey != "", nil
|
return *p.configInput.PreSharedKey != "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -112,7 +112,7 @@ func (p *Preferences) GetRosenpassEnabled() (bool, error) {
|
|||||||
return *p.configInput.RosenpassEnabled, nil
|
return *p.configInput.RosenpassEnabled, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@ func (p *Preferences) GetRosenpassPermissive() (bool, error) {
|
|||||||
return *p.configInput.RosenpassPermissive, nil
|
return *p.configInput.RosenpassPermissive, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -149,7 +149,7 @@ func (p *Preferences) GetDisableClientRoutes() (bool, error) {
|
|||||||
return *p.configInput.DisableClientRoutes, nil
|
return *p.configInput.DisableClientRoutes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -170,7 +170,7 @@ func (p *Preferences) GetDisableServerRoutes() (bool, error) {
|
|||||||
return *p.configInput.DisableServerRoutes, nil
|
return *p.configInput.DisableServerRoutes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -188,7 +188,7 @@ func (p *Preferences) GetDisableDNS() (bool, error) {
|
|||||||
return *p.configInput.DisableDNS, nil
|
return *p.configInput.DisableDNS, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -206,7 +206,7 @@ func (p *Preferences) GetDisableFirewall() (bool, error) {
|
|||||||
return *p.configInput.DisableFirewall, nil
|
return *p.configInput.DisableFirewall, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -227,7 +227,7 @@ func (p *Preferences) GetServerSSHAllowed() (bool, error) {
|
|||||||
return *p.configInput.ServerSSHAllowed, nil
|
return *p.configInput.ServerSSHAllowed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -249,7 +249,7 @@ func (p *Preferences) GetEnableSSHRoot() (bool, error) {
|
|||||||
return *p.configInput.EnableSSHRoot, nil
|
return *p.configInput.EnableSSHRoot, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -271,7 +271,7 @@ func (p *Preferences) GetEnableSSHSFTP() (bool, error) {
|
|||||||
return *p.configInput.EnableSSHSFTP, nil
|
return *p.configInput.EnableSSHSFTP, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -293,7 +293,7 @@ func (p *Preferences) GetEnableSSHLocalPortForwarding() (bool, error) {
|
|||||||
return *p.configInput.EnableSSHLocalPortForwarding, nil
|
return *p.configInput.EnableSSHLocalPortForwarding, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -315,7 +315,7 @@ func (p *Preferences) GetEnableSSHRemotePortForwarding() (bool, error) {
|
|||||||
return *p.configInput.EnableSSHRemotePortForwarding, nil
|
return *p.configInput.EnableSSHRemotePortForwarding, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -340,7 +340,7 @@ func (p *Preferences) GetBlockInbound() (bool, error) {
|
|||||||
return *p.configInput.BlockInbound, nil
|
return *p.configInput.BlockInbound, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -358,7 +358,7 @@ func (p *Preferences) GetDisableIPv6() (bool, error) {
|
|||||||
return *p.configInput.DisableIPv6, nil
|
return *p.configInput.DisableIPv6, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -377,7 +377,7 @@ func (p *Preferences) GetRemoteJobsAllowed() (bool, error) {
|
|||||||
return *p.configInput.RemoteJobsAllowed, nil
|
return *p.configInput.RemoteJobsAllowed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-85
@@ -6,13 +6,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal"
|
"github.com/netbirdio/netbird/client/internal"
|
||||||
"github.com/netbirdio/netbird/client/internal/auth"
|
"github.com/netbirdio/netbird/client/internal/auth"
|
||||||
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
|
|
||||||
"github.com/netbirdio/netbird/client/internal/peer"
|
|
||||||
cProto "github.com/netbirdio/netbird/client/proto"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// StateChangeListener receives client state notifications.
|
// StateChangeListener receives client state notifications.
|
||||||
@@ -21,16 +16,11 @@ import (
|
|||||||
// changed: connection state, the run-loop status label (e.g. NeedsLogin) or
|
// changed: connection state, the run-loop status label (e.g. NeedsLogin) or
|
||||||
// the session deadline. It mirrors the daemon's SubscribeStatus stream
|
// the session deadline. It mirrors the daemon's SubscribeStatus stream
|
||||||
// trigger — on each signal the consumer pulls the fresh values via
|
// trigger — on each signal the consumer pulls the fresh values via
|
||||||
// Status() / SessionExpiresAtUnix().
|
// Status() / SessionExpiresAtUnix(). The engine arms no expiry-warning
|
||||||
//
|
// timers on Android; the app schedules the warnings from the deadline it
|
||||||
// OnSessionExpiring forwards the engine's session-expiry warnings, fired at
|
// reads here.
|
||||||
// sessionwatch.WarningLead before the deadline and again at FinalWarningLead
|
|
||||||
// (finalWarning true). The second one is suppressed when the user dismissed
|
|
||||||
// the first via DismissSessionWarning. The daemon turns the same events into
|
|
||||||
// its tray notification.
|
|
||||||
type StateChangeListener interface {
|
type StateChangeListener interface {
|
||||||
OnStateChanged()
|
OnStateChanged()
|
||||||
OnSessionExpiring(expiresAtUnix int64, leadMinutes int64, finalWarning bool)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status returns the connect run-loop's status label — the same value the
|
// Status returns the connect run-loop's status label — the same value the
|
||||||
@@ -110,11 +100,11 @@ func (c *Client) SetStateChangeListener(listener StateChangeListener) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both subscriptions are buffered (one pending tick, ten pending events),
|
// The subscription is buffered (one pending tick), so unsubscribing is
|
||||||
// so unsubscribing is not enough to stop callbacks: the loops would drain
|
// not enough to stop callbacks: the loop would drain what is already
|
||||||
// what is already queued and deliver it to a listener the caller has
|
// queued and deliver it to a listener the caller has already removed or
|
||||||
// already removed or replaced. Gate every callback on this registration's
|
// replaced. Gate every callback on this registration's own signal, which
|
||||||
// own signal, which is closed before unsubscribing.
|
// is closed before unsubscribing.
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
c.stateChangeDone = done
|
c.stateChangeDone = done
|
||||||
|
|
||||||
@@ -133,9 +123,6 @@ func (c *Client) SetStateChangeListener(listener StateChangeListener) {
|
|||||||
listener.OnStateChanged()
|
listener.OnStateChanged()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
c.eventSub = c.recorder.SubscribeToEvents()
|
|
||||||
go watchSessionWarnings(c.eventSub, listener, done)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveStateChangeListener unregisters the state notification listener.
|
// RemoveStateChangeListener unregisters the state notification listener.
|
||||||
@@ -145,21 +132,6 @@ func (c *Client) RemoveStateChangeListener() {
|
|||||||
c.stopStateChangeWatchLocked()
|
c.stopStateChangeWatchLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
// DismissSessionWarning records the user's "Dismiss" on the first expiry
|
|
||||||
// warning and suppresses the final one for the current deadline. A refreshed
|
|
||||||
// deadline re-arms both. No-op while the engine is not running.
|
|
||||||
func (c *Client) DismissSessionWarning() {
|
|
||||||
cc := c.getConnectClient()
|
|
||||||
if cc == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
engine := cc.Engine()
|
|
||||||
if engine == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
engine.DismissSessionWarning()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExtendAuthSession runs the interactive SSO flow to obtain a fresh JWT and
|
// ExtendAuthSession runs the interactive SSO flow to obtain a fresh JWT and
|
||||||
// asks the management server to extend the session deadline. The tunnel is
|
// asks the management server to extend the session deadline. The tunnel is
|
||||||
// untouched: no resync, no reconnect. Async; the result arrives on the
|
// untouched: no resync, no reconnect. Async; the result arrives on the
|
||||||
@@ -201,8 +173,8 @@ func (c *Client) CancelExtendAuthSession() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) stopStateChangeWatchLocked() {
|
func (c *Client) stopStateChangeWatchLocked() {
|
||||||
// Signal first, unsubscribe second: closing the channels only stops new
|
// Signal first, unsubscribe second: closing the channel only stops new
|
||||||
// items, and the loops would still hand whatever is buffered to a listener
|
// items, and the loop would still hand whatever is buffered to a listener
|
||||||
// that is no longer registered.
|
// that is no longer registered.
|
||||||
if c.stateChangeDone != nil {
|
if c.stateChangeDone != nil {
|
||||||
close(c.stateChangeDone)
|
close(c.stateChangeDone)
|
||||||
@@ -212,49 +184,6 @@ func (c *Client) stopStateChangeWatchLocked() {
|
|||||||
c.recorder.UnsubscribeFromStateChanges(c.stateChangeSubID)
|
c.recorder.UnsubscribeFromStateChanges(c.stateChangeSubID)
|
||||||
c.stateChangeSubID = ""
|
c.stateChangeSubID = ""
|
||||||
}
|
}
|
||||||
if c.eventSub != nil {
|
|
||||||
// Closes the channel, which ends watchSessionWarnings.
|
|
||||||
c.recorder.UnsubscribeFromEvents(c.eventSub)
|
|
||||||
c.eventSub = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// watchSessionWarnings forwards the engine's session-expiry warnings to the
|
|
||||||
// listener. The event stream also carries unrelated traffic — network-map
|
|
||||||
// updates on every sync, DNS and route errors — so everything but an
|
|
||||||
// AUTHENTICATION event carrying the session-warning marker is dropped. Exits
|
|
||||||
// when the subscription is closed by UnsubscribeFromEvents, or earlier when
|
|
||||||
// done is closed — the stream buffers up to ten events, and a deregistered
|
|
||||||
// listener must not receive the ones already queued.
|
|
||||||
func watchSessionWarnings(sub *peer.EventSubscription, listener StateChangeListener, done <-chan struct{}) {
|
|
||||||
for ev := range sub.Events() {
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
if ev.GetCategory() != cProto.SystemEvent_AUTHENTICATION {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
meta := ev.GetMetadata()
|
|
||||||
if meta[sessionwatch.MetaSessionWarning] != "true" {
|
|
||||||
// Other AUTHENTICATION events exist (e.g. a deadline rejected as
|
|
||||||
// out of range); they carry no warning marker.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
deadline, err := sessionwatch.ParseExpiresAt(meta[sessionwatch.MetaSessionExpiresAt])
|
|
||||||
if err != nil {
|
|
||||||
log.Warnf("session warning event with unparsable deadline: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lead, err := sessionwatch.ParseLeadMinutes(meta[sessionwatch.MetaSessionLeadMinutes])
|
|
||||||
if err != nil {
|
|
||||||
// Informational only — the deadline above is what drives the UI.
|
|
||||||
lead = 0
|
|
||||||
}
|
|
||||||
listener.OnSessionExpiring(deadline.Unix(), int64(lead),
|
|
||||||
meta[sessionwatch.MetaSessionFinal] == "true")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) beginExtend() (context.Context, error) {
|
func (c *Client) beginExtend() (context.Context, error) {
|
||||||
@@ -293,11 +222,13 @@ func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isA
|
|||||||
}
|
}
|
||||||
defer authClient.Close()
|
defer authClient.Close()
|
||||||
|
|
||||||
// Passing the config path makes the flow pick up the login_hint: an extend
|
// Passing the config path makes the flow pick up the login_hint. That alone
|
||||||
// renews the session of the account already signed in, so it must not stop to
|
// cannot keep the IdP on this profile's account though — a hint is only a
|
||||||
// offer a choice.
|
// suggestion, and a silent authorization is answered from whatever session the
|
||||||
|
// IdP already has, which need not be this peer's when several accounts are
|
||||||
|
// signed in. Marking the flow as an extend lets the server rule that out.
|
||||||
a := NewAuthWithConfig(ctx, cfg, cfgPath)
|
a := NewAuthWithConfig(ctx, cfg, cfgPath)
|
||||||
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
|
tokenInfo, err := a.foregroundGetTokenInfoFlow(authClient, urlOpener, isAndroidTV, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("interactive sso login failed: %v", err)
|
return fmt.Errorf("interactive sso login failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -469,7 +469,7 @@ func (s *SSHClient) requestJWTToken(cfg *profilemanager.Config, cfgPath string)
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
flow, err := auth.NewOAuthFlow(ctx, cfg, false, true, profileLoginHint(cfgPath))
|
flow, err := auth.NewOAuthFlow(ctx, cfg, false, true, profileLoginHint(cfgPath), false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("create oauth flow: %w", err)
|
return "", fmt.Errorf("create oauth flow: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/proto"
|
|
||||||
)
|
|
||||||
|
|
||||||
var forwardingRulesCmd = &cobra.Command{
|
|
||||||
Use: "forwarding",
|
|
||||||
Short: "List forwarding rules",
|
|
||||||
Long: `Commands to list forwarding rules.`,
|
|
||||||
}
|
|
||||||
|
|
||||||
var forwardingRulesListCmd = &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Aliases: []string{"ls"},
|
|
||||||
Short: "List forwarding rules",
|
|
||||||
Example: " netbird forwarding list",
|
|
||||||
Long: "Commands to list forwarding rules.",
|
|
||||||
RunE: listForwardingRules,
|
|
||||||
}
|
|
||||||
|
|
||||||
func listForwardingRules(cmd *cobra.Command, _ []string) error {
|
|
||||||
conn, err := getClient(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
client := proto.NewDaemonServiceClient(conn)
|
|
||||||
resp, err := client.ForwardingRules(cmd.Context(), &proto.EmptyRequest{})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to list network: %v", status.Convert(err).Message())
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(resp.GetRules()) == 0 {
|
|
||||||
cmd.Println("No forwarding rules available.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
printForwardingRules(cmd, resp.GetRules())
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func printForwardingRules(cmd *cobra.Command, rules []*proto.ForwardingRule) {
|
|
||||||
cmd.Println("Available forwarding rules:")
|
|
||||||
|
|
||||||
// Sort rules by translated address
|
|
||||||
sort.Slice(rules, func(i, j int) bool {
|
|
||||||
if rules[i].GetTranslatedAddress() != rules[j].GetTranslatedAddress() {
|
|
||||||
return rules[i].GetTranslatedAddress() < rules[j].GetTranslatedAddress()
|
|
||||||
}
|
|
||||||
if rules[i].GetProtocol() != rules[j].GetProtocol() {
|
|
||||||
return rules[i].GetProtocol() < rules[j].GetProtocol()
|
|
||||||
}
|
|
||||||
|
|
||||||
return getFirstPort(rules[i].GetDestinationPort()) < getFirstPort(rules[j].GetDestinationPort())
|
|
||||||
})
|
|
||||||
|
|
||||||
var lastIP string
|
|
||||||
for _, rule := range rules {
|
|
||||||
dPort := portToString(rule.GetDestinationPort())
|
|
||||||
tPort := portToString(rule.GetTranslatedPort())
|
|
||||||
if lastIP != rule.GetTranslatedAddress() {
|
|
||||||
lastIP = rule.GetTranslatedAddress()
|
|
||||||
cmd.Printf("\nTranslated peer: %s\n", rule.GetTranslatedHostname())
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Printf(" Local %s/%s to %s:%s\n", rule.GetProtocol(), dPort, rule.GetTranslatedAddress(), tPort)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getFirstPort(portInfo *proto.PortInfo) int {
|
|
||||||
switch v := portInfo.PortSelection.(type) {
|
|
||||||
case *proto.PortInfo_Port:
|
|
||||||
return int(v.Port)
|
|
||||||
case *proto.PortInfo_Range_:
|
|
||||||
return int(v.Range.GetStart())
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func portToString(translatedPort *proto.PortInfo) string {
|
|
||||||
switch v := translatedPort.PortSelection.(type) {
|
|
||||||
case *proto.PortInfo_Port:
|
|
||||||
return fmt.Sprintf("%d", v.Port)
|
|
||||||
case *proto.PortInfo_Range_:
|
|
||||||
return fmt.Sprintf("%d-%d", v.Range.GetStart(), v.Range.GetEnd())
|
|
||||||
default:
|
|
||||||
return "No port specified"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+54
-8
@@ -9,8 +9,6 @@ import (
|
|||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
gstatus "google.golang.org/grpc/status"
|
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal"
|
"github.com/netbirdio/netbird/client/internal"
|
||||||
"github.com/netbirdio/netbird/client/internal/auth"
|
"github.com/netbirdio/netbird/client/internal/auth"
|
||||||
@@ -145,10 +143,7 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str
|
|||||||
err = WithBackOff(func() error {
|
err = WithBackOff(func() error {
|
||||||
var backOffErr error
|
var backOffErr error
|
||||||
loginResp, backOffErr = client.Login(ctx, &loginRequest)
|
loginResp, backOffErr = client.Login(ctx, &loginRequest)
|
||||||
if s, ok := gstatus.FromError(backOffErr); ok && (s.Code() == codes.InvalidArgument ||
|
if terminalLoginError(backOffErr) {
|
||||||
s.Code() == codes.PermissionDenied ||
|
|
||||||
s.Code() == codes.NotFound ||
|
|
||||||
s.Code() == codes.Unimplemented) {
|
|
||||||
loginErr = backOffErr
|
loginErr = backOffErr
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -327,10 +322,28 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string,
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
config, err := profilemanager.ReadConfig(configFilePath)
|
config, err := profilemanager.ReadConfigOrDefault(configFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read config file %s: %v", configFilePath, err)
|
return fmt.Errorf("read config file %s: %v", configFilePath, err)
|
||||||
}
|
}
|
||||||
|
// Reading a config does not provision one: this login is about to dial
|
||||||
|
// management with the profile's identity, so mint the keys if the profile
|
||||||
|
// has none yet and put them on disk — a key that stayed in memory would
|
||||||
|
// come back different on the next run and register a second peer.
|
||||||
|
//
|
||||||
|
// Before the MDM overlay below, on purpose: the file must keep the
|
||||||
|
// profile's own values. The overlay is runtime-only and re-derived on
|
||||||
|
// every load, so persisting it would turn an enforced management URL or
|
||||||
|
// pre-shared key into one the user appears to own once the policy is
|
||||||
|
// withdrawn.
|
||||||
|
if generated, err := config.EnsureIdentity(); err != nil {
|
||||||
|
return fmt.Errorf("ensure profile identity: %v", err)
|
||||||
|
} else if generated {
|
||||||
|
if err := profilemanager.WriteOutConfig(configFilePath, config); err != nil {
|
||||||
|
return fmt.Errorf("write out config file %s: %v", configFilePath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CLI standalone login: profilemanager no longer auto-applies MDM,
|
// CLI standalone login: profilemanager no longer auto-applies MDM,
|
||||||
// so layer in the OS-native policy here. Desktop builds construct
|
// so layer in the OS-native policy here. Desktop builds construct
|
||||||
// a Loader with no fetcher — the build-tagged loadPlatform reads
|
// a Loader with no fetcher — the build-tagged loadPlatform reads
|
||||||
@@ -412,11 +425,44 @@ func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *pro
|
|||||||
hint = profileState.Email
|
hint = profileState.Email
|
||||||
}
|
}
|
||||||
|
|
||||||
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, util.HasGraphicalSession(), false, hint)
|
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, util.HasGraphicalSession(), false, hint, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tokenInfo, err := runInteractiveFlow(cmd, oAuthFlow)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokenInfo.MatchesAccount(hint) {
|
||||||
|
return tokenInfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The IdP answered from a session belonging to another account. Retrying is
|
||||||
|
// what makes this recoverable: on a peer already registered the server would
|
||||||
|
// reject the token, and on a fresh one it would silently register the peer
|
||||||
|
// under the wrong account and bind the profile to it.
|
||||||
|
cmd.Println("The login returned a different account than this profile uses. Asking to sign in again.")
|
||||||
|
retryFlow := auth.RetryFlowForAccount(oAuthFlow)
|
||||||
|
if retryFlow == nil {
|
||||||
|
return tokenInfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
retryToken, err := runInteractiveFlow(cmd, retryFlow)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !retryToken.MatchesAccount(hint) {
|
||||||
|
log.Warnf("login still returned a different account after the prompt, continuing with it")
|
||||||
|
}
|
||||||
|
|
||||||
|
return retryToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runInteractiveFlow requests the authorization info, shows the URL to the user
|
||||||
|
// and blocks until the token comes back.
|
||||||
|
func runInteractiveFlow(cmd *cobra.Command, oAuthFlow auth.OAuthFlow) (*auth.TokenInfo, error) {
|
||||||
flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO())
|
flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)
|
return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/certproof"
|
||||||
|
)
|
||||||
|
|
||||||
|
var postureCmd = &cobra.Command{
|
||||||
|
Use: "posture",
|
||||||
|
Short: "Posture helpers invoked by the NetBird daemon",
|
||||||
|
Hidden: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var postureCertProofCmd = &cobra.Command{
|
||||||
|
Use: "cert-proof",
|
||||||
|
Short: "Answer certificate posture challenges from the calling user's keychain",
|
||||||
|
Long: "Reads a certificate challenge set as JSON on stdin and writes the proofs as JSON on stdout.\n" +
|
||||||
|
"The daemon launches this in the console user's desktop session, because a login keychain\n" +
|
||||||
|
"cannot be reached from a root daemon. Not intended to be run by hand.",
|
||||||
|
Hidden: true,
|
||||||
|
SilenceUsage: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
// Proofs travel on stdout, so every log line has to go elsewhere.
|
||||||
|
log.SetOutput(cmd.ErrOrStderr())
|
||||||
|
return certproof.RunHelper(cmd.Context(), cmd.InOrStdin(), cmd.OutOrStdout())
|
||||||
|
},
|
||||||
|
}
|
||||||
+42
-3
@@ -20,6 +20,8 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
gstatus "google.golang.org/grpc/status"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/anonymize"
|
"github.com/netbirdio/netbird/client/anonymize"
|
||||||
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
|
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||||
@@ -175,16 +177,16 @@ func init() {
|
|||||||
rootCmd.AddCommand(versionCmd)
|
rootCmd.AddCommand(versionCmd)
|
||||||
rootCmd.AddCommand(sshCmd)
|
rootCmd.AddCommand(sshCmd)
|
||||||
rootCmd.AddCommand(networksCMD)
|
rootCmd.AddCommand(networksCMD)
|
||||||
rootCmd.AddCommand(forwardingRulesCmd)
|
|
||||||
rootCmd.AddCommand(debugCmd)
|
rootCmd.AddCommand(debugCmd)
|
||||||
rootCmd.AddCommand(profileCmd)
|
rootCmd.AddCommand(profileCmd)
|
||||||
rootCmd.AddCommand(exposeCmd)
|
rootCmd.AddCommand(exposeCmd)
|
||||||
|
rootCmd.AddCommand(postureCmd)
|
||||||
|
|
||||||
|
postureCmd.AddCommand(postureCertProofCmd)
|
||||||
|
|
||||||
networksCMD.AddCommand(routesListCmd)
|
networksCMD.AddCommand(routesListCmd)
|
||||||
networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd)
|
networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd)
|
||||||
|
|
||||||
forwardingRulesCmd.AddCommand(forwardingRulesListCmd)
|
|
||||||
|
|
||||||
debugCmd.AddCommand(debugBundleCmd)
|
debugCmd.AddCommand(debugBundleCmd)
|
||||||
debugCmd.AddCommand(logCmd)
|
debugCmd.AddCommand(logCmd)
|
||||||
logCmd.AddCommand(logLevelCmd)
|
logCmd.AddCommand(logLevelCmd)
|
||||||
@@ -285,6 +287,43 @@ func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, e
|
|||||||
return grpc.DialContext(ctx, target, opts...)
|
return grpc.DialContext(ctx, target, opts...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// terminalLoginError reports whether a Login failure is final, so the backoff
|
||||||
|
// cycle stops and the caller is told what the daemon said instead of "login
|
||||||
|
// backoff cycle failed" thirty seconds later. Retrying cannot change any of
|
||||||
|
// these answers: the request is malformed, the caller is not allowed, the
|
||||||
|
// target does not exist, a precondition on the daemon refuses it (the
|
||||||
|
// update-settings kill switch, an MDM-managed field), or the method is not
|
||||||
|
// implemented.
|
||||||
|
//
|
||||||
|
// Both `netbird up` and `netbird login` run Login through the backoff, and
|
||||||
|
// they each carried their own copy of this list — which is how one of them
|
||||||
|
// ended up retrying a refusal the other treated as final.
|
||||||
|
func terminalLoginError(err error) bool {
|
||||||
|
// A successful Login reaches here with a nil error, and that is not a
|
||||||
|
// terminal failure. Handled explicitly rather than left to
|
||||||
|
// gstatus.FromError, which answers (nil, true) for a nil error and leans on
|
||||||
|
// Status.Code tolerating a nil receiver to come back as codes.OK.
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
s, ok := gstatus.FromError(err)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch s.Code() {
|
||||||
|
case codes.InvalidArgument,
|
||||||
|
codes.PermissionDenied,
|
||||||
|
codes.NotFound,
|
||||||
|
codes.FailedPrecondition,
|
||||||
|
codes.Unimplemented:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// WithBackOff execute function in backoff cycle.
|
// WithBackOff execute function in backoff cycle.
|
||||||
func WithBackOff(bf func() error) error {
|
func WithBackOff(bf func() error) error {
|
||||||
return backoff.RetryNotify(bf, CLIBackOffSettings, func(err error, duration time.Duration) {
|
return backoff.RetryNotify(bf, CLIBackOffSettings, func(err error, duration time.Duration) {
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/mock/gomock"
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.opentelemetry.io/otel"
|
"go.opentelemetry.io/otel"
|
||||||
|
"go.uber.org/mock/gomock"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
|
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
|
||||||
@@ -28,7 +28,6 @@ import (
|
|||||||
mgmt "github.com/netbirdio/netbird/management/server"
|
mgmt "github.com/netbirdio/netbird/management/server"
|
||||||
"github.com/netbirdio/netbird/management/server/activity"
|
"github.com/netbirdio/netbird/management/server/activity"
|
||||||
"github.com/netbirdio/netbird/management/server/groups"
|
"github.com/netbirdio/netbird/management/server/groups"
|
||||||
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
|
||||||
"github.com/netbirdio/netbird/management/server/permissions"
|
"github.com/netbirdio/netbird/management/server/permissions"
|
||||||
"github.com/netbirdio/netbird/management/server/settings"
|
"github.com/netbirdio/netbird/management/server/settings"
|
||||||
"github.com/netbirdio/netbird/management/server/store"
|
"github.com/netbirdio/netbird/management/server/store"
|
||||||
@@ -124,9 +123,9 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp
|
|||||||
|
|
||||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||||
requestBuffer := mgmt.NewAccountRequestBuffer(ctx, store)
|
requestBuffer := mgmt.NewAccountRequestBuffer(ctx, store)
|
||||||
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersmanager), config, nil)
|
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", manager.NewEphemeralManager(store, peersmanager), config, nil)
|
||||||
|
|
||||||
accountManager, err := mgmt.BuildManager(ctx, config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore)
|
accountManager, err := mgmt.BuildManager(ctx, config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, settingsMockManager, permissionsManagerMock, false, cacheStore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-78
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -26,16 +25,11 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/proto"
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
"github.com/netbirdio/netbird/client/server"
|
"github.com/netbirdio/netbird/client/server"
|
||||||
"github.com/netbirdio/netbird/client/system"
|
"github.com/netbirdio/netbird/client/system"
|
||||||
|
"github.com/netbirdio/netbird/shared/externalipmap"
|
||||||
"github.com/netbirdio/netbird/shared/management/domain"
|
"github.com/netbirdio/netbird/shared/management/domain"
|
||||||
"github.com/netbirdio/netbird/util"
|
"github.com/netbirdio/netbird/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
invalidInputType int = iota
|
|
||||||
ipInputType
|
|
||||||
interfaceInputType
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
dnsLabelsFlag = "extra-dns-labels"
|
dnsLabelsFlag = "extra-dns-labels"
|
||||||
|
|
||||||
@@ -106,7 +100,7 @@ func upFunc(cmd *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("failed initializing log %v", err)
|
return fmt.Errorf("failed initializing log %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = validateNATExternalIPs(natExternalIPs)
|
err = externalipmap.Validate(natExternalIPs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -357,9 +351,17 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager
|
|||||||
// set the new config
|
// set the new config
|
||||||
req := setupSetConfigReq(customDNSAddressConverted, cmd, activeProf.ID.String(), username.Username)
|
req := setupSetConfigReq(customDNSAddressConverted, cmd, activeProf.ID.String(), username.Username)
|
||||||
if _, err := client.SetConfig(ctx, req); err != nil {
|
if _, err := client.SetConfig(ctx, req); err != nil {
|
||||||
if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Unavailable {
|
switch reason, refused := refusedSettingsUpdate(err); {
|
||||||
log.Warnf("setConfig method is not available in the daemon: %s", st.Message())
|
case refused:
|
||||||
} else {
|
// Failing here is the point: carrying on would connect while
|
||||||
|
// silently dropping the settings the caller asked for, since
|
||||||
|
// nothing further down the line applies them.
|
||||||
|
return fmt.Errorf("the daemon refused the settings update: %s", reason)
|
||||||
|
case gstatus.Code(err) == codes.Unavailable:
|
||||||
|
// The daemon cannot serve the method at all, which is what this
|
||||||
|
// code means; an older daemon without it lands here.
|
||||||
|
log.Warnf("the daemon did not apply the settings update: %s", gstatus.Convert(err).Message())
|
||||||
|
default:
|
||||||
return daemonCallError("call service setConfig method", err)
|
return daemonCallError("call service setConfig method", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -400,10 +402,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
|
|||||||
err = WithBackOff(func() error {
|
err = WithBackOff(func() error {
|
||||||
var backOffErr error
|
var backOffErr error
|
||||||
loginResp, backOffErr = client.Login(ctx, loginRequest)
|
loginResp, backOffErr = client.Login(ctx, loginRequest)
|
||||||
if s, ok := gstatus.FromError(backOffErr); ok && (s.Code() == codes.InvalidArgument ||
|
if terminalLoginError(backOffErr) {
|
||||||
s.Code() == codes.PermissionDenied ||
|
|
||||||
s.Code() == codes.NotFound ||
|
|
||||||
s.Code() == codes.Unimplemented) {
|
|
||||||
loginErr = backOffErr
|
loginErr = backOffErr
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -472,6 +471,22 @@ func setSSHSetConfigFields(req *proto.SetConfigRequest, cmd *cobra.Command) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refusedSettingsUpdate reports whether err is the daemon refusing the settings
|
||||||
|
// a request carried — the update-settings kill switch, or a field an MDM policy
|
||||||
|
// manages — and returns the reason it gave.
|
||||||
|
//
|
||||||
|
// The distinction that matters is against codes.Unavailable, which means the
|
||||||
|
// daemon cannot serve the call: that one is worth a warning, because an older
|
||||||
|
// daemon without the method lands there and the rest of `netbird up` still
|
||||||
|
// works. A refusal is not, because the settings would be silently dropped.
|
||||||
|
func refusedSettingsUpdate(err error) (string, bool) {
|
||||||
|
st, ok := gstatus.FromError(err)
|
||||||
|
if !ok || st.Code() != codes.FailedPrecondition {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return st.Message(), true
|
||||||
|
}
|
||||||
|
|
||||||
func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, profileName, username string) *proto.SetConfigRequest {
|
func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, profileName, username string) *proto.SetConfigRequest {
|
||||||
var req proto.SetConfigRequest
|
var req proto.SetConfigRequest
|
||||||
req.ProfileName = profileName
|
req.ProfileName = profileName
|
||||||
@@ -815,36 +830,6 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
|
|||||||
return &loginRequest, nil
|
return &loginRequest, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateNATExternalIPs(list []string) error {
|
|
||||||
for _, element := range list {
|
|
||||||
if element == "" {
|
|
||||||
return fmt.Errorf("empty string is not a valid input for %s", externalIPMapFlag)
|
|
||||||
}
|
|
||||||
|
|
||||||
subElements := strings.Split(element, "/")
|
|
||||||
if len(subElements) > 2 {
|
|
||||||
return fmt.Errorf("%s is not a valid input for %s. it should be formatted as \"String\" or \"String/String\"", element, externalIPMapFlag)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(subElements) == 1 && !isValidIP(subElements[0]) {
|
|
||||||
return fmt.Errorf("%s is not a valid input for %s. it should be formatted as \"IP\" or \"IP/IP\", or \"IP/Interface Name\"", element, externalIPMapFlag)
|
|
||||||
}
|
|
||||||
|
|
||||||
last := 0
|
|
||||||
for _, singleElement := range subElements {
|
|
||||||
inputType, err := validateElement(singleElement)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%s is not a valid input for %s. it should be an IP string or a network name", singleElement, externalIPMapFlag)
|
|
||||||
}
|
|
||||||
if last == interfaceInputType && inputType == interfaceInputType {
|
|
||||||
return fmt.Errorf("%s is not a valid input for %s. it should not contain two interface names", element, externalIPMapFlag)
|
|
||||||
}
|
|
||||||
last = inputType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseInterfaceName(name string) error {
|
func parseInterfaceName(name string) error {
|
||||||
if runtime.GOOS != "darwin" {
|
if runtime.GOOS != "darwin" {
|
||||||
return nil
|
return nil
|
||||||
@@ -857,39 +842,6 @@ func parseInterfaceName(name string) error {
|
|||||||
return fmt.Errorf("invalid interface name %s. Please use the prefix utun followed by a number on MacOS. e.g., utun1 or utun199", name)
|
return fmt.Errorf("invalid interface name %s. Please use the prefix utun followed by a number on MacOS. e.g., utun1 or utun199", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateElement(element string) (int, error) {
|
|
||||||
if isValidIP(element) {
|
|
||||||
return ipInputType, nil
|
|
||||||
}
|
|
||||||
validIface, err := isValidInterface(element)
|
|
||||||
if err != nil {
|
|
||||||
return invalidInputType, fmt.Errorf("unable to validate the network interface name, error: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if validIface {
|
|
||||||
return interfaceInputType, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return interfaceInputType, fmt.Errorf("invalid IP or network interface name not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
func isValidIP(ip string) bool {
|
|
||||||
return net.ParseIP(ip) != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isValidInterface(name string) (bool, error) {
|
|
||||||
netInterfaces, err := net.Interfaces()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
for _, iface := range netInterfaces {
|
|
||||||
if iface.Name == name {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseCustomDNSAddress(modified bool) ([]byte, error) {
|
func parseCustomDNSAddress(modified bool) ([]byte, error) {
|
||||||
var parsed []byte
|
var parsed []byte
|
||||||
if modified {
|
if modified {
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
gstatus "google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A refused settings update has to fail `netbird up`, or a caller that asked
|
||||||
|
// for a setting the daemon will not apply connects as if it had been applied.
|
||||||
|
// The daemon being unable to serve the call is the case that stays a warning.
|
||||||
|
func TestRefusedSettingsUpdate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
wantRefused bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "the kill switch refused the change",
|
||||||
|
err: gstatus.Errorf(codes.FailedPrecondition, "update settings are disabled, you cannot use this feature without update settings enabled"),
|
||||||
|
wantRefused: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an MDM policy manages the field",
|
||||||
|
err: gstatus.Errorf(codes.FailedPrecondition, "fields managed by MDM policy: managementURL"),
|
||||||
|
wantRefused: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "the daemon cannot serve the call",
|
||||||
|
err: gstatus.Errorf(codes.Unavailable, "connection refused"),
|
||||||
|
wantRefused: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "any other RPC failure",
|
||||||
|
err: gstatus.Errorf(codes.Internal, "boom"),
|
||||||
|
wantRefused: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not a status error at all",
|
||||||
|
err: errors.New("boom"),
|
||||||
|
wantRefused: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
reason, refused := refusedSettingsUpdate(tt.err)
|
||||||
|
require.Equal(t, tt.wantRefused, refused)
|
||||||
|
if tt.wantRefused {
|
||||||
|
require.Equal(t, gstatus.Convert(tt.err).Message(), reason, "the daemon's reason must reach the caller")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both `netbird up` and `netbird login` drive Login through the backoff cycle,
|
||||||
|
// and a final answer has to stop it: retrying a refusal only replaces the
|
||||||
|
// daemon's reason with "login backoff cycle failed" thirty seconds later.
|
||||||
|
func TestTerminalLoginError(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
wantTerminal bool
|
||||||
|
}{
|
||||||
|
{name: "settings refused by the kill switch", err: gstatus.Errorf(codes.FailedPrecondition, "update settings are disabled"), wantTerminal: true},
|
||||||
|
{name: "field managed by MDM", err: gstatus.Errorf(codes.FailedPrecondition, "fields managed by MDM policy: managementURL"), wantTerminal: true},
|
||||||
|
{name: "caller not allowed", err: gstatus.Errorf(codes.PermissionDenied, "nope"), wantTerminal: true},
|
||||||
|
{name: "malformed request", err: gstatus.Errorf(codes.InvalidArgument, "nope"), wantTerminal: true},
|
||||||
|
{name: "profile not found", err: gstatus.Errorf(codes.NotFound, "nope"), wantTerminal: true},
|
||||||
|
{name: "method missing on an older daemon", err: gstatus.Errorf(codes.Unimplemented, "nope"), wantTerminal: true},
|
||||||
|
{name: "daemon unreachable, worth retrying", err: gstatus.Errorf(codes.Unavailable, "connection refused"), wantTerminal: false},
|
||||||
|
{name: "transient internal failure", err: gstatus.Errorf(codes.Internal, "boom"), wantTerminal: false},
|
||||||
|
{name: "not a status error", err: errors.New("boom"), wantTerminal: false},
|
||||||
|
{name: "no error at all, the login succeeded", err: nil, wantTerminal: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
require.Equal(t, tt.wantTerminal, terminalLoginError(tt.err))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
if [ "$#" -lt 2 ]; then
|
|
||||||
printf '%s\n' "usage: $0 OUTPUT_DIRECTORY GOARCH..." >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
repo_root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
|
|
||||||
output_name=$(basename "$1")
|
|
||||||
if [ -z "$output_name" ] || [ "$output_name" = "." ] ||
|
|
||||||
[ "$output_name" = ".." ] || [ "$output_name" = "/" ]; then
|
|
||||||
printf '%s\n' "OUTPUT_DIRECTORY must name a directory" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
output_parent=$(CDPATH= cd -- "$(dirname "$1")" && pwd)
|
|
||||||
output="$output_parent/$output_name"
|
|
||||||
shift
|
|
||||||
modules=$(mktemp "${TMPDIR:-/tmp}/netbird-client-licenses.modules.XXXXXX")
|
|
||||||
sorted_modules=$(mktemp "${TMPDIR:-/tmp}/netbird-client-licenses.sorted.XXXXXX")
|
|
||||||
trap 'rm -f "$modules" "$sorted_modules"' EXIT HUP INT TERM
|
|
||||||
|
|
||||||
if [ -e "$output" ] || [ -L "$output" ]; then
|
|
||||||
printf 'output directory already exists: %s\n' "$output" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
mkdir "$output"
|
|
||||||
mkdir "$output/third_party"
|
|
||||||
|
|
||||||
cp "$repo_root/LICENSE" "$output/BSD-3-Clause.txt"
|
|
||||||
|
|
||||||
cd "$repo_root"
|
|
||||||
for arch in "$@"; do
|
|
||||||
GOOS=${GOOS:-linux} GOARCH="$arch" CGO_ENABLED=${CGO_ENABLED:-0} \
|
|
||||||
go list -deps -f '{{with .Module}}{{if .Replace}}{{.Replace.Path}}{{"\t"}}{{.Replace.Version}}{{"\t"}}{{.Replace.Dir}}{{else}}{{.Path}}{{"\t"}}{{.Version}}{{"\t"}}{{.Dir}}{{end}}{{end}}' -tags load_wgnt_from_rsrc ./client >>"$modules"
|
|
||||||
done
|
|
||||||
LC_ALL=C sort -u "$modules" >"$sorted_modules"
|
|
||||||
|
|
||||||
goroot=$(go env GOROOT)
|
|
||||||
for term in LICENSE PATENTS; do
|
|
||||||
if [ ! -f "$goroot/$term" ]; then
|
|
||||||
printf 'missing Go standard-library term: %s\n' "$goroot/$term" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
cp "$goroot/$term" "$output/Go-$term"
|
|
||||||
done
|
|
||||||
|
|
||||||
while IFS=' ' read -r module version module_dir; do
|
|
||||||
[ -n "$module" ] || continue
|
|
||||||
[ "$module" = "github.com/netbirdio/netbird" ] && continue
|
|
||||||
|
|
||||||
if [ -z "$version" ] || [ ! -d "$module_dir" ]; then
|
|
||||||
printf 'cannot collect terms for module %s at version %s\n' "$module" "$version" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
destination="$output/third_party/$module/$version"
|
|
||||||
mkdir -p "$destination"
|
|
||||||
printf 'module: %s\nversion: %s\n' "$module" "$version" >"$destination/MODULE"
|
|
||||||
|
|
||||||
found=false
|
|
||||||
for term in \
|
|
||||||
"$module_dir"/LICENSE* "$module_dir"/License* "$module_dir"/license* \
|
|
||||||
"$module_dir"/LICENCE* "$module_dir"/Licence* "$module_dir"/licence* \
|
|
||||||
"$module_dir"/COPYING* "$module_dir"/Copying* "$module_dir"/copying* \
|
|
||||||
"$module_dir"/NOTICE* "$module_dir"/Notice* "$module_dir"/notice* \
|
|
||||||
"$module_dir"/PATENTS* "$module_dir"/Patents* "$module_dir"/patents*; do
|
|
||||||
[ -f "$term" ] || continue
|
|
||||||
cp "$term" "$destination/"
|
|
||||||
found=true
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$found" = false ]; then
|
|
||||||
printf 'no root license terms found for module %s at %s\n' "$module" "$module_dir" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done <"$sorted_modules"
|
|
||||||
+17
-3
@@ -54,6 +54,9 @@ type Client struct {
|
|||||||
jwtToken string
|
jwtToken string
|
||||||
connect *internal.ConnectClient
|
connect *internal.ConnectClient
|
||||||
recorder *peer.Status
|
recorder *peer.Status
|
||||||
|
// inMemoryState is set when no StatePath was given in netstack mode,
|
||||||
|
// where the client changes nothing on the host that needs restoring.
|
||||||
|
inMemoryState bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Options configures a new Client.
|
// Options configures a new Client.
|
||||||
@@ -78,7 +81,8 @@ type Options struct {
|
|||||||
NoUserspace bool
|
NoUserspace bool
|
||||||
// ConfigPath is the path to the netbird config file. If empty, the config will be stored in memory and not persisted.
|
// ConfigPath is the path to the netbird config file. If empty, the config will be stored in memory and not persisted.
|
||||||
ConfigPath string
|
ConfigPath string
|
||||||
// StatePath is the path to the netbird state file
|
// StatePath is the path to the netbird state file. When empty in netstack
|
||||||
|
// mode, state is kept in memory; with NoUserspace the default path is used.
|
||||||
StatePath string
|
StatePath string
|
||||||
// DisableClientRoutes disables the client routes
|
// DisableClientRoutes disables the client routes
|
||||||
DisableClientRoutes bool
|
DisableClientRoutes bool
|
||||||
@@ -114,6 +118,9 @@ type Options struct {
|
|||||||
MTU *uint16
|
MTU *uint16
|
||||||
// DNSLabels defines additional DNS labels configured in the peer.
|
// DNSLabels defines additional DNS labels configured in the peer.
|
||||||
DNSLabels []string
|
DNSLabels []string
|
||||||
|
// NATExternalIPs maps external IPs to internal IPs or interfaces for ICE
|
||||||
|
// candidates, same format as the agent's --external-ip-map flag.
|
||||||
|
NATExternalIPs []string
|
||||||
// Performance configures the tunnel's buffer pool cap and batch size.
|
// Performance configures the tunnel's buffer pool cap and batch size.
|
||||||
Performance Performance
|
Performance Performance
|
||||||
}
|
}
|
||||||
@@ -193,7 +200,6 @@ func New(opts Options) (*Client, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if opts.StatePath != "" {
|
if opts.StatePath != "" {
|
||||||
// TODO: Disable state if path not provided
|
|
||||||
if err := os.Setenv("NB_DNS_STATE_FILE", opts.StatePath); err != nil {
|
if err := os.Setenv("NB_DNS_STATE_FILE", opts.StatePath); err != nil {
|
||||||
return nil, fmt.Errorf("setenv: %w", err)
|
return nil, fmt.Errorf("setenv: %w", err)
|
||||||
}
|
}
|
||||||
@@ -221,6 +227,7 @@ func New(opts Options) (*Client, error) {
|
|||||||
WireguardPort: opts.WireguardPort,
|
WireguardPort: opts.WireguardPort,
|
||||||
MTU: opts.MTU,
|
MTU: opts.MTU,
|
||||||
DNSLabels: parsedLabels,
|
DNSLabels: parsedLabels,
|
||||||
|
NATExternalIPs: opts.NATExternalIPs,
|
||||||
}
|
}
|
||||||
if opts.ConfigPath != "" {
|
if opts.ConfigPath != "" {
|
||||||
config, err = profilemanager.UpdateOrCreateConfig(input)
|
config, err = profilemanager.UpdateOrCreateConfig(input)
|
||||||
@@ -261,6 +268,9 @@ func New(opts Options) (*Client, error) {
|
|||||||
jwtToken: opts.JWTToken,
|
jwtToken: opts.JWTToken,
|
||||||
config: config,
|
config: config,
|
||||||
recorder: peer.NewRecorder(config.ManagementURL.String()),
|
recorder: peer.NewRecorder(config.ManagementURL.String()),
|
||||||
|
// Without a path the default would be the installed agent's state
|
||||||
|
// file, which this client must neither read nor overwrite.
|
||||||
|
inMemoryState: opts.StatePath == "" && !opts.NoUserspace,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +302,11 @@ func (c *Client) Start(startCtx context.Context) error {
|
|||||||
if err, _ := authClient.Login(ctx, c.setupKey, c.jwtToken); err != nil {
|
if err, _ := authClient.Login(ctx, c.setupKey, c.jwtToken); err != nil {
|
||||||
return fmt.Errorf("login: %w", err)
|
return fmt.Errorf("login: %w", err)
|
||||||
}
|
}
|
||||||
client := internal.NewConnectClient(ctx, c.config, c.recorder)
|
var connectOpts []internal.ConnectClientOption
|
||||||
|
if c.inMemoryState {
|
||||||
|
connectOpts = append(connectOpts, internal.WithInMemoryState())
|
||||||
|
}
|
||||||
|
client := internal.NewConnectClient(ctx, c.config, c.recorder, connectOpts...)
|
||||||
client.SetSyncResponsePersistence(true)
|
client.SetSyncResponsePersistence(true)
|
||||||
|
|
||||||
// either startup error (permanent backoff err) or nil err (successful engine up)
|
// either startup error (permanent backoff err) or nil err (successful engine up)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/mock/gomock"
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uber.org/mock/gomock"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
|
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
|
||||||
@@ -21,7 +21,6 @@ import (
|
|||||||
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
||||||
"github.com/netbirdio/netbird/management/server/groups"
|
"github.com/netbirdio/netbird/management/server/groups"
|
||||||
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
|
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
|
||||||
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
|
||||||
"github.com/netbirdio/netbird/management/server/job"
|
"github.com/netbirdio/netbird/management/server/job"
|
||||||
"github.com/netbirdio/netbird/management/server/permissions"
|
"github.com/netbirdio/netbird/management/server/permissions"
|
||||||
"github.com/netbirdio/netbird/management/server/settings"
|
"github.com/netbirdio/netbird/management/server/settings"
|
||||||
@@ -146,8 +145,8 @@ func startManagement(t *testing.T, signalAddr string) string {
|
|||||||
|
|
||||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||||
requestBuffer := mgmt.NewAccountRequestBuffer(context.Background(), testStore)
|
requestBuffer := mgmt.NewAccountRequestBuffer(context.Background(), testStore)
|
||||||
networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg, nil)
|
networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", manager.NewEphemeralManager(testStore, peersManager), cfg, nil)
|
||||||
accountManager, err := mgmt.BuildManager(context.Background(), cfg, testStore, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
|
accountManager, err := mgmt.BuildManager(context.Background(), cfg, testStore, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, settingsMockManager, permissionsManager, false, cacheStore)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, cfg.TURNConfig, cfg.Relay, settingsMockManager, groupsManager)
|
secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, cfg.TURNConfig, cfg.Relay, settingsMockManager, groupsManager)
|
||||||
|
|||||||
@@ -8,177 +8,11 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/hashicorp/go-multierror"
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
|
||||||
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *family) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) {
|
|
||||||
ruleID := rule.ID()
|
|
||||||
if _, exists := r.rules[ruleID+dnatSuffix]; exists {
|
|
||||||
return rule, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
toDestination := rule.TranslatedAddress.String()
|
|
||||||
switch {
|
|
||||||
case len(rule.TranslatedPort.Values) == 0:
|
|
||||||
// no translated port, use original port
|
|
||||||
case len(rule.TranslatedPort.Values) == 1:
|
|
||||||
toDestination += fmt.Sprintf(":%d", rule.TranslatedPort.Values[0])
|
|
||||||
case rule.TranslatedPort.IsRange && len(rule.TranslatedPort.Values) == 2:
|
|
||||||
// need the "/originalport" suffix to avoid dnat port randomization
|
|
||||||
toDestination += fmt.Sprintf(":%d-%d/%d", rule.TranslatedPort.Values[0], rule.TranslatedPort.Values[1], rule.DestinationPort.Values[0])
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("invalid translated port: %v", rule.TranslatedPort)
|
|
||||||
}
|
|
||||||
|
|
||||||
proto := strings.ToLower(string(rule.Protocol))
|
|
||||||
|
|
||||||
rules := make(map[firewall.RuleID]ruleInfo, 3)
|
|
||||||
|
|
||||||
// DNAT rule
|
|
||||||
dnatRule := []string{
|
|
||||||
"!", "-i", r.wgIface.Name(),
|
|
||||||
"-p", proto,
|
|
||||||
"-j", "DNAT",
|
|
||||||
"--to-destination", toDestination,
|
|
||||||
}
|
|
||||||
dnatRule = append(dnatRule, applyPort("--dport", &rule.DestinationPort)...)
|
|
||||||
rules[ruleID+dnatSuffix] = ruleInfo{
|
|
||||||
table: tableNat,
|
|
||||||
chain: chainRTRdr,
|
|
||||||
rule: dnatRule,
|
|
||||||
}
|
|
||||||
|
|
||||||
// SNAT rule
|
|
||||||
snatRule := []string{
|
|
||||||
"-o", r.wgIface.Name(),
|
|
||||||
"-p", proto,
|
|
||||||
"-d", rule.TranslatedAddress.String(),
|
|
||||||
"-j", "MASQUERADE",
|
|
||||||
}
|
|
||||||
snatRule = append(snatRule, applyPort("--dport", &rule.TranslatedPort)...)
|
|
||||||
rules[ruleID+snatSuffix] = ruleInfo{
|
|
||||||
table: tableNat,
|
|
||||||
chain: chainRTNAT,
|
|
||||||
rule: snatRule,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forward filtering rule, if fwd policy is DROP
|
|
||||||
forwardRule := []string{
|
|
||||||
"-o", r.wgIface.Name(),
|
|
||||||
"-p", proto,
|
|
||||||
"-d", rule.TranslatedAddress.String(),
|
|
||||||
"-j", "ACCEPT",
|
|
||||||
}
|
|
||||||
forwardRule = append(forwardRule, applyPort("--dport", &rule.TranslatedPort)...)
|
|
||||||
rules[ruleID+fwdSuffix] = ruleInfo{
|
|
||||||
table: tableFilter,
|
|
||||||
chain: chainRTFwdOut,
|
|
||||||
rule: forwardRule,
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, ruleInfo := range rules {
|
|
||||||
if err := r.iptablesClient.Append(ruleInfo.table, ruleInfo.chain, ruleInfo.rule...); err != nil {
|
|
||||||
r.cleanupFailedDNATAdd(rules)
|
|
||||||
return nil, fmt.Errorf("add rule %s: %w", key, err)
|
|
||||||
}
|
|
||||||
r.rules[key] = ruleInfo.rule
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := r.ipFwdState.RequestForwarding(r.v6); err != nil {
|
|
||||||
r.cleanupFailedDNATAdd(rules)
|
|
||||||
return nil, fmt.Errorf("enable forwarding: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
r.updateState()
|
|
||||||
return rule, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanupFailedDNATAdd removes the bookkeeping written by a partially applied
|
|
||||||
// AddDNATRule before rolling back the kernel rules, so no entries remain that
|
|
||||||
// never got a forwarding refcount. rollbackRules re-adds entries it failed to
|
|
||||||
// remove from the kernel.
|
|
||||||
func (r *family) cleanupFailedDNATAdd(rules map[firewall.RuleID]ruleInfo) {
|
|
||||||
for key := range rules {
|
|
||||||
delete(r.rules, key)
|
|
||||||
}
|
|
||||||
if err := r.rollbackRules(rules); err != nil {
|
|
||||||
log.Errorf("rollback failed: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *family) rollbackRules(rules map[firewall.RuleID]ruleInfo) error {
|
|
||||||
var merr *multierror.Error
|
|
||||||
for key, ruleInfo := range rules {
|
|
||||||
if err := r.iptablesClient.DeleteIfExists(ruleInfo.table, ruleInfo.chain, ruleInfo.rule...); err != nil {
|
|
||||||
merr = multierror.Append(merr, fmt.Errorf("rollback rule %s: %w", key, err))
|
|
||||||
// On rollback error, add to rules map for next cleanup
|
|
||||||
r.rules[key] = ruleInfo.rule
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if merr != nil {
|
|
||||||
r.updateState()
|
|
||||||
}
|
|
||||||
return nberrors.FormatErrorOrNil(merr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *family) DeleteDNATRule(rule firewall.Rule) error {
|
|
||||||
ruleID := rule.ID()
|
|
||||||
|
|
||||||
_, hadDNAT := r.rules[ruleID+dnatSuffix]
|
|
||||||
_, hadSNAT := r.rules[ruleID+snatSuffix]
|
|
||||||
_, hadFWD := r.rules[ruleID+fwdSuffix]
|
|
||||||
if !hadDNAT && !hadSNAT && !hadFWD {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var merr *multierror.Error
|
|
||||||
if dnatRule, exists := r.rules[ruleID+dnatSuffix]; exists {
|
|
||||||
if err := r.iptablesClient.Delete(tableNat, chainRTRdr, dnatRule...); err != nil {
|
|
||||||
merr = multierror.Append(merr, fmt.Errorf("delete DNAT rule: %w", err))
|
|
||||||
} else {
|
|
||||||
delete(r.rules, ruleID+dnatSuffix)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if snatRule, exists := r.rules[ruleID+snatSuffix]; exists {
|
|
||||||
if err := r.iptablesClient.Delete(tableNat, chainRTNAT, snatRule...); err != nil {
|
|
||||||
merr = multierror.Append(merr, fmt.Errorf("delete SNAT rule: %w", err))
|
|
||||||
} else {
|
|
||||||
delete(r.rules, ruleID+snatSuffix)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if fwdRule, exists := r.rules[ruleID+fwdSuffix]; exists {
|
|
||||||
if err := r.iptablesClient.Delete(tableFilter, chainRTFwdOut, fwdRule...); err != nil {
|
|
||||||
merr = multierror.Append(merr, fmt.Errorf("delete forward rule: %w", err))
|
|
||||||
} else {
|
|
||||||
delete(r.rules, ruleID+fwdSuffix)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Release the refcount only once all rules are gone from the kernel. On
|
|
||||||
// partial failure the failed entries stay in r.rules so a retry can remove
|
|
||||||
// them and release then.
|
|
||||||
if merr == nil {
|
|
||||||
r.releaseForwarding()
|
|
||||||
}
|
|
||||||
|
|
||||||
r.updateState()
|
|
||||||
|
|
||||||
return nberrors.FormatErrorOrNil(merr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// releaseForwarding drops one IP forwarding reference, logging any error.
|
|
||||||
func (r *family) releaseForwarding() {
|
|
||||||
if err := r.ipFwdState.ReleaseForwarding(r.v6); err != nil {
|
|
||||||
log.Errorf("release IP forwarding: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *family) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error {
|
func (r *family) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error {
|
||||||
ruleID := firewall.RuleID(fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort))
|
ruleID := firewall.RuleID(fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort))
|
||||||
|
|
||||||
|
|||||||
@@ -1,240 +0,0 @@
|
|||||||
//go:build privileged
|
|
||||||
|
|
||||||
package iptables
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/netip"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
fw "github.com/netbirdio/netbird/client/firewall/manager"
|
|
||||||
"github.com/netbirdio/netbird/client/iface"
|
|
||||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
|
||||||
)
|
|
||||||
|
|
||||||
func iptRefcountIfaceV4() *iFaceMock {
|
|
||||||
return &iFaceMock{
|
|
||||||
NameFunc: func() string { return "wt-refcount" },
|
|
||||||
AddressFunc: func() wgaddr.Address {
|
|
||||||
return wgaddr.Address{
|
|
||||||
IP: netip.MustParseAddr("10.20.0.1"),
|
|
||||||
Network: netip.MustParsePrefix("10.20.0.0/24"),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func iptRefcountIfaceDual() *iFaceMock {
|
|
||||||
return &iFaceMock{
|
|
||||||
NameFunc: func() string { return "wt-refcount" },
|
|
||||||
AddressFunc: func() wgaddr.Address {
|
|
||||||
return wgaddr.Address{
|
|
||||||
IP: netip.MustParseAddr("10.20.0.1"),
|
|
||||||
Network: netip.MustParsePrefix("10.20.0.0/24"),
|
|
||||||
IPv6: netip.MustParseAddr("fd00::1"),
|
|
||||||
IPv6Net: netip.MustParsePrefix("fd00::/64"),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newIptRefcountManager(t *testing.T, dual bool) *Manager {
|
|
||||||
t.Helper()
|
|
||||||
var ifMock *iFaceMock
|
|
||||||
if dual {
|
|
||||||
ifMock = iptRefcountIfaceDual()
|
|
||||||
} else {
|
|
||||||
ifMock = iptRefcountIfaceV4()
|
|
||||||
}
|
|
||||||
m, err := Create(ifMock, iface.DefaultMTU)
|
|
||||||
require.NoError(t, err, "create manager")
|
|
||||||
require.NoError(t, m.Init(nil), "init manager")
|
|
||||||
t.Cleanup(func() {
|
|
||||||
require.NoError(t, m.Close(nil), "close manager")
|
|
||||||
})
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
func iptDnatV4(port uint16) fw.ForwardRule {
|
|
||||||
return fw.ForwardRule{
|
|
||||||
Protocol: fw.ProtocolTCP,
|
|
||||||
DestinationPort: fw.Port{Values: []uint16{port}},
|
|
||||||
TranslatedAddress: netip.MustParseAddr("10.20.0.2"),
|
|
||||||
TranslatedPort: fw.Port{Values: []uint16{80}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func iptDnatV6(port uint16) fw.ForwardRule {
|
|
||||||
return fw.ForwardRule{
|
|
||||||
Protocol: fw.ProtocolTCP,
|
|
||||||
DestinationPort: fw.Port{Values: []uint16{port}},
|
|
||||||
TranslatedAddress: netip.MustParseAddr("fd00::2"),
|
|
||||||
TranslatedPort: fw.Port{Values: []uint16{80}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIptablesRouting_RepeatedEnableSingleReference verifies that EnableRouting
|
|
||||||
// (called on every network-map update) holds at most one reference per family
|
|
||||||
// and a single DisableRouting drops both back to zero.
|
|
||||||
func TestIptablesRouting_RepeatedEnableSingleReference(t *testing.T) {
|
|
||||||
m := newIptRefcountManager(t, true)
|
|
||||||
state := m.family4.ipFwdState
|
|
||||||
|
|
||||||
require.NoError(t, m.EnableRouting(), "first enable")
|
|
||||||
require.NoError(t, m.EnableRouting(), "second enable")
|
|
||||||
require.NoError(t, m.EnableRouting(), "third enable")
|
|
||||||
v4, v6 := state.Counts()
|
|
||||||
assert.Equal(t, 1, v4, "repeated enable holds a single v4 reference")
|
|
||||||
assert.Equal(t, 1, v6, "repeated enable holds a single v6 reference")
|
|
||||||
|
|
||||||
require.NoError(t, m.DisableRouting(), "disable")
|
|
||||||
v4, v6 = state.Counts()
|
|
||||||
assert.Equal(t, 0, v4, "single disable releases the v4 reference")
|
|
||||||
assert.Equal(t, 0, v6, "single disable releases the v6 reference")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIptablesRouting_DisableKeepsDNATReference verifies that an unpaired
|
|
||||||
// DisableRouting does not release references held by active DNAT rules.
|
|
||||||
func TestIptablesRouting_DisableKeepsDNATReference(t *testing.T) {
|
|
||||||
m := newIptRefcountManager(t, true)
|
|
||||||
state := m.family4.ipFwdState
|
|
||||||
|
|
||||||
r1, err := m.AddDNATRule(iptDnatV6(9095))
|
|
||||||
require.NoError(t, err, "add v6 dnat")
|
|
||||||
|
|
||||||
require.NoError(t, m.DisableRouting(), "unpaired disable")
|
|
||||||
_, v6 := state.Counts()
|
|
||||||
assert.Equal(t, 1, v6, "DNAT-held reference survives unpaired DisableRouting")
|
|
||||||
|
|
||||||
require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat")
|
|
||||||
_, v6 = state.Counts()
|
|
||||||
assert.Equal(t, 0, v6, "delete releases the DNAT reference")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIptablesDNAT_RefcountBalancedV4 covers a Balanced Add/Delete pair on v4.
|
|
||||||
func TestIptablesDNAT_RefcountBalancedV4(t *testing.T) {
|
|
||||||
m := newIptRefcountManager(t, false)
|
|
||||||
state := m.family4.ipFwdState
|
|
||||||
|
|
||||||
r1, err := m.AddDNATRule(iptDnatV4(7081))
|
|
||||||
require.NoError(t, err, "add v4 dnat 1")
|
|
||||||
v4, v6 := state.Counts()
|
|
||||||
assert.Equal(t, 1, v4, "v4 refcount after first add")
|
|
||||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
|
||||||
|
|
||||||
r2, err := m.AddDNATRule(iptDnatV4(7082))
|
|
||||||
require.NoError(t, err, "add v4 dnat 2")
|
|
||||||
v4, v6 = state.Counts()
|
|
||||||
assert.Equal(t, 2, v4, "v4 refcount after second add")
|
|
||||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
|
||||||
|
|
||||||
require.NoError(t, m.DeleteDNATRule(r1))
|
|
||||||
v4, v6 = state.Counts()
|
|
||||||
assert.Equal(t, 1, v4, "v4 refcount after first delete")
|
|
||||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
|
||||||
|
|
||||||
require.NoError(t, m.DeleteDNATRule(r2))
|
|
||||||
v4, v6 = state.Counts()
|
|
||||||
assert.Equal(t, 0, v4, "v4 refcount after second delete")
|
|
||||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIptablesDNAT_RefcountBalancedV6 checks the v6 path increments v6 only and
|
|
||||||
// decrements back to zero.
|
|
||||||
func TestIptablesDNAT_RefcountBalancedV6(t *testing.T) {
|
|
||||||
m := newIptRefcountManager(t, true)
|
|
||||||
require.NotNil(t, m.family6, "v6 family")
|
|
||||||
require.Same(t, m.family4.ipFwdState, m.family6.ipFwdState, "shared state")
|
|
||||||
state := m.family4.ipFwdState
|
|
||||||
|
|
||||||
r1, err := m.AddDNATRule(iptDnatV6(9081))
|
|
||||||
require.NoError(t, err, "add v6 dnat 1")
|
|
||||||
v4, v6 := state.Counts()
|
|
||||||
assert.Equal(t, 0, v4)
|
|
||||||
assert.Equal(t, 1, v6, "v6 refcount after first add")
|
|
||||||
|
|
||||||
r2, err := m.AddDNATRule(iptDnatV6(9082))
|
|
||||||
require.NoError(t, err, "add v6 dnat 2")
|
|
||||||
v4, v6 = state.Counts()
|
|
||||||
assert.Equal(t, 0, v4, "v4 refcount unchanged")
|
|
||||||
assert.Equal(t, 2, v6, "v6 refcount after second add")
|
|
||||||
|
|
||||||
require.NoError(t, m.DeleteDNATRule(r1))
|
|
||||||
v4, v6 = state.Counts()
|
|
||||||
assert.Equal(t, 0, v4, "v4 refcount unchanged")
|
|
||||||
assert.Equal(t, 1, v6, "v6 refcount after first delete")
|
|
||||||
|
|
||||||
require.NoError(t, m.DeleteDNATRule(r2))
|
|
||||||
v4, v6 = state.Counts()
|
|
||||||
assert.Equal(t, 0, v4)
|
|
||||||
assert.Equal(t, 0, v6, "v6 refcount after second delete")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIptablesDNAT_DuplicateAddNoLeak verifies the duplicate-rule path returns
|
|
||||||
// without bumping the refcount.
|
|
||||||
func TestIptablesDNAT_DuplicateAddNoLeak(t *testing.T) {
|
|
||||||
m := newIptRefcountManager(t, true)
|
|
||||||
state := m.family4.ipFwdState
|
|
||||||
|
|
||||||
rule := iptDnatV4(7083)
|
|
||||||
r1, err := m.AddDNATRule(rule)
|
|
||||||
require.NoError(t, err)
|
|
||||||
v4, _ := state.Counts()
|
|
||||||
assert.Equal(t, 1, v4)
|
|
||||||
|
|
||||||
_, err = m.AddDNATRule(rule)
|
|
||||||
require.NoError(t, err, "duplicate add")
|
|
||||||
v4, _ = state.Counts()
|
|
||||||
assert.Equal(t, 1, v4, "duplicate add must not increment")
|
|
||||||
|
|
||||||
require.NoError(t, m.DeleteDNATRule(r1))
|
|
||||||
v4, _ = state.Counts()
|
|
||||||
assert.Equal(t, 0, v4, "single delete must drop to zero")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIptablesDNAT_DeleteMissingNoUnderflow verifies Delete on an unknown rule
|
|
||||||
// neither errors nor releases the refcount.
|
|
||||||
func TestIptablesDNAT_DeleteMissingNoUnderflow(t *testing.T) {
|
|
||||||
m := newIptRefcountManager(t, true)
|
|
||||||
state := m.family4.ipFwdState
|
|
||||||
|
|
||||||
phantom := iptDnatV4(7099)
|
|
||||||
require.NoError(t, m.DeleteDNATRule(&phantom), "delete missing v4")
|
|
||||||
v4, v6 := state.Counts()
|
|
||||||
assert.Equal(t, 0, v4)
|
|
||||||
assert.Equal(t, 0, v6)
|
|
||||||
|
|
||||||
phantom6 := iptDnatV6(9099)
|
|
||||||
require.NoError(t, m.DeleteDNATRule(&phantom6), "delete missing v6")
|
|
||||||
v4, v6 = state.Counts()
|
|
||||||
assert.Equal(t, 0, v4)
|
|
||||||
assert.Equal(t, 0, v6)
|
|
||||||
|
|
||||||
r1, err := m.AddDNATRule(iptDnatV4(7100))
|
|
||||||
require.NoError(t, err)
|
|
||||||
v4, _ = state.Counts()
|
|
||||||
assert.Equal(t, 1, v4, "real add still increments after phantom delete")
|
|
||||||
require.NoError(t, m.DeleteDNATRule(r1))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIptablesDNAT_DoubleDeleteNoUnderflow verifies a second Delete on the same
|
|
||||||
// rule is a no-op.
|
|
||||||
func TestIptablesDNAT_DoubleDeleteNoUnderflow(t *testing.T) {
|
|
||||||
m := newIptRefcountManager(t, true)
|
|
||||||
state := m.family4.ipFwdState
|
|
||||||
|
|
||||||
r1, err := m.AddDNATRule(iptDnatV6(9083))
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, v6 := state.Counts()
|
|
||||||
assert.Equal(t, 1, v6)
|
|
||||||
|
|
||||||
require.NoError(t, m.DeleteDNATRule(r1), "first delete")
|
|
||||||
_, v6 = state.Counts()
|
|
||||||
assert.Equal(t, 0, v6)
|
|
||||||
|
|
||||||
require.NoError(t, m.DeleteDNATRule(r1), "second delete must be no-op")
|
|
||||||
_, v6 = state.Counts()
|
|
||||||
assert.Equal(t, 0, v6, "double delete must not underflow")
|
|
||||||
}
|
|
||||||
@@ -56,10 +56,6 @@ const (
|
|||||||
markManglePost = "mark-mangle-post"
|
markManglePost = "mark-mangle-post"
|
||||||
matchSet = "--match-set"
|
matchSet = "--match-set"
|
||||||
|
|
||||||
dnatSuffix firewall.RuleID = "_dnat"
|
|
||||||
snatSuffix firewall.RuleID = "_snat"
|
|
||||||
fwdSuffix firewall.RuleID = "_fwd"
|
|
||||||
|
|
||||||
// ipv4TCPHeaderSize is the minimum IPv4 (20) + TCP (20) header size for MSS calculation.
|
// ipv4TCPHeaderSize is the minimum IPv4 (20) + TCP (20) header size for MSS calculation.
|
||||||
ipv4TCPHeaderSize = 40
|
ipv4TCPHeaderSize = 40
|
||||||
// ipv6TCPHeaderSize is the minimum IPv6 (40) + TCP (20) header size for MSS calculation.
|
// ipv6TCPHeaderSize is the minimum IPv6 (40) + TCP (20) header size for MSS calculation.
|
||||||
|
|||||||
@@ -81,15 +81,6 @@ func (r *family) hasRule(id nbid.RuleID) bool {
|
|||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasDNATRule reports whether this family owns the DNAT rule set for
|
|
||||||
// the given user id. DNAT rules live in r.rules under the well-known
|
|
||||||
// "<id>_dnat" key; the lookup here is used by Manager.DeleteDNATRule
|
|
||||||
// to pick the right family.
|
|
||||||
func (r *family) hasDNATRule(id firewall.RuleID) bool {
|
|
||||||
_, ok := r.rules[id+dnatSuffix]
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteFilterRule removes a previously installed filter rule. The
|
// DeleteFilterRule removes a previously installed filter rule. The
|
||||||
// rule's stored chain/table identify where to delete from; source set
|
// rule's stored chain/table identify where to delete from; source set
|
||||||
// references are recovered from the spec via findSets and dropped
|
// references are recovered from the spec via findSets and dropped
|
||||||
|
|||||||
@@ -323,31 +323,6 @@ func (m *Manager) DisableRouting() error {
|
|||||||
return m.family4.ipFwdState.ReleaseRouting()
|
return m.family4.ipFwdState.ReleaseRouting()
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddDNATRule adds a DNAT rule
|
|
||||||
func (m *Manager) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) {
|
|
||||||
m.mutex.Lock()
|
|
||||||
defer m.mutex.Unlock()
|
|
||||||
|
|
||||||
if rule.TranslatedAddress.Is6() {
|
|
||||||
if !m.hasIPv6() {
|
|
||||||
return nil, fmt.Errorf("add DNAT rule: %w", firewall.ErrIPv6NotInitialized)
|
|
||||||
}
|
|
||||||
return m.family6.AddDNATRule(rule)
|
|
||||||
}
|
|
||||||
return m.family4.AddDNATRule(rule)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteDNATRule deletes a DNAT rule
|
|
||||||
func (m *Manager) DeleteDNATRule(rule firewall.Rule) error {
|
|
||||||
m.mutex.Lock()
|
|
||||||
defer m.mutex.Unlock()
|
|
||||||
|
|
||||||
if m.hasIPv6() && !m.family4.hasDNATRule(rule.ID()) {
|
|
||||||
return m.family6.DeleteDNATRule(rule)
|
|
||||||
}
|
|
||||||
return m.family4.DeleteDNATRule(rule)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateSet updates the set with the given prefixes
|
// UpdateSet updates the set with the given prefixes
|
||||||
func (m *Manager) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error {
|
func (m *Manager) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error {
|
||||||
m.mutex.Lock()
|
m.mutex.Lock()
|
||||||
|
|||||||
@@ -497,16 +497,6 @@ func TestIptablesCloseRemovesAllState(t *testing.T) {
|
|||||||
require.NoError(t, manager.AddNatRule(pair), "add nat rule")
|
require.NoError(t, manager.AddNatRule(pair), "add nat rule")
|
||||||
require.NoError(t, manager.EnableRouting(), "enable routing")
|
require.NoError(t, manager.EnableRouting(), "enable routing")
|
||||||
|
|
||||||
// A DNAT redirect, which also holds a forwarding reference.
|
|
||||||
dnat := fw.ForwardRule{
|
|
||||||
Protocol: fw.ProtocolTCP,
|
|
||||||
DestinationPort: fw.Port{Values: []uint16{8080}},
|
|
||||||
TranslatedAddress: netip.MustParseAddr("10.20.0.44"),
|
|
||||||
TranslatedPort: fw.Port{Values: []uint16{80}},
|
|
||||||
}
|
|
||||||
_, err = manager.AddDNATRule(dnat)
|
|
||||||
require.NoError(t, err, "add dnat rule")
|
|
||||||
|
|
||||||
require.NotEqual(t, before, snapshotIptables(t, ipv4Client), "the manager must have installed state")
|
require.NotEqual(t, before, snapshotIptables(t, ipv4Client), "the manager must have installed state")
|
||||||
|
|
||||||
// Everything above stays in place, so Close is what has to remove it.
|
// Everything above stays in place, so Close is what has to remove it.
|
||||||
|
|||||||
@@ -172,12 +172,6 @@ type Manager interface {
|
|||||||
|
|
||||||
DisableRouting() error
|
DisableRouting() error
|
||||||
|
|
||||||
// AddDNATRule adds outbound DNAT rule for forwarding external traffic to the NetBird network.
|
|
||||||
AddDNATRule(ForwardRule) (Rule, error)
|
|
||||||
|
|
||||||
// DeleteDNATRule deletes the outbound DNAT rule.
|
|
||||||
DeleteDNATRule(Rule) error
|
|
||||||
|
|
||||||
// UpdateSet updates the set with the given prefixes
|
// UpdateSet updates the set with the given prefixes
|
||||||
UpdateSet(hash Set, prefixes []netip.Prefix) error
|
UpdateSet(hash Set, prefixes []netip.Prefix) error
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
package manager
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/netip"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ForwardRule todo figure out better place to this to avoid circular imports
|
|
||||||
type ForwardRule struct {
|
|
||||||
Protocol Protocol
|
|
||||||
DestinationPort Port
|
|
||||||
TranslatedAddress netip.Addr
|
|
||||||
TranslatedPort Port
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r ForwardRule) ID() RuleID {
|
|
||||||
id := fmt.Sprintf("%s;%s;%s;%s",
|
|
||||||
r.Protocol,
|
|
||||||
r.DestinationPort.String(),
|
|
||||||
r.TranslatedAddress.String(),
|
|
||||||
r.TranslatedPort.String())
|
|
||||||
return RuleID(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r ForwardRule) String() string {
|
|
||||||
return fmt.Sprintf("protocol: %s, destinationPort: %s, translatedAddress: %s, translatedPort: %s", r.Protocol, r.DestinationPort.String(), r.TranslatedAddress.String(), r.TranslatedPort.String())
|
|
||||||
}
|
|
||||||
@@ -9,332 +9,11 @@ import (
|
|||||||
"github.com/google/nftables"
|
"github.com/google/nftables"
|
||||||
"github.com/google/nftables/binaryutil"
|
"github.com/google/nftables/binaryutil"
|
||||||
"github.com/google/nftables/expr"
|
"github.com/google/nftables/expr"
|
||||||
"github.com/google/nftables/xt"
|
|
||||||
"github.com/hashicorp/go-multierror"
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
|
||||||
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *family) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) {
|
|
||||||
ruleID := rule.ID()
|
|
||||||
if _, exists := r.rules[ruleID+dnatSuffix]; exists {
|
|
||||||
return rule, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
protoNum, err := r.af.protoNum(rule.Protocol)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("convert protocol to number: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request forwarding before queueing rules: addDnatRedirect/addDnatMasq
|
|
||||||
// buffer netlink messages on r.conn that the next caller's Flush would
|
|
||||||
// commit if we returned without flushing them ourselves.
|
|
||||||
if err := r.ipFwdState.RequestForwarding(r.isV6()); err != nil {
|
|
||||||
return nil, fmt.Errorf("enable forwarding: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := r.addDnatRedirect(rule, protoNum, ruleID); err != nil {
|
|
||||||
r.releaseForwarding()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := r.addDnatMasq(rule, protoNum, ruleID); err != nil {
|
|
||||||
r.releaseForwarding()
|
|
||||||
delete(r.rules, ruleID+dnatSuffix)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unlike iptables, there's no point in adding "out" rules in the forward chain here as our policy is ACCEPT.
|
|
||||||
// To overcome DROP policies in other chains, we'd have to add rules to the chains there.
|
|
||||||
// We also cannot just add "oif <iface> accept" there and filter in our own table as we don't know what is supposed to be allowed.
|
|
||||||
// TODO: find chains with drop policies and add rules there
|
|
||||||
|
|
||||||
if err := r.conn.Flush(); err != nil {
|
|
||||||
r.releaseForwarding()
|
|
||||||
delete(r.rules, ruleID+dnatSuffix)
|
|
||||||
delete(r.rules, ruleID+snatSuffix)
|
|
||||||
return nil, fmt.Errorf("flush rules: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &rule, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *family) addDnatRedirect(rule firewall.ForwardRule, protoNum uint8, ruleID firewall.RuleID) error {
|
|
||||||
dnatExprs := []expr.Any{
|
|
||||||
&expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1},
|
|
||||||
&expr.Cmp{
|
|
||||||
Op: expr.CmpOpNeq,
|
|
||||||
Register: 1,
|
|
||||||
Data: ifname(r.wgIface.Name()),
|
|
||||||
},
|
|
||||||
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1},
|
|
||||||
&expr.Cmp{
|
|
||||||
Op: expr.CmpOpEq,
|
|
||||||
Register: 1,
|
|
||||||
Data: []byte{protoNum},
|
|
||||||
},
|
|
||||||
&expr.Payload{
|
|
||||||
DestRegister: 1,
|
|
||||||
Base: expr.PayloadBaseTransportHeader,
|
|
||||||
Offset: 2,
|
|
||||||
Len: 2,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
portExprs, err := r.applyPort(&rule.DestinationPort, false)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("apply destination port: %w", err)
|
|
||||||
}
|
|
||||||
dnatExprs = append(dnatExprs, portExprs...)
|
|
||||||
|
|
||||||
// shifted translated port is not supported in nftables, so we hand this over to xtables
|
|
||||||
if rule.TranslatedPort.IsRange && len(rule.TranslatedPort.Values) == 2 {
|
|
||||||
if rule.TranslatedPort.Values[0] != rule.DestinationPort.Values[0] ||
|
|
||||||
rule.TranslatedPort.Values[1] != rule.DestinationPort.Values[1] {
|
|
||||||
return r.addXTablesRedirect(dnatExprs, ruleID, rule)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
additionalExprs, regProtoMin, regProtoMax, err := r.handleTranslatedPort(rule)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
dnatExprs = append(dnatExprs, additionalExprs...)
|
|
||||||
|
|
||||||
dnatExprs = append(dnatExprs,
|
|
||||||
&expr.NAT{
|
|
||||||
Type: expr.NATTypeDestNAT,
|
|
||||||
Family: uint32(r.af.tableFamily),
|
|
||||||
RegAddrMin: 1,
|
|
||||||
RegProtoMin: regProtoMin,
|
|
||||||
RegProtoMax: regProtoMax,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
dnatRule := &nftables.Rule{
|
|
||||||
Table: r.workTable,
|
|
||||||
Chain: r.chains[chainNameRoutingRdr],
|
|
||||||
Exprs: dnatExprs,
|
|
||||||
UserData: []byte(ruleID + dnatSuffix),
|
|
||||||
}
|
|
||||||
r.conn.AddRule(dnatRule)
|
|
||||||
r.rules[ruleID+dnatSuffix] = dnatRule
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *family) handleTranslatedPort(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) {
|
|
||||||
switch {
|
|
||||||
case rule.TranslatedPort.IsRange && len(rule.TranslatedPort.Values) == 2:
|
|
||||||
return r.handlePortRange(rule)
|
|
||||||
case len(rule.TranslatedPort.Values) == 0:
|
|
||||||
return r.handleAddressOnly(rule)
|
|
||||||
case len(rule.TranslatedPort.Values) == 1:
|
|
||||||
return r.handleSinglePort(rule)
|
|
||||||
default:
|
|
||||||
return nil, 0, 0, fmt.Errorf("invalid translated port: %v", rule.TranslatedPort)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *family) handlePortRange(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) {
|
|
||||||
exprs := []expr.Any{
|
|
||||||
&expr.Immediate{
|
|
||||||
Register: 1,
|
|
||||||
Data: rule.TranslatedAddress.AsSlice(),
|
|
||||||
},
|
|
||||||
&expr.Immediate{
|
|
||||||
Register: 2,
|
|
||||||
Data: binaryutil.BigEndian.PutUint16(rule.TranslatedPort.Values[0]),
|
|
||||||
},
|
|
||||||
&expr.Immediate{
|
|
||||||
Register: 3,
|
|
||||||
Data: binaryutil.BigEndian.PutUint16(rule.TranslatedPort.Values[1]),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return exprs, 2, 3, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *family) handleAddressOnly(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) {
|
|
||||||
exprs := []expr.Any{
|
|
||||||
&expr.Immediate{
|
|
||||||
Register: 1,
|
|
||||||
Data: rule.TranslatedAddress.AsSlice(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return exprs, 0, 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *family) handleSinglePort(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) {
|
|
||||||
exprs := []expr.Any{
|
|
||||||
&expr.Immediate{
|
|
||||||
Register: 1,
|
|
||||||
Data: rule.TranslatedAddress.AsSlice(),
|
|
||||||
},
|
|
||||||
&expr.Immediate{
|
|
||||||
Register: 2,
|
|
||||||
Data: binaryutil.BigEndian.PutUint16(rule.TranslatedPort.Values[0]),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return exprs, 2, 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *family) addXTablesRedirect(dnatExprs []expr.Any, ruleID firewall.RuleID, rule firewall.ForwardRule) error {
|
|
||||||
dnatExprs = append(dnatExprs,
|
|
||||||
&expr.Counter{},
|
|
||||||
&expr.Target{
|
|
||||||
Name: "DNAT",
|
|
||||||
Rev: 2,
|
|
||||||
Info: &xt.NatRange2{
|
|
||||||
NatRange: xt.NatRange{
|
|
||||||
Flags: uint(xt.NatRangeMapIPs | xt.NatRangeProtoSpecified | xt.NatRangeProtoOffset),
|
|
||||||
MinIP: rule.TranslatedAddress.AsSlice(),
|
|
||||||
MaxIP: rule.TranslatedAddress.AsSlice(),
|
|
||||||
MinPort: rule.TranslatedPort.Values[0],
|
|
||||||
MaxPort: rule.TranslatedPort.Values[1],
|
|
||||||
},
|
|
||||||
BasePort: rule.DestinationPort.Values[0],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
natTable := &nftables.Table{
|
|
||||||
Name: tableNat,
|
|
||||||
Family: r.af.tableFamily,
|
|
||||||
}
|
|
||||||
dnatRule := &nftables.Rule{
|
|
||||||
Table: natTable,
|
|
||||||
Chain: &nftables.Chain{
|
|
||||||
Name: chainNameNatPrerouting,
|
|
||||||
Table: natTable,
|
|
||||||
Type: nftables.ChainTypeNAT,
|
|
||||||
Hooknum: nftables.ChainHookPrerouting,
|
|
||||||
Priority: nftables.ChainPriorityNATDest,
|
|
||||||
},
|
|
||||||
Exprs: dnatExprs,
|
|
||||||
UserData: []byte(ruleID + dnatSuffix),
|
|
||||||
}
|
|
||||||
r.conn.AddRule(dnatRule)
|
|
||||||
r.rules[ruleID+dnatSuffix] = dnatRule
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *family) addDnatMasq(rule firewall.ForwardRule, protoNum uint8, ruleID firewall.RuleID) error {
|
|
||||||
portExprs, err := r.applyPort(&rule.TranslatedPort, false)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("apply translated port: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
masqExprs := []expr.Any{
|
|
||||||
&expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1},
|
|
||||||
&expr.Cmp{
|
|
||||||
Op: expr.CmpOpEq,
|
|
||||||
Register: 1,
|
|
||||||
Data: ifname(r.wgIface.Name()),
|
|
||||||
},
|
|
||||||
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1},
|
|
||||||
&expr.Cmp{
|
|
||||||
Op: expr.CmpOpEq,
|
|
||||||
Register: 1,
|
|
||||||
Data: []byte{protoNum},
|
|
||||||
},
|
|
||||||
&expr.Payload{
|
|
||||||
DestRegister: 1,
|
|
||||||
Base: expr.PayloadBaseNetworkHeader,
|
|
||||||
Offset: r.af.dstAddrOffset,
|
|
||||||
Len: r.af.addrLen,
|
|
||||||
},
|
|
||||||
&expr.Cmp{
|
|
||||||
Op: expr.CmpOpEq,
|
|
||||||
Register: 1,
|
|
||||||
Data: rule.TranslatedAddress.AsSlice(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
masqExprs = append(masqExprs, portExprs...)
|
|
||||||
masqExprs = append(masqExprs, &expr.Masq{})
|
|
||||||
|
|
||||||
masqRule := &nftables.Rule{
|
|
||||||
Table: r.workTable,
|
|
||||||
Chain: r.chains[chainNameRoutingNat],
|
|
||||||
Exprs: masqExprs,
|
|
||||||
UserData: []byte(ruleID + snatSuffix),
|
|
||||||
}
|
|
||||||
r.conn.AddRule(masqRule)
|
|
||||||
r.rules[ruleID+snatSuffix] = masqRule
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *family) DeleteDNATRule(rule firewall.Rule) error {
|
|
||||||
ruleID := rule.ID()
|
|
||||||
|
|
||||||
if err := r.refreshRulesMap(); err != nil {
|
|
||||||
return fmt.Errorf(refreshRulesMapError, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var merr *multierror.Error
|
|
||||||
var needsFlush bool
|
|
||||||
var found bool
|
|
||||||
|
|
||||||
if dnatRule, exists := r.rules[ruleID+dnatSuffix]; exists {
|
|
||||||
found = true
|
|
||||||
if dnatRule.Handle == 0 {
|
|
||||||
log.Warnf("dnat rule %s has no handle, removing stale entry", ruleID+dnatSuffix)
|
|
||||||
delete(r.rules, ruleID+dnatSuffix)
|
|
||||||
} else if err := r.conn.DelRule(dnatRule); err != nil {
|
|
||||||
merr = multierror.Append(merr, fmt.Errorf("delete dnat rule: %w", err))
|
|
||||||
} else {
|
|
||||||
needsFlush = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if masqRule, exists := r.rules[ruleID+snatSuffix]; exists {
|
|
||||||
found = true
|
|
||||||
if masqRule.Handle == 0 {
|
|
||||||
log.Warnf("snat rule %s has no handle, removing stale entry", ruleID+snatSuffix)
|
|
||||||
delete(r.rules, ruleID+snatSuffix)
|
|
||||||
} else if err := r.conn.DelRule(masqRule); err != nil {
|
|
||||||
merr = multierror.Append(merr, fmt.Errorf("delete snat rule: %w", err))
|
|
||||||
} else {
|
|
||||||
needsFlush = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if needsFlush {
|
|
||||||
if err := r.conn.Flush(); err != nil {
|
|
||||||
merr = multierror.Append(merr, fmt.Errorf(flushError, err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if merr != nil {
|
|
||||||
return nberrors.FormatErrorOrNil(merr)
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(r.rules, ruleID+dnatSuffix)
|
|
||||||
delete(r.rules, ruleID+snatSuffix)
|
|
||||||
|
|
||||||
// Release once, only if the rule was present and removed.
|
|
||||||
if found {
|
|
||||||
r.releaseForwarding()
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// releaseForwarding drops one IP forwarding reference, logging any error.
|
|
||||||
func (r *family) releaseForwarding() {
|
|
||||||
if err := r.ipFwdState.ReleaseForwarding(r.isV6()); err != nil {
|
|
||||||
log.Errorf("release IP forwarding: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// isV6 reports whether this family handles the IPv6 table.
|
|
||||||
func (r *family) isV6() bool {
|
|
||||||
return r.af.tableFamily == nftables.TableFamilyIPv6
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *family) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error {
|
func (r *family) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error {
|
||||||
ruleID := firewall.RuleID(fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort))
|
ruleID := firewall.RuleID(fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort))
|
||||||
|
|
||||||
|
|||||||
@@ -1,249 +0,0 @@
|
|||||||
//go:build privileged
|
|
||||||
|
|
||||||
package nftables
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/netip"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
fw "github.com/netbirdio/netbird/client/firewall/manager"
|
|
||||||
"github.com/netbirdio/netbird/client/iface"
|
|
||||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
|
||||||
)
|
|
||||||
|
|
||||||
func nftRefcountIfaceV4() *iFaceMock {
|
|
||||||
return &iFaceMock{
|
|
||||||
NameFunc: func() string { return "wt-refcount" },
|
|
||||||
AddressFunc: func() wgaddr.Address {
|
|
||||||
return wgaddr.Address{
|
|
||||||
IP: netip.MustParseAddr("100.96.0.1"),
|
|
||||||
Network: netip.MustParsePrefix("100.96.0.0/16"),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func nftRefcountIfaceDual() *iFaceMock {
|
|
||||||
return &iFaceMock{
|
|
||||||
NameFunc: func() string { return "wt-refcount" },
|
|
||||||
AddressFunc: func() wgaddr.Address {
|
|
||||||
return wgaddr.Address{
|
|
||||||
IP: netip.MustParseAddr("100.96.0.1"),
|
|
||||||
Network: netip.MustParsePrefix("100.96.0.0/16"),
|
|
||||||
IPv6: netip.MustParseAddr("fd00::1"),
|
|
||||||
IPv6Net: netip.MustParsePrefix("fd00::/64"),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newNftRefcountManager(t *testing.T, dual bool) *Manager {
|
|
||||||
t.Helper()
|
|
||||||
if check() != NFTABLES {
|
|
||||||
t.Skip("nftables not supported on this system")
|
|
||||||
}
|
|
||||||
var ifMock *iFaceMock
|
|
||||||
if dual {
|
|
||||||
ifMock = nftRefcountIfaceDual()
|
|
||||||
} else {
|
|
||||||
ifMock = nftRefcountIfaceV4()
|
|
||||||
}
|
|
||||||
m, err := Create(ifMock, iface.DefaultMTU)
|
|
||||||
require.NoError(t, err, "create manager")
|
|
||||||
require.NoError(t, m.Init(nil), "init manager")
|
|
||||||
t.Cleanup(func() {
|
|
||||||
require.NoError(t, m.Close(nil), "close manager")
|
|
||||||
})
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
func dnatV4(port uint16) fw.ForwardRule {
|
|
||||||
return fw.ForwardRule{
|
|
||||||
Protocol: fw.ProtocolTCP,
|
|
||||||
DestinationPort: fw.Port{Values: []uint16{port}},
|
|
||||||
TranslatedAddress: netip.MustParseAddr("100.96.0.2"),
|
|
||||||
TranslatedPort: fw.Port{Values: []uint16{80}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func dnatV6(port uint16) fw.ForwardRule {
|
|
||||||
return fw.ForwardRule{
|
|
||||||
Protocol: fw.ProtocolTCP,
|
|
||||||
DestinationPort: fw.Port{Values: []uint16{port}},
|
|
||||||
TranslatedAddress: netip.MustParseAddr("fd00::2"),
|
|
||||||
TranslatedPort: fw.Port{Values: []uint16{80}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNftablesDNAT_RefcountBalancedV4 verifies that Add/Delete pairs leave the
|
|
||||||
// v4 refcount at zero.
|
|
||||||
func TestNftablesDNAT_RefcountBalancedV4(t *testing.T) {
|
|
||||||
m := newNftRefcountManager(t, false)
|
|
||||||
state := m.family4.ipFwdState
|
|
||||||
|
|
||||||
r1, err := m.AddDNATRule(dnatV4(8081))
|
|
||||||
require.NoError(t, err, "add v4 dnat 1")
|
|
||||||
v4, v6 := state.Counts()
|
|
||||||
assert.Equal(t, 1, v4, "v4 refcount after first add")
|
|
||||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
|
||||||
|
|
||||||
r2, err := m.AddDNATRule(dnatV4(8082))
|
|
||||||
require.NoError(t, err, "add v4 dnat 2")
|
|
||||||
v4, v6 = state.Counts()
|
|
||||||
assert.Equal(t, 2, v4, "v4 refcount after second add")
|
|
||||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
|
||||||
|
|
||||||
require.NoError(t, m.DeleteDNATRule(r1), "delete v4 dnat 1")
|
|
||||||
v4, v6 = state.Counts()
|
|
||||||
assert.Equal(t, 1, v4, "v4 refcount after first delete")
|
|
||||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
|
||||||
|
|
||||||
require.NoError(t, m.DeleteDNATRule(r2), "delete v4 dnat 2")
|
|
||||||
v4, v6 = state.Counts()
|
|
||||||
assert.Equal(t, 0, v4, "v4 refcount after second delete")
|
|
||||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNftablesDNAT_RefcountBalancedV6 verifies the v6 path increments v6 only
|
|
||||||
// and decrements back to zero on Delete.
|
|
||||||
func TestNftablesDNAT_RefcountBalancedV6(t *testing.T) {
|
|
||||||
m := newNftRefcountManager(t, true)
|
|
||||||
require.NotNil(t, m.family6, "v6 family")
|
|
||||||
require.Same(t, m.family4.ipFwdState, m.family6.ipFwdState, "shared state")
|
|
||||||
state := m.family4.ipFwdState
|
|
||||||
|
|
||||||
r1, err := m.AddDNATRule(dnatV6(9091))
|
|
||||||
require.NoError(t, err, "add v6 dnat 1")
|
|
||||||
v4, v6 := state.Counts()
|
|
||||||
assert.Equal(t, 0, v4, "v4 refcount unchanged")
|
|
||||||
assert.Equal(t, 1, v6, "v6 refcount after first add")
|
|
||||||
|
|
||||||
r2, err := m.AddDNATRule(dnatV6(9092))
|
|
||||||
require.NoError(t, err, "add v6 dnat 2")
|
|
||||||
v4, v6 = state.Counts()
|
|
||||||
assert.Equal(t, 0, v4)
|
|
||||||
assert.Equal(t, 2, v6, "v6 refcount after second add")
|
|
||||||
|
|
||||||
require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat 1")
|
|
||||||
v4, v6 = state.Counts()
|
|
||||||
assert.Equal(t, 0, v4, "v4 refcount unchanged")
|
|
||||||
assert.Equal(t, 1, v6, "v6 refcount after first delete")
|
|
||||||
|
|
||||||
require.NoError(t, m.DeleteDNATRule(r2), "delete v6 dnat 2")
|
|
||||||
v4, v6 = state.Counts()
|
|
||||||
assert.Equal(t, 0, v4)
|
|
||||||
assert.Equal(t, 0, v6, "v6 refcount after second delete")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNftablesDNAT_DuplicateAddNoLeak verifies that a duplicate Add (same
|
|
||||||
// ForwardRule) does not double-increment the refcount.
|
|
||||||
func TestNftablesDNAT_DuplicateAddNoLeak(t *testing.T) {
|
|
||||||
m := newNftRefcountManager(t, true)
|
|
||||||
state := m.family4.ipFwdState
|
|
||||||
|
|
||||||
rule := dnatV4(8083)
|
|
||||||
r1, err := m.AddDNATRule(rule)
|
|
||||||
require.NoError(t, err, "add v4 dnat")
|
|
||||||
v4, _ := state.Counts()
|
|
||||||
assert.Equal(t, 1, v4)
|
|
||||||
|
|
||||||
// duplicate add: same rule ID, must be a no-op for the refcount.
|
|
||||||
_, err = m.AddDNATRule(rule)
|
|
||||||
require.NoError(t, err, "duplicate add")
|
|
||||||
v4, _ = state.Counts()
|
|
||||||
assert.Equal(t, 1, v4, "duplicate add must not increment")
|
|
||||||
|
|
||||||
require.NoError(t, m.DeleteDNATRule(r1), "delete v4 dnat")
|
|
||||||
v4, _ = state.Counts()
|
|
||||||
assert.Equal(t, 0, v4, "single delete must drop to zero")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNftablesDNAT_DeleteMissingNoUnderflow verifies deleting a rule that was
|
|
||||||
// never added does not underflow the refcount.
|
|
||||||
func TestNftablesDNAT_DeleteMissingNoUnderflow(t *testing.T) {
|
|
||||||
m := newNftRefcountManager(t, true)
|
|
||||||
state := m.family4.ipFwdState
|
|
||||||
|
|
||||||
// Construct a Rule reference for something never added. The router stores
|
|
||||||
// rules by ID(), and DeleteDNATRule looks them up in r.rules; a missing
|
|
||||||
// entry must be a no-op rather than calling Release.
|
|
||||||
phantom := dnatV4(8099)
|
|
||||||
require.NoError(t, m.DeleteDNATRule(&phantom), "delete missing v4 dnat")
|
|
||||||
v4, v6 := state.Counts()
|
|
||||||
assert.Equal(t, 0, v4, "v4 refcount unaffected by missing delete")
|
|
||||||
assert.Equal(t, 0, v6, "v6 refcount unaffected")
|
|
||||||
|
|
||||||
phantom6 := dnatV6(9099)
|
|
||||||
require.NoError(t, m.DeleteDNATRule(&phantom6), "delete missing v6 dnat")
|
|
||||||
v4, v6 = state.Counts()
|
|
||||||
assert.Equal(t, 0, v4)
|
|
||||||
assert.Equal(t, 0, v6, "v6 refcount unaffected by missing delete")
|
|
||||||
|
|
||||||
// And after a phantom delete, a real add still results in count=1.
|
|
||||||
r1, err := m.AddDNATRule(dnatV4(8100))
|
|
||||||
require.NoError(t, err, "add v4 dnat after phantom delete")
|
|
||||||
v4, _ = state.Counts()
|
|
||||||
assert.Equal(t, 1, v4, "real add still increments after phantom delete")
|
|
||||||
require.NoError(t, m.DeleteDNATRule(r1))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNftablesRouting_RepeatedEnableSingleReference verifies that EnableRouting
|
|
||||||
// (called on every network-map update) holds at most one reference per family
|
|
||||||
// and a single DisableRouting drops both back to zero.
|
|
||||||
func TestNftablesRouting_RepeatedEnableSingleReference(t *testing.T) {
|
|
||||||
m := newNftRefcountManager(t, true)
|
|
||||||
state := m.family4.ipFwdState
|
|
||||||
|
|
||||||
require.NoError(t, m.EnableRouting(), "first enable")
|
|
||||||
require.NoError(t, m.EnableRouting(), "second enable")
|
|
||||||
require.NoError(t, m.EnableRouting(), "third enable")
|
|
||||||
v4, v6 := state.Counts()
|
|
||||||
assert.Equal(t, 1, v4, "repeated enable holds a single v4 reference")
|
|
||||||
assert.Equal(t, 1, v6, "repeated enable holds a single v6 reference")
|
|
||||||
|
|
||||||
require.NoError(t, m.DisableRouting(), "disable")
|
|
||||||
v4, v6 = state.Counts()
|
|
||||||
assert.Equal(t, 0, v4, "single disable releases the v4 reference")
|
|
||||||
assert.Equal(t, 0, v6, "single disable releases the v6 reference")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNftablesRouting_DisableKeepsDNATReference verifies that an unpaired
|
|
||||||
// DisableRouting does not release references held by active DNAT rules.
|
|
||||||
func TestNftablesRouting_DisableKeepsDNATReference(t *testing.T) {
|
|
||||||
m := newNftRefcountManager(t, true)
|
|
||||||
state := m.family4.ipFwdState
|
|
||||||
|
|
||||||
r1, err := m.AddDNATRule(dnatV6(9095))
|
|
||||||
require.NoError(t, err, "add v6 dnat")
|
|
||||||
|
|
||||||
require.NoError(t, m.DisableRouting(), "unpaired disable")
|
|
||||||
_, v6 := state.Counts()
|
|
||||||
assert.Equal(t, 1, v6, "DNAT-held reference survives unpaired DisableRouting")
|
|
||||||
|
|
||||||
require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat")
|
|
||||||
_, v6 = state.Counts()
|
|
||||||
assert.Equal(t, 0, v6, "delete releases the DNAT reference")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNftablesDNAT_DoubleDeleteNoUnderflow verifies that deleting the same rule
|
|
||||||
// twice does not underflow the refcount (the second delete is a no-op).
|
|
||||||
func TestNftablesDNAT_DoubleDeleteNoUnderflow(t *testing.T) {
|
|
||||||
m := newNftRefcountManager(t, true)
|
|
||||||
state := m.family4.ipFwdState
|
|
||||||
|
|
||||||
r1, err := m.AddDNATRule(dnatV6(9093))
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, v6 := state.Counts()
|
|
||||||
assert.Equal(t, 1, v6)
|
|
||||||
|
|
||||||
require.NoError(t, m.DeleteDNATRule(r1), "first delete")
|
|
||||||
_, v6 = state.Counts()
|
|
||||||
assert.Equal(t, 0, v6)
|
|
||||||
|
|
||||||
require.NoError(t, m.DeleteDNATRule(r1), "second delete must be no-op")
|
|
||||||
_, v6 = state.Counts()
|
|
||||||
assert.Equal(t, 0, v6, "double delete must not underflow")
|
|
||||||
}
|
|
||||||
@@ -24,7 +24,6 @@ const (
|
|||||||
tableRaw = "raw"
|
tableRaw = "raw"
|
||||||
tableSecurity = "security"
|
tableSecurity = "security"
|
||||||
|
|
||||||
chainNameNatPrerouting = "PREROUTING"
|
|
||||||
chainNameRoutingFw = "netbird-rt-fwd"
|
chainNameRoutingFw = "netbird-rt-fwd"
|
||||||
chainNameRoutingNat = "netbird-rt-postrouting"
|
chainNameRoutingNat = "netbird-rt-postrouting"
|
||||||
chainNameRoutingRdr = "netbird-rt-redirect"
|
chainNameRoutingRdr = "netbird-rt-redirect"
|
||||||
@@ -47,9 +46,6 @@ const (
|
|||||||
userDataAcceptForwardRuleOif = "frwacceptoif"
|
userDataAcceptForwardRuleOif = "frwacceptoif"
|
||||||
userDataAcceptInputRule = "inputaccept"
|
userDataAcceptInputRule = "inputaccept"
|
||||||
|
|
||||||
dnatSuffix firewall.RuleID = "_dnat"
|
|
||||||
snatSuffix firewall.RuleID = "_snat"
|
|
||||||
|
|
||||||
// ipv4TCPHeaderSize is the minimum IPv4 (20) + TCP (20) header size for MSS calculation.
|
// ipv4TCPHeaderSize is the minimum IPv4 (20) + TCP (20) header size for MSS calculation.
|
||||||
ipv4TCPHeaderSize = 40
|
ipv4TCPHeaderSize = 40
|
||||||
// ipv6TCPHeaderSize is the minimum IPv6 (40) + TCP (20) header size for MSS calculation.
|
// ipv6TCPHeaderSize is the minimum IPv6 (40) + TCP (20) header size for MSS calculation.
|
||||||
@@ -167,10 +163,6 @@ func (r *family) Reset() error {
|
|||||||
merr = multierror.Append(merr, err)
|
merr = multierror.Append(merr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.removeNatPreroutingRules(); err != nil {
|
|
||||||
merr = multierror.Append(merr, fmt.Errorf("remove filter prerouting rules: %w", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nberrors.FormatErrorOrNil(merr)
|
return nberrors.FormatErrorOrNil(merr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -197,11 +197,6 @@ func (r *family) hasRule(id firewall.RuleID) bool {
|
|||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *family) hasDNATRule(id firewall.RuleID) bool {
|
|
||||||
_, ok := r.rules[id+dnatSuffix]
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteFilterRule removes a previously installed filter rule. Source
|
// DeleteFilterRule removes a previously installed filter rule. Source
|
||||||
// set references are recovered from the stored rule's expressions via
|
// set references are recovered from the stored rule's expressions via
|
||||||
// findSets and dropped from the shared refcounter.
|
// findSets and dropped from the shared refcounter.
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ func (m *Manager) DeleteFilterRule(rule firewall.Rule) error {
|
|||||||
m.mutex.Lock()
|
m.mutex.Lock()
|
||||||
defer m.mutex.Unlock()
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
fam, err := m.familyForRuleID(rule.ID(), (*family).hasRule, false)
|
fam, err := m.familyForRuleID(rule.ID(), (*family).hasRule)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -260,11 +260,8 @@ func (m *Manager) DeleteFilterRule(rule firewall.Rule) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// familyForRuleID picks the family holding the rule with the given id, using
|
// familyForRuleID picks the family holding the rule with the given id, using
|
||||||
// the supplied lookup. With refresh set, a miss in both cached maps reloads
|
// the supplied lookup, and falls back to the v4 family on a miss.
|
||||||
// the NAT/DNAT rule maps from the kernel once and re-checks before falling
|
func (m *Manager) familyForRuleID(id firewall.RuleID, has func(*family, firewall.RuleID) bool) (*family, error) {
|
||||||
// back to the v4 family. Filter rules are tracked only in memory and have no
|
|
||||||
// kernel-backed reload, so their callers pass refresh as false.
|
|
||||||
func (m *Manager) familyForRuleID(id firewall.RuleID, has func(*family, firewall.RuleID) bool, refresh bool) (*family, error) {
|
|
||||||
if has(m.family4, id) {
|
if has(m.family4, id) {
|
||||||
return m.family4, nil
|
return m.family4, nil
|
||||||
}
|
}
|
||||||
@@ -274,18 +271,6 @@ func (m *Manager) familyForRuleID(id firewall.RuleID, has func(*family, firewall
|
|||||||
if has(m.family6, id) {
|
if has(m.family6, id) {
|
||||||
return m.family6, nil
|
return m.family6, nil
|
||||||
}
|
}
|
||||||
if !refresh {
|
|
||||||
return m.family4, nil
|
|
||||||
}
|
|
||||||
if err := m.family4.refreshRulesMap(); err != nil {
|
|
||||||
return nil, fmt.Errorf("refresh v4 rules: %w", err)
|
|
||||||
}
|
|
||||||
if err := m.family6.refreshRulesMap(); err != nil {
|
|
||||||
return nil, fmt.Errorf("refresh v6 rules: %w", err)
|
|
||||||
}
|
|
||||||
if has(m.family6, id) && !has(m.family4, id) {
|
|
||||||
return m.family6, nil
|
|
||||||
}
|
|
||||||
return m.family4, nil
|
return m.family4, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,32 +435,6 @@ func (m *Manager) Flush() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddDNATRule adds a DNAT rule
|
|
||||||
func (m *Manager) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) {
|
|
||||||
m.mutex.Lock()
|
|
||||||
defer m.mutex.Unlock()
|
|
||||||
|
|
||||||
if rule.TranslatedAddress.Is6() {
|
|
||||||
if !m.hasIPv6() {
|
|
||||||
return nil, fmt.Errorf("add DNAT rule: %w", firewall.ErrIPv6NotInitialized)
|
|
||||||
}
|
|
||||||
return m.family6.AddDNATRule(rule)
|
|
||||||
}
|
|
||||||
return m.family4.AddDNATRule(rule)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteDNATRule deletes a DNAT rule
|
|
||||||
func (m *Manager) DeleteDNATRule(rule firewall.Rule) error {
|
|
||||||
m.mutex.Lock()
|
|
||||||
defer m.mutex.Unlock()
|
|
||||||
|
|
||||||
r, err := m.familyForRuleID(rule.ID(), (*family).hasDNATRule, true)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return r.DeleteDNATRule(rule)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateSet updates the set with the given prefixes
|
// UpdateSet updates the set with the given prefixes
|
||||||
func (m *Manager) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error {
|
func (m *Manager) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error {
|
||||||
m.mutex.Lock()
|
m.mutex.Lock()
|
||||||
|
|||||||
@@ -378,18 +378,6 @@ func TestNftablesManagerCompatibilityWithIptables(t *testing.T) {
|
|||||||
err = manager.AddNatRule(pair)
|
err = manager.AddNatRule(pair)
|
||||||
require.NoError(t, err, "failed to add NAT rule")
|
require.NoError(t, err, "failed to add NAT rule")
|
||||||
|
|
||||||
dnatRule, err := manager.AddDNATRule(fw.ForwardRule{
|
|
||||||
Protocol: fw.ProtocolTCP,
|
|
||||||
DestinationPort: fw.Port{Values: []uint16{8080}},
|
|
||||||
TranslatedAddress: netip.MustParseAddr("100.96.0.2"),
|
|
||||||
TranslatedPort: fw.Port{Values: []uint16{80}},
|
|
||||||
})
|
|
||||||
require.NoError(t, err, "failed to add DNAT rule")
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
require.NoError(t, manager.DeleteDNATRule(dnatRule), "failed to delete DNAT rule")
|
|
||||||
})
|
|
||||||
|
|
||||||
stdout, stderr = runIptablesSave(t)
|
stdout, stderr = runIptablesSave(t)
|
||||||
verifyIptablesOutput(t, stdout, stderr)
|
verifyIptablesOutput(t, stdout, stderr)
|
||||||
}
|
}
|
||||||
@@ -453,18 +441,6 @@ func TestNftablesManagerIPv6CompatibilityWithIp6tables(t *testing.T) {
|
|||||||
})
|
})
|
||||||
require.NoError(t, err, "add v6 NAT rule")
|
require.NoError(t, err, "add v6 NAT rule")
|
||||||
|
|
||||||
dnatRule, err := manager.AddDNATRule(fw.ForwardRule{
|
|
||||||
Protocol: fw.ProtocolTCP,
|
|
||||||
DestinationPort: fw.Port{Values: []uint16{8080}},
|
|
||||||
TranslatedAddress: netip.MustParseAddr("fd00::2"),
|
|
||||||
TranslatedPort: fw.Port{Values: []uint16{80}},
|
|
||||||
})
|
|
||||||
require.NoError(t, err, "add v6 DNAT rule")
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
require.NoError(t, manager.DeleteDNATRule(dnatRule), "delete v6 DNAT rule")
|
|
||||||
})
|
|
||||||
|
|
||||||
stdout, stderr := runIptablesSave(t)
|
stdout, stderr := runIptablesSave(t)
|
||||||
verifyIptablesOutput(t, stdout, stderr)
|
verifyIptablesOutput(t, stdout, stderr)
|
||||||
|
|
||||||
|
|||||||
@@ -459,41 +459,6 @@ func (r *family) RemoveAllLegacyRouteRules() error {
|
|||||||
return nberrors.FormatErrorOrNil(merr)
|
return nberrors.FormatErrorOrNil(merr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *family) removeNatPreroutingRules() error {
|
|
||||||
table := &nftables.Table{
|
|
||||||
Name: tableNat,
|
|
||||||
Family: r.af.tableFamily,
|
|
||||||
}
|
|
||||||
chain := &nftables.Chain{
|
|
||||||
Name: chainNameNatPrerouting,
|
|
||||||
Table: table,
|
|
||||||
Hooknum: nftables.ChainHookPrerouting,
|
|
||||||
Priority: nftables.ChainPriorityNATDest,
|
|
||||||
Type: nftables.ChainTypeNAT,
|
|
||||||
}
|
|
||||||
rules, err := r.conn.GetRules(table, chain)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get rules from nat table: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var merr *multierror.Error
|
|
||||||
|
|
||||||
// Delete rules that have our UserData suffix
|
|
||||||
for _, rule := range rules {
|
|
||||||
if len(rule.UserData) == 0 || !strings.HasSuffix(string(rule.UserData), string(dnatSuffix)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := r.conn.DelRule(rule); err != nil {
|
|
||||||
merr = multierror.Append(merr, fmt.Errorf("delete rule %s: %w", rule.UserData, err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := r.conn.Flush(); err != nil {
|
|
||||||
merr = multierror.Append(merr, fmt.Errorf(flushError, err))
|
|
||||||
}
|
|
||||||
return nberrors.FormatErrorOrNil(merr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *family) RemoveNatRule(pair firewall.RouterPair) error {
|
func (r *family) RemoveNatRule(pair firewall.RouterPair) error {
|
||||||
if err := r.refreshRulesMap(); err != nil {
|
if err := r.refreshRulesMap(); err != nil {
|
||||||
return fmt.Errorf(refreshRulesMapError, err)
|
return fmt.Errorf(refreshRulesMapError, err)
|
||||||
|
|||||||
@@ -486,16 +486,6 @@ func incrementalUpdate(oldChecksum uint16, oldBytes, newBytes []byte) uint16 {
|
|||||||
return ^uint16(sum)
|
return ^uint16(sum)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddDNATRule adds outbound DNAT rule for forwarding external traffic to NetBird network.
|
|
||||||
func (m *Manager) AddDNATRule(firewall.ForwardRule) (firewall.Rule, error) {
|
|
||||||
return nil, errNotSupported
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteDNATRule deletes outbound DNAT rule.
|
|
||||||
func (m *Manager) DeleteDNATRule(firewall.Rule) error {
|
|
||||||
return errNotSupported
|
|
||||||
}
|
|
||||||
|
|
||||||
// addPortRedirection adds a port redirection rule.
|
// addPortRedirection adds a port redirection rule.
|
||||||
func (m *Manager) addPortRedirection(targetIP netip.Addr, protocol gopacket.LayerType, originalPort, translatedPort uint16) error {
|
func (m *Manager) addPortRedirection(targetIP netip.Addr, protocol gopacket.LayerType, originalPort, translatedPort uint16) error {
|
||||||
m.portDNATMutex.Lock()
|
m.portDNATMutex.Lock()
|
||||||
|
|||||||
@@ -6,12 +6,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"os/user"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
|
|
||||||
@@ -45,18 +42,13 @@ func WithSweeper(sweeper Sweeper) grpc.DialOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func dialContext(ctx context.Context, addr string) (net.Conn, error) {
|
func dialContext(ctx context.Context, addr string) (net.Conn, error) {
|
||||||
if runtime.GOOS == "linux" {
|
// The custom dialer requires root permissions which are not required for
|
||||||
currentUser, err := user.Current()
|
// use cases run as non-root. The effective UID is read directly: a passwd
|
||||||
if err != nil {
|
// lookup fails without cgo for a UID that has no entry, as on OpenShift.
|
||||||
return nil, status.Errorf(codes.FailedPrecondition, "failed to get current user: %v", err)
|
if runtime.GOOS == "linux" && os.Geteuid() != 0 {
|
||||||
}
|
log.Debug("Not running as root, using standard dialer")
|
||||||
|
dialer := &net.Dialer{}
|
||||||
// the custom dialer requires root permissions which are not required for use cases run as non-root
|
return dialer.DialContext(ctx, "tcp", addr)
|
||||||
if currentUser.Uid != "0" {
|
|
||||||
log.Debug("Not running as root, using standard dialer")
|
|
||||||
dialer := &net.Dialer{}
|
|
||||||
return dialer.DialContext(ctx, "tcp", addr)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
conn, err := nbnet.NewDialer().DialContext(ctx, "tcp", addr)
|
conn, err := nbnet.NewDialer().DialContext(ctx, "tcp", addr)
|
||||||
|
|||||||
@@ -2,17 +2,22 @@ package bind
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/pion/transport/v3"
|
||||||
"github.com/pion/transport/v3/stdnet"
|
"github.com/pion/transport/v3/stdnet"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.org/x/net/ipv4"
|
"golang.org/x/net/ipv4"
|
||||||
"golang.org/x/net/ipv6"
|
"golang.org/x/net/ipv6"
|
||||||
|
"golang.zx2c4.com/wireguard/device"
|
||||||
|
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||||
)
|
)
|
||||||
@@ -326,3 +331,86 @@ func listenUDP(t *testing.T, network, addr string) *net.UDPConn {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
return conn
|
return conn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type countingNet struct {
|
||||||
|
transport.Net
|
||||||
|
calls atomic.Int64
|
||||||
|
ifaces []*transport.Interface
|
||||||
|
before func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *countingNet) Interfaces() ([]*transport.Interface, error) {
|
||||||
|
n.calls.Add(1)
|
||||||
|
if n.before != nil {
|
||||||
|
n.before()
|
||||||
|
}
|
||||||
|
return n.ifaces, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestICEBind_OpenDoesNotDiscoverInterfaces(t *testing.T) {
|
||||||
|
n := newCountingNet()
|
||||||
|
iceBind := NewICEBind(n, testAddress(), 1280)
|
||||||
|
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
_, port, err := iceBind.Open(0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotZero(t, port, "the bind listens on a port")
|
||||||
|
assert.Equal(t, int64(0), n.calls.Load(), "opening the bind must not enumerate interfaces, attempt %d", i+1)
|
||||||
|
require.NoError(t, iceBind.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err := iceBind.Open(0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = iceBind.Close() })
|
||||||
|
|
||||||
|
mux, err := iceBind.GetICEMux()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, mux.SingleSocketUDPMux.GetListenAddresses(), "listen addresses come from the discovered interfaces")
|
||||||
|
assert.Equal(t, int64(1), n.calls.Load(), "interfaces are discovered when the listen addresses are asked for")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestICEBind_DeviceUpDoesNotWaitOnInterfaceDiscovery(t *testing.T) {
|
||||||
|
tunDev, _, err := netstack.CreateNetTUN([]netip.Addr{netip.MustParseAddr("100.64.0.1")}, nil, 1280)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var dev *device.Device
|
||||||
|
n := newCountingNet()
|
||||||
|
n.before = func() {
|
||||||
|
_ = dev.IpcGetOperation(io.Discard)
|
||||||
|
}
|
||||||
|
iceBind := NewICEBind(n, testAddress(), 1280)
|
||||||
|
dev = device.NewDevice(tunDev, iceBind, device.NewLogger(device.LogLevelSilent, ""))
|
||||||
|
|
||||||
|
upDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
upDone <- dev.Up()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-upDone:
|
||||||
|
require.NoError(t, err)
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("device Up did not return: interface discovery ran under the device lock and waited on the device itself")
|
||||||
|
}
|
||||||
|
t.Cleanup(dev.Close)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(0), n.calls.Load(), "bringing the device up must not enumerate interfaces")
|
||||||
|
|
||||||
|
mux, err := iceBind.GetICEMux()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, mux.SingleSocketUDPMux.GetListenAddresses(), "discovery that talks to the device succeeds once the device is up")
|
||||||
|
assert.Equal(t, int64(1), n.calls.Load(), "interfaces are discovered outside the device lock")
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCountingNet() *countingNet {
|
||||||
|
ifc := transport.NewInterface(net.Interface{Index: 1, MTU: 1500, Name: "eth0", Flags: net.FlagUp | net.FlagMulticast})
|
||||||
|
ifc.AddAddress(&net.IPNet{IP: net.IPv4(192, 168, 1, 10), Mask: net.CIDRMask(24, 32)})
|
||||||
|
return &countingNet{ifaces: []*transport.Interface{ifc}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAddress() wgaddr.Address {
|
||||||
|
return wgaddr.Address{
|
||||||
|
IP: netip.MustParseAddr("100.64.0.1"),
|
||||||
|
Network: netip.MustParsePrefix("100.64.0.0/10"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build (linux && !android) || freebsd
|
||||||
|
|
||||||
package configurer
|
package configurer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -165,33 +167,6 @@ func (s *allowedIPStore) releaseLocked(k wgtypes.Key) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizePrefix puts a prefix into the form the store recognises it by. It clears the
|
|
||||||
// host bits, which a device does on its own, so a caller passing 10.20.0.1/16 still matches
|
|
||||||
// the 10.20.0.0/16 read back from the device; and it unmaps a v4-mapped prefix so that it
|
|
||||||
// compares equal to, and marshals like, the plain v4 prefix for the same network.
|
|
||||||
//
|
|
||||||
// Masking comes first because it also decides the address family: only a prefix at least 96
|
|
||||||
// bits long keeps the mapped marker through the mask, so a shorter prefix inside the mapped
|
|
||||||
// range is a genuine v6 prefix and unmapping it would yield an invalid v4 prefix.
|
|
||||||
func normalizePrefix(prefix netip.Prefix) netip.Prefix {
|
|
||||||
masked := prefix.Masked()
|
|
||||||
|
|
||||||
addr := masked.Addr()
|
|
||||||
if !addr.Is4In6() {
|
|
||||||
return masked
|
|
||||||
}
|
|
||||||
return netip.PrefixFrom(addr.Unmap(), masked.Bits()-96)
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalizePrefixes returns a normalized copy without changing the caller's slice.
|
|
||||||
func normalizePrefixes(prefixes []netip.Prefix) []netip.Prefix {
|
|
||||||
normalized := make([]netip.Prefix, len(prefixes))
|
|
||||||
for i, prefix := range prefixes {
|
|
||||||
normalized[i] = normalizePrefix(prefix)
|
|
||||||
}
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
// ipNetsToPrefixes converts addresses read back from a device. Unmap keeps a v4-mapped v6
|
// ipNetsToPrefixes converts addresses read back from a device. Unmap keeps a v4-mapped v6
|
||||||
// address comparable to the plain v4 prefix the configurer was given.
|
// address comparable to the plain v4 prefix the configurer was given.
|
||||||
func ipNetsToPrefixes(ipNets []net.IPNet) []netip.Prefix {
|
func ipNetsToPrefixes(ipNets []net.IPNet) []netip.Prefix {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build (linux && !android) || freebsd
|
||||||
|
|
||||||
package configurer
|
package configurer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -125,24 +127,6 @@ func TestAllowedIPStoreNormalizesMappedPrefixes(t *testing.T) {
|
|||||||
assert.Equal(t, []netip.Prefix{v4}, prefixes, "a mapped prefix must be stored unmapped and not duplicated")
|
assert.Equal(t, []netip.Prefix{v4}, prefixes, "a mapped prefix must be stored unmapped and not duplicated")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizePrefix(t *testing.T) {
|
|
||||||
v4 := netip.MustParsePrefix("10.20.0.0/16")
|
|
||||||
v6 := netip.MustParsePrefix("fd00::/64")
|
|
||||||
|
|
||||||
assert.Equal(t, v4, normalizePrefix(v4), "a plain v4 prefix is unchanged")
|
|
||||||
assert.Equal(t, v6, normalizePrefix(v6), "a real v6 prefix is unchanged")
|
|
||||||
assert.Equal(t, v4, normalizePrefix(netip.PrefixFrom(netip.AddrFrom16(v4.Addr().As16()), 112)),
|
|
||||||
"a mapped prefix under a 128 bit mask becomes plain v4")
|
|
||||||
// A prefix shorter than /96 inside the mapped range is a genuine v6 prefix. Unmapping it
|
|
||||||
// would pair a v4 address with a v6 sized mask, which is invalid, and the store would then
|
|
||||||
// record a zero prefix that can never recreate the allowed IP.
|
|
||||||
for _, tc := range []string{"::ffff:0:0/64", "::ffff:1.2.3.4/80", "::ffff:1.2.3.4/95"} {
|
|
||||||
got := normalizePrefix(netip.MustParsePrefix(tc))
|
|
||||||
assert.True(t, got.IsValid(), "%s must normalize to a valid prefix", tc)
|
|
||||||
assert.False(t, got.Addr().Is4(), "%s must stay v6", tc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAllowedIPStoreAddExistingDoesNotCreate(t *testing.T) {
|
func TestAllowedIPStoreAddExistingDoesNotCreate(t *testing.T) {
|
||||||
s := newAllowedIPStore()
|
s := newAllowedIPStore()
|
||||||
routed := netip.MustParsePrefix("10.20.0.0/16")
|
routed := netip.MustParsePrefix("10.20.0.0/16")
|
||||||
@@ -194,15 +178,6 @@ func TestAllowedIPStoreForgetReleasesOwnership(t *testing.T) {
|
|||||||
assert.Equal(t, []netip.Prefix{routed}, current, "the new owner must hold the prefix")
|
assert.Equal(t, []netip.Prefix{routed}, current, "the new owner must hold the prefix")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizePrefixClearsHostBits(t *testing.T) {
|
|
||||||
// A device stores a prefix masked, so a caller passing host bits must still match what a
|
|
||||||
// device fallback seeded, otherwise that prefix could never be removed by value.
|
|
||||||
assert.Equal(t, netip.MustParsePrefix("10.20.0.0/16"),
|
|
||||||
normalizePrefix(netip.MustParsePrefix("10.20.0.1/16")), "host bits must be cleared")
|
|
||||||
assert.Equal(t, netip.MustParsePrefix("fd00::/64"),
|
|
||||||
normalizePrefix(netip.MustParsePrefix("fd00::1/64")), "host bits must be cleared for v6")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIPNetsToPrefixesKeepsV6InTheMappedRange(t *testing.T) {
|
func TestIPNetsToPrefixesKeepsV6InTheMappedRange(t *testing.T) {
|
||||||
// ::ffff:0:0/64 reads as v4-mapped but is a genuine v6 prefix: unmapping it would leave a
|
// ::ffff:0:0/64 reads as v4-mapped but is a genuine v6 prefix: unmapping it would leave a
|
||||||
// v4 address under a 64 bit mask, which is invalid, and the allowed IP would be dropped.
|
// v4 address under a 64 bit mask, which is invalid, and the allowed IP would be dropped.
|
||||||
@@ -215,49 +190,3 @@ func TestIPNetsToPrefixesKeepsV6InTheMappedRange(t *testing.T) {
|
|||||||
assert.False(t, got[0].Addr().Is4(), "a v6 prefix in the mapped range must not become v4")
|
assert.False(t, got[0].Addr().Is4(), "a v6 prefix in the mapped range must not become v4")
|
||||||
assert.Equal(t, 64, got[0].Bits(), "the prefix length must survive the conversion")
|
assert.Equal(t, 64, got[0].Bits(), "the prefix length must survive the conversion")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPrefixesToIPNetsNormalizes(t *testing.T) {
|
|
||||||
// net.IPNet prints a v4-mapped address as v4 but takes the length from its 16 byte
|
|
||||||
// mask, so an unnormalized ::ffff:10.1.2.3/64 reaches a userspace device as 10.1.2.3/0,
|
|
||||||
// an allowed IP that matches every v4 address.
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
given string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{name: "mapped below /96", given: "::ffff:10.1.2.3/64", want: "::/64"},
|
|
||||||
{name: "mapped at /112", given: "::ffff:10.1.2.3/112", want: "10.1.0.0/16"},
|
|
||||||
{name: "host bits are cleared", given: "10.20.0.1/16", want: "10.20.0.0/16"},
|
|
||||||
{name: "v6 is untouched", given: "fd00::1/64", want: "fd00::/64"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
got := prefixesToIPNets([]netip.Prefix{netip.MustParsePrefix(tc.given)})
|
|
||||||
require.Len(t, got, 1, "the prefix must be converted, not dropped")
|
|
||||||
assert.Equal(t, tc.want, got[0].String(), "what the device is given")
|
|
||||||
assert.NotEqual(t, 0, mustOnes(t, got[0]), "a device must never be given a zero length allowed IP")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustOnes(t *testing.T, ipNet net.IPNet) int {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
ones, _ := ipNet.Mask.Size()
|
|
||||||
return ones
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPrefixesToIPNetsAgreesWithTheStore pins the property the store depends on: what a
|
|
||||||
// device is given and what is recorded for it are the same prefix.
|
|
||||||
func TestPrefixesToIPNetsAgreesWithTheStore(t *testing.T) {
|
|
||||||
for _, given := range []string{"::ffff:10.1.2.3/64", "::ffff:10.1.2.3/112", "10.20.0.1/16", "fd00::1/64"} {
|
|
||||||
prefix := netip.MustParsePrefix(given)
|
|
||||||
|
|
||||||
toDevice := prefixesToIPNets([]netip.Prefix{prefix})
|
|
||||||
recorded := normalizePrefix(prefix)
|
|
||||||
|
|
||||||
assert.Equal(t, recorded.String(), toDevice[0].String(),
|
|
||||||
"%s must reach the device in the form the store records", given)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,22 +3,8 @@ package configurer
|
|||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
|
||||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// buildPresharedKeyConfig creates a wgtypes.Config for setting a preshared key on a peer.
|
|
||||||
// This is a shared helper used by both kernel and userspace configurers.
|
|
||||||
func buildPresharedKeyConfig(peerKey wgtypes.Key, psk wgtypes.Key, updateOnly bool) wgtypes.Config {
|
|
||||||
return wgtypes.Config{
|
|
||||||
Peers: []wgtypes.PeerConfig{{
|
|
||||||
PublicKey: peerKey,
|
|
||||||
PresharedKey: &psk,
|
|
||||||
UpdateOnly: updateOnly,
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// prefixesToIPNets converts prefixes on their way to a device. It is the only place that
|
// prefixesToIPNets converts prefixes on their way to a device. It is the only place that
|
||||||
// conversion happens, so it also normalizes: the device is then given the same form the
|
// conversion happens, so it also normalizes: the device is then given the same form the
|
||||||
// store records, and a v4-mapped prefix cannot reach net.IPNet, which prints such an
|
// store records, and a v4-mapped prefix cannot reach net.IPNet, which prints such an
|
||||||
@@ -35,3 +21,30 @@ func prefixesToIPNets(prefixes []netip.Prefix) []net.IPNet {
|
|||||||
}
|
}
|
||||||
return ipNets
|
return ipNets
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizePrefix puts a prefix into the form the store recognises it by. It clears the
|
||||||
|
// host bits, which a device does on its own, so a caller passing 10.20.0.1/16 still matches
|
||||||
|
// the 10.20.0.0/16 read back from the device; and it unmaps a v4-mapped prefix so that it
|
||||||
|
// compares equal to, and marshals like, the plain v4 prefix for the same network.
|
||||||
|
//
|
||||||
|
// Masking comes first because it also decides the address family: only a prefix at least 96
|
||||||
|
// bits long keeps the mapped marker through the mask, so a shorter prefix inside the mapped
|
||||||
|
// range is a genuine v6 prefix and unmapping it would yield an invalid v4 prefix.
|
||||||
|
func normalizePrefix(prefix netip.Prefix) netip.Prefix {
|
||||||
|
masked := prefix.Masked()
|
||||||
|
|
||||||
|
addr := masked.Addr()
|
||||||
|
if !addr.Is4In6() {
|
||||||
|
return masked
|
||||||
|
}
|
||||||
|
return netip.PrefixFrom(addr.Unmap(), masked.Bits()-96)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizePrefixes returns a normalized copy without changing the caller's slice.
|
||||||
|
func normalizePrefixes(prefixes []netip.Prefix) []netip.Prefix {
|
||||||
|
normalized := make([]netip.Prefix, len(prefixes))
|
||||||
|
for i, prefix := range prefixes {
|
||||||
|
normalized[i] = normalizePrefix(prefix)
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package configurer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizePrefix(t *testing.T) {
|
||||||
|
v4 := netip.MustParsePrefix("10.20.0.0/16")
|
||||||
|
v6 := netip.MustParsePrefix("fd00::/64")
|
||||||
|
|
||||||
|
assert.Equal(t, v4, normalizePrefix(v4), "a plain v4 prefix is unchanged")
|
||||||
|
assert.Equal(t, v6, normalizePrefix(v6), "a real v6 prefix is unchanged")
|
||||||
|
assert.Equal(t, v4, normalizePrefix(netip.PrefixFrom(netip.AddrFrom16(v4.Addr().As16()), 112)),
|
||||||
|
"a mapped prefix under a 128 bit mask becomes plain v4")
|
||||||
|
// A prefix shorter than /96 inside the mapped range is a genuine v6 prefix. Unmapping it
|
||||||
|
// would pair a v4 address with a v6 sized mask, which is invalid, and the store would then
|
||||||
|
// record a zero prefix that can never recreate the allowed IP.
|
||||||
|
for _, tc := range []string{"::ffff:0:0/64", "::ffff:1.2.3.4/80", "::ffff:1.2.3.4/95"} {
|
||||||
|
got := normalizePrefix(netip.MustParsePrefix(tc))
|
||||||
|
assert.True(t, got.IsValid(), "%s must normalize to a valid prefix", tc)
|
||||||
|
assert.False(t, got.Addr().Is4(), "%s must stay v6", tc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizePrefixClearsHostBits(t *testing.T) {
|
||||||
|
// A device stores a prefix masked, so a caller passing host bits must still match what a
|
||||||
|
// device fallback seeded, otherwise that prefix could never be removed by value.
|
||||||
|
assert.Equal(t, netip.MustParsePrefix("10.20.0.0/16"),
|
||||||
|
normalizePrefix(netip.MustParsePrefix("10.20.0.1/16")), "host bits must be cleared")
|
||||||
|
assert.Equal(t, netip.MustParsePrefix("fd00::/64"),
|
||||||
|
normalizePrefix(netip.MustParsePrefix("fd00::1/64")), "host bits must be cleared for v6")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrefixesToIPNetsNormalizes(t *testing.T) {
|
||||||
|
// net.IPNet prints a v4-mapped address as v4 but takes the length from its 16 byte
|
||||||
|
// mask, so an unnormalized ::ffff:10.1.2.3/64 reaches a userspace device as 10.1.2.3/0,
|
||||||
|
// an allowed IP that matches every v4 address.
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
given string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "mapped below /96", given: "::ffff:10.1.2.3/64", want: "::/64"},
|
||||||
|
{name: "mapped at /112", given: "::ffff:10.1.2.3/112", want: "10.1.0.0/16"},
|
||||||
|
{name: "host bits are cleared", given: "10.20.0.1/16", want: "10.20.0.0/16"},
|
||||||
|
{name: "v6 is untouched", given: "fd00::1/64", want: "fd00::/64"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := prefixesToIPNets([]netip.Prefix{netip.MustParsePrefix(tc.given)})
|
||||||
|
require.Len(t, got, 1, "the prefix must be converted, not dropped")
|
||||||
|
assert.Equal(t, tc.want, got[0].String(), "what the device is given")
|
||||||
|
assert.NotEqual(t, 0, mustOnes(t, got[0]), "a device must never be given a zero length allowed IP")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrefixesToIPNetsAgreesWithTheStore(t *testing.T) {
|
||||||
|
for _, given := range []string{"::ffff:10.1.2.3/64", "::ffff:10.1.2.3/112", "10.20.0.1/16", "fd00::1/64"} {
|
||||||
|
prefix := netip.MustParsePrefix(given)
|
||||||
|
|
||||||
|
toDevice := prefixesToIPNets([]netip.Prefix{prefix})
|
||||||
|
recorded := normalizePrefix(prefix)
|
||||||
|
|
||||||
|
assert.Equal(t, recorded.String(), toDevice[0].String(),
|
||||||
|
"%s must reach the device in the form the store records", given)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustOnes(t *testing.T, ipNet net.IPNet) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ones, _ := ipNet.Mask.Size()
|
||||||
|
return ones
|
||||||
|
}
|
||||||
@@ -3,10 +3,12 @@
|
|||||||
package configurer
|
package configurer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
@@ -16,18 +18,38 @@ import (
|
|||||||
"github.com/netbirdio/netbird/monotime"
|
"github.com/netbirdio/netbird/monotime"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var errConfigurerClosed = errors.New("configurer closed")
|
||||||
|
|
||||||
|
type wgClient interface {
|
||||||
|
Device(name string) (*wgtypes.Device, error)
|
||||||
|
ConfigureDevice(name string, cfg wgtypes.Config) error
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
type KernelConfigurer struct {
|
type KernelConfigurer struct {
|
||||||
deviceName string
|
deviceName string
|
||||||
statsCache *statsCache
|
statsCache *statsCache
|
||||||
allowedIPs *allowedIPStore
|
allowedIPs *allowedIPStore
|
||||||
|
newWgctrlFn func() (wgClient, error)
|
||||||
|
|
||||||
|
clientMu sync.Mutex
|
||||||
|
client wgClient
|
||||||
|
closed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewKernelConfigurer creates a configurer with an empty allowed IP mirror
|
// NewKernelConfigurer creates a configurer with an empty allowed IP mirror
|
||||||
// and a statistics cache for the named kernel device.
|
// and a statistics cache for the named kernel device.
|
||||||
func NewKernelConfigurer(deviceName string) *KernelConfigurer {
|
func NewKernelConfigurer(deviceName string) *KernelConfigurer {
|
||||||
|
return newKernelConfigurer(deviceName, func() (wgClient, error) {
|
||||||
|
return wgctrl.New()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newKernelConfigurer(deviceName string, newWgctrlFn func() (wgClient, error)) *KernelConfigurer {
|
||||||
c := &KernelConfigurer{
|
c := &KernelConfigurer{
|
||||||
deviceName: deviceName,
|
deviceName: deviceName,
|
||||||
allowedIPs: newAllowedIPStore(),
|
allowedIPs: newAllowedIPStore(),
|
||||||
|
newWgctrlFn: newWgctrlFn,
|
||||||
}
|
}
|
||||||
c.statsCache = newStatsCache(statsCacheTTL, c.fetchStats)
|
c.statsCache = newStatsCache(statsCacheTTL, c.fetchStats)
|
||||||
return c
|
return c
|
||||||
@@ -242,7 +264,7 @@ func (c *KernelConfigurer) peerAllowedIPs(peerKey wgtypes.Key) ([]netip.Prefix,
|
|||||||
return prefixes, nil
|
return prefixes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
existingPeer, err := c.getPeer(c.deviceName, peerKey)
|
existingPeer, err := c.getPeer(peerKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get peer: %w", err)
|
return nil, fmt.Errorf("get peer: %w", err)
|
||||||
}
|
}
|
||||||
@@ -254,21 +276,10 @@ func (c *KernelConfigurer) peerAllowedIPs(peerKey wgtypes.Key) ([]netip.Prefix,
|
|||||||
|
|
||||||
// getPeer scans the device for one peer. wgtypes.Key is an array, so the comparison is a
|
// getPeer scans the device for one peer. wgtypes.Key is an array, so the comparison is a
|
||||||
// plain equality: Key.String would base64 encode into a fresh allocation for every peer.
|
// plain equality: Key.String would base64 encode into a fresh allocation for every peer.
|
||||||
func (c *KernelConfigurer) getPeer(ifaceName string, peerPubKey wgtypes.Key) (wgtypes.Peer, error) {
|
func (c *KernelConfigurer) getPeer(peerPubKey wgtypes.Key) (wgtypes.Peer, error) {
|
||||||
wg, err := wgctrl.New()
|
wgDevice, err := c.device()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return wgtypes.Peer{}, fmt.Errorf("wgctl: %w", err)
|
return wgtypes.Peer{}, err
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
err = wg.Close()
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("Got error while closing wgctl: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
wgDevice, err := wg.Device(ifaceName)
|
|
||||||
if err != nil {
|
|
||||||
return wgtypes.Peer{}, fmt.Errorf("get device %s: %w", ifaceName, err)
|
|
||||||
}
|
}
|
||||||
for _, peer := range wgDevice.Peers {
|
for _, peer := range wgDevice.Peers {
|
||||||
if peer.PublicKey == peerPubKey {
|
if peer.PublicKey == peerPubKey {
|
||||||
@@ -279,37 +290,73 @@ func (c *KernelConfigurer) getPeer(ifaceName string, peerPubKey wgtypes.Key) (wg
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *KernelConfigurer) configure(config wgtypes.Config) error {
|
func (c *KernelConfigurer) configure(config wgtypes.Config) error {
|
||||||
wg, err := wgctrl.New()
|
c.clientMu.Lock()
|
||||||
|
defer c.clientMu.Unlock()
|
||||||
|
|
||||||
|
client, err := c.clientLocked()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer func() {
|
if err := client.ConfigureDevice(c.deviceName, config); err != nil {
|
||||||
if err := wg.Close(); err != nil {
|
c.discardLocked()
|
||||||
log.Errorf("Failed to close wgctrl client: %v", err)
|
return err
|
||||||
}
|
}
|
||||||
}()
|
return nil
|
||||||
|
|
||||||
return wg.ConfigureDevice(c.deviceName, config)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *KernelConfigurer) Close() {
|
func (c *KernelConfigurer) device() (*wgtypes.Device, error) {
|
||||||
|
c.clientMu.Lock()
|
||||||
|
defer c.clientMu.Unlock()
|
||||||
|
|
||||||
|
client, err := c.clientLocked()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
wgDevice, err := client.Device(c.deviceName)
|
||||||
|
if err != nil {
|
||||||
|
c.discardLocked()
|
||||||
|
return nil, fmt.Errorf("get device %s: %w", c.deviceName, err)
|
||||||
|
}
|
||||||
|
return wgDevice, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *KernelConfigurer) FullStats() (*Stats, error) {
|
func (c *KernelConfigurer) clientLocked() (wgClient, error) {
|
||||||
wg, err := wgctrl.New()
|
if c.closed {
|
||||||
|
return nil, errConfigurerClosed
|
||||||
|
}
|
||||||
|
if c.client != nil {
|
||||||
|
return c.client, nil
|
||||||
|
}
|
||||||
|
client, err := c.newWgctrlFn()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("wgctl: %w", err)
|
return nil, fmt.Errorf("wgctl: %w", err)
|
||||||
}
|
}
|
||||||
defer func() {
|
c.client = client
|
||||||
err = wg.Close()
|
return client, nil
|
||||||
if err != nil {
|
}
|
||||||
log.Errorf("Got error while closing wgctl: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
wgDevice, err := wg.Device(c.deviceName)
|
func (c *KernelConfigurer) discardLocked() {
|
||||||
|
if c.client == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := c.client.Close(); err != nil {
|
||||||
|
log.Warnf("failed to close wgctrl client: %v", err)
|
||||||
|
}
|
||||||
|
c.client = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *KernelConfigurer) Close() {
|
||||||
|
c.clientMu.Lock()
|
||||||
|
defer c.clientMu.Unlock()
|
||||||
|
|
||||||
|
c.closed = true
|
||||||
|
c.discardLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *KernelConfigurer) FullStats() (*Stats, error) {
|
||||||
|
wgDevice, err := c.device()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get device %s: %w", c.deviceName, err)
|
return nil, err
|
||||||
}
|
}
|
||||||
fullStats := &Stats{
|
fullStats := &Stats{
|
||||||
DeviceName: wgDevice.Name,
|
DeviceName: wgDevice.Name,
|
||||||
@@ -345,23 +392,12 @@ func (c *KernelConfigurer) LastActivities() map[string]monotime.Time {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *KernelConfigurer) fetchStats() (map[string]WGStats, error) {
|
func (c *KernelConfigurer) fetchStats() (map[string]WGStats, error) {
|
||||||
|
wgDevice, err := c.device()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
stats := make(map[string]WGStats)
|
stats := make(map[string]WGStats)
|
||||||
wg, err := wgctrl.New()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("wgctl: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
err = wg.Close()
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("Got error while closing wgctl: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
wgDevice, err := wg.Device(c.deviceName)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("get device %s: %w", c.deviceName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, peer := range wgDevice.Peers {
|
for _, peer := range wgDevice.Peers {
|
||||||
stats[peer.PublicKey.String()] = WGStats{
|
stats[peer.PublicKey.String()] = WGStats{
|
||||||
LastHandshake: peer.LastHandshakeTime,
|
LastHandshake: peer.LastHandshakeTime,
|
||||||
@@ -371,3 +407,13 @@ func (c *KernelConfigurer) fetchStats() (map[string]WGStats, error) {
|
|||||||
}
|
}
|
||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildPresharedKeyConfig(peerKey wgtypes.Key, psk wgtypes.Key, updateOnly bool) wgtypes.Config {
|
||||||
|
return wgtypes.Config{
|
||||||
|
Peers: []wgtypes.PeerConfig{{
|
||||||
|
PublicKey: peerKey,
|
||||||
|
PresharedKey: &psk,
|
||||||
|
UpdateOnly: updateOnly,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
//go:build (linux && !android) || freebsd
|
||||||
|
|
||||||
|
package configurer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeWGClient struct {
|
||||||
|
configureErr error
|
||||||
|
deviceErr error
|
||||||
|
calls int
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeWGClientFactory struct {
|
||||||
|
openErr error
|
||||||
|
clients []*fakeWGClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeWGClient) Device(string) (*wgtypes.Device, error) {
|
||||||
|
f.calls++
|
||||||
|
if f.deviceErr != nil {
|
||||||
|
return nil, f.deviceErr
|
||||||
|
}
|
||||||
|
return &wgtypes.Device{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeWGClient) ConfigureDevice(string, wgtypes.Config) error {
|
||||||
|
f.calls++
|
||||||
|
return f.configureErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeWGClient) Close() error {
|
||||||
|
f.closed = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeWGClientFactory) open() (wgClient, error) {
|
||||||
|
if f.openErr != nil {
|
||||||
|
return nil, f.openErr
|
||||||
|
}
|
||||||
|
client := &fakeWGClient{}
|
||||||
|
f.clients = append(f.clients, client)
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKernelConfigurer_ReusesClientAcrossRequests(t *testing.T) {
|
||||||
|
c, factory := newFakeKernelConfigurer()
|
||||||
|
peerKey := testPeerKey(t)
|
||||||
|
|
||||||
|
require.NoError(t, c.RemovePeer(peerKey))
|
||||||
|
require.NoError(t, c.RemovePeer(peerKey))
|
||||||
|
_, err := c.FullStats()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Len(t, factory.clients, 1, "all requests should share one client")
|
||||||
|
assert.Equal(t, 3, factory.clients[0].calls, "every request should go through the shared client")
|
||||||
|
assert.False(t, factory.clients[0].closed, "a healthy client should stay open")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKernelConfigurer_ReopensClientAfterFailedConfigure(t *testing.T) {
|
||||||
|
c, factory := newFakeKernelConfigurer()
|
||||||
|
peerKey := testPeerKey(t)
|
||||||
|
|
||||||
|
require.NoError(t, c.RemovePeer(peerKey))
|
||||||
|
require.Len(t, factory.clients, 1)
|
||||||
|
|
||||||
|
factory.clients[0].configureErr = errors.New("netlink down")
|
||||||
|
require.Error(t, c.RemovePeer(peerKey))
|
||||||
|
assert.True(t, factory.clients[0].closed, "the failed client should be closed")
|
||||||
|
assert.Len(t, factory.clients, 1, "a failure must not open a client on its own")
|
||||||
|
|
||||||
|
require.NoError(t, c.RemovePeer(peerKey))
|
||||||
|
require.Len(t, factory.clients, 2, "the next request should open a fresh client")
|
||||||
|
assert.Equal(t, 1, factory.clients[1].calls, "the request should run on the fresh client")
|
||||||
|
assert.False(t, factory.clients[1].closed, "the fresh client should stay open")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKernelConfigurer_ReopensClientAfterFailedDeviceQuery(t *testing.T) {
|
||||||
|
c, factory := newFakeKernelConfigurer()
|
||||||
|
|
||||||
|
_, err := c.FullStats()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, factory.clients, 1)
|
||||||
|
|
||||||
|
factory.clients[0].deviceErr = errors.New("netlink down")
|
||||||
|
_, err = c.FullStats()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.True(t, factory.clients[0].closed, "the failed client should be closed")
|
||||||
|
|
||||||
|
_, err = c.FullStats()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, factory.clients, 2, "the next request should open a fresh client")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKernelConfigurer_RetriesOpenOnNextRequest(t *testing.T) {
|
||||||
|
c, factory := newFakeKernelConfigurer()
|
||||||
|
peerKey := testPeerKey(t)
|
||||||
|
|
||||||
|
factory.openErr = errors.New("no netlink")
|
||||||
|
require.Error(t, c.RemovePeer(peerKey))
|
||||||
|
assert.Empty(t, factory.clients, "a failed open leaves no client behind")
|
||||||
|
|
||||||
|
factory.openErr = nil
|
||||||
|
require.NoError(t, c.RemovePeer(peerKey))
|
||||||
|
assert.Len(t, factory.clients, 1, "the next request should open the client")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKernelConfigurer_CloseRejectsFurtherRequests(t *testing.T) {
|
||||||
|
c, factory := newFakeKernelConfigurer()
|
||||||
|
peerKey := testPeerKey(t)
|
||||||
|
|
||||||
|
require.NoError(t, c.RemovePeer(peerKey))
|
||||||
|
require.Len(t, factory.clients, 1)
|
||||||
|
|
||||||
|
c.Close()
|
||||||
|
c.Close()
|
||||||
|
assert.True(t, factory.clients[0].closed, "Close should release the client")
|
||||||
|
|
||||||
|
require.ErrorIs(t, c.RemovePeer(peerKey), errConfigurerClosed)
|
||||||
|
_, err := c.FullStats()
|
||||||
|
require.ErrorIs(t, err, errConfigurerClosed)
|
||||||
|
assert.Len(t, factory.clients, 1, "a closed configurer must not open a client")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKernelConfigurer_ConcurrentRequestsShareOneClient(t *testing.T) {
|
||||||
|
c, factory := newFakeKernelConfigurer()
|
||||||
|
peerKey := testPeerKey(t)
|
||||||
|
|
||||||
|
const workers = 16
|
||||||
|
errs := make(chan error, workers)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for range workers {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
errs <- c.RemovePeer(peerKey)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(errs)
|
||||||
|
for err := range errs {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Len(t, factory.clients, 1, "concurrent requests should share one client")
|
||||||
|
assert.Equal(t, workers, factory.clients[0].calls, "every request should reach the shared client")
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeKernelConfigurer() (*KernelConfigurer, *fakeWGClientFactory) {
|
||||||
|
factory := &fakeWGClientFactory{}
|
||||||
|
return newKernelConfigurer("wt0", factory.open), factory
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPeerKey(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
key, err := wgtypes.GeneratePrivateKey()
|
||||||
|
require.NoError(t, err)
|
||||||
|
return key.PublicKey().String()
|
||||||
|
}
|
||||||
+137
-478
@@ -1,19 +1,16 @@
|
|||||||
package configurer
|
package configurer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
wgconn "golang.zx2c4.com/wireguard/conn"
|
||||||
"golang.zx2c4.com/wireguard/device"
|
"golang.zx2c4.com/wireguard/device"
|
||||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
|
|
||||||
@@ -22,19 +19,6 @@ import (
|
|||||||
"github.com/netbirdio/netbird/monotime"
|
"github.com/netbirdio/netbird/monotime"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
privateKey = "private_key"
|
|
||||||
ipcKeyLastHandshakeTimeSec = "last_handshake_time_sec"
|
|
||||||
ipcKeyTxBytes = "tx_bytes"
|
|
||||||
ipcKeyRxBytes = "rx_bytes"
|
|
||||||
allowedIP = "allowed_ip"
|
|
||||||
endpoint = "endpoint"
|
|
||||||
fwmark = "fwmark"
|
|
||||||
listenPort = "listen_port"
|
|
||||||
publicKey = "public_key"
|
|
||||||
presharedKey = "preshared_key"
|
|
||||||
)
|
|
||||||
|
|
||||||
var ErrAllowedIPNotFound = fmt.Errorf("allowed IP not found")
|
var ErrAllowedIPNotFound = fmt.Errorf("allowed IP not found")
|
||||||
|
|
||||||
type WGUSPConfigurer struct {
|
type WGUSPConfigurer struct {
|
||||||
@@ -42,20 +26,13 @@ type WGUSPConfigurer struct {
|
|||||||
deviceName string
|
deviceName string
|
||||||
activityRecorder *bind.ActivityRecorder
|
activityRecorder *bind.ActivityRecorder
|
||||||
statsCache *statsCache
|
statsCache *statsCache
|
||||||
allowedIPs *allowedIPStore
|
|
||||||
|
|
||||||
uapiListener net.Listener
|
uapiListener net.Listener
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUSPConfigurer creates a userspace configurer and starts its UAPI listener.
|
// NewUSPConfigurer creates a userspace configurer and starts its UAPI listener.
|
||||||
func NewUSPConfigurer(device *device.Device, deviceName string, activityRecorder *bind.ActivityRecorder) *WGUSPConfigurer {
|
func NewUSPConfigurer(device *device.Device, deviceName string, activityRecorder *bind.ActivityRecorder) *WGUSPConfigurer {
|
||||||
wgCfg := &WGUSPConfigurer{
|
wgCfg := NewUSPConfigurerNoUAPI(device, deviceName, activityRecorder)
|
||||||
device: device,
|
|
||||||
deviceName: deviceName,
|
|
||||||
activityRecorder: activityRecorder,
|
|
||||||
allowedIPs: newAllowedIPStore(),
|
|
||||||
}
|
|
||||||
wgCfg.statsCache = newStatsCache(statsCacheTTL, wgCfg.fetchStats)
|
|
||||||
wgCfg.startUAPI()
|
wgCfg.startUAPI()
|
||||||
return wgCfg
|
return wgCfg
|
||||||
}
|
}
|
||||||
@@ -66,33 +43,32 @@ func NewUSPConfigurerNoUAPI(device *device.Device, deviceName string, activityRe
|
|||||||
device: device,
|
device: device,
|
||||||
deviceName: deviceName,
|
deviceName: deviceName,
|
||||||
activityRecorder: activityRecorder,
|
activityRecorder: activityRecorder,
|
||||||
allowedIPs: newAllowedIPStore(),
|
|
||||||
}
|
}
|
||||||
wgCfg.statsCache = newStatsCache(statsCacheTTL, wgCfg.fetchStats)
|
wgCfg.statsCache = newStatsCache(statsCacheTTL, wgCfg.fetchStats)
|
||||||
return wgCfg
|
return wgCfg
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfigureInterface sets the device key, port and firewall mark, replacing all peers.
|
// ConfigureInterface sets the device key, port and firewall mark, replacing all peers.
|
||||||
// The allowed IP mirror is reset only after the device accepts the configuration.
|
|
||||||
func (c *WGUSPConfigurer) ConfigureInterface(privateKey string, port int) error {
|
func (c *WGUSPConfigurer) ConfigureInterface(privateKey string, port int) error {
|
||||||
log.Debugf("adding Wireguard private key")
|
log.Debugf("adding Wireguard private key")
|
||||||
key, err := wgtypes.ParseKey(privateKey)
|
key, err := wgtypes.ParseKey(privateKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fwmark := getFwmark()
|
if port < 0 || port > math.MaxUint16 {
|
||||||
config := wgtypes.Config{
|
return fmt.Errorf("invalid listen port %d", port)
|
||||||
PrivateKey: &key,
|
|
||||||
ReplacePeers: true,
|
|
||||||
FirewallMark: &fwmark,
|
|
||||||
ListenPort: &port,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.device.IpcSet(toWgUserspaceString(config)); err != nil {
|
if err := c.device.SetPrivateKey(device.NoisePrivateKey(key)); err != nil {
|
||||||
return err
|
return fmt.Errorf("set private key: %w", err)
|
||||||
|
}
|
||||||
|
if err := c.device.SetListenPort(uint16(port)); err != nil {
|
||||||
|
return fmt.Errorf("set listen port %d: %w", port, err)
|
||||||
|
}
|
||||||
|
c.device.RemoveAllPeers()
|
||||||
|
if err := c.device.BindSetMark(uint32(getFwmark())); err != nil {
|
||||||
|
return fmt.Errorf("set fwmark: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.allowedIPs.reset()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,131 +80,91 @@ func (c *WGUSPConfigurer) SetPresharedKey(peerKey string, psk wgtypes.Key, updat
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := buildPresharedKeyConfig(parsedPeerKey, psk, updateOnly)
|
presharedKey := device.NoisePresharedKey(psk)
|
||||||
if err := c.device.IpcSet(toWgUserspaceString(cfg)); err != nil {
|
return c.device.ConfigurePeer(device.NoisePublicKey(parsedPeerKey), device.PeerConfig{
|
||||||
return err
|
PresharedKey: &presharedKey,
|
||||||
}
|
UpdateOnly: updateOnly,
|
||||||
|
})
|
||||||
// Without updateOnly this creates the peer when it is absent, so the store has to
|
|
||||||
// know about it even though no allowed IP was configured.
|
|
||||||
if !updateOnly {
|
|
||||||
c.allowedIPs.ensure(parsedPeerKey)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdatePeer creates or updates a peer, merging allowed IPs with its existing set.
|
// UpdatePeer creates or updates a peer, merging allowed IPs with its existing set.
|
||||||
// It validates the endpoint before writing and records changes after a successful write.
|
|
||||||
func (c *WGUSPConfigurer) UpdatePeer(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error {
|
func (c *WGUSPConfigurer) UpdatePeer(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error {
|
||||||
peerKeyParsed, err := wgtypes.ParseKey(peerKey)
|
peerKeyParsed, err := wgtypes.ParseKey(peerKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Everything that can fail is done before the device is touched, so a failure here
|
prefixes, err := validPrefixes(allowedIps)
|
||||||
// cannot leave the device holding a peer that the activity recorder and the allowed
|
if err != nil {
|
||||||
// IP store never learned about.
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := device.PeerConfig{
|
||||||
|
PersistentKeepalive: &keepAlive,
|
||||||
|
AllowedIPs: prefixes,
|
||||||
|
}
|
||||||
|
|
||||||
var addrPort netip.AddrPort
|
var addrPort netip.AddrPort
|
||||||
if endpoint != nil {
|
if endpoint != nil {
|
||||||
addr, err := netip.ParseAddr(endpoint.IP.String())
|
addr, ok := netip.AddrFromSlice(endpoint.IP)
|
||||||
if err != nil {
|
if !ok {
|
||||||
return fmt.Errorf("parse endpoint address: %w", err)
|
return fmt.Errorf("parse endpoint address %v", endpoint.IP)
|
||||||
}
|
}
|
||||||
addrPort = netip.AddrPortFrom(addr.Unmap(), uint16(endpoint.Port))
|
addr = addr.Unmap()
|
||||||
|
addrPort = netip.AddrPortFrom(addr, uint16(endpoint.Port))
|
||||||
|
cfg.Endpoint = &bind.Endpoint{AddrPort: netip.AddrPortFrom(addr.WithZone(endpoint.Zone), addrPort.Port())}
|
||||||
}
|
}
|
||||||
|
|
||||||
peer := wgtypes.PeerConfig{
|
if preSharedKey != nil {
|
||||||
PublicKey: peerKeyParsed,
|
psk := device.NoisePresharedKey(*preSharedKey)
|
||||||
ReplaceAllowedIPs: false,
|
cfg.PresharedKey = &psk
|
||||||
// don't replace allowed ips, wg will handle duplicated peer IP
|
|
||||||
AllowedIPs: prefixesToIPNets(allowedIps),
|
|
||||||
PersistentKeepaliveInterval: &keepAlive,
|
|
||||||
PresharedKey: preSharedKey,
|
|
||||||
Endpoint: endpoint,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
config := wgtypes.Config{
|
if err := c.device.ConfigurePeer(device.NoisePublicKey(peerKeyParsed), cfg); err != nil {
|
||||||
Peers: []wgtypes.PeerConfig{peer},
|
return fmt.Errorf("configure peer: %w", err)
|
||||||
}
|
|
||||||
|
|
||||||
if ipcErr := c.device.IpcSet(toWgUserspaceString(config)); ipcErr != nil {
|
|
||||||
return ipcErr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if endpoint != nil {
|
if endpoint != nil {
|
||||||
c.activityRecorder.UpsertAddress(peerKey, addrPort)
|
c.activityRecorder.UpsertAddress(peerKey, addrPort)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.allowedIPs.add(peerKeyParsed, allowedIps)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveEndpointAddress clears the endpoint of a peer while keeping it configured.
|
// RemoveEndpointAddress clears the endpoint of a peer while keeping it configured.
|
||||||
// The UAPI cannot clear an endpoint in place, so the peer is removed and re-added with the
|
// The session is reset and the keepalive is switched off until the next endpoint arrives.
|
||||||
// allowed IPs it already had.
|
|
||||||
func (c *WGUSPConfigurer) RemoveEndpointAddress(peerKey string) error {
|
func (c *WGUSPConfigurer) RemoveEndpointAddress(peerKey string) error {
|
||||||
peerKeyParsed, err := wgtypes.ParseKey(peerKey)
|
peerKeyParsed, err := wgtypes.ParseKey(peerKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("parse peer key: %w", err)
|
return fmt.Errorf("parse peer key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
allowedIPs, err := c.peerAllowedIPs(peerKeyParsed)
|
peer := c.device.LookupPeer(device.NoisePublicKey(peerKeyParsed))
|
||||||
if err != nil {
|
if peer == nil {
|
||||||
return err
|
return ErrPeerNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
peer := wgtypes.PeerConfig{
|
peer.ClearEndpoint()
|
||||||
PublicKey: peerKeyParsed,
|
peer.ZeroAndFlushAll()
|
||||||
Remove: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
config := wgtypes.Config{
|
var noKeepAlive time.Duration
|
||||||
Peers: []wgtypes.PeerConfig{peer},
|
if err := c.device.ConfigurePeer(device.NoisePublicKey(peerKeyParsed), device.PeerConfig{
|
||||||
|
PersistentKeepalive: &noKeepAlive,
|
||||||
|
UpdateOnly: true,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("disable keepalive: %w", err)
|
||||||
}
|
}
|
||||||
if ipcErr := c.device.IpcSet(toWgUserspaceString(config)); ipcErr != nil {
|
|
||||||
return fmt.Errorf("remove peer: %w", ipcErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
peer = wgtypes.PeerConfig{
|
|
||||||
PublicKey: peerKeyParsed,
|
|
||||||
ReplaceAllowedIPs: true,
|
|
||||||
AllowedIPs: prefixesToIPNets(allowedIPs),
|
|
||||||
}
|
|
||||||
|
|
||||||
config = wgtypes.Config{
|
|
||||||
Peers: []wgtypes.PeerConfig{peer},
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.device.IpcSet(toWgUserspaceString(config)); err != nil {
|
|
||||||
c.allowedIPs.forget(peerKeyParsed)
|
|
||||||
return fmt.Errorf("re-add peer without endpoint: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemovePeer removes a peer, then clears its activity and allowed IP records.
|
// RemovePeer removes a peer and clears its activity record.
|
||||||
// A failed device write leaves both records intact.
|
|
||||||
func (c *WGUSPConfigurer) RemovePeer(peerKey string) error {
|
func (c *WGUSPConfigurer) RemovePeer(peerKey string) error {
|
||||||
peerKeyParsed, err := wgtypes.ParseKey(peerKey)
|
peerKeyParsed, err := wgtypes.ParseKey(peerKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
peer := wgtypes.PeerConfig{
|
c.device.RemovePeer(device.NoisePublicKey(peerKeyParsed))
|
||||||
PublicKey: peerKeyParsed,
|
|
||||||
Remove: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
config := wgtypes.Config{
|
|
||||||
Peers: []wgtypes.PeerConfig{peer},
|
|
||||||
}
|
|
||||||
if ipcErr := c.device.IpcSet(toWgUserspaceString(config)); ipcErr != nil {
|
|
||||||
return ipcErr
|
|
||||||
}
|
|
||||||
|
|
||||||
c.activityRecorder.Remove(peerKey)
|
c.activityRecorder.Remove(peerKey)
|
||||||
c.allowedIPs.forget(peerKeyParsed)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,22 +174,16 @@ func (c *WGUSPConfigurer) AddAllowedIP(peerKey string, allowedIP netip.Prefix) e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
peer := wgtypes.PeerConfig{
|
if !allowedIP.IsValid() {
|
||||||
PublicKey: peerKeyParsed,
|
return fmt.Errorf("invalid allowed IP %v", allowedIP)
|
||||||
UpdateOnly: true,
|
|
||||||
ReplaceAllowedIPs: false,
|
|
||||||
AllowedIPs: prefixesToIPNets([]netip.Prefix{allowedIP}),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
config := wgtypes.Config{
|
peer := c.device.LookupPeer(device.NoisePublicKey(peerKeyParsed))
|
||||||
Peers: []wgtypes.PeerConfig{peer},
|
if peer == nil {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.device.IpcSet(toWgUserspaceString(config)); err != nil {
|
peer.AddAllowedIP(normalizePrefix(allowedIP))
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
c.allowedIPs.addExisting(peerKeyParsed, []netip.Prefix{allowedIP})
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,86 +194,64 @@ func (c *WGUSPConfigurer) RemoveAllowedIP(peerKey string, allowedIP netip.Prefix
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("parse peer key: %w", err)
|
return fmt.Errorf("parse peer key: %w", err)
|
||||||
}
|
}
|
||||||
|
if !allowedIP.IsValid() {
|
||||||
currentAllowedIPs, err := c.peerAllowedIPs(peerKeyParsed)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
idx := slices.Index(currentAllowedIPs, normalizePrefix(allowedIP))
|
|
||||||
if idx < 0 {
|
|
||||||
return ErrAllowedIPNotFound
|
return ErrAllowedIPNotFound
|
||||||
}
|
}
|
||||||
newAllowedIPs := slices.Delete(currentAllowedIPs, idx, idx+1)
|
|
||||||
|
|
||||||
peer := wgtypes.PeerConfig{
|
peer := c.device.LookupPeer(device.NoisePublicKey(peerKeyParsed))
|
||||||
PublicKey: peerKeyParsed,
|
if peer == nil {
|
||||||
UpdateOnly: true,
|
return ErrPeerNotFound
|
||||||
ReplaceAllowedIPs: true,
|
|
||||||
AllowedIPs: prefixesToIPNets(newAllowedIPs),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
config := wgtypes.Config{
|
if !peer.RemoveAllowedIP(normalizePrefix(allowedIP)) {
|
||||||
Peers: []wgtypes.PeerConfig{peer},
|
return ErrAllowedIPNotFound
|
||||||
}
|
}
|
||||||
if err := c.device.IpcSet(toWgUserspaceString(config)); err != nil {
|
|
||||||
return fmt.Errorf("remove allowed IP %s: %w", allowedIP, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.allowedIPs.set(peerKeyParsed, newAllowedIPs)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// peerAllowedIPs returns the allowed IPs configured for a peer, reading them from the device
|
|
||||||
// only for a peer the store has not seen. Reading them back means dumping and parsing the
|
|
||||||
// whole device configuration, and this runs on every relay and ICE transition.
|
|
||||||
func (c *WGUSPConfigurer) peerAllowedIPs(peerKey wgtypes.Key) ([]netip.Prefix, error) {
|
|
||||||
if prefixes, ok := c.allowedIPs.get(peerKey); ok {
|
|
||||||
return prefixes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ipcStr, err := c.device.IpcGet()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("get IPC config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stats, err := parseStatus(c.deviceName, ipcStr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parse IPC config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseStatus reports keys in their textual form, so the comparison needs it once.
|
|
||||||
wanted := peerKey.String()
|
|
||||||
for _, peer := range stats.Peers {
|
|
||||||
if peer.PublicKey != wanted {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
prefixes := ipNetsToPrefixes(peer.AllowedIPs)
|
|
||||||
c.allowedIPs.set(peerKey, prefixes)
|
|
||||||
return prefixes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, ErrPeerNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WGUSPConfigurer) FullStats() (*Stats, error) {
|
func (c *WGUSPConfigurer) FullStats() (*Stats, error) {
|
||||||
ipcStr, err := c.device.IpcGet()
|
peers := c.device.Peers()
|
||||||
if err != nil {
|
stats := &Stats{
|
||||||
return nil, fmt.Errorf("IpcGet failed: %w", err)
|
DeviceName: c.deviceName,
|
||||||
|
PublicKey: wgtypes.Key(c.device.PublicKey()).String(),
|
||||||
|
ListenPort: int(c.device.ListenPort()),
|
||||||
|
FWMark: int(c.device.FirewallMark()),
|
||||||
|
Peers: make([]Peer, 0, len(peers)),
|
||||||
}
|
}
|
||||||
|
for _, peer := range peers {
|
||||||
return parseStatus(c.deviceName, ipcStr)
|
stats.Peers = append(stats.Peers, peerStats(peer))
|
||||||
|
}
|
||||||
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *WGUSPConfigurer) LastActivities() map[string]monotime.Time {
|
func (c *WGUSPConfigurer) LastActivities() map[string]monotime.Time {
|
||||||
return c.activityRecorder.GetLastActivities()
|
return c.activityRecorder.GetLastActivities()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *WGUSPConfigurer) GetStats() (map[string]WGStats, error) {
|
||||||
|
return c.statsCache.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WGUSPConfigurer) Close() {
|
||||||
|
if c.uapiListener != nil {
|
||||||
|
err := c.uapiListener.Close()
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("failed to close uapi listener: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if runtime.GOOS == "linux" {
|
||||||
|
sockPath := "/var/run/wireguard/" + c.deviceName + ".sock"
|
||||||
|
if _, statErr := os.Stat(sockPath); statErr == nil {
|
||||||
|
_ = os.Remove(sockPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// startUAPI starts the UAPI listener for managing the WireGuard interface via external tool
|
// startUAPI starts the UAPI listener for managing the WireGuard interface via external tool
|
||||||
func (t *WGUSPConfigurer) startUAPI() {
|
func (c *WGUSPConfigurer) startUAPI() {
|
||||||
var err error
|
var err error
|
||||||
t.uapiListener, err = openUAPI(t.deviceName)
|
c.uapiListener, err = openUAPI(c.deviceName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("failed to open uapi listener: %v", err)
|
log.Errorf("failed to open uapi listener: %v", err)
|
||||||
return
|
return
|
||||||
@@ -357,175 +265,61 @@ func (t *WGUSPConfigurer) startUAPI() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
t.device.IpcHandle(uapiConn)
|
c.device.IpcHandle(uapiConn)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
}(t.uapiListener)
|
}(c.uapiListener)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *WGUSPConfigurer) Close() {
|
func (c *WGUSPConfigurer) fetchStats() (map[string]WGStats, error) {
|
||||||
if t.uapiListener != nil {
|
peers := c.device.Peers()
|
||||||
err := t.uapiListener.Close()
|
stats := make(map[string]WGStats, len(peers))
|
||||||
if err != nil {
|
for _, peer := range peers {
|
||||||
log.Errorf("failed to close uapi listener: %v", err)
|
stats[wgtypes.Key(peer.PublicKey()).String()] = WGStats{
|
||||||
|
LastHandshake: peer.LastHandshake(),
|
||||||
|
TxBytes: int64(peer.TxBytes()),
|
||||||
|
RxBytes: int64(peer.RxBytes()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if runtime.GOOS == "linux" {
|
|
||||||
sockPath := "/var/run/wireguard/" + t.deviceName + ".sock"
|
|
||||||
if _, statErr := os.Stat(sockPath); statErr == nil {
|
|
||||||
_ = os.Remove(sockPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *WGUSPConfigurer) GetStats() (map[string]WGStats, error) {
|
|
||||||
return t.statsCache.get()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *WGUSPConfigurer) fetchStats() (map[string]WGStats, error) {
|
|
||||||
ipc, err := t.device.IpcGet()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("ipc get: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseTransfers(ipc)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseTransfers(ipc string) (map[string]WGStats, error) {
|
|
||||||
stats := make(map[string]WGStats)
|
|
||||||
var (
|
|
||||||
currentKey string
|
|
||||||
currentStats WGStats
|
|
||||||
hasPeer bool
|
|
||||||
)
|
|
||||||
lines := strings.Split(ipc, "\n")
|
|
||||||
for _, line := range lines {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
|
|
||||||
// If we're within the details of the found peer and encounter another public key,
|
|
||||||
// this means we're starting another peer's details. So, stop.
|
|
||||||
if strings.HasPrefix(line, "public_key=") {
|
|
||||||
peerID := strings.TrimPrefix(line, "public_key=")
|
|
||||||
h, err := hex.DecodeString(peerID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("decode peerID: %w", err)
|
|
||||||
}
|
|
||||||
currentKey = base64.StdEncoding.EncodeToString(h)
|
|
||||||
currentStats = WGStats{} // Reset stats for the new peer
|
|
||||||
hasPeer = true
|
|
||||||
stats[currentKey] = currentStats
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hasPeer {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
key := strings.SplitN(line, "=", 2)
|
|
||||||
if len(key) != 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch key[0] {
|
|
||||||
case ipcKeyLastHandshakeTimeSec:
|
|
||||||
hs, err := toLastHandshake(key[1])
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
currentStats.LastHandshake = hs
|
|
||||||
stats[currentKey] = currentStats
|
|
||||||
case ipcKeyRxBytes:
|
|
||||||
rxBytes, err := toBytes(key[1])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parse rx_bytes: %w", err)
|
|
||||||
}
|
|
||||||
currentStats.RxBytes = rxBytes
|
|
||||||
stats[currentKey] = currentStats
|
|
||||||
case ipcKeyTxBytes:
|
|
||||||
TxBytes, err := toBytes(key[1])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parse tx_bytes: %w", err)
|
|
||||||
}
|
|
||||||
currentStats.TxBytes = TxBytes
|
|
||||||
stats[currentKey] = currentStats
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func toWgUserspaceString(wgCfg wgtypes.Config) string {
|
func peerStats(peer *device.Peer) Peer {
|
||||||
var sb strings.Builder
|
stats := Peer{
|
||||||
if wgCfg.PrivateKey != nil {
|
PublicKey: wgtypes.Key(peer.PublicKey()).String(),
|
||||||
hexKey := hex.EncodeToString(wgCfg.PrivateKey[:])
|
AllowedIPs: prefixesToIPNets(peer.AllowedIPs()),
|
||||||
sb.WriteString(fmt.Sprintf("private_key=%s\n", hexKey))
|
TxBytes: int64(peer.TxBytes()),
|
||||||
|
RxBytes: int64(peer.RxBytes()),
|
||||||
|
LastHandshake: peer.LastHandshake(),
|
||||||
|
PresharedKey: [32]byte(peer.PresharedKey()),
|
||||||
}
|
}
|
||||||
|
if addrPort, ok := endpointAddrPort(peer.Endpoint()); ok {
|
||||||
if wgCfg.ListenPort != nil {
|
stats.Endpoint = net.UDPAddr{IP: addrPort.Addr().AsSlice(), Port: int(addrPort.Port())}
|
||||||
sb.WriteString(fmt.Sprintf("listen_port=%d\n", *wgCfg.ListenPort))
|
|
||||||
}
|
}
|
||||||
|
return stats
|
||||||
if wgCfg.ReplacePeers {
|
|
||||||
sb.WriteString("replace_peers=true\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
if wgCfg.FirewallMark != nil {
|
|
||||||
sb.WriteString(fmt.Sprintf("fwmark=%d\n", *wgCfg.FirewallMark))
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, p := range wgCfg.Peers {
|
|
||||||
hexKey := hex.EncodeToString(p.PublicKey[:])
|
|
||||||
sb.WriteString(fmt.Sprintf("public_key=%s\n", hexKey))
|
|
||||||
|
|
||||||
if p.Remove {
|
|
||||||
sb.WriteString("remove=true\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
if p.UpdateOnly {
|
|
||||||
sb.WriteString("update_only=true\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
if p.PresharedKey != nil {
|
|
||||||
preSharedHexKey := hex.EncodeToString(p.PresharedKey[:])
|
|
||||||
sb.WriteString(fmt.Sprintf("preshared_key=%s\n", preSharedHexKey))
|
|
||||||
}
|
|
||||||
|
|
||||||
if p.Endpoint != nil {
|
|
||||||
sb.WriteString(fmt.Sprintf("endpoint=%s\n", p.Endpoint.String()))
|
|
||||||
}
|
|
||||||
|
|
||||||
if p.PersistentKeepaliveInterval != nil {
|
|
||||||
sb.WriteString(fmt.Sprintf("persistent_keepalive_interval=%d\n", int(p.PersistentKeepaliveInterval.Seconds())))
|
|
||||||
}
|
|
||||||
|
|
||||||
if p.ReplaceAllowedIPs {
|
|
||||||
sb.WriteString("replace_allowed_ips=true\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, aip := range p.AllowedIPs {
|
|
||||||
sb.WriteString(fmt.Sprintf("allowed_ip=%s\n", aip.String()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sb.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func toLastHandshake(stringVar string) (time.Time, error) {
|
func endpointAddrPort(endpoint wgconn.Endpoint) (netip.AddrPort, bool) {
|
||||||
sec, err := strconv.ParseInt(stringVar, 10, 64)
|
if endpoint == nil {
|
||||||
|
return netip.AddrPort{}, false
|
||||||
|
}
|
||||||
|
if ep, ok := endpoint.(*bind.Endpoint); ok {
|
||||||
|
return ep.AddrPort, true
|
||||||
|
}
|
||||||
|
addrPort, err := netip.ParseAddrPort(endpoint.DstToString())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return time.Time{}, fmt.Errorf("parse handshake sec: %w", err)
|
return netip.AddrPort{}, false
|
||||||
}
|
}
|
||||||
|
return addrPort, true
|
||||||
// If sec is 0 (Unix epoch), return zero time instead
|
|
||||||
// This indicates no handshake has occurred
|
|
||||||
if sec == 0 {
|
|
||||||
return time.Time{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return time.Unix(sec, 0), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func toBytes(s string) (int64, error) {
|
func validPrefixes(prefixes []netip.Prefix) ([]netip.Prefix, error) {
|
||||||
return strconv.ParseInt(s, 10, 64)
|
for _, prefix := range prefixes {
|
||||||
|
if !prefix.IsValid() {
|
||||||
|
return nil, fmt.Errorf("invalid allowed IP %v", prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalizePrefixes(prefixes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getFwmark() int {
|
func getFwmark() int {
|
||||||
@@ -534,138 +328,3 @@ func getFwmark() int {
|
|||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func hexToWireguardKey(hexKey string) (wgtypes.Key, error) {
|
|
||||||
// Decode hex string to bytes
|
|
||||||
keyBytes, err := hex.DecodeString(hexKey)
|
|
||||||
if err != nil {
|
|
||||||
return wgtypes.Key{}, fmt.Errorf("failed to decode hex key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we have the right number of bytes (WireGuard keys are 32 bytes)
|
|
||||||
if len(keyBytes) != 32 {
|
|
||||||
return wgtypes.Key{}, fmt.Errorf("invalid key length: expected 32 bytes, got %d", len(keyBytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to wgtypes.Key
|
|
||||||
var key wgtypes.Key
|
|
||||||
copy(key[:], keyBytes)
|
|
||||||
|
|
||||||
return key, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseStatus(deviceName, ipcStr string) (*Stats, error) {
|
|
||||||
stats := &Stats{DeviceName: deviceName}
|
|
||||||
var currentPeer *Peer
|
|
||||||
for _, line := range strings.Split(strings.TrimSpace(ipcStr), "\n") {
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parts := strings.SplitN(line, "=", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := parts[0]
|
|
||||||
val := parts[1]
|
|
||||||
|
|
||||||
switch key {
|
|
||||||
case privateKey:
|
|
||||||
key, err := hexToWireguardKey(val)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("failed to parse private key: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
stats.PublicKey = key.PublicKey().String()
|
|
||||||
case publicKey:
|
|
||||||
// Save previous peer
|
|
||||||
if currentPeer != nil {
|
|
||||||
stats.Peers = append(stats.Peers, *currentPeer)
|
|
||||||
}
|
|
||||||
key, err := hexToWireguardKey(val)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("failed to parse public key: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
currentPeer = &Peer{
|
|
||||||
PublicKey: key.String(),
|
|
||||||
}
|
|
||||||
case listenPort:
|
|
||||||
if port, err := strconv.Atoi(val); err == nil {
|
|
||||||
stats.ListenPort = port
|
|
||||||
}
|
|
||||||
case fwmark:
|
|
||||||
if fwmark, err := strconv.Atoi(val); err == nil {
|
|
||||||
stats.FWMark = fwmark
|
|
||||||
}
|
|
||||||
case endpoint:
|
|
||||||
if currentPeer == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
host, portStr, err := net.SplitHostPort(val)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("failed to parse endpoint: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
port, err := strconv.Atoi(portStr)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("failed to parse endpoint port: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
currentPeer.Endpoint = net.UDPAddr{
|
|
||||||
IP: net.ParseIP(host),
|
|
||||||
Port: port,
|
|
||||||
}
|
|
||||||
case allowedIP:
|
|
||||||
if currentPeer == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
_, ipnet, err := net.ParseCIDR(val)
|
|
||||||
if err == nil {
|
|
||||||
currentPeer.AllowedIPs = append(currentPeer.AllowedIPs, *ipnet)
|
|
||||||
}
|
|
||||||
case ipcKeyTxBytes:
|
|
||||||
if currentPeer == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rxBytes, err := toBytes(val)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
currentPeer.TxBytes = rxBytes
|
|
||||||
case ipcKeyRxBytes:
|
|
||||||
if currentPeer == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rxBytes, err := toBytes(val)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
currentPeer.RxBytes = rxBytes
|
|
||||||
|
|
||||||
case ipcKeyLastHandshakeTimeSec:
|
|
||||||
if currentPeer == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
ts, err := toLastHandshake(val)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
currentPeer.LastHandshake = ts
|
|
||||||
case presharedKey:
|
|
||||||
if currentPeer == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if val != "" && val != "0000000000000000000000000000000000000000000000000000000000000000" {
|
|
||||||
if pskKey, err := hexToWireguardKey(val); err == nil {
|
|
||||||
currentPeer.PresharedKey = [32]byte(pskKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if currentPeer != nil {
|
|
||||||
stats.Peers = append(stats.Peers, *currentPeer)
|
|
||||||
}
|
|
||||||
return stats, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
wgconn "golang.zx2c4.com/wireguard/conn"
|
wgconn "golang.zx2c4.com/wireguard/conn"
|
||||||
wgdevice "golang.zx2c4.com/wireguard/device"
|
wgdevice "golang.zx2c4.com/wireguard/device"
|
||||||
"golang.zx2c4.com/wireguard/tun/tuntest"
|
|
||||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/iface/bind"
|
"github.com/netbirdio/netbird/client/iface/bind"
|
||||||
@@ -21,8 +20,7 @@ import (
|
|||||||
func newTestUSPConfigurer(t *testing.T) *WGUSPConfigurer {
|
func newTestUSPConfigurer(t *testing.T) *WGUSPConfigurer {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
tun := tuntest.NewChannelTUN()
|
dev := wgdevice.NewDevice(newIdleTUN(), wgconn.NewDefaultBind(), wgdevice.NewLogger(wgdevice.LogLevelSilent, ""))
|
||||||
dev := wgdevice.NewDevice(tun.TUN(), wgconn.NewDefaultBind(), wgdevice.NewLogger(wgdevice.LogLevelSilent, ""))
|
|
||||||
t.Cleanup(dev.Close)
|
t.Cleanup(dev.Close)
|
||||||
|
|
||||||
c := NewUSPConfigurerNoUAPI(dev, "wgtest0", bind.NewActivityRecorder())
|
c := NewUSPConfigurerNoUAPI(dev, "wgtest0", bind.NewActivityRecorder())
|
||||||
@@ -118,27 +116,6 @@ func TestRemoveEndpointAddressDoesNotScaleWithPeerCount(t *testing.T) {
|
|||||||
large, small)
|
large, small)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRemoveEndpointAddressFallsBackToDevice covers a peer the store never saw, which is what
|
|
||||||
// an out-of-band reconfiguration of the device leaves behind. The device stays the source of
|
|
||||||
// truth in that case, so the allowed IPs must still be preserved.
|
|
||||||
func TestRemoveEndpointAddressFallsBackToDevice(t *testing.T) {
|
|
||||||
c := newTestUSPConfigurer(t)
|
|
||||||
peerKey := seedPeers(t, c, 3)[1]
|
|
||||||
require.NoError(t, c.AddAllowedIP(peerKey, netip.MustParsePrefix("10.20.0.0/16")), "add routed prefix")
|
|
||||||
|
|
||||||
before := peerAllowedIPs(t, c, peerKey)
|
|
||||||
c.allowedIPs.reset()
|
|
||||||
|
|
||||||
require.NoError(t, c.RemoveEndpointAddress(peerKey), "remove endpoint address")
|
|
||||||
|
|
||||||
assert.ElementsMatch(t, before, peerAllowedIPs(t, c, peerKey),
|
|
||||||
"allowed IPs recovered from the device must be preserved")
|
|
||||||
|
|
||||||
recovered, ok := c.allowedIPs.get(mustParseKey(t, peerKey))
|
|
||||||
assert.True(t, ok, "the fallback must seed the store so the next call skips the device dump")
|
|
||||||
assert.Len(t, recovered, 2, "seeded prefixes")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRemoveAllowedIPKeepsTheOtherPrefixes(t *testing.T) {
|
func TestRemoveAllowedIPKeepsTheOtherPrefixes(t *testing.T) {
|
||||||
c := newTestUSPConfigurer(t)
|
c := newTestUSPConfigurer(t)
|
||||||
peerKey := seedPeers(t, c, 3)[0]
|
peerKey := seedPeers(t, c, 3)[0]
|
||||||
@@ -156,10 +133,8 @@ func TestRemoveAllowedIPKeepsTheOtherPrefixes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestAddAllowedIPOnAbsentPeerDoesNotResurrectIt covers the lazy connection window documented
|
// TestAddAllowedIPOnAbsentPeerDoesNotResurrectIt covers the lazy connection window documented
|
||||||
// in #6863: AddAllowedIP is update-only, a silent no-op when the peer is absent, so it must not
|
// in #6863: AddAllowedIP is update-only, a silent no-op when the peer is absent, and clearing
|
||||||
// leave the store claiming prefixes the device never took. RemoveEndpointAddress re-adds a peer
|
// the endpoint of an absent peer must not create one either.
|
||||||
// without update-only, so a phantom entry would create a peer the device had dropped, and a
|
|
||||||
// created peer would steal those allowed IPs from whichever peer legitimately holds them.
|
|
||||||
func TestAddAllowedIPOnAbsentPeerDoesNotResurrectIt(t *testing.T) {
|
func TestAddAllowedIPOnAbsentPeerDoesNotResurrectIt(t *testing.T) {
|
||||||
c := newTestUSPConfigurer(t)
|
c := newTestUSPConfigurer(t)
|
||||||
seedPeers(t, c, 2)
|
seedPeers(t, c, 2)
|
||||||
@@ -253,16 +228,11 @@ func TestUpdatePeerDoesNotWidenAMappedPrefixOnTheDevice(t *testing.T) {
|
|||||||
onDevice := peerAllowedIPs(t, c, peerKey)
|
onDevice := peerAllowedIPs(t, c, peerKey)
|
||||||
assert.NotContains(t, onDevice, "0.0.0.0/0", "the device must not be given a catch-all allowed IP")
|
assert.NotContains(t, onDevice, "0.0.0.0/0", "the device must not be given a catch-all allowed IP")
|
||||||
assert.Equal(t, []string{"10.1.0.0/16"}, onDevice, "the device holds the normalized prefix")
|
assert.Equal(t, []string{"10.1.0.0/16"}, onDevice, "the device holds the normalized prefix")
|
||||||
|
|
||||||
recorded, ok := c.allowedIPs.get(mustParseKey(t, peerKey))
|
|
||||||
require.True(t, ok, "the peer must be recorded")
|
|
||||||
require.Len(t, recorded, 1, "one prefix recorded")
|
|
||||||
assert.Equal(t, onDevice[0], recorded[0].String(), "device and store must agree")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestUpdatePeerWithAnUnusableEndpointTouchesNothing pins the ordering: the endpoint is
|
// TestUpdatePeerWithAnUnusableEndpointTouchesNothing pins the ordering: the endpoint is
|
||||||
// parsed before the device is configured, so a failure cannot leave the device holding a
|
// parsed before the device is configured, so a failure cannot leave a half-configured peer
|
||||||
// peer that the store never learned about, with the prefix handover skipped along with it.
|
// on the device.
|
||||||
func TestUpdatePeerWithAnUnusableEndpointTouchesNothing(t *testing.T) {
|
func TestUpdatePeerWithAnUnusableEndpointTouchesNothing(t *testing.T) {
|
||||||
c := newTestUSPConfigurer(t)
|
c := newTestUSPConfigurer(t)
|
||||||
seedPeers(t, c, 2)
|
seedPeers(t, c, 2)
|
||||||
@@ -279,40 +249,4 @@ func TestUpdatePeerWithAnUnusableEndpointTouchesNothing(t *testing.T) {
|
|||||||
stats, err := c.FullStats()
|
stats, err := c.FullStats()
|
||||||
require.NoError(t, err, "read device stats")
|
require.NoError(t, err, "read device stats")
|
||||||
assert.Len(t, stats.Peers, 2, "the peer must not have reached the device")
|
assert.Len(t, stats.Peers, 2, "the peer must not have reached the device")
|
||||||
|
|
||||||
_, ok := c.allowedIPs.get(mustParseKey(t, peerKey))
|
|
||||||
assert.False(t, ok, "the peer must not have been recorded either")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRemovePeerKeepsTheRecordWhenTheDeviceRefuses covers a removal that never reached the
|
|
||||||
// device. A single peer removal is one write, so a failure leaves the peer on the device
|
|
||||||
// exactly as it was, and the record still describes it; dropping it would only force the
|
|
||||||
// next caller to read the whole device back for an answer it already had.
|
|
||||||
func TestRemovePeerKeepsTheRecordWhenTheDeviceRefuses(t *testing.T) {
|
|
||||||
c := newTestUSPConfigurer(t)
|
|
||||||
peerKey := seedPeers(t, c, 1)[0]
|
|
||||||
require.NoError(t, c.AddAllowedIP(peerKey, netip.MustParsePrefix("10.20.0.0/16")), "add routed prefix")
|
|
||||||
|
|
||||||
before, ok := c.allowedIPs.get(mustParseKey(t, peerKey))
|
|
||||||
require.True(t, ok, "the peer must be recorded before the removal")
|
|
||||||
require.Len(t, before, 2, "overlay address plus routed prefix")
|
|
||||||
|
|
||||||
// A closed device refuses every write, which is the shape of any failed removal.
|
|
||||||
c.device.Close()
|
|
||||||
|
|
||||||
require.Error(t, c.RemovePeer(peerKey), "the removal must report the failure")
|
|
||||||
|
|
||||||
after, ok := c.allowedIPs.get(mustParseKey(t, peerKey))
|
|
||||||
require.True(t, ok, "a peer still on the device must stay recorded")
|
|
||||||
assert.Equal(t, before, after, "the record must describe the peer the device kept")
|
|
||||||
}
|
|
||||||
|
|
||||||
// mustParseKey turns the textual key the configurer API takes into the form the store
|
|
||||||
// keys on.
|
|
||||||
func mustParseKey(t *testing.T, key string) wgtypes.Key {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
parsed, err := wgtypes.ParseKey(key)
|
|
||||||
require.NoError(t, err, "parse peer key")
|
|
||||||
return parsed
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,88 +2,222 @@ package configurer
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
wgdevice "golang.zx2c4.com/wireguard/device"
|
||||||
|
"golang.zx2c4.com/wireguard/tun"
|
||||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ipcFixture = `
|
// idleTUN never reports an up event, so the wireguard-go device stays down: no socket is
|
||||||
private_key=e84b5a6d2717c1003a13b431570353dbaca9146cf150c5f8575680feba52027a
|
// opened, no peer goroutine starts, and the configuration state is all the tests observe.
|
||||||
listen_port=12912
|
type idleTUN struct {
|
||||||
public_key=b85996fecc9c7f1fc6d2572a76eda11d59bcd20be8e543b15ce4bd85a8e75a33
|
events chan tun.Event
|
||||||
preshared_key=188515093e952f5f22e865cef3012e72f8b5f0b598ac0309d5dacce3b70fcf52
|
closed chan struct{}
|
||||||
allowed_ip=192.168.4.4/32
|
}
|
||||||
endpoint=[abcd:23::33%2]:51820
|
|
||||||
public_key=58402e695ba1772b1cc9309755f043251ea77fdcf10fbe63989ceb7e19321376
|
|
||||||
tx_bytes=38333
|
|
||||||
rx_bytes=2224
|
|
||||||
allowed_ip=192.168.4.6/32
|
|
||||||
persistent_keepalive_interval=111
|
|
||||||
endpoint=182.122.22.19:3233
|
|
||||||
public_key=662e14fd594556f522604703340351258903b64f35553763f19426ab2a515c58
|
|
||||||
endpoint=5.152.198.39:51820
|
|
||||||
allowed_ip=192.168.4.10/32
|
|
||||||
allowed_ip=192.168.4.11/32
|
|
||||||
tx_bytes=1212111
|
|
||||||
rx_bytes=1929999999
|
|
||||||
protocol_version=1
|
|
||||||
errno=0
|
|
||||||
|
|
||||||
`
|
func newIdleTUN() *idleTUN {
|
||||||
|
return &idleTUN{events: make(chan tun.Event), closed: make(chan struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
func Test_parseTransfers(t *testing.T) {
|
func (t *idleTUN) File() *os.File { return nil }
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
peerKey string
|
|
||||||
want WGStats
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "single",
|
|
||||||
peerKey: "b85996fecc9c7f1fc6d2572a76eda11d59bcd20be8e543b15ce4bd85a8e75a33",
|
|
||||||
want: WGStats{
|
|
||||||
TxBytes: 0,
|
|
||||||
RxBytes: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple",
|
|
||||||
peerKey: "58402e695ba1772b1cc9309755f043251ea77fdcf10fbe63989ceb7e19321376",
|
|
||||||
want: WGStats{
|
|
||||||
TxBytes: 38333,
|
|
||||||
RxBytes: 2224,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "lastpeer",
|
|
||||||
peerKey: "662e14fd594556f522604703340351258903b64f35553763f19426ab2a515c58",
|
|
||||||
want: WGStats{
|
|
||||||
TxBytes: 1212111,
|
|
||||||
RxBytes: 1929999999,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
res, err := hex.DecodeString(tt.peerKey)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
key, err := wgtypes.NewKey(res)
|
func (t *idleTUN) Read([][]byte, []int, int) (int, error) {
|
||||||
require.NoError(t, err)
|
<-t.closed
|
||||||
|
return 0, os.ErrClosed
|
||||||
|
}
|
||||||
|
|
||||||
stats, err := parseTransfers(ipcFixture)
|
func (t *idleTUN) Write(bufs [][]byte, _ int) (int, error) { return len(bufs), nil }
|
||||||
if err != nil {
|
|
||||||
require.NoError(t, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
stat, ok := stats[key.String()]
|
func (t *idleTUN) MTU() (int, error) { return 1420, nil }
|
||||||
if !ok {
|
|
||||||
require.True(t, ok)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
require.Equal(t, tt.want, stat)
|
func (t *idleTUN) Name() (string, error) { return "idle", nil }
|
||||||
})
|
|
||||||
|
func (t *idleTUN) Events() <-chan tun.Event { return t.events }
|
||||||
|
|
||||||
|
func (t *idleTUN) Close() error {
|
||||||
|
close(t.closed)
|
||||||
|
close(t.events)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *idleTUN) BatchSize() int { return 1 }
|
||||||
|
|
||||||
|
func TestConfigureInterfaceAppliesKeyAndPortAndReplacesPeers(t *testing.T) {
|
||||||
|
c := newTestUSPConfigurer(t)
|
||||||
|
seedPeers(t, c, 2)
|
||||||
|
|
||||||
|
key, err := wgtypes.GeneratePrivateKey()
|
||||||
|
require.NoError(t, err, "generate device private key")
|
||||||
|
require.NoError(t, c.ConfigureInterface(key.String(), 51820), "reconfigure the interface")
|
||||||
|
|
||||||
|
stats, err := c.FullStats()
|
||||||
|
require.NoError(t, err, "read device stats")
|
||||||
|
assert.Equal(t, key.PublicKey().String(), stats.PublicKey, "device public key")
|
||||||
|
assert.Equal(t, 51820, stats.ListenPort, "listen port")
|
||||||
|
assert.Empty(t, stats.Peers, "configuring the interface replaces every peer")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigureInterfaceRejectsAnInvalidPort(t *testing.T) {
|
||||||
|
c := newTestUSPConfigurer(t)
|
||||||
|
|
||||||
|
key, err := wgtypes.GeneratePrivateKey()
|
||||||
|
require.NoError(t, err, "generate device private key")
|
||||||
|
assert.Error(t, c.ConfigureInterface(key.String(), 70000), "a port above 65535 must be rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetStatsIsKeyedByPeerKey(t *testing.T) {
|
||||||
|
c := newTestUSPConfigurer(t)
|
||||||
|
keys := seedPeers(t, c, 3)
|
||||||
|
|
||||||
|
stats, err := c.GetStats()
|
||||||
|
require.NoError(t, err, "read stats")
|
||||||
|
require.Len(t, stats, 3, "one entry per peer")
|
||||||
|
for _, key := range keys {
|
||||||
|
entry, ok := stats[key]
|
||||||
|
require.True(t, ok, "peer %s must be reported", key)
|
||||||
|
assert.Zero(t, entry.TxBytes, "no traffic was sent")
|
||||||
|
assert.Zero(t, entry.RxBytes, "no traffic was received")
|
||||||
|
assert.True(t, entry.LastHandshake.IsZero(), "no handshake happened")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFullStatsMatchesTheDeviceDump(t *testing.T) {
|
||||||
|
c := newTestUSPConfigurer(t)
|
||||||
|
|
||||||
|
priv, err := wgtypes.GeneratePrivateKey()
|
||||||
|
require.NoError(t, err, "generate peer private key")
|
||||||
|
peerKey := priv.PublicKey().String()
|
||||||
|
psk, err := wgtypes.GenerateKey()
|
||||||
|
require.NoError(t, err, "generate preshared key")
|
||||||
|
endpoint := &net.UDPAddr{IP: net.IPv4(192, 0, 2, 10), Port: 51820}
|
||||||
|
prefixes := []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32"), netip.MustParsePrefix("10.20.0.0/16")}
|
||||||
|
require.NoError(t, c.UpdatePeer(peerKey, prefixes, 25*time.Second, endpoint, &psk), "add peer")
|
||||||
|
|
||||||
|
stats, err := c.FullStats()
|
||||||
|
require.NoError(t, err, "read device stats")
|
||||||
|
require.Len(t, stats.Peers, 1, "one peer on the device")
|
||||||
|
peer := stats.Peers[0]
|
||||||
|
assert.Equal(t, peerKey, peer.PublicKey, "peer key")
|
||||||
|
assert.Equal(t, "192.0.2.10:51820", peer.Endpoint.String(), "endpoint")
|
||||||
|
assert.Equal(t, [32]byte(psk), peer.PresharedKey, "preshared key")
|
||||||
|
assert.Equal(t, []string{"100.64.0.1/32", "10.20.0.0/16"}, ipNetStrings(peer.AllowedIPs), "allowed IPs")
|
||||||
|
|
||||||
|
block := uapiPeerBlock(t, c, peerKey)
|
||||||
|
assert.Contains(t, block, "endpoint=192.0.2.10:51820", "the dump must show the endpoint")
|
||||||
|
assert.Contains(t, block, "preshared_key="+hex.EncodeToString(psk[:]), "the dump must show the preshared key")
|
||||||
|
assert.Contains(t, block, "persistent_keepalive_interval=25", "the dump must show the keepalive")
|
||||||
|
assert.Contains(t, block, "allowed_ip=100.64.0.1/32", "the dump must show the first prefix")
|
||||||
|
assert.Contains(t, block, "allowed_ip=10.20.0.0/16", "the dump must show the second prefix")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePeerKeepsTheEndpointZone(t *testing.T) {
|
||||||
|
c := newTestUSPConfigurer(t)
|
||||||
|
|
||||||
|
priv, err := wgtypes.GeneratePrivateKey()
|
||||||
|
require.NoError(t, err, "generate peer private key")
|
||||||
|
peerKey := priv.PublicKey().String()
|
||||||
|
// The idle device opens no socket, so the zone name does not have to exist on the host.
|
||||||
|
endpoint := &net.UDPAddr{IP: net.ParseIP("fe80::1"), Port: 51820, Zone: "eth0"}
|
||||||
|
prefixes := []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")}
|
||||||
|
require.NoError(t, c.UpdatePeer(peerKey, prefixes, 25*time.Second, endpoint, nil), "add peer")
|
||||||
|
|
||||||
|
assert.Contains(t, uapiPeerBlock(t, c, peerKey), "endpoint=[fe80::1%eth0]:51820",
|
||||||
|
"a link-local endpoint must keep its zone")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveEndpointAddressKeepsThePeerAndStopsTheKeepalive(t *testing.T) {
|
||||||
|
c := newTestUSPConfigurer(t)
|
||||||
|
|
||||||
|
priv, err := wgtypes.GeneratePrivateKey()
|
||||||
|
require.NoError(t, err, "generate peer private key")
|
||||||
|
peerKey := priv.PublicKey().String()
|
||||||
|
endpoint := &net.UDPAddr{IP: net.IPv4(192, 0, 2, 10), Port: 51820}
|
||||||
|
prefixes := []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")}
|
||||||
|
require.NoError(t, c.UpdatePeer(peerKey, prefixes, 25*time.Second, endpoint, nil), "add peer")
|
||||||
|
before := c.device.LookupPeer(wgdevice.NoisePublicKey(priv.PublicKey()))
|
||||||
|
require.NotNil(t, before, "the peer must be on the device")
|
||||||
|
|
||||||
|
require.NoError(t, c.RemoveEndpointAddress(peerKey), "remove endpoint address")
|
||||||
|
|
||||||
|
assert.Same(t, before, c.device.LookupPeer(wgdevice.NoisePublicKey(priv.PublicKey())),
|
||||||
|
"the peer object must survive the endpoint removal")
|
||||||
|
stats, err := c.FullStats()
|
||||||
|
require.NoError(t, err, "read device stats")
|
||||||
|
require.Len(t, stats.Peers, 1, "the peer stays configured")
|
||||||
|
assert.Nil(t, stats.Peers[0].Endpoint.IP, "the endpoint must be gone")
|
||||||
|
assert.Equal(t, []string{"100.64.0.1/32"}, ipNetStrings(stats.Peers[0].AllowedIPs), "allowed IPs survive")
|
||||||
|
assert.Contains(t, uapiPeerBlock(t, c, peerKey), "persistent_keepalive_interval=0",
|
||||||
|
"an endpoint-less peer must not keep sending keepalives")
|
||||||
|
|
||||||
|
require.NoError(t, c.UpdatePeer(peerKey, prefixes, 25*time.Second, endpoint, nil), "restore the endpoint")
|
||||||
|
block := uapiPeerBlock(t, c, peerKey)
|
||||||
|
assert.Contains(t, block, "endpoint=192.0.2.10:51820", "the endpoint must be back")
|
||||||
|
assert.Contains(t, block, "persistent_keepalive_interval=25", "the keepalive must be back")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePeerRejectsAnInvalidPrefix(t *testing.T) {
|
||||||
|
c := newTestUSPConfigurer(t)
|
||||||
|
|
||||||
|
priv, err := wgtypes.GeneratePrivateKey()
|
||||||
|
require.NoError(t, err, "generate peer private key")
|
||||||
|
peerKey := priv.PublicKey().String()
|
||||||
|
|
||||||
|
require.Error(t, c.UpdatePeer(peerKey, []netip.Prefix{{}}, 25*time.Second, nil, nil), "an invalid prefix must be rejected")
|
||||||
|
|
||||||
|
stats, err := c.FullStats()
|
||||||
|
require.NoError(t, err, "read device stats")
|
||||||
|
assert.Empty(t, stats.Peers, "the peer must not have reached the device")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveAllowedIPOnAbsentPeerReportsThePeer(t *testing.T) {
|
||||||
|
c := newTestUSPConfigurer(t)
|
||||||
|
|
||||||
|
priv, err := wgtypes.GeneratePrivateKey()
|
||||||
|
require.NoError(t, err, "generate peer private key")
|
||||||
|
|
||||||
|
assert.ErrorIs(t, c.RemoveAllowedIP(priv.PublicKey().String(), netip.MustParsePrefix("10.20.0.0/16")), ErrPeerNotFound,
|
||||||
|
"removing a prefix from a peer the device does not have must report the peer")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ipNetStrings(ipNets []net.IPNet) []string {
|
||||||
|
out := make([]string, 0, len(ipNets))
|
||||||
|
for _, ipNet := range ipNets {
|
||||||
|
out = append(out, ipNet.String())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// uapiPeerBlock reads the device back through the UAPI dump, so a typed write is checked
|
||||||
|
// against an independent view of the device.
|
||||||
|
func uapiPeerBlock(t *testing.T, c *WGUSPConfigurer, peerKey string) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
parsed, err := wgtypes.ParseKey(peerKey)
|
||||||
|
require.NoError(t, err, "parse peer key")
|
||||||
|
wanted := "public_key=" + hex.EncodeToString(parsed[:])
|
||||||
|
|
||||||
|
dump, err := c.device.IpcGet()
|
||||||
|
require.NoError(t, err, "dump device")
|
||||||
|
|
||||||
|
var block []string
|
||||||
|
inBlock := false
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(dump), "\n") {
|
||||||
|
if strings.HasPrefix(line, "public_key=") {
|
||||||
|
inBlock = line == wanted
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inBlock {
|
||||||
|
block = append(block, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotEmpty(t, block, "peer %s must appear in the dump", peerKey)
|
||||||
|
return block
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type TunKernelDevice struct {
|
|||||||
link *wgLink
|
link *wgLink
|
||||||
udpMuxConn net.PacketConn
|
udpMuxConn net.PacketConn
|
||||||
udpMux *udpmux.UniversalUDPMuxDefault
|
udpMux *udpmux.UniversalUDPMuxDefault
|
||||||
|
configurer WGConfigurer
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewKernelDevice(name string, address wgaddr.Address, wgPort int, key string, mtu uint16, transportNet transport.Net) *TunKernelDevice {
|
func NewKernelDevice(name string, address wgaddr.Address, wgPort int, key string, mtu uint16, transportNet transport.Net) *TunKernelDevice {
|
||||||
@@ -68,13 +69,18 @@ func (t *TunKernelDevice) Create() (WGConfigurer, error) {
|
|||||||
return nil, fmt.Errorf("set mtu: %w", err)
|
return nil, fmt.Errorf("set mtu: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
configurer := configurer.NewKernelConfigurer(t.name)
|
cfg := configurer.NewKernelConfigurer(t.name)
|
||||||
|
|
||||||
if err := configurer.ConfigureInterface(t.key, t.wgPort); err != nil {
|
if err := cfg.ConfigureInterface(t.key, t.wgPort); err != nil {
|
||||||
|
cfg.Close()
|
||||||
return nil, fmt.Errorf("error configuring interface: %s", err)
|
return nil, fmt.Errorf("error configuring interface: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return configurer, nil
|
if t.configurer != nil {
|
||||||
|
t.configurer.Close()
|
||||||
|
}
|
||||||
|
t.configurer = cfg
|
||||||
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TunKernelDevice) Up() (*udpmux.UniversalUDPMuxDefault, error) {
|
func (t *TunKernelDevice) Up() (*udpmux.UniversalUDPMuxDefault, error) {
|
||||||
@@ -132,6 +138,10 @@ func (t *TunKernelDevice) Close() error {
|
|||||||
closErr = err
|
closErr = err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if t.configurer != nil {
|
||||||
|
t.configurer.Close()
|
||||||
|
}
|
||||||
|
|
||||||
if t.udpMux != nil {
|
if t.udpMux != nil {
|
||||||
if err := t.udpMux.Close(); err != nil {
|
if err := t.udpMux.Close(); err != nil {
|
||||||
log.Debugf("failed to close udp mux: %s", err)
|
log.Debugf("failed to close udp mux: %s", err)
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func TestWGIface_UpdateAddr(t *testing.T) {
|
|||||||
ifaceName := fmt.Sprintf("utun%d", WgIntNumber+4)
|
ifaceName := fmt.Sprintf("utun%d", WgIntNumber+4)
|
||||||
addr := "100.64.0.1/8"
|
addr := "100.64.0.1/8"
|
||||||
wgPort := 33100
|
wgPort := 33100
|
||||||
newNet := stdnet.NewNet(context.Background(), testIFaceBlackList)
|
newNet := stdnet.NewNet(context.Background(), testIFaceBlackList, nil)
|
||||||
|
|
||||||
opts := WGIFaceOpts{
|
opts := WGIFaceOpts{
|
||||||
IFaceName: ifaceName,
|
IFaceName: ifaceName,
|
||||||
@@ -131,7 +131,7 @@ func getIfaceAddrs(ifaceName string) ([]net.Addr, error) {
|
|||||||
func Test_CreateInterface(t *testing.T) {
|
func Test_CreateInterface(t *testing.T) {
|
||||||
ifaceName := fmt.Sprintf("utun%d", WgIntNumber+1)
|
ifaceName := fmt.Sprintf("utun%d", WgIntNumber+1)
|
||||||
wgIP := "10.99.99.1/32"
|
wgIP := "10.99.99.1/32"
|
||||||
newNet := stdnet.NewNet(context.Background(), testIFaceBlackList)
|
newNet := stdnet.NewNet(context.Background(), testIFaceBlackList, nil)
|
||||||
opts := WGIFaceOpts{
|
opts := WGIFaceOpts{
|
||||||
IFaceName: ifaceName,
|
IFaceName: ifaceName,
|
||||||
Address: wgaddr.MustParseWGAddress(wgIP),
|
Address: wgaddr.MustParseWGAddress(wgIP),
|
||||||
@@ -171,7 +171,7 @@ func Test_Close(t *testing.T) {
|
|||||||
ifaceName := fmt.Sprintf("utun%d", WgIntNumber+2)
|
ifaceName := fmt.Sprintf("utun%d", WgIntNumber+2)
|
||||||
wgIP := "10.99.99.2/32"
|
wgIP := "10.99.99.2/32"
|
||||||
wgPort := 33100
|
wgPort := 33100
|
||||||
newNet := stdnet.NewNet(context.Background(), testIFaceBlackList)
|
newNet := stdnet.NewNet(context.Background(), testIFaceBlackList, nil)
|
||||||
|
|
||||||
opts := WGIFaceOpts{
|
opts := WGIFaceOpts{
|
||||||
IFaceName: ifaceName,
|
IFaceName: ifaceName,
|
||||||
@@ -213,7 +213,7 @@ func TestRecreation(t *testing.T) {
|
|||||||
ifaceName := fmt.Sprintf("utun%d", WgIntNumber+2)
|
ifaceName := fmt.Sprintf("utun%d", WgIntNumber+2)
|
||||||
wgIP := "10.99.99.2/32"
|
wgIP := "10.99.99.2/32"
|
||||||
wgPort := 33100
|
wgPort := 33100
|
||||||
newNet := stdnet.NewNet(context.Background(), testIFaceBlackList)
|
newNet := stdnet.NewNet(context.Background(), testIFaceBlackList, nil)
|
||||||
|
|
||||||
opts := WGIFaceOpts{
|
opts := WGIFaceOpts{
|
||||||
IFaceName: ifaceName,
|
IFaceName: ifaceName,
|
||||||
@@ -283,7 +283,7 @@ func Test_ConfigureInterface(t *testing.T) {
|
|||||||
ifaceName := fmt.Sprintf("utun%d", WgIntNumber+3)
|
ifaceName := fmt.Sprintf("utun%d", WgIntNumber+3)
|
||||||
wgIP := "10.99.99.5/30"
|
wgIP := "10.99.99.5/30"
|
||||||
wgPort := 33100
|
wgPort := 33100
|
||||||
newNet := stdnet.NewNet(context.Background(), testIFaceBlackList)
|
newNet := stdnet.NewNet(context.Background(), testIFaceBlackList, nil)
|
||||||
opts := WGIFaceOpts{
|
opts := WGIFaceOpts{
|
||||||
IFaceName: ifaceName,
|
IFaceName: ifaceName,
|
||||||
Address: wgaddr.MustParseWGAddress(wgIP),
|
Address: wgaddr.MustParseWGAddress(wgIP),
|
||||||
@@ -335,7 +335,7 @@ func Test_ConfigureInterface(t *testing.T) {
|
|||||||
func Test_UpdatePeer(t *testing.T) {
|
func Test_UpdatePeer(t *testing.T) {
|
||||||
ifaceName := fmt.Sprintf("utun%d", WgIntNumber+4)
|
ifaceName := fmt.Sprintf("utun%d", WgIntNumber+4)
|
||||||
wgIP := "10.99.99.9/30"
|
wgIP := "10.99.99.9/30"
|
||||||
newNet := stdnet.NewNet(context.Background(), testIFaceBlackList)
|
newNet := stdnet.NewNet(context.Background(), testIFaceBlackList, nil)
|
||||||
|
|
||||||
opts := WGIFaceOpts{
|
opts := WGIFaceOpts{
|
||||||
IFaceName: ifaceName,
|
IFaceName: ifaceName,
|
||||||
@@ -402,7 +402,7 @@ func Test_UpdatePeer(t *testing.T) {
|
|||||||
func Test_RemovePeer(t *testing.T) {
|
func Test_RemovePeer(t *testing.T) {
|
||||||
ifaceName := fmt.Sprintf("utun%d", WgIntNumber+4)
|
ifaceName := fmt.Sprintf("utun%d", WgIntNumber+4)
|
||||||
wgIP := "10.99.99.13/30"
|
wgIP := "10.99.99.13/30"
|
||||||
newNet := stdnet.NewNet(context.Background(), testIFaceBlackList)
|
newNet := stdnet.NewNet(context.Background(), testIFaceBlackList, nil)
|
||||||
|
|
||||||
opts := WGIFaceOpts{
|
opts := WGIFaceOpts{
|
||||||
IFaceName: ifaceName,
|
IFaceName: ifaceName,
|
||||||
@@ -463,7 +463,7 @@ func Test_ConnectPeers(t *testing.T) {
|
|||||||
peer2wgPort := 33200
|
peer2wgPort := 33200
|
||||||
|
|
||||||
keepAlive := 1 * time.Second
|
keepAlive := 1 * time.Second
|
||||||
newNet := stdnet.NewNet(context.Background(), testIFaceBlackList)
|
newNet := stdnet.NewNet(context.Background(), testIFaceBlackList, nil)
|
||||||
|
|
||||||
guid := fmt.Sprintf("{%s}", uuid.New().String())
|
guid := fmt.Sprintf("{%s}", uuid.New().String())
|
||||||
device.CustomWindowsGUIDString = strings.ToLower(guid)
|
device.CustomWindowsGUIDString = strings.ToLower(guid)
|
||||||
@@ -499,7 +499,7 @@ func Test_ConnectPeers(t *testing.T) {
|
|||||||
guid = fmt.Sprintf("{%s}", uuid.New().String())
|
guid = fmt.Sprintf("{%s}", uuid.New().String())
|
||||||
device.CustomWindowsGUIDString = strings.ToLower(guid)
|
device.CustomWindowsGUIDString = strings.ToLower(guid)
|
||||||
|
|
||||||
newNet = stdnet.NewNet(context.Background(), testIFaceBlackList)
|
newNet = stdnet.NewNet(context.Background(), testIFaceBlackList, nil)
|
||||||
|
|
||||||
optsPeer2 := WGIFaceOpts{
|
optsPeer2 := WGIFaceOpts{
|
||||||
IFaceName: peer2ifaceName,
|
IFaceName: peer2ifaceName,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ type SingleSocketUDPMux struct {
|
|||||||
|
|
||||||
// for UDP connection listen at unspecified address
|
// for UDP connection listen at unspecified address
|
||||||
localAddrsForUnspecified []net.Addr
|
localAddrsForUnspecified []net.Addr
|
||||||
|
unspecified bool
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxAddrSize = 512
|
const maxAddrSize = 512
|
||||||
@@ -157,6 +158,9 @@ func NewSingleSocketUDPMux(params Params) *SingleSocketUDPMux {
|
|||||||
if params.Logger == nil {
|
if params.Logger == nil {
|
||||||
params.Logger = getLogger()
|
params.Logger = getLogger()
|
||||||
}
|
}
|
||||||
|
if params.Net == nil {
|
||||||
|
params.Net = stdnet.NewNet(context.Background(), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
mux := &SingleSocketUDPMux{
|
mux := &SingleSocketUDPMux{
|
||||||
addressMap: map[string][]*udpMuxedConn{},
|
addressMap: map[string][]*udpMuxedConn{},
|
||||||
@@ -165,6 +169,7 @@ func NewSingleSocketUDPMux(params Params) *SingleSocketUDPMux {
|
|||||||
connsIPv6: make(map[string]*udpMuxedConn),
|
connsIPv6: make(map[string]*udpMuxedConn),
|
||||||
candidateConnMap: make(map[string]*udpMuxedConn),
|
candidateConnMap: make(map[string]*udpMuxedConn),
|
||||||
closedChan: make(chan struct{}, 1),
|
closedChan: make(chan struct{}, 1),
|
||||||
|
unspecified: isUnspecified(params.UDPConn.LocalAddr()),
|
||||||
pool: &sync.Pool{
|
pool: &sync.Pool{
|
||||||
New: func() interface{} {
|
New: func() interface{} {
|
||||||
// big enough buffer to fit both packet and address
|
// big enough buffer to fit both packet and address
|
||||||
@@ -173,7 +178,6 @@ func NewSingleSocketUDPMux(params Params) *SingleSocketUDPMux {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
mux.updateLocalAddresses()
|
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,10 +203,6 @@ func (m *SingleSocketUDPMux) updateLocalAddresses() {
|
|||||||
m.params.Logger.Errorf("LocalAddr expected IPV4 or IPV6, got %T", m.params.UDPConn.LocalAddr())
|
m.params.Logger.Errorf("LocalAddr expected IPV4 or IPV6, got %T", m.params.UDPConn.LocalAddr())
|
||||||
}
|
}
|
||||||
if len(networks) > 0 {
|
if len(networks) > 0 {
|
||||||
if m.params.Net == nil {
|
|
||||||
m.params.Net = stdnet.NewNet(context.Background(), nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
ips, err := localInterfaces(m.params.Net, m.params.InterfaceFilter, nil, networks, true)
|
ips, err := localInterfaces(m.params.Net, m.params.InterfaceFilter, nil, networks, true)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, ip := range ips {
|
for _, ip := range ips {
|
||||||
@@ -241,10 +241,7 @@ func (m *SingleSocketUDPMux) GetListenAddresses() []net.Addr {
|
|||||||
// creates the connection if an existing one can't be found
|
// creates the connection if an existing one can't be found
|
||||||
func (m *SingleSocketUDPMux) GetConn(ufrag string, addr net.Addr, candidateID string) (net.PacketConn, error) {
|
func (m *SingleSocketUDPMux) GetConn(ufrag string, addr net.Addr, candidateID string) (net.PacketConn, error) {
|
||||||
// don't check addr for mux using unspecified address
|
// don't check addr for mux using unspecified address
|
||||||
m.mu.Lock()
|
if !m.unspecified && m.params.UDPConn.LocalAddr().String() != addr.String() {
|
||||||
lenLocalAddrs := len(m.localAddrsForUnspecified)
|
|
||||||
m.mu.Unlock()
|
|
||||||
if lenLocalAddrs == 0 && m.params.UDPConn.LocalAddr().String() != addr.String() {
|
|
||||||
return nil, fmt.Errorf("invalid address %s", addr.String())
|
return nil, fmt.Errorf("invalid address %s", addr.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,3 +505,8 @@ func getLogger() logging.LeveledLogger {
|
|||||||
//fac.Writer = log.StandardLogger().Writer()
|
//fac.Writer = log.StandardLogger().Writer()
|
||||||
return fac.NewLogger("ice")
|
return fac.NewLogger("ice")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isUnspecified(addr net.Addr) bool {
|
||||||
|
udpAddr, ok := addr.(*net.UDPAddr)
|
||||||
|
return ok && udpAddr.IP.IsUnspecified()
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package udpmux
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/pion/transport/v3"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type countingNet struct {
|
||||||
|
transport.Net
|
||||||
|
calls atomic.Int64
|
||||||
|
ifaces []*transport.Interface
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *countingNet) Interfaces() ([]*transport.Interface, error) {
|
||||||
|
n.calls.Add(1)
|
||||||
|
return n.ifaces, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSingleSocketUDPMux_DoesNotDiscoverInterfaces(t *testing.T) {
|
||||||
|
conn := listenUDP(t, net.IPv4zero)
|
||||||
|
n := &countingNet{ifaces: []*transport.Interface{testInterface("eth0", net.IPv4(192, 168, 1, 10))}}
|
||||||
|
|
||||||
|
mux := NewSingleSocketUDPMux(Params{UDPConn: conn, Net: n})
|
||||||
|
t.Cleanup(func() { _ = mux.Close() })
|
||||||
|
|
||||||
|
assert.Equal(t, int64(0), n.calls.Load(), "constructing the mux must not enumerate interfaces")
|
||||||
|
|
||||||
|
addrs := mux.GetListenAddresses()
|
||||||
|
assert.Equal(t, int64(1), n.calls.Load(), "listen addresses are discovered when they are asked for")
|
||||||
|
|
||||||
|
port := conn.LocalAddr().(*net.UDPAddr).Port
|
||||||
|
require.Len(t, addrs, 1, "one address per discovered interface IP")
|
||||||
|
assert.Equal(t, fmt.Sprintf("192.168.1.10:%d", port), addrs[0].String(), "the discovered IP is paired with the socket port")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSingleSocketUDPMux_GetConnWithoutListenAddresses(t *testing.T) {
|
||||||
|
t.Run("unspecified socket accepts any address", func(t *testing.T) {
|
||||||
|
conn := listenUDP(t, net.IPv4zero)
|
||||||
|
n := &countingNet{}
|
||||||
|
mux := NewSingleSocketUDPMux(Params{UDPConn: conn, Net: n})
|
||||||
|
t.Cleanup(func() { _ = mux.Close() })
|
||||||
|
|
||||||
|
port := conn.LocalAddr().(*net.UDPAddr).Port
|
||||||
|
pc, err := mux.GetConn("ufrag", &net.UDPAddr{IP: net.IPv4(192, 168, 1, 10), Port: port}, "candidate")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotNil(t, pc, "a connection is created for a local address of an unspecified socket")
|
||||||
|
assert.Equal(t, int64(0), n.calls.Load(), "GetConn must not depend on a prior discovery")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("specific socket rejects a foreign address", func(t *testing.T) {
|
||||||
|
conn := listenUDP(t, net.IPv4(127, 0, 0, 1))
|
||||||
|
mux := NewSingleSocketUDPMux(Params{UDPConn: conn})
|
||||||
|
t.Cleanup(func() { _ = mux.Close() })
|
||||||
|
|
||||||
|
_, err := mux.GetConn("ufrag", &net.UDPAddr{IP: net.IPv4(192, 168, 1, 10), Port: 1234}, "candidate")
|
||||||
|
assert.Error(t, err, "an address the socket is not bound to must be rejected")
|
||||||
|
|
||||||
|
pc, err := mux.GetConn("ufrag", conn.LocalAddr(), "candidate")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotNil(t, pc, "the bound address is accepted")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func listenUDP(t *testing.T, ip net.IP) *net.UDPConn {
|
||||||
|
t.Helper()
|
||||||
|
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: ip, Port: 0})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInterface(name string, ip net.IP) *transport.Interface {
|
||||||
|
ifc := transport.NewInterface(net.Interface{Index: 1, MTU: 1500, Name: name, Flags: net.FlagUp | net.FlagMulticast})
|
||||||
|
ifc.AddAddress(&net.IPNet{IP: ip, Mask: net.CIDRMask(24, 32)})
|
||||||
|
return ifc
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSingleSocketUDPMux_ConcurrentListenAddressesWithDefaultNet(t *testing.T) {
|
||||||
|
conn := listenUDP(t, net.IPv4zero)
|
||||||
|
mux := NewSingleSocketUDPMux(Params{UDPConn: conn})
|
||||||
|
t.Cleanup(func() { _ = mux.Close() })
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
assert.NotEmpty(t, mux.GetListenAddresses(), "every caller gets at least the socket address")
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
!define COMP_NAME "Netbird"
|
!define COMP_NAME "Netbird"
|
||||||
!define WEB_SITE "Netbird.io"
|
!define WEB_SITE "Netbird.io"
|
||||||
!define VERSION $%APPVER%
|
!define VERSION $%APPVER%
|
||||||
!define COPYRIGHT "Netbird Authors, 2022"
|
!define COPYRIGHT "Netbird Authors, 2022-2026"
|
||||||
!define DESCRIPTION "Connect your devices into a secure WireGuard-based overlay network with SSO, MFA, and granular access controls."
|
!define DESCRIPTION "Connect your devices into a secure WireGuard-based overlay network with SSO, MFA, and granular access controls."
|
||||||
!define INSTALLER_NAME "netbird-installer.exe"
|
!define INSTALLER_NAME "netbird-installer.exe"
|
||||||
!define MAIN_APP_EXE "Netbird"
|
!define MAIN_APP_EXE "Netbird"
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTokenInfoMatchesAccount(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
token TokenInfo
|
||||||
|
hint string
|
||||||
|
match bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "same account",
|
||||||
|
token: TokenInfo{EmailClaim: "user@example.com"},
|
||||||
|
hint: "user@example.com",
|
||||||
|
match: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "different account",
|
||||||
|
token: TokenInfo{EmailClaim: "other@example.com"},
|
||||||
|
hint: "user@example.com",
|
||||||
|
match: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "case differences are the same account",
|
||||||
|
token: TokenInfo{EmailClaim: "User@Example.com"},
|
||||||
|
hint: "user@example.com",
|
||||||
|
match: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no hint leaves the choice to the IdP",
|
||||||
|
token: TokenInfo{EmailClaim: "other@example.com"},
|
||||||
|
hint: "",
|
||||||
|
match: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "token without an email claim is not judged",
|
||||||
|
token: TokenInfo{EmailClaim: ""},
|
||||||
|
hint: "user@example.com",
|
||||||
|
match: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "name fallback does not trigger matching",
|
||||||
|
token: TokenInfo{Email: "Some One"},
|
||||||
|
hint: "user@example.com",
|
||||||
|
match: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tc.match, tc.token.MatchesAccount(tc.hint))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseEmailFromIDToken(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
claims map[string]interface{}
|
||||||
|
wantValue string
|
||||||
|
wantFromEmail bool
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "email claim",
|
||||||
|
claims: map[string]interface{}{"email": "user@example.com", "name": "Some One"},
|
||||||
|
wantValue: "user@example.com",
|
||||||
|
wantFromEmail: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "name fallback",
|
||||||
|
claims: map[string]interface{}{"name": "Some One"},
|
||||||
|
wantValue: "Some One",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "neither claim present",
|
||||||
|
claims: map[string]interface{}{"sub": "abc"},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
value, fromEmailClaim, err := parseEmailFromIDToken(idTokenWithClaims(t, tc.claims))
|
||||||
|
if tc.wantErr {
|
||||||
|
require.Error(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tc.wantValue, value)
|
||||||
|
assert.Equal(t, tc.wantFromEmail, fromEmailClaim)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryFlowForAccountUnsupportedFlow(t *testing.T) {
|
||||||
|
assert.Nil(t, RetryFlowForAccount(&DeviceAuthorizationFlow{}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func idTokenWithClaims(t *testing.T, claims map[string]interface{}) string {
|
||||||
|
t.Helper()
|
||||||
|
payload, err := json.Marshal(claims)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return "header." + base64.RawURLEncoding.EncodeToString(payload) + ".signature"
|
||||||
|
}
|
||||||
@@ -103,7 +103,7 @@ func (a *Auth) IsSSOSupported(ctx context.Context) (bool, error) {
|
|||||||
|
|
||||||
err := a.withRetry(ctx, func(client *mgm.GrpcClient) error {
|
err := a.withRetry(ctx, func(client *mgm.GrpcClient) error {
|
||||||
// Try PKCE flow first
|
// Try PKCE flow first
|
||||||
_, err := a.getPKCEFlow(client)
|
_, err := a.getPKCEFlow(client, false)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
supportsSSO = true
|
supportsSSO = true
|
||||||
return nil
|
return nil
|
||||||
@@ -136,9 +136,13 @@ func (a *Auth) IsSSOSupported(ctx context.Context) (bool, error) {
|
|||||||
return supportsSSO, err
|
return supportsSSO, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetOAuthFlow returns an OAuth flow (PKCE or Device) using the existing management connection
|
// GetOAuthFlow returns an OAuth flow (PKCE or Device) using the existing management connection.
|
||||||
// This avoids creating a new connection to the management server
|
// This avoids creating a new connection to the management server.
|
||||||
func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool, hint string) (OAuthFlow, error) {
|
//
|
||||||
|
// sessionExtend marks the flow as renewing an existing peer's session rather than
|
||||||
|
// logging one in; the server needs it to rule out a silent authorization that the
|
||||||
|
// IdP could answer from another account. See PKCEAuthorizationFlowRequest.
|
||||||
|
func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool, sessionExtend bool, hint string) (OAuthFlow, error) {
|
||||||
var flow OAuthFlow
|
var flow OAuthFlow
|
||||||
|
|
||||||
err := a.withRetry(ctx, func(client *mgm.GrpcClient) error {
|
err := a.withRetry(ctx, func(client *mgm.GrpcClient) error {
|
||||||
@@ -153,7 +157,7 @@ func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool, hint stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try PKCE flow first
|
// Try PKCE flow first
|
||||||
pkceFlow, err := a.getPKCEFlow(client)
|
pkceFlow, err := a.getPKCEFlow(client, sessionExtend)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If PKCE not supported, try Device flow
|
// If PKCE not supported, try Device flow
|
||||||
if s, ok := status.FromError(err); ok && (s.Code() == codes.NotFound || s.Code() == codes.Unimplemented) {
|
if s, ok := status.FromError(err); ok && (s.Code() == codes.NotFound || s.Code() == codes.Unimplemented) {
|
||||||
@@ -240,8 +244,8 @@ func (a *Auth) Login(ctx context.Context, setupKey string, jwtToken string) (err
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getPKCEFlow retrieves PKCE authorization flow configuration and creates a flow instance
|
// getPKCEFlow retrieves PKCE authorization flow configuration and creates a flow instance
|
||||||
func (a *Auth) getPKCEFlow(client *mgm.GrpcClient) (*PKCEAuthorizationFlow, error) {
|
func (a *Auth) getPKCEFlow(client *mgm.GrpcClient, sessionExtend bool) (*PKCEAuthorizationFlow, error) {
|
||||||
protoFlow, err := client.GetPKCEAuthorizationFlow()
|
protoFlow, err := client.GetPKCEAuthorizationFlow(sessionExtend)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if s, ok := status.FromError(err); ok && s.Code() == codes.NotFound {
|
if s, ok := status.FromError(err); ok && s.Code() == codes.NotFound {
|
||||||
log.Warnf("server couldn't find pkce flow, contact admin: %v", err)
|
log.Warnf("server couldn't find pkce flow, contact admin: %v", err)
|
||||||
|
|||||||
@@ -308,10 +308,13 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn
|
|||||||
// callers store to send back as the login_hint. Without it a client
|
// callers store to send back as the login_hint. Without it a client
|
||||||
// driven through the device flow — Android TV and tvOS — never binds
|
// driven through the device flow — Android TV and tvOS — never binds
|
||||||
// an account to its profile and every later login goes out blind.
|
// an account to its profile and every later login goes out blind.
|
||||||
if email, err := parseEmailFromIDToken(tokenInfo.IDToken); err != nil {
|
if email, fromEmailClaim, err := parseEmailFromIDToken(tokenInfo.IDToken); err != nil {
|
||||||
log.Warnf("failed to parse email from ID token: %v", err)
|
log.Warnf("failed to parse email from ID token: %v", err)
|
||||||
} else {
|
} else {
|
||||||
tokenInfo.Email = email
|
tokenInfo.Email = email
|
||||||
|
if fromEmailClaim {
|
||||||
|
tokenInfo.EmailClaim = email
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("device flow: user authorization confirmed after %d polls in %s", polls, time.Since(start).Round(time.Second))
|
log.Infof("device flow: user authorization confirmed after %d polls in %s", polls, time.Since(start).Round(time.Second))
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
@@ -25,6 +26,14 @@ type HTTPClient interface {
|
|||||||
Do(req *http.Request) (*http.Response, error)
|
Do(req *http.Request) (*http.Response, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// accountPromptForcer is implemented by the PKCE flow only. The device code
|
||||||
|
// flow has no equivalent: RFC 8628 defines no prompt parameter, and the user
|
||||||
|
// confirms the code on a page that shows which account signs in, so a silent
|
||||||
|
// wrong-account answer is not the failure mode there.
|
||||||
|
type accountPromptForcer interface {
|
||||||
|
ForceAccountPrompt()
|
||||||
|
}
|
||||||
|
|
||||||
// AuthFlowInfo holds information for the OAuth 2.0 authorization flow
|
// AuthFlowInfo holds information for the OAuth 2.0 authorization flow
|
||||||
type AuthFlowInfo struct { //nolint:revive
|
type AuthFlowInfo struct { //nolint:revive
|
||||||
DeviceCode string `json:"device_code"`
|
DeviceCode string `json:"device_code"`
|
||||||
@@ -49,6 +58,23 @@ type TokenInfo struct {
|
|||||||
ExpiresIn int `json:"expires_in"`
|
ExpiresIn int `json:"expires_in"`
|
||||||
UseIDToken bool `json:"-"`
|
UseIDToken bool `json:"-"`
|
||||||
Email string `json:"-"`
|
Email string `json:"-"`
|
||||||
|
EmailClaim string `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchesAccount reports whether the token belongs to the account a profile is
|
||||||
|
// bound to. A hint the IdP could not have acted on — no hint stored, or a token
|
||||||
|
// that carried no email claim — is reported as a match: the check exists to catch a
|
||||||
|
// login answered from the wrong account, not to block one it cannot judge.
|
||||||
|
//
|
||||||
|
// The comparison is case-insensitive. Local-parts are case-sensitive per RFC
|
||||||
|
// 5321, but no IdP in practice issues two accounts differing only in case, and
|
||||||
|
// an IdP that echoes a differently-cased address would otherwise fail every
|
||||||
|
// login.
|
||||||
|
func (t TokenInfo) MatchesAccount(hint string) bool {
|
||||||
|
if hint == "" || t.EmailClaim == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.EqualFold(t.EmailClaim, hint)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTokenToUse returns either the access or id token based on UseIDToken field
|
// GetTokenToUse returns either the access or id token based on UseIDToken field
|
||||||
@@ -63,19 +89,22 @@ func shouldUseDeviceFlow(force bool, isUnixDesktopClient bool) bool {
|
|||||||
return force || (runtime.GOOS == "linux" || runtime.GOOS == "freebsd") && !isUnixDesktopClient
|
return force || (runtime.GOOS == "linux" || runtime.GOOS == "freebsd") && !isUnixDesktopClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewOAuthFlow initializes and returns the appropriate OAuth flow based on the management configuration
|
// NewOAuthFlow initializes and returns the appropriate OAuth flow based on the management configuration.
|
||||||
//
|
//
|
||||||
// It starts by initializing the PKCE.If this process fails, it resorts to the Device Code Flow,
|
// It starts by initializing the PKCE.If this process fails, it resorts to the Device Code Flow,
|
||||||
// and if that also fails, the authentication process is deemed unsuccessful
|
// and if that also fails, the authentication process is deemed unsuccessful
|
||||||
//
|
//
|
||||||
// On Linux distros without desktop environment support, it only tries to initialize the Device Code Flow
|
// On Linux distros without desktop environment support, it only tries to initialize the Device Code Flow.
|
||||||
// forceDeviceCodeFlow can be used to skip PKCE and go directly to Device Code Flow (e.g., for Android TV)
|
// forceDeviceCodeFlow can be used to skip PKCE and go directly to Device Code Flow (e.g., for Android TV).
|
||||||
func NewOAuthFlow(ctx context.Context, config *profilemanager.Config, isUnixDesktopClient bool, forceDeviceCodeFlow bool, hint string) (OAuthFlow, error) {
|
//
|
||||||
|
// sessionExtend marks the flow as renewing an existing peer's session rather than
|
||||||
|
// logging one in. See PKCEAuthorizationFlowRequest for what the server makes of it.
|
||||||
|
func NewOAuthFlow(ctx context.Context, config *profilemanager.Config, isUnixDesktopClient bool, forceDeviceCodeFlow bool, hint string, sessionExtend bool) (OAuthFlow, error) {
|
||||||
if shouldUseDeviceFlow(forceDeviceCodeFlow, isUnixDesktopClient) {
|
if shouldUseDeviceFlow(forceDeviceCodeFlow, isUnixDesktopClient) {
|
||||||
return authenticateWithDeviceCodeFlow(ctx, config, hint)
|
return authenticateWithDeviceCodeFlow(ctx, config, hint)
|
||||||
}
|
}
|
||||||
|
|
||||||
pkceFlow, err := authenticateWithPKCEFlow(ctx, config, hint)
|
pkceFlow, err := authenticateWithPKCEFlow(ctx, config, hint, sessionExtend)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debugf("failed to initialize pkce authentication with error: %v\n", err)
|
log.Debugf("failed to initialize pkce authentication with error: %v\n", err)
|
||||||
log.Debug("falling back to device code flow")
|
log.Debug("falling back to device code flow")
|
||||||
@@ -85,14 +114,14 @@ func NewOAuthFlow(ctx context.Context, config *profilemanager.Config, isUnixDesk
|
|||||||
}
|
}
|
||||||
|
|
||||||
// authenticateWithPKCEFlow initializes the Proof Key for Code Exchange flow auth flow
|
// authenticateWithPKCEFlow initializes the Proof Key for Code Exchange flow auth flow
|
||||||
func authenticateWithPKCEFlow(ctx context.Context, config *profilemanager.Config, hint string) (OAuthFlow, error) {
|
func authenticateWithPKCEFlow(ctx context.Context, config *profilemanager.Config, hint string, sessionExtend bool) (OAuthFlow, error) {
|
||||||
authClient, err := NewAuth(ctx, config.PrivateKey, config.ManagementURL, config)
|
authClient, err := NewAuth(ctx, config.PrivateKey, config.ManagementURL, config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create auth client: %v", err)
|
return nil, fmt.Errorf("failed to create auth client: %v", err)
|
||||||
}
|
}
|
||||||
defer authClient.Close()
|
defer authClient.Close()
|
||||||
|
|
||||||
pkceFlowInfo, err := authClient.getPKCEFlow(authClient.client)
|
pkceFlowInfo, err := authClient.getPKCEFlow(authClient.client, sessionExtend)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("getting pkce authorization flow info failed with error: %v", err)
|
return nil, fmt.Errorf("getting pkce authorization flow info failed with error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -129,3 +158,21 @@ func authenticateWithDeviceCodeFlow(ctx context.Context, config *profilemanager.
|
|||||||
|
|
||||||
return deviceFlowInfo, nil
|
return deviceFlowInfo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RetryFlowForAccount returns a flow that asks the IdP to re-authenticate, for
|
||||||
|
// a login answered with an account other than the one hinted. Returns nil when
|
||||||
|
// the flow cannot ask — the caller then proceeds with the token it has.
|
||||||
|
//
|
||||||
|
// Proceeding rather than failing is deliberate. The hint is an email that may
|
||||||
|
// simply have changed since it was stored, and refusing the login would lock a
|
||||||
|
// user out of their own profile over a rename. The retry gives the account a
|
||||||
|
// chance to be corrected; the server still rejects a token that does not own
|
||||||
|
// the peer.
|
||||||
|
func RetryFlowForAccount(flow OAuthFlow) OAuthFlow {
|
||||||
|
forcer, ok := flow.(accountPromptForcer)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
forcer.ForceAccountPrompt()
|
||||||
|
return flow
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,10 +87,11 @@ func validatePKCEConfig(config *PKCEAuthProviderConfig) error {
|
|||||||
// PKCEAuthorizationFlow implements the OAuthFlow interface for
|
// PKCEAuthorizationFlow implements the OAuthFlow interface for
|
||||||
// the Authorization Code Flow with PKCE.
|
// the Authorization Code Flow with PKCE.
|
||||||
type PKCEAuthorizationFlow struct {
|
type PKCEAuthorizationFlow struct {
|
||||||
providerConfig PKCEAuthProviderConfig
|
providerConfig PKCEAuthProviderConfig
|
||||||
state string
|
state string
|
||||||
codeVerifier string
|
codeVerifier string
|
||||||
oAuthConfig *oauth2.Config
|
oAuthConfig *oauth2.Config
|
||||||
|
forceAccountPrompt bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPKCEAuthorizationFlow returns new PKCE authorization code flow.
|
// NewPKCEAuthorizationFlow returns new PKCE authorization code flow.
|
||||||
@@ -153,11 +154,16 @@ func (p *PKCEAuthorizationFlow) RequestAuthInfo(ctx context.Context) (AuthFlowIn
|
|||||||
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
|
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
|
||||||
oauth2.SetAuthURLParam("audience", p.providerConfig.Audience),
|
oauth2.SetAuthURLParam("audience", p.providerConfig.Audience),
|
||||||
}
|
}
|
||||||
|
forceAccountPrompt := p.forceAccountPrompt
|
||||||
|
p.forceAccountPrompt = false
|
||||||
|
|
||||||
if !p.providerConfig.DisablePromptLogin {
|
if !p.providerConfig.DisablePromptLogin {
|
||||||
switch p.providerConfig.LoginFlag {
|
switch {
|
||||||
case common.LoginFlagPromptLogin:
|
case forceAccountPrompt:
|
||||||
params = append(params, oauth2.SetAuthURLParam("prompt", "login"))
|
params = append(params, oauth2.SetAuthURLParam("prompt", "login"))
|
||||||
case common.LoginFlagMaxAge0:
|
case p.providerConfig.LoginFlag == common.LoginFlagPromptLogin:
|
||||||
|
params = append(params, oauth2.SetAuthURLParam("prompt", "login"))
|
||||||
|
case p.providerConfig.LoginFlag == common.LoginFlagMaxAge0:
|
||||||
params = append(params, oauth2.SetAuthURLParam("max_age", "0"))
|
params = append(params, oauth2.SetAuthURLParam("max_age", "0"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,6 +184,20 @@ func (p *PKCEAuthorizationFlow) SetLoginHint(hint string) {
|
|||||||
p.providerConfig.LoginHint = hint
|
p.providerConfig.LoginHint = hint
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ForceAccountPrompt makes the next authorization request ask the IdP to
|
||||||
|
// re-authenticate instead of answering from the session it already holds. Used
|
||||||
|
// to retry a login that came back for an account other than the one hinted.
|
||||||
|
//
|
||||||
|
// The next RequestAuthInfo consumes the flag, so a flow that outlives its retry
|
||||||
|
// goes back to the configured behaviour instead of re-authenticating forever.
|
||||||
|
//
|
||||||
|
// DisablePromptLogin still wins: it is set for IdPs that break on prompt=login,
|
||||||
|
// where retrying with it would replace a wrong-account login with one that
|
||||||
|
// cannot complete at all.
|
||||||
|
func (p *PKCEAuthorizationFlow) ForceAccountPrompt() {
|
||||||
|
p.forceAccountPrompt = true
|
||||||
|
}
|
||||||
|
|
||||||
// WaitToken waits for the OAuth token in the PKCE Authorization Flow.
|
// WaitToken waits for the OAuth token in the PKCE Authorization Flow.
|
||||||
// It starts an HTTP server to receive the OAuth token callback and waits for the token or an error.
|
// It starts an HTTP server to receive the OAuth token callback and waits for the token or an error.
|
||||||
// Once the token is received, it is converted to TokenInfo and validated before returning.
|
// Once the token is received, it is converted to TokenInfo and validated before returning.
|
||||||
@@ -310,49 +330,48 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo,
|
|||||||
return TokenInfo{}, fmt.Errorf("authentication failed: invalid access token - %w", err)
|
return TokenInfo{}, fmt.Errorf("authentication failed: invalid access token - %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
email, err := parseEmailFromIDToken(tokenInfo.IDToken)
|
email, fromEmailClaim, err := parseEmailFromIDToken(tokenInfo.IDToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("failed to parse email from ID token: %v", err)
|
log.Warnf("failed to parse email from ID token: %v", err)
|
||||||
} else {
|
} else {
|
||||||
tokenInfo.Email = email
|
tokenInfo.Email = email
|
||||||
|
if fromEmailClaim {
|
||||||
|
tokenInfo.EmailClaim = email
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return tokenInfo, nil
|
return tokenInfo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseEmailFromIDToken extracts the email (or name) claim from an ID token
|
// parseEmailFromIDToken extracts the email (or name) claim from an ID token
|
||||||
// without verifying its signature. The value is best-effort and used only as a
|
// without verifying its signature. The value is best-effort: it prefills the
|
||||||
// UX convenience (login hint prefill and display); it never drives an
|
// login hint and is displayed. Account matching (see MatchesAccount) only uses
|
||||||
// authorization decision. The authoritative identity is established server-side
|
// it when it came from the email claim, which fromEmailClaim reports. It never
|
||||||
// from the signature-verified token.
|
// grants anything — the authoritative identity is established server-side from
|
||||||
func parseEmailFromIDToken(token string) (string, error) {
|
// the signature-verified token.
|
||||||
|
func parseEmailFromIDToken(token string) (value string, fromEmailClaim bool, err error) {
|
||||||
parts := strings.Split(token, ".")
|
parts := strings.Split(token, ".")
|
||||||
if len(parts) < 2 {
|
if len(parts) < 2 {
|
||||||
return "", fmt.Errorf("invalid token format")
|
return "", false, fmt.Errorf("invalid token format")
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := base64.RawURLEncoding.DecodeString(parts[1])
|
data, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to decode payload: %w", err)
|
return "", false, fmt.Errorf("failed to decode payload: %w", err)
|
||||||
}
|
}
|
||||||
var claims map[string]interface{}
|
var claims map[string]interface{}
|
||||||
if err := json.Unmarshal(data, &claims); err != nil {
|
if err := json.Unmarshal(data, &claims); err != nil {
|
||||||
return "", fmt.Errorf("json unmarshal error: %w", err)
|
return "", false, fmt.Errorf("json unmarshal error: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var email string
|
if email, ok := claims["email"].(string); ok {
|
||||||
if emailValue, ok := claims["email"].(string); ok {
|
return email, true, nil
|
||||||
email = emailValue
|
}
|
||||||
} else {
|
if name, ok := claims["name"].(string); ok {
|
||||||
val, ok := claims["name"].(string)
|
return name, false, nil
|
||||||
if ok {
|
|
||||||
email = val
|
|
||||||
} else {
|
|
||||||
return "", fmt.Errorf("email or name field not found in token payload")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return email, nil
|
return "", false, fmt.Errorf("email or name field not found in token payload")
|
||||||
}
|
}
|
||||||
|
|
||||||
func createCodeChallenge(codeVerifier string) string {
|
func createCodeChallenge(codeVerifier string) string {
|
||||||
|
|||||||
@@ -76,6 +76,32 @@ func TestPromptLogin(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestForceAccountPromptAppliesOnlyToTheRetry(t *testing.T) {
|
||||||
|
config := PKCEAuthProviderConfig{
|
||||||
|
ClientID: "test-client-id",
|
||||||
|
Audience: "test-audience",
|
||||||
|
TokenEndpoint: "https://test-token-endpoint.com/token",
|
||||||
|
Scope: "openid email profile",
|
||||||
|
AuthorizationEndpoint: "https://test-auth-endpoint.com/authorize",
|
||||||
|
RedirectURLs: []string{"http://127.0.0.1:33992/"},
|
||||||
|
UseIDToken: true,
|
||||||
|
LoginFlag: mgm.LoginFlagNone,
|
||||||
|
}
|
||||||
|
pkce, err := NewPKCEAuthorizationFlow(config)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
pkce.ForceAccountPrompt()
|
||||||
|
|
||||||
|
retry, err := pkce.RequestAuthInfo(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, retry.VerificationURIComplete, "prompt=login")
|
||||||
|
|
||||||
|
next, err := pkce.RequestAuthInfo(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotContains(t, next.VerificationURIComplete, "prompt=login",
|
||||||
|
"the forced prompt outlived the retry it was armed for")
|
||||||
|
}
|
||||||
|
|
||||||
func TestIsPortInExcludedRange(t *testing.T) {
|
func TestIsPortInExcludedRange(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -4,12 +4,20 @@
|
|||||||
// T-WarningLead notification and a dismiss-gated T-FinalWarningLead
|
// T-WarningLead notification and a dismiss-gated T-FinalWarningLead
|
||||||
// fallback dialog.
|
// fallback dialog.
|
||||||
//
|
//
|
||||||
|
// The deadline is an absolute wall-clock instant, so the watcher compares
|
||||||
|
// it against the wall clock on a ticker rather than arming a relative
|
||||||
|
// timer for it. A relative timer runs on the monotonic clock, which does
|
||||||
|
// not advance while a device is suspended: it fires once that much awake
|
||||||
|
// time has passed, which can be long after the deadline, and nothing
|
||||||
|
// re-evaluates when the device wakes up. Polling makes every tick after a
|
||||||
|
// resume (or after an NTP correction) see the real remaining time.
|
||||||
|
//
|
||||||
// The watcher is idempotent: Update may be called as often as the network
|
// The watcher is idempotent: Update may be called as often as the network
|
||||||
// map snapshots arrive. Repeating the same deadline is a no-op; a new
|
// map snapshots arrive. Repeating the same deadline is a no-op; a new
|
||||||
// deadline reschedules the timers and arms a fresh warning cycle.
|
// deadline starts a fresh warning cycle.
|
||||||
//
|
//
|
||||||
// Warning firing is edge-detected. Each unique deadline value fires each
|
// Warning firing is edge-detected. Each unique deadline value publishes
|
||||||
// warning callback at most once.
|
// each warning at most once.
|
||||||
package sessionwatch
|
package sessionwatch
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -28,9 +36,15 @@ const (
|
|||||||
|
|
||||||
// maxDeadlineHorizon caps how far in the future an accepted deadline
|
// maxDeadlineHorizon caps how far in the future an accepted deadline
|
||||||
// can sit. A timestamp beyond this is almost certainly a protocol
|
// can sit. A timestamp beyond this is almost certainly a protocol
|
||||||
// glitch, and silently arming a 100-year timer would hide the bug.
|
// glitch, and silently tracking a 100-year deadline would hide the bug.
|
||||||
maxDeadlineHorizon = 10 * 365 * 24 * time.Hour
|
maxDeadlineHorizon = 10 * 365 * 24 * time.Hour
|
||||||
|
|
||||||
|
// defaultEvalInterval is how often the tracked deadline is compared
|
||||||
|
// against the wall clock. The leads are minutes, so a coarse tick costs
|
||||||
|
// nothing in accuracy and keeps the wakeup cheap on battery-powered
|
||||||
|
// devices.
|
||||||
|
defaultEvalInterval = 10 * time.Second
|
||||||
|
|
||||||
// WarningLead is how far before expiry the first (interactive)
|
// WarningLead is how far before expiry the first (interactive)
|
||||||
// warning fires. Drives the T-10 OS notification with
|
// warning fires. Drives the T-10 OS notification with
|
||||||
// Extend/Dismiss actions.
|
// Extend/Dismiss actions.
|
||||||
@@ -90,18 +104,23 @@ type StatusRecorder interface {
|
|||||||
// fallback T-FinalWarningLead dialog (suppressed when the user dismissed
|
// fallback T-FinalWarningLead dialog (suppressed when the user dismissed
|
||||||
// the first one for the same deadline). Safe for concurrent use.
|
// the first one for the same deadline). Safe for concurrent use.
|
||||||
type Watcher struct {
|
type Watcher struct {
|
||||||
lead time.Duration
|
lead time.Duration
|
||||||
finalLead time.Duration
|
finalLead time.Duration
|
||||||
|
interval time.Duration
|
||||||
|
deadlineOnly bool // record the deadline, leave the warnings to the caller
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
current time.Time
|
current time.Time
|
||||||
timer *time.Timer
|
firedAt time.Time // deadline value the T-WarningLead warning last published for
|
||||||
finalTimer *time.Timer
|
finalFiredAt time.Time // deadline value the T-FinalWarningLead warning last published for
|
||||||
firedAt time.Time // deadline value the T-WarningLead callback last fired against
|
dismissedAt time.Time // deadline value the user dismissed via Dismiss(); gates the final warning
|
||||||
finalFiredAt time.Time // deadline value the T-FinalWarningLead callback last fired against
|
announcedAt time.Time // deadline value the recorder has been told about; gates publishing
|
||||||
dismissedAt time.Time // deadline value the user dismissed via Dismiss(); gates fireFinal
|
|
||||||
closed bool
|
closed bool
|
||||||
recorder StatusRecorder
|
recorder StatusRecorder
|
||||||
|
nowFn func() time.Time
|
||||||
|
stop chan struct{} // closed to stop the evaluation loop; nil while it is not running
|
||||||
|
done chan struct{} // closed by the loop on its way out
|
||||||
|
wake chan struct{} // buffered nudge asking the loop to evaluate before its next tick
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a watcher with the package defaults WarningLead and
|
// New returns a watcher with the package defaults WarningLead and
|
||||||
@@ -113,27 +132,40 @@ func New(recorder StatusRecorder) *Watcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewWithLeads returns a watcher with custom lead times. Useful for tests.
|
// NewWithLeads returns a watcher with custom lead times. Useful for tests.
|
||||||
// final must be strictly less than lead; otherwise both timers fire in the
|
// final must be strictly less than lead; otherwise the final warning takes
|
||||||
// wrong order or simultaneously and the UI flow breaks. A zero final lead
|
// over the whole warning window and the interactive notification never
|
||||||
// disables the final-warning timer entirely (see armTimerLocked) so a
|
// shows. A zero final lead disables the final warning entirely (see
|
||||||
// millisecond-scale deadline doesn't flush both timers in one tick.
|
// evaluate), leaving the interactive one as the only warning.
|
||||||
func NewWithLeads(lead, final time.Duration, recorder StatusRecorder) *Watcher {
|
func NewWithLeads(lead, final time.Duration, recorder StatusRecorder) *Watcher {
|
||||||
return &Watcher{
|
return &Watcher{
|
||||||
lead: lead,
|
lead: lead,
|
||||||
finalLead: final,
|
finalLead: final,
|
||||||
|
interval: defaultEvalInterval,
|
||||||
recorder: recorder,
|
recorder: recorder,
|
||||||
|
nowFn: time.Now,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewDeadlineOnly returns a watcher that validates and records deadlines
|
||||||
|
// but publishes no warnings about them, and runs no evaluation loop to
|
||||||
|
// decide. Used where the deadline is handed on to something that schedules
|
||||||
|
// the warnings itself, such as the Android app.
|
||||||
|
func NewDeadlineOnly(recorder StatusRecorder) *Watcher {
|
||||||
|
w := New(recorder)
|
||||||
|
w.deadlineOnly = true
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
// Update sets the latest deadline. Pass the zero time to clear (e.g. when
|
// Update sets the latest deadline. Pass the zero time to clear (e.g. when
|
||||||
// a Sync push from the server omits the field because login expiration
|
// a Sync push from the server omits the field because login expiration
|
||||||
// was disabled).
|
// was disabled).
|
||||||
//
|
//
|
||||||
// Same-value updates are no-ops. A different non-zero value cancels any
|
// Same-value updates are no-ops. A different non-zero value resets the
|
||||||
// pending timer, resets the "already fired" guards, and — when the
|
// "already fired" guards and starts a fresh warning cycle, evaluated
|
||||||
// deadline lies in the future — arms fresh warning timers. A deadline
|
// immediately so a deadline that already sits inside a warning window
|
||||||
// already in the past (within maxPastHorizon) is recorded as-is with no
|
// warns without waiting for the next tick. A deadline already in the past
|
||||||
// timers: the session has expired and consumers render it that way.
|
// (within maxPastHorizon) is recorded as-is and warns nothing: the session
|
||||||
|
// has expired and consumers render it that way.
|
||||||
//
|
//
|
||||||
// Returns one of the sentinel Err* values when the deadline fails the
|
// Returns one of the sentinel Err* values when the deadline fails the
|
||||||
// sanity checks (pre-epoch, far future, or past beyond maxPastHorizon).
|
// sanity checks (pre-epoch, far future, or past beyond maxPastHorizon).
|
||||||
@@ -153,7 +185,7 @@ func (w *Watcher) Update(deadline time.Time) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := w.nowFn()
|
||||||
switch {
|
switch {
|
||||||
case deadline.Before(time.Unix(0, 0)):
|
case deadline.Before(time.Unix(0, 0)):
|
||||||
w.clearLocked()
|
w.clearLocked()
|
||||||
@@ -171,18 +203,22 @@ func (w *Watcher) Update(deadline time.Time) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
w.stopTimerLocked()
|
|
||||||
w.current = deadline
|
w.current = deadline
|
||||||
// Reset every per-deadline guard so a refreshed deadline arms a fresh
|
// Reset every per-deadline guard so a refreshed deadline starts a fresh
|
||||||
// warning cycle: both edge triggers and the user Dismiss decision
|
// warning cycle: both edge triggers and the user Dismiss decision
|
||||||
// (the user agreed to the old deadline expiring; a new deadline
|
// (the user agreed to the old deadline expiring; a new deadline
|
||||||
// restarts the contract).
|
// restarts the contract).
|
||||||
w.firedAt = time.Time{}
|
w.firedAt = time.Time{}
|
||||||
w.finalFiredAt = time.Time{}
|
w.finalFiredAt = time.Time{}
|
||||||
w.dismissedAt = time.Time{}
|
w.dismissedAt = time.Time{}
|
||||||
|
w.announcedAt = time.Time{}
|
||||||
|
|
||||||
if deadline.After(now) {
|
// Poll every accepted deadline, including one that reads as already
|
||||||
w.armTimerLocked(deadline)
|
// expired: the clock may be running ahead of real time and get
|
||||||
|
// corrected later, and evaluate ignores a deadline that has genuinely
|
||||||
|
// passed anyway.
|
||||||
|
if !w.deadlineOnly {
|
||||||
|
w.startPollLocked()
|
||||||
}
|
}
|
||||||
recorder := w.recorder
|
recorder := w.recorder
|
||||||
w.mu.Unlock()
|
w.mu.Unlock()
|
||||||
@@ -190,6 +226,28 @@ func (w *Watcher) Update(deadline time.Time) error {
|
|||||||
recorder.SetSessionExpiresAt(deadline)
|
recorder.SetSessionExpiresAt(deadline)
|
||||||
}
|
}
|
||||||
log.Infof("auth session deadline set to: %s (in %s)", deadline.Format(time.RFC3339), time.Until(deadline).Round(time.Second))
|
log.Infof("auth session deadline set to: %s (in %s)", deadline.Format(time.RFC3339), time.Until(deadline).Round(time.Second))
|
||||||
|
|
||||||
|
// Open the gate only once the recorder knows the new deadline, so a
|
||||||
|
// warning that refers to it can never reach consumers before the state
|
||||||
|
// change itself. A tick landing in between finds the gate shut.
|
||||||
|
w.mu.Lock()
|
||||||
|
if w.closed || !w.current.Equal(deadline) {
|
||||||
|
w.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
w.announcedAt = deadline
|
||||||
|
wake := w.wake
|
||||||
|
w.mu.Unlock()
|
||||||
|
|
||||||
|
// Hand the evaluation to the loop rather than running it here, so a
|
||||||
|
// deadline that already sits inside a warning window is published at
|
||||||
|
// once without this goroutine ever touching the recorder.
|
||||||
|
if wake != nil {
|
||||||
|
select {
|
||||||
|
case wake <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,9 +260,9 @@ func (w *Watcher) Deadline() time.Time {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Dismiss records the user's "Dismiss" action against the current deadline
|
// Dismiss records the user's "Dismiss" action against the current deadline
|
||||||
// and suppresses the upcoming final-warning callback for that deadline.
|
// and suppresses the final warning for that deadline. Idempotent: repeated
|
||||||
// Idempotent: repeated calls are no-ops. A subsequent Update with a fresh
|
// calls are no-ops. A subsequent Update with a fresh deadline resets the
|
||||||
// deadline resets the dismissal so the final-warning cycle re-arms.
|
// dismissal so the final-warning cycle starts over.
|
||||||
//
|
//
|
||||||
// No-op when the watcher holds no deadline or has been closed.
|
// No-op when the watcher holds no deadline or has been closed.
|
||||||
func (w *Watcher) Dismiss() {
|
func (w *Watcher) Dismiss() {
|
||||||
@@ -217,35 +275,48 @@ func (w *Watcher) Dismiss() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.dismissedAt = w.current
|
w.dismissedAt = w.current
|
||||||
// Cancel the armed final-warning timer eagerly. fireFinal would also
|
|
||||||
// gate on dismissedAt, but stopping the timer avoids a wakeup with
|
|
||||||
// nothing to do and makes the intent visible.
|
|
||||||
if w.finalTimer != nil {
|
|
||||||
w.finalTimer.Stop()
|
|
||||||
w.finalTimer = nil
|
|
||||||
}
|
|
||||||
log.Infof("auth session final-warning dismissed for deadline %s", w.current.Format(time.RFC3339))
|
log.Infof("auth session final-warning dismissed for deadline %s", w.current.Format(time.RFC3339))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close stops any pending timer. Update calls after Close are ignored.
|
// Close stops the evaluation loop and waits for it to exit. Update calls
|
||||||
// The recorder keeps its deadline: the watcher is engine-scoped and closes
|
// after Close are ignored. The recorder keeps its deadline: the watcher is
|
||||||
// on every engine restart (network change, sleep/wake, stream errors)
|
// engine-scoped and closes on every engine restart (network change,
|
||||||
// while the SSO deadline stays valid across those, so clearing here would
|
// sleep/wake, stream errors) while the SSO deadline stays valid across
|
||||||
// blank the UI's "expires in" row on every transient reconnect. The
|
// those, so clearing here would blank the UI's "expires in" row on every
|
||||||
// client run loop clears the server-scoped recorder when it exits for
|
// transient reconnect. The client run loop clears the server-scoped
|
||||||
// real (Down, profile switch, permanent login failure).
|
// recorder when it exits for real (Down, profile switch, permanent login
|
||||||
|
// failure).
|
||||||
func (w *Watcher) Close() {
|
func (w *Watcher) Close() {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
|
||||||
if w.closed {
|
if w.closed {
|
||||||
|
// A concurrent Close is already tearing down. w.done outlives it,
|
||||||
|
// so this caller waits for the same loop rather than returning
|
||||||
|
// while a warning is still on its way to the recorder.
|
||||||
|
done := w.done
|
||||||
|
w.mu.Unlock()
|
||||||
|
if done != nil {
|
||||||
|
<-done
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.closed = true
|
w.closed = true
|
||||||
w.stopTimerLocked()
|
|
||||||
w.current = time.Time{}
|
w.current = time.Time{}
|
||||||
w.firedAt = time.Time{}
|
w.firedAt = time.Time{}
|
||||||
w.finalFiredAt = time.Time{}
|
w.finalFiredAt = time.Time{}
|
||||||
w.dismissedAt = time.Time{}
|
w.dismissedAt = time.Time{}
|
||||||
|
w.announcedAt = time.Time{}
|
||||||
|
// Copy the channels out before releasing the lock: the loop takes w.mu
|
||||||
|
// on every tick, so waiting for it while holding the lock would
|
||||||
|
// deadlock. w.done stays on the receiver for the branch above.
|
||||||
|
stop, done := w.stop, w.done
|
||||||
|
w.stop, w.wake = nil, nil
|
||||||
|
w.mu.Unlock()
|
||||||
|
|
||||||
|
if stop == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
close(stop)
|
||||||
|
<-done
|
||||||
}
|
}
|
||||||
|
|
||||||
// clearLocked drops the tracked deadline and notifies the recorder so
|
// clearLocked drops the tracked deadline and notifies the recorder so
|
||||||
@@ -257,11 +328,11 @@ func (w *Watcher) clearLocked() {
|
|||||||
w.mu.Unlock()
|
w.mu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.stopTimerLocked()
|
|
||||||
w.current = time.Time{}
|
w.current = time.Time{}
|
||||||
w.firedAt = time.Time{}
|
w.firedAt = time.Time{}
|
||||||
w.finalFiredAt = time.Time{}
|
w.finalFiredAt = time.Time{}
|
||||||
w.dismissedAt = time.Time{}
|
w.dismissedAt = time.Time{}
|
||||||
|
w.announcedAt = time.Time{}
|
||||||
recorder := w.recorder
|
recorder := w.recorder
|
||||||
w.mu.Unlock()
|
w.mu.Unlock()
|
||||||
if recorder != nil {
|
if recorder != nil {
|
||||||
@@ -270,87 +341,121 @@ func (w *Watcher) clearLocked() {
|
|||||||
log.Infof("auth session deadline cleared")
|
log.Infof("auth session deadline cleared")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Watcher) stopTimerLocked() {
|
// startPollLocked starts the evaluation loop unless it is already
|
||||||
if w.timer != nil {
|
// running. The loop starts lazily on the first accepted deadline, so a
|
||||||
w.timer.Stop()
|
// client whose server never publishes a session expiry never pays for a
|
||||||
w.timer = nil
|
// ticker, and it runs until Close: the watcher is engine-scoped, and a
|
||||||
|
// cleared deadline is normally followed by a fresh one on the next sync.
|
||||||
|
// Caller must hold w.mu.
|
||||||
|
func (w *Watcher) startPollLocked() {
|
||||||
|
if w.stop != nil {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if w.finalTimer != nil {
|
stop := make(chan struct{})
|
||||||
w.finalTimer.Stop()
|
done := make(chan struct{})
|
||||||
w.finalTimer = nil
|
wake := make(chan struct{}, 1)
|
||||||
|
w.stop, w.done, w.wake = stop, done, wake
|
||||||
|
go w.poll(stop, done, wake, w.interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// poll re-evaluates the tracked deadline every interval, and as soon as a
|
||||||
|
// new deadline is announced. It is the only caller of evaluate, so Close
|
||||||
|
// waiting for it to exit is enough to know no warning is still on its way
|
||||||
|
// to the recorder. Its channels and interval are passed in rather than
|
||||||
|
// read off the receiver, so Close can clear them without racing it.
|
||||||
|
func (w *Watcher) poll(stop <-chan struct{}, done chan<- struct{}, wake <-chan struct{}, interval time.Duration) {
|
||||||
|
defer close(done)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
case <-wake:
|
||||||
|
w.evaluate()
|
||||||
|
case <-ticker.C:
|
||||||
|
w.evaluate()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Watcher) armTimerLocked(deadline time.Time) {
|
// evaluate compares the tracked deadline against the wall clock and
|
||||||
w.timer = armOneShotLocked(deadline.Add(-w.lead), func() { w.fire(deadline) })
|
// publishes whichever warning the remaining time calls for. Inside the
|
||||||
// finalLead <= 0 disables the final-warning timer entirely. Used by
|
// final-warning window the interactive warning is stale, so the final one
|
||||||
// tests that predate the final-warning fallback so a millisecond-scale
|
// is published in its place: a device that resumes there never saw the
|
||||||
// deadline does not flush both timers at once.
|
// T-WarningLead notification.
|
||||||
if w.finalLead > 0 {
|
func (w *Watcher) evaluate() {
|
||||||
w.finalTimer = armOneShotLocked(deadline.Add(-w.finalLead), func() { w.fireFinal(deadline) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *Watcher) fire(armedFor time.Time) {
|
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
if w.closed || !w.current.Equal(armedFor) {
|
if w.closed || w.deadlineOnly || w.current.IsZero() {
|
||||||
// Deadline moved while we were waiting (e.g. a successful extend).
|
|
||||||
// The reschedule path armed a fresh timer; this one is stale.
|
|
||||||
w.mu.Unlock()
|
w.mu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !w.firedAt.IsZero() && w.firedAt.Equal(armedFor) {
|
|
||||||
|
deadline := w.current
|
||||||
|
if !w.announcedAt.Equal(deadline) {
|
||||||
|
// Update is still on its way to the recorder with this deadline.
|
||||||
w.mu.Unlock()
|
w.mu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.firedAt = armedFor
|
// Round(0) strips the monotonic reading so the comparison is wall
|
||||||
|
// clock on both sides, whether the deadline came off the wire or from
|
||||||
|
// a caller that derived it from time.Now.
|
||||||
|
remaining := deadline.Round(0).Sub(w.nowFn().Round(0))
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case remaining <= 0:
|
||||||
|
// Already expired: the post-mortem SessionExpired flow owns it.
|
||||||
|
w.mu.Unlock()
|
||||||
|
case w.finalLead > 0 && remaining <= w.finalLead:
|
||||||
|
w.publishFinalLocked(deadline, remaining)
|
||||||
|
case remaining <= w.lead:
|
||||||
|
w.publishWarningLocked(deadline, remaining)
|
||||||
|
default:
|
||||||
|
w.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishWarningLocked emits the interactive T-WarningLead warning, at
|
||||||
|
// most once per deadline value. Caller must hold w.mu; this helper
|
||||||
|
// releases it.
|
||||||
|
func (w *Watcher) publishWarningLocked(deadline time.Time, remaining time.Duration) {
|
||||||
|
if w.firedAt.Equal(deadline) {
|
||||||
|
w.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.firedAt = deadline
|
||||||
recorder := w.recorder
|
recorder := w.recorder
|
||||||
w.mu.Unlock()
|
w.mu.Unlock()
|
||||||
if recorder == nil {
|
if recorder == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Infof("auth session expiry soon warning fired")
|
log.Infof("auth session expiry soon warning fired for deadline %s (in %s)",
|
||||||
publishWarning(recorder, armedFor, false)
|
deadline.Format(time.RFC3339), remaining.Round(time.Second))
|
||||||
|
publishWarning(recorder, deadline, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fireFinal mirrors fire for the T-FinalWarningLead timer with an extra
|
// publishFinalLocked emits the final warning, at most once per deadline
|
||||||
// dismiss-gate: if the user dismissed the T-WarningLead notification for
|
// value and never once the user dismissed that deadline. It marks the
|
||||||
// this deadline, the final warning is suppressed entirely.
|
// interactive warning as handled too: the final window is open, so a
|
||||||
func (w *Watcher) fireFinal(armedFor time.Time) {
|
// "expires in WarningLead minutes" notification would be wrong. Caller
|
||||||
w.mu.Lock()
|
// must hold w.mu; this helper releases it.
|
||||||
if w.closed || !w.current.Equal(armedFor) {
|
func (w *Watcher) publishFinalLocked(deadline time.Time, remaining time.Duration) {
|
||||||
|
if w.finalFiredAt.Equal(deadline) || w.dismissedAt.Equal(deadline) {
|
||||||
w.mu.Unlock()
|
w.mu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !w.finalFiredAt.IsZero() && w.finalFiredAt.Equal(armedFor) {
|
w.firedAt = deadline
|
||||||
w.mu.Unlock()
|
w.finalFiredAt = deadline
|
||||||
return
|
|
||||||
}
|
|
||||||
if w.dismissedAt.Equal(armedFor) {
|
|
||||||
w.mu.Unlock()
|
|
||||||
log.Infof("auth session final-warning skipped (dismissed by user)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.finalFiredAt = armedFor
|
|
||||||
recorder := w.recorder
|
recorder := w.recorder
|
||||||
w.mu.Unlock()
|
w.mu.Unlock()
|
||||||
if recorder == nil {
|
if recorder == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Infof("auth session final-warning fired")
|
log.Infof("auth session final-warning fired for deadline %s (in %s)",
|
||||||
publishWarning(recorder, armedFor, true)
|
deadline.Format(time.RFC3339), remaining.Round(time.Second))
|
||||||
}
|
publishWarning(recorder, deadline, true)
|
||||||
|
|
||||||
// armOneShotLocked schedules cb at fireAt. When fireAt is already in the
|
|
||||||
// past it dispatches on the next scheduler tick so a state-change recorder
|
|
||||||
// notification (invoked after w.mu is released) lands first. Caller must
|
|
||||||
// hold w.mu.
|
|
||||||
func armOneShotLocked(fireAt time.Time, cb func()) *time.Timer {
|
|
||||||
delay := time.Until(fireAt)
|
|
||||||
if delay <= 0 {
|
|
||||||
return time.AfterFunc(0, cb)
|
|
||||||
}
|
|
||||||
return time.AfterFunc(delay, cb)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// publishWarning composes the SystemEvent for a watcher-fired warning and
|
// publishWarning composes the SystemEvent for a watcher-fired warning and
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ type fakeRecorder struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
events []event
|
events []event
|
||||||
lastDeadline time.Time
|
lastDeadline time.Time
|
||||||
|
// setDelay stalls SetSessionExpiresAt, widening the window in which a
|
||||||
|
// concurrent evaluation could publish a warning out of order.
|
||||||
|
setDelay time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
type eventKind int
|
type eventKind int
|
||||||
@@ -43,6 +46,12 @@ type event struct {
|
|||||||
// is the zero time, so an initial clear before any deadline is set emits
|
// is the zero time, so an initial clear before any deadline is set emits
|
||||||
// nothing — matching the real recorder.
|
// nothing — matching the real recorder.
|
||||||
func (r *fakeRecorder) SetSessionExpiresAt(deadline time.Time) {
|
func (r *fakeRecorder) SetSessionExpiresAt(deadline time.Time) {
|
||||||
|
r.mu.Lock()
|
||||||
|
delay := r.setDelay
|
||||||
|
r.mu.Unlock()
|
||||||
|
// Stall without the lock held, so a concurrent publish can still record.
|
||||||
|
time.Sleep(delay)
|
||||||
|
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
if r.lastDeadline.Equal(deadline) {
|
if r.lastDeadline.Equal(deadline) {
|
||||||
@@ -116,10 +125,54 @@ func waitForEvents(t *testing.T, r *fakeRecorder, want int) []event {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// newWatcher builds a watcher with the final timer disabled (finalLead=0),
|
// testInterval keeps the ticker-driven tests fast; the leads they use are
|
||||||
|
// in the same millisecond scale.
|
||||||
|
const testInterval = 2 * time.Millisecond
|
||||||
|
|
||||||
|
// newWatcher builds a watcher with the final warning disabled (finalLead=0),
|
||||||
// matching the lead-only behaviour the pre-final-warning tests assume.
|
// matching the lead-only behaviour the pre-final-warning tests assume.
|
||||||
func newWatcher(lead time.Duration, r *fakeRecorder) *Watcher {
|
func newWatcher(lead time.Duration, r *fakeRecorder) *Watcher {
|
||||||
return NewWithLeads(lead, 0, r)
|
return newWatcherWithLeads(lead, 0, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newWatcherWithLeads builds a watcher that evaluates on testInterval. The
|
||||||
|
// interval is set before Update, so the evaluation loop does not exist yet
|
||||||
|
// and the write cannot race it.
|
||||||
|
func newWatcherWithLeads(lead, final time.Duration, r StatusRecorder) *Watcher {
|
||||||
|
w := NewWithLeads(lead, final, r)
|
||||||
|
w.interval = testInterval
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeClock is the watcher's wall clock under test. Reads come from the
|
||||||
|
// evaluation goroutine while the test writes, so both go through the mutex.
|
||||||
|
type fakeClock struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
t time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeClock(t time.Time) *fakeClock {
|
||||||
|
return &fakeClock{t: t}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeClock) now() time.Time {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.t
|
||||||
|
}
|
||||||
|
|
||||||
|
// set jumps the clock, standing in for a resume from suspension or for an
|
||||||
|
// NTP correction.
|
||||||
|
func (c *fakeClock) set(t time.Time) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.t = t
|
||||||
|
}
|
||||||
|
|
||||||
|
// settle waits out a handful of evaluation ticks so a test can assert that
|
||||||
|
// nothing was published.
|
||||||
|
func settle() {
|
||||||
|
time.Sleep(20 * testInterval)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateZeroBeforeAnythingIsNoop(t *testing.T) {
|
func TestUpdateZeroBeforeAnythingIsNoop(t *testing.T) {
|
||||||
@@ -410,7 +463,7 @@ func TestCloseWithoutDeadlineLeavesRecorderUntouched(t *testing.T) {
|
|||||||
func TestFinalWarningFiresAfterRegularWarning(t *testing.T) {
|
func TestFinalWarningFiresAfterRegularWarning(t *testing.T) {
|
||||||
r := &fakeRecorder{}
|
r := &fakeRecorder{}
|
||||||
// Warning fires at deadline-80ms, final at deadline-30ms.
|
// Warning fires at deadline-80ms, final at deadline-30ms.
|
||||||
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
w := newWatcherWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||||
defer w.Close()
|
defer w.Close()
|
||||||
|
|
||||||
d := time.Now().Add(100 * time.Millisecond)
|
d := time.Now().Add(100 * time.Millisecond)
|
||||||
@@ -446,7 +499,7 @@ func TestFinalWarningFiresAfterRegularWarning(t *testing.T) {
|
|||||||
|
|
||||||
func TestDismissSuppressesFinalWarning(t *testing.T) {
|
func TestDismissSuppressesFinalWarning(t *testing.T) {
|
||||||
r := &fakeRecorder{}
|
r := &fakeRecorder{}
|
||||||
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
w := newWatcherWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||||
defer w.Close()
|
defer w.Close()
|
||||||
|
|
||||||
d := time.Now().Add(100 * time.Millisecond)
|
d := time.Now().Add(100 * time.Millisecond)
|
||||||
@@ -477,7 +530,7 @@ func TestDismissSuppressesFinalWarning(t *testing.T) {
|
|||||||
|
|
||||||
func TestDismissResetByNewDeadline(t *testing.T) {
|
func TestDismissResetByNewDeadline(t *testing.T) {
|
||||||
r := &fakeRecorder{}
|
r := &fakeRecorder{}
|
||||||
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
w := newWatcherWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||||
defer w.Close()
|
defer w.Close()
|
||||||
|
|
||||||
first := time.Now().Add(100 * time.Millisecond)
|
first := time.Now().Add(100 * time.Millisecond)
|
||||||
@@ -507,7 +560,7 @@ func TestDismissResetByNewDeadline(t *testing.T) {
|
|||||||
|
|
||||||
func TestDismissBeforeUpdateIsNoop(t *testing.T) {
|
func TestDismissBeforeUpdateIsNoop(t *testing.T) {
|
||||||
r := &fakeRecorder{}
|
r := &fakeRecorder{}
|
||||||
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
w := newWatcherWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||||
defer w.Close()
|
defer w.Close()
|
||||||
|
|
||||||
// No deadline tracked yet; Dismiss must be a no-op (no panic, no state).
|
// No deadline tracked yet; Dismiss must be a no-op (no panic, no state).
|
||||||
@@ -527,3 +580,340 @@ func TestDismissBeforeUpdateIsNoop(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Fatalf("final-warning did not publish after no-op pre-Update Dismiss, events=%+v", r.snapshot())
|
t.Fatalf("final-warning did not publish after no-op pre-Update Dismiss, events=%+v", r.snapshot())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The tests below drive the watcher's wall clock directly. The deadline sits
|
||||||
|
// an hour out in real time and the fake clock jumps, standing in for a device
|
||||||
|
// that was suspended and resumed somewhere inside — or past — the warning
|
||||||
|
// windows. The evaluation loop is what reacts to the jump, so these exercise
|
||||||
|
// the same path production takes on a resume.
|
||||||
|
func newResumeWatcher(t *testing.T, r *fakeRecorder) (*Watcher, *fakeClock, time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
w := newWatcherWithLeads(WarningLead, FinalWarningLead, r)
|
||||||
|
start := time.Now()
|
||||||
|
clock := newFakeClock(start)
|
||||||
|
// Set before Update: the evaluation loop does not exist yet.
|
||||||
|
w.nowFn = clock.now
|
||||||
|
t.Cleanup(w.Close)
|
||||||
|
|
||||||
|
deadline := start.Add(time.Hour).Round(0)
|
||||||
|
if err := w.Update(deadline); err != nil {
|
||||||
|
t.Fatalf("Update: %v", err)
|
||||||
|
}
|
||||||
|
return w, clock, deadline
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResumeInsideWarningWindowWarns(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
_, clock, deadline := newResumeWatcher(t, r)
|
||||||
|
|
||||||
|
clock.set(deadline.Add(-5 * time.Minute))
|
||||||
|
|
||||||
|
events := waitForEvents(t, r, 2)
|
||||||
|
if !events[1].isWarning() {
|
||||||
|
t.Fatalf("expected the interactive warning after the resume, got %+v", events[1])
|
||||||
|
}
|
||||||
|
if n := countWhere(events, event.isFinalWarning); n != 0 {
|
||||||
|
t.Fatalf("final-warning must wait for its own window, got %d: %+v", n, events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResumeInsideFinalWindowSendsFinalWarningOnly(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
_, clock, deadline := newResumeWatcher(t, r)
|
||||||
|
|
||||||
|
clock.set(deadline.Add(-time.Minute))
|
||||||
|
|
||||||
|
events := waitForEvents(t, r, 2)
|
||||||
|
if !events[1].isFinalWarning() {
|
||||||
|
t.Fatalf("expected the final warning after the resume, got %+v", events[1])
|
||||||
|
}
|
||||||
|
settle()
|
||||||
|
if n := countWhere(r.snapshot(), event.isWarning); n != 0 {
|
||||||
|
t.Fatalf("the interactive warning is stale inside the final window, got %d: %+v", n, r.snapshot())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResumePastDeadlinePublishesNothing(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
_, clock, deadline := newResumeWatcher(t, r)
|
||||||
|
|
||||||
|
clock.set(deadline.Add(time.Minute))
|
||||||
|
|
||||||
|
settle()
|
||||||
|
if n := countWhere(r.snapshot(), func(e event) bool { return e.kind == publish }); n != 0 {
|
||||||
|
t.Fatalf("an expired session must not warn, got %d publishes: %+v", n, r.snapshot())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWarningPublishesOncePerDeadline(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
_, clock, deadline := newResumeWatcher(t, r)
|
||||||
|
|
||||||
|
clock.set(deadline.Add(-5 * time.Minute))
|
||||||
|
waitForEvents(t, r, 2)
|
||||||
|
|
||||||
|
// Many ticks pass inside the same window.
|
||||||
|
settle()
|
||||||
|
if n := countWhere(r.snapshot(), event.isWarning); n != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 warning publish across ticks, got %d: %+v", n, r.snapshot())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWarningRecoversFromAClockRunningAhead covers the device that boots
|
||||||
|
// before NTP has corrected it: the deadline looks long gone, nothing is
|
||||||
|
// published, and the warning still arrives once the clock is fixed.
|
||||||
|
func TestWarningRecoversFromAClockRunningAhead(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
_, clock, deadline := newResumeWatcher(t, r)
|
||||||
|
|
||||||
|
clock.set(deadline.Add(2 * time.Hour))
|
||||||
|
settle()
|
||||||
|
if n := countWhere(r.snapshot(), func(e event) bool { return e.kind == publish }); n != 0 {
|
||||||
|
t.Fatalf("a deadline that looks expired must not warn, got %d publishes: %+v", n, r.snapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
clock.set(deadline.Add(-5 * time.Minute))
|
||||||
|
|
||||||
|
events := waitForEvents(t, r, 2)
|
||||||
|
if !events[1].isWarning() {
|
||||||
|
t.Fatalf("expected the warning once the clock was corrected, got %+v", events[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResumeInsideFinalWindowRespectsDismiss(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w, clock, deadline := newResumeWatcher(t, r)
|
||||||
|
|
||||||
|
// The user dismissed the warning for this deadline before the device
|
||||||
|
// was suspended; resuming inside the final window must not reopen it.
|
||||||
|
w.Dismiss()
|
||||||
|
clock.set(deadline.Add(-time.Minute))
|
||||||
|
|
||||||
|
settle()
|
||||||
|
if n := countWhere(r.snapshot(), func(e event) bool { return e.kind == publish }); n != 0 {
|
||||||
|
t.Fatalf("a dismissed deadline must not warn on resume, got %d: %+v", n, r.snapshot())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloseStopsTheEvaluationLoop(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcherWithLeads(WarningLead, FinalWarningLead, r)
|
||||||
|
start := time.Now()
|
||||||
|
clock := newFakeClock(start)
|
||||||
|
w.nowFn = clock.now
|
||||||
|
|
||||||
|
deadline := start.Add(time.Hour).Round(0)
|
||||||
|
if err := w.Update(deadline); err != nil {
|
||||||
|
t.Fatalf("Update: %v", err)
|
||||||
|
}
|
||||||
|
w.Close()
|
||||||
|
|
||||||
|
clock.set(deadline.Add(-5 * time.Minute))
|
||||||
|
settle()
|
||||||
|
|
||||||
|
if n := countWhere(r.snapshot(), func(e event) bool { return e.kind == publish }); n != 0 {
|
||||||
|
t.Fatalf("a closed watcher must not publish, got %d: %+v", n, r.snapshot())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeadlineOnlyRecordsDeadlineWithoutWarnings(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := NewDeadlineOnly(r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
// With the default leads this deadline sits inside the final-warning
|
||||||
|
// window, so a watcher that warns at all would publish on the spot.
|
||||||
|
d := time.Now().Add(50 * time.Millisecond).Round(0)
|
||||||
|
if err := w.Update(d); err != nil {
|
||||||
|
t.Fatalf("Update: %v", err)
|
||||||
|
}
|
||||||
|
if got := r.deadline(); !got.Equal(d) {
|
||||||
|
t.Fatalf("expected recorder deadline %v, got %v", d, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
events := r.snapshot()
|
||||||
|
if got := countWhere(events, func(e event) bool { return e.kind == publish }); got != 0 {
|
||||||
|
t.Fatalf("expected no publish in deadline-only mode, got %d: %+v", got, events)
|
||||||
|
}
|
||||||
|
w.mu.Lock()
|
||||||
|
polling := w.stop != nil
|
||||||
|
w.mu.Unlock()
|
||||||
|
if polling {
|
||||||
|
t.Fatal("expected no evaluation loop in deadline-only mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeadlineOnlyStillRejectsOutOfRangeDeadlines(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := NewDeadlineOnly(r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
if err := w.Update(time.Now().Add(time.Hour)); err != nil {
|
||||||
|
t.Fatalf("Update: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := w.Update(time.Now().Add(-maxPastHorizon - time.Hour))
|
||||||
|
if !errors.Is(err, ErrDeadlineInPast) {
|
||||||
|
t.Fatalf("expected ErrDeadlineInPast, got %v", err)
|
||||||
|
}
|
||||||
|
if got := r.deadline(); !got.IsZero() {
|
||||||
|
t.Fatalf("expected recorder cleared after rejection, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClockAheadAtUpdateStillWarnsOnceCorrected covers a client that connects
|
||||||
|
// while its clock runs ahead of real time: the deadline reads as already
|
||||||
|
// expired when it arrives, so nothing publishes, and the warning must still
|
||||||
|
// come once NTP pulls the clock back.
|
||||||
|
func TestClockAheadAtUpdateStillWarnsOnceCorrected(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcherWithLeads(WarningLead, FinalWarningLead, r)
|
||||||
|
deadline := time.Now().Add(time.Hour).Round(0)
|
||||||
|
// Two hours past the deadline from the device's point of view, well
|
||||||
|
// inside maxPastHorizon, so Update accepts and records it.
|
||||||
|
clock := newFakeClock(deadline.Add(2 * time.Hour))
|
||||||
|
w.nowFn = clock.now
|
||||||
|
t.Cleanup(w.Close)
|
||||||
|
|
||||||
|
if err := w.Update(deadline); err != nil {
|
||||||
|
t.Fatalf("Update: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
settle()
|
||||||
|
if n := countWhere(r.snapshot(), func(e event) bool { return e.kind == publish }); n != 0 {
|
||||||
|
t.Fatalf("a deadline that reads as expired must not warn, got %d: %+v", n, r.snapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
clock.set(deadline.Add(-5 * time.Minute))
|
||||||
|
|
||||||
|
events := waitForEvents(t, r, 2)
|
||||||
|
if !events[1].isWarning() {
|
||||||
|
t.Fatalf("expected the warning once the clock was corrected, got %+v", events[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWarningNeverPrecedesTheDeadlineStateChange pins the ordering Update
|
||||||
|
// documents: consumers learn the new deadline before they see a warning that
|
||||||
|
// refers to it. The deadline here already sits inside the warning window, and
|
||||||
|
// the recorder stalls, so the evaluation loop gets many chances to publish
|
||||||
|
// while Update is still announcing.
|
||||||
|
func TestWarningNeverPrecedesTheDeadlineStateChange(t *testing.T) {
|
||||||
|
r := &fakeRecorder{setDelay: 50 * time.Millisecond}
|
||||||
|
w := newWatcherWithLeads(WarningLead, FinalWarningLead, r)
|
||||||
|
deadline := time.Now().Add(time.Hour).Round(0)
|
||||||
|
clock := newFakeClock(deadline.Add(-5 * time.Minute))
|
||||||
|
w.nowFn = clock.now
|
||||||
|
t.Cleanup(w.Close)
|
||||||
|
|
||||||
|
if err := w.Update(deadline); err != nil {
|
||||||
|
t.Fatalf("Update: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
events := waitForEvents(t, r, 2)
|
||||||
|
if events[0].kind != stateChange {
|
||||||
|
t.Fatalf("event[0] should be the deadline state change, got %+v", events)
|
||||||
|
}
|
||||||
|
if !events[1].isWarning() {
|
||||||
|
t.Fatalf("event[1] should be the warning, got %+v", events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUpdateWakesTheLoopWithoutWaitingForATick pins the hand-off: Update
|
||||||
|
// publishes nothing itself, it nudges the evaluation loop, so a deadline that
|
||||||
|
// already sits inside a warning window is warned about at once even though the
|
||||||
|
// ticker here would not fire for an hour.
|
||||||
|
func TestUpdateWakesTheLoopWithoutWaitingForATick(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcherWithLeads(WarningLead, FinalWarningLead, r)
|
||||||
|
w.interval = time.Hour
|
||||||
|
t.Cleanup(w.Close)
|
||||||
|
|
||||||
|
d := time.Now().Add(5 * time.Minute).Round(0)
|
||||||
|
if err := w.Update(d); err != nil {
|
||||||
|
t.Fatalf("Update: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
events := waitForEvents(t, r, 2)
|
||||||
|
if !events[1].isWarning() {
|
||||||
|
t.Fatalf("expected the warning on the wake-up, got %+v", events[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// blockingRecorder holds a publish open until the test releases it, so the
|
||||||
|
// evaluation loop can be parked mid-publish while Close is called.
|
||||||
|
type blockingRecorder struct {
|
||||||
|
fakeRecorder
|
||||||
|
entered chan struct{}
|
||||||
|
release chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *blockingRecorder) PublishEvent(
|
||||||
|
severity cProto.SystemEvent_Severity,
|
||||||
|
category cProto.SystemEvent_Category,
|
||||||
|
message string,
|
||||||
|
userMessage string,
|
||||||
|
metadata map[string]string,
|
||||||
|
) {
|
||||||
|
select {
|
||||||
|
case r.entered <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
<-r.release
|
||||||
|
r.fakeRecorder.PublishEvent(severity, category, message, userMessage, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConcurrentCloseWaitsForTheLoop pins the contract for the caller that
|
||||||
|
// loses the race: Close returns only once the loop is done, whichever of the
|
||||||
|
// two calls got there first.
|
||||||
|
func TestConcurrentCloseWaitsForTheLoop(t *testing.T) {
|
||||||
|
r := &blockingRecorder{entered: make(chan struct{}, 1), release: make(chan struct{})}
|
||||||
|
var releaseOnce sync.Once
|
||||||
|
release := func() { releaseOnce.Do(func() { close(r.release) }) }
|
||||||
|
|
||||||
|
w := newWatcherWithLeads(WarningLead, FinalWarningLead, r)
|
||||||
|
w.interval = time.Hour
|
||||||
|
// Order matters: Close waits for the loop, which stays parked in the
|
||||||
|
// publish until the release, so a t.Fatal below would hang the cleanup
|
||||||
|
// instead of reporting the failure.
|
||||||
|
t.Cleanup(func() {
|
||||||
|
release()
|
||||||
|
w.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
d := time.Now().Add(5 * time.Minute).Round(0)
|
||||||
|
if err := w.Update(d); err != nil {
|
||||||
|
t.Fatalf("Update: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The loop is now parked inside the warning publish.
|
||||||
|
select {
|
||||||
|
case <-r.entered:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("the loop never reached the publish")
|
||||||
|
}
|
||||||
|
|
||||||
|
first := make(chan struct{})
|
||||||
|
second := make(chan struct{})
|
||||||
|
go func() { w.Close(); close(first) }()
|
||||||
|
go func() { w.Close(); close(second) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-first:
|
||||||
|
t.Fatal("Close returned while the loop was still publishing")
|
||||||
|
case <-second:
|
||||||
|
t.Fatal("Close returned while the loop was still publishing")
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
}
|
||||||
|
|
||||||
|
release()
|
||||||
|
for _, done := range []chan struct{}{first, second} {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("Close did not return after the publish completed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package certproof
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||||
|
"github.com/netbirdio/netbird/shared/management/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collect answers the certificate challenges in checks: for each challenge it picks a
|
||||||
|
// stored certificate that chains to the challenge's CAs and signs the nonce with its
|
||||||
|
// key. The same certificate is proven once even if several checks accept it.
|
||||||
|
func Collect(ctx context.Context, store Store, checks []*proto.Checks, peerKey []byte) []certposture.Proof {
|
||||||
|
challenges := certificateChallenges(checks)
|
||||||
|
if len(challenges) == 0 {
|
||||||
|
logNoChallenges(checks)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return CollectChallenges(ctx, store, challenges, peerKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func logNoChallenges(checks []*proto.Checks) {
|
||||||
|
if len(checks) > 0 {
|
||||||
|
log.Debugf("certificate posture: %d posture checks received, none carries a certificate challenge", len(checks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectChallenges answers challenges already extracted from the posture checks, so a
|
||||||
|
// caller that ships them across a process boundary reuses the same matching and signing.
|
||||||
|
// Only nonces of the size management issues are signed, for a peer key of the size of
|
||||||
|
// ours, so the keys behind the store never sign arbitrary caller-chosen data.
|
||||||
|
func CollectChallenges(ctx context.Context, store Store, challenges []*proto.CertificateChallenge, peerKey []byte) []certposture.Proof {
|
||||||
|
if len(peerKey) != wgtypes.KeyLen {
|
||||||
|
log.Warnf("certificate posture: refusing to sign for a %d byte peer key", len(peerKey))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
challenges = wellFormed(challenges)
|
||||||
|
if len(challenges) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Debugf("certificate posture: answering %d certificate challenges from store %T", len(challenges), store)
|
||||||
|
|
||||||
|
candidates, err := store.Candidates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("failed loading certificates for posture checks: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
log.Debug("certificate posture: certificate store holds no candidates, no proof will be sent")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Debugf("certificate posture: store holds %d candidate certificates", len(candidates))
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
proven := make(map[[sha256.Size]byte]struct{})
|
||||||
|
var proofs []certposture.Proof
|
||||||
|
for i, challenge := range challenges {
|
||||||
|
roots, err := certposture.ParseCAs(challenge.GetCaCertificates())
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("skipping certificate challenge with invalid CA certificates: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Debugf("certificate posture: challenge %d accepts %d CA certificates, nonce is %d bytes", i, len(challenge.GetCaCertificates()), len(challenge.GetNonce()))
|
||||||
|
|
||||||
|
matched := false
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if len(candidate.Chain) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
leaf := candidate.Chain[0]
|
||||||
|
chain, err := certposture.VerifiedChain(leaf, candidate.issuers(), roots, now)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("certificate posture: challenge %d rejected %q issued by %q: %v", i, leaf.Subject, leaf.Issuer, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matched = true
|
||||||
|
|
||||||
|
// The same leaf can chain to different CAs for different challenges, and
|
||||||
|
// management checks each chain against each check's CAs, so a proof is
|
||||||
|
// deduplicated by its whole chain rather than by its leaf.
|
||||||
|
fingerprint := chainFingerprint(chain)
|
||||||
|
if _, done := proven[fingerprint]; done {
|
||||||
|
log.Debugf("certificate posture: challenge %d matched %q, already proven for an earlier challenge", i, leaf.Subject)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
proof, err := prove(candidate.Signer, chain, challenge.GetNonce(), peerKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("failed signing certificate proof for %s: %v", leaf.Subject, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Debugf("certificate posture: challenge %d proven by %q with %s, signature %d bytes, chain of %d", i, leaf.Subject, proof.SigAlg, len(proof.Signature), len(proof.Chain))
|
||||||
|
proven[fingerprint] = struct{}{}
|
||||||
|
proofs = append(proofs, proof)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
log.Debugf("certificate posture: challenge %d matched none of the %d candidates", i, len(candidates))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Debugf("certificate posture: %d challenges produced %d proofs", len(challenges), len(proofs))
|
||||||
|
return proofs
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasChallenges reports whether any of checks asks for a certificate proof.
|
||||||
|
func HasChallenges(checks []*proto.Checks) bool {
|
||||||
|
return len(certificateChallenges(checks)) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func certificateChallenges(checks []*proto.Checks) []*proto.CertificateChallenge {
|
||||||
|
var challenges []*proto.CertificateChallenge
|
||||||
|
for _, check := range checks {
|
||||||
|
if challenge := check.GetCertificateChallenge(); challenge != nil {
|
||||||
|
challenges = append(challenges, challenge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return wellFormed(challenges)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wellFormed drops challenges whose nonce is not one management could have issued.
|
||||||
|
func wellFormed(challenges []*proto.CertificateChallenge) []*proto.CertificateChallenge {
|
||||||
|
var kept []*proto.CertificateChallenge
|
||||||
|
for _, challenge := range challenges {
|
||||||
|
if len(challenge.GetNonce()) != certposture.NonceSize {
|
||||||
|
log.Debugf("certificate posture: skipping challenge with a %d byte nonce", len(challenge.GetNonce()))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, challenge)
|
||||||
|
}
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
|
func prove(signer crypto.Signer, chain []*x509.Certificate, nonce, peerKey []byte) (certposture.Proof, error) {
|
||||||
|
sigAlg, sig, err := certposture.Sign(signer, nonce, peerKey)
|
||||||
|
if err != nil {
|
||||||
|
return certposture.Proof{}, err
|
||||||
|
}
|
||||||
|
der := make([][]byte, 0, len(chain))
|
||||||
|
for _, cert := range chain {
|
||||||
|
der = append(der, cert.Raw)
|
||||||
|
}
|
||||||
|
return certposture.Proof{Nonce: nonce, Chain: der, SigAlg: sigAlg, Signature: sig}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func chainFingerprint(chain []*x509.Certificate) [sha256.Size]byte {
|
||||||
|
ders := make([][]byte, 0, len(chain))
|
||||||
|
for _, cert := range chain {
|
||||||
|
ders = append(ders, cert.Raw)
|
||||||
|
}
|
||||||
|
return chainDERFingerprint(ders)
|
||||||
|
}
|
||||||
|
|
||||||
|
// chainDERFingerprint identifies a chain of DER certificates by the hashes of all of them,
|
||||||
|
// in order.
|
||||||
|
func chainDERFingerprint(chain [][]byte) [sha256.Size]byte {
|
||||||
|
buf := make([]byte, 0, len(chain)*sha256.Size)
|
||||||
|
for _, der := range chain {
|
||||||
|
certHash := sha256.Sum256(der)
|
||||||
|
buf = append(buf, certHash[:]...)
|
||||||
|
}
|
||||||
|
return sha256.Sum256(buf)
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
//go:build !ios
|
||||||
|
|
||||||
|
package certproof
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||||
|
"github.com/netbirdio/netbird/shared/management/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
const helperTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// userHelperBackoff holds off asking a user's keychain again after it proved nothing.
|
||||||
|
var userHelperBackoff = newHelperBackoff()
|
||||||
|
|
||||||
|
// CollectProofs answers the certificate challenges in checks from every store this Mac
|
||||||
|
// can reach. The root daemon reads the System keychain itself, which is where MDM
|
||||||
|
// installs device identities, and reaches the console user's login keychain only by
|
||||||
|
// launching a helper into that user's session. A Mac sitting at the login window
|
||||||
|
// therefore yields device proofs alone.
|
||||||
|
func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte, cfg Config) []certposture.Proof {
|
||||||
|
challenges := certificateChallenges(checks)
|
||||||
|
if len(challenges) == 0 {
|
||||||
|
logNoChallenges(checks)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// A helper already runs inside the user's session, so it reads its own keychain
|
||||||
|
// directly and must never launch another one.
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
return CollectChallenges(ctx, DefaultStore(), challenges, peerKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
proofs := CollectChallenges(ctx, DefaultStore(), challenges, peerKey)
|
||||||
|
if cfg.OwnerUnknown {
|
||||||
|
return proofs
|
||||||
|
}
|
||||||
|
|
||||||
|
userProofs, err := collectAsConsoleUser(ctx, cfg.ProfileOwner, challenges, peerKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("certificate posture: console user keychain unavailable: %v", err)
|
||||||
|
}
|
||||||
|
return mergeProofs(proofs, userProofs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserContext identifies the user whose keychain a collection would include: the console
|
||||||
|
// user when it owns the active profile, or empty when no user keychain would be asked. A
|
||||||
|
// change means a collection made earlier no longer reflects what this Mac can prove.
|
||||||
|
func UserContext(cfg Config) string {
|
||||||
|
if os.Geteuid() != 0 || cfg.OwnerUnknown {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
user, ok := currentConsoleUser()
|
||||||
|
if !ok || !user.isOwner(cfg.ProfileOwner) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strconv.FormatUint(uint64(user.UID), 10) + ":" + user.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectAsConsoleUser runs the helper inside the desktop session of the logged-in
|
||||||
|
// user. Dropping to their uid is not enough: keychain access is an XPC call to a
|
||||||
|
// per-session securityd, so the helper has to enter their Mach bootstrap namespace,
|
||||||
|
// which is what launchctl asuser does.
|
||||||
|
func collectAsConsoleUser(ctx context.Context, owner string, challenges []*proto.CertificateChallenge, peerKey []byte) ([]certposture.Proof, error) {
|
||||||
|
user, ok := currentConsoleUser()
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if !user.isOwner(owner) {
|
||||||
|
log.Debugf("certificate posture: console user %s does not own the active profile, no user keychain is asked", user.Name)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := strconv.FormatUint(uint64(user.UID), 10)
|
||||||
|
backoffKey := helperBackoffKey(uid, challenges)
|
||||||
|
if !userHelperBackoff.allow(backoffKey, time.Now()) {
|
||||||
|
log.Debugf("certificate posture: the keychain of uid %s proved nothing recently, not asking again yet", uid)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
binary, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve own binary: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parent := ctx
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, helperTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Absolute paths, because the daemon's PATH is configurable through the service
|
||||||
|
// environment, and sudo selects the user by uid so the name never has to round-trip.
|
||||||
|
cmd := exec.CommandContext(ctx, "/bin/launchctl", "asuser", uid, "/usr/bin/sudo", "-u", "#"+uid, "-H", "--", binary, "posture", "cert-proof")
|
||||||
|
killHelperGroupOnCancel(cmd)
|
||||||
|
|
||||||
|
log.Debugf("certificate posture: asking the desktop session of uid %s to answer %d challenges", uid, len(challenges))
|
||||||
|
proofs, err := runHelperCmd(cmd, helperRequest(challenges, peerKey))
|
||||||
|
// A run that completed, or ran into the timeout waiting on a prompt nobody answered,
|
||||||
|
// tells whether the keychain proves anything. A launch or output failure, or a run the
|
||||||
|
// caller cut short, says nothing about it and must not hold off the next one.
|
||||||
|
timedOut := errors.Is(ctx.Err(), context.DeadlineExceeded) && parent.Err() == nil
|
||||||
|
if err == nil || timedOut {
|
||||||
|
userHelperBackoff.record(backoffKey, err == nil && len(proofs) > 0, time.Now())
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("run helper as uid %s: %w", uid, err)
|
||||||
|
}
|
||||||
|
log.Debugf("certificate posture: desktop session of uid %s returned %d proofs", uid, len(proofs))
|
||||||
|
return proofs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// helperStore is the store the helper reads: the user's login keychain alone. The
|
||||||
|
// session's search list also holds the System keychain, which the daemon reads itself,
|
||||||
|
// and using a System keychain key from the user's session would ask for an
|
||||||
|
// administrator's approval.
|
||||||
|
func helperStore() Store {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("certificate posture: no home directory, searching the default keychain list: %v", err)
|
||||||
|
return newKeychainStore()
|
||||||
|
}
|
||||||
|
login := filepath.Join(home, "Library", "Keychains", "login.keychain-db")
|
||||||
|
if _, err := os.Stat(login); err != nil {
|
||||||
|
// Keychains created before macOS 10.12 keep the old file name.
|
||||||
|
login = filepath.Join(home, "Library", "Keychains", "login.keychain")
|
||||||
|
}
|
||||||
|
return newKeychainStore(login)
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
//go:build js
|
||||||
|
|
||||||
|
package certproof
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||||
|
"github.com/netbirdio/netbird/shared/management/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CollectProofs proves nothing in a browser: it has no TPM, certificate store or file
|
||||||
|
// store to sign with, so the stores stay out of the WebAssembly build.
|
||||||
|
func CollectProofs(context.Context, []*proto.Checks, []byte, Config) []certposture.Proof {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserContext identifies the user whose certificates a collection would include. A
|
||||||
|
// browser has no per-user store, so it never changes.
|
||||||
|
func UserContext(Config) string {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultStore is empty in a browser.
|
||||||
|
func DefaultStore() Store {
|
||||||
|
return Stores{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func helperStore() Store {
|
||||||
|
return DefaultStore()
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
//go:build ((!darwin && !windows) || ios) && !js
|
||||||
|
|
||||||
|
package certproof
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||||
|
"github.com/netbirdio/netbird/shared/management/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CollectProofs answers the certificate challenges in checks from the PEM directory cfg
|
||||||
|
// names, joined by its PKCS#11 token when it names one. Only macOS and Windows keep
|
||||||
|
// per-user certificates out of reach of a privileged daemon, so every other platform
|
||||||
|
// reads its store in the daemon itself.
|
||||||
|
func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte, cfg Config) []certposture.Proof {
|
||||||
|
return Collect(ctx, storeWithToken(cfg), checks, peerKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserContext identifies the user whose certificates a collection would include. These
|
||||||
|
// platforms have no per-user store, so it never changes.
|
||||||
|
func UserContext(Config) string {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// helperStore is the store the helper reads. Nothing launches a helper on these
|
||||||
|
// platforms, so it is the store the daemon reads, configured from the same environment,
|
||||||
|
// which lets an administrator check a setup by running the helper by hand.
|
||||||
|
func helperStore() Store {
|
||||||
|
return storeWithToken(Config{PKCS11: PKCS11FromEnv()})
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
package certproof
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"math/big"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||||
|
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
|
||||||
|
"github.com/netbirdio/netbird/shared/management/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
var peerKey = []byte("peer-public-key-aaaaaaaaaaaaaaaa")
|
||||||
|
|
||||||
|
func TestCollect_ProvesOneMatchingCertificatePerChallenge(t *testing.T) {
|
||||||
|
corpCA := certtest.NewCA(t, "corp-root")
|
||||||
|
otherCA := certtest.NewCA(t, "other-root")
|
||||||
|
unrelatedCA := certtest.NewCA(t, "unrelated-root")
|
||||||
|
|
||||||
|
dir := storeDir(t)
|
||||||
|
deviceKey := certtest.ECDSAKey(t)
|
||||||
|
device := corpCA.Issue(t, deviceKey, "device")
|
||||||
|
writeFile(t, dir, "device.pem", certtest.CertPEM(device)+certtest.KeyPEM(t, deviceKey))
|
||||||
|
|
||||||
|
otherKey := certtest.RSAKey(t)
|
||||||
|
writeFile(t, dir, "other.crt", certtest.CertPEM(otherCA.Issue(t, otherKey, "other")))
|
||||||
|
writeFile(t, dir, "other.key", certtest.KeyPEM(t, otherKey))
|
||||||
|
|
||||||
|
writeFile(t, dir, "keyless.crt", certtest.CertPEM(corpCA.Issue(t, certtest.ECDSAKey(t), "keyless")))
|
||||||
|
writeFile(t, dir, "notes.txt", "ignored")
|
||||||
|
|
||||||
|
challenger := certposture.NewChallenger([]byte("secret"))
|
||||||
|
nonce := challenger.Nonce(peerKey, time.Now())
|
||||||
|
challenge := func(cas ...string) *proto.Checks {
|
||||||
|
return &proto.Checks{CertificateChallenge: &proto.CertificateChallenge{Nonce: nonce, CaCertificates: cas}}
|
||||||
|
}
|
||||||
|
checks := []*proto.Checks{
|
||||||
|
{Files: []string{"/usr/bin/agent"}},
|
||||||
|
challenge(corpCA.PEM),
|
||||||
|
challenge(corpCA.PEM),
|
||||||
|
challenge(otherCA.PEM),
|
||||||
|
challenge(unrelatedCA.PEM),
|
||||||
|
challenge("not a pem"),
|
||||||
|
}
|
||||||
|
|
||||||
|
proofs := Collect(context.Background(), newFileStore(dir), checks, peerKey)
|
||||||
|
|
||||||
|
require.Len(t, proofs, 2)
|
||||||
|
var subjects []string
|
||||||
|
for _, p := range proofs {
|
||||||
|
chain, err := challenger.Verify(p, peerKey, time.Now())
|
||||||
|
require.NoError(t, err)
|
||||||
|
subjects = append(subjects, chain[0].Subject.CommonName)
|
||||||
|
}
|
||||||
|
assert.ElementsMatch(t, []string{"device", "other"}, subjects)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollect_NothingToProve(t *testing.T) {
|
||||||
|
dir := storeDir(t)
|
||||||
|
key := certtest.ECDSAKey(t)
|
||||||
|
ca := certtest.NewCA(t, "root")
|
||||||
|
writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, key, "device"))+certtest.KeyPEM(t, key))
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
store Store
|
||||||
|
checks []*proto.Checks
|
||||||
|
}{
|
||||||
|
{"no checks", newFileStore(dir), nil},
|
||||||
|
{"files only", newFileStore(dir), []*proto.Checks{{Files: []string{"/bin/x"}}}},
|
||||||
|
{"challenge without nonce", newFileStore(dir), []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{CaCertificates: []string{ca.PEM}}}}},
|
||||||
|
{"missing store dir", newFileStore(filepath.Join(dir, "missing")), []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{Nonce: make([]byte, certposture.NonceSize), CaCertificates: []string{ca.PEM}}}}},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Nil(t, Collect(context.Background(), tt.store, tt.checks, peerKey))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileStore_ChainWithIntermediate(t *testing.T) {
|
||||||
|
root := certtest.NewCA(t, "root")
|
||||||
|
intermediate := certtest.NewIntermediate(t, root, "intermediate")
|
||||||
|
key := certtest.ECDSAKey(t)
|
||||||
|
leaf := intermediate.Issue(t, key, "device")
|
||||||
|
|
||||||
|
dir := storeDir(t)
|
||||||
|
writeFile(t, dir, "device.pem", certtest.CertPEM(leaf)+certtest.CertPEM(intermediate.Cert)+certtest.KeyPEM(t, key))
|
||||||
|
|
||||||
|
candidates, err := newFileStore(dir).Candidates(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, candidates, 1)
|
||||||
|
require.Len(t, candidates[0].Chain, 2)
|
||||||
|
|
||||||
|
roots, err := certposture.ParseCAs([]string{root.PEM})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NoError(t, certposture.VerifyChain(candidates[0].Chain, roots, time.Now()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeDir is a PEM directory the store accepts: t.TempDir follows the umask, which
|
||||||
|
// leaves the directory group-writable on systems with a user-private group umask.
|
||||||
|
func storeDir(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.Chmod(dir, 0o700))
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFile(t *testing.T, dir, name, content string) {
|
||||||
|
t.Helper()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectChallenges_RefusesMalformedInput(t *testing.T) {
|
||||||
|
ca := certtest.NewCA(t, "corp-root")
|
||||||
|
dir := storeDir(t)
|
||||||
|
key := certtest.ECDSAKey(t)
|
||||||
|
writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, key, "device"))+certtest.KeyPEM(t, key))
|
||||||
|
store := newFileStore(dir)
|
||||||
|
nonce := certposture.NewChallenger([]byte("secret")).Nonce(peerKey, time.Now())
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
nonce []byte
|
||||||
|
peerKey []byte
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"issued nonce and peer key are signed", nonce, peerKey, 1},
|
||||||
|
{"short nonce is not signed", nonce[:8], peerKey, 0},
|
||||||
|
{"oversized nonce is not signed", append(append([]byte{}, nonce...), 0), peerKey, 0},
|
||||||
|
{"short peer key is not signed", nonce, peerKey[:16], 0},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
challenges := []*proto.CertificateChallenge{{Nonce: tt.nonce, CaCertificates: []string{ca.PEM}}}
|
||||||
|
assert.Len(t, CollectChallenges(context.Background(), store, challenges, tt.peerKey), tt.want,
|
||||||
|
"the device key signs only what management could have issued")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileStore_SkipsKeyOfAnotherCertificate(t *testing.T) {
|
||||||
|
ca := certtest.NewCA(t, "corp-root")
|
||||||
|
dir := storeDir(t)
|
||||||
|
|
||||||
|
// A stale key next to a renewed certificate, sorted before the good pair, must not
|
||||||
|
// produce a proof that management rejects and stop the search there.
|
||||||
|
writeFile(t, dir, "a-renewed.crt", certtest.CertPEM(ca.Issue(t, certtest.ECDSAKey(t), "renewed")))
|
||||||
|
writeFile(t, dir, "a-renewed.key", certtest.KeyPEM(t, certtest.ECDSAKey(t)))
|
||||||
|
goodKey := certtest.ECDSAKey(t)
|
||||||
|
good := ca.Issue(t, goodKey, "good")
|
||||||
|
writeFile(t, dir, "b-good.pem", certtest.CertPEM(good)+certtest.KeyPEM(t, goodKey))
|
||||||
|
|
||||||
|
candidates, err := newFileStore(dir).Candidates(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, candidates, 1, "only the certificate whose key matches is a candidate")
|
||||||
|
assert.True(t, good.Equal(candidates[0].Chain[0]), "the matching pair is kept")
|
||||||
|
|
||||||
|
challenger := certposture.NewChallenger([]byte("secret"))
|
||||||
|
now := time.Now()
|
||||||
|
nonce := challenger.Nonce(peerKey, now)
|
||||||
|
proofs := CollectChallenges(context.Background(), newFileStore(dir), []*proto.CertificateChallenge{{Nonce: nonce, CaCertificates: []string{ca.PEM}}}, peerKey)
|
||||||
|
require.Len(t, proofs, 1)
|
||||||
|
_, err = challenger.Verify(proofs[0], peerKey, now)
|
||||||
|
assert.NoError(t, err, "the proof sent is one management accepts")
|
||||||
|
}
|
||||||
|
|
||||||
|
// staticStore hands out fixed candidates, for scenarios no on-disk layout can express.
|
||||||
|
type staticStore []Candidate
|
||||||
|
|
||||||
|
func (s staticStore) Candidates(context.Context) ([]Candidate, error) { return s, nil }
|
||||||
|
|
||||||
|
func TestCollectChallenges_RoutesAroundExpiredCopyOfRenewedIntermediate(t *testing.T) {
|
||||||
|
root := certtest.NewCA(t, "root")
|
||||||
|
intermediate := certtest.NewIntermediate(t, root, "issuing-ca")
|
||||||
|
|
||||||
|
// Renewing a CA with the same key pair leaves two certificates with the same
|
||||||
|
// subject and key in the store. The expired one sorts first here.
|
||||||
|
expiredTmpl := *intermediate.Cert
|
||||||
|
expiredTmpl.SerialNumber = big.NewInt(1)
|
||||||
|
expiredTmpl.NotBefore = time.Now().Add(-72 * time.Hour)
|
||||||
|
expiredTmpl.NotAfter = time.Now().Add(-48 * time.Hour)
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, &expiredTmpl, root.Cert, intermediate.Key.Public(), root.Key)
|
||||||
|
require.NoError(t, err)
|
||||||
|
expired, err := x509.ParseCertificate(der)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
key := certtest.ECDSAKey(t)
|
||||||
|
leaf := intermediate.Issue(t, key, "device")
|
||||||
|
pool := []*x509.Certificate{expired, intermediate.Cert}
|
||||||
|
|
||||||
|
chain := buildChain(leaf, pool)
|
||||||
|
require.Len(t, chain, 2)
|
||||||
|
require.True(t, expired.Equal(chain[1]), "precondition: the first-match chain runs through the expired copy")
|
||||||
|
|
||||||
|
challenger := certposture.NewChallenger([]byte("secret"))
|
||||||
|
now := time.Now()
|
||||||
|
nonce := challenger.Nonce(peerKey, now)
|
||||||
|
store := staticStore{{Chain: chain, Signer: key, Intermediates: pool}}
|
||||||
|
|
||||||
|
proofs := CollectChallenges(context.Background(), store, []*proto.CertificateChallenge{{Nonce: nonce, CaCertificates: []string{root.PEM}}}, peerKey)
|
||||||
|
|
||||||
|
require.Len(t, proofs, 1, "a valid path through the renewed intermediate exists, so the challenge is answered")
|
||||||
|
verified, err := challenger.Verify(proofs[0], peerKey, now)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, intermediate.Cert.Equal(verified[1]), "the proof carries the valid intermediate, not the expired copy")
|
||||||
|
assert.True(t, certposture.ChainMatchesCAs(certposture.EncodeChainPEM(verified), []string{root.PEM}, now),
|
||||||
|
"management's own check accepts the chain the proof carries")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectChallenges_ProvesALeafOncePerDistinctChain(t *testing.T) {
|
||||||
|
rootA := certtest.NewCA(t, "root-a")
|
||||||
|
rootB := certtest.NewCA(t, "root-b")
|
||||||
|
issuer := certtest.NewIntermediate(t, rootA, "issuing-ca")
|
||||||
|
|
||||||
|
// The same issuing CA cross-signed by a second root: one leaf, two valid paths.
|
||||||
|
crossTmpl := *issuer.Cert
|
||||||
|
crossTmpl.SerialNumber = big.NewInt(2)
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, &crossTmpl, rootB.Cert, issuer.Key.Public(), rootB.Key)
|
||||||
|
require.NoError(t, err)
|
||||||
|
cross, err := x509.ParseCertificate(der)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
key := certtest.ECDSAKey(t)
|
||||||
|
leaf := issuer.Issue(t, key, "device")
|
||||||
|
pool := []*x509.Certificate{issuer.Cert, cross}
|
||||||
|
store := staticStore{{Chain: buildChain(leaf, pool), Signer: key, Intermediates: pool}}
|
||||||
|
|
||||||
|
challenger := certposture.NewChallenger([]byte("secret"))
|
||||||
|
now := time.Now()
|
||||||
|
nonce := challenger.Nonce(peerKey, now)
|
||||||
|
challenges := []*proto.CertificateChallenge{
|
||||||
|
{Nonce: nonce, CaCertificates: []string{rootA.PEM}},
|
||||||
|
{Nonce: nonce, CaCertificates: []string{rootB.PEM}},
|
||||||
|
{Nonce: nonce, CaCertificates: []string{rootA.PEM}},
|
||||||
|
}
|
||||||
|
|
||||||
|
proofs := CollectChallenges(context.Background(), store, challenges, peerKey)
|
||||||
|
|
||||||
|
require.Len(t, proofs, 2, "one proof per distinct chain, the repeated root-a challenge reuses the first")
|
||||||
|
for _, root := range []*certtest.CA{rootA, rootB} {
|
||||||
|
matched := false
|
||||||
|
for _, p := range proofs {
|
||||||
|
chain, err := challenger.Verify(p, peerKey, now)
|
||||||
|
require.NoError(t, err)
|
||||||
|
matched = matched || certposture.ChainMatchesCAs(certposture.EncodeChainPEM(chain), []string{root.PEM}, now)
|
||||||
|
}
|
||||||
|
assert.True(t, matched, "management can match a chain for the check that trusts %s", root.Cert.Subject.CommonName)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package certproof
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||||
|
"github.com/netbirdio/netbird/shared/management/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
const helperTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// CollectProofs answers the certificate challenges in checks from every store this
|
||||||
|
// machine can reach. The service reads the local machine store itself, where AD and
|
||||||
|
// Intune enrol device certificates, and reaches the signed-in user's store by launching
|
||||||
|
// a helper with that session's token. A machine at the sign-in screen therefore proves
|
||||||
|
// device certificates alone.
|
||||||
|
func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte, cfg Config) []certposture.Proof {
|
||||||
|
challenges := certificateChallenges(checks)
|
||||||
|
if len(challenges) == 0 {
|
||||||
|
logNoChallenges(checks)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
proofs := CollectChallenges(ctx, DefaultStore(), challenges, peerKey)
|
||||||
|
|
||||||
|
// The helper already runs as the signed-in user, and an ordinary process has no
|
||||||
|
// right to a session token, so only the service goes looking for one.
|
||||||
|
if !runningAsLocalSystem() || cfg.OwnerUnknown {
|
||||||
|
return proofs
|
||||||
|
}
|
||||||
|
|
||||||
|
userProofs, err := collectAsDesktopUser(ctx, cfg.ProfileOwner, challenges, peerKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("certificate posture: user certificate store unavailable: %v", err)
|
||||||
|
}
|
||||||
|
return mergeProofs(proofs, userProofs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserContext identifies the session whose store a collection would include: a session
|
||||||
|
// of the profile owner, or empty when no user store would be asked. A change means a
|
||||||
|
// collection made earlier no longer reflects what this machine can prove.
|
||||||
|
func UserContext(cfg Config) string {
|
||||||
|
if !runningAsLocalSystem() || cfg.OwnerUnknown {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
user, ok := currentDesktopUser(cfg.ProfileOwner)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer user.Close()
|
||||||
|
return fmt.Sprintf("%d:%s", user.Session, user.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// helperStore is the store the helper reads. It runs as the signed-in user, so it wants
|
||||||
|
// that user's store rather than the machine store the service already read.
|
||||||
|
func helperStore() Store {
|
||||||
|
return newUserStore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectAsDesktopUser runs the helper inside the interactive session of the signed-in
|
||||||
|
// user. Unlike a keychain on macOS, a Windows service can assume a user identity
|
||||||
|
// directly, so the session token goes straight into the child process.
|
||||||
|
func collectAsDesktopUser(ctx context.Context, owner string, challenges []*proto.CertificateChallenge, peerKey []byte) ([]certposture.Proof, error) {
|
||||||
|
user, ok := currentDesktopUser(owner)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
defer user.Close()
|
||||||
|
|
||||||
|
binary, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve own binary: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user's own environment, not the service's: the service environment may carry
|
||||||
|
// secrets such as a setup key that the signed-in user must not be able to read.
|
||||||
|
env, err := user.Token.Environ(false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build environment of %s: %w", user.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, helperTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, binary, "posture", "cert-proof")
|
||||||
|
cmd.Env = env
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
Token: syscall.Token(user.Token),
|
||||||
|
HideWindow: true,
|
||||||
|
CreationFlags: windows.CREATE_NO_WINDOW,
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("certificate posture: asking session %d to answer %d challenges", user.Session, len(challenges))
|
||||||
|
proofs, err := runHelperCmd(cmd, helperRequest(challenges, peerKey))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("run helper in session %d: %w", user.Session, err)
|
||||||
|
}
|
||||||
|
log.Debugf("certificate posture: session %d returned %d proofs", user.Session, len(proofs))
|
||||||
|
return proofs, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package certproof
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||||
|
"github.com/netbirdio/netbird/shared/management/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
collectTimeout = 45 * time.Second
|
||||||
|
|
||||||
|
// lostAfter is how many deadlines an abandoned collection is waited for before a new
|
||||||
|
// one may start beside it. A store that is merely slow finishes well within that and
|
||||||
|
// releases its slot; only one wedged for good is worked around.
|
||||||
|
lostAfter = 10
|
||||||
|
|
||||||
|
// maxInFlight bounds how many abandoned collections may pile up. A store that never
|
||||||
|
// answers would otherwise leak a goroutine on every collection for the life of the
|
||||||
|
// daemon.
|
||||||
|
maxInFlight = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// collectSlots tracks the collections running in the process. Collectors share one by
|
||||||
|
// default: a collection abandoned in a token, TPM or keychain call keeps running after
|
||||||
|
// the engine that started it has stopped, and the next engine must account for it.
|
||||||
|
type collectSlots struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
next uint64
|
||||||
|
// running holds the start time of each collection still running, by slot id.
|
||||||
|
running map[uint64]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var processSlots collectSlots
|
||||||
|
|
||||||
|
// Collector runs CollectProofs with a deadline and, in the normal case, one collection
|
||||||
|
// at a time in the process. Token, TPM and keychain calls cannot be interrupted, and on
|
||||||
|
// Linux there is not even a process to kill, so a collection that overruns is abandoned
|
||||||
|
// rather than awaited. A single wedged call would then refuse every later collection for
|
||||||
|
// the life of the daemon, so once an abandoned collection has been running for lostAfter
|
||||||
|
// deadlines another may start beside it, up to maxInFlight. The zero value is ready to
|
||||||
|
// use.
|
||||||
|
type Collector struct {
|
||||||
|
// slots overrides the process-wide slots when set.
|
||||||
|
slots *collectSlots
|
||||||
|
// timeout overrides collectTimeout when set.
|
||||||
|
timeout time.Duration
|
||||||
|
// now overrides the clock when set.
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect answers the certificate challenges in checks, returning no proofs when there
|
||||||
|
// are no challenges, when previous collections still hold the slots, or when this one
|
||||||
|
// does not finish in time. Missing proofs fail the certificate check on management.
|
||||||
|
func (c *Collector) Collect(ctx context.Context, checks []*proto.Checks, peerKey []byte, cfg Config) []certposture.Proof {
|
||||||
|
return c.collect(ctx, checks, func(ctx context.Context) []certposture.Proof {
|
||||||
|
return CollectProofs(ctx, checks, peerKey, cfg)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) collect(ctx context.Context, checks []*proto.Checks, run func(context.Context) []certposture.Proof) []certposture.Proof {
|
||||||
|
if len(certificateChallenges(checks)) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
slots := c.slotsInUse()
|
||||||
|
slot, ok := slots.start(c.clock(), time.Duration(lostAfter)*c.deadline())
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, c.deadline())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
done := make(chan []certposture.Proof, 1)
|
||||||
|
go func() {
|
||||||
|
// The slot is freed before the result is delivered, so a caller that starts the
|
||||||
|
// next collection right after this one returned is not turned away.
|
||||||
|
proofs := run(ctx)
|
||||||
|
slots.finish(slot)
|
||||||
|
done <- proofs
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case proofs := <-done:
|
||||||
|
return proofs
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.Warnf("certificate posture: proof collection did not finish within %s, sending no proofs", c.deadline())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stuck reports whether lost collections hold every slot. No collection starts again
|
||||||
|
// until one of them returns, which for a store that never answers means a restart.
|
||||||
|
func (c *Collector) Stuck() bool {
|
||||||
|
return c.slotsInUse().allLost(c.clock(), time.Duration(lostAfter)*c.deadline())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) slotsInUse() *collectSlots {
|
||||||
|
if c.slots != nil {
|
||||||
|
return c.slots
|
||||||
|
}
|
||||||
|
return &processSlots
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) clock() time.Time {
|
||||||
|
if c.now != nil {
|
||||||
|
return c.now()
|
||||||
|
}
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) deadline() time.Duration {
|
||||||
|
if c.timeout > 0 {
|
||||||
|
return c.timeout
|
||||||
|
}
|
||||||
|
return collectTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
// start claims a slot at now, reporting its id and whether the caller may collect. A
|
||||||
|
// collection that has held a slot for lost is taken to be wedged.
|
||||||
|
func (s *collectSlots) start(now time.Time, lost time.Duration) (uint64, bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if len(s.running) > 0 {
|
||||||
|
oldest := s.oldest()
|
||||||
|
switch {
|
||||||
|
case len(s.running) >= maxInFlight:
|
||||||
|
log.Warnf("certificate posture: %d proof collections are wedged, sending no proofs", len(s.running))
|
||||||
|
return 0, false
|
||||||
|
case now.Sub(oldest) < lost:
|
||||||
|
log.Warnf("certificate posture: previous proof collection is still running, sending no proofs")
|
||||||
|
return 0, false
|
||||||
|
default:
|
||||||
|
log.Warnf("certificate posture: a proof collection has been stuck since %s, starting another", oldest.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.running == nil {
|
||||||
|
s.running = make(map[uint64]time.Time, maxInFlight)
|
||||||
|
}
|
||||||
|
s.next++
|
||||||
|
s.running[s.next] = now
|
||||||
|
return s.next, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// finish releases the slot with the given id.
|
||||||
|
func (s *collectSlots) finish(slot uint64) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
delete(s.running, slot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// allLost reports whether every slot is held by a collection that has run for lost.
|
||||||
|
func (s *collectSlots) allLost(now time.Time, lost time.Duration) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if len(s.running) < maxInFlight {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, started := range s.running {
|
||||||
|
if now.Sub(started) < lost {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// idle reports whether no collection is running.
|
||||||
|
func (s *collectSlots) idle() bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return len(s.running) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// oldest returns the start time of the longest-running collection. The caller holds mu
|
||||||
|
// and has checked that one is running.
|
||||||
|
func (s *collectSlots) oldest() time.Time {
|
||||||
|
var oldest time.Time
|
||||||
|
for _, started := range s.running {
|
||||||
|
if oldest.IsZero() || started.Before(oldest) {
|
||||||
|
oldest = started
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return oldest
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package certproof
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||||
|
"github.com/netbirdio/netbird/shared/management/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
var challengeChecks = []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{
|
||||||
|
Nonce: certposture.NewChallenger([]byte("secret")).Nonce(peerKey, time.Now()),
|
||||||
|
}}}
|
||||||
|
|
||||||
|
func TestCollector_SkipsChecksWithoutChallenges(t *testing.T) {
|
||||||
|
var c Collector
|
||||||
|
called := false
|
||||||
|
|
||||||
|
proofs := c.collect(context.Background(), []*proto.Checks{{Files: []string{"/bin/agent"}}}, func(context.Context) []certposture.Proof {
|
||||||
|
called = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Nil(t, proofs)
|
||||||
|
assert.False(t, called, "no store is touched when no check carries a challenge")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollector_ReturnsProofs(t *testing.T) {
|
||||||
|
var c Collector
|
||||||
|
want := []certposture.Proof{{Nonce: []byte("nonce")}}
|
||||||
|
|
||||||
|
proofs := c.collect(context.Background(), challengeChecks, func(context.Context) []certposture.Proof { return want })
|
||||||
|
|
||||||
|
assert.Equal(t, want, proofs, "a collection that finishes in time is returned as is")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollector_AbandonsStuckCollection(t *testing.T) {
|
||||||
|
c := Collector{timeout: 50 * time.Millisecond, slots: &collectSlots{}}
|
||||||
|
release := make(chan struct{})
|
||||||
|
finished := make(chan struct{})
|
||||||
|
|
||||||
|
// A token or keychain call that ignores its context and blocks well past the deadline.
|
||||||
|
stuck := func(context.Context) []certposture.Proof {
|
||||||
|
defer close(finished)
|
||||||
|
<-release
|
||||||
|
return []certposture.Proof{{Nonce: []byte("late")}}
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
proofs := c.collect(context.Background(), challengeChecks, stuck)
|
||||||
|
assert.Nil(t, proofs, "an overrunning collection yields no proofs")
|
||||||
|
assert.Less(t, time.Since(start), time.Second, "the caller is released at the deadline, not when the call returns")
|
||||||
|
|
||||||
|
called := false
|
||||||
|
proofs = c.collect(context.Background(), challengeChecks, func(context.Context) []certposture.Proof {
|
||||||
|
called = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
assert.Nil(t, proofs)
|
||||||
|
assert.False(t, called, "no second collection starts while the first is still running")
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
<-finished
|
||||||
|
require.Eventually(t, func() bool { return c.slots.idle() }, time.Second, 5*time.Millisecond, "the collector frees up once the stuck call returns")
|
||||||
|
|
||||||
|
want := []certposture.Proof{{Nonce: []byte("nonce")}}
|
||||||
|
assert.Equal(t, want, c.collect(context.Background(), challengeChecks, func(context.Context) []certposture.Proof { return want }),
|
||||||
|
"collection works again after the stuck call returned")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollector_CancelsContextAtDeadline(t *testing.T) {
|
||||||
|
c := Collector{timeout: 20 * time.Millisecond, slots: &collectSlots{}}
|
||||||
|
cancelled := make(chan struct{})
|
||||||
|
|
||||||
|
c.collect(context.Background(), challengeChecks, func(ctx context.Context) []certposture.Proof {
|
||||||
|
<-ctx.Done()
|
||||||
|
close(cancelled)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-cancelled:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("a collection that honours its context, like the helper process, must see it cancelled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCollector_SingleFlightAcrossCollectors: each engine has its own Collector, and a
|
||||||
|
// collection stuck in a token call outlives the engine that started it, so the next
|
||||||
|
// engine's Collector must not start another one until it has finished.
|
||||||
|
func TestCollector_SingleFlightAcrossCollectors(t *testing.T) {
|
||||||
|
shared := &collectSlots{}
|
||||||
|
first := Collector{timeout: 20 * time.Millisecond, slots: shared}
|
||||||
|
second := Collector{timeout: time.Second, slots: shared}
|
||||||
|
|
||||||
|
release := make(chan struct{})
|
||||||
|
stuck := func(context.Context) []certposture.Proof {
|
||||||
|
<-release
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
assert.Nil(t, first.collect(context.Background(), challengeChecks, stuck), "the first collection is abandoned at its deadline")
|
||||||
|
|
||||||
|
called := false
|
||||||
|
proofs := second.collect(context.Background(), challengeChecks, func(context.Context) []certposture.Proof {
|
||||||
|
called = true
|
||||||
|
return []certposture.Proof{{}}
|
||||||
|
})
|
||||||
|
assert.Nil(t, proofs, "no proofs while the abandoned collection still runs")
|
||||||
|
assert.False(t, called, "a second collection does not start on top of the abandoned one")
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
require.Eventually(t, func() bool { return shared.idle() }, time.Second, 5*time.Millisecond)
|
||||||
|
assert.Len(t, second.collect(context.Background(), challengeChecks, func(context.Context) []certposture.Proof { return []certposture.Proof{{}} }), 1,
|
||||||
|
"collections resume once the abandoned one finished")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCollector_ZeroValuesShareTheProcessFlag: the zero value uses the process-wide flag.
|
||||||
|
func TestCollector_ZeroValuesShareTheProcessFlag(t *testing.T) {
|
||||||
|
var a, b Collector
|
||||||
|
assert.Same(t, a.slotsInUse(), b.slotsInUse(), "separate Collectors share the process slots")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCollector_LostCollectionDoesNotBlockForever: a store call that never answers, such
|
||||||
|
// as a wedged token on Linux where there is no process to kill, must not refuse every
|
||||||
|
// later collection. Once it has run for lostAfter deadlines another starts beside it,
|
||||||
|
// and no more than maxInFlight ever run.
|
||||||
|
func TestCollector_LostCollectionDoesNotBlockForever(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
slots := &collectSlots{}
|
||||||
|
c := Collector{timeout: 10 * time.Millisecond, slots: slots, now: func() time.Time { return now }}
|
||||||
|
|
||||||
|
release := make(chan struct{})
|
||||||
|
defer close(release)
|
||||||
|
var started atomic.Int32
|
||||||
|
wedged := func(context.Context) []certposture.Proof {
|
||||||
|
started.Add(1)
|
||||||
|
<-release
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
c.collect(context.Background(), challengeChecks, wedged)
|
||||||
|
waitStarted(t, &started, 1)
|
||||||
|
c.collect(context.Background(), challengeChecks, wedged)
|
||||||
|
assert.Equal(t, int32(1), started.Load(), "a second collection is refused while the first may still finish")
|
||||||
|
assert.False(t, c.Stuck(), "one slow collection is not stuck yet")
|
||||||
|
|
||||||
|
now = now.Add(time.Duration(lostAfter) * c.deadline())
|
||||||
|
c.collect(context.Background(), challengeChecks, wedged)
|
||||||
|
waitStarted(t, &started, 2)
|
||||||
|
assert.False(t, c.Stuck(), "the second collection may still return, so the collector is not stuck yet")
|
||||||
|
|
||||||
|
now = now.Add(time.Duration(lostAfter) * c.deadline())
|
||||||
|
c.collect(context.Background(), challengeChecks, wedged)
|
||||||
|
assert.Equal(t, int32(2), started.Load(), "no more than maxInFlight collections run")
|
||||||
|
assert.True(t, c.Stuck(), "with every slot held by a lost collection the collector reports itself stuck")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCollector_LostWindowFollowsTheOldestRunningCollection: A wedges, B starts beside it
|
||||||
|
// once A counts as lost, then A returns. B is recent, so a third collection must wait for
|
||||||
|
// B to be lost in its own right rather than inherit A's start time.
|
||||||
|
func TestCollector_LostWindowFollowsTheOldestRunningCollection(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
slots := &collectSlots{}
|
||||||
|
c := Collector{timeout: 10 * time.Millisecond, slots: slots, now: func() time.Time { return now }}
|
||||||
|
lost := time.Duration(lostAfter) * c.deadline()
|
||||||
|
|
||||||
|
releaseA := make(chan struct{})
|
||||||
|
releaseB := make(chan struct{})
|
||||||
|
defer close(releaseB)
|
||||||
|
var started atomic.Int32
|
||||||
|
blockOn := func(release chan struct{}) func(context.Context) []certposture.Proof {
|
||||||
|
return func(context.Context) []certposture.Proof {
|
||||||
|
started.Add(1)
|
||||||
|
<-release
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.collect(context.Background(), challengeChecks, blockOn(releaseA))
|
||||||
|
waitStarted(t, &started, 1)
|
||||||
|
now = now.Add(lost)
|
||||||
|
c.collect(context.Background(), challengeChecks, blockOn(releaseB))
|
||||||
|
waitStarted(t, &started, 2)
|
||||||
|
|
||||||
|
close(releaseA)
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
slots.mu.Lock()
|
||||||
|
defer slots.mu.Unlock()
|
||||||
|
return len(slots.running) == 1
|
||||||
|
}, time.Second, 5*time.Millisecond, "A frees its slot once it returns")
|
||||||
|
|
||||||
|
c.collect(context.Background(), challengeChecks, blockOn(make(chan struct{})))
|
||||||
|
assert.Equal(t, int32(2), started.Load(), "a third collection waits while B is recent")
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitStarted waits for the collections the test launched to have started. collect may
|
||||||
|
// return at its deadline before the goroutine running the store call was scheduled.
|
||||||
|
func waitStarted(t *testing.T, started *atomic.Int32, n int32) {
|
||||||
|
t.Helper()
|
||||||
|
require.Eventually(t, func() bool { return started.Load() == n }, time.Second, time.Millisecond,
|
||||||
|
"%d collections should have started", n)
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
//go:build !ios
|
||||||
|
|
||||||
|
package certproof
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/ebitengine/purego"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
systemConfigurationFramework = "/System/Library/Frameworks/SystemConfiguration.framework/SystemConfiguration"
|
||||||
|
|
||||||
|
encodingUTF8 = 0x08000100
|
||||||
|
consoleNameBufSize = 256
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
consoleOnce sync.Once
|
||||||
|
consoleErr error
|
||||||
|
|
||||||
|
scDynamicStoreCopyConsoleUser func(store uintptr, uid, gid *uint32) uintptr
|
||||||
|
cfStringGetCString func(str uintptr, buffer *byte, size int, encoding uint32) bool
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConsoleUser is the account whose desktop session owns the display. Its login keychain
|
||||||
|
// is the only user keychain a NetBird daemon can reach, and only while it is logged in.
|
||||||
|
type ConsoleUser struct {
|
||||||
|
Name string
|
||||||
|
UID uint32
|
||||||
|
GID uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentConsoleUser reports the user sitting at the desktop. The second return value is
|
||||||
|
// false when nobody is: at the login window macOS either reports no console user at all
|
||||||
|
// or attributes the session to root, and neither has a login keychain to offer.
|
||||||
|
func currentConsoleUser() (ConsoleUser, bool) {
|
||||||
|
if err := loadConsoleUser(); err != nil {
|
||||||
|
log.Debugf("console user lookup unavailable: %v", err)
|
||||||
|
return ConsoleUser{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var uid, gid uint32
|
||||||
|
name := scDynamicStoreCopyConsoleUser(0, &uid, &gid)
|
||||||
|
if name == 0 {
|
||||||
|
log.Debug("no console user is logged in, no login keychain is reachable")
|
||||||
|
return ConsoleUser{}, false
|
||||||
|
}
|
||||||
|
defer release(name)
|
||||||
|
|
||||||
|
user := ConsoleUser{Name: cfString(name), UID: uid, GID: gid}
|
||||||
|
if !user.hasDesktop() {
|
||||||
|
log.Debugf("console session belongs to %q uid=%d, which is not a desktop login, no login keychain is reachable", user.Name, user.UID)
|
||||||
|
return ConsoleUser{}, false
|
||||||
|
}
|
||||||
|
return user, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasDesktop reports whether the console session is a real user desktop. The login
|
||||||
|
// window runs as root and some macOS releases name it "loginwindow" instead.
|
||||||
|
func (u ConsoleUser) hasDesktop() bool {
|
||||||
|
switch u.Name {
|
||||||
|
case "", "root", "loginwindow":
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return u.UID != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// isOwner reports whether the console user is owner, the account of the active profile,
|
||||||
|
// which is recorded as a short user name or, for an account without one, a numeric uid.
|
||||||
|
// With no owner the console user counts, as macOS has a single console user.
|
||||||
|
func (u ConsoleUser) isOwner(owner string) bool {
|
||||||
|
return owner == "" || owner == u.Name || owner == strconv.FormatUint(uint64(u.UID), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cfString(str uintptr) string {
|
||||||
|
buf := make([]byte, consoleNameBufSize)
|
||||||
|
if !cfStringGetCString(str, &buf[0], len(buf), encodingUTF8) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if end := bytes.IndexByte(buf, 0); end >= 0 {
|
||||||
|
return string(buf[:end])
|
||||||
|
}
|
||||||
|
return string(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadConsoleUser resolves the console user symbols. It loads the keychain bindings
|
||||||
|
// first because CFRelease is resolved there and released strings depend on it.
|
||||||
|
func loadConsoleUser() error {
|
||||||
|
if err := loadKeychain(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
consoleOnce.Do(func() { consoleErr = resolveConsoleUser() })
|
||||||
|
return consoleErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveConsoleUser() error {
|
||||||
|
systemConfiguration, err := purego.Dlopen(systemConfigurationFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open %s: %w", systemConfigurationFramework, err)
|
||||||
|
}
|
||||||
|
coreFoundation, err := purego.Dlopen(coreFoundationFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open %s: %w", coreFoundationFramework, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
symbol, err := purego.Dlsym(systemConfiguration, "SCDynamicStoreCopyConsoleUser")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve SCDynamicStoreCopyConsoleUser: %w", err)
|
||||||
|
}
|
||||||
|
purego.RegisterFunc(&scDynamicStoreCopyConsoleUser, symbol)
|
||||||
|
|
||||||
|
symbol, err = purego.Dlsym(coreFoundation, "CFStringGetCString")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve CFStringGetCString: %w", err)
|
||||||
|
}
|
||||||
|
purego.RegisterFunc(&cfStringGetCString, symbol)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
//go:build !ios
|
||||||
|
|
||||||
|
package certproof
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConsoleUser_OnlyADesktopSessionCanBeValidated(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
user ConsoleUser
|
||||||
|
desktop bool
|
||||||
|
}{
|
||||||
|
{"logged in user", ConsoleUser{Name: "maycon", UID: 501, GID: 20}, true},
|
||||||
|
{"login window as root", ConsoleUser{Name: "root", UID: 0}, false},
|
||||||
|
{"login window by name", ConsoleUser{Name: "loginwindow", UID: 0}, false},
|
||||||
|
{"named user still at uid 0", ConsoleUser{Name: "admin", UID: 0}, false},
|
||||||
|
{"no console user", ConsoleUser{}, false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.desktop, tt.user.hasDesktop(), "only a real desktop session offers a login keychain")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentConsoleUser runs against the real SystemConfiguration framework. A machine with
|
||||||
|
// a desktop open must report a non-root user; a headless runner must report none.
|
||||||
|
func TestCurrentConsoleUser_AgreesWithItself(t *testing.T) {
|
||||||
|
user, ok := currentConsoleUser()
|
||||||
|
if !ok {
|
||||||
|
t.Log("no console user, running headless")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.NotEmpty(t, user.Name, "a console user must have a name")
|
||||||
|
assert.NotZero(t, user.UID, "a desktop session never belongs to uid 0")
|
||||||
|
assert.True(t, user.hasDesktop(), "a reported console user must be a desktop session")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleUser_IsOwner(t *testing.T) {
|
||||||
|
user := ConsoleUser{Name: "maycon", UID: 501, GID: 20}
|
||||||
|
|
||||||
|
assert.True(t, user.isOwner(""), "a profile without owner accepts the single console user")
|
||||||
|
assert.True(t, user.isOwner("maycon"), "the owner by short name")
|
||||||
|
assert.True(t, user.isOwner("501"), "the owner recorded as a numeric uid")
|
||||||
|
assert.False(t, user.isOwner("viktor"), "another account's profile must not read this user's keychain")
|
||||||
|
assert.False(t, user.isOwner("502"), "another uid is another account")
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package certproof
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
noActiveSession = 0xFFFFFFFF
|
||||||
|
servicesSession = 0
|
||||||
|
|
||||||
|
// wtsCurrentServer is WTS_CURRENT_SERVER_HANDLE, which x/sys/windows does not export.
|
||||||
|
wtsCurrentServer = windows.Handle(0)
|
||||||
|
)
|
||||||
|
|
||||||
|
// DesktopUser is an interactive session and the account signed into it. The user's
|
||||||
|
// certificate store is readable only from a process running as that account, because
|
||||||
|
// its private keys are protected against the user profile rather than the machine.
|
||||||
|
type DesktopUser struct {
|
||||||
|
Session uint32
|
||||||
|
Name string
|
||||||
|
Token windows.Token
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the session token.
|
||||||
|
func (u DesktopUser) Close() {
|
||||||
|
if err := u.Token.Close(); err != nil {
|
||||||
|
log.Debugf("failed closing desktop session token: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentDesktopUser returns a token for the session whose certificate store should be
|
||||||
|
// asked: a session of owner, the account the active profile belongs to, with the
|
||||||
|
// physical console preferred over remote sessions. With no owner only the console user
|
||||||
|
// counts. Picking any signed-in user instead would let whoever else is logged in to a
|
||||||
|
// terminal server or VDI host decide the result. The second return is false when no
|
||||||
|
// such session exists, and only machine certificates can then be proven.
|
||||||
|
//
|
||||||
|
// Obtaining the token needs SE_TCB_NAME, which the LocalSystem service has and an
|
||||||
|
// ordinary process does not.
|
||||||
|
func currentDesktopUser(owner string) (DesktopUser, bool) {
|
||||||
|
console := windows.WTSGetActiveConsoleSessionId()
|
||||||
|
if owner == "" {
|
||||||
|
return consoleUser(console)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions, err := userSessions(console)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("cannot enumerate terminal sessions: %v", err)
|
||||||
|
return DesktopUser{}, false
|
||||||
|
}
|
||||||
|
var found DesktopUser
|
||||||
|
var ok bool
|
||||||
|
for _, session := range sessions {
|
||||||
|
user, signedIn := desktopUser(session)
|
||||||
|
if !signedIn {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case !sameAccountName(user.Name, owner):
|
||||||
|
user.Close()
|
||||||
|
case !ok:
|
||||||
|
found, ok = user, true
|
||||||
|
case strings.EqualFold(user.Name, found.Name):
|
||||||
|
// Another session of the same account; the first one in preference order wins.
|
||||||
|
user.Close()
|
||||||
|
default:
|
||||||
|
// An owner recorded without a domain matches accounts of several domains here.
|
||||||
|
// Picking one would let another domain's user answer for the owner.
|
||||||
|
log.Debugf("certificate posture: profile owner %s matches both %s and %s, no user certificate store is used", owner, found.Name, user.Name)
|
||||||
|
user.Close()
|
||||||
|
found.Close()
|
||||||
|
return DesktopUser{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
log.Debugf("certificate posture: profile owner %s has no signed-in session, no user certificate store is reachable", owner)
|
||||||
|
}
|
||||||
|
return found, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func consoleUser(console uint32) (DesktopUser, bool) {
|
||||||
|
if console == noActiveSession || console == servicesSession {
|
||||||
|
log.Debug("no console session, no user certificate store is reachable")
|
||||||
|
return DesktopUser{}, false
|
||||||
|
}
|
||||||
|
user, ok := desktopUser(console)
|
||||||
|
if !ok {
|
||||||
|
log.Debugf("console session %d has nobody signed in, no user certificate store is reachable", console)
|
||||||
|
}
|
||||||
|
return user, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameAccountName compares DOMAIN\account names case-insensitively, as Windows does, and an
|
||||||
|
// owner given without a domain against the account part alone. The session side comes from
|
||||||
|
// the session token's own SID, which Windows resolves from its cache of signed-in users.
|
||||||
|
// Resolving the owner name to a SID instead would ask the domain controller, which on a
|
||||||
|
// laptop that cannot reach it yet blocks for tens of seconds, past the collection deadline.
|
||||||
|
func sameAccountName(sessionName, owner string) bool {
|
||||||
|
if strings.EqualFold(sessionName, owner) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.Contains(owner, `\`) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, account, found := strings.Cut(sessionName, `\`)
|
||||||
|
return found && strings.EqualFold(account, owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
func desktopUser(session uint32) (DesktopUser, bool) {
|
||||||
|
var token windows.Token
|
||||||
|
if err := windows.WTSQueryUserToken(session, &token); err != nil {
|
||||||
|
log.Debugf("no user token for session %d: %v", session, err)
|
||||||
|
return DesktopUser{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
name, err := tokenAccount(token)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("session %d token has no readable account: %v", session, err)
|
||||||
|
if closeErr := token.Close(); closeErr != nil {
|
||||||
|
log.Debugf("failed closing session token: %v", closeErr)
|
||||||
|
}
|
||||||
|
return DesktopUser{}, false
|
||||||
|
}
|
||||||
|
return DesktopUser{Session: session, Name: name, Token: token}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenAccount(token windows.Token) (string, error) {
|
||||||
|
user, err := token.GetTokenUser()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read token user: %w", err)
|
||||||
|
}
|
||||||
|
account, domain, _, err := user.User.Sid.LookupAccount("")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("look up account: %w", err)
|
||||||
|
}
|
||||||
|
if domain == "" {
|
||||||
|
return account, nil
|
||||||
|
}
|
||||||
|
return domain + `\` + account, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// userSessions lists the sessions a user can be signed in to, console first, then
|
||||||
|
// active remote sessions, then disconnected ones, whose user is still signed in. Session
|
||||||
|
// 0 is skipped: it hosts services and never belongs to an interactive user.
|
||||||
|
func userSessions(console uint32) ([]uint32, error) {
|
||||||
|
var info *windows.WTS_SESSION_INFO
|
||||||
|
var count uint32
|
||||||
|
if err := windows.WTSEnumerateSessions(wtsCurrentServer, 0, 1, &info, &count); err != nil {
|
||||||
|
return nil, fmt.Errorf("enumerate sessions: %w", err)
|
||||||
|
}
|
||||||
|
defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(info)))
|
||||||
|
|
||||||
|
var sessions []uint32
|
||||||
|
if console != noActiveSession && console != servicesSession {
|
||||||
|
sessions = append(sessions, console)
|
||||||
|
}
|
||||||
|
for _, state := range []uint32{windows.WTSActive, windows.WTSDisconnected} {
|
||||||
|
for _, session := range unsafe.Slice(info, count) {
|
||||||
|
if session.SessionID == servicesSession || session.SessionID == console || session.State != state {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sessions = append(sessions, session.SessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sessions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runningAsLocalSystem reports whether this process is the service. The helper runs as
|
||||||
|
// the signed-in user and must read its own store rather than launching another helper.
|
||||||
|
func runningAsLocalSystem() bool {
|
||||||
|
user, err := windows.GetCurrentProcessToken().GetTokenUser()
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("failed reading own token user: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return user.User.Sid.IsWellKnown(windows.WinLocalSystemSid)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package certproof
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSameAccountName(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
session, owner string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{`CORP\alice`, `CORP\alice`, true},
|
||||||
|
{`CORP\alice`, `corp\ALICE`, true},
|
||||||
|
{`CORP\alice`, `alice`, true},
|
||||||
|
{`CORP\alice`, `OTHER\alice`, false},
|
||||||
|
{`CORP\alice`, `bob`, false},
|
||||||
|
{`alice`, `alice`, true},
|
||||||
|
{`CORP\alice`, `CORP\alic`, false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
assert.Equal(t, tt.want, sameAccountName(tt.session, tt.owner), "session %q, owner %q", tt.session, tt.owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentDesktopUser against the real session manager: an owner no session belongs to
|
||||||
|
// must never yield a session, whoever else is signed in.
|
||||||
|
func TestCurrentDesktopUser_UnknownOwnerHasNoSession(t *testing.T) {
|
||||||
|
user, ok := currentDesktopUser(`NO-SUCH-DOMAIN\no-such-user-netbird`)
|
||||||
|
if ok {
|
||||||
|
user.Close()
|
||||||
|
}
|
||||||
|
assert.False(t, ok, "a profile owner without a session must not borrow another user's store")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCurrentDesktopUser_FindsTheOwnersSession runs against a real machine as LocalSystem,
|
||||||
|
// with NB_TEST_DESKTOP_OWNER naming an account that is signed in. That account's session
|
||||||
|
// must be found, and only that account's.
|
||||||
|
func TestCurrentDesktopUser_FindsTheOwnersSession(t *testing.T) {
|
||||||
|
owner := os.Getenv("NB_TEST_DESKTOP_OWNER")
|
||||||
|
if owner == "" {
|
||||||
|
t.Skip("set NB_TEST_DESKTOP_OWNER to a signed-in account and run as LocalSystem")
|
||||||
|
}
|
||||||
|
user, ok := currentDesktopUser(owner)
|
||||||
|
require.True(t, ok, "the signed-in owner %s must have a session", owner)
|
||||||
|
defer user.Close()
|
||||||
|
t.Logf("owner %s resolved to session %d as %s", owner, user.Session, user.Name)
|
||||||
|
assert.True(t, sameAccountName(user.Name, owner), "the session found belongs to %s, not %s", owner, user.Name)
|
||||||
|
assert.NotZero(t, user.Session, "session 0 hosts services and never belongs to the owner")
|
||||||
|
|
||||||
|
console, consoleOK := currentDesktopUser("")
|
||||||
|
if consoleOK {
|
||||||
|
defer console.Close()
|
||||||
|
t.Logf("without an owner the console user counts: session %d as %s", console.Session, console.Name)
|
||||||
|
} else {
|
||||||
|
t.Log("without an owner nobody counts: no user at the console")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Package certproof answers certificate posture challenges: it finds the certificates a
|
||||||
|
// peer holds a private key for and signs the challenge nonce, bound to the peer's
|
||||||
|
// WireGuard key, with that key. Management verifies the signature and the chain against
|
||||||
|
// the CAs of the check, so only a holder of the key passes and a proof made for one peer
|
||||||
|
// cannot be replayed by another. Keys in the OS stores, the TPM and a PKCS#11 token are
|
||||||
|
// used through the platform, which signs; a plain PEM key file is the exception, parsed
|
||||||
|
// and used in the daemon's memory.
|
||||||
|
//
|
||||||
|
// Machine stores are read by the daemon itself: the Windows LocalMachine store, the macOS
|
||||||
|
// System keychain, and on Linux a directory of PEM files, TSS2 key files the TPM signs
|
||||||
|
// with, and a PKCS#11 token. On Linux the directory and the token URI come from the
|
||||||
|
// daemon's environment (NB_CERT_STORE_DIR, NB_CERT_PKCS11_URI), as does the token PIN
|
||||||
|
// (NB_TPM_PIN); none of them is read from the profile config.
|
||||||
|
//
|
||||||
|
// User stores cannot be read by a privileged daemon, so it starts this binary as
|
||||||
|
// "netbird posture cert-proof" inside the user's session and receives only signatures
|
||||||
|
// and chains on its stdout. On macOS uid is not what unlocks a login keychain, the
|
||||||
|
// session's bootstrap namespace is, which is why the helper enters it through launchctl
|
||||||
|
// asuser. On Windows a service opening CURRENT_USER silently reads its own empty hive, so
|
||||||
|
// the helper runs with the session's token instead. Its output is untrusted: sizes are
|
||||||
|
// capped and only proofs for requested nonces are kept.
|
||||||
|
//
|
||||||
|
// A user proof shows that some process in that user's session could use a key whose
|
||||||
|
// certificate chains to the CA. It does not show which binary signed, or that the key is
|
||||||
|
// hardware-bound; device stores are the stronger signal.
|
||||||
|
package certproof
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package certproof
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||||
|
"github.com/netbirdio/netbird/shared/management/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HelperRequest is the work the daemon hands to a helper running in a user session. The
|
||||||
|
// peer key binds every signature to this machine, so a proof cannot be replayed onto
|
||||||
|
// another peer.
|
||||||
|
type HelperRequest struct {
|
||||||
|
PeerKey []byte `json:"peerKey"`
|
||||||
|
Challenges []HelperChallenge `json:"challenges"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HelperChallenge is one certificate challenge in the form the helper needs.
|
||||||
|
type HelperChallenge struct {
|
||||||
|
Nonce []byte `json:"nonce"`
|
||||||
|
CACertificates []string `json:"caCertificates"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HelperResponse carries the proofs the helper produced from its own keychain.
|
||||||
|
type HelperResponse struct {
|
||||||
|
Proofs []certposture.Proof `json:"proofs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunHelper answers the challenges on in from the store of the user running this
|
||||||
|
// process and writes the proofs to out. It is the child half of the console user
|
||||||
|
// lookup: the daemon cannot read a login keychain, so it launches this in the user's
|
||||||
|
// session instead. Only the signature crosses back, never the private key.
|
||||||
|
func RunHelper(ctx context.Context, in io.Reader, out io.Writer) error {
|
||||||
|
return runHelper(ctx, helperStore(), in, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runHelper(ctx context.Context, store Store, in io.Reader, out io.Writer) error {
|
||||||
|
var req HelperRequest
|
||||||
|
if err := json.NewDecoder(in).Decode(&req); err != nil {
|
||||||
|
return fmt.Errorf("decode helper request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
challenges := make([]*proto.CertificateChallenge, 0, len(req.Challenges))
|
||||||
|
for _, challenge := range req.Challenges {
|
||||||
|
challenges = append(challenges, &proto.CertificateChallenge{
|
||||||
|
Nonce: challenge.Nonce,
|
||||||
|
CaCertificates: challenge.CACertificates,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var proofs []certposture.Proof
|
||||||
|
if len(challenges) > 0 {
|
||||||
|
proofs = CollectChallenges(ctx, store, challenges, req.PeerKey)
|
||||||
|
}
|
||||||
|
log.Debugf("certificate posture helper: answering %d challenges with %d proofs", len(challenges), len(proofs))
|
||||||
|
|
||||||
|
if err := json.NewEncoder(out).Encode(HelperResponse{Proofs: proofs}); err != nil {
|
||||||
|
return fmt.Errorf("encode helper response: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package certproof
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/shared/management/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// helperQuietPeriod is how long a user whose helper proved nothing is not asked again
|
||||||
|
// for the same CAs. On macOS each helper run may show a keychain prompt, so retrying on
|
||||||
|
// every collection would put up a new one each time the user ignored or denied the last.
|
||||||
|
const helperQuietPeriod = time.Hour
|
||||||
|
|
||||||
|
// helperBackoff remembers, per user and set of CAs asked about, until when the helper
|
||||||
|
// is not launched again because its last run proved nothing.
|
||||||
|
type helperBackoff struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
until map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHelperBackoff() *helperBackoff {
|
||||||
|
return &helperBackoff{until: map[string]time.Time{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// allow reports whether the helper may be launched for key now.
|
||||||
|
func (b *helperBackoff) allow(key string, now time.Time) bool {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return !now.Before(b.until[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
// record stores the outcome of a helper run for key: one that proved something clears
|
||||||
|
// the back-off, one that proved nothing starts it. Expired entries are dropped, so the
|
||||||
|
// map holds only users currently held off.
|
||||||
|
func (b *helperBackoff) record(key string, proven bool, now time.Time) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
for other, until := range b.until {
|
||||||
|
if !now.Before(until) {
|
||||||
|
delete(b.until, other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if proven {
|
||||||
|
delete(b.until, key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.until[key] = now.Add(helperQuietPeriod)
|
||||||
|
}
|
||||||
|
|
||||||
|
// helperBackoffKey identifies a user and the set of CAs the challenges accept, in any
|
||||||
|
// order, leaving out the nonces, which rotate without changing what the user is asked to
|
||||||
|
// prove.
|
||||||
|
func helperBackoffKey(user string, challenges []*proto.CertificateChallenge) string {
|
||||||
|
sets := make([]string, 0, len(challenges))
|
||||||
|
for _, challenge := range challenges {
|
||||||
|
cas := make([]string, 0, len(challenge.GetCaCertificates()))
|
||||||
|
for _, ca := range challenge.GetCaCertificates() {
|
||||||
|
sum := sha256.Sum256([]byte(ca))
|
||||||
|
cas = append(cas, hex.EncodeToString(sum[:]))
|
||||||
|
}
|
||||||
|
slices.Sort(cas)
|
||||||
|
sets = append(sets, strings.Join(cas, ","))
|
||||||
|
}
|
||||||
|
slices.Sort(sets)
|
||||||
|
|
||||||
|
h := sha256.New()
|
||||||
|
h.Write([]byte(user))
|
||||||
|
for _, set := range sets {
|
||||||
|
h.Write([]byte{0})
|
||||||
|
h.Write([]byte(set))
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil))
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package certproof
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/shared/management/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestHelperBackoff: a user whose helper proved nothing, for one ignoring or denying a
|
||||||
|
// keychain prompt, is not asked again for the same CAs within the quiet period, while a
|
||||||
|
// success or other CAs are asked at once.
|
||||||
|
func TestHelperBackoff(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
b := newHelperBackoff()
|
||||||
|
key := helperBackoffKey("501", []*proto.CertificateChallenge{{CaCertificates: []string{"ca-a"}}})
|
||||||
|
|
||||||
|
assert.True(t, b.allow(key, now), "a user never asked is asked")
|
||||||
|
|
||||||
|
b.record(key, false, now)
|
||||||
|
assert.False(t, b.allow(key, now.Add(helperQuietPeriod-time.Second)), "no new prompt within the quiet period")
|
||||||
|
assert.True(t, b.allow(key, now.Add(helperQuietPeriod)), "asked again once the quiet period passed")
|
||||||
|
|
||||||
|
other := helperBackoffKey("501", []*proto.CertificateChallenge{{CaCertificates: []string{"ca-b"}}})
|
||||||
|
assert.True(t, b.allow(other, now), "a check for other CAs is asked at once")
|
||||||
|
assert.True(t, b.allow(helperBackoffKey("502", []*proto.CertificateChallenge{{CaCertificates: []string{"ca-a"}}}), now), "another user is asked at once")
|
||||||
|
|
||||||
|
b.record(key, true, now)
|
||||||
|
assert.True(t, b.allow(key, now), "a run that proved something clears the quiet period")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHelperBackoffKey_IgnoresNonces: nonces rotate every window without changing what
|
||||||
|
// the user is asked to prove, so they do not reset the quiet period.
|
||||||
|
func TestHelperBackoffKey_IgnoresNonces(t *testing.T) {
|
||||||
|
a := helperBackoffKey("501", []*proto.CertificateChallenge{{Nonce: []byte("one"), CaCertificates: []string{"ca"}}})
|
||||||
|
b := helperBackoffKey("501", []*proto.CertificateChallenge{{Nonce: []byte("two"), CaCertificates: []string{"ca"}}})
|
||||||
|
assert.Equal(t, a, b, "the key does not depend on the nonce")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHelperBackoffKey_IgnoresOrder: the same CAs, listed in another order or with the
|
||||||
|
// challenges reordered, are the same question to the user.
|
||||||
|
func TestHelperBackoffKey_IgnoresOrder(t *testing.T) {
|
||||||
|
a := helperBackoffKey("501", []*proto.CertificateChallenge{
|
||||||
|
{CaCertificates: []string{"ca-1", "ca-2"}},
|
||||||
|
{CaCertificates: []string{"ca-3"}},
|
||||||
|
})
|
||||||
|
b := helperBackoffKey("501", []*proto.CertificateChallenge{
|
||||||
|
{CaCertificates: []string{"ca-3"}},
|
||||||
|
{CaCertificates: []string{"ca-2", "ca-1"}},
|
||||||
|
})
|
||||||
|
assert.Equal(t, a, b, "the key does not depend on the order of CAs or challenges")
|
||||||
|
|
||||||
|
c := helperBackoffKey("501", []*proto.CertificateChallenge{{CaCertificates: []string{"ca-1", "ca-2", "ca-3"}}})
|
||||||
|
assert.NotEqual(t, a, c, "the same CAs grouped into other challenges are another question")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHelperBackoff_PrunesExpired: entries whose quiet period passed are dropped when the
|
||||||
|
// next outcome is recorded, so the map does not grow with every user and CA set seen.
|
||||||
|
func TestHelperBackoff_PrunesExpired(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
b := newHelperBackoff()
|
||||||
|
b.record("old", false, now)
|
||||||
|
b.record("new", false, now.Add(helperQuietPeriod))
|
||||||
|
|
||||||
|
assert.NotContains(t, b.until, "old", "an expired entry is pruned")
|
||||||
|
assert.Contains(t, b.until, "new", "a current entry is kept")
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
//go:build !unix && !windows
|
||||||
|
|
||||||
|
package certproof
|
||||||
|
|
||||||
|
import "os/exec"
|
||||||
|
|
||||||
|
// startHelper starts cmd.
|
||||||
|
func startHelper(cmd *exec.Cmd) (func(), error) {
|
||||||
|
return func() {}, cmd.Start()
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
//go:build unix
|
||||||
|
|
||||||
|
package certproof
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// killHelperGroupOnCancel puts cmd in a process group of its own and kills the whole
|
||||||
|
// group when cmd's context ends. The helper may run below launchers such as launchctl
|
||||||
|
// and sudo, which do not pass a kill on, and it keeps the output pipes open, so killing
|
||||||
|
// only the direct child would leave the helper running and Wait blocked until the
|
||||||
|
// helper exits on its own, for one waiting on a keychain prompt nobody answers.
|
||||||
|
func killHelperGroupOnCancel(cmd *exec.Cmd) {
|
||||||
|
if cmd.SysProcAttr == nil {
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||||
|
}
|
||||||
|
cmd.SysProcAttr.Setpgid = true
|
||||||
|
cmd.Cancel = func() error {
|
||||||
|
err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||||
|
if errors.Is(err, syscall.ESRCH) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cmd.WaitDelay = helperWaitDelay
|
||||||
|
}
|
||||||
|
|
||||||
|
// startHelper starts cmd. Killing the helper with everything below it is arranged by
|
||||||
|
// killHelperGroupOnCancel where the helper runs under a launcher.
|
||||||
|
func startHelper(cmd *exec.Cmd) (func(), error) {
|
||||||
|
return func() {}, cmd.Start()
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package certproof
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// startHelper starts cmd inside a job object that is terminated when cmd's context ends
|
||||||
|
// and closed, killing whatever is left in it, once the helper has been awaited. Killing
|
||||||
|
// only the helper on Windows leaves its descendants running, and one that holds the
|
||||||
|
// output pipe would block Wait until it exits, so WaitDelay bounds that wait as well.
|
||||||
|
func startHelper(cmd *exec.Cmd) (func(), error) {
|
||||||
|
job, err := newKillOnCloseJob()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
closeJob := func() {
|
||||||
|
if err := windows.CloseHandle(job); err != nil {
|
||||||
|
log.Debugf("failed to close certificate proof helper job: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Cancel = func() error {
|
||||||
|
return windows.TerminateJobObject(job, 1)
|
||||||
|
}
|
||||||
|
cmd.WaitDelay = helperWaitDelay
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
closeJob()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// A helper outside the job is still killed directly and bounded by WaitDelay; only
|
||||||
|
// its descendants would outlive it.
|
||||||
|
if err := assignToJob(job, cmd.Process.Pid); err != nil {
|
||||||
|
log.Debugf("failed to put certificate proof helper %d in its job: %v", cmd.Process.Pid, err)
|
||||||
|
cmd.Cancel = func() error { return cmd.Process.Kill() }
|
||||||
|
}
|
||||||
|
return closeJob, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newKillOnCloseJob creates a job object whose processes are killed when its last handle
|
||||||
|
// is closed.
|
||||||
|
func newKillOnCloseJob() (windows.Handle, error) {
|
||||||
|
job, err := windows.CreateJobObject(nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("create job object: %w", err)
|
||||||
|
}
|
||||||
|
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{
|
||||||
|
BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{
|
||||||
|
LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if _, err := windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation,
|
||||||
|
uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))); err != nil {
|
||||||
|
_ = windows.CloseHandle(job)
|
||||||
|
return 0, fmt.Errorf("set job object limits: %w", err)
|
||||||
|
}
|
||||||
|
return job, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// assignToJob puts the process pid into job.
|
||||||
|
func assignToJob(job windows.Handle, pid int) error {
|
||||||
|
process, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(pid))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open process: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = windows.CloseHandle(process)
|
||||||
|
}()
|
||||||
|
return windows.AssignProcessToJobObject(job, process)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package certproof
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os/exec"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRunHelperCmd_KillsHelperTree: the helper's child holds stdout and runs for a minute,
|
||||||
|
// like a helper stuck below a launcher. Killing only the helper would leave Wait blocked
|
||||||
|
// on the child's pipe until WaitDelay gives up; terminating the job ends both at once.
|
||||||
|
func TestRunHelperCmd_KillsHelperTree(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "cmd.exe", "/c", "ping -n 60 127.0.0.1")
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, err := runHelperCmd(cmd, HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}})
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Less(t, time.Since(start), helperWaitDelay, "the whole tree is killed at the timeout, not left for WaitDelay")
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package certproof
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxHelperStdout = 1 << 20
|
||||||
|
maxHelperStderr = 4 << 10
|
||||||
|
|
||||||
|
// helperWaitDelay bounds how long a helper's output is awaited after it was killed.
|
||||||
|
helperWaitDelay = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
var errHelperOutputTooLarge = errors.New("helper output exceeds the size limit")
|
||||||
|
|
||||||
|
// runHelperCmd feeds req to the helper process cmd and returns the proofs it answered.
|
||||||
|
// The helper runs as an unprivileged user who can control its output, so both streams
|
||||||
|
// are capped and only proofs for a nonce req asked about are kept, one per challenge.
|
||||||
|
func runHelperCmd(cmd *exec.Cmd, req HelperRequest) ([]certposture.Proof, error) {
|
||||||
|
payload, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode helper request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout := &cappedBuffer{limit: maxHelperStdout}
|
||||||
|
stderr := &cappedBuffer{limit: maxHelperStderr}
|
||||||
|
cmd.Stdin = bytes.NewReader(payload)
|
||||||
|
cmd.Stdout = stdout
|
||||||
|
cmd.Stderr = stderr
|
||||||
|
|
||||||
|
release, err := startHelper(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("start helper: %w", err)
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
if err := cmd.Wait(); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
|
||||||
|
}
|
||||||
|
logHelperStderr(stderr.String())
|
||||||
|
if stdout.truncated {
|
||||||
|
return nil, errHelperOutputTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp HelperResponse
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode helper response: %w", err)
|
||||||
|
}
|
||||||
|
return requestedProofs(req, resp.Proofs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// logHelperStderr records what a helper that succeeded wrote to stderr, such as a user
|
||||||
|
// certificate it could not sign with, which is otherwise only visible in the user's
|
||||||
|
// session. Each line is quoted because the helper's user controls its content.
|
||||||
|
func logHelperStderr(stderr string) {
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(stderr), "\n") {
|
||||||
|
if line = strings.TrimSpace(line); line != "" {
|
||||||
|
log.Debugf("certificate posture helper: %q", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestedProofs keeps the proofs whose nonce belongs to one of req's challenges, at
|
||||||
|
// most as many as req has challenges.
|
||||||
|
func requestedProofs(req HelperRequest, proofs []certposture.Proof) []certposture.Proof {
|
||||||
|
var kept []certposture.Proof
|
||||||
|
for _, proof := range proofs {
|
||||||
|
if len(kept) == len(req.Challenges) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !req.asked(proof.Nonce) {
|
||||||
|
log.Debugf("certificate posture: dropping helper proof for a nonce that was not requested")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, proof)
|
||||||
|
}
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r HelperRequest) asked(nonce []byte) bool {
|
||||||
|
for _, challenge := range r.Challenges {
|
||||||
|
if len(challenge.Nonce) > 0 && bytes.Equal(challenge.Nonce, nonce) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// cappedBuffer keeps the first limit bytes written to it and discards the rest, so a
|
||||||
|
// misbehaving child cannot grow the parent's memory without bound. The buffer is a
|
||||||
|
// named field rather than embedded: an embedded bytes.Buffer would promote ReadFrom,
|
||||||
|
// which io.Copy prefers over Write, bypassing the cap.
|
||||||
|
type cappedBuffer struct {
|
||||||
|
buf bytes.Buffer
|
||||||
|
limit int
|
||||||
|
truncated bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *cappedBuffer) Write(p []byte) (int, error) {
|
||||||
|
if room := b.limit - b.buf.Len(); room < len(p) {
|
||||||
|
b.truncated = true
|
||||||
|
if room > 0 {
|
||||||
|
b.buf.Write(p[:room])
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
return b.buf.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *cappedBuffer) Bytes() []byte {
|
||||||
|
return b.buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *cappedBuffer) String() string {
|
||||||
|
return b.buf.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
//go:build !windows && !js
|
||||||
|
|
||||||
|
package certproof
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeHelper is a child process that drains its stdin and then prints script's output,
|
||||||
|
// standing in for a helper running in a user session the daemon cannot trust.
|
||||||
|
func fakeHelper(t *testing.T, script string) *exec.Cmd {
|
||||||
|
t.Helper()
|
||||||
|
return exec.Command("/bin/sh", "-c", "cat >/dev/null; "+script)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printJSON(t *testing.T, v any) string {
|
||||||
|
t.Helper()
|
||||||
|
out, err := json.Marshal(v)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return fmt.Sprintf("printf '%%s' '%s'", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunHelperCmd_KeepsOnlyRequestedProofs(t *testing.T) {
|
||||||
|
req := HelperRequest{PeerKey: peerKey, Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
|
||||||
|
resp := HelperResponse{Proofs: []certposture.Proof{
|
||||||
|
{Nonce: []byte("injected"), Signature: []byte("x")},
|
||||||
|
{Nonce: []byte("asked"), Signature: []byte("first")},
|
||||||
|
{Nonce: []byte("asked"), Signature: []byte("second")},
|
||||||
|
}}
|
||||||
|
|
||||||
|
proofs, err := runHelperCmd(fakeHelper(t, printJSON(t, resp)), req)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, proofs, 1, "a helper may not return more proofs than challenges or proofs for nonces it was not asked about")
|
||||||
|
assert.Equal(t, []byte("first"), proofs[0].Signature, "the first proof for the requested nonce is kept")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunHelperCmd_RejectsOversizedOutput(t *testing.T) {
|
||||||
|
req := HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
|
||||||
|
script := fmt.Sprintf("head -c %d /dev/zero", maxHelperStdout+1)
|
||||||
|
|
||||||
|
_, err := runHelperCmd(fakeHelper(t, script), req)
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, errHelperOutputTooLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunHelperCmd_CapsStderrInError(t *testing.T) {
|
||||||
|
req := HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
|
||||||
|
script := fmt.Sprintf("head -c %d /dev/zero | tr '\\0' 'a' >&2; exit 3", 10*maxHelperStderr)
|
||||||
|
|
||||||
|
_, err := runHelperCmd(fakeHelper(t, script), req)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.LessOrEqual(t, len(err.Error()), maxHelperStderr+100, "a chatty helper must not blow up the daemon's error or log line")
|
||||||
|
assert.True(t, strings.Contains(err.Error(), "exit status 3"), "the exit status is kept: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunHelperCmd_LogsStderrOfSuccessfulHelper covers a helper that answers but warns,
|
||||||
|
// for one about a certificate it could not sign with: the warning reaches the daemon's
|
||||||
|
// log, quoted so a helper cannot forge log lines with embedded newlines.
|
||||||
|
func TestRunHelperCmd_LogsStderrOfSuccessfulHelper(t *testing.T) {
|
||||||
|
var logged bytes.Buffer
|
||||||
|
log.SetOutput(&logged)
|
||||||
|
level := log.GetLevel()
|
||||||
|
log.SetLevel(log.DebugLevel)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
log.SetOutput(os.Stderr)
|
||||||
|
log.SetLevel(level)
|
||||||
|
})
|
||||||
|
|
||||||
|
req := HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
|
||||||
|
script := "printf 'failed signing certificate proof for CN=user\\nforged line\\n' >&2; " + printJSON(t, HelperResponse{})
|
||||||
|
|
||||||
|
_, err := runHelperCmd(fakeHelper(t, script), req)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, logged.String(), "failed signing certificate proof for CN=user", "the helper's warning reaches the daemon log")
|
||||||
|
assert.NotContains(t, logged.String(), "\nforged line", "helper output cannot start a log line of its own")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunHelperCmd_KillsHelperBelowLauncher stands in for launchctl and sudo starting the
|
||||||
|
// helper: the direct child spawns a grandchild that holds stdout and never exits, like a
|
||||||
|
// helper waiting on a keychain prompt. The timeout must end both, not wait for the
|
||||||
|
// grandchild.
|
||||||
|
func TestRunHelperCmd_KillsHelperBelowLauncher(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", "sleep 30 & cat >/dev/null; wait")
|
||||||
|
killHelperGroupOnCancel(cmd)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, err := runHelperCmd(cmd, HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}})
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Less(t, time.Since(start), 5*time.Second, "the helper is killed at the timeout, not awaited until it exits")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunHelperCmd_RejectsGarbage(t *testing.T) {
|
||||||
|
req := HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
|
||||||
|
|
||||||
|
_, err := runHelperCmd(fakeHelper(t, "printf 'not json'"), req)
|
||||||
|
|
||||||
|
assert.ErrorContains(t, err, "decode helper response")
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//go:build (darwin && !ios) || windows
|
||||||
|
|
||||||
|
package certproof
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/netbirdio/netbird/shared/management/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
func helperRequest(challenges []*proto.CertificateChallenge, peerKey []byte) HelperRequest {
|
||||||
|
req := HelperRequest{PeerKey: peerKey, Challenges: make([]HelperChallenge, 0, len(challenges))}
|
||||||
|
for _, challenge := range challenges {
|
||||||
|
req.Challenges = append(req.Challenges, HelperChallenge{
|
||||||
|
Nonce: challenge.GetNonce(),
|
||||||
|
CACertificates: challenge.GetCaCertificates(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return req
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
//go:build (darwin && !ios) || windows
|
||||||
|
|
||||||
|
package certproof
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
|
||||||
|
"github.com/netbirdio/netbird/shared/management/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHelperRequest_CarriesEveryChallenge(t *testing.T) {
|
||||||
|
ca := certtest.NewCA(t, "corp-root")
|
||||||
|
challenges := []*proto.CertificateChallenge{
|
||||||
|
{Nonce: []byte("first"), CaCertificates: []string{ca.PEM}},
|
||||||
|
{Nonce: []byte("second")},
|
||||||
|
}
|
||||||
|
|
||||||
|
req := helperRequest(challenges, peerKey)
|
||||||
|
|
||||||
|
require.Len(t, req.Challenges, 2, "every challenge must reach the helper")
|
||||||
|
assert.Equal(t, peerKey, req.PeerKey, "the peer key binds the signature to this machine")
|
||||||
|
assert.Equal(t, []byte("first"), req.Challenges[0].Nonce, "the nonce must survive unchanged")
|
||||||
|
assert.Equal(t, []string{ca.PEM}, req.Challenges[0].CACertificates, "the accepted CAs must survive unchanged")
|
||||||
|
assert.Empty(t, req.Challenges[1].CACertificates, "a challenge without CAs stays without CAs")
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package certproof
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||||
|
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRunHelper_AnswersAChallengeFromItsStore runs the helper's request and response
|
||||||
|
// handling in-process: the proof it writes verifies as the daemon will verify it. The
|
||||||
|
// process boundary itself is covered by the runHelperCmd tests.
|
||||||
|
func TestRunHelper_AnswersAChallengeFromItsStore(t *testing.T) {
|
||||||
|
ca := certtest.NewCA(t, "corp-root")
|
||||||
|
dir := storeDir(t)
|
||||||
|
key := certtest.ECDSAKey(t)
|
||||||
|
writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, key, "device"))+certtest.KeyPEM(t, key))
|
||||||
|
|
||||||
|
challenger := certposture.NewChallenger([]byte("secret"))
|
||||||
|
nonce := challenger.Nonce(peerKey, time.Now())
|
||||||
|
request, err := json.Marshal(HelperRequest{
|
||||||
|
PeerKey: peerKey,
|
||||||
|
Challenges: []HelperChallenge{{Nonce: nonce, CACertificates: []string{ca.PEM}}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "request must encode")
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
require.NoError(t, runHelper(context.Background(), newFileStore(dir), bytes.NewReader(request), &stdout))
|
||||||
|
|
||||||
|
var resp HelperResponse
|
||||||
|
require.NoError(t, json.Unmarshal(stdout.Bytes(), &resp), "helper must emit decodable JSON")
|
||||||
|
require.Len(t, resp.Proofs, 1, "the matching certificate should produce one proof")
|
||||||
|
|
||||||
|
// Verify exactly as management does, so the proof is proven to survive the encode,
|
||||||
|
// the process boundary and the decode intact.
|
||||||
|
chain, err := challenger.Verify(resp.Proofs[0], peerKey, time.Now())
|
||||||
|
require.NoError(t, err, "the decoded proof must verify against the issued nonce")
|
||||||
|
assert.Equal(t, "device", chain[0].Subject.CommonName, "the proven leaf should be the device certificate")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunHelper_NoChallengesYieldsEmptyResponse(t *testing.T) {
|
||||||
|
request, err := json.Marshal(HelperRequest{PeerKey: peerKey})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
require.NoError(t, runHelper(context.Background(), newFileStore(storeDir(t)), bytes.NewReader(request), &stdout))
|
||||||
|
|
||||||
|
var resp HelperResponse
|
||||||
|
require.NoError(t, json.Unmarshal(stdout.Bytes(), &resp), "an empty request must still emit valid JSON")
|
||||||
|
assert.Empty(t, resp.Proofs, "no challenges should produce no proofs")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunHelper_RejectsMalformedRequest(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
err := runHelper(context.Background(), newFileStore(storeDir(t)), bytes.NewReader([]byte("not json")), &stdout)
|
||||||
|
|
||||||
|
require.Error(t, err, "a malformed request must fail rather than emit an empty proof set")
|
||||||
|
assert.Empty(t, stdout.String(), "nothing should be written to stdout on a decode failure")
|
||||||
|
}
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
//go:build !ios
|
||||||
|
|
||||||
|
package certproof
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto"
|
||||||
|
"crypto/x509"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/ebitengine/purego"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
securityFramework = "/System/Library/Frameworks/Security.framework/Security"
|
||||||
|
coreFoundationFramework = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"
|
||||||
|
|
||||||
|
errSecItemNotFound = -25300
|
||||||
|
errSecInteractionNotAllowed = -25308
|
||||||
|
)
|
||||||
|
|
||||||
|
// errKeyNeedsApproval reports a key whose access list does not include netbird, so using
|
||||||
|
// it needs the user's approval, which a daemon has no UI to ask for.
|
||||||
|
var errKeyNeedsApproval = errors.New("the key's access control requires user approval for netbird; " +
|
||||||
|
"import the identity with netbird allowed (security import <identity.p12> -k <keychain> " +
|
||||||
|
"-T /Applications/NetBird.app/Contents/MacOS/netbird), " +
|
||||||
|
"or set AllowAllAppsAccess in the MDM certificate payload, which allows every application")
|
||||||
|
|
||||||
|
var (
|
||||||
|
keychainOnce sync.Once
|
||||||
|
keychainErr error
|
||||||
|
|
||||||
|
secItemCopyMatching func(query uintptr, result *uintptr) int32
|
||||||
|
secIdentityCopyCertificate func(identity uintptr, cert *uintptr) int32
|
||||||
|
secIdentityCopyPrivateKey func(identity uintptr, key *uintptr) int32
|
||||||
|
secCertificateCopyData func(cert uintptr) uintptr
|
||||||
|
secKeyCreateSignature func(key, algorithm, data uintptr, err *uintptr) uintptr
|
||||||
|
|
||||||
|
secKeychainOpen func(path *byte, keychain *uintptr) int32
|
||||||
|
secKeychainCopySearchList func(searchList *uintptr) int32
|
||||||
|
secKeychainGetPath func(keychain uintptr, pathLength *uint32, path *byte) int32
|
||||||
|
cfArrayCreate func(alloc uintptr, values *uintptr, count int, callBacks uintptr) uintptr
|
||||||
|
|
||||||
|
cfDictionaryCreate func(alloc uintptr, keys, values *uintptr, count int, keyCallBacks, valueCallBacks uintptr) uintptr
|
||||||
|
cfArrayGetCount func(array uintptr) int
|
||||||
|
cfArrayGetValueAtIndex func(array uintptr, index int) uintptr
|
||||||
|
cfDataCreate func(alloc uintptr, data *byte, length int) uintptr
|
||||||
|
cfDataGetLength func(data uintptr) int
|
||||||
|
cfDataGetBytePtr func(data uintptr) unsafe.Pointer
|
||||||
|
cfErrorGetCode func(err uintptr) int
|
||||||
|
cfRelease func(ref uintptr)
|
||||||
|
|
||||||
|
kSecClass, kSecClassIdentity, kSecClassCertificate, kSecMatchLimit, kSecMatchLimitAll, kSecReturnRef uintptr
|
||||||
|
kSecMatchSearchList uintptr
|
||||||
|
kSecKeyAlgorithmECDSASHA256, kSecKeyAlgorithmECDSASHA384, kSecKeyAlgorithmRSAPSSSHA256 uintptr
|
||||||
|
kCFBooleanTrue, kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks, kCFTypeArrayCallBacks uintptr
|
||||||
|
)
|
||||||
|
|
||||||
|
// systemKeychain is where MDM installs device identities.
|
||||||
|
const systemKeychain = "/Library/Keychains/System.keychain"
|
||||||
|
|
||||||
|
// DefaultStore is the System keychain for the root daemon, so it never touches a user's
|
||||||
|
// keychain, whose keys would ask for approval in a session the daemon has no UI in. Run
|
||||||
|
// by an ordinary user, it is that user's keychain search list.
|
||||||
|
func DefaultStore() Store {
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
return newKeychainStore(systemKeychain)
|
||||||
|
}
|
||||||
|
return newKeychainStore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// keychainStore yields the identities of a set of keychains, or of the process's keychain
|
||||||
|
// search list when none is named, reached through purego so the client keeps building
|
||||||
|
// with CGO_ENABLED=0.
|
||||||
|
type keychainStore struct {
|
||||||
|
keychains []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// newKeychainStore returns a store that searches the keychain files at paths, or the
|
||||||
|
// process's keychain search list when no path is given.
|
||||||
|
func newKeychainStore(paths ...string) *keychainStore {
|
||||||
|
return &keychainStore{keychains: paths}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *keychainStore) Candidates(_ context.Context) ([]Candidate, error) {
|
||||||
|
if err := loadKeychain(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
searchList, done, err := searchListOf(s.keychains)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer done()
|
||||||
|
|
||||||
|
var leaves []*x509.Certificate
|
||||||
|
err = eachIdentity(searchList, func(_ uintptr, der []byte) (bool, error) {
|
||||||
|
cert, err := x509.ParseCertificate(der)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("skipping keychain identity: %v", err)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
log.Debugf("keychain identity: subject=%q issuer=%q serial=%s expires=%s", cert.Subject, cert.Issuer, cert.SerialNumber, cert.NotAfter)
|
||||||
|
leaves = append(leaves, cert)
|
||||||
|
return false, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// The certificate query runs even without identities: it separates a keychain that is
|
||||||
|
// readable but holds no identity from one the process cannot read at all.
|
||||||
|
pool, err := keychainCertificates(searchList)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(leaves) == 0 {
|
||||||
|
log.Debugf("keychain search list holds no identities usable for certificate posture, but %d readable certificates: an identity needs its private key in the same keychain", len(pool))
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
log.Debugf("keychain search list holds %d identities and %d certificates for chain building", len(leaves), len(pool))
|
||||||
|
|
||||||
|
candidates := make([]Candidate, 0, len(leaves))
|
||||||
|
for _, leaf := range leaves {
|
||||||
|
chain := buildChain(leaf, pool)
|
||||||
|
log.Debugf("keychain candidate %q issued by %q built a chain of %d certificates", leaf.Subject, leaf.Issuer, len(chain))
|
||||||
|
if len(chain) == 1 && leaf.CheckSignatureFrom(leaf) != nil {
|
||||||
|
log.Debugf("keychain candidate %q has no issuer in the keychain, its proof carries the leaf alone and only verifies if the challenge supplies %q", leaf.Subject, leaf.Issuer)
|
||||||
|
}
|
||||||
|
candidates = append(candidates, Candidate{Chain: chain, Signer: &keychainSigner{leaf: leaf, keychains: s.keychains}, Intermediates: pool})
|
||||||
|
}
|
||||||
|
return candidates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// keychainSigner holds only the certificate; the identity is looked up again at signing
|
||||||
|
// time so no keychain references outlive a call.
|
||||||
|
type keychainSigner struct {
|
||||||
|
leaf *x509.Certificate
|
||||||
|
keychains []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *keychainSigner) Public() crypto.PublicKey {
|
||||||
|
return s.leaf.PublicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *keychainSigner) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
|
||||||
|
scheme, err := schemeFor(s.leaf.PublicKey, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
log.Debugf("signing certificate posture challenge with keychain key of %q", s.leaf.Subject)
|
||||||
|
|
||||||
|
searchList, done, err := searchListOf(s.keychains)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer done()
|
||||||
|
|
||||||
|
algorithm := keychainAlgorithm(scheme)
|
||||||
|
var signature []byte
|
||||||
|
var attempts signAttempts
|
||||||
|
err = eachIdentity(searchList, func(identity uintptr, der []byte) (bool, error) {
|
||||||
|
if !bytes.Equal(der, s.leaf.Raw) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
// The same certificate can sit in the keychain with more than one key, of which
|
||||||
|
// only one may be usable, so a failure moves on to the next match.
|
||||||
|
var ok bool
|
||||||
|
signature, ok = attempts.try(func() ([]byte, error) { return signWithIdentity(identity, algorithm, digest) })
|
||||||
|
return ok, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if signature == nil {
|
||||||
|
if err := attempts.err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, errors.New("certificate is no longer in the keychain")
|
||||||
|
}
|
||||||
|
log.Debugf("keychain signed certificate posture challenge for %q, %d bytes", s.leaf.Subject, len(signature))
|
||||||
|
return signature, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func keychainAlgorithm(scheme sigScheme) uintptr {
|
||||||
|
switch scheme {
|
||||||
|
case schemeECDSASHA384:
|
||||||
|
return kSecKeyAlgorithmECDSASHA384
|
||||||
|
case schemeRSAPSSSHA256:
|
||||||
|
return kSecKeyAlgorithmRSAPSSSHA256
|
||||||
|
default:
|
||||||
|
return kSecKeyAlgorithmECDSASHA256
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func signWithIdentity(identity, algorithm uintptr, digest []byte) ([]byte, error) {
|
||||||
|
var key uintptr
|
||||||
|
if status := secIdentityCopyPrivateKey(identity, &key); status != 0 {
|
||||||
|
return nil, fmt.Errorf("SecIdentityCopyPrivateKey: %d", status)
|
||||||
|
}
|
||||||
|
defer release(key)
|
||||||
|
|
||||||
|
data := cfDataCreate(0, &digest[0], len(digest))
|
||||||
|
if data == 0 {
|
||||||
|
return nil, errors.New("CFDataCreate returned NULL")
|
||||||
|
}
|
||||||
|
defer release(data)
|
||||||
|
|
||||||
|
var cfErr uintptr
|
||||||
|
signature := secKeyCreateSignature(key, algorithm, data, &cfErr)
|
||||||
|
if signature == 0 {
|
||||||
|
if cfErr == 0 {
|
||||||
|
return nil, errors.New("SecKeyCreateSignature failed without a CFError")
|
||||||
|
}
|
||||||
|
defer release(cfErr)
|
||||||
|
code := cfErrorGetCode(cfErr)
|
||||||
|
if code == errSecInteractionNotAllowed {
|
||||||
|
return nil, fmt.Errorf("SecKeyCreateSignature: CFError %d: %w", code, errKeyNeedsApproval)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("SecKeyCreateSignature: CFError %d", code)
|
||||||
|
}
|
||||||
|
defer release(signature)
|
||||||
|
return dataBytes(signature), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchListOf opens the keychain files at paths as a CFArray for kSecMatchSearchList,
|
||||||
|
// or yields 0, the process's own search list, when paths is empty. The caller calls done
|
||||||
|
// once it no longer uses the list.
|
||||||
|
func searchListOf(paths []string) (searchList uintptr, done func(), err error) {
|
||||||
|
if len(paths) == 0 {
|
||||||
|
return 0, func() {}, nil
|
||||||
|
}
|
||||||
|
list, err := openSearchList(paths)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
return list, func() { release(list) }, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// openSearchList opens the keychain files at paths, at least one, as a CFArray. The
|
||||||
|
// caller releases the array.
|
||||||
|
func openSearchList(paths []string) (uintptr, error) {
|
||||||
|
refs := make([]uintptr, 0, len(paths))
|
||||||
|
defer func() {
|
||||||
|
for _, ref := range refs {
|
||||||
|
release(ref)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
for _, path := range paths {
|
||||||
|
cpath := append([]byte(path), 0)
|
||||||
|
var keychain uintptr
|
||||||
|
if status := secKeychainOpen(&cpath[0], &keychain); status != 0 {
|
||||||
|
return 0, fmt.Errorf("SecKeychainOpen %s: %d", path, status)
|
||||||
|
}
|
||||||
|
refs = append(refs, keychain)
|
||||||
|
}
|
||||||
|
// The array retains the keychains, so the references opened here are released.
|
||||||
|
list := cfArrayCreate(0, &refs[0], len(refs), kCFTypeArrayCallBacks)
|
||||||
|
if list == 0 {
|
||||||
|
return 0, errors.New("CFArrayCreate returned NULL")
|
||||||
|
}
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// eachIdentity calls fn with every identity in searchList, or in the process's search
|
||||||
|
// list when it is 0, and its certificate. An identity whose certificate cannot be read is
|
||||||
|
// skipped rather than ending the walk.
|
||||||
|
func eachIdentity(searchList uintptr, fn func(identity uintptr, der []byte) (bool, error)) error {
|
||||||
|
return eachMatching(searchList, kSecClassIdentity, "identity", func(identity uintptr) (bool, error) {
|
||||||
|
var cert uintptr
|
||||||
|
if status := secIdentityCopyCertificate(identity, &cert); status != 0 {
|
||||||
|
log.Debugf("skipping keychain identity: SecIdentityCopyCertificate: %d", status)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
der := certificateDER(cert)
|
||||||
|
release(cert)
|
||||||
|
if der == nil {
|
||||||
|
log.Debug("skipping keychain identity whose certificate has no DER data")
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return fn(identity, der)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func keychainCertificates(searchList uintptr) ([]*x509.Certificate, error) {
|
||||||
|
var certs []*x509.Certificate
|
||||||
|
var unparsable int
|
||||||
|
err := eachMatching(searchList, kSecClassCertificate, "certificate", func(item uintptr) (bool, error) {
|
||||||
|
if cert, err := x509.ParseCertificate(certificateDER(item)); err == nil {
|
||||||
|
certs = append(certs, cert)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
unparsable++
|
||||||
|
return false, nil
|
||||||
|
})
|
||||||
|
log.Debugf("keychain holds %d parsable certificates, %d unparsable", len(certs), unparsable)
|
||||||
|
return certs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func eachMatching(searchList, class uintptr, name string, fn func(item uintptr) (bool, error)) error {
|
||||||
|
keys := []uintptr{kSecClass, kSecMatchLimit, kSecReturnRef}
|
||||||
|
values := []uintptr{class, kSecMatchLimitAll, kCFBooleanTrue}
|
||||||
|
if searchList != 0 {
|
||||||
|
keys = append(keys, kSecMatchSearchList)
|
||||||
|
values = append(values, searchList)
|
||||||
|
}
|
||||||
|
query := cfDictionaryCreate(0, &keys[0], &values[0], len(keys), kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks)
|
||||||
|
defer release(query)
|
||||||
|
|
||||||
|
var items uintptr
|
||||||
|
switch status := secItemCopyMatching(query, &items); status {
|
||||||
|
case 0:
|
||||||
|
case errSecItemNotFound:
|
||||||
|
log.Debugf("keychain %s query returned errSecItemNotFound (%d): the search list holds no item of this class", name, errSecItemNotFound)
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
log.Debugf("keychain %s query returned OSStatus %d", name, status)
|
||||||
|
return fmt.Errorf("SecItemCopyMatching: %d", status)
|
||||||
|
}
|
||||||
|
defer release(items)
|
||||||
|
|
||||||
|
n := cfArrayGetCount(items)
|
||||||
|
log.Debugf("keychain %s query returned %d items", name, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
if stop, err := fn(cfArrayGetValueAtIndex(items, i)); stop || err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// certificateDER returns the DER form of cert, or nil when SecCertificateCopyData
|
||||||
|
// returns NULL, which it does for an object that is not a valid certificate.
|
||||||
|
func certificateDER(cert uintptr) []byte {
|
||||||
|
data := secCertificateCopyData(cert)
|
||||||
|
if data == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer release(data)
|
||||||
|
return dataBytes(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dataBytes(data uintptr) []byte {
|
||||||
|
n := cfDataGetLength(data)
|
||||||
|
if n <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return bytes.Clone(unsafe.Slice((*byte)(cfDataGetBytePtr(data)), n))
|
||||||
|
}
|
||||||
|
|
||||||
|
// release drops a CoreFoundation reference. CFRelease crashes the process on NULL, and
|
||||||
|
// several Security calls return NULL on failure, so every release goes through here.
|
||||||
|
func release(ref uintptr) {
|
||||||
|
if ref != 0 {
|
||||||
|
cfRelease(ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadKeychain() error {
|
||||||
|
keychainOnce.Do(func() {
|
||||||
|
if keychainErr = resolveKeychain(); keychainErr != nil {
|
||||||
|
log.Debugf("macOS keychain unavailable for certificate posture: %v", keychainErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Debugf("macOS Security framework loaded for certificate posture, running as uid=%d euid=%d", os.Getuid(), os.Geteuid())
|
||||||
|
logSearchList()
|
||||||
|
})
|
||||||
|
return keychainErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// logSearchList reports the keychains the process searches. The root daemon sees the
|
||||||
|
// System keychain and System Roots, never a user's login keychain.
|
||||||
|
func logSearchList() {
|
||||||
|
if secKeychainCopySearchList == nil || secKeychainGetPath == nil {
|
||||||
|
log.Debug("keychain search list diagnostics unavailable on this macOS version")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var list uintptr
|
||||||
|
if status := secKeychainCopySearchList(&list); status != 0 {
|
||||||
|
log.Debugf("SecKeychainCopySearchList returned OSStatus %d", status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer release(list)
|
||||||
|
|
||||||
|
n := cfArrayGetCount(list)
|
||||||
|
log.Debugf("keychain search list contains %d keychains", n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
log.Debugf("keychain search list[%d]: %s", i, keychainPath(cfArrayGetValueAtIndex(list, i)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func keychainPath(keychain uintptr) string {
|
||||||
|
path := make([]byte, 1024)
|
||||||
|
length := uint32(len(path))
|
||||||
|
if status := secKeychainGetPath(keychain, &length, &path[0]); status != 0 {
|
||||||
|
return fmt.Sprintf("<SecKeychainGetPath: %d>", status)
|
||||||
|
}
|
||||||
|
return string(path[:length])
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveKeychain() error {
|
||||||
|
security, err := purego.Dlopen(securityFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open %s: %w", securityFramework, err)
|
||||||
|
}
|
||||||
|
coreFoundation, err := purego.Dlopen(coreFoundationFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open %s: %w", coreFoundationFramework, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, fn := range []struct {
|
||||||
|
ptr any
|
||||||
|
lib uintptr
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{&secItemCopyMatching, security, "SecItemCopyMatching"},
|
||||||
|
{&secIdentityCopyCertificate, security, "SecIdentityCopyCertificate"},
|
||||||
|
{&secIdentityCopyPrivateKey, security, "SecIdentityCopyPrivateKey"},
|
||||||
|
{&secCertificateCopyData, security, "SecCertificateCopyData"},
|
||||||
|
{&secKeyCreateSignature, security, "SecKeyCreateSignature"},
|
||||||
|
{&secKeychainOpen, security, "SecKeychainOpen"},
|
||||||
|
{&cfArrayCreate, coreFoundation, "CFArrayCreate"},
|
||||||
|
{&cfDictionaryCreate, coreFoundation, "CFDictionaryCreate"},
|
||||||
|
{&cfArrayGetCount, coreFoundation, "CFArrayGetCount"},
|
||||||
|
{&cfArrayGetValueAtIndex, coreFoundation, "CFArrayGetValueAtIndex"},
|
||||||
|
{&cfDataCreate, coreFoundation, "CFDataCreate"},
|
||||||
|
{&cfDataGetLength, coreFoundation, "CFDataGetLength"},
|
||||||
|
{&cfDataGetBytePtr, coreFoundation, "CFDataGetBytePtr"},
|
||||||
|
{&cfErrorGetCode, coreFoundation, "CFErrorGetCode"},
|
||||||
|
{&cfRelease, coreFoundation, "CFRelease"},
|
||||||
|
} {
|
||||||
|
symbol, err := purego.Dlsym(fn.lib, fn.name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve %s: %w", fn.name, err)
|
||||||
|
}
|
||||||
|
purego.RegisterFunc(fn.ptr, symbol)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, global := range []struct {
|
||||||
|
ptr *uintptr
|
||||||
|
lib uintptr
|
||||||
|
name string
|
||||||
|
deref bool
|
||||||
|
}{
|
||||||
|
{&kSecClass, security, "kSecClass", true},
|
||||||
|
{&kSecClassIdentity, security, "kSecClassIdentity", true},
|
||||||
|
{&kSecClassCertificate, security, "kSecClassCertificate", true},
|
||||||
|
{&kSecMatchLimit, security, "kSecMatchLimit", true},
|
||||||
|
{&kSecMatchLimitAll, security, "kSecMatchLimitAll", true},
|
||||||
|
{&kSecReturnRef, security, "kSecReturnRef", true},
|
||||||
|
{&kSecMatchSearchList, security, "kSecMatchSearchList", true},
|
||||||
|
{&kSecKeyAlgorithmECDSASHA256, security, "kSecKeyAlgorithmECDSASignatureDigestX962SHA256", true},
|
||||||
|
{&kSecKeyAlgorithmECDSASHA384, security, "kSecKeyAlgorithmECDSASignatureDigestX962SHA384", true},
|
||||||
|
{&kSecKeyAlgorithmRSAPSSSHA256, security, "kSecKeyAlgorithmRSASignatureDigestPSSSHA256", true},
|
||||||
|
{&kCFBooleanTrue, coreFoundation, "kCFBooleanTrue", true},
|
||||||
|
{&kCFTypeDictionaryKeyCallBacks, coreFoundation, "kCFTypeDictionaryKeyCallBacks", false},
|
||||||
|
{&kCFTypeDictionaryValueCallBacks, coreFoundation, "kCFTypeDictionaryValueCallBacks", false},
|
||||||
|
{&kCFTypeArrayCallBacks, coreFoundation, "kCFTypeArrayCallBacks", false},
|
||||||
|
} {
|
||||||
|
addr, err := purego.Dlsym(global.lib, global.name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve %s: %w", global.name, err)
|
||||||
|
}
|
||||||
|
if global.deref {
|
||||||
|
addr = **(**uintptr)(unsafe.Pointer(&addr))
|
||||||
|
}
|
||||||
|
*global.ptr = addr
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveOptional(security, "SecKeychainCopySearchList", &secKeychainCopySearchList)
|
||||||
|
resolveOptional(security, "SecKeychainGetPath", &secKeychainGetPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveOptional binds a diagnostic-only symbol, leaving it nil when the framework no
|
||||||
|
// longer exports it so keychain lookups keep working without it.
|
||||||
|
func resolveOptional(lib uintptr, name string, ptr any) {
|
||||||
|
symbol, err := purego.Dlsym(lib, name)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("keychain diagnostics: %s unavailable: %v", name, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
purego.RegisterFunc(ptr, symbol)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package certproof
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/asn1"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errUnsupportedScheme = errors.New("unsupported signature scheme for OS keystore")
|
||||||
|
|
||||||
|
type sigScheme int
|
||||||
|
|
||||||
|
const (
|
||||||
|
schemeECDSASHA256 sigScheme = iota + 1
|
||||||
|
schemeECDSASHA384
|
||||||
|
schemeRSAPSSSHA256
|
||||||
|
)
|
||||||
|
|
||||||
|
// schemeFor maps a crypto.Signer request onto the schemes the OS keystores perform.
|
||||||
|
func schemeFor(pub crypto.PublicKey, opts crypto.SignerOpts) (sigScheme, error) {
|
||||||
|
switch pub.(type) {
|
||||||
|
case *ecdsa.PublicKey:
|
||||||
|
switch opts.HashFunc() {
|
||||||
|
case crypto.SHA256:
|
||||||
|
return schemeECDSASHA256, nil
|
||||||
|
case crypto.SHA384:
|
||||||
|
return schemeECDSASHA384, nil
|
||||||
|
}
|
||||||
|
case *rsa.PublicKey:
|
||||||
|
if pss, ok := opts.(*rsa.PSSOptions); ok && pss.Hash == crypto.SHA256 {
|
||||||
|
return schemeRSAPSSSHA256, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("%w: %T with %v", errUnsupportedScheme, pub, opts.HashFunc())
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildChain extends leaf with the issuers found in pool up to a self-signed certificate.
|
||||||
|
func buildChain(leaf *x509.Certificate, pool []*x509.Certificate) []*x509.Certificate {
|
||||||
|
chain := []*x509.Certificate{leaf}
|
||||||
|
current := leaf
|
||||||
|
for current.CheckSignatureFrom(current) != nil {
|
||||||
|
issuer := issuerIn(current, pool, chain)
|
||||||
|
if issuer == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
chain = append(chain, issuer)
|
||||||
|
current = issuer
|
||||||
|
}
|
||||||
|
return chain
|
||||||
|
}
|
||||||
|
|
||||||
|
func issuerIn(cert *x509.Certificate, pool, seen []*x509.Certificate) *x509.Certificate {
|
||||||
|
for _, candidate := range pool {
|
||||||
|
if slices.ContainsFunc(seen, candidate.Equal) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cert.CheckSignatureFrom(candidate) == nil {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ecdsaSignatureASN1 converts the fixed-width r||s form emitted by CNG into the DER form Go verifies.
|
||||||
|
func ecdsaSignatureASN1(raw []byte) ([]byte, error) {
|
||||||
|
if len(raw) == 0 || len(raw)%2 != 0 {
|
||||||
|
return nil, errors.New("malformed raw ECDSA signature")
|
||||||
|
}
|
||||||
|
half := len(raw) / 2
|
||||||
|
return asn1.Marshal(struct{ R, S *big.Int }{new(big.Int).SetBytes(raw[:half]), new(big.Int).SetBytes(raw[half:])})
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package certproof
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||||
|
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildChain_FollowsIssuersThroughThePool(t *testing.T) {
|
||||||
|
root := certtest.NewCA(t, "root")
|
||||||
|
intermediate := certtest.NewIntermediate(t, root, "intermediate")
|
||||||
|
unrelated := certtest.NewCA(t, "unrelated")
|
||||||
|
leaf := intermediate.Issue(t, certtest.ECDSAKey(t), "device")
|
||||||
|
pool := []*x509.Certificate{unrelated.Cert, root.Cert, leaf, intermediate.Cert}
|
||||||
|
|
||||||
|
chain := buildChain(leaf, pool)
|
||||||
|
|
||||||
|
require.Equal(t, []*x509.Certificate{leaf, intermediate.Cert, root.Cert}, chain)
|
||||||
|
roots, err := certposture.ParseCAs([]string{root.PEM})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NoError(t, certposture.VerifyChain(chain, roots, time.Now()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildChain_StopsWhereThePoolEnds(t *testing.T) {
|
||||||
|
root := certtest.NewCA(t, "root")
|
||||||
|
intermediate := certtest.NewIntermediate(t, root, "intermediate")
|
||||||
|
leaf := intermediate.Issue(t, certtest.ECDSAKey(t), "device")
|
||||||
|
|
||||||
|
assert.Equal(t, []*x509.Certificate{leaf}, buildChain(leaf, nil))
|
||||||
|
assert.Equal(t, []*x509.Certificate{leaf, intermediate.Cert}, buildChain(leaf, []*x509.Certificate{intermediate.Cert}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSchemeFor(t *testing.T) {
|
||||||
|
ecKey := certtest.ECDSAKey(t)
|
||||||
|
rsaKey := certtest.RSAKey(t)
|
||||||
|
pss := &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash, Hash: crypto.SHA256}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
pub crypto.PublicKey
|
||||||
|
opts crypto.SignerOpts
|
||||||
|
want sigScheme
|
||||||
|
}{
|
||||||
|
{"ecdsa sha256", ecKey.Public(), crypto.SHA256, schemeECDSASHA256},
|
||||||
|
{"ecdsa sha384", ecKey.Public(), crypto.SHA384, schemeECDSASHA384},
|
||||||
|
{"rsa pss sha256", rsaKey.Public(), pss, schemeRSAPSSSHA256},
|
||||||
|
{"rsa pkcs1v15", rsaKey.Public(), crypto.SHA256, 0},
|
||||||
|
{"ecdsa sha512", ecKey.Public(), crypto.SHA512, 0},
|
||||||
|
{"rsa pss sha384", rsaKey.Public(), &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash, Hash: crypto.SHA384}, 0},
|
||||||
|
{"ed25519", certtest.Ed25519Key(t).Public(), crypto.Hash(0), 0},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, err := schemeFor(tc.pub, tc.opts)
|
||||||
|
if tc.want == 0 {
|
||||||
|
assert.ErrorIs(t, err, errUnsupportedScheme)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tc.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestECDSASignatureASN1(t *testing.T) {
|
||||||
|
key := certtest.ECDSAKey(t).(*ecdsa.PrivateKey)
|
||||||
|
digest := sha256.Sum256([]byte("nonce"))
|
||||||
|
r, s, err := ecdsa.Sign(rand.Reader, key, digest[:])
|
||||||
|
require.NoError(t, err)
|
||||||
|
raw := append(r.FillBytes(make([]byte, 32)), s.FillBytes(make([]byte, 32))...)
|
||||||
|
|
||||||
|
der, err := ecdsaSignatureASN1(raw)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, ecdsa.VerifyASN1(&key.PublicKey, digest[:], der))
|
||||||
|
|
||||||
|
_, err = ecdsaSignatureASN1(raw[:63])
|
||||||
|
assert.Error(t, err, "an odd length cannot split into r and s")
|
||||||
|
_, err = ecdsaSignatureASN1(nil)
|
||||||
|
assert.Error(t, err, "an empty signature is malformed")
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package certproof
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mergeProofs appends the user session proofs to the device proofs, dropping one whose
|
||||||
|
// chain the device already proved. Management checks each chain against each check's CAs,
|
||||||
|
// so the same leaf proven through another chain is kept.
|
||||||
|
func mergeProofs(device, user []certposture.Proof) []certposture.Proof {
|
||||||
|
if len(user) == 0 {
|
||||||
|
return device
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[[sha256.Size]byte]struct{}, len(device)+len(user))
|
||||||
|
for _, proof := range device {
|
||||||
|
if len(proof.Chain) > 0 {
|
||||||
|
seen[chainDERFingerprint(proof.Chain)] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merged := device
|
||||||
|
for _, proof := range user {
|
||||||
|
if len(proof.Chain) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fingerprint := chainDERFingerprint(proof.Chain)
|
||||||
|
if _, done := seen[fingerprint]; done {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[fingerprint] = struct{}{}
|
||||||
|
merged = append(merged, proof)
|
||||||
|
logUserProof(proof)
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
func logUserProof(proof certposture.Proof) {
|
||||||
|
leaf, err := x509.ParseCertificate(proof.Chain[0])
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("certificate posture: user proof carries an unparsable leaf: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Debugf("certificate posture: signed-in user proved %q issued by %q", leaf.Subject, leaf.Issuer)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user