mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-27 23:09:08 +01:00
Merge remote-tracking branch 'origin/main' into dmitri-catch-disconnected-peer
This commit is contained in:
@@ -14,5 +14,15 @@ reviews:
|
||||
- "!**/*.ts"
|
||||
- "!**/*.js"
|
||||
- "!**/*.svg"
|
||||
pre_merge_checks:
|
||||
custom_checks:
|
||||
- name: "No attribution trailers"
|
||||
mode: error
|
||||
instructions: >-
|
||||
Fail when the PR description or any commit message carries an
|
||||
attribution trailer or footer: Co-Authored-By, Claude-Session,
|
||||
Generated-By, or a "Generated with"/"Generated by" tool line.
|
||||
Contributors own their contributions (AGENTS.md); ask for the
|
||||
lines to be removed.
|
||||
chat:
|
||||
auto_reply: true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.26.7-bookworm
|
||||
FROM golang:1.26.10-bookworm
|
||||
|
||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
&& apt-get -y install --no-install-recommends\
|
||||
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
# Refuses commit messages that carry attribution trailers. Contributors own
|
||||
# their contributions (AGENTS.md, "No Co-Authored-By or tool-attribution
|
||||
# trailers"); a trailer spreads that ownership onto a tool or a bystander.
|
||||
|
||||
msg_file="$1"
|
||||
|
||||
# Trailer keys in any casing, with any bullet or emoji in front.
|
||||
trailers='^[^[:alnum:]]*(co-authored-by|claude-session|generated-by):'
|
||||
# "Generated with/by" footers, including "Generated with <emoji> by".
|
||||
footer='^[^[:alnum:]]*generated (with|by)( [^[:alnum:]]*by)? '
|
||||
# A footer names a product, so a capitalized word must follow the phrase
|
||||
# itself. Prose such as "generated by the protobuf compiler" stays legal.
|
||||
tool='[Gg][Ee][Nn][Ee][Rr][Aa][Tt][Ee][Dd] ([Ww][Ii][Tt][Hh]|[Bb][Yy])( [^[:alnum:]]*[Bb][Yy])? [^[:alnum:]]*[A-Z]'
|
||||
|
||||
offending=$( {
|
||||
grep -Ein "$trailers" "$msg_file"
|
||||
grep -Ein "$footer" "$msg_file" | grep -E "$tool"
|
||||
} | sort -un )
|
||||
|
||||
if [ -n "$offending" ]; then
|
||||
echo "commit-msg: attribution trailers are not accepted in this repository:" >&2
|
||||
printf '%s\n' "$offending" | sed 's/^/ /' >&2
|
||||
echo "Remove them and commit again (see AGENTS.md)." >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -46,3 +46,25 @@ updates:
|
||||
wireguard:
|
||||
patterns:
|
||||
- "golang.zx2c4.com/wireguard*"
|
||||
|
||||
# Base images of the source-build Dockerfiles, pinned by digest (Chainguard
|
||||
# publishes only :latest for free). Dockerfile.release files feed goreleaser
|
||||
# and keep the published images as they are, so their bases are left alone.
|
||||
- package-ecosystem: "docker"
|
||||
directories:
|
||||
- "/upload-server"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 3
|
||||
groups:
|
||||
base-images:
|
||||
patterns:
|
||||
- "*"
|
||||
ignore:
|
||||
- dependency-name: "gcr.io/distroless/base"
|
||||
# Go minor and major versions move with the rest of the repository;
|
||||
# patch releases and new digests of the pinned tag still come through.
|
||||
- dependency-name: "golang"
|
||||
update-types:
|
||||
- "version-update:semver-minor"
|
||||
- "version-update:semver-major"
|
||||
|
||||
Executable
+338
@@ -0,0 +1,338 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
fail() {
|
||||
echo "::error::$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ ${RUNNER_ENVIRONMENT:-} != github-hosted || ${RUNNER_OS:-} != macOS || $(uname -s) != Darwin ]]; then
|
||||
fail "This test installs a system daemon and must run on a disposable GitHub macOS runner."
|
||||
fi
|
||||
if [[ $EUID == 0 ]]; then
|
||||
fail "Run this script as the Homebrew user, not root."
|
||||
fi
|
||||
|
||||
readonly test_dir="${RUNNER_TEMP:?}/homebrew-cask"
|
||||
readonly results_dir="$test_dir/results"
|
||||
readonly app='/Applications/Netbird UI.app'
|
||||
readonly plist='/Library/LaunchDaemons/netbird.plist'
|
||||
readonly cask='netbirdio/tap/netbird-ui'
|
||||
readonly formula='netbirdio/tap/netbird'
|
||||
readonly published_cask="$test_dir/published-netbird-ui.rb"
|
||||
readonly legacy_cask="$test_dir/legacy-netbird-ui.rb"
|
||||
readonly rendered_cask="$test_dir/rendered-netbird-ui.rb"
|
||||
readonly fixture_dir="$test_dir/fixture"
|
||||
readonly serve_dir="$test_dir/serve"
|
||||
readonly fixture_zip="$serve_dir/netbird-ui.zip"
|
||||
readonly fixture_port=18080
|
||||
readonly fixture_url="http://127.0.0.1:$fixture_port/netbird-ui.zip"
|
||||
readonly marker="$test_dir/installer.marker"
|
||||
|
||||
mkdir -p "$results_dir" "$fixture_dir/netbird_ui_darwin" "$serve_dir" "$test_dir/downloads"
|
||||
exec > >(tee "$results_dir/test.log") 2>&1
|
||||
|
||||
sudo -n true
|
||||
if command -v netbird || [[ -e "$app" || -e "$plist" ]] || pgrep -x netbird-ui; then
|
||||
fail "The runner already has NetBird installed or running."
|
||||
fi
|
||||
if sudo launchctl print system/netbird > "$results_dir/initial-service.log" 2>&1; then
|
||||
fail "The runner already has a NetBird service loaded."
|
||||
fi
|
||||
|
||||
install_attempted=false
|
||||
server_pid=''
|
||||
daemon_pid=''
|
||||
version=''
|
||||
|
||||
stop_ui() {
|
||||
local status=0
|
||||
sudo pkill -x netbird-ui || status=$?
|
||||
# pkill returns 1 when the UI is already closed.
|
||||
[[ $status == 0 || $status == 1 ]]
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local status=$?
|
||||
trap - EXIT
|
||||
set +e
|
||||
|
||||
if [[ $install_attempted == true ]]; then
|
||||
stop_ui || status=1
|
||||
if [[ -S /var/run/netbird.sock ]]; then
|
||||
sudo netbird down || status=1
|
||||
fi
|
||||
if brew list --cask "$cask" >/dev/null 2>&1 || [[ -e "$app" ]]; then
|
||||
brew uninstall --cask --force "$cask" || status=1
|
||||
fi
|
||||
# A failed cask install can leave a daemon even after Homebrew rolls back the app.
|
||||
if sudo launchctl print system/netbird > "$results_dir/cleanup-service.log" 2>&1; then
|
||||
sudo netbird service stop || status=1
|
||||
fi
|
||||
if [[ -e "$plist" ]]; then
|
||||
sudo netbird service uninstall || status=1
|
||||
fi
|
||||
fi
|
||||
if [[ -f /var/log/netbird/client.log ]]; then
|
||||
sudo cat /var/log/netbird/client.log > "$results_dir/client.log" || status=1
|
||||
fi
|
||||
if command -v netbird >/dev/null; then
|
||||
brew uninstall --formula "$formula" || status=1
|
||||
fi
|
||||
if [[ -n $server_pid ]]; then
|
||||
kill "$server_pid" 2>/dev/null || true
|
||||
fi
|
||||
exit "$status"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
run_logged() {
|
||||
local name=$1
|
||||
shift
|
||||
"$@" 2>&1 | tee "$results_dir/$name.log"
|
||||
}
|
||||
|
||||
cask_field() {
|
||||
local stanza=$1 file=$2
|
||||
sed -nE "s/^[[:space:]]*$stanza \"([^\"]+)\".*/\\1/p" "$file"
|
||||
}
|
||||
|
||||
release_fields() {
|
||||
local file=$1
|
||||
grep -E '^[[:space:]]*(version|url|sha256|app) ' "$file"
|
||||
}
|
||||
|
||||
use_cask() {
|
||||
local file=$1
|
||||
cp "$file" "$tap_dir/Casks/netbird-ui.rb"
|
||||
}
|
||||
|
||||
# The released installer opens the UI as root, which never returns on a headless
|
||||
# runner. The cask only needs two script paths and a version argument, so the test
|
||||
# ships a stub bundle that records what it received and starts the daemon.
|
||||
build_fixture() {
|
||||
local bundle="$fixture_dir/netbird_ui_darwin"
|
||||
printf '#!/bin/sh\nexit 0\n' > "$bundle/netbird-ui"
|
||||
chmod 755 "$bundle/netbird-ui"
|
||||
# After a bootout launchd keeps tearing the previous daemon down for a couple of
|
||||
# seconds, and loading the same label again fails until that finishes.
|
||||
cat > "$bundle/installer.sh" <<EOF
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
export PATH=\$PATH:/usr/local/bin:/opt/homebrew/bin
|
||||
printf 'version=%s\\nuid=%s\\n' "\$1" "\$(id -u)" > '$marker'
|
||||
netbird service install
|
||||
attempt=0
|
||||
until netbird service start; do
|
||||
attempt=\$((attempt + 1))
|
||||
[ "\$attempt" -lt 15 ] || exit 1
|
||||
sleep 1
|
||||
done
|
||||
EOF
|
||||
printf '#!/bin/sh\nexit 0\n' > "$bundle/uninstaller.sh"
|
||||
# Shipped without the executable bit so the 0755 seen after install can only come from the cask.
|
||||
chmod 644 "$bundle/installer.sh" "$bundle/uninstaller.sh"
|
||||
rm -f "$fixture_zip"
|
||||
(cd "$fixture_dir" && zip -qr "$fixture_zip" netbird_ui_darwin)
|
||||
}
|
||||
|
||||
start_fixture_server() {
|
||||
python3 -m http.server "$fixture_port" --bind 127.0.0.1 --directory "$serve_dir" \
|
||||
> "$results_dir/fixture-server.log" 2>&1 &
|
||||
server_pid=$!
|
||||
local attempt
|
||||
for attempt in {1..20}; do
|
||||
if curl --silent --fail --output /dev/null "$fixture_url"; then
|
||||
return
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
fail "The fixture HTTP server did not come up on port $fixture_port."
|
||||
}
|
||||
|
||||
assert_published_layout() {
|
||||
local url archive script
|
||||
while read -r url; do
|
||||
archive="$test_dir/downloads/${url##*/}"
|
||||
curl --fail --location --silent --retry 3 --output "$archive" "$url"
|
||||
for script in installer.sh uninstaller.sh; do
|
||||
unzip -l "$archive" | grep -q " netbird_ui_darwin/$script\$" ||
|
||||
fail "The published archive ${url##*/} has no netbird_ui_darwin/$script."
|
||||
done
|
||||
done < <(cask_field url "$published_cask")
|
||||
}
|
||||
|
||||
assert_no_deprecations() {
|
||||
if grep -Ei '(postflight|uninstall_preflight).*deprecated|deprecated.*(postflight|uninstall_preflight)' "$@"; then
|
||||
fail "Homebrew reported a deprecated cask lifecycle hook."
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_daemon() {
|
||||
local attempt
|
||||
for attempt in {1..30}; do
|
||||
if sudo launchctl print system/netbird > "$results_dir/service.log" 2>&1 &&
|
||||
grep -Eq '^[[:space:]]*state = running$' "$results_dir/service.log"; then
|
||||
return
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
cat "$results_dir/service.log"
|
||||
fail "The installed daemon did not reach the running state."
|
||||
}
|
||||
|
||||
wait_for_exit() {
|
||||
local pid=$1 attempt
|
||||
for attempt in {1..30}; do
|
||||
if ! sudo kill -0 "$pid" 2>/dev/null; then
|
||||
return
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
fail "Daemon process $pid is still running after removal."
|
||||
}
|
||||
|
||||
assert_service_absent() {
|
||||
if sudo launchctl print system/netbird > "$results_dir/removed-service.log" 2>&1; then
|
||||
fail "The NetBird service is still loaded after removal."
|
||||
fi
|
||||
}
|
||||
|
||||
assert_installed() {
|
||||
local script
|
||||
[[ -f $marker ]] || fail "The cask did not run installer.sh."
|
||||
grep -qx "version=$version" "$marker" || fail "installer.sh did not receive the cask version: $(cat "$marker")"
|
||||
grep -qx 'uid=0' "$marker" || fail "installer.sh did not run as root: $(cat "$marker")"
|
||||
[[ -d "$app" && -x "$app/netbird-ui" ]] || fail "The UI was not installed."
|
||||
for script in installer.sh uninstaller.sh; do
|
||||
[[ $(stat -f '%Lp' "$app/$script") == 755 ]] || fail "Incorrect permissions on $script."
|
||||
done
|
||||
[[ -f "$plist" ]] || fail "The installer did not create the daemon plist."
|
||||
wait_for_daemon
|
||||
daemon_pid=$(awk '/^[[:space:]]*pid = / { print $3; exit }' "$results_dir/service.log")
|
||||
[[ $daemon_pid =~ ^[0-9]+$ ]] || fail "The running daemon has no PID."
|
||||
sudo kill -0 "$daemon_pid"
|
||||
}
|
||||
|
||||
assert_uninstalled() {
|
||||
local log=$1
|
||||
assert_no_deprecations "$log"
|
||||
[[ ! -e "$app" ]] || fail "The UI app remains after uninstall."
|
||||
[[ ! -e "$plist" ]] || fail "The daemon plist remains after uninstall."
|
||||
assert_service_absent
|
||||
wait_for_exit "$daemon_pid"
|
||||
[[ $(netbird version) == "$version" ]] || fail "Cask uninstall removed the CLI dependency."
|
||||
}
|
||||
|
||||
installed_caskfiles() {
|
||||
local extension=$1
|
||||
find "$(brew --caskroom)/netbird-ui/.metadata" -name "netbird-ui.$extension" 2>/dev/null
|
||||
}
|
||||
|
||||
assert_legacy_metadata() {
|
||||
installed_caskfiles rb | grep -q . || fail "The legacy cask did not leave a Ruby caskfile behind."
|
||||
}
|
||||
|
||||
assert_steps_metadata() {
|
||||
if installed_caskfiles rb | grep -q .; then
|
||||
fail "Homebrew still keeps the legacy Ruby caskfile after reinstall."
|
||||
fi
|
||||
installed_caskfiles json | grep -q . || fail "Homebrew did not save the reinstalled cask as JSON."
|
||||
}
|
||||
|
||||
brew --version
|
||||
sw_vers
|
||||
brew tap netbirdio/tap "${GITHUB_WORKSPACE:?}/.homebrew-cask-tap"
|
||||
tap_dir=$(brew --repository netbirdio/tap)
|
||||
readonly tap_dir
|
||||
|
||||
[[ -f "$tap_dir/Casks/netbird-ui.rb" ]] || fail "The tap has no Casks/netbird-ui.rb."
|
||||
cp "$tap_dir/Casks/netbird-ui.rb" "$published_cask"
|
||||
cp "$published_cask" "$results_dir/published-netbird-ui.rb"
|
||||
|
||||
version=$(brew info --json=v2 --formula "$formula" | jq -r '.formulae[0].versions.stable')
|
||||
readonly version
|
||||
[[ -n $version && $version != null ]] || fail "Could not read the formula version from the tap."
|
||||
|
||||
assert_published_layout
|
||||
|
||||
build_fixture
|
||||
fixture_sha=$(shasum -a 256 "$fixture_zip" | cut -d' ' -f1)
|
||||
readonly fixture_sha
|
||||
start_fixture_server
|
||||
|
||||
export PROJECT=netbird-ui VERSION="$version"
|
||||
export AMD="$fixture_zip" ARM="$fixture_zip" AMD_URL="$fixture_url" ARM_URL="$fixture_url"
|
||||
gomplate -f "$GITHUB_WORKSPACE/client/ui/netbird-ui.rb.tmpl" -o "$rendered_cask"
|
||||
cp "$rendered_cask" "$results_dir/rendered-netbird-ui.rb"
|
||||
|
||||
sed -E "s|^([[:space:]]*version) \"[^\"]+\"|\\1 \"$version\"|; s|^([[:space:]]*url) \"[^\"]+\"|\\1 \"$fixture_url\"|; s|^([[:space:]]*sha256) \"[^\"]+\"|\\1 \"$fixture_sha\"|" \
|
||||
"$published_cask" > "$legacy_cask"
|
||||
cp "$legacy_cask" "$results_dir/legacy-netbird-ui.rb"
|
||||
if ! diff <(release_fields "$legacy_cask") <(release_fields "$rendered_cask"); then
|
||||
fail "The rendered cask changes release data, not only lifecycle stanzas."
|
||||
fi
|
||||
|
||||
use_cask "$rendered_cask"
|
||||
brew info --json=v2 --cask "$cask" > "$results_dir/cask.json" 2> "$results_dir/load.log"
|
||||
cat "$results_dir/load.log"
|
||||
assert_no_deprecations "$results_dir/load.log"
|
||||
run_logged style brew style --cask --only-cops=Cask/InstallSteps "$cask"
|
||||
|
||||
run_logged install-cli brew install --formula "$formula"
|
||||
[[ $(netbird version) == "$version" ]] || fail "The installed CLI does not report the formula version."
|
||||
|
||||
for scenario in running stopped missing; do
|
||||
echo "::group::Uninstall with $scenario service"
|
||||
install_attempted=true
|
||||
sudo rm -f "$marker"
|
||||
run_logged "install-$scenario" brew install --cask "$cask"
|
||||
assert_no_deprecations "$results_dir/install-$scenario.log"
|
||||
assert_installed
|
||||
stop_ui
|
||||
|
||||
case "$scenario" in
|
||||
running) ;;
|
||||
stopped)
|
||||
run_logged stop-daemon sudo netbird service stop
|
||||
wait_for_exit "$daemon_pid"
|
||||
[[ -f "$plist" ]] || fail "Stopping the daemon unexpectedly removed its plist."
|
||||
;;
|
||||
missing)
|
||||
run_logged stop-missing-daemon sudo netbird service stop
|
||||
run_logged remove-daemon sudo netbird service uninstall
|
||||
wait_for_exit "$daemon_pid"
|
||||
[[ ! -e "$plist" ]] || fail "The missing-service scenario still has a plist."
|
||||
assert_service_absent
|
||||
;;
|
||||
*) fail "Unknown uninstall scenario: $scenario" ;;
|
||||
esac
|
||||
|
||||
run_logged "uninstall-$scenario" brew uninstall --cask "$cask"
|
||||
assert_uninstalled "$results_dir/uninstall-$scenario.log"
|
||||
echo "::endgroup::"
|
||||
done
|
||||
|
||||
# Every existing user first meets the new cask through an upgrade of the published
|
||||
# one, whose legacy flight blocks Homebrew replays from the saved Ruby caskfile.
|
||||
echo "::group::Reinstall over the published legacy cask"
|
||||
install_attempted=true
|
||||
use_cask "$legacy_cask"
|
||||
sudo rm -f "$marker"
|
||||
run_logged install-legacy brew install --cask "$cask"
|
||||
assert_installed
|
||||
assert_legacy_metadata
|
||||
stop_ui
|
||||
|
||||
use_cask "$rendered_cask"
|
||||
sudo rm -f "$marker"
|
||||
run_logged reinstall-legacy brew reinstall --cask "$cask"
|
||||
assert_installed
|
||||
assert_steps_metadata
|
||||
stop_ui
|
||||
|
||||
run_logged uninstall-legacy brew uninstall --cask "$cask"
|
||||
assert_uninstalled "$results_dir/uninstall-legacy.log"
|
||||
echo "::endgroup::"
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
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: |
|
||||
${{ runner.os }}-anet-e2e-buildx-
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ jobs:
|
||||
docs-ack:
|
||||
name: Require docs PR URL or explicit "not needed"
|
||||
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:
|
||||
- name: Read PR body
|
||||
|
||||
@@ -3,9 +3,16 @@ name: Post release topic on Discourse
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_run:
|
||||
workflows: [Release]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
post:
|
||||
if: github.event_name == 'release'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: roots/discourse-topic-github-release-action@557d74ea05b6cc0c47f555c1d5d28a89d904005b # v1.1.0
|
||||
@@ -15,3 +22,78 @@ jobs:
|
||||
discourse-author-username: NetBird
|
||||
discourse-category: 17
|
||||
discourse-tags: releases
|
||||
|
||||
# goreleaser publishes the release of a pushed tag with GITHUB_TOKEN, and events
|
||||
# raised by that token start no workflow, so the release event above never fires.
|
||||
post-tag-release:
|
||||
if: >-
|
||||
github.event_name == 'workflow_run' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
startsWith(github.event.workflow_run.head_branch, 'v') &&
|
||||
!contains(github.event.workflow_run.head_branch, '-')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Post the release topic
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.event.workflow_run.head_branch }}
|
||||
DISCOURSE_API_KEY: ${{ secrets.DISCOURSE_RELEASES_API_KEY }}
|
||||
DISCOURSE_URL: https://forum.netbird.io
|
||||
DISCOURSE_USER: NetBird
|
||||
DISCOURSE_CATEGORY: "17"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if ! release=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" 2>gh.err); then
|
||||
if grep -q 'HTTP 404' gh.err; then
|
||||
echo "no release for ${TAG}, nothing to announce"
|
||||
exit 0
|
||||
fi
|
||||
cat gh.err >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A release a person published already fired the release event above.
|
||||
author=$(jq -r '.author.login' <<<"$release")
|
||||
if [ "$author" != "github-actions[bot]" ] || [ "$(jq -r '.draft' <<<"$release")" = "true" ]; then
|
||||
echo "${TAG} published by ${author}, announced by the release event"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Same title and body as the action above, so both paths read alike.
|
||||
title="${GITHUB_REPOSITORY#*/} ${TAG} released"
|
||||
|
||||
# A re-run of a successful Release run fires this event again. A failed lookup
|
||||
# stops the step rather than risk a duplicate topic.
|
||||
search=$(curl -fsS --retry 3 --proto '=https' --proto-redir '=https' -G \
|
||||
-H "Api-Key: ${DISCOURSE_API_KEY}" \
|
||||
-H "Api-Username: ${DISCOURSE_USER}" \
|
||||
"${DISCOURSE_URL}/search.json" \
|
||||
--data-urlencode "q=\"${title}\" in:title")
|
||||
existing=$(jq -r --arg t "$title" --argjson c "$DISCOURSE_CATEGORY" \
|
||||
'[(.topics // [])[] | select(.category_id == $c and (.title | ascii_downcase) == ($t | ascii_downcase))] | length' \
|
||||
<<<"$search")
|
||||
if [ "$existing" != "0" ]; then
|
||||
echo "topic '${title}' already exists"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# goreleaser leaves the body empty; the notes are written on the release page afterwards.
|
||||
url=$(jq -r '.html_url' <<<"$release")
|
||||
body=$(jq -r '.body // ""' <<<"$release")
|
||||
if [ -z "${body//[[:space:]]/}" ]; then
|
||||
body="Release notes and downloads: ${url}"
|
||||
fi
|
||||
|
||||
jq -n \
|
||||
--arg title "$title" \
|
||||
--arg raw "$(printf '[%s](%s)\n\n%s' "$title" "$url" "$body")" \
|
||||
--argjson category "$DISCOURSE_CATEGORY" \
|
||||
'{title: $title, raw: $raw, category: $category, tags: ["releases"]}' \
|
||||
| curl -fsS -X POST "${DISCOURSE_URL}/posts.json" \
|
||||
-H "Api-Key: ${DISCOURSE_API_KEY}" \
|
||||
-H "Api-Username: ${DISCOURSE_USER}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data @- \
|
||||
| jq -r '"posted \(env.DISCOURSE_URL)/t/\(.topic_slug)/\(.topic_id)"'
|
||||
|
||||
@@ -38,12 +38,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 11
|
||||
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('client/ui/frontend/pnpm-lock.yaml') }}
|
||||
|
||||
@@ -46,15 +46,17 @@ jobs:
|
||||
run: git --no-pager diff --exit-code
|
||||
|
||||
- name: Test
|
||||
# Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
|
||||
# which fails to compile until the frontend has been built. The Wails UI
|
||||
# has no Go-side unit tests, and its release pipeline runs `pnpm build`
|
||||
# before goreleaser.
|
||||
# Exclude the client/ui package itself: its main.go uses //go:embed
|
||||
# all:frontend/dist, which fails to compile until the frontend has been
|
||||
# built, and its release pipeline runs `pnpm build` before goreleaser.
|
||||
# The pattern is anchored so the subpackages (services, preferences,
|
||||
# i18n, authsession) still run: they hold Go-side unit tests and need no
|
||||
# frontend bundle.
|
||||
# `go list -e` lets the listing succeed even though the embed fails to
|
||||
# resolve; the grep then drops the broken package by path. Without -e,
|
||||
# go list aborts with empty stdout and `go test` falls back to the repo
|
||||
# root, which has no Go files.
|
||||
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /client/testutil/privileged)
|
||||
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e '/client/ui$' -e /client/testutil/privileged)
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
with:
|
||||
usesh: true
|
||||
copyback: false
|
||||
release: "15.0"
|
||||
release: "15.1"
|
||||
envs: "GO_VERSION"
|
||||
prepare: |
|
||||
pkg install -y curl pkgconf xorg
|
||||
|
||||
@@ -160,9 +160,10 @@ jobs:
|
||||
|
||||
- name: Test
|
||||
# Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
|
||||
# which fails to compile until the frontend has been built. The Wails UI
|
||||
# has no Go-side unit tests, and its release pipeline runs `pnpm build`
|
||||
# before goreleaser.
|
||||
# which fails to compile until the frontend has been built, and its
|
||||
# release pipeline runs `pnpm build` before goreleaser. The subpackages
|
||||
# go with it because this runner's gtk4 is older than the wails runtime
|
||||
# needs; the Client UI / Unit job below covers them instead.
|
||||
# `go list -e` lets the listing succeed even though the embed fails to
|
||||
# resolve; the grep then drops the broken package by path. Without -e,
|
||||
# go list aborts with empty stdout and `go test` falls back to the repo
|
||||
@@ -177,6 +178,80 @@ jobs:
|
||||
slug: netbirdio/netbird
|
||||
flags: unit,client
|
||||
|
||||
test_client_ui:
|
||||
name: "Client UI / Unit"
|
||||
# Pinned to 24.04 rather than the 22.04 the other client jobs use: the wails
|
||||
# runtime's linux cgo layer needs GtkFileDialog, which arrived in gtk4 4.10,
|
||||
# and jammy ships 4.6. Not ubuntu-latest, so a runner image rollover cannot
|
||||
# move this out from under us.
|
||||
runs-on: ubuntu-24.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 dependencies
|
||||
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev
|
||||
|
||||
- name: Test
|
||||
# client/ui itself stays out: its main.go embeds all:frontend/dist,
|
||||
# which only exists after `pnpm build`. The subpackages carry the
|
||||
# Go-side unit tests, including the window manager re-entrancy
|
||||
# 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/...
|
||||
|
||||
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/...
|
||||
|
||||
- name: Test SSH privilege drop
|
||||
# purego marks the runtime as cgo without libc's setxid hooks, so the syscall
|
||||
# package's setuid family panics in this build. Drop privileges as root to cover it.
|
||||
run: |
|
||||
CGO_ENABLED=0 go test -c -tags 'pkcs11 privileged' -o "$RUNNER_TEMP/ssh-server.test" ./client/ssh/server/
|
||||
sudo "$RUNNER_TEMP/ssh-server.test" -test.run '^TestDropPrivilegesAllThreads$' -test.v
|
||||
CGO_ENABLED=0 GOARCH=arm64 go test -c -tags 'pkcs11 privileged' -o "$RUNNER_TEMP/ssh-server-arm64.test" ./client/ssh/server/
|
||||
|
||||
test_client_on_docker:
|
||||
name: "Client (Docker) / Unit"
|
||||
needs: [build-cache]
|
||||
@@ -211,6 +286,9 @@ jobs:
|
||||
${{ runner.os }}-gotest-cache-
|
||||
|
||||
- name: Run tests in container
|
||||
# Unlike the native job above, this one drops all of client/ui including
|
||||
# the subpackages: the alpine container has no gtk4/webkitgtk, so the
|
||||
# Wails application package they import would fail to link.
|
||||
env:
|
||||
HOST_GOCACHE: ${{ steps.go-env.outputs.cache_dir }}
|
||||
HOST_GOMODCACHE: ${{ steps.go-env.outputs.modcache_dir }}
|
||||
@@ -233,7 +311,7 @@ jobs:
|
||||
-e GOCACHE=${CONTAINER_GOCACHE} \
|
||||
-e GOMODCACHE=${CONTAINER_GOMODCACHE} \
|
||||
-e CONTAINER=${CONTAINER} \
|
||||
golang:1.26.7-alpine \
|
||||
golang:1.26.10-alpine \
|
||||
sh -c ' \
|
||||
apk update; apk add --no-cache \
|
||||
ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \
|
||||
@@ -428,7 +506,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: ["amd64"]
|
||||
store: ["sqlite", "postgres", "mysql"]
|
||||
store: ["sqlite", "postgres"]
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -477,18 +555,32 @@ jobs:
|
||||
DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }}
|
||||
run: echo "$DOCKER_TOKEN" | sudo docker login --username "$DOCKER_USER" --password-stdin
|
||||
|
||||
- name: download mysql image
|
||||
if: matrix.store == 'mysql'
|
||||
run: docker pull mlsmaycon/warmed-mysql:8
|
||||
|
||||
# The -json stream goes through tools/gotestsummary so the log shows one
|
||||
# line per test, the output of failed tests, the head of a timeout panic
|
||||
# with the still-running tests, and the slowest tests per package.
|
||||
- name: Test
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
|
||||
CI=true \
|
||||
go test -tags=devcert -coverprofile=coverage.txt \
|
||||
go test -json -tags=devcert -coverprofile=coverage.txt \
|
||||
-exec "sudo --preserve-env=CI,NETBIRD_STORE_ENGINE" \
|
||||
-timeout 20m ./management/... ./shared/management/...
|
||||
-timeout 20m ./management/... ./shared/management/... \
|
||||
| tee management-test-events.jsonl \
|
||||
| go run ./tools/gotestsummary
|
||||
|
||||
# The summary trims long outputs; the raw stream keeps every line for
|
||||
# the failures that need it. A green run has no use for it.
|
||||
- name: Upload raw test events
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||
with:
|
||||
name: management-unit-test-events-${{ matrix.store }}
|
||||
path: management-test-events.jsonl
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.arch == 'amd64'
|
||||
@@ -738,12 +830,27 @@ jobs:
|
||||
- name: check git status
|
||||
run: git --no-pager diff --exit-code
|
||||
|
||||
# Same summary as the unit job: a timeout here names the tests still
|
||||
# running instead of ending in a goroutine dump.
|
||||
- name: Test
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
|
||||
CI=true \
|
||||
mage integrationtest:all -gotestflags="-coverprofile=coverage.txt"
|
||||
mage integrationtest:all -gotestflags="-json -coverprofile=coverage.txt" \
|
||||
| tee management-integration-test-events.jsonl \
|
||||
| go run ./tools/gotestsummary
|
||||
|
||||
- name: Upload raw test events
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||
with:
|
||||
name: management-integration-test-events-${{ matrix.store }}
|
||||
path: management-integration-test-events.jsonl
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.arch == 'amd64'
|
||||
|
||||
@@ -66,15 +66,17 @@ jobs:
|
||||
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOCACHE=${{ env.modcache }}
|
||||
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe mod tidy
|
||||
- name: Generate test script
|
||||
# Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
|
||||
# which fails to compile until the frontend has been built. The Wails UI
|
||||
# has no Go-side unit tests, and its release pipeline runs `pnpm build`
|
||||
# before goreleaser.
|
||||
# Exclude the client/ui package itself: its main.go uses //go:embed
|
||||
# all:frontend/dist, which fails to compile until the frontend has been
|
||||
# built, and its release pipeline runs `pnpm build` before goreleaser.
|
||||
# The pattern is anchored so the subpackages (services, preferences,
|
||||
# i18n, authsession) still run: they hold Go-side unit tests and need no
|
||||
# frontend bundle.
|
||||
# `go list -e` lets the listing succeed even though the embed fails to
|
||||
# resolve; the Where-Object pipeline then drops the broken package by
|
||||
# path. Without -e, go list aborts with empty stdout.
|
||||
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$' }
|
||||
$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"
|
||||
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
|
||||
# recursive "**" form did not take effect with the codespell shipped
|
||||
# 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:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -80,3 +80,49 @@ jobs:
|
||||
skip-save-cache: true
|
||||
cache-invalidation-interval: 0
|
||||
args: --timeout=20m
|
||||
|
||||
# Separate job rather than extra rows in the matrix above: those rows pick a
|
||||
# GOOS by picking a runner OS, while android/ios are cross-compiled from
|
||||
# ubuntu — an `include` entry with os: ubuntu-latest would merge into the
|
||||
# Linux row instead of adding one. The package path is restricted because a
|
||||
# whole-repo run under GOOS=android pulls *_linux.go files into packages that
|
||||
# have no android counterpart.
|
||||
golangci-mobile:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- goos: android
|
||||
goarch: arm64
|
||||
packages: ./client/android/...
|
||||
display_name: Android
|
||||
- goos: ios
|
||||
goarch: arm64
|
||||
packages: ./client/ios/...
|
||||
display_name: iOS
|
||||
name: ${{ matrix.display_name }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
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: golangci-lint
|
||||
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
|
||||
with:
|
||||
version: latest
|
||||
install-mode: binary
|
||||
skip-cache: true
|
||||
skip-save-cache: true
|
||||
cache-invalidation-interval: 0
|
||||
args: --timeout=20m ${{ matrix.packages }}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
name: Mobile
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release-*"
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
android_build:
|
||||
name: "Android / Build"
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
goarch: [arm64, arm, amd64, "386"]
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: android
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
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"
|
||||
- name: Build Android bridge
|
||||
run: go build ./client/android/...
|
||||
- name: Vet Android bridge
|
||||
if: matrix.goarch == 'arm64'
|
||||
run: go vet ./client/android/...
|
||||
|
||||
ios_build:
|
||||
name: "iOS / Build"
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
goarch: [arm64, amd64]
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: ios
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
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"
|
||||
# No `go vet` counterpart: every ios target requires external (cgo)
|
||||
# linking, which needs an Xcode toolchain the runner does not have.
|
||||
- name: Build iOS SDK
|
||||
run: go build ./client/ios/...
|
||||
@@ -7,6 +7,8 @@ on:
|
||||
jobs:
|
||||
check-title:
|
||||
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:
|
||||
- name: Validate PR title prefix
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
name: Red Hat Certification
|
||||
|
||||
# Certify published UBI images in the Red Hat Ecosystem Catalog. Called by
|
||||
# release.yml on stable tags, or run by hand to (re)certify any released
|
||||
# version. preflight submits every architecture of an image's manifest list
|
||||
# to Pyxis; auto-publish on the component makes it public once certified.
|
||||
#
|
||||
# Each component's Partner Connect ID comes from the REDHAT_CERT_ID_<NAME>
|
||||
# repository variable, e.g. REDHAT_CERT_ID_CLIENT_ROOTLESS. The run fails
|
||||
# before certifying anything if a selected component's variable is not set.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
component:
|
||||
type: string
|
||||
required: true
|
||||
version:
|
||||
type: string
|
||||
required: true
|
||||
secrets:
|
||||
PYXIS_API_TOKEN:
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
component:
|
||||
description: "Component to certify"
|
||||
type: choice
|
||||
required: true
|
||||
default: all
|
||||
options:
|
||||
- all
|
||||
- client-rootless
|
||||
- reverse-proxy
|
||||
- netbird-server
|
||||
- management
|
||||
- signal
|
||||
- relay
|
||||
version:
|
||||
description: "Released version, e.g. v0.80.0"
|
||||
type: string
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
resolve:
|
||||
name: Resolve components
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
version: ${{ steps.resolve.outputs.version }}
|
||||
matrix: ${{ steps.resolve.outputs.matrix }}
|
||||
steps:
|
||||
- name: Resolve components and images
|
||||
id: resolve
|
||||
env:
|
||||
COMPONENT: ${{ inputs.component }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
REPO_VARS: ${{ toJSON(vars) }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${INPUT_VERSION#v}"
|
||||
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::Only stable x.y.z versions are certified, got '${INPUT_VERSION}'"
|
||||
exit 1
|
||||
fi
|
||||
# name, image repository, tag suffix (must match .goreleaser.yaml).
|
||||
# Keep the names in sync with the workflow_dispatch options above.
|
||||
components=(
|
||||
"client-rootless ghcr.io/netbirdio/netbird -rootless-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="[]"
|
||||
missing=()
|
||||
for c in "${components[@]}"; do
|
||||
read -r name repo suffix <<< "$c"
|
||||
[[ "$COMPONENT" == all || "$COMPONENT" == "$name" ]] || continue
|
||||
var="REDHAT_CERT_ID_${name^^}"; var="${var//-/_}"
|
||||
id="$(jq -r --arg v "$var" '.[$v] // empty' <<< "$REPO_VARS")"
|
||||
if [[ -z "$id" ]]; then
|
||||
missing+=("$var")
|
||||
continue
|
||||
fi
|
||||
matrix="$(jq -c --arg n "$name" --arg t "${version}${suffix}" --arg r "${repo}:${version}${suffix}" --arg i "$id" \
|
||||
'. + [{component: $n, tag: $t, ref: $r, component_id: $i}]' <<< "$matrix")"
|
||||
done
|
||||
if (( ${#missing[@]} )); then
|
||||
echo "::error::Set these repository variables to the Partner Connect component IDs: ${missing[*]}"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$matrix" == "[]" ]]; then
|
||||
echo "::error::No component to certify for '${COMPONENT}'"
|
||||
exit 1
|
||||
fi
|
||||
echo "Components to certify: ${matrix}"
|
||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||
echo "matrix=${matrix}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
certify:
|
||||
name: "Certify ${{ matrix.component }} UBI image"
|
||||
needs: resolve
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include: ${{ fromJSON(needs.resolve.outputs.matrix) }}
|
||||
env:
|
||||
PREFLIGHT_VERSION: "1.21.0"
|
||||
# sha256 of preflight-linux-amd64 from the 1.21.0 GitHub release.
|
||||
# Red Hat publishes no checksum file, so the value is pinned here.
|
||||
PREFLIGHT_SHA256: "5e653135503c72f8702bbe31d7643197d12937c68086879133dd6b9650a9a449"
|
||||
steps:
|
||||
- name: Verify the multi-arch image is on ghcr.io
|
||||
env:
|
||||
IMAGE_REF: ${{ matrix.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker buildx imagetools inspect "$IMAGE_REF" --raw > manifest.json
|
||||
for arch in amd64 arm64; do
|
||||
if ! jq -e --arg a "$arch" '.manifests[] | select(.platform.architecture == $a)' manifest.json > /dev/null; then
|
||||
echo "::error::${IMAGE_REF} has no ${arch} manifest"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "Manifest list for ${IMAGE_REF}:"
|
||||
jq -r '.manifests[] | "\(.platform.os)/\(.platform.architecture) \(.digest)"' manifest.json
|
||||
|
||||
- name: Install preflight
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL --proto '=https' --proto-redir '=https' -o preflight \
|
||||
"https://github.com/redhat-openshift-ecosystem/openshift-preflight/releases/download/${PREFLIGHT_VERSION}/preflight-linux-amd64"
|
||||
echo "${PREFLIGHT_SHA256} preflight" | sha256sum -c -
|
||||
chmod +x preflight
|
||||
./preflight --version
|
||||
|
||||
- name: Run preflight checks and submit to Red Hat
|
||||
env:
|
||||
IMAGE_REF: ${{ matrix.ref }}
|
||||
PFLT_PYXIS_API_TOKEN: ${{ secrets.PYXIS_API_TOKEN }}
|
||||
PFLT_CERTIFICATION_COMPONENT_ID: ${{ matrix.component_id }}
|
||||
PFLT_ARTIFACTS: artifacts
|
||||
PFLT_LOGFILE: artifacts/preflight.log
|
||||
PFLT_LOGLEVEL: info
|
||||
PFLT_JUNIT: "true"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# No --platform: preflight walks the manifest list and submits every
|
||||
# architecture in one run, grouped under one manifest-list digest.
|
||||
# preflight does not create the PFLT_LOGFILE directory, and --submit
|
||||
# fails if the log file is missing.
|
||||
mkdir -p artifacts
|
||||
./preflight check container "$IMAGE_REF" --submit
|
||||
|
||||
- name: Fail if any check did not pass
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob globstar
|
||||
results=(artifacts/**/results.json)
|
||||
if [[ ${#results[@]} -eq 0 ]]; then
|
||||
echo "::error::preflight produced no results.json"
|
||||
exit 1
|
||||
fi
|
||||
status=0
|
||||
for f in "${results[@]}"; do
|
||||
arch="$(basename "$(dirname "$f")")"
|
||||
passed="$(jq -r '.passed' "$f")"
|
||||
failed="$(jq -r '[.results.failed[]?.name] | join(", ")' "$f")"
|
||||
echo "${arch}: passed=${passed} ${failed:+failed checks: ${failed}}"
|
||||
[[ "$passed" == "true" ]] || status=1
|
||||
done
|
||||
exit $status
|
||||
|
||||
- name: Upload preflight artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: redhat-preflight-${{ matrix.component }}-${{ needs.resolve.outputs.version }}
|
||||
path: artifacts/
|
||||
retention-days: 30
|
||||
|
||||
- name: Wait for Pyxis to mark both architectures certified
|
||||
env:
|
||||
TAG: ${{ matrix.tag }}
|
||||
COMPONENT_ID: ${{ matrix.component_id }}
|
||||
PFLT_PYXIS_API_TOKEN: ${{ secrets.PYXIS_API_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Filter on the tag server-side so older versions are found past the first page.
|
||||
url="https://catalog.redhat.com/api/containers/v1/projects/certification/id/${COMPONENT_ID}/images?filter=repositories.tags.name==${TAG}&page_size=100"
|
||||
for attempt in $(seq 1 20); do
|
||||
certified="$(curl -fsS --proto '=https' --proto-redir '=https' -H "X-API-KEY: ${PFLT_PYXIS_API_TOKEN}" "$url" \
|
||||
| jq -r --arg t "$TAG" '[.data[] | select(.repositories[]?.tags[]?.name == $t) | select(.certified == true) | .architecture] | unique | join(",")')"
|
||||
echo "attempt ${attempt}: certified architectures for ${TAG}: ${certified:-none}"
|
||||
if [[ "$certified" == "amd64,arm64" ]]; then
|
||||
echo "Both architectures certified. Auto-publish is enabled on the component, so the catalog updates on its own."
|
||||
exit 0
|
||||
fi
|
||||
sleep 30
|
||||
done
|
||||
echo "::error::Pyxis has not marked both architectures certified after 10 minutes. Check https://connect.redhat.com/component/view/${COMPONENT_ID}/images"
|
||||
exit 1
|
||||
+123
-23
@@ -23,8 +23,50 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
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:
|
||||
name: "FreeBSD Port / Build & Test"
|
||||
needs: freebsd_port_trigger
|
||||
if: needs.freebsd_port_trigger.outputs.run == 'true'
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -33,18 +75,19 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Generate FreeBSD port diff
|
||||
run: bash -x release_files/freebsd-port-diff.sh
|
||||
|
||||
- name: Generate FreeBSD port issue body
|
||||
run: bash -x release_files/freebsd-port-issue-body.sh
|
||||
env:
|
||||
TARGET_VERSION: ${{ needs.freebsd_port_trigger.outputs.version }}
|
||||
run: bash -x release_files/freebsd-port-diff.sh "$TARGET_VERSION"
|
||||
|
||||
- name: Check if diff was generated
|
||||
id: check_diff
|
||||
run: |
|
||||
if ls netbird-*.diff 1> /dev/null 2>&1; then
|
||||
echo "diff_exists=true" >> $GITHUB_OUTPUT
|
||||
shopt -s nullglob
|
||||
diffs=(netbird-*.diff)
|
||||
if [[ ${#diffs[@]} -gt 0 ]]; then
|
||||
echo "diff_exists=true" >> "$GITHUB_OUTPUT"
|
||||
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)"
|
||||
fi
|
||||
|
||||
@@ -52,10 +95,18 @@ jobs:
|
||||
if: steps.check_diff.outputs.diff_exists == 'true'
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(ls netbird-*.diff | sed 's/netbird-\(.*\)\.diff/\1/')
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
diffs=(netbird-*.diff)
|
||||
VERSION="${diffs[0]#netbird-}"
|
||||
VERSION="${VERSION%.diff}"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
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
|
||||
id: goversion
|
||||
@@ -69,7 +120,7 @@ jobs:
|
||||
with:
|
||||
usesh: true
|
||||
copyback: false
|
||||
release: "15.0"
|
||||
release: "15.1"
|
||||
envs: "GO_VERSION"
|
||||
prepare: |
|
||||
# Install required packages
|
||||
@@ -186,6 +237,22 @@ jobs:
|
||||
run: bash shared/management/http/api/generate.sh
|
||||
- name: check git status
|
||||
run: git --no-pager diff --exit-code
|
||||
- name: Generate RPM changelog from git tags
|
||||
# nfpm embeds changelog.yml into the RPM; Red Hat software certification
|
||||
# requires a changelog. Generated, not committed (see .gitignore).
|
||||
# chglog is a go.mod tool directive, so go.sum pins it and its deps.
|
||||
run: bash release_files/rpm-changelog.sh
|
||||
- name: Fill the RPM ISA provide version
|
||||
# nfpm cannot emit rpmbuild's ISA provide and GoReleaser cannot template it.
|
||||
run: bash release_files/rpm-provides.sh
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Install proxy web dependencies for license collection
|
||||
# release_files/collect-licenses.sh -w reads the proxy UI's license terms from node_modules.
|
||||
working-directory: proxy/web
|
||||
run: npm ci --ignore-scripts
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 #v4.1.0
|
||||
- name: Set up Docker Buildx
|
||||
@@ -225,14 +292,18 @@ jobs:
|
||||
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
|
||||
with:
|
||||
version: ${{ env.GORELEASER_VER }}
|
||||
args: release --clean ${{ env.flags }}
|
||||
args: release --config .goreleaser.generated.yaml --clean ${{ env.flags }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
||||
UPLOAD_DEBIAN_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
||||
UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
||||
GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }}
|
||||
NFPM_NETBIRD_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
|
||||
# One per nfpm id: GoReleaser looks the passphrase up as NFPM_<ID>_PASSPHRASE.
|
||||
NFPM_NETBIRD_RPM_AMD64_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
|
||||
NFPM_NETBIRD_RPM_ARM64_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
|
||||
NFPM_NETBIRD_RPM_ARM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
|
||||
NFPM_NETBIRD_RPM_386_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
|
||||
SKIP_PUBLISH: ${{ env.SKIP_PUBLISH }}
|
||||
SKIP_DOCKER_PUSH: ${{ env.SKIP_DOCKER_PUSH }}
|
||||
- name: Verify RPM signatures
|
||||
@@ -287,10 +358,17 @@ jobs:
|
||||
image_refs=()
|
||||
|
||||
tag_and_push() {
|
||||
local src="$1" img_name tag dst
|
||||
local src="$1" img_name tag dst variant=""
|
||||
img_name="${src%%:*}"
|
||||
# Variants share a repository with their default image, so keep
|
||||
# their tag suffixes. Order matters: the first matching pattern wins.
|
||||
case "$src" in
|
||||
*-rootless-ubi-amd64) variant="-rootless-ubi" ;;
|
||||
*-rootless-amd64) variant="-rootless" ;;
|
||||
*-ubi-amd64) variant="-ubi" ;;
|
||||
esac
|
||||
for tag in $(resolve_tags); do
|
||||
dst="${img_name}:${tag}"
|
||||
dst="${img_name}:${tag}${variant}"
|
||||
echo "Tagging ${src} -> ${dst}"
|
||||
docker tag "$src" "$dst"
|
||||
docker push "$dst"
|
||||
@@ -336,7 +414,11 @@ jobs:
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||
with:
|
||||
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
|
||||
- name: upload windows packages
|
||||
id: upload_windows_packages
|
||||
@@ -353,6 +435,24 @@ jobs:
|
||||
path: dist/netbird_darwin**
|
||||
retention-days: 7
|
||||
|
||||
# Certify the UBI images in the Red Hat Ecosystem Catalog on stable tags.
|
||||
# See redhat-certify.yml, which can also be run by hand for any released version.
|
||||
redhat_certification:
|
||||
name: "Red Hat"
|
||||
needs: release
|
||||
if: |
|
||||
github.repository == 'netbirdio/netbird' &&
|
||||
startsWith(github.ref, 'refs/tags/v') &&
|
||||
!contains(github.ref_name, '-')
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/redhat-certify.yml
|
||||
with:
|
||||
component: all
|
||||
version: ${{ github.ref_name }}
|
||||
secrets:
|
||||
PYXIS_API_TOKEN: ${{ secrets.PYXIS_API_TOKEN }}
|
||||
|
||||
release_ui:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
@@ -407,12 +507,12 @@ jobs:
|
||||
run: git --no-pager diff --exit-code
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 11
|
||||
|
||||
@@ -544,12 +644,12 @@ jobs:
|
||||
run: git --no-pager diff --exit-code
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 11
|
||||
|
||||
@@ -641,11 +741,11 @@ jobs:
|
||||
- name: check git status
|
||||
run: git --no-pager diff --exit-code
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 11
|
||||
- name: Install wails3 CLI
|
||||
@@ -764,7 +864,7 @@ jobs:
|
||||
run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/ShellExecAsUser_amd64-Unicode.7z"
|
||||
|
||||
- name: Set up Go for wails3 CLI
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
cache: false
|
||||
|
||||
@@ -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/...
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Test Homebrew cask
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "client/ui/netbird-ui.rb.tmpl"
|
||||
- ".github/scripts/test-homebrew-cask.sh"
|
||||
- ".github/workflows/test-homebrew-cask.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
install-uninstall:
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Clone the Homebrew tap
|
||||
run: git clone https://github.com/netbirdio/homebrew-tap.git .homebrew-cask-tap
|
||||
|
||||
- name: Update Homebrew and install gomplate
|
||||
# The runner image disables auto-update; the cask steps DSL needs Homebrew 6.0.20 or newer.
|
||||
run: |
|
||||
brew update
|
||||
brew install gomplate
|
||||
|
||||
- name: Install and uninstall the cask
|
||||
run: .github/scripts/test-homebrew-cask.sh
|
||||
|
||||
- name: Upload logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||
with:
|
||||
name: homebrew-cask-results
|
||||
path: ${{ runner.temp }}/homebrew-cask/results
|
||||
if-no-files-found: ignore
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
store: ["sqlite", "postgres", "mysql"]
|
||||
store: ["sqlite", "postgres"]
|
||||
services:
|
||||
postgres:
|
||||
image: ${{ (matrix.store == 'postgres') && 'postgres' || '' }}
|
||||
@@ -35,19 +35,6 @@ jobs:
|
||||
--health-timeout 5s
|
||||
ports:
|
||||
- 5432:5432
|
||||
mysql:
|
||||
image: ${{ (matrix.store == 'mysql') && 'mysql' || '' }}
|
||||
env:
|
||||
MYSQL_USER: netbird
|
||||
MYSQL_PASSWORD: mysql
|
||||
MYSQL_ROOT_PASSWORD: mysqlroot
|
||||
MYSQL_DATABASE: netbird
|
||||
options: >-
|
||||
--health-cmd "mysqladmin ping --silent"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
ports:
|
||||
- 3306:3306
|
||||
steps:
|
||||
- name: Set Database Connection String
|
||||
run: |
|
||||
@@ -56,11 +43,6 @@ jobs:
|
||||
else
|
||||
echo "NETBIRD_STORE_ENGINE_POSTGRES_DSN==" >> $GITHUB_ENV
|
||||
fi
|
||||
if [ "${{ matrix.store }}" == "mysql" ]; then
|
||||
echo "NETBIRD_STORE_ENGINE_MYSQL_DSN=netbird:mysql@tcp($(hostname -I | awk '{print $1}'):3306)/netbird" >> $GITHUB_ENV
|
||||
else
|
||||
echo "NETBIRD_STORE_ENGINE_MYSQL_DSN==" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Install jq
|
||||
run: sudo apt-get install -y jq
|
||||
@@ -86,15 +68,6 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Setup MySQL privileges
|
||||
if: matrix.store == 'mysql'
|
||||
run: |
|
||||
sleep 10
|
||||
mysql -h 127.0.0.1 -u root -pmysqlroot -e "
|
||||
GRANT SYSTEM_VARIABLES_ADMIN ON *.* TO 'netbird'@'%';
|
||||
FLUSH PRIVILEGES;
|
||||
"
|
||||
|
||||
- name: cp setup.env
|
||||
run: cp infrastructure_files/tests/setup.env infrastructure_files/
|
||||
|
||||
@@ -114,7 +87,6 @@ jobs:
|
||||
CI_NETBIRD_AUTH_SUPPORTED_SCOPES: "openid profile email offline_access api email_verified"
|
||||
CI_NETBIRD_STORE_CONFIG_ENGINE: ${{ matrix.store }}
|
||||
NETBIRD_STORE_ENGINE_POSTGRES_DSN: ${{ env.NETBIRD_STORE_ENGINE_POSTGRES_DSN }}
|
||||
NETBIRD_STORE_ENGINE_MYSQL_DSN: ${{ env.NETBIRD_STORE_ENGINE_MYSQL_DSN }}
|
||||
CI_NETBIRD_MGMT_IDP_SIGNKEY_REFRESH: false
|
||||
|
||||
- name: check values
|
||||
@@ -143,7 +115,6 @@ jobs:
|
||||
CI_NETBIRD_SIGNAL_PORT: 12345
|
||||
CI_NETBIRD_STORE_CONFIG_ENGINE: ${{ matrix.store }}
|
||||
NETBIRD_STORE_ENGINE_POSTGRES_DSN: "${{ env.NETBIRD_STORE_ENGINE_POSTGRES_DSN }}$"
|
||||
NETBIRD_STORE_ENGINE_MYSQL_DSN: "${{ env.NETBIRD_STORE_ENGINE_MYSQL_DSN }}$"
|
||||
CI_NETBIRD_MGMT_IDP_SIGNKEY_REFRESH: false
|
||||
CI_NETBIRD_TURN_EXTERNAL_IP: "1.2.3.4"
|
||||
CI_NETBIRD_MGMT_DISABLE_DEFAULT_POLICY: false
|
||||
@@ -182,7 +153,6 @@ jobs:
|
||||
grep -A 10 PKCEAuthorizationFlow management.json | grep -A 10 ProviderConfig | grep Scope | grep "$CI_NETBIRD_AUTH_SUPPORTED_SCOPES"
|
||||
grep -A 10 PKCEAuthorizationFlow management.json | grep -A 10 ProviderConfig | grep -A 3 RedirectURLs | grep "http://localhost:53000"
|
||||
grep "external-ip" turnserver.conf | grep $CI_NETBIRD_TURN_EXTERNAL_IP
|
||||
grep "NETBIRD_STORE_ENGINE_MYSQL_DSN=$NETBIRD_STORE_ENGINE_MYSQL_DSN" docker-compose.yml
|
||||
grep NETBIRD_STORE_ENGINE_POSTGRES_DSN docker-compose.yml | egrep "$NETBIRD_STORE_ENGINE_POSTGRES_DSN"
|
||||
# check relay values
|
||||
grep "NB_EXPOSED_ADDRESS=rels://$CI_NETBIRD_DOMAIN:33445" docker-compose.yml
|
||||
@@ -258,6 +228,18 @@ jobs:
|
||||
with:
|
||||
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
|
||||
run: |
|
||||
grep -Fxq ' SESSION_COOKIE_ENCRYPTION_KEY=$(openssl rand -base64 32)' infrastructure_files/getting-started.sh
|
||||
|
||||
@@ -32,11 +32,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
# English (en) is the source of truth for translation keys; every other
|
||||
# locale declared in _index.json must carry the exact same key set.
|
||||
# English (en) is the source of truth for translation keys. Locales declared
|
||||
# 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
|
||||
run: node client/ui/i18n/check-translations.mjs
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
|
||||
echo "Size: ${SIZE} bytes (${SIZE_MB} MB)"
|
||||
|
||||
if [ ${SIZE} -gt 62914560 ]; then
|
||||
echo "Wasm binary size (${SIZE_MB}MB) exceeds 60MB limit!"
|
||||
if [ ${SIZE} -gt 73400320 ]; then
|
||||
echo "Wasm binary size (${SIZE_MB}MB) exceeds 70MB limit!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -35,3 +35,10 @@ vendor/
|
||||
/netbird
|
||||
client/netbird-electron/
|
||||
management/server/types/testdata/
|
||||
|
||||
# generated by chglog in the release workflow, embedded into the RPM
|
||||
changelog.yml
|
||||
|
||||
# generated by rpm-provides.sh, the config GoReleaser actually runs
|
||||
.goreleaser.generated.yaml
|
||||
.chglog.yml
|
||||
|
||||
@@ -24,6 +24,15 @@ linters:
|
||||
settings:
|
||||
errcheck:
|
||||
check-type-assertions: false
|
||||
forbidigo:
|
||||
analyze-types: true
|
||||
forbid:
|
||||
- pattern: ^(fmt\.Print(|f|ln)|print|println)$
|
||||
# Builds that link libc through purego mark the runtime as cgo without
|
||||
# registering the libc setxid hooks, so these panic in AllThreadsSyscall.
|
||||
- pattern: ^(syscall|unix)\.(Setgroups|Set(e|re|res)?[ug]id|AllThreadsSyscall6?)$
|
||||
pkg: ^(syscall|golang\.org/x/sys/unix)$
|
||||
msg: use the setgroups/setgid/setuid helpers in client/ssh/server, which go through libc in purego builds
|
||||
gocritic:
|
||||
disabled-checks:
|
||||
- commentFormatting
|
||||
@@ -86,6 +95,9 @@ linters:
|
||||
- linters:
|
||||
- forbidigo
|
||||
path: signal/cmd/root\.go
|
||||
- linters:
|
||||
- forbidigo
|
||||
path: client/ssh/server/setid_unix\.go
|
||||
- linters:
|
||||
- unused
|
||||
path: sharedsock/filter\.go
|
||||
|
||||
+396
-7
@@ -40,6 +40,73 @@ builds:
|
||||
tags:
|
||||
- load_wgnt_from_rsrc
|
||||
|
||||
# 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
|
||||
id: netbird-rpm-amd64
|
||||
dir: client
|
||||
binary: netbird
|
||||
env: [CGO_ENABLED=0]
|
||||
goos: [linux]
|
||||
goarch: [amd64]
|
||||
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
|
||||
|
||||
- <<: *netbird_rpm_build
|
||||
id: netbird-rpm-arm64
|
||||
goarch: [arm64]
|
||||
|
||||
- <<: *netbird_rpm_build
|
||||
id: netbird-rpm-arm
|
||||
goarch: [arm]
|
||||
tags:
|
||||
- load_wgnt_from_rsrc
|
||||
|
||||
- <<: *netbird_rpm_build
|
||||
id: netbird-rpm-386
|
||||
goarch: [386]
|
||||
tags:
|
||||
- load_wgnt_from_rsrc
|
||||
|
||||
- id: netbird-static
|
||||
dir: client
|
||||
binary: netbird
|
||||
@@ -168,6 +235,29 @@ builds:
|
||||
- -s -w -X main.Version={{.Version}} -X main.Commit={{.Commit}} -X main.BuildDate={{.CommitDate}}
|
||||
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
|
||||
dir: tools/idp-migrate
|
||||
env:
|
||||
@@ -206,6 +296,15 @@ archives:
|
||||
builds:
|
||||
- netbird-idp-migrate
|
||||
name_template: "netbird-idp-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:
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
@@ -216,30 +315,83 @@ nfpms:
|
||||
id: netbird_deb
|
||||
bindir: /usr/bin
|
||||
builds:
|
||||
- netbird
|
||||
- netbird-pkcs11
|
||||
- netbird-deb-32bit
|
||||
formats:
|
||||
- deb
|
||||
scripts:
|
||||
postinstall: "release_files/post_install.sh"
|
||||
preremove: "release_files/pre_remove.sh"
|
||||
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
- &netbird_rpm
|
||||
maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client.
|
||||
homepage: https://netbird.io/
|
||||
license: BSD-3-Clause
|
||||
vendor: NetBird
|
||||
id: netbird_rpm
|
||||
id: netbird_rpm_amd64
|
||||
bindir: /usr/bin
|
||||
builds:
|
||||
- netbird
|
||||
ids:
|
||||
- netbird-rpm-amd64
|
||||
formats:
|
||||
- rpm
|
||||
# Red Hat certification (RPM Version Handling) requires rpmbuild's ISA
|
||||
# provide, which nfpm does not emit. The version is filled in by the release job.
|
||||
provides:
|
||||
- "netbird(x86-64) = @RPM_EVR@"
|
||||
# The client verifies TLS to management and signal against the system trust
|
||||
# store. Red Hat software certification (RPM Dependency Tracking) also
|
||||
# rejects packages that declare no dependencies at all.
|
||||
dependencies:
|
||||
- ca-certificates
|
||||
# Generated in CI by chglog from git tags; Red Hat certification requires an
|
||||
# RPM changelog (RPM Version Handling subtest).
|
||||
changelog: changelog.yml
|
||||
# License, documentation and a config file so the RPM Provenance subtest sees
|
||||
# %license, %doc and %config entries instead of a bare binary.
|
||||
contents:
|
||||
- src: LICENSE
|
||||
dst: /usr/share/licenses/netbird/LICENSE
|
||||
type: license
|
||||
- src: README.md
|
||||
dst: /usr/share/doc/netbird/README.md
|
||||
type: doc
|
||||
- src: release_files/netbird.sysconfig
|
||||
dst: /etc/sysconfig/netbird
|
||||
type: config|noreplace
|
||||
# May hold the PKCS#11 token PIN (NB_TPM_PIN), so readable by root alone.
|
||||
file_info:
|
||||
mode: 0600
|
||||
scripts:
|
||||
postinstall: "release_files/post_install.sh"
|
||||
preremove: "release_files/pre_remove.sh"
|
||||
rpm:
|
||||
summary: NetBird client
|
||||
group: Applications/Internet
|
||||
packager: NetBird <dev@netbird.io>
|
||||
signature:
|
||||
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
||||
|
||||
- <<: *netbird_rpm
|
||||
id: netbird_rpm_arm64
|
||||
ids:
|
||||
- netbird-rpm-arm64
|
||||
provides:
|
||||
- "netbird(aarch-64) = @RPM_EVR@"
|
||||
|
||||
- <<: *netbird_rpm
|
||||
id: netbird_rpm_arm
|
||||
ids:
|
||||
- netbird-rpm-arm
|
||||
provides:
|
||||
- "netbird(armv6hl-32) = @RPM_EVR@"
|
||||
|
||||
- <<: *netbird_rpm
|
||||
id: netbird_rpm_386
|
||||
ids:
|
||||
- netbird-rpm-386
|
||||
provides:
|
||||
- "netbird(x86-32) = @RPM_EVR@"
|
||||
dockers_v2:
|
||||
- id: netbird
|
||||
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||
@@ -289,6 +441,43 @@ dockers_v2:
|
||||
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||
"maintainer": "dev@netbird.io"
|
||||
- id: netbird-rootless-ubi
|
||||
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||
ids:
|
||||
- netbird
|
||||
images:
|
||||
- netbirdio/netbird
|
||||
- ghcr.io/netbirdio/netbird
|
||||
tags:
|
||||
- "{{ .Version }}-rootless-ubi"
|
||||
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}rootless-ubi-latest{{ end }}"
|
||||
dockerfile: client/Dockerfile-rootless.ubi
|
||||
extra_files:
|
||||
- client/netbird-entrypoint.sh
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
build_args:
|
||||
VERSION: "{{ .Version }}"
|
||||
RELEASE: "{{ .Timestamp }}"
|
||||
hooks:
|
||||
pre:
|
||||
- cmd: 'sh release_files/collect-licenses.sh -t load_wgnt_from_rsrc "{{ .ContextDir }}/licenses" ./client 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: relay
|
||||
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||
ids:
|
||||
@@ -311,6 +500,41 @@ dockers_v2:
|
||||
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||
"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
|
||||
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||
ids:
|
||||
@@ -333,6 +557,41 @@ dockers_v2:
|
||||
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||
"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
|
||||
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||
ids:
|
||||
@@ -355,6 +614,41 @@ dockers_v2:
|
||||
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||
"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
|
||||
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||
ids:
|
||||
@@ -365,7 +659,7 @@ dockers_v2:
|
||||
tags:
|
||||
- "{{ .Version }}"
|
||||
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
|
||||
dockerfile: upload-server/Dockerfile
|
||||
dockerfile: upload-server/Dockerfile.release
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
@@ -399,6 +693,41 @@ dockers_v2:
|
||||
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||
"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
|
||||
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||
ids:
|
||||
@@ -421,6 +750,63 @@ dockers_v2:
|
||||
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||
"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
|
||||
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||
ids:
|
||||
- netbird-proxy
|
||||
images:
|
||||
- netbirdio/reverse-proxy
|
||||
- ghcr.io/netbirdio/reverse-proxy
|
||||
tags:
|
||||
- "{{ .Version }}-ubi"
|
||||
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}ubi-latest{{ end }}"
|
||||
dockerfile: proxy/Dockerfile.ubi
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
build_args:
|
||||
VERSION: "{{ .Version }}"
|
||||
RELEASE: "{{ .Timestamp }}"
|
||||
hooks:
|
||||
pre:
|
||||
- cmd: 'sh release_files/collect-licenses.sh -l proxy/LICENSE -w "{{ .ContextDir }}/licenses" ./proxy/cmd/proxy 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"
|
||||
|
||||
brews:
|
||||
- ids:
|
||||
@@ -453,7 +839,10 @@ uploads:
|
||||
- name: yum
|
||||
skip: "{{ .Env.SKIP_PUBLISH }}"
|
||||
ids:
|
||||
- netbird_rpm
|
||||
- netbird_rpm_amd64
|
||||
- netbird_rpm_arm64
|
||||
- netbird_rpm_arm
|
||||
- netbird_rpm_386
|
||||
mode: archive
|
||||
target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
|
||||
username: dev@wiretrustee.com
|
||||
|
||||
@@ -77,7 +77,7 @@ make lint # golangci-lint on files changed vs origin/main (also the p
|
||||
make lint-all # full-repository lint, matches CI
|
||||
make test-unit # host-safe unit tests, -tags devcert, no sudo
|
||||
make test-privileged # privileged-tagged suite in a Docker container with NET_ADMIN
|
||||
make setup-hooks # wire make lint into .githooks/pre-push
|
||||
make setup-hooks # wire .githooks: pre-push runs make lint, commit-msg refuses attribution trailers
|
||||
|
||||
# Narrow runs
|
||||
go test ./client/internal/dns/...
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
See [AGENTS.md](AGENTS.md) for the agent guidelines in this repository.
|
||||
The agent guidelines live in [AGENTS.md](AGENTS.md). It is imported here so
|
||||
every session loads it in full rather than following a pointer.
|
||||
|
||||
@AGENTS.md
|
||||
|
||||
@@ -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.
|
||||
|
||||
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:
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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:
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@ lint-install: $(GOLANGCI_LINT)
|
||||
# Setup git hooks for all developers
|
||||
setup-hooks:
|
||||
@git config core.hooksPath .githooks
|
||||
@chmod +x .githooks/pre-push
|
||||
@echo "✅ Git hooks configured! Pre-push will now run 'make lint'"
|
||||
@chmod +x .githooks/pre-push .githooks/commit-msg
|
||||
@echo "✅ Git hooks configured! Pre-push runs 'make lint'; commit-msg refuses attribution trailers"
|
||||
|
||||
# Host-safe unit tests: excludes the privileged-tagged tests (root / system-mutating).
|
||||
# Runs as a normal user with no sudo and leaves host networking untouched.
|
||||
|
||||
@@ -115,6 +115,56 @@ export NETBIRD_DOMAIN=netbird.example.com; curl -fsSL https://github.com/netbird
|
||||
|
||||
See a complete [architecture overview](https://docs.netbird.io/about-netbird/how-netbird-works#architecture) for details.
|
||||
|
||||
### Reporting bugs and requesting features
|
||||
|
||||
NetBird uses a discussion-first workflow. Bug reports and feature requests start in
|
||||
[Discussions](https://github.com/netbirdio/netbird/discussions), not as issues.
|
||||
|
||||
| What you want to do | Where to go |
|
||||
| --- | --- |
|
||||
| Report a bug, regression, or unexpected behavior | [Issue Triage](https://github.com/netbirdio/netbird/discussions/new?category=issue-triage) |
|
||||
| Request a feature or share an idea | [Ideas & Feature Requests](https://github.com/netbirdio/netbird/discussions/new?category=ideas-feature-requests) |
|
||||
| Ask about setup, configuration, or self-hosting | [Q&A / Support](https://github.com/netbirdio/netbird/discussions/new?category=q-a-support) |
|
||||
| Report a security vulnerability | [Security policy](https://github.com/netbirdio/netbird/security/policy), never a public thread |
|
||||
|
||||
Our team and maintainers triage discussions, ask follow-up questions, check for duplicates,
|
||||
and reproduce bugs. Validated reports are promoted to issues. This keeps the issue tracker a clear
|
||||
answer to one question: what is the team working on.
|
||||
|
||||
Please search existing discussions and issues first, including closed ones. If something similar
|
||||
already exists, upvote it and add your details there instead of opening a duplicate.
|
||||
|
||||
For bug reports, include your NetBird version, operating system, deployment type (Cloud,
|
||||
self-hosted, Kubernetes, or Docker), reproduction steps, expected and actual behavior, and a debug
|
||||
bundle where relevant:
|
||||
|
||||
```shell
|
||||
netbird version
|
||||
netbird status -d -A
|
||||
netbird debug for 1m -A -S -U
|
||||
```
|
||||
|
||||
`-U` uploads the bundle and prints a file key you can paste instead of attaching the archive.
|
||||
`-A` anonymizes the output, which matters on a public thread. It masks most identifying details
|
||||
but is not full redaction, so read the bundle before posting it. Two levels are available:
|
||||
|
||||
| Level | How to select | What it masks |
|
||||
| --- | --- | --- |
|
||||
| `default` | `-A` / `--anonymize` | Public IP addresses, IPv6 ULA addresses, MAC addresses, and domains other than `netbird.io`, `netbird.cloud`, `netbird.selfhosted`, and `netbird.stage`. IPv4 private, CGNAT, and link-local ranges are kept |
|
||||
| `strict` | `--anonymize-level strict` (implies `-A`) | The above, plus IPv4 private, CGNAT, and link-local ranges, peer names in front of `netbird.cloud`, `netbird.selfhosted`, and `netbird.stage`, and WireGuard public keys. Labels under `netbird.io` are kept, since it only hosts infrastructure |
|
||||
|
||||
See [collecting a debug bundle](https://docs.netbird.io/help/troubleshooting-client#debug-bundle)
|
||||
and the [CLI reference](https://docs.netbird.io/get-started/cli#debug-for) for details.
|
||||
|
||||
See [How to use Discussions, Issues, and Pull Requests](https://github.com/netbirdio/netbird/discussions/6075)
|
||||
for the full workflow, or [SUPPORT.md](SUPPORT.md) for a shorter version.
|
||||
|
||||
### Contributing
|
||||
|
||||
Contributions are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) first. NetBird works ticket
|
||||
first, anything that changes behavior needs an issue the team has agreed on before you open a pull
|
||||
request.
|
||||
|
||||
### Community projects
|
||||
- [NetBird installer script](https://github.com/physk/netbird-installer)
|
||||
- [netbird-tui](https://github.com/n0pashkov/netbird-tui) - terminal UI for managing NetBird peers, routes, and settings
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
# Getting help with NetBird
|
||||
|
||||
Where to go depends on what you need. If you are not sure, start with
|
||||
[Q&A / Support](https://github.com/netbirdio/netbird/discussions/new?category=q-a-support)
|
||||
and we will move it.
|
||||
|
||||
## Before you post
|
||||
|
||||
1. Search existing [discussions](https://github.com/netbirdio/netbird/discussions) and
|
||||
[issues](https://github.com/netbirdio/netbird/issues), including closed ones.
|
||||
2. Check the [documentation](https://docs.netbird.io) and the troubleshooting guides for
|
||||
[clients](https://docs.netbird.io/help/troubleshooting-client) and
|
||||
[self-hosted deployments](https://docs.netbird.io/selfhosted/troubleshooting).
|
||||
3. Remove or anonymize sensitive information from logs, screenshots, and configuration.
|
||||
|
||||
If a discussion already covers your problem, upvote it and add your details there rather than
|
||||
opening a duplicate. Extra reproduction detail, affected versions, and deployment notes are
|
||||
useful even on an existing thread.
|
||||
|
||||
## Community support
|
||||
|
||||
Free, for everyone. Covers the NetBird client, open source self-hosted deployments, and general
|
||||
questions.
|
||||
|
||||
| What you want to do | Where to go |
|
||||
| --- | --- |
|
||||
| Report a bug, regression, or unexpected behavior | [Issue Triage](https://github.com/netbirdio/netbird/discussions/new?category=issue-triage) |
|
||||
| Request a feature or share an idea | [Ideas & Feature Requests](https://github.com/netbirdio/netbird/discussions/new?category=ideas-feature-requests) |
|
||||
| Ask about setup, configuration, or self-hosting | [Q&A / Support](https://github.com/netbirdio/netbird/discussions/new?category=q-a-support) |
|
||||
| Chat with the community | [Slack](https://docs.netbird.io/slack-url) |
|
||||
|
||||
## Paid support
|
||||
|
||||
For NetBird Cloud customers and commercial-license self-hosted deployments, covering the
|
||||
dashboard, control plane, billing, and subscriptions, see
|
||||
[reporting bugs and issues](https://docs.netbird.io/help/report-bug-issues).
|
||||
|
||||
## Security
|
||||
|
||||
Do not report security vulnerabilities in public issues or discussions, and do not post secrets,
|
||||
private keys, internal hostnames, or sensitive logs. Use the
|
||||
[security policy](https://github.com/netbirdio/netbird/security/policy).
|
||||
|
||||
## What makes a report we can act on
|
||||
|
||||
For a bug, the most useful reports include:
|
||||
|
||||
- NetBird version, and component versions where applicable
|
||||
- Operating system or environment
|
||||
- Deployment type: NetBird Cloud, self-hosted, Kubernetes, Docker, or local development
|
||||
- Current behavior and expected behavior
|
||||
- The smallest set of steps that reproduces the problem
|
||||
- Logs, status output, screenshots, or a debug bundle when relevant
|
||||
- Whether this worked before, and the last known working version
|
||||
|
||||
For client reports, these commands usually give us what we need:
|
||||
|
||||
```shell
|
||||
netbird version
|
||||
netbird status -d -A
|
||||
netbird debug for 1m -A -S -U
|
||||
```
|
||||
|
||||
`-A` (`--anonymize`) replaces sensitive values consistently across every file in the bundle, so
|
||||
it stays readable while masking most identifying details. It is not a guarantee of full redaction:
|
||||
internal address ranges survive at the default level, and interface names, indexes, MTUs, and
|
||||
flags are never anonymized. Read the bundle before posting it publicly. Two levels are
|
||||
available:
|
||||
|
||||
| Level | How to select | What it masks |
|
||||
| --- | --- | --- |
|
||||
| `default` | `-A` / `--anonymize`, or `--anonymize-level default` | Public IP addresses, IPv6 ULA addresses, MAC addresses, and domains other than `netbird.io`, `netbird.cloud`, `netbird.selfhosted`, and `netbird.stage`. IPv4 private, CGNAT, and link-local ranges are kept, and interface names are not anonymized |
|
||||
| `strict` | `--anonymize-level strict` (implies `-A`) | The above, plus IPv4 private, CGNAT, and link-local ranges, peer names in front of `netbird.cloud`, `netbird.selfhosted`, and `netbird.stage`, and WireGuard public keys. Labels under `netbird.io` are kept, since it only hosts infrastructure |
|
||||
|
||||
Use `strict` when internal addressing or peer naming is itself sensitive. Either way, private
|
||||
keys and SSH keys are never included, and the packet capture (`capture.pcap`) is left out of
|
||||
anonymized bundles because it holds raw decrypted packets.
|
||||
|
||||
`-U` (`--upload-bundle`) uploads the bundle and returns a file key you can paste into the thread
|
||||
instead of attaching an archive. Retention is controlled by the upload service; check its policy
|
||||
before uploading, and configure cleanup for self-hosted deployments.
|
||||
|
||||
For more detail, see [troubleshooting client issues](https://docs.netbird.io/help/troubleshooting-client),
|
||||
which explains [what a debug bundle contains](https://docs.netbird.io/help/troubleshooting-client#debug-bundle),
|
||||
and the [CLI reference](https://docs.netbird.io/get-started/cli#debug-for).
|
||||
|
||||
Intermittent problems are still worth reporting. They just need enough detail to investigate:
|
||||
trigger, frequency, timing, timestamps, and any related logs.
|
||||
|
||||
For a feature request, describe the problem before the solution: what you are trying to
|
||||
accomplish, who is affected and how often, why the current behavior or workaround is not enough,
|
||||
and what you would like to see instead.
|
||||
|
||||
## What happens after you post
|
||||
|
||||
Our team, maintainers, or community members may ask for missing details, link related
|
||||
threads, merge duplicates, move your post to a better category, or try to reproduce the problem.
|
||||
|
||||
Not every discussion becomes an issue. Some are answered in Q&A, some turn out to be
|
||||
configuration problems, and some need more information before engineering can act. A
|
||||
well-answered discussion is still a useful outcome.
|
||||
|
||||
When a report is confirmed and actionable, a maintainer opens a validated issue linked back to
|
||||
the discussion, in whichever repository the fix belongs to. You do not need to know which
|
||||
repository that is. Routing is part of triage.
|
||||
|
||||
## A note on issues
|
||||
|
||||
Issues in this repository are maintainer-curated work items. Every open issue is something a
|
||||
maintainer or contributor can pick up and act on. Issues opened without a linked validated
|
||||
discussion may be closed and redirected here.
|
||||
|
||||
Maintainers can still open issues directly for work found internally, such as regressions caught
|
||||
during development, planned maintenance, or release blockers.
|
||||
|
||||
## Related reading
|
||||
|
||||
- [How to use Discussions, Issues, and Pull Requests](https://github.com/netbirdio/netbird/discussions/6075)
|
||||
- [Moving to a discussion-first approach](https://github.com/netbirdio/netbird/discussions/6074)
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) for opening pull requests
|
||||
- [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)
|
||||
@@ -110,12 +110,12 @@ Two roles delegate Agent Network access without account-admin rights:
|
||||
read-only users, groups, peers, and account info (needed to build policies).
|
||||
Nothing else in the account.
|
||||
- **`usage_viewer`** — the regular User baseline plus read on
|
||||
`agent_network.usage` (the aggregated usage and cost overview) and read-only
|
||||
access to the resources the usage filters resolve against: users, groups,
|
||||
peers, and the provider list (connection config redacted — no upstream URLs
|
||||
or operator-supplied header values). No policies, and no account-wide
|
||||
request-level access logs; like any caller, it still reads its own requests
|
||||
through the self-scoped endpoints below.
|
||||
`agent_network.usage` (the aggregated usage and cost overview) and
|
||||
`agent_network.logs` (the account-wide request-level access logs, which can
|
||||
contain captured prompts), and read-only access to the resources those
|
||||
filters resolve against: users, groups, peers, and the provider list
|
||||
(connection config redacted — no upstream URLs or operator-supplied header
|
||||
values). No policies, guardrails, budgets, or settings.
|
||||
|
||||
Every authenticated user, regardless of role, can read the caller-scoped
|
||||
self-service endpoint `GET /api/agent-network/agent-config` (the endpoint, providers,
|
||||
|
||||
+52
-33
@@ -3,56 +3,75 @@ package base62
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
base = uint32(len(alphabet))
|
||||
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
base = uint32(len(alphabet))
|
||||
maxBase62Digits = 6 // max number of digits required to encode MaxUint32
|
||||
|
||||
)
|
||||
|
||||
var (
|
||||
ErrEmptyString = fmt.Errorf("empty string")
|
||||
ErrInvalidChar = fmt.Errorf("invalid character")
|
||||
ErrOverflow = fmt.Errorf("integer overflow")
|
||||
)
|
||||
|
||||
// Fixed-size arrays have better performance and lower memory overhead compared to maps for small static sets of data
|
||||
var charToIndex [123]int8 // Assuming ASCII from '\0' - 'z'
|
||||
|
||||
func init() {
|
||||
for i := range charToIndex {
|
||||
charToIndex[i] = -1
|
||||
}
|
||||
for i, c := range alphabet {
|
||||
charToIndex[c] = int8(i)
|
||||
}
|
||||
}
|
||||
|
||||
// Encode encodes a uint32 value to a base62 string.
|
||||
func Encode(num uint32) string {
|
||||
if num == 0 {
|
||||
return string(alphabet[0])
|
||||
// The returned string will be between 1-6 characters long.
|
||||
func Encode(n uint32) string {
|
||||
if n < base {
|
||||
return string(alphabet[n])
|
||||
}
|
||||
// avoid dynamic memory usage for small, fixed size data
|
||||
buf := [maxBase62Digits]byte{}
|
||||
idx := len(buf)
|
||||
|
||||
for n > 0 {
|
||||
idx--
|
||||
buf[idx] = alphabet[n%base]
|
||||
n /= base
|
||||
}
|
||||
|
||||
var encoded strings.Builder
|
||||
|
||||
for num > 0 {
|
||||
remainder := num % base
|
||||
encoded.WriteByte(alphabet[remainder])
|
||||
num /= base
|
||||
}
|
||||
|
||||
// Reverse the encoded string
|
||||
encodedString := encoded.String()
|
||||
reversed := reverse(encodedString)
|
||||
return reversed
|
||||
return string(buf[idx:])
|
||||
}
|
||||
|
||||
// Decode decodes a base62 string to a uint32 value.
|
||||
// Returns an error if the input string is empty, contains invalid characters,
|
||||
// or would result in integer overflow.
|
||||
func Decode(encoded string) (uint32, error) {
|
||||
if len(encoded) == 0 {
|
||||
return 0, ErrEmptyString
|
||||
}
|
||||
var decoded uint32
|
||||
strLen := len(encoded)
|
||||
|
||||
for i, char := range encoded {
|
||||
index := strings.IndexRune(alphabet, char)
|
||||
for _, char := range encoded {
|
||||
index := int8(-1)
|
||||
if int(char) < len(charToIndex) {
|
||||
index = charToIndex[char]
|
||||
}
|
||||
if index < 0 {
|
||||
return 0, fmt.Errorf("invalid character: %c", char)
|
||||
return 0, fmt.Errorf("%w: %c", ErrInvalidChar, char)
|
||||
}
|
||||
// Add overflow check when calculating the decoded value to prevent silent overflow of uint32
|
||||
if decoded > (math.MaxUint32-uint32(index))/base {
|
||||
return 0, fmt.Errorf("%w: %s", ErrOverflow, encoded)
|
||||
}
|
||||
|
||||
decoded += uint32(index) * uint32(math.Pow(float64(base), float64(strLen-i-1)))
|
||||
decoded = decoded*base + uint32(index)
|
||||
}
|
||||
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
// Reverse a string.
|
||||
func reverse(s string) string {
|
||||
runes := []rune(s)
|
||||
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
|
||||
runes[i], runes[j] = runes[j], runes[i]
|
||||
}
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
+50
-14
@@ -1,31 +1,67 @@
|
||||
package base62
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncodeDecode(t *testing.T) {
|
||||
tests := []struct {
|
||||
num uint32
|
||||
testCases := []struct {
|
||||
input uint32
|
||||
expected string
|
||||
}{
|
||||
{0},
|
||||
{1},
|
||||
{42},
|
||||
{12345},
|
||||
{99999},
|
||||
{123456789},
|
||||
{0, "0"},
|
||||
{1, "1"},
|
||||
{5, "5"},
|
||||
{9, "9"},
|
||||
{10, "A"},
|
||||
{42, "g"},
|
||||
{61, "z"},
|
||||
{62, "10"},
|
||||
{'0', "m"},
|
||||
{'9', "v"},
|
||||
{'A', "13"},
|
||||
{'Z', "1S"},
|
||||
{'a', "1Z"},
|
||||
{'z', "1y"},
|
||||
{99999, "Q0t"},
|
||||
{12345, "3D7"},
|
||||
{123456789, "8M0kX"},
|
||||
{math.MaxUint32, "4gfFC3"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
encoded := Encode(tt.num)
|
||||
for _, tc := range testCases {
|
||||
encoded := Encode(tc.input)
|
||||
if encoded != tc.expected {
|
||||
t.Errorf("Encode(%d) = %s; want %s", tc.input, encoded, tc.expected)
|
||||
}
|
||||
decoded, err := Decode(encoded)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Decode error: %v", err)
|
||||
t.Errorf("Expected error nil, got %v", err)
|
||||
}
|
||||
|
||||
if decoded != tt.num {
|
||||
t.Errorf("Decode(%v) = %v, want %v", encoded, decoded, tt.num)
|
||||
if decoded != tc.input {
|
||||
t.Errorf("Decode(%v) = %v, want %v", encoded, decoded, tc.input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decode handles empty string input with appropriate error
|
||||
func TestDecodeEmptyString(t *testing.T) {
|
||||
if _, err := Decode(""); !errors.Is(err, ErrEmptyString) {
|
||||
t.Errorf("Expected error %v, got %v", ErrEmptyString, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeOverflow(t *testing.T) {
|
||||
if _, err := Decode("4gfFC4"); !errors.Is(err, ErrOverflow) {
|
||||
t.Errorf("Expected error %v, got %v", ErrOverflow, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeInvalid(t *testing.T) {
|
||||
if _, err := Decode("/"); !errors.Is(err, ErrInvalidChar) {
|
||||
t.Errorf("Expected error %v, got %v", ErrInvalidChar, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,45 @@
|
||||
FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:2d4c4551af6831dcd26bbf86108c4385de5183ec8fe6b5868f6f9640fe47f4ed
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
ARG NETBIRD_BINARY=$TARGETPLATFORM/netbird
|
||||
ARG VERSION=dev
|
||||
ARG RELEASE=1
|
||||
|
||||
LABEL name="netbird-rootless" \
|
||||
maintainer="NetBird <dev@netbird.io>" \
|
||||
vendor="NetBird GmbH" \
|
||||
version="${VERSION}" \
|
||||
release="${RELEASE}" \
|
||||
summary="NetBird Rootless Client" \
|
||||
description="NetBird connects devices through an encrypted overlay using userspace networking without a TUN device or network administration capabilities."
|
||||
|
||||
RUN microdnf install -y bash ca-certificates && microdnf clean all
|
||||
|
||||
COPY --chmod=0555 client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh
|
||||
COPY --chmod=0555 ${NETBIRD_BINARY} /usr/local/bin/netbird
|
||||
COPY licenses/ /licenses/
|
||||
# Only application storage is group-writable for arbitrary non-root UIDs.
|
||||
# Runtime-created credentials keep the client's restrictive file modes.
|
||||
RUN mkdir -p /var/lib/netbird && \
|
||||
chown 1000:0 /var/lib/netbird && \
|
||||
chmod 0770 /var/lib/netbird && \
|
||||
chmod -R a+rX /licenses
|
||||
|
||||
WORKDIR /var/lib/netbird
|
||||
USER 1000:0
|
||||
|
||||
ENV \
|
||||
HOME="/var/lib/netbird" \
|
||||
NETBIRD_BIN="/usr/local/bin/netbird" \
|
||||
NB_USE_NETSTACK_MODE="true" \
|
||||
NB_ENABLE_NETSTACK_LOCAL_FORWARDING="true" \
|
||||
NB_CONFIG="/var/lib/netbird/config.json" \
|
||||
NB_STATE_DIR="/var/lib/netbird" \
|
||||
NB_DAEMON_ADDR="unix:///var/lib/netbird/netbird.sock" \
|
||||
NB_LOG_FILE="console,/var/lib/netbird/client.log" \
|
||||
NB_DISABLE_DNS="true" \
|
||||
NB_ENABLE_CAPTURE="false" \
|
||||
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
|
||||
|
||||
STOPSIGNAL SIGTERM
|
||||
ENTRYPOINT ["/usr/local/bin/netbird-entrypoint.sh"]
|
||||
+31
-11
@@ -104,8 +104,7 @@ type Client struct {
|
||||
|
||||
stateChangeMu sync.Mutex
|
||||
stateChangeSubID string
|
||||
eventSub *peer.EventSubscription
|
||||
// Closed to stop the watch goroutines from delivering buffered items to a
|
||||
// Closed to stop the watch goroutine from delivering buffered ticks to a
|
||||
// listener that has been removed or replaced. See stopStateChangeWatchLocked.
|
||||
stateChangeDone chan struct{}
|
||||
|
||||
@@ -213,6 +212,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
|
||||
internal.WithNetEvents(c.netMgr))
|
||||
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
||||
connectClient.SetSyncResponsePersistence(true)
|
||||
// This path runs the interactive SSO flow, so reaching here means the peer
|
||||
// is authenticated again — release the latch Status() reports from. Clear
|
||||
// only once the fresh connect client is installed: until then Status()
|
||||
@@ -256,6 +256,7 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
|
||||
internal.WithNetEvents(c.netMgr))
|
||||
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
||||
connectClient.SetSyncResponsePersistence(true)
|
||||
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
||||
}
|
||||
|
||||
@@ -327,6 +328,19 @@ func (c *Client) NotifyNetworkChange() {
|
||||
// or "strict"; strict also anonymizes internal IP ranges, peer names, and
|
||||
// WireGuard public keys, and implies anonymize.
|
||||
func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonymizeLevel string) (string, error) {
|
||||
return c.debugBundle(platformFiles, anonymize, anonymizeLevel, true)
|
||||
}
|
||||
|
||||
// DebugBundleFile generates a debug bundle and returns the path of the zip in
|
||||
// the cache directory instead of uploading it, so the app can hand the file to
|
||||
// the user for inspection. The caller owns the file and removes it once done;
|
||||
// the stale-bundle cleanup of later runs removes it only after a day.
|
||||
// anonymize and anonymizeLevel behave as in DebugBundle.
|
||||
func (c *Client) DebugBundleFile(platformFiles PlatformFiles, anonymize bool, anonymizeLevel string) (string, error) {
|
||||
return c.debugBundle(platformFiles, anonymize, anonymizeLevel, false)
|
||||
}
|
||||
|
||||
func (c *Client) debugBundle(platformFiles PlatformFiles, anonymize bool, anonymizeLevel string, upload bool) (string, error) {
|
||||
cfg, cacheDir, cc := c.stateSnapshot()
|
||||
|
||||
// If the engine hasn't been started, load config from disk
|
||||
@@ -342,6 +356,11 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonym
|
||||
cacheDir = platformFiles.CacheDir()
|
||||
}
|
||||
|
||||
// Clear what an interrupted earlier run may have left in the cache before
|
||||
// adding to it. Remote debug jobs write to the same directory, so anything
|
||||
// younger than an hour is treated as possibly still in use.
|
||||
debug.RemoveStaleBundles(cacheDir, time.Hour)
|
||||
|
||||
deps := debug.GeneratorDependencies{
|
||||
InternalConfig: cfg,
|
||||
StatusRecorder: c.recorder,
|
||||
@@ -379,6 +398,9 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonym
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate debug bundle: %w", err)
|
||||
}
|
||||
if !upload {
|
||||
return debug.ExportBundle(path)
|
||||
}
|
||||
defer func() {
|
||||
if err := os.Remove(path); err != nil {
|
||||
log.Errorf("failed to remove debug bundle file: %v", err)
|
||||
@@ -475,6 +497,7 @@ func (c *Client) Networks() *NetworkArray {
|
||||
routesMap := routeManager.GetClientRoutesWithNetID()
|
||||
v6Merged := route.V6ExitMergeSet(routesMap)
|
||||
resolvedDomains := c.recorder.GetResolvedDomainsStates()
|
||||
activeRoutePeers := c.recorder.GetActiveRoutePeers()
|
||||
|
||||
networkArray := &NetworkArray{
|
||||
items: make([]Network, 0),
|
||||
@@ -488,7 +511,7 @@ func (c *Client) Networks() *NetworkArray {
|
||||
continue
|
||||
}
|
||||
|
||||
network := c.buildNetwork(id, routes, routeSelector.IsSelected(id), resolvedDomains, v6Merged)
|
||||
network := c.buildNetwork(id, routes, routeSelector.IsSelected(id), resolvedDomains, v6Merged, activeRoutePeers)
|
||||
if network == nil {
|
||||
continue
|
||||
}
|
||||
@@ -497,14 +520,14 @@ func (c *Client) Networks() *NetworkArray {
|
||||
return networkArray
|
||||
}
|
||||
|
||||
func (c *Client) buildNetwork(id route.NetID, routes []*route.Route, selected bool, resolvedDomains map[domain.Domain]peer.ResolvedDomainInfo, v6Merged map[route.NetID]struct{}) *Network {
|
||||
func (c *Client) buildNetwork(id route.NetID, routes []*route.Route, selected bool, resolvedDomains map[domain.Domain]peer.ResolvedDomainInfo, v6Merged map[route.NetID]struct{}, activeRoutePeers map[route.HAUniqueID]string) *Network {
|
||||
r := routes[0]
|
||||
netStr := r.Network.String()
|
||||
if r.IsDynamic() {
|
||||
netStr = r.Domains.SafeString()
|
||||
}
|
||||
|
||||
routePeer, err := c.findBestRoutePeer(routes)
|
||||
routePeer, err := c.findBestRoutePeer(routes, activeRoutePeers)
|
||||
if err != nil {
|
||||
log.Errorf("could not get peer info for route %s: %v", id, err)
|
||||
return nil
|
||||
@@ -528,12 +551,9 @@ func (c *Client) buildNetwork(id route.NetID, routes []*route.Route, selected bo
|
||||
|
||||
// findBestRoutePeer returns the peer actively routing traffic for the given
|
||||
// HA route group. Falls back to the first connected peer, then the first peer.
|
||||
func (c *Client) findBestRoutePeer(routes []*route.Route) (peer.State, error) {
|
||||
netStr := routes[0].Network.String()
|
||||
|
||||
fullStatus := c.recorder.GetFullStatus()
|
||||
for _, p := range fullStatus.Peers {
|
||||
if _, ok := p.GetRoutes()[netStr]; ok {
|
||||
func (c *Client) findBestRoutePeer(routes []*route.Route, activeRoutePeers map[route.HAUniqueID]string) (peer.State, error) {
|
||||
if peerKey, ok := activeRoutePeers[routes[0].GetHAUniqueID()]; ok {
|
||||
if p, err := c.recorder.GetPeer(peerKey); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
|
||||
+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) {
|
||||
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 {
|
||||
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.
|
||||
|
||||
@@ -46,7 +46,7 @@ func (p *Preferences) GetManagementURL() (string, error) {
|
||||
return p.configInput.ManagementURL, nil
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
||||
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -58,23 +58,17 @@ func (p *Preferences) SetManagementURL(url string) {
|
||||
p.configInput.ManagementURL = url
|
||||
}
|
||||
|
||||
// GetAdminURL reads URL from config file
|
||||
// GetAdminURL returns the default admin panel URL.
|
||||
//
|
||||
// Deprecated: the admin URL is no longer stored or used by the client.
|
||||
func (p *Preferences) GetAdminURL() (string, error) {
|
||||
if p.configInput.AdminURL != "" {
|
||||
return p.configInput.AdminURL, nil
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cfg.AdminURL.String(), err
|
||||
return profilemanager.DefaultAdminURL, nil
|
||||
}
|
||||
|
||||
// SetAdminURL stores the given URL and waits for commit
|
||||
func (p *Preferences) SetAdminURL(url string) {
|
||||
p.configInput.AdminURL = url
|
||||
}
|
||||
// SetAdminURL is a no-op kept for API compatibility.
|
||||
//
|
||||
// Deprecated: the admin URL is no longer stored or used by the client.
|
||||
func (p *Preferences) SetAdminURL(_ string) {}
|
||||
|
||||
// HasPreSharedKey reports whether a pre-shared key is staged, persisted, or
|
||||
// enforced by MDM; the key itself is never handed to the native layer.
|
||||
@@ -86,7 +80,7 @@ func (p *Preferences) HasPreSharedKey() (bool, error) {
|
||||
return *p.configInput.PreSharedKey != "", nil
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
||||
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -112,7 +106,7 @@ func (p *Preferences) GetRosenpassEnabled() (bool, error) {
|
||||
return *p.configInput.RosenpassEnabled, nil
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
||||
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -133,7 +127,7 @@ func (p *Preferences) GetRosenpassPermissive() (bool, error) {
|
||||
return *p.configInput.RosenpassPermissive, nil
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
||||
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -149,7 +143,7 @@ func (p *Preferences) GetDisableClientRoutes() (bool, error) {
|
||||
return *p.configInput.DisableClientRoutes, nil
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
||||
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -170,7 +164,7 @@ func (p *Preferences) GetDisableServerRoutes() (bool, error) {
|
||||
return *p.configInput.DisableServerRoutes, nil
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
||||
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -188,7 +182,7 @@ func (p *Preferences) GetDisableDNS() (bool, error) {
|
||||
return *p.configInput.DisableDNS, nil
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
||||
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -206,7 +200,7 @@ func (p *Preferences) GetDisableFirewall() (bool, error) {
|
||||
return *p.configInput.DisableFirewall, nil
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
||||
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -227,7 +221,7 @@ func (p *Preferences) GetServerSSHAllowed() (bool, error) {
|
||||
return *p.configInput.ServerSSHAllowed, nil
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
||||
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -249,7 +243,7 @@ func (p *Preferences) GetEnableSSHRoot() (bool, error) {
|
||||
return *p.configInput.EnableSSHRoot, nil
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
||||
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -271,7 +265,7 @@ func (p *Preferences) GetEnableSSHSFTP() (bool, error) {
|
||||
return *p.configInput.EnableSSHSFTP, nil
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
||||
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -293,7 +287,7 @@ func (p *Preferences) GetEnableSSHLocalPortForwarding() (bool, error) {
|
||||
return *p.configInput.EnableSSHLocalPortForwarding, nil
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
||||
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -315,7 +309,7 @@ func (p *Preferences) GetEnableSSHRemotePortForwarding() (bool, error) {
|
||||
return *p.configInput.EnableSSHRemotePortForwarding, nil
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
||||
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -340,7 +334,7 @@ func (p *Preferences) GetBlockInbound() (bool, error) {
|
||||
return *p.configInput.BlockInbound, nil
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
||||
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -358,7 +352,7 @@ func (p *Preferences) GetDisableIPv6() (bool, error) {
|
||||
return *p.configInput.DisableIPv6, nil
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
||||
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -377,7 +371,7 @@ func (p *Preferences) GetRemoteJobsAllowed() (bool, error) {
|
||||
return *p.configInput.RemoteJobsAllowed, nil
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
||||
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -43,13 +43,14 @@ func TestPreferences_ReadUncommitedValues(t *testing.T) {
|
||||
cfgFile := filepath.Join(t.TempDir(), "netbird.json")
|
||||
p := NewPreferences(cfgFile)
|
||||
|
||||
// The admin URL is deprecated: setting it is a no-op.
|
||||
p.SetAdminURL(exampleString)
|
||||
resp, err := p.GetAdminURL()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read admin url: %s", err)
|
||||
}
|
||||
|
||||
if resp != exampleString {
|
||||
if resp != profilemanager.DefaultAdminURL {
|
||||
t.Errorf("unexpected admin url: %s", resp)
|
||||
}
|
||||
|
||||
@@ -95,7 +96,7 @@ func TestPreferences_Commit(t *testing.T) {
|
||||
t.Fatalf("failed to read admin url: %s", err)
|
||||
}
|
||||
|
||||
if resp != exampleURL {
|
||||
if resp != profilemanager.DefaultAdminURL {
|
||||
t.Errorf("unexpected admin url: %s", resp)
|
||||
}
|
||||
|
||||
|
||||
+16
-85
@@ -6,13 +6,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"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.
|
||||
@@ -21,16 +16,11 @@ import (
|
||||
// changed: connection state, the run-loop status label (e.g. NeedsLogin) or
|
||||
// the session deadline. It mirrors the daemon's SubscribeStatus stream
|
||||
// trigger — on each signal the consumer pulls the fresh values via
|
||||
// Status() / SessionExpiresAtUnix().
|
||||
//
|
||||
// OnSessionExpiring forwards the engine's session-expiry warnings, fired at
|
||||
// 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.
|
||||
// Status() / SessionExpiresAtUnix(). The engine arms no expiry-warning
|
||||
// timers on Android; the app schedules the warnings from the deadline it
|
||||
// reads here.
|
||||
type StateChangeListener interface {
|
||||
OnStateChanged()
|
||||
OnSessionExpiring(expiresAtUnix int64, leadMinutes int64, finalWarning bool)
|
||||
}
|
||||
|
||||
// Status returns the connect run-loop's status label — the same value the
|
||||
@@ -110,11 +100,11 @@ func (c *Client) SetStateChangeListener(listener StateChangeListener) {
|
||||
return
|
||||
}
|
||||
|
||||
// Both subscriptions are buffered (one pending tick, ten pending events),
|
||||
// so unsubscribing is not enough to stop callbacks: the loops would drain
|
||||
// what is already queued and deliver it to a listener the caller has
|
||||
// already removed or replaced. Gate every callback on this registration's
|
||||
// own signal, which is closed before unsubscribing.
|
||||
// The subscription is buffered (one pending tick), so unsubscribing is
|
||||
// not enough to stop callbacks: the loop would drain what is already
|
||||
// queued and deliver it to a listener the caller has already removed or
|
||||
// replaced. Gate every callback on this registration's own signal, which
|
||||
// is closed before unsubscribing.
|
||||
done := make(chan struct{})
|
||||
c.stateChangeDone = done
|
||||
|
||||
@@ -133,9 +123,6 @@ func (c *Client) SetStateChangeListener(listener StateChangeListener) {
|
||||
listener.OnStateChanged()
|
||||
}
|
||||
}()
|
||||
|
||||
c.eventSub = c.recorder.SubscribeToEvents()
|
||||
go watchSessionWarnings(c.eventSub, listener, done)
|
||||
}
|
||||
|
||||
// RemoveStateChangeListener unregisters the state notification listener.
|
||||
@@ -145,21 +132,6 @@ func (c *Client) RemoveStateChangeListener() {
|
||||
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
|
||||
// asks the management server to extend the session deadline. The tunnel is
|
||||
// untouched: no resync, no reconnect. Async; the result arrives on the
|
||||
@@ -201,8 +173,8 @@ func (c *Client) CancelExtendAuthSession() {
|
||||
}
|
||||
|
||||
func (c *Client) stopStateChangeWatchLocked() {
|
||||
// Signal first, unsubscribe second: closing the channels only stops new
|
||||
// items, and the loops would still hand whatever is buffered to a listener
|
||||
// Signal first, unsubscribe second: closing the channel only stops new
|
||||
// items, and the loop would still hand whatever is buffered to a listener
|
||||
// that is no longer registered.
|
||||
if c.stateChangeDone != nil {
|
||||
close(c.stateChangeDone)
|
||||
@@ -212,49 +184,6 @@ func (c *Client) stopStateChangeWatchLocked() {
|
||||
c.recorder.UnsubscribeFromStateChanges(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) {
|
||||
@@ -293,11 +222,13 @@ func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isA
|
||||
}
|
||||
defer authClient.Close()
|
||||
|
||||
// Passing the config path makes the flow pick up the login_hint: an extend
|
||||
// renews the session of the account already signed in, so it must not stop to
|
||||
// offer a choice.
|
||||
// Passing the config path makes the flow pick up the login_hint. That alone
|
||||
// cannot keep the IdP on this profile's account though — a hint is only a
|
||||
// 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)
|
||||
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
|
||||
tokenInfo, err := a.foregroundGetTokenInfoFlow(authClient, urlOpener, isAndroidTV, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("interactive sso login failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ const (
|
||||
// PasswordRequiredMarker tells Java to prompt for a password and retry. It is
|
||||
// a string because gomobile flattens errors to their message, so a sentinel
|
||||
// value would not survive the binding.
|
||||
//
|
||||
//nolint:gosec // G101 false positive: a sentinel marker, not a credential
|
||||
const PasswordRequiredMarker = "netbird-ssh-password-required"
|
||||
|
||||
// HostKeyUnknownMarker tells Java to show the fingerprint and, on confirmation,
|
||||
@@ -467,7 +469,7 @@ func (s *SSHClient) requestJWTToken(cfg *profilemanager.Config, cfgPath string)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
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 {
|
||||
return "", fmt.Errorf("create oauth flow: %w", err)
|
||||
}
|
||||
|
||||
+58
-26
@@ -23,7 +23,10 @@ import (
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
const errCloseConnection = "Failed to close connection: %v"
|
||||
const (
|
||||
errCloseConnection = "Failed to close connection: %v"
|
||||
noUpDownFlag = "no-updown"
|
||||
)
|
||||
|
||||
var (
|
||||
logFileCount uint32
|
||||
@@ -257,13 +260,14 @@ func runForDuration(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
stateWasDown := stat.Status != string(internal.StatusConnected) && stat.Status != string(internal.StatusConnecting)
|
||||
noUpDown, _ := cmd.Flags().GetBool(noUpDownFlag)
|
||||
|
||||
initialLogLevel, err := client.GetLogLevel(cmd.Context(), &proto.GetLogLevelRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get log level: %v", status.Convert(err).Message())
|
||||
}
|
||||
|
||||
if stateWasDown {
|
||||
if stateWasDown && !noUpDown {
|
||||
if _, err := client.Up(cmd.Context(), &proto.UpRequest{}); err != nil {
|
||||
cmd.PrintErrf("Failed to bring service up: %v\n", status.Convert(err).Message())
|
||||
} else {
|
||||
@@ -284,34 +288,20 @@ func runForDuration(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
needsRestoreUp := false
|
||||
if _, err := client.Down(cmd.Context(), &proto.DownRequest{}); err != nil {
|
||||
cmd.PrintErrf("Failed to bring service down: %v\n", status.Convert(err).Message())
|
||||
if noUpDown {
|
||||
enableSyncResponsePersistence(cmd, client)
|
||||
} else {
|
||||
needsRestoreUp = !stateWasDown
|
||||
cmd.Println("netbird down")
|
||||
needsRestoreUp = restartDaemon(cmd, client, stateWasDown)
|
||||
}
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// Enable sync response persistence before bringing the service up
|
||||
if _, err := client.SetSyncResponsePersistence(cmd.Context(), &proto.SetSyncResponsePersistenceRequest{
|
||||
Enabled: true,
|
||||
}); err != nil {
|
||||
cmd.PrintErrf("Failed to enable sync response persistence: %v\n", status.Convert(err).Message())
|
||||
}
|
||||
|
||||
if _, err := client.Up(cmd.Context(), &proto.UpRequest{}); err != nil {
|
||||
cmd.PrintErrf("Failed to bring service up: %v\n", status.Convert(err).Message())
|
||||
} else {
|
||||
needsRestoreUp = false
|
||||
cmd.Println("netbird up")
|
||||
}
|
||||
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
cpuProfilingStarted := false
|
||||
if _, err := client.StartCPUProfile(cmd.Context(), &proto.StartCPUProfileRequest{}); err != nil {
|
||||
cmd.PrintErrf("Failed to start CPU profiling: %v\n", err)
|
||||
if msg := status.Convert(err).Message(); strings.Contains(msg, "already in progress") {
|
||||
cmd.PrintErrln("CPU profiling is already running (started with `netbird debug cpu start`). " +
|
||||
"It is left running and is included in a bundle created after `netbird debug cpu stop`.")
|
||||
} else {
|
||||
cmd.PrintErrf("Failed to start CPU profiling: %v\n", msg)
|
||||
}
|
||||
} else {
|
||||
cpuProfilingStarted = true
|
||||
defer func() {
|
||||
@@ -401,7 +391,7 @@ func runForDuration(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
if stateWasDown {
|
||||
if stateWasDown && !noUpDown {
|
||||
if _, err := client.Down(cmd.Context(), &proto.DownRequest{}); err != nil {
|
||||
cmd.PrintErrf("Failed to restore service down state: %v\n", status.Convert(err).Message())
|
||||
} else {
|
||||
@@ -458,6 +448,47 @@ func setSyncResponsePersistence(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// enableSyncResponsePersistence asks the daemon to keep the latest sync
|
||||
// response so the bundle carries the network map. With a running daemon only
|
||||
// syncs received after the call are kept.
|
||||
func enableSyncResponsePersistence(cmd *cobra.Command, client proto.DaemonServiceClient) {
|
||||
if _, err := client.SetSyncResponsePersistence(cmd.Context(), &proto.SetSyncResponsePersistenceRequest{
|
||||
Enabled: true,
|
||||
}); err != nil {
|
||||
cmd.PrintErrf("Failed to enable sync response persistence: %v\n", status.Convert(err).Message())
|
||||
}
|
||||
}
|
||||
|
||||
// restartDaemon cycles the daemon down and up with sync response persistence
|
||||
// enabled so the bundle carries the network map. It reports whether the
|
||||
// daemon was left down although it was running before, so the caller can
|
||||
// bring it back up.
|
||||
func restartDaemon(cmd *cobra.Command, client proto.DaemonServiceClient, stateWasDown bool) bool {
|
||||
needsRestoreUp := false
|
||||
if _, err := client.Down(cmd.Context(), &proto.DownRequest{}); err != nil {
|
||||
cmd.PrintErrf("Failed to bring service down: %v\n", status.Convert(err).Message())
|
||||
} else {
|
||||
needsRestoreUp = !stateWasDown
|
||||
cmd.Println("netbird down")
|
||||
}
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// Enable sync response persistence before bringing the service up
|
||||
enableSyncResponsePersistence(cmd, client)
|
||||
|
||||
if _, err := client.Up(cmd.Context(), &proto.UpRequest{}); err != nil {
|
||||
cmd.PrintErrf("Failed to bring service up: %v\n", status.Convert(err).Message())
|
||||
} else {
|
||||
needsRestoreUp = false
|
||||
cmd.Println("netbird up")
|
||||
}
|
||||
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
return needsRestoreUp
|
||||
}
|
||||
|
||||
func waitForDurationOrCancel(ctx context.Context, duration time.Duration, cmd *cobra.Command) error {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
@@ -546,4 +577,5 @@ func init() {
|
||||
forCmd.Flags().StringVar(&uploadBundleURLFlag, "upload-bundle-url", types.DefaultBundleURL, "Service URL to get an URL to upload the debug bundle")
|
||||
forCmd.Flags().BoolVar(&uploadBundleInsecureFlag, "upload-bundle-insecure", false, "Allow uploading to an http or untrusted-TLS upload server (self-hosted); requires root")
|
||||
forCmd.Flags().Bool("capture", false, "Capture packets during the debug duration and include in bundle")
|
||||
forCmd.Flags().Bool(noUpDownFlag, false, "Keep the daemon running instead of bringing it down and up before collecting. The bundle only includes the network map if a sync arrives during the run")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
var debugCPUCmd = &cobra.Command{
|
||||
Use: "cpu",
|
||||
Short: "Profile the daemon's CPU usage",
|
||||
Long: `Starts and stops CPU profiling in the running daemon without restarting it.
|
||||
The profile is included in the next debug bundle as cpu.prof.
|
||||
|
||||
Profiling is not time limited: it keeps running, and keeps costing CPU, until
|
||||
"netbird debug cpu stop" is run.`,
|
||||
}
|
||||
|
||||
var debugCPUStartCmd = &cobra.Command{
|
||||
Use: "start",
|
||||
Short: "Start CPU profiling in the daemon",
|
||||
Example: " netbird debug cpu start",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: debugCPUStart,
|
||||
}
|
||||
|
||||
var debugCPUStopCmd = &cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "Stop CPU profiling in the daemon",
|
||||
Long: `Stops CPU profiling. The captured profile stays in the daemon until the next
|
||||
debug bundle is created, which includes it as cpu.prof.`,
|
||||
Example: " netbird debug cpu stop && netbird debug bundle",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: debugCPUStop,
|
||||
}
|
||||
|
||||
func debugCPUStart(cmd *cobra.Command, _ []string) error {
|
||||
conn, err := getClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := conn.Close(); err != nil {
|
||||
log.Errorf(errCloseConnection, err)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := proto.NewDaemonServiceClient(conn).StartCPUProfile(cmd.Context(), &proto.StartCPUProfileRequest{}); err != nil {
|
||||
return fmt.Errorf("start CPU profiling: %v", status.Convert(err).Message())
|
||||
}
|
||||
|
||||
cmd.Println("CPU profiling started and runs until stopped. Run `netbird debug cpu stop` and then `netbird debug bundle` to collect it.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func debugCPUStop(cmd *cobra.Command, _ []string) error {
|
||||
conn, err := getClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := conn.Close(); err != nil {
|
||||
log.Errorf(errCloseConnection, err)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := proto.NewDaemonServiceClient(conn).StopCPUProfile(cmd.Context(), &proto.StopCPUProfileRequest{}); err != nil {
|
||||
return fmt.Errorf("stop CPU profiling: %v", status.Convert(err).Message())
|
||||
}
|
||||
|
||||
cmd.Println("CPU profiling stopped. Run `netbird debug bundle` to include cpu.prof.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
debugCPUCmd.AddCommand(debugCPUStartCmd)
|
||||
debugCPUCmd.AddCommand(debugCPUStopCmd)
|
||||
debugCmd.AddCommand(debugCPUCmd)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os/user"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
|
||||
// startDebugTestDaemon starts an in-process daemon with an isolated profile
|
||||
// directory and returns the address the CLI should dial.
|
||||
func startDebugTestDaemon(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
origDefaultProfileDir := profilemanager.DefaultConfigPathDir
|
||||
origActiveProfileStatePath := profilemanager.ActiveProfileStatePath
|
||||
origConfigDirOverride := profilemanager.ConfigDirOverride
|
||||
origDaemonAddr := daemonAddr
|
||||
t.Cleanup(func() {
|
||||
profilemanager.DefaultConfigPathDir = origDefaultProfileDir
|
||||
profilemanager.ActiveProfileStatePath = origActiveProfileStatePath
|
||||
profilemanager.ConfigDirOverride = origConfigDirOverride
|
||||
daemonAddr = origDaemonAddr
|
||||
})
|
||||
|
||||
profilemanager.DefaultConfigPathDir = tempDir
|
||||
profilemanager.ActiveProfileStatePath = tempDir + "/active_profile.json"
|
||||
profilemanager.ConfigDirOverride = tempDir
|
||||
|
||||
currUser, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
sm := profilemanager.ServiceManager{}
|
||||
created, err := sm.AddProfile("test1", currUser.Username)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, sm.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
||||
ID: created.ID,
|
||||
Username: currUser.Username,
|
||||
}))
|
||||
|
||||
ctx, cancel := context.WithCancel(internal.CtxInitState(context.Background()))
|
||||
srv, lis := startClientDaemon(t, ctx, "", tempDir+"/config.json")
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
srv.Stop()
|
||||
})
|
||||
|
||||
return "tcp://" + lis.Addr().String()
|
||||
}
|
||||
|
||||
// runDebugCmd runs `netbird debug <args>` against the daemon at addr and
|
||||
// returns everything the command printed.
|
||||
func runDebugCmd(addr string, args ...string) (string, error) {
|
||||
daemonAddr = addr
|
||||
var out bytes.Buffer
|
||||
rootCmd.SetOut(&out)
|
||||
rootCmd.SetErr(&out)
|
||||
rootCmd.SetArgs(append(append([]string{"debug"}, args...), "--daemon-addr", addr, "--log-file", ""))
|
||||
err := rootCmd.Execute()
|
||||
rootCmd.SetOut(nil)
|
||||
rootCmd.SetErr(nil)
|
||||
rootCmd.SetArgs(nil)
|
||||
resetFlags(rootCmd)
|
||||
return out.String(), err
|
||||
}
|
||||
|
||||
// resetFlags puts every flag of the command and its subcommands back to its
|
||||
// default so a value parsed in one run does not leak into the next in-process
|
||||
// execution.
|
||||
func resetFlags(cmd *cobra.Command) {
|
||||
reset := func(f *pflag.Flag) {
|
||||
// Set appends to a slice flag and would parse the "[a,b]" default
|
||||
// text as elements, so slices are replaced instead.
|
||||
if sv, ok := f.Value.(pflag.SliceValue); ok {
|
||||
var def []string
|
||||
if trimmed := strings.Trim(f.DefValue, "[]"); trimmed != "" {
|
||||
def = strings.Split(trimmed, ",")
|
||||
}
|
||||
_ = sv.Replace(def)
|
||||
} else {
|
||||
_ = f.Value.Set(f.DefValue)
|
||||
}
|
||||
f.Changed = false
|
||||
}
|
||||
cmd.Flags().VisitAll(reset)
|
||||
cmd.PersistentFlags().VisitAll(reset)
|
||||
// Commands pin their writers to the buffer of the run that first used
|
||||
// them, so a later run would print into the old buffer.
|
||||
cmd.SetOut(nil)
|
||||
cmd.SetErr(nil)
|
||||
for _, sub := range cmd.Commands() {
|
||||
resetFlags(sub)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResetFlagsSliceDefault guards against Set("[]") on slice flags, which
|
||||
// stores a literal "[]" element instead of the empty default.
|
||||
func TestResetFlagsSliceDefault(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "x"}
|
||||
var env, withDefault []string
|
||||
cmd.Flags().StringSliceVar(&env, "env", nil, "")
|
||||
cmd.Flags().StringSliceVar(&withDefault, "names", []string{"a", "b"}, "")
|
||||
require.NoError(t, cmd.Flags().Parse([]string{"--env", "K=V", "--names", "c"}))
|
||||
|
||||
resetFlags(cmd)
|
||||
|
||||
assert.Empty(t, env, "slice flag with no default must reset to empty")
|
||||
assert.Equal(t, []string{"a", "b"}, withDefault, "slice flag must reset to its default")
|
||||
}
|
||||
|
||||
func TestDebugCPUStartStop(t *testing.T) {
|
||||
addr := startDebugTestDaemon(t)
|
||||
|
||||
run := func(args ...string) error {
|
||||
_, err := runDebugCmd(addr, append([]string{"cpu"}, args...)...)
|
||||
return err
|
||||
}
|
||||
|
||||
require.Error(t, run("stop"), "stop without a running profile must fail")
|
||||
require.NoError(t, run("start"))
|
||||
assert.Error(t, run("start"), "second start must be rejected while profiling")
|
||||
require.NoError(t, run("stop"))
|
||||
assert.Error(t, run("stop"), "second stop must be rejected")
|
||||
assert.NoError(t, run("start"), "profiling can be started again after a stop")
|
||||
assert.NoError(t, run("stop"))
|
||||
}
|
||||
|
||||
// TestDebugForKeepsRunningCPUProfile covers `debug for` started while a
|
||||
// profile from `debug cpu start` is running: it must say so, leave the
|
||||
// profile alone, and still create the bundle.
|
||||
func TestDebugForKeepsRunningCPUProfile(t *testing.T) {
|
||||
addr := startDebugTestDaemon(t)
|
||||
|
||||
_, err := runDebugCmd(addr, "cpu", "start")
|
||||
require.NoError(t, err)
|
||||
|
||||
out, err := runDebugCmd(addr, "for", "1s", "-S=false", "--no-updown")
|
||||
require.NoError(t, err, "output: %s", out)
|
||||
assert.Contains(t, out, "CPU profiling is already running", "the conflict must be explained")
|
||||
assert.NotContains(t, out, "rpc error", "the raw RPC error must not reach the user")
|
||||
assert.Contains(t, out, "Local file:", "the bundle must still be created")
|
||||
|
||||
_, err = runDebugCmd(addr, "cpu", "stop")
|
||||
assert.NoError(t, err, "the profile started by the user must still be running")
|
||||
}
|
||||
|
||||
func TestDebugForNoUpDown(t *testing.T) {
|
||||
addr := startDebugTestDaemon(t)
|
||||
|
||||
out, err := runDebugCmd(addr, "for", "1s", "-S=false", "--no-updown")
|
||||
require.NoError(t, err, "output: %s", out)
|
||||
assert.NotContains(t, out, "netbird down", "--no-updown must not bring the daemon down")
|
||||
assert.NotContains(t, out, "netbird up", "--no-updown must not bring the daemon up")
|
||||
assert.Contains(t, out, "Local file:", "the bundle must still be created")
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+61
-20
@@ -132,48 +132,89 @@ func getKubernetesClusters(ctx context.Context, peers []*proto.PeerState, nameFi
|
||||
// https://github.com/golang/go/issues/17093
|
||||
PreferGo: true,
|
||||
}
|
||||
return findKubernetesClusters(ctx, httpClient, resolver.LookupAddr, peers, nameFilter)
|
||||
}
|
||||
|
||||
func findKubernetesClusters(
|
||||
ctx context.Context,
|
||||
httpClient *http.Client,
|
||||
lookupAddr func(context.Context, string) ([]string, error),
|
||||
peers []*proto.PeerState,
|
||||
nameFilter string,
|
||||
) ([]kubernetesCluster, error) {
|
||||
kcs := []kubernetesCluster{}
|
||||
attempted := map[string]struct{}{}
|
||||
var lookupErr error
|
||||
resolvedAny := false
|
||||
for _, peer := range peers {
|
||||
fqdns, err := resolver.LookupAddr(ctx, peer.IP)
|
||||
if err != nil {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Peers without a DNS record have no PTR, which must not hide the remaining peers.
|
||||
fqdns, err := lookupAddr(ctx, peer.IP)
|
||||
if err != nil {
|
||||
log.Debugf("reverse lookup of peer %s: %v", peer.IP, err)
|
||||
// Before any success, a timeout or SERVFAIL means the resolver is down; waiting
|
||||
// on each remaining peer would only repeat the timeout.
|
||||
if !resolvedAny && !isNotFoundErr(err) {
|
||||
return nil, fmt.Errorf("reverse lookup of peers: %w", err)
|
||||
}
|
||||
lookupErr = err
|
||||
continue
|
||||
}
|
||||
resolvedAny = true
|
||||
for _, fqdn := range fqdns {
|
||||
if _, ok := attempted[fqdn]; ok {
|
||||
continue
|
||||
}
|
||||
attempted[fqdn] = struct{}{}
|
||||
comps := strings.Split(fqdn, ".")
|
||||
if len(comps) < 2 {
|
||||
kc, ok := probeCluster(ctx, httpClient, fqdn, nameFilter)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if comps[1] != KubernetesDNSSuffix {
|
||||
continue
|
||||
}
|
||||
if nameFilter != "" && nameFilter != comps[0] {
|
||||
continue
|
||||
}
|
||||
clusterURL, clusterVersion, err := fingerprintClusters(ctx, httpClient, fqdn)
|
||||
if err != nil {
|
||||
log.Debugf("could not fingerprint Kubernetes cluster %s %q", fqdn, err)
|
||||
continue
|
||||
}
|
||||
kc := kubernetesCluster{
|
||||
name: comps[0],
|
||||
url: clusterURL,
|
||||
version: clusterVersion,
|
||||
}
|
||||
if nameFilter != "" {
|
||||
return []kubernetesCluster{kc}, nil
|
||||
}
|
||||
kcs = append(kcs, kc)
|
||||
}
|
||||
}
|
||||
// A skipped failure on the last peer would otherwise hide a cancellation.
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Every lookup failing points at the resolver rather than at individual peers.
|
||||
if !resolvedAny && lookupErr != nil {
|
||||
return nil, fmt.Errorf("reverse lookup of peers: %w", lookupErr)
|
||||
}
|
||||
return kcs, nil
|
||||
}
|
||||
|
||||
// probeCluster fingerprints fqdn when it names a netbird-kubeapi-proxy peer matching nameFilter.
|
||||
func probeCluster(ctx context.Context, httpClient *http.Client, fqdn, nameFilter string) (kubernetesCluster, bool) {
|
||||
comps := strings.Split(fqdn, ".")
|
||||
if len(comps) < 2 || comps[1] != KubernetesDNSSuffix {
|
||||
return kubernetesCluster{}, false
|
||||
}
|
||||
if nameFilter != "" && nameFilter != comps[0] {
|
||||
return kubernetesCluster{}, false
|
||||
}
|
||||
clusterURL, clusterVersion, err := fingerprintClusters(ctx, httpClient, fqdn)
|
||||
if err != nil {
|
||||
log.Debugf("could not fingerprint Kubernetes cluster %s %q", fqdn, err)
|
||||
return kubernetesCluster{}, false
|
||||
}
|
||||
return kubernetesCluster{
|
||||
name: comps[0],
|
||||
url: clusterURL,
|
||||
version: clusterVersion,
|
||||
}, true
|
||||
}
|
||||
|
||||
func isNotFoundErr(err error) bool {
|
||||
var dnsErr *net.DNSError
|
||||
return errors.As(err, &dnsErr) && dnsErr.IsNotFound
|
||||
}
|
||||
|
||||
func fingerprintClusters(ctx context.Context, httpClient *http.Client, fqdn string) (*url.URL, string, error) {
|
||||
clusterURL, err := url.Parse("https://" + fqdn)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -9,7 +13,10 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
func TestFingerprintClusters(t *testing.T) {
|
||||
@@ -27,6 +34,107 @@ func TestFingerprintClusters(t *testing.T) {
|
||||
require.Equal(t, "foobar", clusterVersion)
|
||||
}
|
||||
|
||||
func TestFindKubernetesClustersSkipsFailedLookup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"gitVersion": "v1.30.0"}`)) //nolint:errcheck
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Route every cluster FQDN to the test server, regardless of the resolved name.
|
||||
httpClient := &http.Client{Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
|
||||
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, network, srv.Listener.Addr().String())
|
||||
},
|
||||
}}
|
||||
|
||||
// The first peer has no DNS record in NetBird, so its reverse lookup fails.
|
||||
lookupAddr := func(_ context.Context, addr string) ([]string, error) {
|
||||
switch addr {
|
||||
case "100.64.0.1":
|
||||
return nil, &net.DNSError{Err: "no such host", Name: "1.0.64.100.in-addr.arpa.", IsNotFound: true}
|
||||
case "100.64.0.2":
|
||||
return []string{"homelab." + KubernetesDNSSuffix + ".netbird.cloud."}, nil
|
||||
}
|
||||
return nil, errors.New("unexpected address " + addr)
|
||||
}
|
||||
peers := []*proto.PeerState{{IP: "100.64.0.1"}, {IP: "100.64.0.2"}}
|
||||
|
||||
for _, filter := range []string{"", "homelab"} {
|
||||
kcs, err := findKubernetesClusters(t.Context(), httpClient, lookupAddr, peers, filter)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, kcs, 1, "cluster behind the failing peer must be found (filter %q)", filter)
|
||||
require.Equal(t, "homelab", kcs[0].name)
|
||||
require.Equal(t, "v1.30.0", kcs[0].version)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
_, err := findKubernetesClusters(ctx, httpClient, lookupAddr, peers, "homelab")
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
|
||||
// Cancellation during the last peer's lookup must surface instead of an empty result.
|
||||
ctx, cancel = context.WithCancel(t.Context())
|
||||
cancelOnLookup := func(ctx context.Context, _ string) ([]string, error) {
|
||||
cancel()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
_, err = findKubernetesClusters(ctx, httpClient, cancelOnLookup, peers[:1], "")
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
}
|
||||
|
||||
func TestFindKubernetesClustersReportsDeadResolver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
peers := []*proto.PeerState{{IP: "100.64.0.1"}, {IP: "100.64.0.2"}, {IP: "100.64.0.3"}}
|
||||
timeoutErr := &net.DNSError{Err: "i/o timeout", Name: "1.0.64.100.in-addr.arpa.", IsTimeout: true}
|
||||
notFoundErr := &net.DNSError{Err: "no such host", Name: "1.0.64.100.in-addr.arpa.", IsNotFound: true}
|
||||
|
||||
// A timeout before any peer resolved means the resolver is down: stop at the first
|
||||
// peer instead of waiting out the timeout once per peer.
|
||||
for _, filter := range []string{"", "homelab"} {
|
||||
calls := 0
|
||||
timeoutAll := func(context.Context, string) ([]string, error) {
|
||||
calls++
|
||||
return nil, timeoutErr
|
||||
}
|
||||
kcs, err := findKubernetesClusters(t.Context(), http.DefaultClient, timeoutAll, peers, filter)
|
||||
require.ErrorIs(t, err, timeoutErr, "filter %q", filter)
|
||||
assert.Empty(t, kcs)
|
||||
assert.Equal(t, 1, calls, "scan must stop after the first timeout (filter %q)", filter)
|
||||
}
|
||||
|
||||
// No PTR anywhere is not a resolver outage, so every peer is tried before the
|
||||
// last lookup error is reported instead of an empty cluster list.
|
||||
calls := 0
|
||||
notFoundAll := func(context.Context, string) ([]string, error) {
|
||||
calls++
|
||||
return nil, notFoundErr
|
||||
}
|
||||
kcs, err := findKubernetesClusters(t.Context(), http.DefaultClient, notFoundAll, peers, "")
|
||||
require.ErrorIs(t, err, notFoundErr)
|
||||
assert.Empty(t, kcs)
|
||||
assert.Equal(t, len(peers), calls, "every peer must be tried when lookups only miss")
|
||||
|
||||
// Once one peer resolved the resolver is known to work, so a later timeout is
|
||||
// skipped like any other per-peer failure.
|
||||
resolveThenTimeout := func(_ context.Context, addr string) ([]string, error) {
|
||||
if addr == "100.64.0.1" {
|
||||
return []string{"laptop.netbird.cloud."}, nil
|
||||
}
|
||||
return nil, timeoutErr
|
||||
}
|
||||
kcs, err = findKubernetesClusters(t.Context(), http.DefaultClient, resolveThenTimeout, peers, "")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, kcs)
|
||||
|
||||
kcs, err = findKubernetesClusters(t.Context(), http.DefaultClient, notFoundAll, nil, "")
|
||||
require.NoError(t, err, "an empty peer list is not a resolver failure")
|
||||
assert.Empty(t, kcs)
|
||||
}
|
||||
|
||||
func TestResolveKubeconfigPath(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
|
||||
+54
-8
@@ -9,8 +9,6 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
"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/auth"
|
||||
@@ -145,10 +143,7 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str
|
||||
err = WithBackOff(func() error {
|
||||
var backOffErr error
|
||||
loginResp, backOffErr = client.Login(ctx, &loginRequest)
|
||||
if s, ok := gstatus.FromError(backOffErr); ok && (s.Code() == codes.InvalidArgument ||
|
||||
s.Code() == codes.PermissionDenied ||
|
||||
s.Code() == codes.NotFound ||
|
||||
s.Code() == codes.Unimplemented) {
|
||||
if terminalLoginError(backOffErr) {
|
||||
loginErr = backOffErr
|
||||
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 {
|
||||
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,
|
||||
// so layer in the OS-native policy here. Desktop builds construct
|
||||
// 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
|
||||
}
|
||||
|
||||
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, util.HasGraphicalSession(), false, hint)
|
||||
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, util.HasGraphicalSession(), false, hint, false)
|
||||
if err != nil {
|
||||
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())
|
||||
if err != nil {
|
||||
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())
|
||||
},
|
||||
}
|
||||
+44
-4
@@ -20,6 +20,8 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/anonymize"
|
||||
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
@@ -155,7 +157,8 @@ func init() {
|
||||
|
||||
rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp|npipe]://[path|host:port|name]")
|
||||
rootCmd.PersistentFlags().StringVarP(&managementURL, "management-url", "m", "", fmt.Sprintf("Management Service URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultManagementURL))
|
||||
rootCmd.PersistentFlags().StringVar(&adminURL, "admin-url", "", fmt.Sprintf("Admin Panel URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultAdminURL))
|
||||
rootCmd.PersistentFlags().StringVar(&adminURL, "admin-url", "", "(DEPRECATED) Admin Panel URL - This flag is no longer functional")
|
||||
_ = rootCmd.PersistentFlags().MarkDeprecated("admin-url", "the admin-url flag is no longer functional and will be removed in a future version")
|
||||
rootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "l", "info", "sets NetBird log level")
|
||||
rootCmd.PersistentFlags().StringSliceVar(&logFiles, "log-file", []string{defaultLogFile}, "sets NetBird log paths written to simultaneously. If `console` is specified the log will be output to stdout. If `syslog` is specified the log will be sent to syslog daemon. You can pass the flag multiple times or separate entries by `,` character")
|
||||
rootCmd.PersistentFlags().StringVarP(&setupKey, "setup-key", "k", "", "Setup key obtained from the Management Service Dashboard (used to register peer)")
|
||||
@@ -175,16 +178,16 @@ func init() {
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
rootCmd.AddCommand(sshCmd)
|
||||
rootCmd.AddCommand(networksCMD)
|
||||
rootCmd.AddCommand(forwardingRulesCmd)
|
||||
rootCmd.AddCommand(debugCmd)
|
||||
rootCmd.AddCommand(profileCmd)
|
||||
rootCmd.AddCommand(exposeCmd)
|
||||
rootCmd.AddCommand(postureCmd)
|
||||
|
||||
postureCmd.AddCommand(postureCertProofCmd)
|
||||
|
||||
networksCMD.AddCommand(routesListCmd)
|
||||
networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd)
|
||||
|
||||
forwardingRulesCmd.AddCommand(forwardingRulesListCmd)
|
||||
|
||||
debugCmd.AddCommand(debugBundleCmd)
|
||||
debugCmd.AddCommand(logCmd)
|
||||
logCmd.AddCommand(logLevelCmd)
|
||||
@@ -285,6 +288,43 @@ func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, e
|
||||
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.
|
||||
func WithBackOff(bf func() error) error {
|
||||
return backoff.RetryNotify(bf, CLIBackOffSettings, func(err error, duration time.Duration) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -25,6 +26,30 @@ var serviceCmd = &cobra.Command{
|
||||
|
||||
const defaultJSONSocket = "unix:///var/run/netbird-http.sock"
|
||||
|
||||
// forbiddenServiceEnvVars are the environment variables the service is never
|
||||
// registered with, keyed in upper case since these are Windows names. Each one
|
||||
// decides where the daemon resolves something it then uses with the privileges
|
||||
// of the account it runs under — LocalSystem on Windows, root elsewhere: the
|
||||
// executables it runs (PATH, PATHEXT, COMSPEC, SystemRoot, windir) or the
|
||||
// directory it writes temporary files in (TEMP, TMP). The daemon needs none of
|
||||
// them, and the utilities it shells out to are resolved by absolute path.
|
||||
var forbiddenServiceEnvVars = map[string]struct{}{
|
||||
"PATH": {},
|
||||
"PATHEXT": {},
|
||||
"SYSTEMROOT": {},
|
||||
"WINDIR": {},
|
||||
"COMSPEC": {},
|
||||
"TEMP": {},
|
||||
"TMP": {},
|
||||
}
|
||||
|
||||
// forbiddenServiceEnvPrefixes are the dynamic-loader families, refused whole
|
||||
// rather than by name: LD_PRELOAD, DYLD_INSERT_LIBRARIES and their siblings all
|
||||
// reach the loader of the process, the set differs per platform and libc, and
|
||||
// new members arrive with new OS releases. Listing them one by one is a list
|
||||
// that is wrong the moment it is written.
|
||||
var forbiddenServiceEnvPrefixes = []string{"LD_", "DYLD_"}
|
||||
|
||||
var (
|
||||
serviceName string
|
||||
serviceEnvVars []string
|
||||
@@ -127,8 +152,33 @@ func parseServiceEnvVars(envVars []string) (map[string]string, error) {
|
||||
return nil, fmt.Errorf("empty environment variable key in: %s", env)
|
||||
}
|
||||
|
||||
if isForbiddenServiceEnvVar(key) {
|
||||
return nil, fmt.Errorf("environment variable %s cannot be set on the service: it decides where the service resolves the executables, libraries or temporary files it uses", key)
|
||||
}
|
||||
|
||||
envMap[key] = value
|
||||
}
|
||||
|
||||
return envMap, nil
|
||||
}
|
||||
|
||||
// isForbiddenServiceEnvVar reports whether name is one the service must not be
|
||||
// registered with.
|
||||
//
|
||||
// The names are matched case-insensitively only on Windows, where they are the
|
||||
// same variable however they are spelled. Elsewhere the environment is
|
||||
// case-sensitive, so Path and PATH are two different variables and only the
|
||||
// exact spelling is the one the loader reads.
|
||||
func isForbiddenServiceEnvVar(name string) bool {
|
||||
if runtime.GOOS == "windows" {
|
||||
name = strings.ToUpper(name)
|
||||
}
|
||||
|
||||
if _, forbidden := forbiddenServiceEnvVars[name]; forbidden {
|
||||
return true
|
||||
}
|
||||
|
||||
return slices.ContainsFunc(forbiddenServiceEnvPrefixes, func(prefix string) bool {
|
||||
return strings.HasPrefix(name, prefix)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/configs"
|
||||
"github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/elevate"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
@@ -43,10 +44,33 @@ func serviceParamsPath() string {
|
||||
|
||||
// loadServiceParams reads saved service parameters from disk.
|
||||
// Returns nil with no error if the file does not exist.
|
||||
//
|
||||
// The file is read by an elevated install and decides the arguments and the
|
||||
// environment of the service it then registers, so it is used only when its
|
||||
// ownership and permissions are the ones saveServiceParams leaves behind. That
|
||||
// restricted ACL is applied when the file is written, which is not necessarily
|
||||
// before it is first read, so this is checked rather than assumed. A file that
|
||||
// fails the check is treated as absent, and the install proceeds with its
|
||||
// defaults.
|
||||
func loadServiceParams() (*serviceParams, error) {
|
||||
path := serviceParamsPath()
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
// Resolve links first so the checks apply to the file that is actually read.
|
||||
// Since the check covers every directory above it as well, nobody who fails
|
||||
// it can swap the file between here and the read below.
|
||||
resolved, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
return nil, fmt.Errorf("resolve service params %s: %w", path, err)
|
||||
}
|
||||
|
||||
if err := elevate.CheckOnlyOwnerWritable(resolved); err != nil {
|
||||
return nil, fmt.Errorf("refusing to read service params from %s: %w", resolved, err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(resolved)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil //nolint:nilnil
|
||||
@@ -182,10 +206,16 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) {
|
||||
// If --service-env was explicitly set to empty, all saved env vars are cleared.
|
||||
// If --service-env was not set, saved env vars are used entirely.
|
||||
func applyServiceEnvParams(cmd *cobra.Command, params *serviceParams) {
|
||||
// A forbidden name explicitly passed on the command line is an error the
|
||||
// operator is told about, but one restored from a file written by an older
|
||||
// version is dropped: an install that refuses to run would leave the host
|
||||
// without a daemon over a variable nobody is asking for any more.
|
||||
saved := dropForbiddenServiceEnvVars(cmd, params.ServiceEnvVars)
|
||||
|
||||
if !cmd.Flags().Changed("service-env") {
|
||||
if len(params.ServiceEnvVars) > 0 {
|
||||
if len(saved) > 0 {
|
||||
// No explicit env vars: rebuild serviceEnvVars from saved params.
|
||||
serviceEnvVars = envMapToSlice(params.ServiceEnvVars)
|
||||
serviceEnvVars = envMapToSlice(saved)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -204,13 +234,13 @@ func applyServiceEnvParams(cmd *cobra.Command, params *serviceParams) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(params.ServiceEnvVars) == 0 {
|
||||
if len(saved) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Merge saved values underneath explicit ones.
|
||||
merged := make(map[string]string, len(params.ServiceEnvVars)+len(explicit))
|
||||
maps.Copy(merged, params.ServiceEnvVars)
|
||||
merged := make(map[string]string, len(saved)+len(explicit))
|
||||
maps.Copy(merged, saved)
|
||||
maps.Copy(merged, explicit) // explicit wins on conflict
|
||||
serviceEnvVars = envMapToSlice(merged)
|
||||
}
|
||||
@@ -233,6 +263,20 @@ var resetParamsCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
// dropForbiddenServiceEnvVars returns the saved entries that may still be
|
||||
// registered on the service, reporting every one it leaves behind.
|
||||
func dropForbiddenServiceEnvVars(cmd *cobra.Command, saved map[string]string) map[string]string {
|
||||
kept := make(map[string]string, len(saved))
|
||||
for key, value := range saved {
|
||||
if isForbiddenServiceEnvVar(key) {
|
||||
cmd.PrintErrf("Warning: ignoring saved service environment variable %s: it decides where the service resolves the executables, libraries or temporary files it uses\n", key)
|
||||
continue
|
||||
}
|
||||
kept[key] = value
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
// envMapToSlice converts a map of env vars to a KEY=VALUE slice.
|
||||
func envMapToSlice(m map[string]string) []string {
|
||||
s := make([]string, 0, len(m))
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -353,6 +354,59 @@ func TestApplyServiceEnvParams_NotChanged(t *testing.T) {
|
||||
assert.Equal(t, map[string]string{"FROM_SAVED": "val"}, result)
|
||||
}
|
||||
|
||||
func TestParseServiceEnvVars_RejectsForbiddenNames(t *testing.T) {
|
||||
for _, env := range []string{"PATH=C:\\somewhere", "LD_PRELOAD=/tmp/lib.so", "DYLD_FALLBACK_LIBRARY_PATH=/tmp"} {
|
||||
_, err := parseServiceEnvVars([]string{"KEEP=me", env})
|
||||
require.Errorf(t, err, "%s selects what the service resolves and must be refused", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsForbiddenServiceEnvVar(t *testing.T) {
|
||||
// The loader families are matched by prefix, so a name nobody has heard of
|
||||
// yet is refused too.
|
||||
for _, name := range []string{
|
||||
"PATH", "PATHEXT", "COMSPEC", "SYSTEMROOT", "WINDIR", "TEMP", "TMP",
|
||||
"LD_PRELOAD", "LD_AUDIT", "DYLD_INSERT_LIBRARIES", "DYLD_FALLBACK_FRAMEWORK_PATH",
|
||||
} {
|
||||
assert.Truef(t, isForbiddenServiceEnvVar(name), "%s must be refused", name)
|
||||
}
|
||||
|
||||
// The prefix must not swallow names that merely start with the same letters.
|
||||
for _, name := range []string{"NB_LOG_LEVEL", "NB_WG_DEBUG", "HTTPS_PROXY", "LDAP_URL", "DYLDX"} {
|
||||
assert.Falsef(t, isForbiddenServiceEnvVar(name), "%s has no reason to be refused", name)
|
||||
}
|
||||
|
||||
// On Windows a variable is the same one however it is spelled; elsewhere
|
||||
// Path and PATH are two variables and only the exact one is read.
|
||||
if runtime.GOOS == "windows" {
|
||||
assert.True(t, isForbiddenServiceEnvVar("Path"))
|
||||
assert.True(t, isForbiddenServiceEnvVar("ld_preload"))
|
||||
} else {
|
||||
assert.False(t, isForbiddenServiceEnvVar("Path"))
|
||||
assert.False(t, isForbiddenServiceEnvVar("ld_preload"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyServiceEnvParams_DropsForbiddenSavedNames(t *testing.T) {
|
||||
origServiceEnvVars := serviceEnvVars
|
||||
t.Cleanup(func() { serviceEnvVars = origServiceEnvVars })
|
||||
|
||||
serviceEnvVars = nil
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().StringSlice("service-env", nil, "")
|
||||
|
||||
saved := &serviceParams{
|
||||
ServiceEnvVars: map[string]string{"PATH": "C:\\attacker", "NB_LOG_FORMAT": "json"},
|
||||
}
|
||||
|
||||
applyServiceEnvParams(cmd, saved)
|
||||
|
||||
result, err := parseServiceEnvVars(serviceEnvVars)
|
||||
require.NoError(t, err, "a saved PATH must be dropped rather than fail the install")
|
||||
assert.Equal(t, map[string]string{"NB_LOG_FORMAT": "json"}, result)
|
||||
}
|
||||
|
||||
func TestApplyServiceEnvParams_ExplicitEmptyClears(t *testing.T) {
|
||||
origServiceEnvVars := serviceEnvVars
|
||||
t.Cleanup(func() { serviceEnvVars = origServiceEnvVars })
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//go:build !windows && !ios && !android
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/configs"
|
||||
)
|
||||
|
||||
// The Windows equivalent of this is the ACL check in
|
||||
// elevate.CheckOnlyOwnerWritable, covered by that package's own tests; here the
|
||||
// point is that loadServiceParams asks the question at all.
|
||||
func TestLoadServiceParams_RefusesWorldWritableFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
original := configs.StateDir
|
||||
t.Cleanup(func() { configs.StateDir = original })
|
||||
configs.StateDir = tmpDir
|
||||
|
||||
path := filepath.Join(tmpDir, serviceParamsFile)
|
||||
require.NoError(t, os.WriteFile(path, []byte(`{"log_level":"debug"}`), 0o666))
|
||||
// WriteFile is subject to the umask, so set the bits that matter explicitly.
|
||||
require.NoError(t, os.Chmod(path, 0o666))
|
||||
|
||||
params, err := loadServiceParams()
|
||||
require.Error(t, err, "a service.json anyone can rewrite must not be trusted")
|
||||
assert.Nil(t, params)
|
||||
|
||||
require.NoError(t, os.Chmod(path, 0o600))
|
||||
params, err = loadServiceParams()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, params)
|
||||
assert.Equal(t, "debug", params.LogLevel)
|
||||
}
|
||||
|
||||
func TestLoadServiceParams_RefusesWorldWritableDirectory(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
stateDir := filepath.Join(tmpDir, "state")
|
||||
require.NoError(t, os.Mkdir(stateDir, 0o777))
|
||||
require.NoError(t, os.Chmod(stateDir, 0o777))
|
||||
|
||||
original := configs.StateDir
|
||||
t.Cleanup(func() { configs.StateDir = original })
|
||||
configs.StateDir = stateDir
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(stateDir, serviceParamsFile), []byte(`{}`), 0o600))
|
||||
|
||||
params, err := loadServiceParams()
|
||||
require.Error(t, err, "a service.json in a directory anyone can replace entries in must not be trusted")
|
||||
assert.Nil(t, params)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
var (
|
||||
detailFlag bool
|
||||
tableFlag bool
|
||||
ipv4Flag bool
|
||||
ipv6Flag bool
|
||||
jsonFlag bool
|
||||
@@ -43,11 +44,12 @@ func init() {
|
||||
ipsFilterMap = make(map[string]struct{})
|
||||
prefixNamesFilterMap = make(map[string]struct{})
|
||||
statusCmd.PersistentFlags().BoolVarP(&detailFlag, "detail", "d", false, "display detailed status information in human-readable format")
|
||||
statusCmd.PersistentFlags().BoolVarP(&tableFlag, "table", "t", false, "display peers in a table, supports the --filter-by-* flags")
|
||||
statusCmd.PersistentFlags().BoolVarP(&jsonFlag, "json", "j", false, "display detailed status information in json format")
|
||||
statusCmd.PersistentFlags().BoolVarP(&yamlFlag, "yaml", "y", false, "display detailed status information in yaml format")
|
||||
statusCmd.PersistentFlags().BoolVarP(&ipv4Flag, "ipv4", "4", false, "display only NetBird IPv4 of this peer, e.g., --ipv4 will output 100.64.0.33")
|
||||
statusCmd.PersistentFlags().BoolVarP(&ipv6Flag, "ipv6", "6", false, "display only NetBird IPv6 of this peer")
|
||||
statusCmd.MarkFlagsMutuallyExclusive("detail", "json", "yaml", "ipv4", "ipv6")
|
||||
statusCmd.MarkFlagsMutuallyExclusive("detail", "table", "json", "yaml", "ipv4", "ipv6")
|
||||
statusCmd.PersistentFlags().StringSliceVarP(&ipsFilter, "filter-by-ips", "I", []string{}, "filters the detailed output by a list of one or more IPs (v4 or v6), e.g., --filter-by-ips 100.64.0.100,fd00::1")
|
||||
statusCmd.PersistentFlags().StringSliceVarP(&prefixNamesFilter, "filter-by-names", "N", []string{}, "filters the detailed output by a list of one or more peer FQDN or hostnames, e.g., --filter-by-names peer-a,peer-b.netbird.cloud")
|
||||
statusCmd.PersistentFlags().StringVarP(&statusFilter, "filter-by-status", "S", "", "filters the detailed output by connection status(idle|connecting|connected), e.g., --filter-by-status connected")
|
||||
@@ -143,6 +145,8 @@ func statusFunc(cmd *cobra.Command, args []string) error {
|
||||
switch {
|
||||
case detailFlag:
|
||||
statusOutputString = outputInformationHolder.FullDetailSummary()
|
||||
case tableFlag:
|
||||
statusOutputString = outputInformationHolder.PeersTable()
|
||||
case jsonFlag:
|
||||
statusOutputString, err = outputInformationHolder.JSON()
|
||||
case yamlFlag:
|
||||
@@ -238,7 +242,7 @@ func parseFilters() error {
|
||||
}
|
||||
|
||||
func enableDetailFlagWhenFilterFlag() {
|
||||
if !detailFlag && !jsonFlag && !yamlFlag {
|
||||
if !detailFlag && !jsonFlag && !yamlFlag && !tableFlag {
|
||||
detailFlag = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParsingOfIP(t *testing.T) {
|
||||
@@ -13,3 +14,61 @@ func TestParsingOfIP(t *testing.T) {
|
||||
|
||||
assert.Equal(t, "192.168.178.123\n", parsedIP)
|
||||
}
|
||||
|
||||
// resetStatusFlags restores only the status-specific output flags. The global
|
||||
// root flags (setup-key and friends) are shared with other tests in the
|
||||
// package, so they must not be touched here.
|
||||
func resetStatusFlags(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Cleanup(func() {
|
||||
for _, name := range []string{"detail", "table", "json", "yaml", "ipv4", "ipv6"} {
|
||||
f := statusCmd.Flags().Lookup(name)
|
||||
require.NotNil(t, f, "flag %s must exist", name)
|
||||
require.NoError(t, f.Value.Set(f.DefValue))
|
||||
f.Changed = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStatusTableFlagExclusive(t *testing.T) {
|
||||
for _, other := range []string{"detail", "json", "yaml", "ipv4", "ipv6"} {
|
||||
t.Run(other, func(t *testing.T) {
|
||||
resetStatusFlags(t)
|
||||
require.NoError(t, statusCmd.Flags().Set("table", "true"))
|
||||
require.NoError(t, statusCmd.Flags().Set(other, "true"))
|
||||
|
||||
assert.Error(t, statusCmd.ValidateFlagGroups(), "--table with --%s must be rejected", other)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("alone", func(t *testing.T) {
|
||||
resetStatusFlags(t)
|
||||
require.NoError(t, statusCmd.Flags().Set("table", "true"))
|
||||
|
||||
assert.NoError(t, statusCmd.ValidateFlagGroups())
|
||||
})
|
||||
}
|
||||
|
||||
func TestFilterEnablesDetailOnlyWithoutOutputFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
table bool
|
||||
json bool
|
||||
wantDetail bool
|
||||
}{
|
||||
{name: "no format", wantDetail: true},
|
||||
{name: "table", table: true, wantDetail: false},
|
||||
{name: "json", json: true, wantDetail: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resetStatusFlags(t)
|
||||
tableFlag, jsonFlag = tt.table, tt.json
|
||||
|
||||
enableDetailFlagWhenFilterFlag()
|
||||
|
||||
assert.Equal(t, tt.wantDetail, detailFlag)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.uber.org/mock/gomock"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
|
||||
@@ -28,7 +28,6 @@ import (
|
||||
mgmt "github.com/netbirdio/netbird/management/server"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"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/settings"
|
||||
"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)
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
+30
-79
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"runtime"
|
||||
"strings"
|
||||
@@ -26,16 +25,11 @@ import (
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/server"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/shared/externalipmap"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
const (
|
||||
invalidInputType int = iota
|
||||
ipInputType
|
||||
interfaceInputType
|
||||
)
|
||||
|
||||
const (
|
||||
dnsLabelsFlag = "extra-dns-labels"
|
||||
|
||||
@@ -106,7 +100,7 @@ func upFunc(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("failed initializing log %v", err)
|
||||
}
|
||||
|
||||
err = validateNATExternalIPs(natExternalIPs)
|
||||
err = externalipmap.Validate(natExternalIPs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -357,9 +351,17 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager
|
||||
// set the new config
|
||||
req := setupSetConfigReq(customDNSAddressConverted, cmd, activeProf.ID.String(), username.Username)
|
||||
if _, err := client.SetConfig(ctx, req); err != nil {
|
||||
if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Unavailable {
|
||||
log.Warnf("setConfig method is not available in the daemon: %s", st.Message())
|
||||
} else {
|
||||
switch reason, refused := refusedSettingsUpdate(err); {
|
||||
case refused:
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -400,10 +402,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
|
||||
err = WithBackOff(func() error {
|
||||
var backOffErr error
|
||||
loginResp, backOffErr = client.Login(ctx, loginRequest)
|
||||
if s, ok := gstatus.FromError(backOffErr); ok && (s.Code() == codes.InvalidArgument ||
|
||||
s.Code() == codes.PermissionDenied ||
|
||||
s.Code() == codes.NotFound ||
|
||||
s.Code() == codes.Unimplemented) {
|
||||
if terminalLoginError(backOffErr) {
|
||||
loginErr = backOffErr
|
||||
return nil
|
||||
}
|
||||
@@ -472,13 +471,28 @@ 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 {
|
||||
var req proto.SetConfigRequest
|
||||
req.ProfileName = profileName
|
||||
req.Username = username
|
||||
|
||||
req.ManagementUrl = managementURL
|
||||
req.AdminURL = adminURL
|
||||
req.NatExternalIPs = natExternalIPs
|
||||
req.CustomDNSAddress = customDNSAddressConverted
|
||||
req.ExtraIFaceBlacklist = extraIFaceBlackList
|
||||
@@ -815,36 +829,6 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
|
||||
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 {
|
||||
if runtime.GOOS != "darwin" {
|
||||
return nil
|
||||
@@ -857,39 +841,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)
|
||||
}
|
||||
|
||||
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) {
|
||||
var parsed []byte
|
||||
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))
|
||||
})
|
||||
}
|
||||
}
|
||||
+21
-8
@@ -12,11 +12,10 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
wgdevice "golang.zx2c4.com/wireguard/device"
|
||||
wgnetstack "golang.zx2c4.com/wireguard/tun/netstack"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/iface/netstack"
|
||||
"github.com/netbirdio/netbird/client/iface/wgbackend"
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
@@ -54,6 +53,9 @@ type Client struct {
|
||||
jwtToken string
|
||||
connect *internal.ConnectClient
|
||||
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.
|
||||
@@ -78,7 +80,8 @@ type Options struct {
|
||||
NoUserspace bool
|
||||
// ConfigPath is the path to the netbird config file. If empty, the config will be stored in memory and not persisted.
|
||||
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
|
||||
// DisableClientRoutes disables the client routes
|
||||
DisableClientRoutes bool
|
||||
@@ -114,6 +117,9 @@ type Options struct {
|
||||
MTU *uint16
|
||||
// DNSLabels defines additional DNS labels configured in the peer.
|
||||
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 Performance
|
||||
}
|
||||
@@ -193,7 +199,6 @@ func New(opts Options) (*Client, error) {
|
||||
}
|
||||
|
||||
if opts.StatePath != "" {
|
||||
// TODO: Disable state if path not provided
|
||||
if err := os.Setenv("NB_DNS_STATE_FILE", opts.StatePath); err != nil {
|
||||
return nil, fmt.Errorf("setenv: %w", err)
|
||||
}
|
||||
@@ -221,6 +226,7 @@ func New(opts Options) (*Client, error) {
|
||||
WireguardPort: opts.WireguardPort,
|
||||
MTU: opts.MTU,
|
||||
DNSLabels: parsedLabels,
|
||||
NATExternalIPs: opts.NATExternalIPs,
|
||||
}
|
||||
if opts.ConfigPath != "" {
|
||||
config, err = profilemanager.UpdateOrCreateConfig(input)
|
||||
@@ -249,10 +255,10 @@ func New(opts Options) (*Client, error) {
|
||||
}
|
||||
|
||||
if opts.Performance.PreallocatedBuffersPerPool != nil {
|
||||
wgdevice.SetPreallocatedBuffersPerPool(*opts.Performance.PreallocatedBuffersPerPool)
|
||||
wgbackend.SetPreallocatedBuffersPerPool(*opts.Performance.PreallocatedBuffersPerPool)
|
||||
}
|
||||
if opts.Performance.MaxBatchSize != nil {
|
||||
wgdevice.SetMaxBatchSizeOverride(*opts.Performance.MaxBatchSize)
|
||||
wgbackend.SetMaxBatchSizeOverride(*opts.Performance.MaxBatchSize)
|
||||
}
|
||||
|
||||
return &Client{
|
||||
@@ -261,6 +267,9 @@ func New(opts Options) (*Client, error) {
|
||||
jwtToken: opts.JWTToken,
|
||||
config: config,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -292,7 +301,11 @@ func (c *Client) Start(startCtx context.Context) error {
|
||||
if err, _ := authClient.Login(ctx, c.setupKey, c.jwtToken); err != nil {
|
||||
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)
|
||||
|
||||
// either startup error (permanent backoff err) or nil err (successful engine up)
|
||||
@@ -637,7 +650,7 @@ func (c *Client) getEngine() (*internal.Engine, error) {
|
||||
return engine, nil
|
||||
}
|
||||
|
||||
func (c *Client) getNet() (*wgnetstack.Net, netip.Addr, error) {
|
||||
func (c *Client) getNet() (*wgbackend.NetstackNet, netip.Addr, error) {
|
||||
engine, err := c.getEngine()
|
||||
if err != nil {
|
||||
return nil, netip.Addr{}, err
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
||||
"github.com/netbirdio/netbird/management/server/groups"
|
||||
"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/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/settings"
|
||||
@@ -146,8 +145,8 @@ func startManagement(t *testing.T, signalAddr string) string {
|
||||
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
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)
|
||||
accountManager, err := mgmt.BuildManager(context.Background(), cfg, testStore, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
|
||||
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, settingsMockManager, permissionsManager, false, cacheStore)
|
||||
require.NoError(t, err)
|
||||
|
||||
secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, cfg.TURNConfig, cfg.Relay, settingsMockManager, groupsManager)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
wgdevice "golang.zx2c4.com/wireguard/device"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
"github.com/netbirdio/netbird/client/iface/wgbackend"
|
||||
)
|
||||
|
||||
// EnvForceUserspaceFirewall forces the use of the userspace packet filter even when
|
||||
@@ -20,5 +19,5 @@ type IFaceMapper interface {
|
||||
IsUserspaceBind() bool
|
||||
SetFilter(device.PacketFilter) error
|
||||
GetDevice() *device.FilteredDevice
|
||||
GetWGDevice() *wgdevice.Device
|
||||
GetWGDevice() *wgbackend.Device
|
||||
}
|
||||
|
||||
@@ -8,177 +8,11 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/go-multierror"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||
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 {
|
||||
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")
|
||||
}
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
tableFilter = "filter"
|
||||
tableNat = "nat"
|
||||
tableMangle = "mangle"
|
||||
tableRaw = "raw"
|
||||
|
||||
// chainACLInput is the peer ACL chain that holds installed
|
||||
// peer-filtering rules.
|
||||
@@ -34,6 +35,7 @@ const (
|
||||
mangleForwardKey chainKey = "MANGLE-FORWARD"
|
||||
|
||||
chainInput = "INPUT"
|
||||
chainOutput = "OUTPUT"
|
||||
chainPostrouting = "POSTROUTING"
|
||||
chainPrerouting = "PREROUTING"
|
||||
chainForward = "FORWARD"
|
||||
@@ -54,10 +56,6 @@ const (
|
||||
markManglePost = "mark-mangle-post"
|
||||
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 = 40
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
// rule's stored chain/table identify where to delete from; source set
|
||||
// references are recovered from the spec via findSets and dropped
|
||||
|
||||
@@ -25,9 +25,8 @@ type Manager struct {
|
||||
|
||||
wgIface iFaceMapper
|
||||
|
||||
ipv4Client *iptables.IPTables
|
||||
family4 *family
|
||||
rawSupported bool
|
||||
ipv4Client *iptables.IPTables
|
||||
family4 *family
|
||||
|
||||
// IPv6 counterparts, nil when no v6 overlay
|
||||
ipv6Client *iptables.IPTables
|
||||
@@ -108,10 +107,6 @@ func (m *Manager) Init(stateManager *statemanager.Manager) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.initNoTrackChain(); err != nil {
|
||||
log.Warnf("raw table not available, notrack rules will be disabled: %v", err)
|
||||
}
|
||||
|
||||
// Trust after all fatal init steps so a later failure doesn't leave the
|
||||
// interface in firewalld's trusted zone without a corresponding Close.
|
||||
if err := firewalld.TrustInterface(m.wgIface.Name()); err != nil {
|
||||
@@ -285,10 +280,6 @@ func (m *Manager) Close(stateManager *statemanager.Manager) error {
|
||||
|
||||
var merr *multierror.Error
|
||||
|
||||
if err := m.cleanupNoTrackChain(); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("cleanup notrack chain: %w", err))
|
||||
}
|
||||
|
||||
if m.hasIPv6() {
|
||||
if err := m.family6.Reset(); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("reset v6 family: %w", err))
|
||||
@@ -332,31 +323,6 @@ func (m *Manager) DisableRouting() error {
|
||||
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
|
||||
func (m *Manager) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error {
|
||||
m.mutex.Lock()
|
||||
@@ -440,134 +406,6 @@ func (m *Manager) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Proto
|
||||
return m.family4.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort)
|
||||
}
|
||||
|
||||
const (
|
||||
chainNameRaw = "NETBIRD-RAW"
|
||||
chainOutput = "OUTPUT"
|
||||
tableRaw = "raw"
|
||||
)
|
||||
|
||||
// SetupEBPFProxyNoTrack creates notrack rules for eBPF proxy loopback traffic.
|
||||
// This prevents conntrack from tracking WireGuard proxy traffic on loopback, which
|
||||
// can interfere with MASQUERADE rules (e.g., from container runtimes like Podman/netavark).
|
||||
//
|
||||
// Traffic flows that need NOTRACK:
|
||||
//
|
||||
// 1. Egress: WireGuard -> fake endpoint (before eBPF rewrite)
|
||||
// src=127.0.0.1:wgPort -> dst=127.0.0.1:fakePort
|
||||
// Matched by: sport=wgPort
|
||||
//
|
||||
// 2. Egress: Proxy -> WireGuard (via raw socket)
|
||||
// src=127.0.0.1:fakePort -> dst=127.0.0.1:wgPort
|
||||
// Matched by: dport=wgPort
|
||||
//
|
||||
// 3. Ingress: Packets to WireGuard
|
||||
// dst=127.0.0.1:wgPort
|
||||
// Matched by: dport=wgPort
|
||||
//
|
||||
// 4. Ingress: Packets to proxy (after eBPF rewrite)
|
||||
// dst=127.0.0.1:proxyPort
|
||||
// Matched by: dport=proxyPort
|
||||
//
|
||||
// Rules are cleaned up when the firewall manager is closed.
|
||||
func (m *Manager) SetupEBPFProxyNoTrack(proxyPort, wgPort uint16) error {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
if !m.rawSupported {
|
||||
return fmt.Errorf("raw table not available")
|
||||
}
|
||||
|
||||
wgPortStr := fmt.Sprintf("%d", wgPort)
|
||||
proxyPortStr := fmt.Sprintf("%d", proxyPort)
|
||||
|
||||
// Egress rules: match outgoing loopback UDP packets
|
||||
outputRuleSport := []string{"-o", "lo", "-s", "127.0.0.1", "-d", "127.0.0.1", "-p", "udp", "--sport", wgPortStr, "-j", "NOTRACK"}
|
||||
if err := m.ipv4Client.AppendUnique(tableRaw, chainNameRaw, outputRuleSport...); err != nil {
|
||||
return fmt.Errorf("add output sport notrack rule: %w", err)
|
||||
}
|
||||
|
||||
outputRuleDport := []string{"-o", "lo", "-s", "127.0.0.1", "-d", "127.0.0.1", "-p", "udp", "--dport", wgPortStr, "-j", "NOTRACK"}
|
||||
if err := m.ipv4Client.AppendUnique(tableRaw, chainNameRaw, outputRuleDport...); err != nil {
|
||||
return fmt.Errorf("add output dport notrack rule: %w", err)
|
||||
}
|
||||
|
||||
// Ingress rules: match incoming loopback UDP packets
|
||||
preroutingRuleWg := []string{"-i", "lo", "-s", "127.0.0.1", "-d", "127.0.0.1", "-p", "udp", "--dport", wgPortStr, "-j", "NOTRACK"}
|
||||
if err := m.ipv4Client.AppendUnique(tableRaw, chainNameRaw, preroutingRuleWg...); err != nil {
|
||||
return fmt.Errorf("add prerouting wg notrack rule: %w", err)
|
||||
}
|
||||
|
||||
preroutingRuleProxy := []string{"-i", "lo", "-s", "127.0.0.1", "-d", "127.0.0.1", "-p", "udp", "--dport", proxyPortStr, "-j", "NOTRACK"}
|
||||
if err := m.ipv4Client.AppendUnique(tableRaw, chainNameRaw, preroutingRuleProxy...); err != nil {
|
||||
return fmt.Errorf("add prerouting proxy notrack rule: %w", err)
|
||||
}
|
||||
|
||||
log.Debugf("set up ebpf proxy notrack rules for ports %d,%d", proxyPort, wgPort)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) initNoTrackChain() error {
|
||||
if err := m.cleanupNoTrackChain(); err != nil {
|
||||
log.Debugf("cleanup notrack chain: %v", err)
|
||||
}
|
||||
|
||||
if err := m.ipv4Client.NewChain(tableRaw, chainNameRaw); err != nil {
|
||||
return fmt.Errorf("create chain: %w", err)
|
||||
}
|
||||
|
||||
jumpRule := []string{"-j", chainNameRaw}
|
||||
|
||||
if err := m.ipv4Client.InsertUnique(tableRaw, chainOutput, 1, jumpRule...); err != nil {
|
||||
if delErr := m.ipv4Client.DeleteChain(tableRaw, chainNameRaw); delErr != nil {
|
||||
log.Debugf("delete orphan chain: %v", delErr)
|
||||
}
|
||||
return fmt.Errorf("add output jump rule: %w", err)
|
||||
}
|
||||
|
||||
if err := m.ipv4Client.InsertUnique(tableRaw, chainPrerouting, 1, jumpRule...); err != nil {
|
||||
if delErr := m.ipv4Client.DeleteIfExists(tableRaw, chainOutput, jumpRule...); delErr != nil {
|
||||
log.Debugf("delete output jump rule: %v", delErr)
|
||||
}
|
||||
if delErr := m.ipv4Client.DeleteChain(tableRaw, chainNameRaw); delErr != nil {
|
||||
log.Debugf("delete orphan chain: %v", delErr)
|
||||
}
|
||||
return fmt.Errorf("add prerouting jump rule: %w", err)
|
||||
}
|
||||
|
||||
m.rawSupported = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) cleanupNoTrackChain() error {
|
||||
exists, err := m.ipv4Client.ChainExists(tableRaw, chainNameRaw)
|
||||
if err != nil {
|
||||
if !m.rawSupported {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("check chain exists: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
jumpRule := []string{"-j", chainNameRaw}
|
||||
|
||||
if err := m.ipv4Client.DeleteIfExists(tableRaw, chainOutput, jumpRule...); err != nil {
|
||||
return fmt.Errorf("remove output jump rule: %w", err)
|
||||
}
|
||||
|
||||
if err := m.ipv4Client.DeleteIfExists(tableRaw, chainPrerouting, jumpRule...); err != nil {
|
||||
return fmt.Errorf("remove prerouting jump rule: %w", err)
|
||||
}
|
||||
|
||||
if err := m.ipv4Client.ClearAndDeleteChain(tableRaw, chainNameRaw); err != nil {
|
||||
return fmt.Errorf("clear and delete chain: %w", err)
|
||||
}
|
||||
|
||||
m.rawSupported = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func getConntrackEstablished() []string {
|
||||
return []string{"-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT"}
|
||||
}
|
||||
|
||||
@@ -497,16 +497,6 @@ func TestIptablesCloseRemovesAllState(t *testing.T) {
|
||||
require.NoError(t, manager.AddNatRule(pair), "add nat rule")
|
||||
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")
|
||||
|
||||
// Everything above stays in place, so Close is what has to remove it.
|
||||
|
||||
@@ -172,12 +172,6 @@ type Manager interface {
|
||||
|
||||
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(hash Set, prefixes []netip.Prefix) error
|
||||
|
||||
@@ -192,10 +186,6 @@ type Manager interface {
|
||||
|
||||
// RemoveOutputDNAT removes an OUTPUT chain DNAT rule.
|
||||
RemoveOutputDNAT(localAddr netip.Addr, protocol Protocol, originalPort, translatedPort uint16) error
|
||||
|
||||
// SetupEBPFProxyNoTrack creates static notrack rules for eBPF proxy loopback traffic.
|
||||
// This prevents conntrack from interfering with WireGuard proxy communication.
|
||||
SetupEBPFProxyNoTrack(proxyPort, wgPort uint16) error
|
||||
}
|
||||
|
||||
// GenKey builds the rule id for this pair from the given format.
|
||||
|
||||
@@ -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/binaryutil"
|
||||
"github.com/google/nftables/expr"
|
||||
"github.com/google/nftables/xt"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||
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 {
|
||||
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"
|
||||
tableSecurity = "security"
|
||||
|
||||
chainNameNatPrerouting = "PREROUTING"
|
||||
chainNameRoutingFw = "netbird-rt-fwd"
|
||||
chainNameRoutingNat = "netbird-rt-postrouting"
|
||||
chainNameRoutingRdr = "netbird-rt-redirect"
|
||||
@@ -47,9 +46,6 @@ const (
|
||||
userDataAcceptForwardRuleOif = "frwacceptoif"
|
||||
userDataAcceptInputRule = "inputaccept"
|
||||
|
||||
dnatSuffix firewall.RuleID = "_dnat"
|
||||
snatSuffix firewall.RuleID = "_snat"
|
||||
|
||||
// ipv4TCPHeaderSize is the minimum IPv4 (20) + TCP (20) header size for MSS calculation.
|
||||
ipv4TCPHeaderSize = 40
|
||||
// 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)
|
||||
}
|
||||
|
||||
if err := r.removeNatPreroutingRules(); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("remove filter prerouting rules: %w", err))
|
||||
}
|
||||
|
||||
return nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
|
||||
|
||||
@@ -197,11 +197,6 @@ func (r *family) hasRule(id firewall.RuleID) bool {
|
||||
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
|
||||
// set references are recovered from the stored rule's expressions via
|
||||
// findSets and dropped from the shared refcounter.
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/google/nftables/expr"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
@@ -55,9 +54,6 @@ type Manager struct {
|
||||
// IPv6 counterpart, nil when no v6 overlay.
|
||||
family6 *family
|
||||
|
||||
notrackOutputChain *nftables.Chain
|
||||
notrackPreroutingChain *nftables.Chain
|
||||
|
||||
extMonitor *externalChainMonitor
|
||||
}
|
||||
|
||||
@@ -170,10 +166,6 @@ func (m *Manager) initFirewall() (err error) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.initNoTrackChains(workTable); err != nil {
|
||||
log.Warnf("raw priority chains not available, notrack rules will be disabled: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -260,7 +252,7 @@ func (m *Manager) DeleteFilterRule(rule firewall.Rule) error {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
fam, err := m.familyForRuleID(rule.ID(), (*family).hasRule, false)
|
||||
fam, err := m.familyForRuleID(rule.ID(), (*family).hasRule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -268,11 +260,8 @@ func (m *Manager) DeleteFilterRule(rule firewall.Rule) error {
|
||||
}
|
||||
|
||||
// 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 NAT/DNAT rule maps from the kernel once and re-checks before falling
|
||||
// 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) {
|
||||
// the supplied lookup, and falls back to the v4 family on a miss.
|
||||
func (m *Manager) familyForRuleID(id firewall.RuleID, has func(*family, firewall.RuleID) bool) (*family, error) {
|
||||
if has(m.family4, id) {
|
||||
return m.family4, nil
|
||||
}
|
||||
@@ -282,18 +271,6 @@ func (m *Manager) familyForRuleID(id firewall.RuleID, has func(*family, firewall
|
||||
if has(m.family6, id) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -455,39 +432,9 @@ func (m *Manager) Flush() error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.refreshNoTrackChains(); err != nil {
|
||||
log.Errorf("failed to refresh notrack chains: %v", err)
|
||||
}
|
||||
|
||||
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
|
||||
func (m *Manager) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error {
|
||||
m.mutex.Lock()
|
||||
@@ -571,176 +518,6 @@ func (m *Manager) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Proto
|
||||
return m.family4.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort)
|
||||
}
|
||||
|
||||
const (
|
||||
chainNameRawOutput = "netbird-raw-out"
|
||||
chainNameRawPrerouting = "netbird-raw-pre"
|
||||
)
|
||||
|
||||
// SetupEBPFProxyNoTrack creates notrack rules for eBPF proxy loopback traffic.
|
||||
// This prevents conntrack from tracking WireGuard proxy traffic on loopback, which
|
||||
// can interfere with MASQUERADE rules (e.g., from container runtimes like Podman/netavark).
|
||||
//
|
||||
// Traffic flows that need NOTRACK:
|
||||
//
|
||||
// 1. Egress: WireGuard -> fake endpoint (before eBPF rewrite)
|
||||
// src=127.0.0.1:wgPort -> dst=127.0.0.1:fakePort
|
||||
// Matched by: sport=wgPort
|
||||
//
|
||||
// 2. Egress: Proxy -> WireGuard (via raw socket)
|
||||
// src=127.0.0.1:fakePort -> dst=127.0.0.1:wgPort
|
||||
// Matched by: dport=wgPort
|
||||
//
|
||||
// 3. Ingress: Packets to WireGuard
|
||||
// dst=127.0.0.1:wgPort
|
||||
// Matched by: dport=wgPort
|
||||
//
|
||||
// 4. Ingress: Packets to proxy (after eBPF rewrite)
|
||||
// dst=127.0.0.1:proxyPort
|
||||
// Matched by: dport=proxyPort
|
||||
//
|
||||
// Rules are cleaned up when the firewall manager is closed.
|
||||
func (m *Manager) SetupEBPFProxyNoTrack(proxyPort, wgPort uint16) error {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
if m.notrackOutputChain == nil || m.notrackPreroutingChain == nil {
|
||||
return fmt.Errorf("notrack chains not initialized")
|
||||
}
|
||||
|
||||
proxyPortBytes := binaryutil.BigEndian.PutUint16(proxyPort)
|
||||
wgPortBytes := binaryutil.BigEndian.PutUint16(wgPort)
|
||||
loopback := []byte{127, 0, 0, 1}
|
||||
|
||||
// Egress rules: match outgoing loopback UDP packets
|
||||
m.rConn.AddRule(&nftables.Rule{
|
||||
Table: m.notrackOutputChain.Table,
|
||||
Chain: m.notrackOutputChain,
|
||||
Exprs: []expr.Any{
|
||||
&expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ifname("lo")},
|
||||
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4}, // saddr
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback},
|
||||
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 16, Len: 4}, // daddr
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback},
|
||||
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.IPPROTO_UDP}},
|
||||
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 0, Len: 2},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: wgPortBytes}, // sport=wgPort
|
||||
&expr.Counter{},
|
||||
&expr.Notrack{},
|
||||
},
|
||||
})
|
||||
m.rConn.AddRule(&nftables.Rule{
|
||||
Table: m.notrackOutputChain.Table,
|
||||
Chain: m.notrackOutputChain,
|
||||
Exprs: []expr.Any{
|
||||
&expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ifname("lo")},
|
||||
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4}, // saddr
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback},
|
||||
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 16, Len: 4}, // daddr
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback},
|
||||
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.IPPROTO_UDP}},
|
||||
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: wgPortBytes}, // dport=wgPort
|
||||
&expr.Counter{},
|
||||
&expr.Notrack{},
|
||||
},
|
||||
})
|
||||
|
||||
// Ingress rules: match incoming loopback UDP packets
|
||||
m.rConn.AddRule(&nftables.Rule{
|
||||
Table: m.notrackPreroutingChain.Table,
|
||||
Chain: m.notrackPreroutingChain,
|
||||
Exprs: []expr.Any{
|
||||
&expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ifname("lo")},
|
||||
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4}, // saddr
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback},
|
||||
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 16, Len: 4}, // daddr
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback},
|
||||
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.IPPROTO_UDP}},
|
||||
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: wgPortBytes}, // dport=wgPort
|
||||
&expr.Counter{},
|
||||
&expr.Notrack{},
|
||||
},
|
||||
})
|
||||
m.rConn.AddRule(&nftables.Rule{
|
||||
Table: m.notrackPreroutingChain.Table,
|
||||
Chain: m.notrackPreroutingChain,
|
||||
Exprs: []expr.Any{
|
||||
&expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ifname("lo")},
|
||||
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4}, // saddr
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback},
|
||||
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 16, Len: 4}, // daddr
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback},
|
||||
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.IPPROTO_UDP}},
|
||||
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: proxyPortBytes}, // dport=proxyPort
|
||||
&expr.Counter{},
|
||||
&expr.Notrack{},
|
||||
},
|
||||
})
|
||||
|
||||
if err := m.rConn.Flush(); err != nil {
|
||||
return fmt.Errorf("flush notrack rules: %w", err)
|
||||
}
|
||||
|
||||
log.Debugf("set up ebpf proxy notrack rules for ports %d,%d", proxyPort, wgPort)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) initNoTrackChains(table *nftables.Table) error {
|
||||
m.notrackOutputChain = m.rConn.AddChain(&nftables.Chain{
|
||||
Name: chainNameRawOutput,
|
||||
Table: table,
|
||||
Type: nftables.ChainTypeFilter,
|
||||
Hooknum: nftables.ChainHookOutput,
|
||||
Priority: nftables.ChainPriorityRaw,
|
||||
})
|
||||
|
||||
m.notrackPreroutingChain = m.rConn.AddChain(&nftables.Chain{
|
||||
Name: chainNameRawPrerouting,
|
||||
Table: table,
|
||||
Type: nftables.ChainTypeFilter,
|
||||
Hooknum: nftables.ChainHookPrerouting,
|
||||
Priority: nftables.ChainPriorityRaw,
|
||||
})
|
||||
|
||||
if err := m.rConn.Flush(); err != nil {
|
||||
return fmt.Errorf("flush chain creation: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) refreshNoTrackChains() error {
|
||||
chains, err := m.rConn.ListChainsOfTableFamily(nftables.TableFamilyIPv4)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list chains: %w", err)
|
||||
}
|
||||
|
||||
tableName := getTableName()
|
||||
for _, c := range chains {
|
||||
if c.Table.Name != tableName {
|
||||
continue
|
||||
}
|
||||
switch c.Name {
|
||||
case chainNameRawOutput:
|
||||
m.notrackOutputChain = c
|
||||
case chainNameRawPrerouting:
|
||||
m.notrackPreroutingChain = c
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) createWorkTable() (*nftables.Table, error) {
|
||||
return m.createWorkTableFamily(nftables.TableFamilyIPv4)
|
||||
}
|
||||
|
||||
@@ -378,18 +378,6 @@ func TestNftablesManagerCompatibilityWithIptables(t *testing.T) {
|
||||
err = manager.AddNatRule(pair)
|
||||
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)
|
||||
verifyIptablesOutput(t, stdout, stderr)
|
||||
}
|
||||
@@ -453,18 +441,6 @@ func TestNftablesManagerIPv6CompatibilityWithIp6tables(t *testing.T) {
|
||||
})
|
||||
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)
|
||||
verifyIptablesOutput(t, stdout, stderr)
|
||||
|
||||
|
||||
@@ -192,7 +192,7 @@ func (r *family) addPostroutingRules() {
|
||||
Data: binaryutil.NativeEndian.PutUint32(nbnet.PreroutingFwmarkMasquerade),
|
||||
},
|
||||
|
||||
// We need to exclude the loopback interface as this changes the ebpf proxy port
|
||||
// We need to exclude the loopback interface as this changes the wg proxy port
|
||||
&expr.Meta{
|
||||
Key: expr.MetaKeyOIFNAME,
|
||||
Register: 1,
|
||||
@@ -459,41 +459,6 @@ func (r *family) RemoveAllLegacyRouteRules() error {
|
||||
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 {
|
||||
if err := r.refreshRulesMap(); err != nil {
|
||||
return fmt.Errorf(refreshRulesMapError, err)
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
log "github.com/sirupsen/logrus"
|
||||
wgdevice "golang.zx2c4.com/wireguard/device"
|
||||
|
||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||
"github.com/netbirdio/netbird/client/firewall/firewalld"
|
||||
@@ -31,6 +30,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
"github.com/netbirdio/netbird/client/iface/netstack"
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
"github.com/netbirdio/netbird/client/iface/wgbackend"
|
||||
nbid "github.com/netbirdio/netbird/client/internal/acl/id"
|
||||
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||
"github.com/netbirdio/netbird/client/internal/statemanager"
|
||||
@@ -121,7 +121,7 @@ type Iface interface {
|
||||
Name() string
|
||||
Address() wgaddr.Address
|
||||
SetFilter(device.PacketFilter) error
|
||||
GetWGDevice() *wgdevice.Device
|
||||
GetWGDevice() *wgbackend.Device
|
||||
}
|
||||
|
||||
// InterfaceAllower opens the NetBird interface in the host firewall so it
|
||||
@@ -879,12 +879,6 @@ func (m *Manager) resetState() {
|
||||
}
|
||||
}
|
||||
|
||||
// SetupEBPFProxyNoTrack is not supported by the userspace firewall: eBPF isn't
|
||||
// used in userspace mode, so this should never be called.
|
||||
func (m *Manager) SetupEBPFProxyNoTrack(uint16, uint16) error {
|
||||
return errNotSupported
|
||||
}
|
||||
|
||||
// UpdateSet updates the rule destinations associated with the given set
|
||||
// by merging the existing prefixes with the new ones, then deduplicating.
|
||||
func (m *Manager) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error {
|
||||
|
||||
@@ -5,17 +5,17 @@ import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/google/gopacket"
|
||||
"github.com/google/gopacket/layers"
|
||||
"github.com/stretchr/testify/require"
|
||||
wgdevice "golang.zx2c4.com/wireguard/device"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
fw "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
"github.com/netbirdio/netbird/client/iface/mocks"
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
"github.com/netbirdio/netbird/client/iface/wgbackend"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
)
|
||||
|
||||
@@ -791,10 +791,10 @@ func setupRoutedManager(tb testing.TB, network string) *Manager {
|
||||
}
|
||||
},
|
||||
GetDeviceFunc: func() *device.FilteredDevice {
|
||||
return &device.FilteredDevice{Device: dev}
|
||||
return &device.FilteredDevice{TUN: dev}
|
||||
},
|
||||
GetWGDeviceFunc: func() *wgdevice.Device {
|
||||
return &wgdevice.Device{}
|
||||
GetWGDeviceFunc: func() *wgbackend.Device {
|
||||
return &wgbackend.Device{}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -4,17 +4,17 @@ import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/google/gopacket/layers"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
wgdevice "golang.zx2c4.com/wireguard/device"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
fw "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
"github.com/netbirdio/netbird/client/iface/mocks"
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
"github.com/netbirdio/netbird/client/iface/wgbackend"
|
||||
)
|
||||
|
||||
// TestAddRouteFilteringReturnsExistingRule verifies that adding the same route
|
||||
@@ -180,10 +180,10 @@ func TestBlockInvalidRoutedDualStack(t *testing.T) {
|
||||
}
|
||||
},
|
||||
GetDeviceFunc: func() *device.FilteredDevice {
|
||||
return &device.FilteredDevice{Device: dev}
|
||||
return &device.FilteredDevice{TUN: dev}
|
||||
},
|
||||
GetWGDeviceFunc: func() *wgdevice.Device {
|
||||
return &wgdevice.Device{}
|
||||
GetWGDeviceFunc: func() *wgbackend.Device {
|
||||
return &wgbackend.Device{}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -228,10 +228,10 @@ func TestBlockInvalidRoutedIdempotent(t *testing.T) {
|
||||
}
|
||||
},
|
||||
GetDeviceFunc: func() *device.FilteredDevice {
|
||||
return &device.FilteredDevice{Device: dev}
|
||||
return &device.FilteredDevice{TUN: dev}
|
||||
},
|
||||
GetWGDeviceFunc: func() *wgdevice.Device {
|
||||
return &wgdevice.Device{}
|
||||
GetWGDeviceFunc: func() *wgbackend.Device {
|
||||
return &wgbackend.Device{}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -291,10 +291,10 @@ func TestBlockRuleNotAccumulatedOnRepeatedEnableRouting(t *testing.T) {
|
||||
}
|
||||
},
|
||||
GetDeviceFunc: func() *device.FilteredDevice {
|
||||
return &device.FilteredDevice{Device: dev}
|
||||
return &device.FilteredDevice{TUN: dev}
|
||||
},
|
||||
GetWGDeviceFunc: func() *wgdevice.Device {
|
||||
return &wgdevice.Device{}
|
||||
GetWGDeviceFunc: func() *wgbackend.Device {
|
||||
return &wgbackend.Device{}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -410,10 +410,10 @@ func setupTestManager(t *testing.T) *Manager {
|
||||
}
|
||||
},
|
||||
GetDeviceFunc: func() *device.FilteredDevice {
|
||||
return &device.FilteredDevice{Device: dev}
|
||||
return &device.FilteredDevice{TUN: dev}
|
||||
},
|
||||
GetWGDeviceFunc: func() *wgdevice.Device {
|
||||
return &wgdevice.Device{}
|
||||
GetWGDeviceFunc: func() *wgbackend.Device {
|
||||
return &wgbackend.Device{}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
wgdevice "golang.zx2c4.com/wireguard/device"
|
||||
|
||||
fw "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
"github.com/netbirdio/netbird/client/firewall/uspfilter/conntrack"
|
||||
@@ -22,6 +21,7 @@ import (
|
||||
nbiface "github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
"github.com/netbirdio/netbird/client/iface/wgbackend"
|
||||
"github.com/netbirdio/netbird/client/internal/netflow"
|
||||
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
@@ -34,7 +34,7 @@ type IFaceMock struct {
|
||||
NameFunc func() string
|
||||
SetFilterFunc func(device.PacketFilter) error
|
||||
AddressFunc func() wgaddr.Address
|
||||
GetWGDeviceFunc func() *wgdevice.Device
|
||||
GetWGDeviceFunc func() *wgbackend.Device
|
||||
GetDeviceFunc func() *device.FilteredDevice
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func (i *IFaceMock) Name() string {
|
||||
return i.NameFunc()
|
||||
}
|
||||
|
||||
func (i *IFaceMock) GetWGDevice() *wgdevice.Device {
|
||||
func (i *IFaceMock) GetWGDevice() *wgbackend.Device {
|
||||
if i.GetWGDeviceFunc == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@ import (
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
|
||||
wgdevice "golang.zx2c4.com/wireguard/device"
|
||||
"gvisor.dev/gvisor/pkg/tcpip"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/header"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/stack"
|
||||
|
||||
nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log"
|
||||
"github.com/netbirdio/netbird/client/iface/wgbackend"
|
||||
)
|
||||
|
||||
// PacketCapture captures raw packets for debugging. Implementations must be
|
||||
@@ -23,7 +23,7 @@ type PacketCapture interface {
|
||||
type endpoint struct {
|
||||
logger *nblog.Logger
|
||||
dispatcher stack.NetworkDispatcher
|
||||
device *wgdevice.Device
|
||||
device *wgbackend.Device
|
||||
mtu atomic.Uint32
|
||||
capture atomic.Pointer[PacketCapture]
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
wgdevice "golang.zx2c4.com/wireguard/device"
|
||||
"gvisor.dev/gvisor/pkg/buffer"
|
||||
"gvisor.dev/gvisor/pkg/tcpip"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/header"
|
||||
@@ -26,6 +25,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/firewall/uspfilter/conntrack"
|
||||
nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log"
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
"github.com/netbirdio/netbird/client/iface/wgbackend"
|
||||
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||
)
|
||||
|
||||
@@ -43,7 +43,7 @@ const (
|
||||
|
||||
// IFace provides the WireGuard device and overlay addresses the forwarder needs.
|
||||
type IFace interface {
|
||||
GetWGDevice() *wgdevice.Device
|
||||
GetWGDevice() *wgbackend.Device
|
||||
Address() wgaddr.Address
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||
"github.com/netbirdio/netbird/client/internal/wincmd"
|
||||
)
|
||||
|
||||
type action string
|
||||
@@ -91,7 +92,7 @@ func manageFirewallRule(ruleName string, action action, extraArgs ...string) err
|
||||
if action == addRule {
|
||||
args = append(args, extraArgs...)
|
||||
}
|
||||
netshCmd := GetSystem32Command("netsh")
|
||||
netshCmd := wincmd.System32("netsh")
|
||||
cmd := exec.Command(netshCmd, args...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
return cmd.Run()
|
||||
@@ -100,7 +101,7 @@ func manageFirewallRule(ruleName string, action action, extraArgs ...string) err
|
||||
func isWindowsFirewallReachable() bool {
|
||||
args := []string{"advfirewall", "show", "allprofiles", "state"}
|
||||
|
||||
netshCmd := GetSystem32Command("netsh")
|
||||
netshCmd := wincmd.System32("netsh")
|
||||
|
||||
cmd := exec.Command(netshCmd, args...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
@@ -117,23 +118,10 @@ func isWindowsFirewallReachable() bool {
|
||||
func isFirewallRuleActive(ruleName string) bool {
|
||||
args := []string{"advfirewall", "firewall", "show", "rule", "name=" + ruleName}
|
||||
|
||||
netshCmd := GetSystem32Command("netsh")
|
||||
netshCmd := wincmd.System32("netsh")
|
||||
|
||||
cmd := exec.Command(netshCmd, args...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
_, err := cmd.Output()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// GetSystem32Command checks if a command can be found in the system path and returns it. In case it can't find it
|
||||
// in the path it will return the full path of a command assuming C:\windows\system32 as the base path.
|
||||
func GetSystem32Command(command string) string {
|
||||
_, err := exec.LookPath(command)
|
||||
if err == nil {
|
||||
return command
|
||||
}
|
||||
|
||||
log.Tracef("Command %s not found in PATH, using C:\\windows\\system32\\%s.exe path", command, command)
|
||||
|
||||
return "C:\\windows\\system32\\" + command + ".exe"
|
||||
}
|
||||
|
||||
@@ -486,16 +486,6 @@ func incrementalUpdate(oldChecksum uint16, oldBytes, newBytes []byte) uint16 {
|
||||
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.
|
||||
func (m *Manager) addPortRedirection(targetIP netip.Addr, protocol gopacket.LayerType, originalPort, translatedPort uint16) error {
|
||||
m.portDNATMutex.Lock()
|
||||
|
||||
@@ -6,12 +6,9 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os/user"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
@@ -45,18 +42,13 @@ func WithSweeper(sweeper Sweeper) grpc.DialOption {
|
||||
}
|
||||
|
||||
func dialContext(ctx context.Context, addr string) (net.Conn, error) {
|
||||
if runtime.GOOS == "linux" {
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.FailedPrecondition, "failed to get current user: %v", err)
|
||||
}
|
||||
|
||||
// the custom dialer requires root permissions which are not required for use cases run as non-root
|
||||
if currentUser.Uid != "0" {
|
||||
log.Debug("Not running as root, using standard dialer")
|
||||
dialer := &net.Dialer{}
|
||||
return dialer.DialContext(ctx, "tcp", addr)
|
||||
}
|
||||
// The custom dialer requires root permissions which are not required for
|
||||
// use cases run as non-root. The effective UID is read directly: a passwd
|
||||
// lookup fails without cgo for a UID that has no entry, as on OpenShift.
|
||||
if runtime.GOOS == "linux" && os.Geteuid() != 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)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
package bind
|
||||
|
||||
import (
|
||||
wireguard "golang.zx2c4.com/wireguard/conn"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/wgbackend"
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
)
|
||||
|
||||
@@ -10,6 +9,6 @@ import (
|
||||
func init() {
|
||||
listener := nbnet.NewListener()
|
||||
if listener.ListenConfig.Control != nil {
|
||||
*wireguard.ControlFns = append(*wireguard.ControlFns, listener.ListenConfig.Control)
|
||||
*wgbackend.ControlFns = append(*wgbackend.ControlFns, listener.ListenConfig.Control)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ package bind
|
||||
import (
|
||||
"net"
|
||||
|
||||
wgConn "golang.zx2c4.com/wireguard/conn"
|
||||
"github.com/netbirdio/netbird/client/iface/wgbackend"
|
||||
)
|
||||
|
||||
type Endpoint = wgConn.StdNetEndpoint
|
||||
type Endpoint = wgbackend.StdNetEndpoint
|
||||
|
||||
func EndpointToUDPAddr(e Endpoint) *net.UDPAddr {
|
||||
return &net.UDPAddr{
|
||||
|
||||
@@ -15,10 +15,10 @@ import (
|
||||
"github.com/pion/transport/v3"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/net/ipv6"
|
||||
wgConn "golang.zx2c4.com/wireguard/conn"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/udpmux"
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
"github.com/netbirdio/netbird/client/iface/wgbackend"
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
)
|
||||
|
||||
@@ -36,7 +36,7 @@ type receiverCreator struct {
|
||||
iceBind *ICEBind
|
||||
}
|
||||
|
||||
func (rc receiverCreator) CreateReceiverFn(pc wgConn.BatchReader, conn *net.UDPConn, rxOffload bool, msgPool *sync.Pool) wgConn.ReceiveFunc {
|
||||
func (rc receiverCreator) CreateReceiverFn(pc wgbackend.BatchReader, conn *net.UDPConn, rxOffload bool, msgPool *sync.Pool) wgbackend.ReceiveFunc {
|
||||
return rc.iceBind.createReceiverFn(pc, conn, rxOffload, msgPool)
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ func (rc receiverCreator) CreateReceiverFn(pc wgConn.BatchReader, conn *net.UDPC
|
||||
// without port, in the format of 127.1.x.x where x.x is the last two octets of the peer address. We try to avoid to
|
||||
// use the port because in the Send function the wgConn.Endpoint the port info is not exported.
|
||||
type ICEBind struct {
|
||||
*wgConn.StdNetBind
|
||||
*wgbackend.StdNetBind
|
||||
|
||||
transportNet transport.Net
|
||||
address wgaddr.Address
|
||||
@@ -74,7 +74,7 @@ type ICEBind struct {
|
||||
}
|
||||
|
||||
func NewICEBind(transportNet transport.Net, address wgaddr.Address, mtu uint16) *ICEBind {
|
||||
b, _ := wgConn.NewStdNetBind().(*wgConn.StdNetBind)
|
||||
b, _ := wgbackend.NewStdNetBind().(*wgbackend.StdNetBind)
|
||||
ib := &ICEBind{
|
||||
StdNetBind: b,
|
||||
transportNet: transportNet,
|
||||
@@ -90,11 +90,11 @@ func NewICEBind(transportNet transport.Net, address wgaddr.Address, mtu uint16)
|
||||
rc := receiverCreator{
|
||||
ib,
|
||||
}
|
||||
ib.StdNetBind = wgConn.NewStdNetBindWithReceiverCreator(rc)
|
||||
ib.StdNetBind = wgbackend.NewStdNetBindWithReceiverCreator(rc)
|
||||
return ib
|
||||
}
|
||||
|
||||
func (s *ICEBind) Open(uport uint16) ([]wgConn.ReceiveFunc, uint16, error) {
|
||||
func (s *ICEBind) Open(uport uint16) ([]wgbackend.ReceiveFunc, uint16, error) {
|
||||
s.closedChanMu.Lock()
|
||||
defer s.closedChanMu.Unlock()
|
||||
|
||||
@@ -190,7 +190,7 @@ func (b *ICEBind) ReceiveFromEndpoint(ctx context.Context, ep *Endpoint, buf []b
|
||||
}
|
||||
}
|
||||
|
||||
func (b *ICEBind) Send(bufs [][]byte, ep wgConn.Endpoint) error {
|
||||
func (b *ICEBind) Send(bufs [][]byte, ep wgbackend.Endpoint) error {
|
||||
b.endpointsMu.Lock()
|
||||
conn, ok := b.endpoints[ep.DstIP()]
|
||||
b.endpointsMu.Unlock()
|
||||
@@ -206,7 +206,7 @@ func (b *ICEBind) Send(bufs [][]byte, ep wgConn.Endpoint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ICEBind) createReceiverFn(pc wgConn.BatchReader, conn *net.UDPConn, rxOffload bool, msgsPool *sync.Pool) wgConn.ReceiveFunc {
|
||||
func (s *ICEBind) createReceiverFn(pc wgbackend.BatchReader, conn *net.UDPConn, rxOffload bool, msgsPool *sync.Pool) wgbackend.ReceiveFunc {
|
||||
s.muUDPMux.Lock()
|
||||
defer s.muUDPMux.Unlock()
|
||||
|
||||
@@ -218,7 +218,7 @@ func (s *ICEBind) createReceiverFn(pc wgConn.BatchReader, conn *net.UDPConn, rxO
|
||||
}
|
||||
s.createOrUpdateMux()
|
||||
|
||||
return func(bufs [][]byte, sizes []int, eps []wgConn.Endpoint) (n int, err error) {
|
||||
return func(bufs [][]byte, sizes []int, eps []wgbackend.Endpoint) (n int, err error) {
|
||||
msgs := getMessages(msgsPool)
|
||||
for i := range bufs {
|
||||
(*msgs)[i].Buffers[0] = bufs[i]
|
||||
@@ -229,13 +229,13 @@ func (s *ICEBind) createReceiverFn(pc wgConn.BatchReader, conn *net.UDPConn, rxO
|
||||
var numMsgs int
|
||||
if runtime.GOOS == "linux" || runtime.GOOS == "android" {
|
||||
if rxOffload {
|
||||
readAt := len(*msgs) - (wgConn.IdealBatchSize / wgConn.UdpSegmentMaxDatagrams)
|
||||
readAt := len(*msgs) - (wgbackend.IdealBatchSize / wgbackend.UdpSegmentMaxDatagrams)
|
||||
//nolint:staticcheck
|
||||
_, err = pc.ReadBatch((*msgs)[readAt:], 0)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
numMsgs, err = wgConn.SplitCoalescedMessages(*msgs, readAt, wgConn.GetGSOSize)
|
||||
numMsgs, err = wgbackend.SplitCoalescedMessages(*msgs, readAt, wgbackend.GetGSOSize)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -278,8 +278,8 @@ func (s *ICEBind) createReceiverFn(pc wgConn.BatchReader, conn *net.UDPConn, rxO
|
||||
s.activityRecorder.record(addrPort)
|
||||
}
|
||||
|
||||
ep := &wgConn.StdNetEndpoint{AddrPort: addrPort} // TODO: remove allocation
|
||||
wgConn.GetSrcFromControl(msg.OOB[:msg.NN], ep)
|
||||
ep := &wgbackend.StdNetEndpoint{AddrPort: addrPort} // TODO: remove allocation
|
||||
wgbackend.GetSrcFromControl(msg.OOB[:msg.NN], ep)
|
||||
eps[i] = ep
|
||||
}
|
||||
return numMsgs, nil
|
||||
@@ -363,7 +363,7 @@ func (s *ICEBind) parseSTUNMessage(raw []byte) (*stun.Message, error) {
|
||||
|
||||
// receiveRelayed is a receive function that is used to receive packets from the relayed connection and forward to the
|
||||
// WireGuard. Critical part is do not block if the Closed() has been called.
|
||||
func (c *ICEBind) receiveRelayed(buffs [][]byte, sizes []int, eps []wgConn.Endpoint) (int, error) {
|
||||
func (c *ICEBind) receiveRelayed(buffs [][]byte, sizes []int, eps []wgbackend.Endpoint) (int, error) {
|
||||
closedChan := c.currentClosedChan()
|
||||
|
||||
select {
|
||||
@@ -375,7 +375,7 @@ func (c *ICEBind) receiveRelayed(buffs [][]byte, sizes []int, eps []wgConn.Endpo
|
||||
}
|
||||
copy(buffs[0], msg.Buffer)
|
||||
sizes[0] = len(msg.Buffer)
|
||||
eps[0] = wgConn.Endpoint(msg.Endpoint)
|
||||
eps[0] = wgbackend.Endpoint(msg.Endpoint)
|
||||
|
||||
if isTransportPkg(buffs, sizes[0]) {
|
||||
if ep, ok := eps[0].(*Endpoint); ok {
|
||||
|
||||
@@ -6,7 +6,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
wgConn "golang.zx2c4.com/wireguard/conn"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/wgbackend"
|
||||
)
|
||||
|
||||
// startReceivers runs every receive function the way wireguard-go's device
|
||||
@@ -14,7 +15,7 @@ import (
|
||||
// calling Bind.Close, closeBindLocked waits on exactly that WaitGroup while
|
||||
// holding device.net, so a receive function that never returns wedges the
|
||||
// device and every goroutine that needs the same lock.
|
||||
func startReceivers(fns []wgConn.ReceiveFunc) *sync.WaitGroup {
|
||||
func startReceivers(fns []wgbackend.ReceiveFunc) *sync.WaitGroup {
|
||||
wg, _ := startReceiversEntered(fns)
|
||||
return wg
|
||||
}
|
||||
@@ -23,18 +24,18 @@ func startReceivers(fns []wgConn.ReceiveFunc) *sync.WaitGroup {
|
||||
// function has been called at least once. A receive function that has been
|
||||
// entered is either inside its blocking receive or about to be, which is a
|
||||
// stronger signal to synchronise on than a bare sleep.
|
||||
func startReceiversEntered(fns []wgConn.ReceiveFunc) (*sync.WaitGroup, <-chan struct{}) {
|
||||
func startReceiversEntered(fns []wgbackend.ReceiveFunc) (*sync.WaitGroup, <-chan struct{}) {
|
||||
var wg sync.WaitGroup
|
||||
var entered sync.WaitGroup
|
||||
wg.Add(len(fns))
|
||||
entered.Add(len(fns))
|
||||
|
||||
for i := range fns {
|
||||
go func(fn wgConn.ReceiveFunc) {
|
||||
go func(fn wgbackend.ReceiveFunc) {
|
||||
defer wg.Done()
|
||||
buffs := [][]byte{make([]byte, 1500)}
|
||||
sizes := make([]int, 1)
|
||||
eps := make([]wgConn.Endpoint, 1)
|
||||
eps := make([]wgbackend.Endpoint, 1)
|
||||
first := true
|
||||
for {
|
||||
if first {
|
||||
|
||||
@@ -2,12 +2,15 @@ package bind
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pion/transport/v3"
|
||||
"github.com/pion/transport/v3/stdnet"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -15,6 +18,7 @@ import (
|
||||
"golang.org/x/net/ipv6"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
"github.com/netbirdio/netbird/client/iface/wgbackend"
|
||||
)
|
||||
|
||||
func TestICEBind_CreatesReceiverForBothIPv4AndIPv6(t *testing.T) {
|
||||
@@ -326,3 +330,86 @@ func listenUDP(t *testing.T, network, addr string) *net.UDPConn {
|
||||
require.NoError(t, err)
|
||||
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 := wgbackend.CreateNetTUN([]netip.Addr{netip.MustParseAddr("100.64.0.1")}, nil, 1280)
|
||||
require.NoError(t, err)
|
||||
|
||||
var dev *wgbackend.Device
|
||||
n := newCountingNet()
|
||||
n.before = func() {
|
||||
_ = dev.IpcGetOperation(io.Discard)
|
||||
}
|
||||
iceBind := NewICEBind(n, testAddress(), 1280)
|
||||
dev = wgbackend.NewDevice(tunDev, iceBind, wgbackend.NewLogger(wgbackend.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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,15 @@ import (
|
||||
"sync"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.zx2c4.com/wireguard/conn"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/udpmux"
|
||||
"github.com/netbirdio/netbird/client/iface/wgbackend"
|
||||
)
|
||||
|
||||
// RelayBindJS is a conn.Bind implementation for WebAssembly environments.
|
||||
// Do not limit to build only js, because we want to be able to run tests
|
||||
type RelayBindJS struct {
|
||||
*conn.StdNetBind
|
||||
*wgbackend.StdNetBind
|
||||
|
||||
recvChan chan recvMessage
|
||||
endpoints map[netip.Addr]net.Conn
|
||||
@@ -34,12 +34,12 @@ func NewRelayBindJS() *RelayBindJS {
|
||||
}
|
||||
|
||||
// Open creates a receive function for handling relay packets in WASM.
|
||||
func (s *RelayBindJS) Open(uport uint16) ([]conn.ReceiveFunc, uint16, error) {
|
||||
func (s *RelayBindJS) Open(uport uint16) ([]wgbackend.ReceiveFunc, uint16, error) {
|
||||
log.Debugf("Open: creating receive function for port %d", uport)
|
||||
|
||||
s.ctx, s.cancel = context.WithCancel(context.Background())
|
||||
|
||||
receiveFn := func(bufs [][]byte, sizes []int, eps []conn.Endpoint) (int, error) {
|
||||
receiveFn := func(bufs [][]byte, sizes []int, eps []wgbackend.Endpoint) (int, error) {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return 0, net.ErrClosed
|
||||
@@ -49,13 +49,13 @@ func (s *RelayBindJS) Open(uport uint16) ([]conn.ReceiveFunc, uint16, error) {
|
||||
}
|
||||
copy(bufs[0], msg.Buffer)
|
||||
sizes[0] = len(msg.Buffer)
|
||||
eps[0] = conn.Endpoint(msg.Endpoint)
|
||||
eps[0] = wgbackend.Endpoint(msg.Endpoint)
|
||||
return 1, nil
|
||||
}
|
||||
}
|
||||
|
||||
log.Debugf("Open: receive function created, returning port %d", uport)
|
||||
return []conn.ReceiveFunc{receiveFn}, uport, nil
|
||||
return []wgbackend.ReceiveFunc{receiveFn}, uport, nil
|
||||
}
|
||||
|
||||
func (s *RelayBindJS) Close() error {
|
||||
@@ -78,7 +78,7 @@ func (s *RelayBindJS) ReceiveFromEndpoint(ctx context.Context, ep *Endpoint, buf
|
||||
}
|
||||
|
||||
// Send forwards packets through the relay connection for WASM.
|
||||
func (s *RelayBindJS) Send(bufs [][]byte, ep conn.Endpoint) error {
|
||||
func (s *RelayBindJS) Send(bufs [][]byte, ep wgbackend.Endpoint) error {
|
||||
if ep == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/net/ipv4"
|
||||
wgConn "golang.zx2c4.com/wireguard/conn"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/wgbackend"
|
||||
)
|
||||
|
||||
// magicCookieBytes is the STUN magic cookie as it appears on the wire. In a
|
||||
@@ -165,7 +166,7 @@ func TestReceiveFn_ClearsSizeOfConsumedPacket(t *testing.T) {
|
||||
// A leftover size from an earlier read, which is what makes the missing reset
|
||||
// observable.
|
||||
sizes := []int{148}
|
||||
eps := make([]wgConn.Endpoint, 1)
|
||||
eps := make([]wgbackend.Endpoint, 1)
|
||||
|
||||
n, err := recvFn(bufs, sizes, eps)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
//go:build (linux && !android) || freebsd
|
||||
|
||||
package configurer
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
// allowedIPStore mirrors the allowed IPs configured on each peer of a device.
|
||||
//
|
||||
// A configurer is the only writer of its device's peer set, so the mirror is authoritative
|
||||
// by construction. It spares the paths that have to rewrite one peer's allowed IPs a full
|
||||
// device dump just to recover prefixes the process already configured itself.
|
||||
//
|
||||
// An allowed IP belongs to exactly one peer: configuring a prefix on a peer takes it away
|
||||
// from whichever peer held it before, and the configurer leaves that handover to the device
|
||||
// rather than removing the prefix from the previous holder itself. The store tracks the
|
||||
// owner of each prefix and performs the same handover, so rewriting one peer's list never
|
||||
// takes a prefix back from the peer that owns it now.
|
||||
//
|
||||
// Its own lock guards the map alone, not the device write it accompanies. Consistency
|
||||
// between the two rests on the caller serializing every configurer call, which WGIface
|
||||
// does with its mutex; two unserialized writers would interleave a device write with the
|
||||
// record of a different one.
|
||||
//
|
||||
// An operator reconfiguring the device out of band, through `wg set` or the UAPI socket,
|
||||
// is the one way the mirror can still go stale. A peer missing from it falls back to the
|
||||
// device, which reseats that peer's prefixes and their ownership; a peer that is present
|
||||
// does not, so one recorded from empty while the device already held prefixes keeps only
|
||||
// what was recorded, and the next endpoint removal drops the rest.
|
||||
type allowedIPStore struct {
|
||||
mu sync.RWMutex
|
||||
peers map[wgtypes.Key][]netip.Prefix
|
||||
owners map[netip.Prefix]wgtypes.Key
|
||||
}
|
||||
|
||||
func newAllowedIPStore() *allowedIPStore {
|
||||
return &allowedIPStore{
|
||||
peers: make(map[wgtypes.Key][]netip.Prefix),
|
||||
owners: make(map[netip.Prefix]wgtypes.Key),
|
||||
}
|
||||
}
|
||||
|
||||
// get returns the prefixes recorded for a peer, and whether the peer is known at all.
|
||||
// The caller receives a copy and may retain or modify it freely.
|
||||
func (s *allowedIPStore) get(key wgtypes.Key) ([]netip.Prefix, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
prefixes, ok := s.peers[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return slices.Clone(prefixes), true
|
||||
}
|
||||
|
||||
// set replaces the prefixes recorded for a peer.
|
||||
func (s *allowedIPStore) set(key wgtypes.Key, prefixes []netip.Prefix) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
k := key
|
||||
s.releaseLocked(k)
|
||||
|
||||
normalized := normalizePrefixes(prefixes)
|
||||
for _, prefix := range normalized {
|
||||
s.claimLocked(k, prefix)
|
||||
}
|
||||
s.peers[k] = normalized
|
||||
}
|
||||
|
||||
// add records prefixes on a peer without dropping the ones already there, matching the
|
||||
// union semantics of a peer update that does not replace its allowed IPs. It records the
|
||||
// peer if it is not known yet, so it belongs to the operations that create a peer on the
|
||||
// device rather than to the update-only ones.
|
||||
func (s *allowedIPStore) add(key wgtypes.Key, prefixes []netip.Prefix) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.mergeLocked(key, prefixes)
|
||||
}
|
||||
|
||||
// addExisting is add for an update-only device operation. Such an operation is a silent
|
||||
// no-op when the peer is absent, so recording a peer here would leave the store claiming
|
||||
// prefixes the device never took, and the peer would then be recreated by the next endpoint
|
||||
// removal, stealing those allowed IPs from the peer that legitimately holds them.
|
||||
func (s *allowedIPStore) addExisting(key wgtypes.Key, prefixes []netip.Prefix) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
k := key
|
||||
if _, ok := s.peers[k]; !ok {
|
||||
return
|
||||
}
|
||||
s.mergeLocked(k, prefixes)
|
||||
}
|
||||
|
||||
// ensure records a peer with no prefixes unless it is already known. A device operation
|
||||
// that is not update-only creates the peer when it is absent, so it has to be recorded even
|
||||
// when it configures nothing else; otherwise the peer exists on the device while the store
|
||||
// treats it as unknown, and a prefix later handed over to it is not accounted for.
|
||||
func (s *allowedIPStore) ensure(key wgtypes.Key) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
k := key
|
||||
if _, ok := s.peers[k]; !ok {
|
||||
s.peers[k] = nil
|
||||
}
|
||||
}
|
||||
|
||||
// forget drops every prefix recorded for a peer.
|
||||
func (s *allowedIPStore) forget(key wgtypes.Key) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
k := key
|
||||
s.releaseLocked(k)
|
||||
delete(s.peers, k)
|
||||
}
|
||||
|
||||
// reset drops every peer, mirroring a device reconfiguration that replaces the peer set.
|
||||
func (s *allowedIPStore) reset() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.peers = make(map[wgtypes.Key][]netip.Prefix)
|
||||
s.owners = make(map[netip.Prefix]wgtypes.Key)
|
||||
}
|
||||
|
||||
// mergeLocked unions normalized prefixes into a peer and transfers their ownership.
|
||||
// The caller must hold s.mu for writing.
|
||||
func (s *allowedIPStore) mergeLocked(k wgtypes.Key, prefixes []netip.Prefix) {
|
||||
merged := s.peers[k]
|
||||
for _, prefix := range prefixes {
|
||||
prefix = normalizePrefix(prefix)
|
||||
s.claimLocked(k, prefix)
|
||||
if !slices.Contains(merged, prefix) {
|
||||
merged = append(merged, prefix)
|
||||
}
|
||||
}
|
||||
s.peers[k] = merged
|
||||
}
|
||||
|
||||
// claimLocked hands a prefix over to a peer, taking it from its previous owner the way the
|
||||
// device does when the same prefix is configured on a second peer.
|
||||
func (s *allowedIPStore) claimLocked(k wgtypes.Key, prefix netip.Prefix) {
|
||||
if owner, ok := s.owners[prefix]; ok && owner != k {
|
||||
s.peers[owner] = slices.DeleteFunc(s.peers[owner], func(p netip.Prefix) bool {
|
||||
return p == prefix
|
||||
})
|
||||
}
|
||||
s.owners[prefix] = k
|
||||
}
|
||||
|
||||
// releaseLocked drops a peer's claim on every prefix it currently holds.
|
||||
func (s *allowedIPStore) releaseLocked(k wgtypes.Key) {
|
||||
for _, prefix := range s.peers[k] {
|
||||
if s.owners[prefix] == k {
|
||||
delete(s.owners, prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func ipNetsToPrefixes(ipNets []net.IPNet) []netip.Prefix {
|
||||
prefixes := make([]netip.Prefix, 0, len(ipNets))
|
||||
for _, ipNet := range ipNets {
|
||||
addr, ok := netip.AddrFromSlice(ipNet.IP)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
ones, maskBits := ipNet.Mask.Size()
|
||||
// A device may report a v4 prefix as a v4-mapped address. Align the address form with
|
||||
// the mask rather than unmapping on sight: a 32 bit mask always describes v4, while a
|
||||
// 128 bit mask describes v4 only when it covers the mapped prefix, so a genuine v6
|
||||
// prefix inside the mapped range stays v6 instead of being dropped as invalid.
|
||||
if addr.Is4In6() {
|
||||
switch {
|
||||
case maskBits == 32:
|
||||
addr = addr.Unmap()
|
||||
case maskBits == 128 && ones >= 96:
|
||||
addr, ones = addr.Unmap(), ones-96
|
||||
}
|
||||
}
|
||||
|
||||
prefix := netip.PrefixFrom(addr, ones)
|
||||
if !prefix.IsValid() {
|
||||
continue
|
||||
}
|
||||
prefixes = append(prefixes, prefix.Masked())
|
||||
}
|
||||
return prefixes
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//go:build (linux && !android) || freebsd
|
||||
|
||||
package configurer
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
// The store keys on the parsed key, so the tests use two distinct ones rather than names.
|
||||
var (
|
||||
testPeer = wgtypes.Key{1}
|
||||
otherPeer = wgtypes.Key{2}
|
||||
)
|
||||
|
||||
func TestAllowedIPStoreUnknownPeer(t *testing.T) {
|
||||
s := newAllowedIPStore()
|
||||
|
||||
prefixes, ok := s.get(testPeer)
|
||||
assert.False(t, ok, "an unconfigured peer must be reported as unknown, not as one without prefixes")
|
||||
assert.Nil(t, prefixes, "an unknown peer has no prefixes")
|
||||
}
|
||||
|
||||
func TestAllowedIPStoreAddUnions(t *testing.T) {
|
||||
s := newAllowedIPStore()
|
||||
overlay := netip.MustParsePrefix("100.64.0.1/32")
|
||||
routed := netip.MustParsePrefix("10.20.0.0/16")
|
||||
|
||||
s.set(testPeer, []netip.Prefix{overlay})
|
||||
// A peer update does not replace allowed IPs, and a repeated prefix must not be doubled.
|
||||
s.add(testPeer, []netip.Prefix{overlay, routed})
|
||||
|
||||
prefixes, ok := s.get(testPeer)
|
||||
require.True(t, ok, "peer must be known after set")
|
||||
assert.Equal(t, []netip.Prefix{overlay, routed}, prefixes, "add must union rather than replace")
|
||||
}
|
||||
|
||||
func TestAllowedIPStoreGetReturnsCopy(t *testing.T) {
|
||||
s := newAllowedIPStore()
|
||||
overlay := netip.MustParsePrefix("100.64.0.1/32")
|
||||
s.set(testPeer, []netip.Prefix{overlay})
|
||||
|
||||
prefixes, ok := s.get(testPeer)
|
||||
require.True(t, ok, "peer must be known after set")
|
||||
prefixes[0] = netip.MustParsePrefix("0.0.0.0/0")
|
||||
|
||||
stored, _ := s.get(testPeer)
|
||||
assert.Equal(t, []netip.Prefix{overlay}, stored, "a caller mutating the returned slice must not corrupt the store")
|
||||
}
|
||||
|
||||
func TestAllowedIPStoreForgetAndReset(t *testing.T) {
|
||||
s := newAllowedIPStore()
|
||||
s.set(testPeer, []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")})
|
||||
s.set(otherPeer, []netip.Prefix{netip.MustParsePrefix("100.64.0.2/32")})
|
||||
|
||||
s.forget(testPeer)
|
||||
_, ok := s.get(testPeer)
|
||||
assert.False(t, ok, "a forgotten peer must be unknown")
|
||||
_, ok = s.get(otherPeer)
|
||||
assert.True(t, ok, "forgetting one peer must not touch the others")
|
||||
|
||||
s.reset()
|
||||
_, ok = s.get(otherPeer)
|
||||
assert.False(t, ok, "reset must drop every peer")
|
||||
}
|
||||
|
||||
func TestIPNetsToPrefixes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ipNet net.IPNet
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "v4",
|
||||
ipNet: net.IPNet{IP: net.IP{10, 20, 0, 0}, Mask: net.CIDRMask(16, 32)},
|
||||
want: "10.20.0.0/16",
|
||||
},
|
||||
{
|
||||
name: "v4 mapped under a 128 bit mask",
|
||||
ipNet: net.IPNet{IP: net.ParseIP("10.20.0.0"), Mask: net.CIDRMask(112, 128)},
|
||||
want: "10.20.0.0/16",
|
||||
},
|
||||
{
|
||||
name: "v6",
|
||||
ipNet: net.IPNet{IP: net.ParseIP("fd00::"), Mask: net.CIDRMask(64, 128)},
|
||||
want: "fd00::/64",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := ipNetsToPrefixes([]net.IPNet{tc.ipNet})
|
||||
require.Len(t, got, 1, "the address must be converted, not dropped")
|
||||
assert.Equal(t, tc.want, got[0].String(), "converted prefix")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPNetsToPrefixesRoundTrip(t *testing.T) {
|
||||
prefixes := []netip.Prefix{
|
||||
netip.MustParsePrefix("100.64.0.1/32"),
|
||||
netip.MustParsePrefix("10.20.0.0/16"),
|
||||
netip.MustParsePrefix("fd00::/64"),
|
||||
}
|
||||
|
||||
assert.Equal(t, prefixes, ipNetsToPrefixes(prefixesToIPNets(prefixes)),
|
||||
"prefixes handed to a device must come back unchanged")
|
||||
}
|
||||
|
||||
func TestAllowedIPStoreNormalizesMappedPrefixes(t *testing.T) {
|
||||
s := newAllowedIPStore()
|
||||
v4 := netip.MustParsePrefix("10.20.0.0/16")
|
||||
mapped := netip.PrefixFrom(netip.AddrFrom16(v4.Addr().As16()), 112)
|
||||
|
||||
s.set(testPeer, []netip.Prefix{mapped})
|
||||
// A v4 rule only matches a v4-mapped address once it has been unmapped, so the store must
|
||||
// hold the plain form and recognise the two spellings as the same prefix.
|
||||
s.add(testPeer, []netip.Prefix{v4})
|
||||
|
||||
prefixes, ok := s.get(testPeer)
|
||||
require.True(t, ok, "peer must be known after set")
|
||||
assert.Equal(t, []netip.Prefix{v4}, prefixes, "a mapped prefix must be stored unmapped and not duplicated")
|
||||
}
|
||||
|
||||
func TestAllowedIPStoreAddExistingDoesNotCreate(t *testing.T) {
|
||||
s := newAllowedIPStore()
|
||||
routed := netip.MustParsePrefix("10.20.0.0/16")
|
||||
|
||||
// An update-only device operation on an absent peer is a silent no-op, so nothing may be
|
||||
// recorded for a peer the store does not already know.
|
||||
s.addExisting(testPeer, []netip.Prefix{routed})
|
||||
_, ok := s.get(testPeer)
|
||||
assert.False(t, ok, "addExisting must not record an unknown peer")
|
||||
|
||||
overlay := netip.MustParsePrefix("100.64.0.1/32")
|
||||
s.set(testPeer, []netip.Prefix{overlay})
|
||||
s.addExisting(testPeer, []netip.Prefix{routed})
|
||||
|
||||
prefixes, _ := s.get(testPeer)
|
||||
assert.Equal(t, []netip.Prefix{overlay, routed}, prefixes, "addExisting must union onto a known peer")
|
||||
}
|
||||
|
||||
func TestAllowedIPStoreHandsPrefixOverToTheNewOwner(t *testing.T) {
|
||||
s := newAllowedIPStore()
|
||||
routed := netip.MustParsePrefix("10.20.0.0/16")
|
||||
other := otherPeer
|
||||
|
||||
s.set(testPeer, []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32"), routed})
|
||||
s.set(other, []netip.Prefix{netip.MustParsePrefix("100.64.0.2/32")})
|
||||
|
||||
// The device takes an allowed IP away from its previous holder when it is configured on
|
||||
// another peer, so the store must do the same rather than list it under both.
|
||||
s.addExisting(other, []netip.Prefix{routed})
|
||||
|
||||
previous, _ := s.get(testPeer)
|
||||
assert.NotContains(t, previous, routed, "the previous owner must lose the prefix")
|
||||
current, _ := s.get(other)
|
||||
assert.Contains(t, current, routed, "the new owner must hold the prefix")
|
||||
}
|
||||
|
||||
func TestAllowedIPStoreForgetReleasesOwnership(t *testing.T) {
|
||||
s := newAllowedIPStore()
|
||||
routed := netip.MustParsePrefix("10.20.0.0/16")
|
||||
|
||||
s.set(testPeer, []netip.Prefix{routed})
|
||||
s.forget(testPeer)
|
||||
s.set(otherPeer, []netip.Prefix{routed})
|
||||
|
||||
// A forgotten peer must not be resurrected as a key in the peer map by a later claim.
|
||||
_, ok := s.get(testPeer)
|
||||
assert.False(t, ok, "the forgotten peer must stay unknown")
|
||||
current, _ := s.get(otherPeer)
|
||||
assert.Equal(t, []netip.Prefix{routed}, current, "the new owner must hold the prefix")
|
||||
}
|
||||
|
||||
func TestIPNetsToPrefixesKeepsV6InTheMappedRange(t *testing.T) {
|
||||
// ::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.
|
||||
got := ipNetsToPrefixes([]net.IPNet{{
|
||||
IP: net.ParseIP("::ffff:0:0"),
|
||||
Mask: net.CIDRMask(64, 128),
|
||||
}})
|
||||
|
||||
require.Len(t, got, 1, "the prefix must be converted, not dropped")
|
||||
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")
|
||||
}
|
||||
@@ -3,29 +3,48 @@ package configurer
|
||||
import (
|
||||
"net"
|
||||
"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
|
||||
// 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
|
||||
// address as v4 while taking the length from its 16 byte mask and so turns
|
||||
// ::ffff:10.1.2.3/64 into 10.1.2.3/0 — an allowed IP matching every v4 address.
|
||||
func prefixesToIPNets(prefixes []netip.Prefix) []net.IPNet {
|
||||
ipNets := make([]net.IPNet, len(prefixes))
|
||||
for i, prefix := range prefixes {
|
||||
normalized := normalizePrefix(prefix)
|
||||
ipNets[i] = net.IPNet{
|
||||
IP: prefix.Addr().AsSlice(), // Convert netip.Addr to net.IP
|
||||
Mask: net.CIDRMask(prefix.Bits(), prefix.Addr().BitLen()), // Create subnet mask
|
||||
IP: normalized.Addr().AsSlice(),
|
||||
Mask: net.CIDRMask(normalized.Bits(), normalized.Addr().BitLen()),
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user