mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 11:03:34 +00:00
Connect to remote SSH nodes using SSH CLI (#152)
* Connect to remote SSH nodes using SSH CLI Replace Go-native SSH implementation with SSH CLI execution to support diverse SSH configurations and agents. Implements 'uncloudd dial-stdio' subcommand that proxies gRPC connections over stdin/stdout, similar to Docker's approach. This change addresses compatibility issues with: - SSH agents exposing many keys (1Password, causing "too many authentication failures") - Tailscale SSH (which doesn't support advanced SSH channel types like direct-streamlocal) - Custom SSH configurations in ~/.ssh/config The dial-stdio approach reduces SSH feature requirements by streaming the unix socket connection over stdin/stdout instead of using SSH channel forwarding. Changes: - Add 'uncloudd dial-stdio' hidden subcommand for socket proxy - Add SSHCLIConnector using ssh command + dial-stdio - Update connection logic to use SSH CLI connector - Maintain backward compatibility with SSHKeyFile config Resolves #131 * Fix sshcli tests missing ConnectionTimeout Introduced short connection timeout on the first change but forgot to update tests to match. * Add SSHCLI field and update MachineConnection String() format - Add SSHCLI field to support ssh_cli YAML configuration - Update String() to use URI-like format (ssh://, ssh+cli://, tcp://) * Add Validate() method and tests for MachineConnection - Add Validate() to ensure connection methods are mutually exclusive - Add tests for validation and String() method * Unify SSH connector configs to use SSHConnectorConfig * Update connectCluster to support both SSH connector types * Restore Go SSH connector as default for machine init/add Revert provisionOrConnectRemoteMachine to use Go SSH connector: - Root users: reuse SSH connection from provisioning - Non-root users: establish new connection for group membership - Remove SSH CLI as default connector SSH CLI connector remains available via ssh_cli config field. * Add sshCLIDialer with DialContext method Implement proxy.ContextDialer for SSHCLIConnector using SSH -W flag. Each dial spawns a new SSH process for TCP forwarding, enabling independent connections separate from the gRPC dial-stdio connection. * Implement SSHCLIConnector.Dialer() method Return sshCLIDialer instead of error, enabling uc image push functionality with SSHCLIConnector. Validates connector is configured before returning dialer. * Fix half-closing implementation matchin Docker's approach * Use testify assertions for connection tests * Allow ssh+cli:// to be used with --connect This way I can skip the configuration file while testing things out, and confirm it works correctly: $ unset SSH_AUTH_SOCK $ ./uncloud --connect ssh://provision@blatta11 machine ls Error: connect to cluster: connect to machine: SSH login to provision@blatta11:22: connect using SSH agent: connect to SSH agent: dial unix: missing address $ ./uncloud --connect ssh+cli://provision@blatta11 machine ls NAME STATE ADDRESS PUBLIC IP WIREGUARD ENDPOINTS MACHINE ID blatta11 Up 10.210.0.1/24 - 100.64.0.22:51820, ... * Validates configuration before connecting to cluster * Do not tie client constructor with real validation No longer attempt to validate the connection when instantiating a new client. Later on we could validate it in different places. * Cleanup test and remove AI-slop There were some serious slop in those tests, so took the time to clean them up and kept only the relevant ones. There is some repetition between buildSSHArgs and buildDialArgs but can be tackled at a later stage. * Fix connection representation tests Prefix connection with ssh ssh+cli respectively. * Wait for stdout before returning Missed copy & pasta from Docker dial-stdio implementation (this happens when you stare at the code for too long that it burns your eyes).
This commit is contained in:
+1
-1
@@ -13,7 +13,7 @@ COPY go.mod go.sum ./
|
|||||||
RUN go mod download && go mod verify
|
RUN go mod download && go mod verify
|
||||||
|
|
||||||
COPY . .
|
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
|
FROM alpine:${ALPINE_VERSION} AS corrosion-download
|
||||||
|
|||||||
+6
-1
@@ -50,6 +50,11 @@ func main() {
|
|||||||
conn = &config.MachineConnection{
|
conn = &config.MachineConnection{
|
||||||
TCP: &addrPort,
|
TCP: &addrPort,
|
||||||
}
|
}
|
||||||
|
} else if strings.HasPrefix(opts.connect, "ssh+cli://") {
|
||||||
|
dest := opts.connect[len("ssh+cli://"):]
|
||||||
|
conn = &config.MachineConnection{
|
||||||
|
SSHCLI: config.SSHDestination(dest),
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
dest := opts.connect
|
dest := opts.connect
|
||||||
if strings.HasPrefix(dest, "ssh://") {
|
if strings.HasPrefix(dest, "ssh://") {
|
||||||
@@ -73,7 +78,7 @@ func main() {
|
|||||||
|
|
||||||
cmd.PersistentFlags().StringVar(&opts.connect, "connect", "",
|
cmd.PersistentFlags().StringVar(&opts.connect, "connect", "",
|
||||||
"Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]\n"+
|
"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",
|
cmd.PersistentFlags().StringVar(&opts.configPath, "uncloud-config", "~/.config/uncloud/config.yaml",
|
||||||
"Path to the Uncloud configuration file. [$UNCLOUD_CONFIG]")
|
"Path to the Uncloud configuration file. [$UNCLOUD_CONFIG]")
|
||||||
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "yaml", "yml")
|
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "yaml", "yml")
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -42,6 +42,9 @@ func main() {
|
|||||||
"Directory for storing persistent machine state")
|
"Directory for storing persistent machine state")
|
||||||
_ = cmd.MarkFlagDirname("data-dir")
|
_ = cmd.MarkFlagDirname("data-dir")
|
||||||
|
|
||||||
|
// Add dial-stdio subcommand.
|
||||||
|
cmd.AddCommand(newDialStdioCommand())
|
||||||
|
|
||||||
// ctx is canceled when the daemon command is interrupted.
|
// ctx is canceled when the daemon command is interrupted.
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
@@ -17,6 +18,7 @@ const (
|
|||||||
|
|
||||||
type MachineConnection struct {
|
type MachineConnection struct {
|
||||||
SSH SSHDestination `yaml:"ssh,omitempty"`
|
SSH SSHDestination `yaml:"ssh,omitempty"`
|
||||||
|
SSHCLI SSHDestination `yaml:"ssh_cli,omitempty"`
|
||||||
SSHKeyFile string `yaml:"ssh_key_file,omitempty"`
|
SSHKeyFile string `yaml:"ssh_key_file,omitempty"`
|
||||||
// TCP is the address and port of the machine's API server.
|
// 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.
|
// 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 {
|
func (c MachineConnection) String() string {
|
||||||
if c.SSH != "" {
|
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() {
|
} else if c.TCP != nil && c.TCP.IsValid() {
|
||||||
return fmt.Sprintf("tcp://%s", c.TCP)
|
return fmt.Sprintf("tcp://%s", c.TCP)
|
||||||
}
|
}
|
||||||
return "unknown connection"
|
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".
|
// SSHDestination represents an SSH destination string in the canonical form of "user@host:port".
|
||||||
// The default user "root" and port 22 can be omitted.
|
// The default user "root" and port 22 can be omitted.
|
||||||
type SSHDestination string
|
type SSHDestination string
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
-17
@@ -56,26 +56,47 @@ func connectClusterWithProgress(ctx context.Context, conn config.MachineConnecti
|
|||||||
}
|
}
|
||||||
|
|
||||||
func connectCluster(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
|
func connectCluster(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
|
||||||
if conn.SSH != "" {
|
// Determine which SSH type is configured
|
||||||
user, host, port, err := conn.SSH.Parse()
|
var sshDest config.SSHDestination
|
||||||
if err != nil {
|
var useSSHCLI bool
|
||||||
return nil, fmt.Errorf("parse SSH connection %q: %w", conn.SSH, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
keyPath := fs.ExpandHomeDir(conn.SSHKeyFile)
|
// Validate connection configuration early to provide clear error messages.
|
||||||
|
if err := conn.Validate(); err != nil {
|
||||||
sshConfig := &connector.SSHConnectorConfig{
|
return nil, fmt.Errorf("invalid connection configuration: %w", err)
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
// connectModel is a TUI model for connecting to a cluster with a progress spinner.
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ func New(ctx context.Context, connector Connector) (*Client, error) {
|
|||||||
c.ClusterClient = pb.NewClusterClient(c.conn)
|
c.ClusterClient = pb.NewClusterClient(c.conn)
|
||||||
c.Caddy = pb.NewCaddyClient(c.conn)
|
c.Caddy = pb.NewCaddyClient(c.conn)
|
||||||
c.Docker = docker.NewClient(c.conn)
|
c.Docker = docker.NewClient(c.conn)
|
||||||
|
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user