mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-18 04:29:54 +00:00
* [client] categorize root/system-mutating tests behind a privileged build tag Tests that need root or mutate host state (nftables/iptables/DNS, TUN/WireGuard interfaces, routes, eBPF, SSH/service install) are now gated behind a //go:build privileged tag. The default `go test ./client/...` runs as a non-root user with no sudo and leaves host networking untouched; mixed files were split so pure-logic tests stay in the default suite. A self-hosting ory/dockertest/v4 harness (client/testutil/privileged) runs the privileged suite inside a --privileged --cap-add=NET_ADMIN container via `make test-privileged`; a DOCKER_CI=true guard skips the spawn when already inside the container. Added `make test-unit` for the host-safe run. * [client] add PRIV_RUN/PRIV_PKGS filters to the privileged test harness The dockertest harness now reads two optional env vars when building the in-container `go test` command: PRIV_RUN adds a -run test-name filter and PRIV_PKGS overrides the package list. Both empty reproduce the full privileged suite, so CI and `make test-privileged` behave as before. Lets a developer run a single privileged test in the container, e.g.: PRIV_RUN=TestNftablesManager PRIV_PKGS=./client/firewall/nftables/... make test-privileged * [client] fix unused-helper lint after the privileged test split Splitting privileged tests into *_privileged_test.go left their shared helpers in the untagged files, so in the default (no-tag) build they had no callers and golangci-lint flagged them as unused. Moved the privileged-only helpers into the privileged files next to their callers (generateDummyHandler; createEngine/startSignal/startManagement/getConnectedPeers/ getPeers + kaep/kasp; (*mockDaemon).setJWTToken). Annotated the shared routing-test fixtures that must stay untagged for cross-platform compilation with //nolint:unused (systemops_bsd expected* vars, ensureIPv6DefaultRoute on bsd/windows, loopbackIfaceWindows), matching the existing linux variant. * [client] fix privileged test CI failures and run the harness on macOS The host-safe unit run dropped sudo but two privileged test groups were never tagged, and the Docker privileged job silently never ran the suite: - Gate the ssh/server PrivilegeDropper command-construction tests behind the privileged tag (they require root to target a different UID); split them into executor_unix_privileged_test.go. - Tag sharedsock raw-socket tests privileged (need CAP_NET_RAW). - Fix the Docker job command: nested single quotes around the build tags closed the sh -c wrapper early, dropping the go list package set and the privileged tag, so go test ran on the empty repo root. Use double quotes. Make the self-hosting harness usable from a dev Mac: - Build it on darwin as well as linux; it only drives Docker. - Resolve the active docker context endpoint into DOCKER_HOST when the default /var/run/docker.sock is absent (Docker Desktop, Colima, OrbStack). - Rename the misspelled containerGoModache constant to containerGoModCache. * Update client/internal/engine_privileged_test.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update client/internal/routemanager/systemops/systemops_linux_test.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update client/internal/routemanager/systemops/systemops_windows_test.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update client/server/server_privileged_test.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * [ci] Run privileged-tagged tests on darwin, windows and freebsd The privileged build tag split moved root/system-mutating tests behind //go:build privileged, but only the linux docker job was given the tag. The native darwin (sudo), windows (PsExec64 -s) and freebsd VM runners already have the required privileges, so add the privileged tag there too to keep CI running the same set of tests as before the split. * [ci] Exclude dockertest harness from the darwin privileged run The privileged tag now compiles client/testutil/privileged on darwin, whose TestRunPrivilegedSuiteInDocker spawns a container the macOS runner has no Docker for. Exclude the harness package from the darwin list, matching the linux job, so the privileged tests run in place without a container spawn. --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
253 lines
7.1 KiB
Go
253 lines
7.1 KiB
Go
//go:build windows && privileged
|
|
|
|
package systemops
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/netip"
|
|
"os/exec"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
nbnet "github.com/netbirdio/netbird/client/net"
|
|
)
|
|
|
|
var (
|
|
expectedExternalInt = "Ethernet1"
|
|
expectedVPNint = "wgtest0"
|
|
)
|
|
|
|
type RouteInfo struct {
|
|
NextHop string `json:"nexthop"`
|
|
InterfaceAlias string `json:"interfacealias"`
|
|
RouteMetric int `json:"routemetric"`
|
|
}
|
|
|
|
type FindNetRouteOutput struct {
|
|
IPAddress string `json:"IPAddress"`
|
|
InterfaceIndex int `json:"InterfaceIndex"`
|
|
InterfaceAlias string `json:"InterfaceAlias"`
|
|
AddressFamily int `json:"AddressFamily"`
|
|
NextHop string `json:"Nexthop"`
|
|
DestinationPrefix string `json:"DestinationPrefix"`
|
|
}
|
|
|
|
type testCase struct {
|
|
name string
|
|
destination string
|
|
expectedSourceIP string
|
|
expectedDestPrefix string
|
|
expectedNextHop string
|
|
expectedInterface string
|
|
dialer dialer
|
|
}
|
|
|
|
var testCases = []testCase{
|
|
{
|
|
name: "To external host without custom dialer via vpn",
|
|
destination: "192.0.2.1:53",
|
|
expectedSourceIP: "100.64.0.1",
|
|
expectedDestPrefix: "128.0.0.0/1",
|
|
expectedNextHop: "0.0.0.0",
|
|
expectedInterface: expectedVPNint,
|
|
dialer: &net.Dialer{},
|
|
},
|
|
{
|
|
name: "To external host with custom dialer via physical interface",
|
|
destination: "192.0.2.1:53",
|
|
expectedDestPrefix: "192.0.2.1/32",
|
|
expectedInterface: expectedExternalInt,
|
|
dialer: nbnet.NewDialer(),
|
|
},
|
|
|
|
{
|
|
name: "To duplicate internal route with custom dialer via physical interface",
|
|
destination: "10.0.0.2:53",
|
|
expectedDestPrefix: "10.0.0.2/32",
|
|
expectedInterface: expectedExternalInt,
|
|
dialer: nbnet.NewDialer(),
|
|
},
|
|
|
|
{
|
|
name: "To unique vpn route with custom dialer via physical interface",
|
|
destination: "172.16.0.2:53",
|
|
expectedDestPrefix: "172.16.0.2/32",
|
|
expectedInterface: expectedExternalInt,
|
|
dialer: nbnet.NewDialer(),
|
|
},
|
|
{
|
|
name: "To unique vpn route without custom dialer via vpn",
|
|
destination: "172.16.0.2:53",
|
|
expectedSourceIP: "100.64.0.1",
|
|
expectedDestPrefix: "172.16.0.0/12",
|
|
expectedNextHop: "0.0.0.0",
|
|
expectedInterface: expectedVPNint,
|
|
dialer: &net.Dialer{},
|
|
},
|
|
|
|
{
|
|
name: "To more specific route without custom dialer via vpn interface",
|
|
destination: "10.10.0.2:53",
|
|
expectedSourceIP: "100.64.0.1",
|
|
expectedDestPrefix: "10.10.0.0/24",
|
|
expectedNextHop: "0.0.0.0",
|
|
expectedInterface: expectedVPNint,
|
|
dialer: &net.Dialer{},
|
|
},
|
|
}
|
|
|
|
func TestRouting(t *testing.T) {
|
|
log.SetLevel(log.DebugLevel)
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
setupTestEnv(t)
|
|
|
|
route, err := fetchOriginalGateway()
|
|
require.NoError(t, err, "Failed to fetch original gateway")
|
|
ip, err := fetchInterfaceIP(route.InterfaceAlias)
|
|
require.NoError(t, err, "Failed to fetch interface IP")
|
|
|
|
output := testRoute(t, tc.destination, tc.dialer)
|
|
if tc.expectedInterface == expectedExternalInt {
|
|
verifyOutput(t, output, ip, tc.expectedDestPrefix, route.NextHop, route.InterfaceAlias)
|
|
} else {
|
|
verifyOutput(t, output, tc.expectedSourceIP, tc.expectedDestPrefix, tc.expectedNextHop, tc.expectedInterface)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// fetchInterfaceIP fetches the IPv4 address of the specified interface.
|
|
func fetchInterfaceIP(interfaceAlias string) (string, error) {
|
|
script := fmt.Sprintf(`Get-NetIPAddress -InterfaceAlias "%s" | Where-Object AddressFamily -eq 2 | Select-Object -ExpandProperty IPAddress`, interfaceAlias)
|
|
out, err := exec.Command("powershell", "-Command", script).Output()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to execute Get-NetIPAddress: %w", err)
|
|
}
|
|
|
|
ip := strings.TrimSpace(string(out))
|
|
return ip, nil
|
|
}
|
|
|
|
func testRoute(t *testing.T, destination string, dialer dialer) *FindNetRouteOutput {
|
|
t.Helper()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
|
defer cancel()
|
|
|
|
conn, err := dialer.DialContext(ctx, "udp", destination)
|
|
require.NoError(t, err, "Failed to dial destination")
|
|
defer func() {
|
|
err := conn.Close()
|
|
assert.NoError(t, err, "Failed to close connection")
|
|
}()
|
|
|
|
host, _, err := net.SplitHostPort(destination)
|
|
require.NoError(t, err)
|
|
|
|
script := fmt.Sprintf(`Find-NetRoute -RemoteIPAddress "%s" | Select-Object -Property IPAddress, InterfaceIndex, InterfaceAlias, AddressFamily, Nexthop, DestinationPrefix | ConvertTo-Json`, host)
|
|
|
|
out, err := exec.Command("powershell", "-Command", script).Output()
|
|
require.NoError(t, err, "Failed to execute Find-NetRoute")
|
|
|
|
var outputs []FindNetRouteOutput
|
|
err = json.Unmarshal(out, &outputs)
|
|
require.NoError(t, err, "Failed to parse JSON outputs from Find-NetRoute")
|
|
|
|
require.Greater(t, len(outputs), 0, "No route found for destination")
|
|
combinedOutput := combineOutputs(outputs)
|
|
|
|
return combinedOutput
|
|
}
|
|
|
|
func fetchOriginalGateway() (*RouteInfo, error) {
|
|
cmd := exec.Command("powershell", "-Command", "Get-NetRoute -DestinationPrefix 0.0.0.0/0 | Select-Object Nexthop, RouteMetric, InterfaceAlias | ConvertTo-Json")
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to execute Get-NetRoute: %w", err)
|
|
}
|
|
|
|
var routeInfo RouteInfo
|
|
err = json.Unmarshal(output, &routeInfo)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse JSON output: %w", err)
|
|
}
|
|
|
|
return &routeInfo, nil
|
|
}
|
|
|
|
func verifyOutput(t *testing.T, output *FindNetRouteOutput, sourceIP, destPrefix, nextHop, intf string) {
|
|
t.Helper()
|
|
|
|
assert.Equal(t, sourceIP, output.IPAddress, "Source IP mismatch")
|
|
assert.Equal(t, destPrefix, output.DestinationPrefix, "Destination prefix mismatch")
|
|
assert.Equal(t, nextHop, output.NextHop, "Next hop mismatch")
|
|
assert.Equal(t, intf, output.InterfaceAlias, "Interface mismatch")
|
|
}
|
|
|
|
func combineOutputs(outputs []FindNetRouteOutput) *FindNetRouteOutput {
|
|
var combined FindNetRouteOutput
|
|
|
|
for _, output := range outputs {
|
|
if output.IPAddress != "" {
|
|
combined.IPAddress = output.IPAddress
|
|
}
|
|
if output.InterfaceIndex != 0 {
|
|
combined.InterfaceIndex = output.InterfaceIndex
|
|
}
|
|
if output.InterfaceAlias != "" {
|
|
combined.InterfaceAlias = output.InterfaceAlias
|
|
}
|
|
if output.AddressFamily != 0 {
|
|
combined.AddressFamily = output.AddressFamily
|
|
}
|
|
if output.NextHop != "" {
|
|
combined.NextHop = output.NextHop
|
|
}
|
|
if output.DestinationPrefix != "" {
|
|
combined.DestinationPrefix = output.DestinationPrefix
|
|
}
|
|
}
|
|
|
|
return &combined
|
|
}
|
|
|
|
func setupDummyInterfacesAndRoutes(t *testing.T) {
|
|
t.Helper()
|
|
|
|
addDummyRoute(t, "10.0.0.0/8")
|
|
}
|
|
|
|
func addDummyRoute(t *testing.T, dstCIDR string) {
|
|
t.Helper()
|
|
|
|
prefix, err := netip.ParsePrefix(dstCIDR)
|
|
if err != nil {
|
|
t.Fatalf("Failed to parse destination CIDR %s: %v", dstCIDR, err)
|
|
}
|
|
|
|
nexthop := Nexthop{
|
|
Intf: &net.Interface{Index: 1},
|
|
}
|
|
|
|
if err = addRoute(prefix, nexthop); err != nil {
|
|
t.Fatalf("Failed to add dummy route: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
err := deleteRoute(prefix, nexthop)
|
|
if err != nil {
|
|
t.Logf("Failed to remove dummy route: %v", err)
|
|
}
|
|
})
|
|
}
|