Skip to content

Commit f740f50

Browse files
authored
Merge pull request #49 from stacklok/fix/agent-forwarding-half-close
Fix SSH agent forwarding goroutine leak via missing half-close
2 parents 3667a34 + 4d567a5 commit f740f50

2 files changed

Lines changed: 158 additions & 0 deletions

File tree

guest/sshd/session.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,7 @@ func (s *Server) proxyAgentConnection(conn *ssh.ServerConn, unixConn net.Conn) {
513513
go func() {
514514
defer wg.Done()
515515
_, _ = io.Copy(channel, unixConn)
516+
_ = channel.CloseWrite() // Signal host that guest disconnected from agent socket.
516517
}()
517518
go func() {
518519
defer wg.Done()

guest/sshd/session_agent_test.go

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//go:build linux
5+
6+
package sshd
7+
8+
import (
9+
"crypto/ecdsa"
10+
"crypto/elliptic"
11+
"crypto/rand"
12+
"log/slog"
13+
"os"
14+
"strings"
15+
"testing"
16+
"time"
17+
18+
"github.com/stretchr/testify/assert"
19+
"github.com/stretchr/testify/require"
20+
"golang.org/x/crypto/ssh"
21+
"golang.org/x/crypto/ssh/agent"
22+
)
23+
24+
// startAgentServer creates a test SSH server with agent forwarding
25+
// enabled and returns (server, address, client-signer).
26+
func startAgentServer(t *testing.T) (string, ssh.Signer) {
27+
t.Helper()
28+
29+
signer, pubKey := generateTestKeyPair(t)
30+
_, addr := startTestServerWithConfig(t, Config{
31+
Port: 0,
32+
AuthorizedKeys: []ssh.PublicKey{pubKey},
33+
Env: []string{"PATH=/usr/bin:/bin"},
34+
DefaultUID: uint32(os.Getuid()),
35+
DefaultGID: uint32(os.Getgid()),
36+
DefaultUser: "testuser",
37+
DefaultHome: os.TempDir(),
38+
DefaultShell: "/bin/sh",
39+
AgentForwarding: true,
40+
Logger: slog.Default(),
41+
})
42+
43+
return addr, signer
44+
}
45+
46+
// setupClientAgentForwarding registers a host-side agent forwarding
47+
// handler on the SSH client that serves a test key. This mirrors what
48+
// brood-box does: for each incoming "auth-agent@openssh.com" channel,
49+
// it creates a keyring with a test key and serves the agent protocol.
50+
func setupClientAgentForwarding(t *testing.T, client *ssh.Client) {
51+
t.Helper()
52+
53+
// Generate a fresh key for the agent keyring.
54+
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
55+
require.NoError(t, err)
56+
57+
keyring := agent.NewKeyring()
58+
err = keyring.Add(agent.AddedKey{PrivateKey: privKey})
59+
require.NoError(t, err)
60+
61+
// Register the handler for incoming agent channels from the server.
62+
chans := client.HandleChannelOpen("auth-agent@openssh.com")
63+
require.NotNil(t, chans, "HandleChannelOpen should not return nil")
64+
65+
go func() {
66+
for ch := range chans {
67+
channel, reqs, err := ch.Accept()
68+
if err != nil {
69+
continue
70+
}
71+
go ssh.DiscardRequests(reqs)
72+
go func() {
73+
defer func() { _ = channel.Close() }()
74+
_ = agent.ServeAgent(keyring, channel)
75+
}()
76+
}
77+
}()
78+
}
79+
80+
// TestAgentProxyCleanup verifies that the agent forwarding proxy
81+
// goroutines and semaphore slots are released after each command.
82+
// Before the CloseWrite fix, proxy goroutines would leak and after
83+
// maxAgentConns (8) connections the semaphore would be exhausted,
84+
// causing all subsequent agent queries to fail.
85+
func TestAgentProxyCleanup(t *testing.T) {
86+
t.Parallel()
87+
88+
addr, signer := startAgentServer(t)
89+
client := dialSSH(t, addr, signer)
90+
setupClientAgentForwarding(t, client)
91+
92+
// Run more sessions than maxAgentConns (8) to verify semaphore
93+
// slots are released. Stay within maxChannelsPerConn (10) to
94+
// avoid the per-connection channel limit. Without CloseWrite,
95+
// proxy goroutines would leak and exhaust the semaphore.
96+
for i := range maxChannelsPerConn {
97+
session, err := client.NewSession()
98+
require.NoError(t, err, "session %d", i)
99+
100+
err = agent.RequestAgentForwarding(session)
101+
require.NoError(t, err, "request agent forwarding %d", i)
102+
103+
// The command connects to SSH_AUTH_SOCK (triggering the proxy)
104+
// and then exits. The proxy must clean up afterwards.
105+
output, err := session.CombinedOutput(
106+
`test -S "$SSH_AUTH_SOCK" && echo "socket_ok" || echo "no_socket"`,
107+
)
108+
require.NoError(t, err, "command %d", i)
109+
assert.Equal(t, "socket_ok", strings.TrimSpace(string(output)),
110+
"agent socket should exist on iteration %d", i)
111+
112+
_ = session.Close()
113+
114+
// Brief pause to let proxy goroutines complete cleanup.
115+
time.Sleep(50 * time.Millisecond)
116+
}
117+
}
118+
119+
// TestAgentProxyConcurrent verifies that multiple concurrent agent
120+
// connections within the maxAgentConns limit work correctly and all
121+
// clean up properly.
122+
func TestAgentProxyConcurrent(t *testing.T) {
123+
t.Parallel()
124+
125+
addr, signer := startAgentServer(t)
126+
client := dialSSH(t, addr, signer)
127+
setupClientAgentForwarding(t, client)
128+
129+
// Run a command that makes several agent socket connections in
130+
// quick succession within a single session.
131+
session, err := client.NewSession()
132+
require.NoError(t, err)
133+
defer func() { _ = session.Close() }()
134+
135+
err = agent.RequestAgentForwarding(session)
136+
require.NoError(t, err)
137+
138+
// Each `test -S` triggers a stat, not a socket connection. Instead,
139+
// use a loop that actually connects to the agent socket via ssh-add -l
140+
// equivalent. We'll do multiple nc/socat-free socket tests.
141+
output, err := session.CombinedOutput(`
142+
success=0
143+
fail=0
144+
for i in $(seq 1 5); do
145+
if test -S "$SSH_AUTH_SOCK"; then
146+
success=$((success + 1))
147+
else
148+
fail=$((fail + 1))
149+
fi
150+
done
151+
echo "success=$success fail=$fail"
152+
`)
153+
require.NoError(t, err)
154+
155+
result := strings.TrimSpace(string(output))
156+
assert.Equal(t, "success=5 fail=0", result)
157+
}

0 commit comments

Comments
 (0)