From 0ddf01b8eb748248d7d609a96b7290d620bebeeb Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 30 Jul 2026 22:40:03 -0500 Subject: [PATCH 1/2] fix(ssh): wait for reverse-forward listener before running setup-gpg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setupGPGAgent started the gpg-agent socket's reverse forward in a background goroutine and immediately ran the remote setup-gpg command, which symlinks the container's gnupg socket to that forward's path. Nothing synchronized the two: setup-gpg could finish symlinking before the SSH global request binding the remote listener had actually been acknowledged, leaving the symlink pointing at a path nothing was listening on yet. Split ReversePortForward into ReverseListen (blocking bind) and RunReverseForward (the accept loop), and add startReverseForwardsAndWait, which binds every configured forward before returning so setup-gpg only runs once the listener is confirmed live. Note: this closes one real race, but is not sufficient on its own to fix "gpg: no gpg-agent running in this session" end to end — the container's ~/.gnupg/private-keys-v1.d never receives the secret-key shadow stubs GnuPG needs to recognize an agent-forwarded key, which is a separate, larger gap in the forwarding setup. --- cmd/workspace/ssh.go | 85 ++++++++++++++++++++++++++++++++++++++++++-- pkg/ssh/forward.go | 35 +++++++++++++++--- 2 files changed, 112 insertions(+), 8 deletions(-) diff --git a/cmd/workspace/ssh.go b/cmd/workspace/ssh.go index 447e62874..c884c5a65 100644 --- a/cmd/workspace/ssh.go +++ b/cmd/workspace/ssh.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "net" "os" "path" "strings" @@ -476,6 +477,84 @@ func (cmd *SSHCmd) reverseForwardPorts( return <-errChan } +type boundReverseForward struct { + portMapping string + mapping port.Mapping + listener net.Listener +} + +// startReverseForwardsAndWait blocks until every forward's listener is bound, +// unlike reverseForwardPorts which blocks for the forward's lifetime. +func (cmd *SSHCmd) startReverseForwardsAndWait( + ctx context.Context, + containerClient *ssh.Client, +) error { + timeout, err := cmd.forwardTimeout() + if err != nil { + return err + } + + bound, err := bindReverseForwards(containerClient, cmd.ReverseForwardPorts) + if err != nil { + return err + } + + for _, b := range bound { + log.Infof( + "Reverse forwarding local %s/%s to remote %s/%s", + b.mapping.Host.Protocol, + b.mapping.Host.Address, + b.mapping.Container.Protocol, + b.mapping.Container.Address, + ) + go runReverseForwardInBackground(ctx, containerClient, b, timeout) + } + + return nil +} + +func bindReverseForwards( + containerClient *ssh.Client, + portMappings []string, +) ([]boundReverseForward, error) { + var bound []boundReverseForward + for _, portMapping := range portMappings { + mapping, err := port.ParsePortSpec(portMapping) + if err != nil { + return nil, fmt.Errorf("parse port mapping: %w", err) + } + + listener, err := devssh.ReverseListen( + containerClient, + mapping.Host.Protocol, + mapping.Host.Address, + ) + if err != nil { + return nil, fmt.Errorf("listen for reverse forward %s: %w", portMapping, err) + } + bound = append(bound, boundReverseForward{portMapping, mapping, listener}) + } + return bound, nil +} + +func runReverseForwardInBackground( + ctx context.Context, + containerClient *ssh.Client, + b boundReverseForward, + timeout time.Duration, +) { + err := devssh.RunReverseForward(ctx, containerClient, devssh.ReverseForwardOpts{ + Listener: b.listener, + RemoteAddr: b.mapping.Host.Address, + LocalNetwork: b.mapping.Container.Protocol, + LocalAddr: b.mapping.Container.Address, + ExitAfterTimeout: timeout, + }) + if err != nil && !errors.Is(err, devssh.ErrIdleTimeout) && !errors.Is(err, io.EOF) { + log.Errorf("error forwarding %s: %v", b.portMapping, err) + } +} + func (cmd *SSHCmd) forwardPorts( ctx context.Context, containerClient *ssh.Client, @@ -782,9 +861,9 @@ func (cmd *SSHCmd) setupGPGAgent( gpgExtraSocketPath, ) - go func() { - log.Error(cmd.reverseForwardPorts(ctx, containerClient)) - }() + if err := cmd.startReverseForwardsAndWait(ctx, containerClient); err != nil { + return fmt.Errorf("start gpg-agent reverse forward: %w", err) + } writer := log.Writer(log.LevelInfo) defer func() { _ = writer.Close() }() diff --git a/pkg/ssh/forward.go b/pkg/ssh/forward.go index f87d3ce10..34176f63f 100644 --- a/pkg/ssh/forward.go +++ b/pkg/ssh/forward.go @@ -79,16 +79,41 @@ func ReversePortForward( remoteNetwork, remoteAddr, localNetwork, localAddr string, exitAfterTimeout time.Duration, ) error { - listener, err := client.Listen(remoteNetwork, remoteAddr) + listener, err := ReverseListen(client, remoteNetwork, remoteAddr) if err != nil { return err } - defer func() { _ = listener.Close() }() + return RunReverseForward(ctx, client, ReverseForwardOpts{ + Listener: listener, + RemoteAddr: remoteAddr, + LocalNetwork: localNetwork, + LocalAddr: localAddr, + ExitAfterTimeout: exitAfterTimeout, + }) +} +// ReverseListen binds the remote listener; pair with RunReverseForward. +func ReverseListen(client *ssh.Client, remoteNetwork, remoteAddr string) (net.Listener, error) { + return client.Listen(remoteNetwork, remoteAddr) +} + +// ReverseForwardOpts groups the parameters for RunReverseForward. +type ReverseForwardOpts struct { + Listener net.Listener + RemoteAddr string + LocalNetwork string + LocalAddr string + ExitAfterTimeout time.Duration +} + +// RunReverseForward runs the forwarding loop for a listener obtained via +// ReverseListen, closing it on return. +func RunReverseForward(ctx context.Context, client *ssh.Client, opts ReverseForwardOpts) error { + defer func() { _ = opts.Listener.Close() }() return portForwarding( - ctx, client, listener, - remoteAddr, localNetwork, localAddr, - exitAfterTimeout, reverseForward, + ctx, client, opts.Listener, + opts.RemoteAddr, opts.LocalNetwork, opts.LocalAddr, + opts.ExitAfterTimeout, reverseForward, ) } From f794851b86c2fefc65c4516afc6d5b1238c81df4 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 31 Jul 2026 00:29:19 -0500 Subject: [PATCH 2/2] fix(ssh): close already-bound listeners on partial bindReverseForwards failure If a later port mapping failed to parse or bind, earlier listeners in bound stayed open on the remote until the SSH transport closed, leaking the bind and breaking a retry against the same address. --- cmd/workspace/ssh.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cmd/workspace/ssh.go b/cmd/workspace/ssh.go index c884c5a65..918b1493a 100644 --- a/cmd/workspace/ssh.go +++ b/cmd/workspace/ssh.go @@ -518,9 +518,15 @@ func bindReverseForwards( portMappings []string, ) ([]boundReverseForward, error) { var bound []boundReverseForward + closeBound := func() { + for _, b := range bound { + _ = b.listener.Close() + } + } for _, portMapping := range portMappings { mapping, err := port.ParsePortSpec(portMapping) if err != nil { + closeBound() return nil, fmt.Errorf("parse port mapping: %w", err) } @@ -530,6 +536,7 @@ func bindReverseForwards( mapping.Host.Address, ) if err != nil { + closeBound() return nil, fmt.Errorf("listen for reverse forward %s: %w", portMapping, err) } bound = append(bound, boundReverseForward{portMapping, mapping, listener})