diff --git a/Dockerfile b/Dockerfile index 46175615..18e953b9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ COPY go.mod go.sum ./ RUN go mod download && go mod verify COPY . . -RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o uncloudd cmd/uncloudd/main.go +RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o uncloudd ./cmd/uncloudd FROM alpine:${ALPINE_VERSION} AS corrosion-download diff --git a/cmd/uncloud/main.go b/cmd/uncloud/main.go index 66057c65..44a180ab 100644 --- a/cmd/uncloud/main.go +++ b/cmd/uncloud/main.go @@ -50,6 +50,11 @@ func main() { conn = &config.MachineConnection{ TCP: &addrPort, } + } else if strings.HasPrefix(opts.connect, "ssh+cli://") { + dest := opts.connect[len("ssh+cli://"):] + conn = &config.MachineConnection{ + SSHCLI: config.SSHDestination(dest), + } } else { dest := opts.connect if strings.HasPrefix(dest, "ssh://") { @@ -73,7 +78,7 @@ func main() { cmd.PersistentFlags().StringVar(&opts.connect, "connect", "", "Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]\n"+ - "Format: [ssh://]user@host[:port] or tcp://host:port") + "Format: [ssh://]user@host[:port], ssh+cli://user@host[:port], or tcp://host:port") cmd.PersistentFlags().StringVar(&opts.configPath, "uncloud-config", "~/.config/uncloud/config.yaml", "Path to the Uncloud configuration file. [$UNCLOUD_CONFIG]") _ = cmd.MarkPersistentFlagFilename("uncloud-config", "yaml", "yml") diff --git a/cmd/uncloudd/dialstdio.go b/cmd/uncloudd/dialstdio.go new file mode 100644 index 00000000..af13fbed --- /dev/null +++ b/cmd/uncloudd/dialstdio.go @@ -0,0 +1,128 @@ +package main + +import ( + "context" + "fmt" + "io" + "net" + "os" + + "github.com/psviderski/uncloud/internal/machine" + "github.com/spf13/cobra" +) + +func newDialStdioCommand() *cobra.Command { + var socketPath string + + cmd := &cobra.Command{ + Use: "dial-stdio", + Short: "Proxy stdin/stdout to the Uncloud API socket", + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runDialStdio(cmd.Context(), socketPath, os.Stdin, os.Stdout) + }, + } + + cmd.Flags().StringVar(&socketPath, "socket", machine.DefaultUncloudSockPath, + "Path to the Uncloud API socket") + + return cmd +} + +// halfReadCloser is the read side of a half-duplex connection. +type halfReadCloser interface { + io.Reader + CloseRead() error +} + +// halfWriteCloser is the write side of a half-duplex connection. +type halfWriteCloser interface { + io.Writer + CloseWrite() error +} + +// halfReadCloserWrapper wraps an io.ReadCloser to implement halfReadCloser. +type halfReadCloserWrapper struct { + io.ReadCloser +} + +func (x *halfReadCloserWrapper) CloseRead() error { + return x.Close() +} + +// halfWriteCloserWrapper wraps an io.WriteCloser to implement halfWriteCloser. +type halfWriteCloserWrapper struct { + io.WriteCloser +} + +func (x *halfWriteCloserWrapper) CloseWrite() error { + return x.Close() +} + +func runDialStdio(ctx context.Context, socketPath string, stdin io.Reader, stdout io.Writer) error { + // Connect to the unix socket. + var dialer net.Dialer + conn, err := dialer.DialContext(ctx, "unix", socketPath) + if err != nil { + return fmt.Errorf("connect to socket %q: %w", socketPath, err) + } + defer conn.Close() + + // Wrap stdin/stdout to support half-closing. + var stdinCloser halfReadCloser + if c, ok := stdin.(halfReadCloser); ok { + stdinCloser = c + } else if c, ok := stdin.(io.ReadCloser); ok { + stdinCloser = &halfReadCloserWrapper{c} + } + + var stdoutCloser halfWriteCloser + if c, ok := stdout.(halfWriteCloser); ok { + stdoutCloser = c + } else if c, ok := stdout.(io.WriteCloser); ok { + stdoutCloser = &halfWriteCloserWrapper{c} + } + + // Copy data bidirectionally between stdin/stdout and the socket. + stdin2socket := make(chan error, 1) + socket2stdout := make(chan error, 1) + + // Copy from stdin to socket. + go func() { + _, err := io.Copy(conn, stdin) + stdin2socket <- err + // Close write side of connection after stdin is done. + if unixConn, ok := conn.(*net.UnixConn); ok { + unixConn.CloseWrite() + } + if stdinCloser != nil { + stdinCloser.CloseRead() + } + }() + + // Copy from socket to stdout. + go func() { + _, err := io.Copy(stdout, conn) + socket2stdout <- err + // Close read side of connection after socket is done sending. + if unixConn, ok := conn.(*net.UnixConn); ok { + unixConn.CloseRead() + } + if stdoutCloser != nil { + stdoutCloser.CloseWrite() + } + }() + + select { + case err = <-stdin2socket: + if err != nil { + return err + } + // wait for stdout + err = <-socket2stdout + case err = <-socket2stdout: + // return immediately, matching Docker's approach + // (stdin is never closed when TTY) + } + return err +} diff --git a/cmd/uncloudd/main.go b/cmd/uncloudd/main.go index 84e715c1..1db71da1 100644 --- a/cmd/uncloudd/main.go +++ b/cmd/uncloudd/main.go @@ -42,6 +42,9 @@ func main() { "Directory for storing persistent machine state") _ = cmd.MarkFlagDirname("data-dir") + // Add dial-stdio subcommand. + cmd.AddCommand(newDialStdioCommand()) + // ctx is canceled when the daemon command is interrupted. ctx, cancel := context.WithCancel(context.Background()) diff --git a/internal/cli/config/connection.go b/internal/cli/config/connection.go index a44a5a4f..c1be9c93 100644 --- a/internal/cli/config/connection.go +++ b/internal/cli/config/connection.go @@ -1,6 +1,7 @@ package config import ( + "errors" "fmt" "net" "net/netip" @@ -17,6 +18,7 @@ const ( type MachineConnection struct { SSH SSHDestination `yaml:"ssh,omitempty"` + SSHCLI SSHDestination `yaml:"ssh_cli,omitempty"` SSHKeyFile string `yaml:"ssh_key_file,omitempty"` // TCP is the address and port of the machine's API server. // The pointer is used to omit the field when not set. Otherwise, yaml marshalling includes an empty object. @@ -27,13 +29,37 @@ type MachineConnection struct { func (c MachineConnection) String() string { if c.SSH != "" { - return string(c.SSH) + return "ssh://" + string(c.SSH) + } else if c.SSHCLI != "" { + return "ssh+cli://" + string(c.SSHCLI) } else if c.TCP != nil && c.TCP.IsValid() { return fmt.Sprintf("tcp://%s", c.TCP) } return "unknown connection" } +func (c *MachineConnection) Validate() error { + setCount := 0 + if c.SSH != "" { + setCount++ + } + if c.SSHCLI != "" { + setCount++ + } + if c.TCP != nil && c.TCP.IsValid() { + setCount++ + } + + if setCount == 0 { + return errors.New("no connection method specified (ssh, ssh_cli, or tcp required)") + } + if setCount > 1 { + return errors.New("only one connection method allowed per connection (ssh, ssh_cli, or tcp)") + } + + return nil +} + // SSHDestination represents an SSH destination string in the canonical form of "user@host:port". // The default user "root" and port 22 can be omitted. type SSHDestination string diff --git a/internal/cli/config/connection_test.go b/internal/cli/config/connection_test.go new file mode 100644 index 00000000..1dc3118c --- /dev/null +++ b/internal/cli/config/connection_test.go @@ -0,0 +1,173 @@ +package config + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMachineConnection_String(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + conn MachineConnection + want string + }{ + { + name: "ssh connection", + conn: MachineConnection{ + SSH: "user@host.com", + }, + want: "ssh://user@host.com", + }, + { + name: "ssh connection with port", + conn: MachineConnection{ + SSH: "user@host.com:2222", + }, + want: "ssh://user@host.com:2222", + }, + { + name: "ssh_cli connection", + conn: MachineConnection{ + SSHCLI: "user@host.com", + }, + want: "ssh+cli://user@host.com", + }, + { + name: "ssh_cli connection with port", + conn: MachineConnection{ + SSHCLI: "user@host.com:2222", + }, + want: "ssh+cli://user@host.com:2222", + }, + { + name: "tcp connection", + conn: MachineConnection{ + TCP: func() *netip.AddrPort { + addr := netip.MustParseAddrPort("10.0.0.1:8080") + return &addr + }(), + }, + want: "tcp://10.0.0.1:8080", + }, + { + name: "no connection", + conn: MachineConnection{}, + want: "unknown connection", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := tt.conn.String() + assert.Equal(t, tt.want, got) + }) + } +} + +func TestMachineConnection_Validate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + conn MachineConnection + wantErr bool + errMsg string + }{ + { + name: "ssh only - valid", + conn: MachineConnection{ + SSH: "user@host", + }, + wantErr: false, + }, + { + name: "ssh_cli only - valid", + conn: MachineConnection{ + SSHCLI: "user@host", + }, + wantErr: false, + }, + { + name: "tcp only - valid", + conn: MachineConnection{ + TCP: func() *netip.AddrPort { + addr := netip.MustParseAddrPort("10.0.0.1:8080") + return &addr + }(), + }, + wantErr: false, + }, + { + name: "no connection method - error", + conn: MachineConnection{}, + wantErr: true, + errMsg: "no connection method specified", + }, + { + name: "ssh and ssh_cli - error", + conn: MachineConnection{ + SSH: "user@host", + SSHCLI: "user@host", + }, + wantErr: true, + errMsg: "only one connection method allowed", + }, + { + name: "ssh and tcp - error", + conn: MachineConnection{ + SSH: "user@host", + TCP: func() *netip.AddrPort { + addr := netip.MustParseAddrPort("10.0.0.1:8080") + return &addr + }(), + }, + wantErr: true, + errMsg: "only one connection method allowed", + }, + { + name: "ssh_cli and tcp - error", + conn: MachineConnection{ + SSHCLI: "user@host", + TCP: func() *netip.AddrPort { + addr := netip.MustParseAddrPort("10.0.0.1:8080") + return &addr + }(), + }, + wantErr: true, + errMsg: "only one connection method allowed", + }, + { + name: "all three - error", + conn: MachineConnection{ + SSH: "user@host", + SSHCLI: "user@host2", + TCP: func() *netip.AddrPort { + addr := netip.MustParseAddrPort("10.0.0.1:8080") + return &addr + }(), + }, + wantErr: true, + errMsg: "only one connection method allowed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := tt.conn.Validate() + if tt.wantErr { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/internal/cli/connect.go b/internal/cli/connect.go index ee0eb722..f497bf9e 100644 --- a/internal/cli/connect.go +++ b/internal/cli/connect.go @@ -56,26 +56,47 @@ func connectClusterWithProgress(ctx context.Context, conn config.MachineConnecti } func connectCluster(ctx context.Context, conn config.MachineConnection) (*client.Client, error) { - if conn.SSH != "" { - user, host, port, err := conn.SSH.Parse() - if err != nil { - return nil, fmt.Errorf("parse SSH connection %q: %w", conn.SSH, err) - } + // Determine which SSH type is configured + var sshDest config.SSHDestination + var useSSHCLI bool - keyPath := fs.ExpandHomeDir(conn.SSHKeyFile) - - sshConfig := &connector.SSHConnectorConfig{ - User: user, - Host: host, - Port: port, - KeyPath: keyPath, - } - return client.New(ctx, connector.NewSSHConnector(sshConfig)) - } else if conn.TCP != nil && conn.TCP.IsValid() { - return client.New(ctx, connector.NewTCPConnector(*conn.TCP)) + // Validate connection configuration early to provide clear error messages. + if err := conn.Validate(); err != nil { + return nil, fmt.Errorf("invalid connection configuration: %w", err) } - return nil, errors.New("connection configuration is invalid") + if conn.SSH != "" { + sshDest = conn.SSH + useSSHCLI = false + } else if conn.SSHCLI != "" { + sshDest = conn.SSHCLI + useSSHCLI = true + } else if conn.TCP != nil && conn.TCP.IsValid() { + return client.New(ctx, connector.NewTCPConnector(*conn.TCP)) + } else { + return nil, errors.New("connection configuration is invalid") + } + + // Parse SSH destination and create config (shared for both types) + user, host, port, err := sshDest.Parse() + if err != nil { + return nil, fmt.Errorf("parse SSH connection %q: %w", sshDest, err) + } + + keyPath := fs.ExpandHomeDir(conn.SSHKeyFile) + + sshConfig := &connector.SSHConnectorConfig{ + User: user, + Host: host, + Port: port, + KeyPath: keyPath, + } + + // Create appropriate connector based on type + if useSSHCLI { + return client.New(ctx, connector.NewSSHCLIConnector(sshConfig)) + } + return client.New(ctx, connector.NewSSHConnector(sshConfig)) } // connectModel is a TUI model for connecting to a cluster with a progress spinner. diff --git a/pkg/client/client.go b/pkg/client/client.go index 277707c3..9a152c5d 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -57,6 +57,7 @@ func New(ctx context.Context, connector Connector) (*Client, error) { c.ClusterClient = pb.NewClusterClient(c.conn) c.Caddy = pb.NewCaddyClient(c.conn) c.Docker = docker.NewClient(c.conn) + return c, nil } diff --git a/pkg/client/connector/sshcli.go b/pkg/client/connector/sshcli.go new file mode 100644 index 00000000..a06febf1 --- /dev/null +++ b/pkg/client/connector/sshcli.go @@ -0,0 +1,154 @@ +package connector + +import ( + "context" + "fmt" + "net" + "strconv" + + "github.com/docker/cli/cli/connhelper/commandconn" + "github.com/psviderski/uncloud/internal/machine" + "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 + conn net.Conn +} + +func NewSSHCLIConnector(cfg *SSHConnectorConfig) *SSHCLIConnector { + return &SSHCLIConnector{config: *cfg} +} + +// sshCLIDialer implements proxy.ContextDialer by spawning SSH processes with -W flag. +type sshCLIDialer struct { + config SSHConnectorConfig +} + +// buildDialArgs constructs SSH command arguments for -W flag dialing. +func (d *sshCLIDialer) buildDialArgs(address string) []string { + args := []string{} + + // Add connection timeout to fail fast when node is down. + args = append(args, "-o", "ConnectTimeout=5") + + // Add port if non-standard. + if d.config.Port != 0 && d.config.Port != 22 { + args = append(args, "-p", strconv.Itoa(d.config.Port)) + } + + // Add identity file if specified. + if d.config.KeyPath != "" { + args = append(args, "-i", d.config.KeyPath) + } + + // Add -W flag for stdin/stdout forwarding to target address. + args = append(args, "-W", address) + + // Add user@host. + args = append(args, d.config.User+"@"+d.config.Host) + + return args +} + +// DialContext establishes a connection to the target address through an SSH tunnel using -W flag. +func (d *sshCLIDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + // Only support TCP connections. + if network != "tcp" { + return nil, fmt.Errorf("unsupported network type: %s", network) + } + + // Build SSH command arguments. + args := d.buildDialArgs(address) + + // Create connection using commandconn. + conn, err := commandconn.New(ctx, "ssh", args...) + if err != nil { + return nil, fmt.Errorf("SSH connection to %s@%s for dialing %s: %w", d.config.User, d.config.Host, address, err) + } + + return conn, nil +} + +func (c *SSHCLIConnector) Connect(ctx context.Context) (*grpc.ClientConn, error) { + // Build SSH command arguments. + args := c.buildSSHArgs() + + // Create connection using commandconn. + conn, err := commandconn.New(ctx, "ssh", args...) + if err != nil { + return nil, fmt.Errorf("SSH connection to %s@%s: %w", c.config.User, c.config.Host, err) + } + c.conn = conn + + // Create gRPC client over the connection. + // Use a custom dialer that returns our existing connection. + grpcConn, err := grpc.NewClient( + "passthrough:///", // Dummy target since we're using a custom dialer. + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { + return c.conn, nil + }), + ) + if err != nil { + c.conn.Close() + return nil, fmt.Errorf("create machine API client: %w", err) + } + + return grpcConn, nil +} + +// buildSSHArgs constructs the SSH command arguments. +func (c *SSHCLIConnector) buildSSHArgs() []string { + args := []string{} + + // Add connection timeout to fail fast when node is down. + args = append(args, "-o", "ConnectTimeout=5") + + // Add port if non-standard. + if c.config.Port != 0 && c.config.Port != 22 { + 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. + args = append(args, c.config.User+"@"+c.config.Host) + + // Add remote command: uncloudd dial-stdio + args = append(args, "uncloudd", "dial-stdio") + + // Add socket path if non-default. + if c.config.SockPath != "" && c.config.SockPath != machine.DefaultUncloudSockPath { + args = append(args, "--socket", c.config.SockPath) + } + + 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 &sshCLIDialer{ + config: c.config, + }, nil +} + +func (c *SSHCLIConnector) Close() error { + if c.conn != nil { + err := c.conn.Close() + c.conn = nil + return err + } + return nil +} diff --git a/pkg/client/connector/sshcli_test.go b/pkg/client/connector/sshcli_test.go new file mode 100644 index 00000000..a3da9df9 --- /dev/null +++ b/pkg/client/connector/sshcli_test.go @@ -0,0 +1,166 @@ +package connector + +import ( + "testing" + + "github.com/psviderski/uncloud/internal/machine" + "github.com/stretchr/testify/assert" +) + +func TestSSHCLIConnector_buildSSHArgs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config SSHConnectorConfig + expected []string + }{ + { + name: "basic connection", + config: SSHConnectorConfig{ + User: "root", + Host: "example.com", + }, + expected: []string{"-o", "ConnectTimeout=5", "root@example.com", "uncloudd", "dial-stdio"}, + }, + { + name: "with custom port", + config: SSHConnectorConfig{ + User: "root", + Host: "example.com", + Port: 2222, + }, + expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "root@example.com", "uncloudd", "dial-stdio"}, + }, + { + name: "with identity file", + config: SSHConnectorConfig{ + User: "root", + Host: "example.com", + KeyPath: "/path/to/key", + }, + expected: []string{"-o", "ConnectTimeout=5", "-i", "/path/to/key", "root@example.com", "uncloudd", "dial-stdio"}, + }, + { + name: "with custom socket path", + config: SSHConnectorConfig{ + User: "root", + Host: "example.com", + SockPath: "/custom/path/uncloud.sock", + }, + expected: []string{"-o", "ConnectTimeout=5", "root@example.com", "uncloudd", "dial-stdio", "--socket", "/custom/path/uncloud.sock"}, + }, + { + name: "with default socket path (not included)", + config: SSHConnectorConfig{ + User: "root", + Host: "example.com", + SockPath: machine.DefaultUncloudSockPath, + }, + expected: []string{"-o", "ConnectTimeout=5", "root@example.com", "uncloudd", "dial-stdio"}, + }, + { + name: "all options combined", + config: SSHConnectorConfig{ + User: "root", + Host: "example.com", + Port: 2222, + KeyPath: "/path/to/key", + SockPath: "/custom/path/uncloud.sock", + }, + expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "-i", "/path/to/key", "root@example.com", "uncloudd", "dial-stdio", "--socket", "/custom/path/uncloud.sock"}, + }, + { + name: "port 22 not included (default)", + config: SSHConnectorConfig{ + User: "root", + Host: "example.com", + Port: 0, + }, + expected: []string{"-o", "ConnectTimeout=5", "root@example.com", "uncloudd", "dial-stdio"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + c := &SSHCLIConnector{config: tt.config} + got := c.buildSSHArgs() + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestSSHCLIDialer_buildDialArgs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config SSHConnectorConfig + address string + expected []string + }{ + { + name: "basic connection", + config: SSHConnectorConfig{ + User: "root", + Host: "example.com", + }, + address: "10.210.1.1:5000", + expected: []string{"-o", "ConnectTimeout=5", "-W", "10.210.1.1:5000", "root@example.com"}, + }, + { + name: "custom port", + config: SSHConnectorConfig{ + User: "root", + Host: "example.com", + Port: 2222, + }, + address: "10.210.1.1:5000", + expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "-W", "10.210.1.1:5000", "root@example.com"}, + }, + { + name: "with identity file", + config: SSHConnectorConfig{ + User: "root", + Host: "example.com", + Port: 22, + KeyPath: "/home/user/.ssh/id_rsa", + }, + address: "10.210.1.1:5000", + expected: []string{"-o", "ConnectTimeout=5", "-i", "/home/user/.ssh/id_rsa", "-W", "10.210.1.1:5000", "root@example.com"}, + }, + { + name: "custom port with identity file", + config: SSHConnectorConfig{ + User: "root", + Host: "example.com", + Port: 2222, + KeyPath: "/home/user/.ssh/id_rsa", + }, + address: "10.210.1.1:5000", + expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "-i", "/home/user/.ssh/id_rsa", "-W", "10.210.1.1:5000", "root@example.com"}, + }, + { + name: "zero port defaults to 22", + config: SSHConnectorConfig{ + User: "root", + Host: "example.com", + Port: 0, + }, + address: "10.210.1.1:5000", + expected: []string{"-o", "ConnectTimeout=5", "-W", "10.210.1.1:5000", "root@example.com"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + d := &sshCLIDialer{config: tt.config} + got := d.buildDialArgs(tt.address) + assert.Equal(t, tt.expected, got) + }) + } +}