From 91a09d5d37b62f98f592b152e8f7ec69111ea668 Mon Sep 17 00:00:00 2001 From: Justin Bradford Date: Tue, 25 Nov 2025 03:45:07 -0800 Subject: [PATCH] feat: add CLI connector for unix domain socket (#186) * feat: Add cli connector for unix domain socket * Update cmd/uncloud/main.go --------- Co-authored-by: Pasha Sviderski --- cmd/uncloud/main.go | 11 +++---- internal/cli/config/connection.go | 19 ++++++++---- internal/cli/config/connection_test.go | 23 +++++++++++++++ internal/cli/connect.go | 2 ++ pkg/client/connector/unix.go | 41 ++++++++++++++++++++++++++ 5 files changed, 85 insertions(+), 11 deletions(-) create mode 100644 pkg/client/connector/unix.go diff --git a/cmd/uncloud/main.go b/cmd/uncloud/main.go index 29aa8be0..cbae5d40 100644 --- a/cmd/uncloud/main.go +++ b/cmd/uncloud/main.go @@ -58,11 +58,12 @@ func main() { conn = &config.MachineConnection{ SSHCLI: config.SSHDestination(dest), } - } else { - dest := opts.connect - if strings.HasPrefix(dest, "ssh://") { - dest = dest[len("ssh://"):] + } else if strings.HasPrefix(opts.connect, "unix://") { + conn = &config.MachineConnection{ + Unix: opts.connect[len("unix://"):], } + } else { + dest := strings.TrimPrefix(opts.connect, "ssh://") conn = &config.MachineConnection{ SSH: config.SSHDestination(dest), } @@ -81,7 +82,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], ssh+cli://user@host[:port], or tcp://host:port") + "Format: [ssh://]user@host[:port], ssh+cli://user@host[:port], tcp://host:port, or unix:///path/to/uncloud.sock") 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/internal/cli/config/connection.go b/internal/cli/config/connection.go index 42f374ac..2051b95c 100644 --- a/internal/cli/config/connection.go +++ b/internal/cli/config/connection.go @@ -22,10 +22,12 @@ type MachineConnection struct { 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. - TCP *netip.AddrPort `yaml:"tcp,omitempty"` - Host string `yaml:"host,omitempty"` - PublicKey secret.Secret `yaml:"public_key,omitempty"` - MachineID string `yaml:"machine_id,omitempty"` + TCP *netip.AddrPort `yaml:"tcp,omitempty"` + // Unix is the path to the machine's API unix socket. + Unix string `yaml:"unix,omitempty"` + Host string `yaml:"host,omitempty"` + PublicKey secret.Secret `yaml:"public_key,omitempty"` + MachineID string `yaml:"machine_id,omitempty"` } func (c MachineConnection) String() string { @@ -35,6 +37,8 @@ func (c MachineConnection) String() string { return "ssh+cli://" + string(c.SSHCLI) } else if c.TCP != nil && c.TCP.IsValid() { return fmt.Sprintf("tcp://%s", c.TCP) + } else if c.Unix != "" { + return fmt.Sprintf("unix://%s", c.Unix) } return "unknown connection" } @@ -50,12 +54,15 @@ func (c *MachineConnection) Validate() error { if c.TCP != nil && c.TCP.IsValid() { setCount++ } + if c.Unix != "" { + setCount++ + } if setCount == 0 { - return errors.New("no connection method specified (ssh, ssh_cli, or tcp required)") + return errors.New("no connection method specified (ssh, ssh_cli, tcp, or unix required)") } if setCount > 1 { - return errors.New("only one connection method allowed per connection (ssh, ssh_cli, or tcp)") + return errors.New("only one connection method allowed per connection (ssh, ssh_cli, tcp, or unix)") } return nil diff --git a/internal/cli/config/connection_test.go b/internal/cli/config/connection_test.go index 3e6e9160..e28279d2 100644 --- a/internal/cli/config/connection_test.go +++ b/internal/cli/config/connection_test.go @@ -53,6 +53,13 @@ func TestMachineConnection_String(t *testing.T) { }, want: "tcp://10.0.0.1:8080", }, + { + name: "unix connection", + conn: MachineConnection{ + Unix: "/run/uncloud/uncloud.sock", + }, + want: "unix:///run/uncloud/uncloud.sock", + }, { name: "no connection", conn: MachineConnection{}, @@ -112,6 +119,13 @@ func TestMachineConnection_Validate(t *testing.T) { }, wantErr: false, }, + { + name: "unix only - valid", + conn: MachineConnection{ + Unix: "/path/to/socket", + }, + wantErr: false, + }, { name: "no connection method - error", conn: MachineConnection{}, @@ -127,6 +141,15 @@ func TestMachineConnection_Validate(t *testing.T) { wantErr: true, errMsg: "only one connection method allowed", }, + { + name: "ssh and unix - error", + conn: MachineConnection{ + SSH: "user@host", + Unix: "/path/to/socket", + }, + wantErr: true, + errMsg: "only one connection method allowed", + }, { name: "ssh and tcp - error", conn: MachineConnection{ diff --git a/internal/cli/connect.go b/internal/cli/connect.go index f497bf9e..f080089b 100644 --- a/internal/cli/connect.go +++ b/internal/cli/connect.go @@ -73,6 +73,8 @@ func connectCluster(ctx context.Context, conn config.MachineConnection) (*client useSSHCLI = true } else if conn.TCP != nil && conn.TCP.IsValid() { return client.New(ctx, connector.NewTCPConnector(*conn.TCP)) + } else if conn.Unix != "" { + return client.New(ctx, connector.NewUnixConnector(conn.Unix)) } else { return nil, errors.New("connection configuration is invalid") } diff --git a/pkg/client/connector/unix.go b/pkg/client/connector/unix.go new file mode 100644 index 00000000..b338f5ef --- /dev/null +++ b/pkg/client/connector/unix.go @@ -0,0 +1,41 @@ +package connector + +import ( + "context" + "fmt" + + "golang.org/x/net/proxy" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +// UnixConnector establishes a connection to the machine API through a unix domain socket. +type UnixConnector struct { + socketPath string +} + +func NewUnixConnector(socketPath string) *UnixConnector { + return &UnixConnector{socketPath: socketPath} +} + +func (c *UnixConnector) Connect(_ context.Context) (*grpc.ClientConn, error) { + // gRPC uses "unix:path" syntax for unix sockets. + target := "unix:" + c.socketPath + + conn, err := grpc.NewClient( + target, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + return nil, fmt.Errorf("create machine API client: %w", err) + } + return conn, nil +} + +func (c *UnixConnector) Dialer() (proxy.ContextDialer, error) { + return nil, fmt.Errorf("proxy connections are not supported over a unix connection") +} + +func (c *UnixConnector) Close() error { + return nil +}