Files
uncloud/pkg/client/connector/ssh.go
T
897f30fd36 feat: add client/server version check mechanism to gRPC calls (#260)
* Add version check mechanism to gRPC calls

* Use semver not semver/v3

* Give dev builds a special "infinite" version number (999.0.0-dev)

* Handle no metadata on grpc call correctly for version check

* Move versioncheck package to root pkg/ from pkg/api/ since it is shared by both pkg/api/ and pkg/client/

* Only show "no daemon version" warning once

* Append version headers to metadata, not overwrite...

* Unit tests on versioncheck logic

* Move SetHeader to the ServerStream in ServerStreamInterceptor

* Go modernizer nits: interface{} -> any

* Use more conventional gRPC header names for version/min-versions

* Add TODO notes on checkDaemonVersionInResponse and related code that can be removed eventually after transition to version checking client/daemons

* Use testify for testing assertions

* Add explanatory comments on MinCLIVersion and MinDaemonVersion

---------

Co-authored-by: Pasha Sviderski <me@psviderski.name>
2026-04-08 19:52:42 +10:00

119 lines
3.2 KiB
Go

package connector
import (
"context"
"errors"
"fmt"
"net"
"strings"
"github.com/psviderski/uncloud/internal/machine"
"github.com/psviderski/uncloud/internal/sshexec"
"github.com/psviderski/uncloud/pkg/versioncheck"
"golang.org/x/crypto/ssh"
"golang.org/x/net/proxy"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
type SSHConnectorConfig struct {
User string
Host string
Port int
KeyPath string
SockPath string
}
// Destination returns the destination string that can be passed to ssh CLI in the format [user@]host.
func (cfg *SSHConnectorConfig) Destination() string {
dst := cfg.Host
if cfg.User != "" {
dst = fmt.Sprintf("%s@%s", cfg.User, dst)
}
return dst
}
// SSHConnector establishes a connection to the machine API through an SSH tunnel to the machine.
type SSHConnector struct {
config SSHConnectorConfig
client *ssh.Client
}
func NewSSHConnector(cfg *SSHConnectorConfig) *SSHConnector {
return &SSHConnector{config: *cfg}
}
func NewSSHConnectorFromClient(client *ssh.Client) *SSHConnector {
return &SSHConnector{client: client}
}
// TODO: handle context cancelation.
func (c *SSHConnector) Connect(ctx context.Context) (*grpc.ClientConn, error) {
if c.client == nil {
// Establish an SSH connection if the SSH client is not provided.
if c.config == (SSHConnectorConfig{}) {
return nil, fmt.Errorf("SSH connector not configured")
}
var err error
c.client, err = sshexec.Connect(c.config.User, c.config.Host, c.config.Port, c.config.KeyPath)
if err != nil {
dst := c.config.Destination()
if c.config.Port != 0 {
dst = fmt.Sprintf("%s:%d", dst, c.config.Port)
}
return nil, fmt.Errorf("SSH login to %s: %w", dst, err)
}
}
sockPath := c.config.SockPath
if sockPath == "" {
sockPath = machine.DefaultUncloudSockPath
}
conn, err := grpc.NewClient(
"unix://"+sockPath,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultServiceConfig(defaultServiceConfig),
grpc.WithUnaryInterceptor(versioncheck.ClientUnaryInterceptor),
grpc.WithStreamInterceptor(versioncheck.ClientStreamInterceptor),
grpc.WithContextDialer(
func(ctx context.Context, addr string) (net.Conn, error) {
addr = strings.TrimPrefix(addr, "unix://")
conn, dErr := c.client.DialContext(ctx, "unix", addr)
if dErr != nil {
return nil, fmt.Errorf(
"connect to machine API socket '%s' through SSH tunnel (is uncloud.service running "+
"on the remote machine and does the SSH user '%s' have permissions to access the socket?):"+
" %w",
addr, c.client.User(), dErr,
)
}
return conn, nil
},
),
)
if err != nil {
return nil, fmt.Errorf("create machine API client: %w", err)
}
return conn, nil
}
// Dialer returns a proxy dialer for establishing connections within the cluster through the SSH tunnel.
// The connector must be created with an existing SSH client or Connect must be called first.
func (c *SSHConnector) Dialer() (proxy.ContextDialer, error) {
if c.client == nil {
return nil, errors.New("SSH connection must be established first")
}
return c.client, nil
}
func (c *SSHConnector) Close() error {
if c.client != nil {
err := c.client.Close()
c.client = nil
return err
}
return nil
}