mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-12 09:02:25 -04: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>
424 lines
11 KiB
Go
424 lines
11 KiB
Go
//go:build privileged
|
|
|
|
package proxy
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"io"
|
|
"math/big"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"runtime"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
cryptossh "golang.org/x/crypto/ssh"
|
|
|
|
nbssh "github.com/netbirdio/netbird/client/ssh"
|
|
sshauth "github.com/netbirdio/netbird/client/ssh/auth"
|
|
"github.com/netbirdio/netbird/client/ssh/server"
|
|
"github.com/netbirdio/netbird/client/ssh/testutil"
|
|
nbjwt "github.com/netbirdio/netbird/shared/auth/jwt"
|
|
sshuserhash "github.com/netbirdio/netbird/shared/sshauth"
|
|
)
|
|
|
|
func (m *mockDaemon) setJWTToken(token string) {
|
|
m.impl.jwtToken = token
|
|
}
|
|
|
|
func TestSSHProxy_Connect(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
// TODO: Windows test times out - user switching and command execution tested on Linux
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Skipping on Windows - covered by Linux tests")
|
|
}
|
|
|
|
const (
|
|
issuer = "https://test-issuer.example.com"
|
|
audience = "test-audience"
|
|
)
|
|
|
|
jwksServer, privateKey, jwksURL := setupJWKSServer(t)
|
|
defer jwksServer.Close()
|
|
|
|
hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
|
|
require.NoError(t, err)
|
|
hostPubKey, err := nbssh.GeneratePublicKey(hostKey)
|
|
require.NoError(t, err)
|
|
|
|
serverConfig := &server.Config{
|
|
HostKeyPEM: hostKey,
|
|
JWT: &server.JWTConfig{
|
|
Issuer: issuer,
|
|
Audiences: []string{audience},
|
|
KeysLocation: jwksURL,
|
|
},
|
|
}
|
|
sshServer := server.New(serverConfig)
|
|
sshServer.SetAllowRootLogin(true)
|
|
|
|
// Configure SSH authorization for the test user
|
|
testUsername := testutil.GetTestUsername(t)
|
|
testJWTUser := "test-username"
|
|
testUserHash, err := sshuserhash.HashUserID(testJWTUser)
|
|
require.NoError(t, err)
|
|
|
|
authConfig := &sshauth.Config{
|
|
UserIDClaim: sshauth.DefaultUserIDClaim,
|
|
AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash},
|
|
MachineUsers: map[string][]uint32{
|
|
testUsername: {0}, // Index 0 in AuthorizedUsers
|
|
},
|
|
}
|
|
sshServer.UpdateSSHAuth(authConfig)
|
|
|
|
sshServerAddr := server.StartTestServer(t, sshServer)
|
|
defer func() { _ = sshServer.Stop() }()
|
|
|
|
mockDaemon := startMockDaemon(t)
|
|
defer mockDaemon.stop()
|
|
|
|
host, portStr, err := net.SplitHostPort(sshServerAddr)
|
|
require.NoError(t, err)
|
|
port, err := strconv.Atoi(portStr)
|
|
require.NoError(t, err)
|
|
|
|
mockDaemon.setHostKey(host, hostPubKey)
|
|
|
|
validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser)
|
|
mockDaemon.setJWTToken(validToken)
|
|
|
|
proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil)
|
|
require.NoError(t, err)
|
|
|
|
clientConn, proxyConn := net.Pipe()
|
|
defer func() { _ = clientConn.Close() }()
|
|
|
|
origStdin := os.Stdin
|
|
origStdout := os.Stdout
|
|
defer func() {
|
|
os.Stdin = origStdin
|
|
os.Stdout = origStdout
|
|
}()
|
|
|
|
stdinReader, stdinWriter, err := os.Pipe()
|
|
require.NoError(t, err)
|
|
stdoutReader, stdoutWriter, err := os.Pipe()
|
|
require.NoError(t, err)
|
|
|
|
os.Stdin = stdinReader
|
|
os.Stdout = stdoutWriter
|
|
|
|
go func() {
|
|
_, _ = io.Copy(stdinWriter, proxyConn)
|
|
}()
|
|
go func() {
|
|
_, _ = io.Copy(proxyConn, stdoutReader)
|
|
}()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
connectErrCh := make(chan error, 1)
|
|
go func() {
|
|
connectErrCh <- proxyInstance.Connect(ctx)
|
|
}()
|
|
|
|
sshConfig := &cryptossh.ClientConfig{
|
|
User: testutil.GetTestUsername(t),
|
|
Auth: []cryptossh.AuthMethod{},
|
|
HostKeyCallback: cryptossh.InsecureIgnoreHostKey(),
|
|
Timeout: 3 * time.Second,
|
|
}
|
|
|
|
sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig)
|
|
require.NoError(t, err, "Should connect to proxy server")
|
|
defer func() { _ = sshClientConn.Close() }()
|
|
|
|
sshClient := cryptossh.NewClient(sshClientConn, chans, reqs)
|
|
|
|
session, err := sshClient.NewSession()
|
|
require.NoError(t, err, "Should create session through full proxy to backend")
|
|
|
|
outputCh := make(chan []byte, 1)
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
output, err := session.Output("echo hello-from-proxy")
|
|
outputCh <- output
|
|
errCh <- err
|
|
}()
|
|
|
|
select {
|
|
case output := <-outputCh:
|
|
err := <-errCh
|
|
require.NoError(t, err, "Command should execute successfully through proxy")
|
|
assert.Contains(t, string(output), "hello-from-proxy", "Should receive command output through proxy")
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("Command execution timed out")
|
|
}
|
|
|
|
_ = session.Close()
|
|
_ = sshClient.Close()
|
|
_ = clientConn.Close()
|
|
cancel()
|
|
}
|
|
|
|
// TestSSHProxy_CommandQuoting verifies that the proxy preserves shell quoting
|
|
// when forwarding commands to the backend. This is critical for tools like
|
|
// Ansible that send commands such as:
|
|
//
|
|
// /bin/sh -c '( umask 77 && mkdir -p ... ) && sleep 0'
|
|
//
|
|
// The single quotes must be preserved so the backend shell receives the
|
|
// subshell expression as a single argument to -c.
|
|
func TestSSHProxy_CommandQuoting(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
sshClient, cleanup := setupProxySSHClient(t)
|
|
defer cleanup()
|
|
|
|
// These commands simulate what the SSH protocol delivers as exec payloads.
|
|
// When a user types: ssh host '/bin/sh -c "( echo hello )"'
|
|
// the local shell strips the outer single quotes, and the SSH exec request
|
|
// contains the raw string: /bin/sh -c "( echo hello )"
|
|
//
|
|
// The proxy must forward this string verbatim. Using session.Command()
|
|
// (shlex.Split + strings.Join) strips the inner double quotes, breaking
|
|
// the command on the backend.
|
|
tests := []struct {
|
|
name string
|
|
command string
|
|
expect string
|
|
}{
|
|
{
|
|
name: "subshell_in_double_quotes",
|
|
command: `/bin/sh -c "( echo from-subshell ) && echo outer"`,
|
|
expect: "from-subshell\nouter\n",
|
|
},
|
|
{
|
|
name: "printf_with_special_chars",
|
|
command: `/bin/sh -c "printf '%s\n' 'hello world'"`,
|
|
expect: "hello world\n",
|
|
},
|
|
{
|
|
name: "nested_command_substitution",
|
|
command: `/bin/sh -c "echo $(echo nested)"`,
|
|
expect: "nested\n",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
session, err := sshClient.NewSession()
|
|
require.NoError(t, err)
|
|
defer func() { _ = session.Close() }()
|
|
|
|
var stderrBuf bytes.Buffer
|
|
session.Stderr = &stderrBuf
|
|
|
|
outputCh := make(chan []byte, 1)
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
output, err := session.Output(tc.command)
|
|
outputCh <- output
|
|
errCh <- err
|
|
}()
|
|
|
|
select {
|
|
case output := <-outputCh:
|
|
err := <-errCh
|
|
if stderrBuf.Len() > 0 {
|
|
t.Logf("stderr: %s", stderrBuf.String())
|
|
}
|
|
require.NoError(t, err, "command should succeed: %s", tc.command)
|
|
assert.Equal(t, tc.expect, string(output), "output mismatch for: %s", tc.command)
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatalf("command timed out: %s", tc.command)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// setupProxySSHClient creates a full proxy test environment and returns
|
|
// an SSH client connected through the proxy to a backend NetBird SSH server.
|
|
func setupProxySSHClient(t *testing.T) (*cryptossh.Client, func()) {
|
|
t.Helper()
|
|
|
|
const (
|
|
issuer = "https://test-issuer.example.com"
|
|
audience = "test-audience"
|
|
)
|
|
|
|
jwksServer, privateKey, jwksURL := setupJWKSServer(t)
|
|
|
|
hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
|
|
require.NoError(t, err)
|
|
hostPubKey, err := nbssh.GeneratePublicKey(hostKey)
|
|
require.NoError(t, err)
|
|
|
|
serverConfig := &server.Config{
|
|
HostKeyPEM: hostKey,
|
|
JWT: &server.JWTConfig{
|
|
Issuer: issuer,
|
|
Audiences: []string{audience},
|
|
KeysLocation: jwksURL,
|
|
},
|
|
}
|
|
sshServer := server.New(serverConfig)
|
|
sshServer.SetAllowRootLogin(true)
|
|
|
|
testUsername := testutil.GetTestUsername(t)
|
|
testJWTUser := "test-username"
|
|
testUserHash, err := sshuserhash.HashUserID(testJWTUser)
|
|
require.NoError(t, err)
|
|
|
|
authConfig := &sshauth.Config{
|
|
UserIDClaim: sshauth.DefaultUserIDClaim,
|
|
AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash},
|
|
MachineUsers: map[string][]uint32{
|
|
testUsername: {0},
|
|
},
|
|
}
|
|
sshServer.UpdateSSHAuth(authConfig)
|
|
|
|
sshServerAddr := server.StartTestServer(t, sshServer)
|
|
|
|
mockDaemon := startMockDaemon(t)
|
|
|
|
host, portStr, err := net.SplitHostPort(sshServerAddr)
|
|
require.NoError(t, err)
|
|
port, err := strconv.Atoi(portStr)
|
|
require.NoError(t, err)
|
|
|
|
mockDaemon.setHostKey(host, hostPubKey)
|
|
|
|
validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser)
|
|
mockDaemon.setJWTToken(validToken)
|
|
|
|
proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil)
|
|
require.NoError(t, err)
|
|
|
|
origStdin := os.Stdin
|
|
origStdout := os.Stdout
|
|
|
|
stdinReader, stdinWriter, err := os.Pipe()
|
|
require.NoError(t, err)
|
|
stdoutReader, stdoutWriter, err := os.Pipe()
|
|
require.NoError(t, err)
|
|
|
|
os.Stdin = stdinReader
|
|
os.Stdout = stdoutWriter
|
|
|
|
clientConn, proxyConn := net.Pipe()
|
|
|
|
go func() { _, _ = io.Copy(stdinWriter, proxyConn) }()
|
|
go func() { _, _ = io.Copy(proxyConn, stdoutReader) }()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
|
|
go func() {
|
|
_ = proxyInstance.Connect(ctx)
|
|
}()
|
|
|
|
sshConfig := &cryptossh.ClientConfig{
|
|
User: testutil.GetTestUsername(t),
|
|
Auth: []cryptossh.AuthMethod{},
|
|
HostKeyCallback: cryptossh.InsecureIgnoreHostKey(),
|
|
Timeout: 5 * time.Second,
|
|
}
|
|
|
|
sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig)
|
|
require.NoError(t, err)
|
|
|
|
client := cryptossh.NewClient(sshClientConn, chans, reqs)
|
|
|
|
cleanupFn := func() {
|
|
_ = client.Close()
|
|
_ = clientConn.Close()
|
|
cancel()
|
|
os.Stdin = origStdin
|
|
os.Stdout = origStdout
|
|
_ = sshServer.Stop()
|
|
mockDaemon.stop()
|
|
jwksServer.Close()
|
|
}
|
|
|
|
return client, cleanupFn
|
|
}
|
|
|
|
func setupJWKSServer(t *testing.T) (*httptest.Server, *rsa.PrivateKey, string) {
|
|
t.Helper()
|
|
privateKey, jwksJSON := generateTestJWKS(t)
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if _, err := w.Write(jwksJSON); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
}))
|
|
|
|
return server, privateKey, server.URL
|
|
}
|
|
|
|
func generateTestJWKS(t *testing.T) (*rsa.PrivateKey, []byte) {
|
|
t.Helper()
|
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
require.NoError(t, err)
|
|
|
|
publicKey := &privateKey.PublicKey
|
|
n := publicKey.N.Bytes()
|
|
e := publicKey.E
|
|
|
|
jwk := nbjwt.JSONWebKey{
|
|
Kty: "RSA",
|
|
Kid: "test-key-id",
|
|
Use: "sig",
|
|
N: base64.RawURLEncoding.EncodeToString(n),
|
|
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(e)).Bytes()),
|
|
}
|
|
|
|
jwks := nbjwt.Jwks{
|
|
Keys: []nbjwt.JSONWebKey{jwk},
|
|
}
|
|
|
|
jwksJSON, err := json.Marshal(jwks)
|
|
require.NoError(t, err)
|
|
|
|
return privateKey, jwksJSON
|
|
}
|
|
|
|
func generateValidJWT(t *testing.T, privateKey *rsa.PrivateKey, issuer, audience string, user string) string {
|
|
t.Helper()
|
|
claims := jwt.MapClaims{
|
|
"iss": issuer,
|
|
"aud": audience,
|
|
"sub": user,
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
"iat": time.Now().Unix(),
|
|
}
|
|
|
|
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
|
token.Header["kid"] = "test-key-id"
|
|
|
|
tokenString, err := token.SignedString(privateKey)
|
|
require.NoError(t, err)
|
|
|
|
return tokenString
|
|
}
|