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 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).
211 lines
5.5 KiB
Go
211 lines
5.5 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/spinner"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/psviderski/uncloud/internal/cli/config"
|
|
"github.com/psviderski/uncloud/internal/fs"
|
|
"github.com/psviderski/uncloud/pkg/client"
|
|
"github.com/psviderski/uncloud/pkg/client/connector"
|
|
)
|
|
|
|
// ConnectOptions configures the behavior of cluster connection attempts.
|
|
type ConnectOptions struct {
|
|
// Whether to show connection progress spinner if stdout is a terminal or progress logs if not.
|
|
ShowProgress bool
|
|
}
|
|
|
|
func ConnectCluster(ctx context.Context, conn config.MachineConnection, opts ConnectOptions) (*client.Client, error) {
|
|
if opts.ShowProgress {
|
|
return connectClusterWithProgress(ctx, conn)
|
|
}
|
|
return connectCluster(ctx, conn)
|
|
}
|
|
|
|
// connectClusterWithProgress connects to the cluster while displaying a progress spinner.
|
|
// If the stdout is not a terminal, it falls back to simple progress logs to stderr.
|
|
func connectClusterWithProgress(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
|
|
// If stdout is not a terminal, fall back to simple progress logs.
|
|
if !IsStdoutTerminal() {
|
|
fmt.Fprintln(os.Stderr, "Connecting to", conn.String())
|
|
cli, err := connectCluster(ctx, conn)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "Connection failed:", err)
|
|
} else {
|
|
fmt.Fprintln(os.Stderr, "Connected to cluster.")
|
|
}
|
|
return cli, err
|
|
}
|
|
|
|
// Run the connection TUI model.
|
|
p := tea.NewProgram(newConnectModel(ctx, conn))
|
|
model, err := p.Run()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("run connection TUI: %w", err)
|
|
}
|
|
|
|
m := model.(connectModel)
|
|
return m.result.client, m.result.err
|
|
}
|
|
|
|
func connectCluster(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
|
|
// Determine which SSH type is configured
|
|
var sshDest config.SSHDestination
|
|
var useSSHCLI bool
|
|
|
|
// Validate connection configuration early to provide clear error messages.
|
|
if err := conn.Validate(); err != nil {
|
|
return nil, fmt.Errorf("invalid connection configuration: %w", err)
|
|
}
|
|
|
|
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.
|
|
type connectModel struct {
|
|
ctx context.Context
|
|
conn config.MachineConnection
|
|
spinner spinner.Model
|
|
// showSpinner controls whether the spinner is visible (delayed to avoid flashing).
|
|
showSpinner bool
|
|
// done indicates whether the connection attempt has completed (successfully or with error).
|
|
done bool
|
|
// result holds the result of the connection attempt.
|
|
result connectResultMsg
|
|
}
|
|
|
|
type connectResultMsg struct {
|
|
client *client.Client
|
|
err error
|
|
}
|
|
|
|
// showSpinnerMsg is sent after a delay to show the spinner.
|
|
type showSpinnerMsg struct{}
|
|
|
|
func newConnectModel(ctx context.Context, conn config.MachineConnection) connectModel {
|
|
s := spinner.New()
|
|
s.Spinner = spinner.MiniDot
|
|
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // the same yellow as in compose progress
|
|
|
|
return connectModel{
|
|
ctx: ctx,
|
|
conn: conn,
|
|
spinner: s,
|
|
}
|
|
}
|
|
|
|
func (m connectModel) Init() tea.Cmd {
|
|
return tea.Batch(
|
|
m.spinner.Tick,
|
|
m.connect(),
|
|
m.delayShowSpinner(),
|
|
)
|
|
}
|
|
|
|
func (m connectModel) connect() tea.Cmd {
|
|
return func() tea.Msg {
|
|
cli, err := connectCluster(m.ctx, m.conn)
|
|
return connectResultMsg{
|
|
client: cli,
|
|
err: err,
|
|
}
|
|
}
|
|
}
|
|
|
|
// delayShowSpinner returns a command that sends a message to show the spinner after a delay.
|
|
// This avoids flashing the spinner if the connection is fast.
|
|
func (m connectModel) delayShowSpinner() tea.Cmd {
|
|
return tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg {
|
|
return showSpinnerMsg{}
|
|
})
|
|
}
|
|
|
|
func (m connectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
select {
|
|
case <-m.ctx.Done():
|
|
m.result.err = m.ctx.Err()
|
|
m.done = true
|
|
return m, tea.Quit
|
|
default:
|
|
}
|
|
|
|
switch msg := msg.(type) {
|
|
case connectResultMsg:
|
|
m.result = msg
|
|
m.done = true
|
|
return m, tea.Quit
|
|
|
|
case showSpinnerMsg:
|
|
// Only show spinner if connection hasn't completed yet.
|
|
if !m.done {
|
|
m.showSpinner = true
|
|
}
|
|
return m, nil
|
|
|
|
case spinner.TickMsg:
|
|
var cmd tea.Cmd
|
|
m.spinner, cmd = m.spinner.Update(msg)
|
|
return m, cmd
|
|
|
|
case tea.KeyMsg:
|
|
if msg.Type == tea.KeyCtrlC {
|
|
m.result.err = fmt.Errorf("connection cancelled")
|
|
m.done = true
|
|
return m, tea.Quit
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m connectModel) View() string {
|
|
// Don't show anything if done or spinner not yet visible.
|
|
if m.done || !m.showSpinner {
|
|
return ""
|
|
}
|
|
|
|
style := lipgloss.NewStyle().Foreground(lipgloss.Color("153"))
|
|
return fmt.Sprintf("%s %s\n",
|
|
m.spinner.View(),
|
|
fmt.Sprintf("Connecting to %s", style.Render(m.conn.String())),
|
|
)
|
|
}
|