Skip to content

Commit 6afbb3b

Browse files
JAORMXclaude
andcommitted
Add SSH agent forwarding support to guest SSH server
Enable the SSH server to accept auth-agent-req@openssh.com requests and create per-session Unix domain agent sockets. When a guest process connects to the socket, the server opens an auth-agent@openssh.com channel back to the client and pipes bidirectionally. Controlled via WithSSHAgentForwarding boot option (default: off). Includes concurrency limits (max 8 agent connections), explicit socket permissions (0700/0600), and proper goroutine lifecycle tracking. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d935a41 commit 6afbb3b

5 files changed

Lines changed: 345 additions & 27 deletions

File tree

guest/boot/boot.go

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -103,16 +103,17 @@ func Run(logger *slog.Logger, opts ...Option) (shutdown func(), err error) {
103103

104104
// 9. Start SSH server.
105105
sshdCfg := sshd.Config{
106-
Port: cfg.sshPort,
107-
AuthorizedKeys: authorizedKeys,
108-
Env: envVars,
109-
DefaultUID: cfg.userUID,
110-
DefaultGID: cfg.userGID,
111-
DefaultUser: cfg.userName,
112-
DefaultHome: cfg.userHome,
113-
DefaultShell: cfg.userShell,
114-
DefaultWorkDir: cfg.workspaceMountPoint,
115-
Logger: logger,
106+
Port: cfg.sshPort,
107+
AuthorizedKeys: authorizedKeys,
108+
Env: envVars,
109+
DefaultUID: cfg.userUID,
110+
DefaultGID: cfg.userGID,
111+
DefaultUser: cfg.userName,
112+
DefaultHome: cfg.userHome,
113+
DefaultShell: cfg.userShell,
114+
DefaultWorkDir: cfg.workspaceMountPoint,
115+
AgentForwarding: cfg.sshAgentForwarding,
116+
Logger: logger,
116117
}
117118
srv, err := sshd.New(sshdCfg)
118119
if err != nil {

guest/boot/options.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type config struct {
3030
userUID uint32
3131
userGID uint32
3232
lockdownRoot bool
33+
sshAgentForwarding bool
3334
}
3435

3536
func defaultConfig() *config {
@@ -96,3 +97,10 @@ func WithUser(name, home, shell string, uid, gid uint32) Option {
9697
func WithLockdownRoot(enabled bool) Option {
9798
return optionFunc(func(c *config) { c.lockdownRoot = enabled })
9899
}
100+
101+
// WithSSHAgentForwarding controls whether the SSH server supports
102+
// agent forwarding. When enabled and the client requests it, the
103+
// server creates a Unix socket and sets SSH_AUTH_SOCK for the session.
104+
func WithSSHAgentForwarding(enabled bool) Option {
105+
return optionFunc(func(c *config) { c.sshAgentForwarding = enabled })
106+
}

guest/sshd/server.go

Lines changed: 73 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -61,18 +61,25 @@ type Config struct {
6161
// empty or the directory does not exist, DefaultHome is used instead.
6262
DefaultWorkDir string
6363

64+
// AgentForwarding enables SSH agent forwarding support. When true,
65+
// the server accepts auth-agent-req@openssh.com requests and creates
66+
// per-session agent sockets.
67+
AgentForwarding bool
68+
6469
// Logger is the structured logger. If nil, slog.Default() is used.
6570
Logger *slog.Logger
6671
}
6772

6873
// Server is an embedded SSH server designed to run inside a guest VM.
6974
type Server struct {
70-
cfg Config
71-
sshCfg *ssh.ServerConfig
72-
listener net.Listener
73-
wg sync.WaitGroup
74-
quit chan struct{}
75-
logger *slog.Logger
75+
cfg Config
76+
sshCfg *ssh.ServerConfig
77+
listener net.Listener
78+
wg sync.WaitGroup
79+
quit chan struct{}
80+
logger *slog.Logger
81+
agentFwdMu sync.Mutex
82+
agentFwd map[*ssh.ServerConn]bool
7683
}
7784

7885
// New creates a new Server with an ephemeral ECDSA P-256 host key. It
@@ -119,10 +126,11 @@ func New(cfg Config) (*Server, error) {
119126
sshCfg.AddHostKey(signer)
120127

121128
return &Server{
122-
cfg: cfg,
123-
sshCfg: sshCfg,
124-
quit: make(chan struct{}),
125-
logger: logger,
129+
cfg: cfg,
130+
sshCfg: sshCfg,
131+
quit: make(chan struct{}),
132+
logger: logger,
133+
agentFwd: make(map[*ssh.ServerConn]bool),
126134
}, nil
127135
}
128136

@@ -206,6 +214,7 @@ func (s *Server) handleConnection(netConn net.Conn) {
206214
return
207215
}
208216
defer func() { _ = srvConn.Close() }()
217+
defer s.setAgentForwarding(srvConn, false)
209218

210219
// Clear the deadline after a successful handshake.
211220
if err := netConn.SetDeadline(time.Time{}); err != nil {
@@ -218,8 +227,8 @@ func (s *Server) handleConnection(netConn net.Conn) {
218227
"remote", srvConn.RemoteAddr(),
219228
)
220229

221-
// Discard all global requests (keepalive, etc.).
222-
go ssh.DiscardRequests(reqs)
230+
// Handle global requests (agent forwarding, keepalive, etc.).
231+
go s.handleGlobalRequests(reqs, srvConn)
223232

224233
channelCount := 0
225234
for newCh := range chans {
@@ -249,7 +258,58 @@ func (s *Server) handleConnection(netConn net.Conn) {
249258
s.wg.Add(1)
250259
go func() {
251260
defer s.wg.Done()
252-
s.handleSession(ch, requests)
261+
s.handleSession(ch, requests, srvConn)
253262
}()
254263
}
255264
}
265+
266+
// handleGlobalRequests processes connection-level SSH requests.
267+
// It handles agent forwarding requests when enabled and discards
268+
// all other global requests.
269+
func (s *Server) handleGlobalRequests(reqs <-chan *ssh.Request, conn *ssh.ServerConn) {
270+
for req := range reqs {
271+
switch req.Type {
272+
case "auth-agent-req@openssh.com":
273+
if s.cfg.AgentForwarding {
274+
s.setAgentForwarding(conn, true)
275+
s.logger.Info("agent forwarding enabled",
276+
"remote", conn.RemoteAddr(),
277+
)
278+
if req.WantReply {
279+
_ = req.Reply(true, nil)
280+
}
281+
} else {
282+
s.logger.Debug("agent forwarding rejected (disabled)",
283+
"remote", conn.RemoteAddr(),
284+
)
285+
if req.WantReply {
286+
_ = req.Reply(false, nil)
287+
}
288+
}
289+
default:
290+
if req.WantReply {
291+
_ = req.Reply(false, nil)
292+
}
293+
}
294+
}
295+
}
296+
297+
// setAgentForwarding records or clears the agent-forwarding state for
298+
// the given connection.
299+
func (s *Server) setAgentForwarding(conn *ssh.ServerConn, enabled bool) {
300+
s.agentFwdMu.Lock()
301+
defer s.agentFwdMu.Unlock()
302+
if enabled {
303+
s.agentFwd[conn] = true
304+
} else {
305+
delete(s.agentFwd, conn)
306+
}
307+
}
308+
309+
// isAgentForwarding reports whether agent forwarding has been enabled
310+
// for the given connection.
311+
func (s *Server) isAgentForwarding(conn *ssh.ServerConn) bool {
312+
s.agentFwdMu.Lock()
313+
defer s.agentFwdMu.Unlock()
314+
return s.agentFwd[conn]
315+
}

guest/sshd/server_test.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,3 +307,123 @@ func TestDefaultWorkDirFallback(t *testing.T) {
307307

308308
assert.Equal(t, expected, strings.TrimSpace(string(output)))
309309
}
310+
311+
func TestAgentForwardingDisabled(t *testing.T) {
312+
t.Parallel()
313+
314+
signer, pubKey := generateTestKeyPair(t)
315+
_, addr := startTestServerWithConfig(t, Config{
316+
Port: 0,
317+
AuthorizedKeys: []ssh.PublicKey{pubKey},
318+
Env: []string{"PATH=/usr/bin:/bin"},
319+
DefaultUID: uint32(os.Getuid()),
320+
DefaultGID: uint32(os.Getgid()),
321+
DefaultUser: "testuser",
322+
DefaultHome: os.TempDir(),
323+
DefaultShell: "/bin/sh",
324+
AgentForwarding: false,
325+
Logger: slog.Default(),
326+
})
327+
328+
client := dialSSH(t, addr, signer)
329+
330+
// Request agent forwarding — should be rejected.
331+
ok, _, err := client.SendRequest("auth-agent-req@openssh.com", true, nil)
332+
require.NoError(t, err)
333+
assert.False(t, ok, "agent forwarding should be rejected when disabled")
334+
}
335+
336+
func TestAgentForwardingEnabled(t *testing.T) {
337+
t.Parallel()
338+
339+
signer, pubKey := generateTestKeyPair(t)
340+
_, addr := startTestServerWithConfig(t, Config{
341+
Port: 0,
342+
AuthorizedKeys: []ssh.PublicKey{pubKey},
343+
Env: []string{"PATH=/usr/bin:/bin"},
344+
DefaultUID: uint32(os.Getuid()),
345+
DefaultGID: uint32(os.Getgid()),
346+
DefaultUser: "testuser",
347+
DefaultHome: os.TempDir(),
348+
DefaultShell: "/bin/sh",
349+
AgentForwarding: true,
350+
Logger: slog.Default(),
351+
})
352+
353+
client := dialSSH(t, addr, signer)
354+
355+
// Request agent forwarding — should be accepted.
356+
ok, _, err := client.SendRequest("auth-agent-req@openssh.com", true, nil)
357+
require.NoError(t, err)
358+
assert.True(t, ok, "agent forwarding should be accepted when enabled")
359+
}
360+
361+
func TestAgentSocketCreated(t *testing.T) {
362+
t.Parallel()
363+
364+
signer, pubKey := generateTestKeyPair(t)
365+
_, addr := startTestServerWithConfig(t, Config{
366+
Port: 0,
367+
AuthorizedKeys: []ssh.PublicKey{pubKey},
368+
Env: []string{"PATH=/usr/bin:/bin"},
369+
DefaultUID: uint32(os.Getuid()),
370+
DefaultGID: uint32(os.Getgid()),
371+
DefaultUser: "testuser",
372+
DefaultHome: os.TempDir(),
373+
DefaultShell: "/bin/sh",
374+
AgentForwarding: true,
375+
Logger: slog.Default(),
376+
})
377+
378+
client := dialSSH(t, addr, signer)
379+
380+
// Request agent forwarding.
381+
ok, _, err := client.SendRequest("auth-agent-req@openssh.com", true, nil)
382+
require.NoError(t, err)
383+
require.True(t, ok)
384+
385+
// Run a command that checks if SSH_AUTH_SOCK is set.
386+
session, err := client.NewSession()
387+
require.NoError(t, err)
388+
defer func() { _ = session.Close() }()
389+
390+
output, err := session.CombinedOutput("echo $SSH_AUTH_SOCK")
391+
require.NoError(t, err)
392+
393+
sockPath := strings.TrimSpace(string(output))
394+
assert.NotEmpty(t, sockPath, "SSH_AUTH_SOCK should be set when agent forwarding is enabled")
395+
assert.Contains(t, sockPath, "/tmp/ssh-", "agent socket should be in /tmp/ssh-*")
396+
}
397+
398+
func TestNoSocketWithoutForwardingRequest(t *testing.T) {
399+
t.Parallel()
400+
401+
signer, pubKey := generateTestKeyPair(t)
402+
_, addr := startTestServerWithConfig(t, Config{
403+
Port: 0,
404+
AuthorizedKeys: []ssh.PublicKey{pubKey},
405+
Env: []string{"PATH=/usr/bin:/bin"},
406+
DefaultUID: uint32(os.Getuid()),
407+
DefaultGID: uint32(os.Getgid()),
408+
DefaultUser: "testuser",
409+
DefaultHome: os.TempDir(),
410+
DefaultShell: "/bin/sh",
411+
AgentForwarding: true,
412+
Logger: slog.Default(),
413+
})
414+
415+
client := dialSSH(t, addr, signer)
416+
417+
// Do NOT request agent forwarding.
418+
419+
// Run a command that checks if SSH_AUTH_SOCK is set.
420+
session, err := client.NewSession()
421+
require.NoError(t, err)
422+
defer func() { _ = session.Close() }()
423+
424+
output, err := session.CombinedOutput("echo ${SSH_AUTH_SOCK:-unset}")
425+
require.NoError(t, err)
426+
427+
result := strings.TrimSpace(string(output))
428+
assert.Equal(t, "unset", result, "SSH_AUTH_SOCK should not be set without forwarding request")
429+
}

0 commit comments

Comments
 (0)