mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 11:03:34 +00:00
fix: probe for checking TCP forwarding over SSH (fixes #321)
This commit is contained in:
@@ -25,7 +25,7 @@ type SSHCLIConnector struct {
|
|||||||
config SSHConnectorConfig
|
config SSHConnectorConfig
|
||||||
// Path to SSH control socket for connection reuse.
|
// Path to SSH control socket for connection reuse.
|
||||||
controlSockPath string
|
controlSockPath string
|
||||||
// fwdCheckOnce ensures the TCP forwarding check runs only once.
|
// fwdCheckOnce ensures the TCP forwarding check runs only once per connector.
|
||||||
fwdCheckOnce sync.Once
|
fwdCheckOnce sync.Once
|
||||||
// fwdCheckErr caches the result of the TCP forwarding check.
|
// fwdCheckErr caches the result of the TCP forwarding check.
|
||||||
fwdCheckErr error
|
fwdCheckErr error
|
||||||
@@ -75,7 +75,7 @@ func controlSocketPath() string {
|
|||||||
func (c *SSHCLIConnector) Connect(ctx context.Context) (*grpc.ClientConn, error) {
|
func (c *SSHCLIConnector) Connect(ctx context.Context) (*grpc.ClientConn, error) {
|
||||||
// Validate SSH connectivity by running a no-op command on the remote machine. This also
|
// Validate SSH connectivity by running a no-op command on the remote machine. This also
|
||||||
// establishes the control socket (ControlMaster=auto) so subsequent connections reuse it.
|
// establishes the control socket (ControlMaster=auto) so subsequent connections reuse it.
|
||||||
probeArgs := append(c.buildSSHArgs(), "true")
|
probeArgs := append(c.buildSSHArgs(true), "true")
|
||||||
probe := exec.CommandContext(ctx, "ssh", probeArgs...)
|
probe := exec.CommandContext(ctx, "ssh", probeArgs...)
|
||||||
if output, err := probe.CombinedOutput(); err != nil {
|
if output, err := probe.CombinedOutput(); err != nil {
|
||||||
return nil, fmt.Errorf("SSH connection to '%s': %w: %s",
|
return nil, fmt.Errorf("SSH connection to '%s': %w: %s",
|
||||||
@@ -91,7 +91,7 @@ func (c *SSHCLIConnector) Connect(ctx context.Context) (*grpc.ClientConn, error)
|
|||||||
grpc.WithUnaryInterceptor(grpcversion.ClientUnaryInterceptor),
|
grpc.WithUnaryInterceptor(grpcversion.ClientUnaryInterceptor),
|
||||||
grpc.WithStreamInterceptor(grpcversion.ClientStreamInterceptor),
|
grpc.WithStreamInterceptor(grpcversion.ClientStreamInterceptor),
|
||||||
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
|
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
|
||||||
dialArgs := append(c.buildSSHArgs(), "uncloudd", "dial-stdio")
|
dialArgs := append(c.buildSSHArgs(true), "uncloudd", "dial-stdio")
|
||||||
if c.config.SockPath != "" {
|
if c.config.SockPath != "" {
|
||||||
dialArgs = append(dialArgs, "--socket", c.config.SockPath)
|
dialArgs = append(dialArgs, "--socket", c.config.SockPath)
|
||||||
}
|
}
|
||||||
@@ -111,13 +111,13 @@ func (c *SSHCLIConnector) Connect(ctx context.Context) (*grpc.ClientConn, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildSSHArgs constructs the SSH command arguments with connection options and destination. The options
|
// buildSSHArgs constructs the SSH command arguments with connection options and destination. The options
|
||||||
// include control socket settings for connection reuse if necessary.
|
// include control socket settings for connection reuse if the path is configured and useControlMaster is true.
|
||||||
// The remote command is not included and should be appended by the caller.
|
// The remote command is not included and should be appended by the caller.
|
||||||
func (c *SSHCLIConnector) buildSSHArgs() []string {
|
func (c *SSHCLIConnector) buildSSHArgs(useControlMaster bool) []string {
|
||||||
var args []string
|
var args []string
|
||||||
|
|
||||||
// Add control socket options for connection reuse if available.
|
// Add control socket options for connection reuse if available.
|
||||||
if c.controlSockPath != "" {
|
if useControlMaster && c.controlSockPath != "" {
|
||||||
args = append(args, "-o", "ControlMaster=auto")
|
args = append(args, "-o", "ControlMaster=auto")
|
||||||
args = append(args, "-o", "ControlPath="+c.controlSockPath)
|
args = append(args, "-o", "ControlPath="+c.controlSockPath)
|
||||||
|
|
||||||
@@ -170,11 +170,22 @@ func (c *SSHCLIConnector) DialContext(ctx context.Context, network, address stri
|
|||||||
if network != "tcp" {
|
if network != "tcp" {
|
||||||
return nil, fmt.Errorf("unsupported network type: %s", network)
|
return nil, fmt.Errorf("unsupported network type: %s", network)
|
||||||
}
|
}
|
||||||
if err := c.CheckTCPForwarding(ctx); err != nil {
|
|
||||||
return nil, err
|
c.fwdCheckOnce.Do(func() {
|
||||||
|
c.fwdCheckErr = c.CheckTCPForwarding(ctx)
|
||||||
|
if c.fwdCheckErr != nil {
|
||||||
|
// Close the cached ControlMaster so the next call picks up the new sshd policy once the
|
||||||
|
// user enables forwarding. Fresh context so close runs even if the parent already timed out.
|
||||||
|
closeCtx, closeCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer closeCancel()
|
||||||
|
c.CloseControlMaster(closeCtx)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if c.fwdCheckErr != nil {
|
||||||
|
return nil, c.fwdCheckErr
|
||||||
}
|
}
|
||||||
|
|
||||||
args := append(c.buildSSHArgs(), "-W", address)
|
args := append(c.buildSSHArgs(true), "-W", address)
|
||||||
conn, err := commandconn.New(ctx, "ssh", args...)
|
conn, err := commandconn.New(ctx, "ssh", args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("SSH connection to '%s' for dialing '%s': %w", c.config.Destination(), address, err)
|
return nil, fmt.Errorf("SSH connection to '%s' for dialing '%s': %w", c.config.Destination(), address, err)
|
||||||
@@ -183,32 +194,25 @@ func (c *SSHCLIConnector) DialContext(ctx context.Context, network, address stri
|
|||||||
return conn, nil
|
return conn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckTCPForwarding verifies that TCP forwarding is enabled on the remote SSH server. It probes the server once
|
// CheckTCPForwarding returns an actionable error when the remote SSH server doesn't allow TCP forwarding.
|
||||||
// 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 {
|
func (c *SSHCLIConnector) CheckTCPForwarding(ctx context.Context) error {
|
||||||
c.fwdCheckOnce.Do(func() {
|
// Do not use ControlMaster because disabled forwarding and a refused port both surface as
|
||||||
// Probe TCP forwarding by requesting a forward to 127.0.0.1:1 (a port almost never in use).
|
// "Session open refused by peer" over it and can't be told apart.
|
||||||
// If forwarding is disabled, sshd rejects the channel. The error message differs depending on
|
probeCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
// whether the connection goes through a ControlMaster mux or directly:
|
defer cancel()
|
||||||
// - 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")
|
// Request forwarding to a port that is almost never in use (:1) so sshd rejects the channel if forwarding
|
||||||
probe := exec.CommandContext(probeCtx, "ssh", args...)
|
// is disabled or fails to connect otherwise.
|
||||||
output, _ := probe.CombinedOutput()
|
args := append(c.buildSSHArgs(false), "-W", "127.0.0.1:1")
|
||||||
outStr := string(output)
|
output, _ := exec.CommandContext(probeCtx, "ssh", args...).CombinedOutput()
|
||||||
if strings.Contains(outStr, "administratively prohibited") ||
|
if strings.Contains(string(output), "administratively prohibited") {
|
||||||
strings.Contains(outStr, "Session open refused by peer") {
|
return fmt.Errorf("SSH TCP forwarding appears to be disabled on '%s': ensure 'AllowTcpForwarding yes' "+
|
||||||
c.fwdCheckErr = fmt.Errorf(
|
"is set in /etc/ssh/sshd_config on the remote machine and restart sshd (sudo systemctl restart ssh), "+
|
||||||
"SSH TCP forwarding appears to be disabled on '%s': ensure 'AllowTcpForwarding yes' is set "+
|
"then retry",
|
||||||
"in /etc/ssh/sshd_config on the remote machine and restart sshd (sudo systemctl restart ssh)",
|
c.config.Destination())
|
||||||
c.config.Destination(),
|
}
|
||||||
)
|
|
||||||
}
|
return nil
|
||||||
})
|
|
||||||
return c.fwdCheckErr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloseControlMaster terminates the SSH ControlMaster process for this destination so the next connection starts
|
// CloseControlMaster terminates the SSH ControlMaster process for this destination so the next connection starts
|
||||||
@@ -217,7 +221,7 @@ func (c *SSHCLIConnector) CloseControlMaster(ctx context.Context) {
|
|||||||
if c.controlSockPath == "" {
|
if c.controlSockPath == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
args := append(c.buildSSHArgs(), "-O", "exit")
|
args := append(c.buildSSHArgs(true), "-O", "exit")
|
||||||
_ = exec.CommandContext(ctx, "ssh", args...).Run()
|
_ = exec.CommandContext(ctx, "ssh", args...).Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
config SSHConnectorConfig
|
config SSHConnectorConfig
|
||||||
controlSockPath string
|
controlSockPath string
|
||||||
expected []string
|
useControlMaster bool
|
||||||
|
expected []string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "basic connection with control socket",
|
name: "basic connection with control socket",
|
||||||
@@ -22,8 +23,9 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
|
|||||||
User: "root",
|
User: "root",
|
||||||
Host: "example.com",
|
Host: "example.com",
|
||||||
},
|
},
|
||||||
controlSockPath: "/tmp/test.sock",
|
controlSockPath: "/tmp/test.sock",
|
||||||
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-T", "root@example.com"},
|
useControlMaster: true,
|
||||||
|
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-T", "root@example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "basic connection without control socket",
|
name: "basic connection without control socket",
|
||||||
@@ -31,8 +33,9 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
|
|||||||
User: "root",
|
User: "root",
|
||||||
Host: "example.com",
|
Host: "example.com",
|
||||||
},
|
},
|
||||||
controlSockPath: "",
|
controlSockPath: "",
|
||||||
expected: []string{"-o", "ConnectTimeout=5", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-T", "root@example.com"},
|
useControlMaster: true,
|
||||||
|
expected: []string{"-o", "ConnectTimeout=5", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-T", "root@example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "with custom port",
|
name: "with custom port",
|
||||||
@@ -41,8 +44,9 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
|
|||||||
Host: "example.com",
|
Host: "example.com",
|
||||||
Port: 2222,
|
Port: 2222,
|
||||||
},
|
},
|
||||||
controlSockPath: "/tmp/test.sock",
|
controlSockPath: "/tmp/test.sock",
|
||||||
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-T", "-p", "2222", "root@example.com"},
|
useControlMaster: true,
|
||||||
|
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-T", "-p", "2222", "root@example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "with identity file",
|
name: "with identity file",
|
||||||
@@ -51,8 +55,9 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
|
|||||||
Host: "example.com",
|
Host: "example.com",
|
||||||
KeyPath: "/path/to/key",
|
KeyPath: "/path/to/key",
|
||||||
},
|
},
|
||||||
controlSockPath: "/tmp/test.sock",
|
controlSockPath: "/tmp/test.sock",
|
||||||
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-T", "-i", "/path/to/key", "root@example.com"},
|
useControlMaster: true,
|
||||||
|
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-T", "-i", "/path/to/key", "root@example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "all options combined",
|
name: "all options combined",
|
||||||
@@ -63,8 +68,9 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
|
|||||||
KeyPath: "/path/to/key",
|
KeyPath: "/path/to/key",
|
||||||
SockPath: "/custom/path/uncloud.sock",
|
SockPath: "/custom/path/uncloud.sock",
|
||||||
},
|
},
|
||||||
controlSockPath: "/tmp/test.sock",
|
controlSockPath: "/tmp/test.sock",
|
||||||
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-T", "-p", "2222", "-i", "/path/to/key", "root@example.com"},
|
useControlMaster: true,
|
||||||
|
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-T", "-p", "2222", "-i", "/path/to/key", "root@example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "port 0 not included",
|
name: "port 0 not included",
|
||||||
@@ -73,8 +79,9 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
|
|||||||
Host: "example.com",
|
Host: "example.com",
|
||||||
Port: 0,
|
Port: 0,
|
||||||
},
|
},
|
||||||
controlSockPath: "/tmp/test.sock",
|
controlSockPath: "/tmp/test.sock",
|
||||||
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-T", "root@example.com"},
|
useControlMaster: true,
|
||||||
|
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-T", "root@example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "port 22 included when explicit",
|
name: "port 22 included when explicit",
|
||||||
@@ -83,8 +90,19 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
|
|||||||
Host: "example.com",
|
Host: "example.com",
|
||||||
Port: 22,
|
Port: 22,
|
||||||
},
|
},
|
||||||
controlSockPath: "/tmp/test.sock",
|
controlSockPath: "/tmp/test.sock",
|
||||||
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-T", "-p", "22", "root@example.com"},
|
useControlMaster: true,
|
||||||
|
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-T", "-p", "22", "root@example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "useControlMaster=false strips control socket options",
|
||||||
|
config: SSHConnectorConfig{
|
||||||
|
User: "root",
|
||||||
|
Host: "example.com",
|
||||||
|
},
|
||||||
|
controlSockPath: "/tmp/test.sock",
|
||||||
|
useControlMaster: false,
|
||||||
|
expected: []string{"-o", "ConnectTimeout=5", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-T", "root@example.com"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +111,7 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
c := &SSHCLIConnector{config: tt.config, controlSockPath: tt.controlSockPath}
|
c := &SSHCLIConnector{config: tt.config, controlSockPath: tt.controlSockPath}
|
||||||
got := c.buildSSHArgs()
|
got := c.buildSSHArgs(tt.useControlMaster)
|
||||||
assert.Equal(t, tt.expected, got)
|
assert.Equal(t, tt.expected, got)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user