diff --git a/pkg/client/connector/sshcli.go b/pkg/client/connector/sshcli.go index ac997e4e..5d65c9bd 100644 --- a/pkg/client/connector/sshcli.go +++ b/pkg/client/connector/sshcli.go @@ -9,6 +9,8 @@ import ( "path/filepath" "strconv" "strings" + "sync" + "time" "github.com/docker/cli/cli/connhelper/commandconn" "github.com/psviderski/uncloud/internal/grpcversion" @@ -23,6 +25,10 @@ type SSHCLIConnector struct { config SSHConnectorConfig // Path to SSH control socket for connection reuse. controlSockPath string + // fwdCheckOnce ensures the TCP forwarding check runs only once. + fwdCheckOnce sync.Once + // fwdCheckErr caches the result of the TCP forwarding check. + fwdCheckErr error } func NewSSHCLIConnector(cfg *SSHConnectorConfig) *SSHCLIConnector { @@ -158,6 +164,9 @@ func (c *SSHCLIConnector) DialContext(ctx context.Context, network, address stri if network != "tcp" { return nil, fmt.Errorf("unsupported network type: %s", network) } + if err := c.CheckTCPForwarding(ctx); err != nil { + return nil, err + } args := append(c.buildSSHArgs(), "-W", address) conn, err := commandconn.New(ctx, "ssh", args...) @@ -168,6 +177,34 @@ func (c *SSHCLIConnector) DialContext(ctx context.Context, network, address stri return conn, nil } +// CheckTCPForwarding verifies that TCP forwarding is enabled on the remote SSH server. It probes the server once +// and caches the result for subsequent calls. Returns an error with actionable instructions if forwarding is disabled. +func (c *SSHCLIConnector) CheckTCPForwarding(ctx context.Context) error { + c.fwdCheckOnce.Do(func() { + // Probe TCP forwarding by requesting a forward to 127.0.0.1:1 (a port almost never in use). + // If forwarding is disabled, sshd rejects the channel. The error message differs depending on + // whether the connection goes through a ControlMaster mux or directly: + // - Multiplexed: "Session open refused by peer" + // - Direct: "administratively prohibited" + probeCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + args := append(c.buildSSHArgs(), "-W", "127.0.0.1:1") + probe := exec.CommandContext(probeCtx, "ssh", args...) + output, _ := probe.CombinedOutput() + outStr := string(output) + if strings.Contains(outStr, "administratively prohibited") || + strings.Contains(outStr, "Session open refused by peer") { + c.fwdCheckErr = fmt.Errorf( + "SSH TCP forwarding appears to be disabled on '%s': ensure 'AllowTcpForwarding yes' is set "+ + "in /etc/ssh/sshd_config on the remote machine and restart sshd (sudo systemctl restart ssh)", + c.config.Destination(), + ) + } + }) + return c.fwdCheckErr +} + func (c *SSHCLIConnector) Close() error { // Individual connections are managed by gRPC and closed when the gRPC connection closes. // The SSH control socket may persist for connection reuse across CLI invocations.