//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 }