package connector import ( "context" "fmt" "net" "os" "os/exec" "path/filepath" "strconv" "strings" "sync" "time" "github.com/docker/cli/cli/connhelper/commandconn" "github.com/psviderski/uncloud/internal/grpcversion" "golang.org/x/net/proxy" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) // SSHCLIConnector establishes a connection to the machine API by executing SSH CLI // and running `uncloudd dial-stdio` on the remote machine. type SSHCLIConnector struct { config SSHConnectorConfig // Path to SSH control socket for connection reuse. controlSockPath string // fwdCheckOnce ensures the TCP forwarding check runs only once per connector. fwdCheckOnce sync.Once // fwdCheckErr caches the result of the TCP forwarding check. fwdCheckErr error } func NewSSHCLIConnector(cfg *SSHConnectorConfig) *SSHCLIConnector { return &SSHCLIConnector{ config: *cfg, controlSockPath: controlSocketPath(), } } // controlSocketPath returns a unique control socket path for the SSH connection. // Returns an empty string if unable to find or create a suitable path. func controlSocketPath() string { // %C is expanded by `ssh` to a hash of user, local and remote hostnames, port, and the contents // of the ProxyJump option. This ensures that shared connections are uniquely identified. sockName := fmt.Sprintf("uc_control_%%C.sock") // Prefer XDG_RUNTIME_DIR if set and the directory exists, fall back to ~/.ssh if it exists. if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" { // On WSL2 without systemd, XDG_RUNTIME_DIR may be set to /run/user/$UID that doesn't actually exist, // so existence must be verified before use: https://github.com/psviderski/uncloud/issues/319. if fi, err := os.Stat(dir); err == nil && fi.IsDir() { return filepath.Join(dir, sockName) } } if home, err := os.UserHomeDir(); err == nil { sshDir := filepath.Join(home, ".ssh") if fi, sErr := os.Stat(sshDir); sErr == nil && fi.IsDir() { return filepath.Join(sshDir, sockName) } } // Last resort: create a subdirectory in temp with restricted permissions. tmpDir := filepath.Join(os.TempDir(), fmt.Sprintf("uncloud-%d", os.Getuid())) path := filepath.Join(tmpDir, sockName) if len(path)-2+40 < 104 { // 40 chars for %C hash, 104 is typical UNIX socket path limit if err := os.MkdirAll(tmpDir, 0o700); err == nil { return path } } return "" } 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 // establishes the control socket (ControlMaster=auto) so subsequent connections reuse it. probeArgs := append(c.buildSSHArgs(true), "true") probe := exec.CommandContext(ctx, "ssh", probeArgs...) if output, err := probe.CombinedOutput(); err != nil { return nil, fmt.Errorf("SSH connection to '%s': %w: %s", c.config.Destination(), err, strings.TrimSpace(string(output))) } // Create gRPC client with a dialer that spawns new SSH connections on demand, // reusing the control socket established above. grpcConn, err := grpc.NewClient( "passthrough:///", // Dummy target since we're using a custom dialer. grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithDefaultServiceConfig(defaultServiceConfig), grpc.WithUnaryInterceptor(grpcversion.ClientUnaryInterceptor), grpc.WithStreamInterceptor(grpcversion.ClientStreamInterceptor), grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { dialArgs := append(c.buildSSHArgs(true), "uncloudd", "dial-stdio") if c.config.SockPath != "" { dialArgs = append(dialArgs, "--socket", c.config.SockPath) } conn, err := commandconn.New(ctx, "ssh", dialArgs...) if err != nil { return nil, fmt.Errorf("SSH connection to '%s': %w", c.config.Destination(), err) } return conn, nil }), ) if err != nil { return nil, fmt.Errorf("create machine API client: %w", err) } return grpcConn, nil } // buildSSHArgs constructs the SSH command arguments with connection options and destination. The options // 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. func (c *SSHCLIConnector) buildSSHArgs(useControlMaster bool) []string { var args []string // Add control socket options for connection reuse if available. if useControlMaster && c.controlSockPath != "" { args = append(args, "-o", "ControlMaster=auto") args = append(args, "-o", "ControlPath="+c.controlSockPath) // Keep the established connection alive for a short duration after the last session closes to allow reuse. controlPersist := "10m" // Override the default duration with the UNCLOUD_SSH_CONTROL_PERSIST env variable. if v := os.Getenv("UNCLOUD_SSH_CONTROL_PERSIST"); v != "" { controlPersist = v } args = append(args, "-o", "ControlPersist="+controlPersist) } // Add connection timeout to fail fast when node is down. args = append(args, "-o", "ConnectTimeout=5") // Disable interactive prompts (e.g., passphrase input) to prevent interference with the TUI. // Authentication must succeed non-interactively via SSH agent or unencrypted key. args = append(args, "-o", "BatchMode=yes") // Disable host key checking for parity with go+ssh. args = append(args, "-o", "StrictHostKeyChecking=accept-new") // Disable pseudo-terminal allocation to prevent SSH from executing as a login shell. args = append(args, "-T") // Add port if specified. if c.config.Port != 0 { args = append(args, "-p", strconv.Itoa(c.config.Port)) } // Add identity file if specified (backward compatibility with SSHKeyFile). if c.config.KeyPath != "" { args = append(args, "-i", c.config.KeyPath) } // Add [user@]host destination. args = append(args, c.config.Destination()) return args } // Dialer returns a proxy dialer for establishing connections within the cluster through SSH tunnels. func (c *SSHCLIConnector) Dialer() (proxy.ContextDialer, error) { if c.config == (SSHConnectorConfig{}) { return nil, fmt.Errorf("SSH connector not configured") } return c, nil } // DialContext establishes a connection to the target address through an SSH tunnel using -W flag. func (c *SSHCLIConnector) DialContext(ctx context.Context, network, address string) (net.Conn, error) { if network != "tcp" { return nil, fmt.Errorf("unsupported network type: %s", network) } 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(true), "-W", address) conn, err := commandconn.New(ctx, "ssh", args...) if err != nil { return nil, fmt.Errorf("SSH connection to '%s' for dialing '%s': %w", c.config.Destination(), address, err) } return conn, nil } // CheckTCPForwarding returns an actionable error when the remote SSH server doesn't allow TCP forwarding. func (c *SSHCLIConnector) CheckTCPForwarding(ctx context.Context) error { // Do not use ControlMaster because disabled forwarding and a refused port both surface as // "Session open refused by peer" over it and can't be told apart. probeCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() // Request forwarding to a port that is almost never in use (:1) so sshd rejects the channel if forwarding // is disabled or fails to connect otherwise. args := append(c.buildSSHArgs(false), "-W", "127.0.0.1:1") output, _ := exec.CommandContext(probeCtx, "ssh", args...).CombinedOutput() if strings.Contains(string(output), "administratively prohibited") { return 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), "+ "then retry", c.config.Destination()) } return nil } // CloseControlMaster terminates the SSH ControlMaster process for this destination so the next connection starts // a fresh SSH session. No-op if no master is running or the control socket is not configured. Errors are ignored. func (c *SSHCLIConnector) CloseControlMaster(ctx context.Context) { if c.controlSockPath == "" { return } args := append(c.buildSSHArgs(true), "-O", "exit") _ = exec.CommandContext(ctx, "ssh", args...).Run() } 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. return nil }