From 073f82d0d0872a6fc8a7b83c4eb9d33a6734d086 Mon Sep 17 00:00:00 2001 From: wuyue Date: Sat, 7 Nov 2026 09:46:24 +0800 Subject: [PATCH 1/7] feat(ssh): support OpenSSH multiplexing and control persist Implements full OpenSSH-compatible multiplexing (ControlMaster, ControlPath, ControlPersist) across CLI commands (ssh, exec, scp, sftp) and core/ssh transport. Key improvements: - Control socket lifecycle management, daemon handoff, and background execution - OpenSSH mux client protocol implementation (-O check/forward/cancel/exit/stop/proxy) - Socket access control and peer credential verification on Unix/Linux/macOS/FreeBSD - File descriptor passing for passenger sessions over Unix domain sockets - Preserves core architecture decoupling and full invariant alignment across target resolution --- cmd/cli/main.go | 8 + cmd/command_output.go | 6 +- cmd/exec.go | 56 +- cmd/scp.go | 160 +- cmd/sftp.go | 20 +- cmd/ssh.go | 1022 ++++++- cmd/ssh_control.go | 694 +++++ cmd/ssh_control_adversarial_test.go | 576 ++++ cmd/ssh_control_guard_test.go | 2712 +++++++++++++++++ cmd/ssh_control_resolution_test.go | 537 ++++ cmd/ssh_control_test.go | 939 ++++++ cmd/ssh_interaction.go | 35 +- cmd/ssh_option_flag.go | 366 +++ cmd/ssh_option_flag_test.go | 566 ++++ core/ssh/adversarial_lifecycle_test.go | 706 +++++ core/ssh/client.go | 6 +- core/ssh/connector.go | 725 ++++- core/ssh/connector_mux_reuse_test.go | 222 ++ core/ssh/connector_mux_test.go | 650 ++++ core/ssh/control_config.go | 557 ++++ core/ssh/control_config_test.go | 573 ++++ core/ssh/environment.go | 7 + core/ssh/execute.go | 4 +- core/ssh/mux_client.go | 575 ++++ core/ssh/mux_client_test.go | 336 ++ core/ssh/mux_lifecycle_test.go | 495 +++ core/ssh/mux_passenger_fd_test.go | 687 +++++ core/ssh/mux_peer_darwin.go | 17 + core/ssh/mux_peer_freebsd.go | 17 + core/ssh/mux_peer_linux.go | 17 + core/ssh/mux_protocol.go | 613 ++++ core/ssh/mux_protocol_test.go | 388 +++ core/ssh/mux_proxy_close_test.go | 322 ++ core/ssh/mux_proxy_conn.go | 343 +++ core/ssh/mux_proxy_conn_test.go | 127 + core/ssh/mux_proxy_flow_test.go | 563 ++++ core/ssh/mux_proxy_test.go | 793 +++++ core/ssh/mux_rights_other.go | 50 + core/ssh/mux_rights_unix.go | 706 +++++ core/ssh/mux_server.go | 2285 ++++++++++++++ core/ssh/mux_server_test.go | 620 ++++ core/ssh/plan.go | 6 + core/ssh/sftp_stream.go | 2 +- core/ssh/types_api.go | 12 +- .../governance/architecture-map.md | 23 +- docs/development/index.md | 1 + .../openssh-multiplexing-design.md | 550 ++++ .../governance/architecture-map.md | 23 +- docs/en/development/index.md | 1 + .../openssh-multiplexing-design.md | 542 ++++ internal/mcphost/connector.go | 2 +- internal/mcphost/host.go | 13 +- internal/sshenv/environment.go | 24 + pkg/adapter/ssh_adapter.go | 554 +++- pkg/adapter/ssh_adapter_control_test.go | 779 +++++ pkg/adapter/ssh_adapter_test.go | 89 + pkg/adapter/verification.go | 2 +- pkg/adapter/verification_test.go | 82 + pkg/config/openssh.go | 117 + pkg/config/openssh_import.go | 40 + pkg/config/openssh_import_test.go | 92 + pkg/config/openssh_test.go | 256 ++ pkg/config/pending_connection.go | 103 +- pkg/config/provider.go | 98 +- pkg/config/repository.go | 45 +- pkg/config/schema_v2.go | 25 + pkg/config/schema_v2_test.go | 282 ++ pkg/config/target.go | 970 ++++-- pkg/config/target_test.go | 167 + pkg/config/types.go | 2 + pkg/config/validate.go | 142 + pkg/config/validate_test.go | 166 + pkg/i18n/locales/active.en.yaml | 8 + pkg/i18n/locales/active.zh.yaml | 8 + pkg/logger/interface.go | 4 + pkg/models/types.go | 6 + pkg/models/types_test.go | 47 + pkg/playbook/adversarial_concurrency_test.go | 389 +++ pkg/playbook/playbook_control_test.go | 418 +++ 79 files changed, 25750 insertions(+), 441 deletions(-) create mode 100644 cmd/ssh_control.go create mode 100644 cmd/ssh_control_adversarial_test.go create mode 100644 cmd/ssh_control_guard_test.go create mode 100644 cmd/ssh_control_resolution_test.go create mode 100644 cmd/ssh_control_test.go create mode 100644 cmd/ssh_option_flag.go create mode 100644 cmd/ssh_option_flag_test.go create mode 100644 core/ssh/adversarial_lifecycle_test.go create mode 100644 core/ssh/connector_mux_reuse_test.go create mode 100644 core/ssh/connector_mux_test.go create mode 100644 core/ssh/control_config.go create mode 100644 core/ssh/control_config_test.go create mode 100644 core/ssh/mux_client.go create mode 100644 core/ssh/mux_client_test.go create mode 100644 core/ssh/mux_lifecycle_test.go create mode 100644 core/ssh/mux_passenger_fd_test.go create mode 100644 core/ssh/mux_peer_darwin.go create mode 100644 core/ssh/mux_peer_freebsd.go create mode 100644 core/ssh/mux_peer_linux.go create mode 100644 core/ssh/mux_protocol.go create mode 100644 core/ssh/mux_protocol_test.go create mode 100644 core/ssh/mux_proxy_close_test.go create mode 100644 core/ssh/mux_proxy_conn.go create mode 100644 core/ssh/mux_proxy_conn_test.go create mode 100644 core/ssh/mux_proxy_flow_test.go create mode 100644 core/ssh/mux_proxy_test.go create mode 100644 core/ssh/mux_rights_other.go create mode 100644 core/ssh/mux_rights_unix.go create mode 100644 core/ssh/mux_server.go create mode 100644 core/ssh/mux_server_test.go create mode 100644 docs/development/openssh-multiplexing-design.md create mode 100644 docs/en/development/openssh-multiplexing-design.md create mode 100644 pkg/adapter/ssh_adapter_control_test.go create mode 100644 pkg/config/validate.go create mode 100644 pkg/config/validate_test.go create mode 100644 pkg/playbook/adversarial_concurrency_test.go create mode 100644 pkg/playbook/playbook_control_test.go diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 88266757..6e4fa0a1 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -33,6 +33,14 @@ func main() { } if err := cmd.Execute(); err != nil { + if code, ok := cmd.ExitCodeFromError(err); ok { + if !cmd.ErrorAlreadyReported(err) { + // Control-command diagnostics must stay byte-for-byte compatible + // with OpenSSH: plain stderr, no color or log prefix. + fmt.Fprintln(os.Stderr, err.Error()) + } + os.Exit(code) + } reportError(err) os.Exit(1) } diff --git a/cmd/command_output.go b/cmd/command_output.go index 5e4276dd..58980615 100644 --- a/cmd/command_output.go +++ b/cmd/command_output.go @@ -34,7 +34,11 @@ func writeCommandOutput(ctx context.Context, target io.Writer, render func(io.Wr // and rechecks capabilities before starting any remote command. Never retry a // command on the legacy path after strict execution has begun. func commandOutputSupported(ctx context.Context) (bool, error) { - for _, target := range []*os.File{os.Stdout, os.Stderr} { + return streamOutputSupported(ctx, os.Stdout, os.Stderr) +} + +func streamOutputSupported(ctx context.Context, stdout, stderr io.Writer) (bool, error) { + for _, target := range []io.Writer{stdout, stderr} { _, release, supported, err := ssh.BindOutput(ctx, target) err = errors.Join(err, release()) if err != nil { diff --git a/cmd/exec.go b/cmd/exec.go index d5a2375f..e0f9a545 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -11,6 +11,7 @@ import ( "sync" "github.com/spf13/cobra" + "github.com/spf13/pflag" "github.com/wentf9/xops-cli/cmd/utils" "github.com/wentf9/xops-cli/core/ssh" "github.com/wentf9/xops-cli/pkg/adapter" @@ -77,6 +78,7 @@ func newCmdExecWithOptions(o *ExecOptions) *cobra.Command { cmd.Flags().StringVarP(&o.User, "login", "l", "", i18n.T("flag_login")) cmd.Flags().StringVarP(&o.IdentityFile, "identity", "i", "", i18n.T("flag_identity")) cmd.Flags().StringVarP(&o.JumpHost, "jump", "J", "", i18n.T("flag_jump")) + o.control.RegisterCommonFlags(cmd) // xops-enhanced flags (long-form only, no short flags to avoid OpenSSH conflicts) cmd.Flags().StringVar(&o.Host, "host", "", i18n.T("flag_hosts")) @@ -245,6 +247,17 @@ func (o *ExecOptions) completeNonDashArgs(cmd *cobra.Command, args []string) err func (o *ExecOptions) Complete(cmd *cobra.Command, args []string) error { o.args = args + o.appliedGeneralOpts = false + var flags *pflag.FlagSet + if cmd != nil { + flags = cmd.Flags() + } + if err := ApplyGeneralOptions(&o.SshOptions, o.control.Options, flags); err != nil { + return err + } + if _, err := o.control.ToSessionControlOptions(); err != nil { + return err + } o.execution.capturePresence(cmd) if cmd != nil && cmd.Flags().Changed("cmd") && o.Command == "" { return fmt.Errorf("explicit command must not be empty") @@ -303,6 +316,12 @@ func (o *ExecOptions) readStdinIfRequired(ctx context.Context) error { } func (o *ExecOptions) Validate() error { + if err := o.ensureGeneralOptionsApplied(); err != nil { + return err + } + if err := o.control.Validate(); err != nil { + return err + } if o.OTP != "" && o.OTPStdin { return errors.New(i18n.T("err_otp_mutually_exclusive")) } @@ -367,6 +386,9 @@ func (o *ExecOptions) Run() error { // //nolint:gocyclo func (o *ExecOptions) RunContext(ctx context.Context) (retErr error) { + if err := o.ensureGeneralOptionsApplied(); err != nil { + return err + } configPath, keyPath, pathErr := utils.GetConfigFilePath() if pathErr != nil { return fmt.Errorf("get config file path failed: %w", pathErr) @@ -429,11 +451,14 @@ func (o *ExecOptions) RunContext(ctx context.Context) (retErr error) { } var connector *ssh.Connector if o.Interactive { - connector = newCLIConnectorWithAdapterOptions(provider, adpOpts, ssh.WithLogger(logger.DefaultLogger()), ssh.WithInteractionHandler(o.interaction)) + connector = newCLIConnectorWithAdapterOptions(provider, o.interaction, adpOpts, ssh.WithLogger(logger.DefaultLogger())) } else { connector = newNonInteractiveConnector(provider, adpOpts, ssh.WithLogger(logger.DefaultLogger())) } defer func() { + if handoffErr := o.handoffConnectorMasters(ctx, connector); handoffErr != nil { + logger.DefaultLogger().Debugf("mux: master handoff failed: %v", handoffErr) + } joinConnectorCloseError(&retErr, connector) }() @@ -558,11 +583,24 @@ func (o *ExecOptions) buildAdapterOptions(tasks []execHostTask, cfg *config.Conf // A global policy applies to proxy-jump nodes, which do not have a task // specific override. Explicit task credentials remain scoped to their target. adpOpts = append(adpOpts, adapter.WithGlobalSessionAuth(adapter.SessionAuth{Remember: remember})) + o.targetNodeIDs = make([]string, 0, len(tasks)) for _, task := range tasks { + o.targetNodeIDs = append(o.targetNodeIDs, task.nodeID) adpOpts = append(adpOpts, adapter.WithSessionAuthOverride(task.nodeID, adapter.SessionAuth{ - Password: task.pass, Passphrase: task.passphrase, KeyPath: task.keyPath, Remember: remember, OTP: o.OTP, + Password: task.pass, Passphrase: task.passphrase, KeyPath: task.keyPath, Remember: remember, OTP: o.OTP, ProxyJump: o.JumpHost, })) } + + // Session Control Overrides (Tier 1) + sessionCtrlOpts, err := o.control.ToSessionControlOptions() + if err != nil { + return nil, err + } + if !sessionCtrlOpts.IsEmpty() { + for _, task := range tasks { + adpOpts = append(adpOpts, adapter.WithSessionControlOverride(task.nodeID, sessionCtrlOpts)) + } + } if reg, regErr := utils.GetCredentialRegistry(cfg); regErr != nil { return nil, fmt.Errorf("initialize credential resolver: %w", regErr) } else if reg != nil { @@ -859,6 +897,9 @@ func sanitizeHostForFilename(host string) string { } func (o *ExecOptions) buildTasksFromTags(provider config.ConfigProvider) ([]execHostTask, error) { + if err := o.ensureGeneralOptionsApplied(); err != nil { + return nil, err + } var tasks []execHostTask nodes := provider.GetNodesByTag(o.Tag) if len(nodes) == 0 { @@ -892,6 +933,9 @@ func (o *ExecOptions) buildTasksFromTags(provider config.ConfigProvider) ([]exec } func (o *ExecOptions) buildTasksFromHosts(ctx context.Context, repository *config.Repository) ([]execHostTask, []error, error) { + if err := o.ensureGeneralOptionsApplied(); err != nil { + return nil, nil, err + } var tasks []execHostTask var hostErrs []error hosts, err := utils.ParseHosts(o.Host, o.HostFile, "") @@ -978,6 +1022,14 @@ func appendExecAlias(slice []string, val string) ([]string, bool) { } func (o *ExecOptions) updateNodeFromHostInfo(ctx context.Context, nodeID string, repository *config.Repository, addr utils.HostInfo) (bool, error) { + if strings.HasPrefix(nodeID, config.OpenSSHNodePrefix) { + if o.JumpHost != "" { + if _, err := repository.ResolveProxyJumpChain(o.JumpHost); err != nil { + return false, fmt.Errorf("resolve exec proxy jump %q failed: %w", o.JumpHost, err) + } + } + return false, nil + } view := repository.View() ref, ok := view.NodeRefs[nodeID] if !ok { diff --git a/cmd/scp.go b/cmd/scp.go index 3e3d1e7d..6cb56daf 100644 --- a/cmd/scp.go +++ b/cmd/scp.go @@ -14,6 +14,7 @@ import ( pkgsftp "github.com/pkg/sftp" "github.com/schollz/progressbar/v3" "github.com/spf13/cobra" + "github.com/spf13/pflag" cmdutils "github.com/wentf9/xops-cli/cmd/utils" corelog "github.com/wentf9/xops-cli/core/log" "github.com/wentf9/xops-cli/core/sftp" @@ -78,6 +79,7 @@ func NewCmdScp() *cobra.Command { cmd.Flags().StringVarP(&o.IdentityFile, "identity", "i", "", i18n.T("flag_identity")) cmd.Flags().StringVarP(&o.JumpHost, "jump", "J", "", i18n.T("flag_jump")) cmd.Flags().BoolVarP(&o.Recursive, "recursive", "r", false, i18n.T("flag_recursive")) + o.control.RegisterCommonFlags(cmd) // xops-enhanced flags (long-form only, no short flags to avoid OpenSSH conflicts) cmd.Flags().StringVar(&o.Host, "host", "", i18n.T("flag_hosts")) @@ -110,6 +112,17 @@ func NewCmdScp() *cobra.Command { func (o *ScpOptions) Complete(cmd *cobra.Command, args []string) error { o.args = args + o.appliedGeneralOpts = false + var flags *pflag.FlagSet + if cmd != nil { + flags = cmd.Flags() + } + if err := ApplyGeneralOptions(&o.SshOptions, o.control.Options, flags); err != nil { + return err + } + if _, err := o.control.ToSessionControlOptions(); err != nil { + return err + } pathFlags := o.Source != "" || o.Dest != "" if cmd != nil { if cmd.Flags().Changed("port") && o.Port == 0 { @@ -172,6 +185,12 @@ func (o *ScpOptions) completeStdinCredentials() error { } func (o *ScpOptions) Validate() error { + if err := o.ensureGeneralOptionsApplied(); err != nil { + return err + } + if err := o.control.Validate(); err != nil { + return err + } if o.OTP != "" && o.OTPStdin { return errors.New(i18n.T("err_otp_mutually_exclusive")) } @@ -277,6 +296,9 @@ func (e *transferProgressErrors) Err() error { } func (o *ScpOptions) RunContext(ctx context.Context) (retErr error) { + if err := o.ensureGeneralOptionsApplied(); err != nil { + return err + } if o.interaction == nil { o.interaction = newCLIInteractionHandler() } @@ -307,6 +329,9 @@ func (o *ScpOptions) RunContext(ctx context.Context) (retErr error) { } defer func() { + if handoffErr := o.handoffConnectorMasters(ctx, connector); handoffErr != nil { + logger.DefaultLogger().Debugf("mux: master handoff failed: %v", handoffErr) + } joinConnectorCloseError(&retErr, connector) }() @@ -315,6 +340,9 @@ func (o *ScpOptions) RunContext(ctx context.Context) (retErr error) { // runTransfer borrows the caller-owned connector for path routing and transfer. func (o *ScpOptions) runTransfer(ctx context.Context, provider config.ConfigProvider, connector *ssh.Connector) (retErr error) { + if err := o.ensureGeneralOptionsApplied(); err != nil { + return err + } if diagnostics, ok := o.diagnostics.(*scpDiagnostics); ok { diagnostics.transfer(o.Source, o.Dest, "starting") defer func() { @@ -761,12 +789,12 @@ func (o *ScpOptions) runBatch(ctx context.Context, provider config.ConfigProvide } } nid := nodeID // capture - _, hostObj, identity, resolveErr := provider.Resolve(nid) + _, _, identity, resolveErr := provider.Resolve(nid) if resolveErr != nil { return fmt.Errorf("resolve tagged scp node %q failed: %w", nid, resolveErr) } wp.Execute(func() { - addr := PathInfo{Host: hostObj.Address, User: identity.User, Port: hostObj.Port, IsRemote: true} + addr := PathInfo{Host: nid, IsRemote: true} if tErr := o.executeTransfer(ctx, nid, addr, identity.Password, provider, connector); tErr != nil { errMu.Lock() transferErrs = append(transferErrs, fmt.Errorf("[%s] %w", nid, tErr)) @@ -976,6 +1004,9 @@ func (o *ScpOptions) resolvePathInfo(path PathInfo) (string, string, uint16, err } func (o *ScpOptions) resolveTargetForPath(path PathInfo) (config.ConnectionTarget, error) { + if err := o.ensureGeneralOptionsApplied(); err != nil { + return config.ConnectionTarget{}, err + } host := path.Host if host == "" && o.Host != "" && !strings.Contains(o.Host, ",") { host = o.Host @@ -1047,6 +1078,14 @@ func (o *ScpOptions) getOrCreateNodeForPath(ctx context.Context, provider config } func (o *ScpOptions) updateNode(ctx context.Context, nodeID string, provider config.ConfigProvider, specificPassword string, shouldRemember bool) (bool, error) { + if strings.HasPrefix(nodeID, config.OpenSSHNodePrefix) { + if o.JumpHost != "" { + if _, err := provider.ResolveProxyJumpChain(o.JumpHost); err != nil { + return false, fmt.Errorf("resolve SCP proxy jump %q failed: %w", o.JumpHost, err) + } + } + return false, nil + } node, host, identity, err := provider.Resolve(nodeID) if err != nil { return false, fmt.Errorf("resolve scp node %q for update failed: %w", nodeID, err) @@ -1157,6 +1196,9 @@ func (o *ScpOptions) credentialConnector(provider *config.Repository, cfg *confi } func (o *ScpOptions) credentialConnectorWithInteraction(provider *config.Repository, cfg *config.Configuration, interaction *cliInteractionHandler) (*ssh.Connector, error) { + if err := o.ensureGeneralOptionsApplied(); err != nil { + return nil, err + } shouldRemember, adpOpts := interaction.rememberOptions(cmdutils.EffectiveRememberPolicy(o.Remember, cfg), cfg) batch := o.Tag != "" || strings.Contains(o.Host, ",") || o.HostFile != "" shouldRemember = !batch && shouldRemember @@ -1164,18 +1206,35 @@ func (o *ScpOptions) credentialConnectorWithInteraction(provider *config.Reposit adpOpts = append(adpOpts, adapter.WithNonInteractive(true)) } targetNodes := o.resolvedTargetNodeIDs(provider) + o.targetNodeIDs = targetNodes + keyPath := cmdutils.ToAbsolutePath(o.IdentityFile) if len(targetNodes) > 0 { adpOpts = append(adpOpts, adapter.WithGlobalSessionAuth(adapter.SessionAuth{Remember: shouldRemember})) for _, nodeID := range targetNodes { adpOpts = append(adpOpts, adapter.WithSessionAuthOverride(nodeID, adapter.SessionAuth{ - Password: o.Password, Passphrase: o.Passphrase, Remember: shouldRemember, OTP: o.OTP, + Password: o.Password, Passphrase: o.Passphrase, KeyPath: keyPath, Remember: shouldRemember, OTP: o.OTP, ProxyJump: o.JumpHost, })) } } else { adpOpts = append(adpOpts, adapter.WithGlobalSessionAuth(adapter.SessionAuth{ - Password: o.Password, Passphrase: o.Passphrase, Remember: shouldRemember, OTP: o.OTP, + Password: o.Password, Passphrase: o.Passphrase, KeyPath: keyPath, Remember: shouldRemember, OTP: o.OTP, })) } + + // Session Control Overrides (Tier 1) + sessionCtrlOpts, err := o.control.ToSessionControlOptions() + if err != nil { + return nil, err + } + if !sessionCtrlOpts.IsEmpty() { + if len(targetNodes) > 0 { + for _, nodeID := range targetNodes { + adpOpts = append(adpOpts, adapter.WithSessionControlOverride(nodeID, sessionCtrlOpts)) + } + } else { + adpOpts = append(adpOpts, adapter.WithGlobalSessionControl(sessionCtrlOpts)) + } + } if shouldRemember { persistence, serviceErr := interaction.automaticPersistenceOptions(provider, cfg) if serviceErr != nil { @@ -1192,7 +1251,7 @@ func (o *ScpOptions) credentialConnectorWithInteraction(provider *config.Reposit if batch { connector = newNonInteractiveConnector(provider, adpOpts, ssh.WithLogger(logger.DefaultLogger()), ssh.WithDiagnostics(o.diagnostics)) } else { - connector = newCLIConnectorWithAdapterOptions(provider, adpOpts, ssh.WithLogger(logger.DefaultLogger()), ssh.WithDiagnostics(o.diagnostics), ssh.WithInteractionHandler(interaction)) + connector = newCLIConnectorWithAdapterOptions(provider, interaction, adpOpts, ssh.WithLogger(logger.DefaultLogger()), ssh.WithDiagnostics(o.diagnostics)) } return connector, nil } @@ -1201,31 +1260,82 @@ func (o *ScpOptions) resolvedTargetNodeIDs(provider *config.Repository) []string if provider == nil { return nil } - var paths []PathInfo - if o.Host != "" && !strings.Contains(o.Host, ",") { - paths = append(paths, PathInfo{Host: o.Host, IsRemote: true}) - } - if src, err := parsePath(o.Source); err == nil && src.IsRemote { - paths = append(paths, src) - } - if dst, err := parsePath(o.Dest); err == nil && dst.IsRemote { - paths = append(paths, dst) - } var nodeIDs []string seen := make(map[string]struct{}) - for _, p := range paths { - selector, _, _, err := o.resolvePathInfo(p) - if err != nil || selector == "" { - continue - } - nodeID, err := provider.ResolveSelector(selector) - if err != nil || nodeID == "" { + for _, p := range o.collectTargetPaths(provider) { + target, err := o.resolveTargetForPath(p) + if err != nil || target.Selector == "" { continue } - if _, exists := seen[nodeID]; !exists { - seen[nodeID] = struct{}{} - nodeIDs = append(nodeIDs, nodeID) + for _, id := range targetCandidateNodeIDs(provider, target) { + if id == "" { + continue + } + if _, exists := seen[id]; !exists { + seen[id] = struct{}{} + nodeIDs = append(nodeIDs, id) + } } } return nodeIDs } + +func (o *ScpOptions) collectTargetPaths(provider *config.Repository) []PathInfo { + var paths []PathInfo + if o.Tag != "" { + for nodeID := range provider.GetNodesByTag(o.Tag) { + paths = append(paths, PathInfo{Host: nodeID, IsRemote: true}) + } + } + if o.Host != "" || o.HostFile != "" { + if hosts, err := cmdutils.ParseHosts(o.Host, o.HostFile, ""); err == nil { + for _, h := range hosts { + if h.Host != "" { + paths = append(paths, PathInfo{Host: h.Host, User: h.User, Port: h.Port, IsRemote: true}) + } + } + } else if o.Host != "" { + for h := range strings.SplitSeq(o.Host, ",") { + h = strings.TrimSpace(h) + if h != "" { + paths = append(paths, PathInfo{Host: h, IsRemote: true}) + } + } + } + } + if o.Host == "" && o.Tag == "" && o.HostFile == "" { + if src, err := parsePath(o.Source); err == nil && src.IsRemote { + paths = append(paths, src) + } + if dst, err := parsePath(o.Dest); err == nil && dst.IsRemote { + paths = append(paths, dst) + } + } + return paths +} + +func targetCandidateNodeIDs(provider config.ConfigProvider, target config.ConnectionTarget) []string { + if resolved, err := config.ResolveTarget(provider, target); err == nil && resolved.NodeID != "" { + if resolved.MatchedNodeID != "" && resolved.Host != "" && resolved.User != "" && resolved.Port > 0 { + derivedID := config.FormatNodeID(resolved.User, resolved.Host, resolved.Port) + if derivedID != resolved.NodeID { + return []string{resolved.NodeID, derivedID} + } + } + return []string{resolved.NodeID} + } + user := target.User + if user == "" { + if u, uErr := cmdutils.GetCurrentUser(); uErr == nil { + user = u + } + } + port := target.Port + if port == 0 { + port = 22 + } + if user == "" { + return nil + } + return []string{config.FormatNodeID(user, target.Selector, port)} +} diff --git a/cmd/sftp.go b/cmd/sftp.go index 45abc691..8ab1446f 100644 --- a/cmd/sftp.go +++ b/cmd/sftp.go @@ -74,6 +74,7 @@ func NewCmdSftp() *cobra.Command { // OpenSSH-compatible flags cmd.Flags().StringVarP(&o.IdentityFile, "identity", "i", "", i18n.T("flag_identity")) cmd.Flags().StringVarP(&o.JumpHost, "jump", "J", "", i18n.T("flag_jump")) + o.control.RegisterCommonFlags(cmd) // xops-enhanced flags (long-form only, no short flags to avoid OpenSSH conflicts) cmd.Flags().StringVar(&o.Host, "host", "", i18n.T("flag_host")) @@ -92,6 +93,17 @@ func NewCmdSftp() *cobra.Command { return cmd } +func (o *SftpOptions) Validate() error { + extraArgs := o.args + if o.Host == "" && len(extraArgs) > 0 { + extraArgs = extraArgs[1:] + } + if len(extraArgs) > 0 { + return fmt.Errorf("unexpected positional argument %q", extraArgs[0]) + } + return o.SshOptions.Validate() +} + func (o *SftpOptions) Run() error { return o.RunContext(context.Background()) } @@ -100,6 +112,9 @@ func (o *SftpOptions) Run() error { // //nolint:gocyclo func (o *SftpOptions) RunContext(ctx context.Context) (err error) { + if err := o.ensureGeneralOptionsApplied(); err != nil { + return err + } batch := !term.IsTerminal(int(os.Stdin.Fd())) connectionOptions := o.SshOptions if batch { @@ -144,7 +159,7 @@ func (o *SftpOptions) RunContext(ctx context.Context) (err error) { adpOpts = append(adpOpts, adapter.WithNonInteractive(true)) connector = newNonInteractiveConnector(provider, adpOpts, ssh.WithLogger(logger.DefaultLogger()), ssh.WithDiagnostics(observer)) } else { - connector = newCLIConnectorWithAdapterOptions(provider, adpOpts, ssh.WithLogger(logger.DefaultLogger()), ssh.WithInteractionHandler(connectionOptions.interaction), ssh.WithDiagnostics(observer)) + connector = newCLIConnectorWithAdapterOptions(provider, connectionOptions.interaction, adpOpts, ssh.WithLogger(logger.DefaultLogger()), ssh.WithDiagnostics(observer)) } defer func() { joinConnectorCloseError(&err, connector) @@ -186,6 +201,9 @@ func (o *SftpOptions) RunContext(ctx context.Context) (err error) { if closeErr := sftpClient.Close(); closeErr != nil { err = errors.Join(err, fmt.Errorf("close SFTP client failed: %w", closeErr)) } + if handoffErr := connectionOptions.handoffPersistentMaster(ctx, connector, provider, nodeID); handoffErr != nil { + logger.DefaultLogger().Debugf("mux: master handoff failed: %v", handoffErr) + } if closeErr := client.Close(); closeErr != nil { err = errors.Join(err, fmt.Errorf("close SSH client after SFTP shell failed: %w", closeErr)) } diff --git a/cmd/ssh.go b/cmd/ssh.go index dcd20d51..bce51084 100644 --- a/cmd/ssh.go +++ b/cmd/ssh.go @@ -11,8 +11,10 @@ import ( "strconv" "strings" "sync" + "time" "github.com/spf13/cobra" + "github.com/spf13/pflag" "github.com/wentf9/xops-cli/cmd/utils" corelog "github.com/wentf9/xops-cli/core/log" "github.com/wentf9/xops-cli/core/ssh" @@ -24,31 +26,35 @@ import ( ) type SshOptions struct { - interaction *cliInteractionHandler - diagnostic sshDiagnosticOptions - diagnostics corelog.DiagnosticObserver - Host string - Port uint16 - User string - Password string - PasswordStdin bool - OTP string - OTPStdin bool - IdentityFile string - Passphrase string - PassphraseStdin bool - Remember string - Sudo bool - Alias string - JumpHost string - Tags []string - LocalForwards []string - RemoteForwards []string - NoCmd bool - DynamicForward string - BgRun bool - StdinRedirect bool - args []string + interaction *cliInteractionHandler + diagnostic sshDiagnosticOptions + diagnostics corelog.DiagnosticObserver + control SSHControlFlags + Host string + Port uint16 + User string + Password string + PasswordStdin bool + OTP string + OTPStdin bool + IdentityFile string + Passphrase string + PassphraseStdin bool + Remember string + Sudo bool + Alias string + JumpHost string + Tags []string + LocalForwards []string + RemoteForwards []string + NoCmd bool + DynamicForward string + BgRun bool + StdinRedirect bool + args []string + dashIdx int + parsedArgs bool + appliedGeneralOpts bool Target config.ConnectionTarget @@ -56,14 +62,16 @@ type SshOptions struct { stdinScript bool NoLoginShell bool - execution execExecutionOptions - commandPlan *ssh.CommandPlan + execution execExecutionOptions + commandPlan *ssh.CommandPlan + targetNodeIDs []string } func NewSshOptions() *SshOptions { return &SshOptions{ Sudo: false, NoLoginShell: false, + dashIdx: -1, } } @@ -87,6 +95,7 @@ func newCmdSshWithOptions(o *SshOptions) *cobra.Command { }, } o.diagnostic.register(cmd) + o.control.RegisterSSHFlags(cmd) // OpenSSH-compatible flags cmd.Flags().Uint16VarP(&o.Port, "port", "p", 0, i18n.T("flag_port")) cmd.Flags().StringVarP(&o.User, "login", "l", "", i18n.T("flag_login")) @@ -119,6 +128,31 @@ func newCmdSshWithOptions(o *SshOptions) *cobra.Command { func (o *SshOptions) Complete(cmd *cobra.Command, args []string) error { o.args = args + o.dashIdx = -1 + o.parsedArgs = false + o.appliedGeneralOpts = false + var flags *pflag.FlagSet + if cmd != nil { + flags = cmd.Flags() + o.dashIdx = cmd.ArgsLenAtDash() + } + if err := ApplyGeneralOptions(o, o.control.Options, flags); err != nil { + return o.control.controlUsageError(err) + } + if isMuxDaemonChild() { + // The detached master daemon re-executes the original command line, but it + // only serves the control socket. Normalize before Complete reads stdin: + // with stdio on /dev/null a --password-stdin read would fail the child and + // silently break ControlPersist. + o.PasswordStdin = false + o.PassphraseStdin = false + o.OTPStdin = false + o.NoCmd = true + o.Command = "" + o.stdinScript = false + o.StdinRedirect = true + return nil + } o.execution.capturePresence(cmd) hadCredentialStdin := o.OTPStdin || o.PasswordStdin || o.PassphraseStdin if o.OTP != "" && o.OTPStdin { @@ -146,7 +180,8 @@ func (o *SshOptions) Complete(cmd *cobra.Command, args []string) error { } o.Passphrase = pass } - if !o.StdinRedirect && !hadCredentialStdin { + ctlCmd := strings.ToLower(strings.TrimSpace(o.control.CtlCmd)) + if !o.StdinRedirect && !hadCredentialStdin && (ctlCmd == "" || ctlCmd == "proxy") { stat, err := os.Stdin.Stat() if err == nil && (stat.Mode()&os.ModeCharDevice) == 0 { o.stdinScript = true @@ -156,6 +191,10 @@ func (o *SshOptions) Complete(cmd *cobra.Command, args []string) error { } func (o *SshOptions) parseArgs() error { + return o.parseTarget(o.control.ControlCommandWithoutTarget()) +} + +func (o *SshOptions) parseTarget(allowMissingHost bool) error { hasUser := false var finalUser string hasPort := false @@ -171,23 +210,21 @@ func (o *SshOptions) parseArgs() error { finalPort = o.Port } + rawSelector := finalHost if len(o.args) == 0 && finalHost == "" { - return errors.New(i18n.T("ssh_err_no_host")) + if !allowMissingHost { + return errors.New(i18n.T("ssh_err_no_host")) + } } else if finalHost == "" && len(o.args) >= 1 { + rawSelector = o.args[0] u, h, p, err := utils.ParseAddr(o.args[0]) if err != nil { return err } - if finalHost == "" && h != "" { - finalHost = h - } - if !hasUser && u != "" { - hasUser = true - finalUser = u - } - if !hasPort && p != 0 { - hasPort = true - finalPort = p + finalHost, finalUser, hasUser, finalPort, hasPort = applyParsedAddr(finalHost, finalUser, hasUser, finalPort, hasPort, h, u, p) + } else if finalHost != "" && !strings.HasPrefix(finalHost, config.OpenSSHNodePrefix) { + if u, h, p, err := utils.ParseAddr(finalHost); err == nil && h != "" { + finalHost, finalUser, hasUser, finalPort, hasPort = applyParsedAddr("", finalUser, hasUser, finalPort, hasPort, h, u, p) } } @@ -195,27 +232,59 @@ func (o *SshOptions) parseArgs() error { o.User = finalUser o.Port = finalPort + selector := finalHost + trimmedRaw := strings.TrimSpace(rawSelector) + if isMuxDaemonChild() && strings.Contains(trimmedRaw, "@") && strings.Contains(trimmedRaw, ":") { + selector = trimmedRaw + } + + hasJump := o.JumpHost != "" || isMuxDaemonChild() + o.Target = config.ConnectionTarget{ - Selector: finalHost, + Selector: selector, + RawSelector: trimmedRaw, User: finalUser, HasUser: hasUser, Port: finalPort, HasPort: hasPort, ProxyJump: o.JumpHost, - HasProxyJump: o.JumpHost != "", + HasProxyJump: hasJump, } return nil } -func (o *SshOptions) Validate() error { - if o.OTP != "" && o.OTPStdin { - return errors.New(i18n.T("err_otp_mutually_exclusive")) +func applyParsedAddr(finalHost, finalUser string, hasUser bool, finalPort uint16, hasPort bool, h, u string, p uint16) (string, string, bool, uint16, bool) { + if finalHost == "" && h != "" { + finalHost = h } - if err := utils.ValidateRememberPolicy(o.Remember); err != nil { + if !hasUser && u != "" { + hasUser = true + finalUser = u + } + if !hasPort && p != 0 { + hasPort = true + finalPort = p + } + return finalHost, finalUser, hasUser, finalPort, hasPort +} + +func (o *SshOptions) ensureGeneralOptionsApplied() error { + if o.appliedGeneralOpts || len(o.control.Options) == 0 { + return nil + } + return ApplyGeneralOptions(o, o.control.Options, nil) +} + +func (o *SshOptions) ensureArgsParsed() error { + if err := o.ensureGeneralOptionsApplied(); err != nil { return err } + if o.parsedArgs { + return nil + } // A flag-selected target leaves every positional token for the command. - // Determine the boundary before parseArgs fills Host from a positional target. + // When no --host flag was given, the first positional argument is always + // the target host (including `xops ssh -- host cmd`). commandArgs := o.args if o.Host == "" && len(commandArgs) > 0 { commandArgs = commandArgs[1:] @@ -223,12 +292,48 @@ func (o *SshOptions) Validate() error { if err := o.parseArgs(); err != nil { return err } - if len(commandArgs) > 0 { + o.parsedArgs = true + if isMuxDaemonChild() { + // parseArgs rebuilds the target from the preserved positional arguments, so + // the daemon normalization above must be re-applied before the command + // boundary is turned back into an executable command. + o.NoCmd = true + o.Command = "" + o.stdinScript = false + commandArgs = nil + } + if o.Command == "" && len(commandArgs) > 0 { o.Command = strings.Join(commandArgs, " ") if o.Command == "" { return fmt.Errorf("explicit SSH command must not be empty") } } + return nil +} + +func (o *SshOptions) Validate() error { + if err := o.ensureGeneralOptionsApplied(); err != nil { + return o.control.controlUsageError(err) + } + if err := o.control.Validate(); err != nil { + return err + } + if isMuxDaemonChild() { + // Validate runs after Complete and would otherwise rebuild a command from + // the preserved positional arguments that the daemon must never execute. + o.NoCmd = true + o.Command = "" + o.stdinScript = false + } + if o.OTP != "" && o.OTPStdin { + return errors.New(i18n.T("err_otp_mutually_exclusive")) + } + if err := utils.ValidateRememberPolicy(o.Remember); err != nil { + return err + } + if err := o.ensureArgsParsed(); err != nil { + return o.control.controlUsageError(err) + } if o.BgRun && !o.NoCmd { return errors.New(i18n.T("ssh_err_background_requires_nocmd")) } @@ -238,6 +343,10 @@ func (o *SshOptions) Validate() error { if strings.Contains(o.Alias, "@") || strings.Contains(o.Alias, ":") { return errors.New(i18n.T("ssh_err_alias_invalid")) } + ctlCmd := strings.ToLower(strings.TrimSpace(o.control.CtlCmd)) + if ctlCmd != "" && ctlCmd != "proxy" { + return nil + } return o.prepareCommandPlan() } @@ -247,7 +356,19 @@ func (o *SshOptions) Run() error { // RunContext starts the SSH operation and propagates caller cancellation. func (o *SshOptions) RunContext(ctx context.Context) (err error) { - isChild := os.Getenv("XOPS_CLI_SSH_BG_CHILD") == "true" + if o.control.CtlCmd != "" { + return ExecuteControlCommand(ctx, o) + } + + isChild := os.Getenv(sshBackgroundChildEnv) == "true" + if isMuxDaemonChild() { + // A detached multiplexing daemon serves the control socket only. It must + // never re-run the command or read the terminal the parent handed over. + o.NoCmd = true + o.Command = "" + o.stdinScript = false + o.StdinRedirect = true + } // -n 功能:如果是非后台运行或已是后台子进程,且指定了 -n,直接重定向标准输入到 /dev/null if o.StdinRedirect && (!o.BgRun || isChild) { @@ -305,9 +426,23 @@ func (o *SshOptions) runParentDaemon(ctx context.Context) (err error) { if optErr != nil { return optErr } - connector := newCLIConnectorWithAdapterOptions(provider, adpOpts, ssh.WithLogger(logger.DefaultLogger()), ssh.WithInteractionHandler(o.interaction), ssh.WithDiagnostics(o.diagnostics)) + // The parent only validates connectivity and tunnels. The background child + // owns the control socket, so the parent must not bind it and then exit. The + // disable is node-scoped on purpose: buildAdapterOptions above may have + // registered a node-level session override, and a node override outranks the + // global default used by other nodes. + adpOpts = append(adpOpts, disableSessionControlForParent(nodeID)) + var connector *ssh.Connector + if isMuxDaemonChild() { + connector = newNonInteractiveConnector(provider, adpOpts, ssh.WithLogger(logger.DefaultLogger()), ssh.WithDiagnostics(o.diagnostics)) + } else { + connector = newCLIConnectorWithAdapterOptions(provider, o.interaction, adpOpts, ssh.WithLogger(logger.DefaultLogger()), ssh.WithDiagnostics(o.diagnostics)) + } + var connectorClosed bool defer func() { - joinConnectorCloseError(&err, connector) + if !connectorClosed { + joinConnectorCloseError(&err, connector) + } }() client, err := connector.Connect(ctx, nodeID) if err != nil { @@ -340,6 +475,10 @@ func (o *SshOptions) runParentDaemon(ctx context.Context) (err error) { return fmt.Errorf("close SSH daemon validation client failed: %w", err) } clientClosed = true + if closeErr := connector.CloseAll(); closeErr != nil { + return fmt.Errorf("close SSH daemon validation connector failed: %w", closeErr) + } + connectorClosed = true // 启动后台子进程 exe, err := os.Executable() @@ -347,7 +486,7 @@ func (o *SshOptions) runParentDaemon(ctx context.Context) (err error) { return fmt.Errorf("failed to get executable path: %w", err) } cmd := exec.Command(exe, os.Args[1:]...) - cmd.Env = append(os.Environ(), "XOPS_CLI_SSH_BG_CHILD=true") + cmd.Env = append(os.Environ(), sshBackgroundChildEnv+"=true") // 子进程的 stdin/stdout/stderr 均重定向到 /dev/null devNull, err := os.Open(os.DevNull) @@ -375,9 +514,17 @@ func (o *SshOptions) buildAdapterOptions(nodeID string, cfg *config.Configuratio o.interaction = newCLIInteractionHandler() } shouldRemember, adpOpts := o.interaction.rememberOptions(utils.EffectiveRememberPolicy(o.Remember, cfg), cfg) + if isMuxDaemonChild() { + shouldRemember = false + adpOpts = append(adpOpts, adapter.WithNonInteractive(true)) + } // The global policy also covers ProxyJump nodes. The target-specific override // below carries its explicit session material without leaking it to jumps. adpOpts = append(adpOpts, adapter.WithGlobalSessionAuth(adapter.SessionAuth{Remember: shouldRemember})) + proxyJumpOverride := o.JumpHost + if proxyJumpOverride == "" && isMuxDaemonChild() { + proxyJumpOverride = "none" + } // 总是注入 SessionAuthOverride(即使密码为空),以便 UpdateAuth / UpdateSudo // 能根据 Remember 策略决定是否将交互提示获得的密码回写配置,而不是绕过策略检查。 adpOpts = append(adpOpts, adapter.WithSessionAuthOverride(nodeID, adapter.SessionAuth{ @@ -386,6 +533,7 @@ func (o *SshOptions) buildAdapterOptions(nodeID string, cfg *config.Configuratio KeyPath: utils.ToAbsolutePath(o.IdentityFile), Remember: shouldRemember, OTP: o.OTP, + ProxyJump: proxyJumpOverride, })) if reg, regErr := utils.GetCredentialRegistry(cfg); regErr != nil { return nil, fmt.Errorf("initialize credential resolver: %w", regErr) @@ -403,9 +551,641 @@ func (o *SshOptions) buildAdapterOptions(nodeID string, cfg *config.Configuratio } adpOpts = append(adpOpts, persistence...) } + + // Session Control Overrides (Tier 1) + sessionCtrlOpts, err := o.control.ToSessionControlOptions() + if err != nil { + return nil, err + } + if !sessionCtrlOpts.IsEmpty() { + adpOpts = append(adpOpts, adapter.WithSessionControlOverride(nodeID, sessionCtrlOpts)) + } + return adpOpts, nil } +// disableSessionControlForParent stops a -f parent from claiming the control +// socket that the detached child owns, including any ProxyJump hops. It sets +// both global and node-level overrides so neither a jump hop nor a node-scoped +// -M/-S/-o override on the target can bind or attach to a control socket. +func disableSessionControlForParent(nodeID string) adapter.Option { + disabledSharing := "none" + ctrl := adapter.SessionControlOptions{Path: &disabledSharing} + return func(a *adapter.SSHAdapter) { + adapter.WithGlobalSessionControl(ctrl)(a) + adapter.WithSessionControlOverride(nodeID, ctrl)(a) + } +} + +// getActiveMuxServer returns the in-process master serving this node, if any. The +// control path must be expanded from the resolved connection target, exactly as +// Connector.buildTargetContext does, otherwise %h/%p/%r/%C expand differently. +func (o *SshOptions) getActiveMuxServer(ctx context.Context, provider *config.Repository, nodeID string) *ssh.MuxServer { + expandedPath, err := controlSocketPath(o, provider, nodeID) + if err != nil || expandedPath == "" { + return nil + } + return ssh.GetActiveMuxServer(expandedPath) +} + +// handoffPersistentMaster releases this process's master and starts a detached +// master so ControlPersist survives the session, matching OpenSSH behavior for a +// command or interactive shell that finished. +func (o *SshOptions) handoffPersistentMaster(ctx context.Context, connector *ssh.Connector, provider *config.Repository, nodeID string) error { + if isMuxDaemonChild() || o.BgRun { + // This process owns the master for its whole lifetime. + return nil + } + if o.NoCmd { + // A -N process holds the master until it stops; there is nothing to hand + // over, and spawning a second daemon would duplicate the master. + return nil + } + if ctx != nil && ctx.Err() != nil { + // The session was interrupted (Ctrl-C/cancellation). Reviving the master + // would resurrect what the user just stopped. + return nil + } + if err := drainOwnedMasters(ctx, connector); err != nil { + return nil + } + if o.OTP != "" { + // A one-time code was already consumed by this process. Re-playing it in + // the daemon cannot succeed and may trip server-side MFA lockouts. + logger.DefaultLogger().Debugf("mux: skipping master handoff because a one-time password was used") + return nil + } + if nodeID != "" && len(o.targetNodeIDs) == 0 { + o.targetNodeIDs = []string{nodeID} + } + var master *ssh.MuxServer + expandedPath, err := controlSocketPath(o, provider, nodeID) + if err == nil && expandedPath != "" { + master = ssh.GetActiveMuxServer(expandedPath) + } + if master == nil && connector != nil { + for _, m := range connector.Masters() { + if m.Target().OriginalHost == nodeID || nodeID == "" { + master = m + break + } + } + } + if !canHandoffMaster(master) { + return o.handoffConnectorMasters(ctx, connector) + } + return o.handoffMasterInstance(ctx, connector, master, nodeID) +} + +func canHandoffMaster(master *ssh.MuxServer) bool { + if master == nil || !master.PersistEnabled() { + return false + } + if ctrl := master.Target().Control; ctrl != nil { + if ctrl.ImplicitTier4 { + return false + } + if ctrl.Master == ssh.ControlMasterAsk || ctrl.Master == ssh.ControlMasterAutoAsk { + return false + } + } + return true +} + +// handoffConnectorMasters drains and hands off any persistent multiplexing +// masters started by connector during scp, sftp, or exec operations. +func (o *SshOptions) handoffConnectorMasters(ctx context.Context, connector *ssh.Connector) error { + if connector == nil { + return nil + } + masters := connector.Masters() + if len(masters) == 0 { + return nil + } + if isMuxDaemonChild() || o.BgRun || o.NoCmd { + return nil + } + if ctx != nil && ctx.Err() != nil { + return nil + } + if err := drainOwnedMasters(ctx, connector); err != nil { + return nil + } + if o.OTP != "" { + logger.DefaultLogger().Debugf("mux: skipping master handoff because a one-time password was used") + return nil + } + jumps := persistentJumpHops(masters) + var firstErr error + for _, m := range masters { + if !canHandoffMaster(m) { + continue + } + nodeID := m.Target().OriginalHost + if nodeID != "" && jumps[nodeID] { + continue + } + if err := o.handoffMasterInstance(ctx, connector, m, nodeID); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +func persistentJumpHops(masters []*ssh.MuxServer) map[string]bool { + jumps := make(map[string]bool, len(masters)) + for _, m := range masters { + if !canHandoffMaster(m) { + continue + } + if j := m.Target().JumpHost; j != "" { + for hop := range strings.SplitSeq(j, ",") { + hop = strings.TrimSpace(hop) + if hop != "" { + jumps[hop] = true + } + } + } + } + return jumps +} + +func (o *SshOptions) handoffMasterInstance(ctx context.Context, connector *ssh.Connector, master *ssh.MuxServer, nodeID string) error { + if master == nil || !master.PersistEnabled() { + return nil + } + if ctrl := master.Target().Control; ctrl != nil { + // 1.A: When multiplexing comes solely from ~/.ssh/config (Tier 4) without + // explicit CLI or xops config, never spawn a detached background daemon. + if ctrl.ImplicitTier4 { + logger.DefaultLogger().Debugf("mux: skipping master handoff for implicit Tier 4 (OpenSSH config) control on %q", master.ControlPath()) + return nil + } + // H11: A detached daemon has no TTY to prompt confirmation in ask/autoask mode. + if ctrl.Master == ssh.ControlMasterAsk || ctrl.Master == ssh.ControlMasterAutoAsk { + logger.DefaultLogger().Debugf("mux: skipping master handoff in %s confirmation mode on %q", ctrl.Master, master.ControlPath()) + return nil + } + } + if connector != nil && !connector.OwnsMaster(master) { + return nil + } + expandedPath := master.ControlPath() + if expandedPath == "" { + return nil + } + ready, err := waitAndReleaseMastersForHandoff(ctx, connector, master, expandedPath) + if err != nil || !ready { + return err + } + + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("resolve executable for multiplexing daemon failed: %w", err) + } + devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) + if err != nil { + return fmt.Errorf("open %s for multiplexing daemon failed: %w", os.DevNull, err) + } + defer func() { + if closeErr := devNull.Close(); closeErr != nil { + logger.DefaultLogger().Debugf("close multiplexing daemon stdio failed: %v", closeErr) + } + }() + + cmd := exec.Command(exe, o.muxDaemonArgs(nodeID, expandedPath, master)...) + cmd.Env = append(os.Environ(), sshMuxDaemonEnv+"=true", sshBackgroundChildEnv+"=true") + cmd.Stdin = devNull + cmd.Stdout = devNull + cmd.Stderr = devNull + if err := startDaemonProcess(cmd); err != nil { + return fmt.Errorf("start multiplexing daemon failed: %w", err) + } + // The child re-authenticates on its own. Interactive-only credentials cannot + // be inherited, so a failed handoff is advisory: the command already ran. + // Watch for an early exit so a doomed daemon does not stall the shell for the + // whole readiness timeout. + daemonExited := make(chan struct{}) + go func() { + defer close(daemonExited) + _ = cmd.Wait() + }() + + // The command already finished, so a slow or unauthenticated daemon must not + // be reported as a command failure. Report it and keep the exit status. + if err := waitForControlSocket(ctx, expandedPath, 10*time.Second, daemonExited); err != nil { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + logger.DefaultLogger().Debugf("mux: persistent master handoff on %q failed: %v", expandedPath, err) + if _, writeErr := fmt.Fprintf(os.Stderr, "warning: connection sharing master could not be kept alive on %s: %v\n", expandedPath, err); writeErr != nil { + logger.DefaultLogger().Debugf("write handoff warning failed: %v", writeErr) + } + } + return nil +} + +func waitAndReleaseMastersForHandoff(ctx context.Context, connector *ssh.Connector, master *ssh.MuxServer, expandedPath string) (bool, error) { + for { + if err := drainOwnedMasters(ctx, connector); err != nil { + return false, nil + } + if master.StoppedListening() { + return false, nil + } + ready, err := releaseMastersForHandoff(connector, master, expandedPath) + if err != nil { + return false, err + } + if ready { + return true, nil + } + if connector == nil || master.StoppedListening() { + return false, nil + } + select { + case <-ctx.Done(): + return false, nil + case <-master.Wait(): + return false, nil + default: + } + } +} + +// drainOwnedMasters waits for any secondary client sessions or dynamic forwards +// on masters started by connector to finish before the foreground process tears +// down its transport or hands the control socket over to a daemon. +func drainOwnedMasters(ctx context.Context, connector *ssh.Connector) error { + if connector == nil { + return nil + } + if ctx == nil { + ctx = context.Background() + } + for _, m := range connector.Masters() { + maxSessions := int32(0) + if connector.HasMasterSession(m) { + maxSessions = 1 + } + if err := m.WaitForDrain(ctx, maxSessions); err != nil { + return err + } + if m.StoppedListening() { + connector.ReleaseMaster(m) + select { + case <-ctx.Done(): + return ctx.Err() + case <-m.Wait(): + } + } + } + return nil +} + +func (o *SshOptions) muxDaemonArgs(nodeID string, expandedPath string, master *ssh.MuxServer) []string { + targetHost, port, user := o.resolveMuxDaemonTarget(nodeID, master) + args := []string{"ssh", "-N", "--host", targetHost, "-S", expandedPath} + if port != 0 { + args = append(args, "-p", strconv.Itoa(port)) + } + if user != "" { + args = append(args, "-l", user) + } + if keyPath := o.resolveMuxDaemonIdentityFile(nodeID, master); keyPath != "" { + args = append(args, "-i", keyPath) + } + jumpHost := o.JumpHost + if master != nil { + jumpHost = master.Target().JumpHost + if jumpHost == "" && strings.EqualFold(strings.TrimSpace(o.JumpHost), "none") { + jumpHost = "none" + } + } + if jumpHost != "" { + args = append(args, "-J", jumpHost) + } + args = append(args, "--remember", "never") + for _, opt := range o.control.Options { + if master != nil && isTargetSpecificOption(opt) { + continue + } + args = append(args, "-o", opt) + } + if master != nil { + if ctrl := master.Target().Control; ctrl != nil && ctrl.Persist != "" { + args = append(args, "-o", "ControlPersist="+ctrl.Persist) + } + } + // Force ControlMaster=yes on the detached daemon so it never attaches as a + // passenger to a competing master. + args = append(args, "-o", "ControlMaster=yes") + return args +} + +func (o *SshOptions) resolveMuxDaemonIdentityFile(nodeID string, master *ssh.MuxServer) string { + if o.IdentityFile != "" && o.shouldPassIdentityFile(nodeID, master) { + return o.IdentityFile + } + if master != nil && master.Target().KeyPath != "" { + if o.IdentityFile == "" || len(o.targetNodeIDs) > 0 || master.Target().KeyPath != utils.ToAbsolutePath(o.IdentityFile) { + return master.Target().KeyPath + } + } + return "" +} + +func (o *SshOptions) shouldPassIdentityFile(nodeID string, master *ssh.MuxServer) bool { + if master == nil { + return true + } + if o.JumpHost != "" && master.Target().JumpHost == "" { + return false + } + if len(o.targetNodeIDs) > 0 { + return matchesAnyTargetNodeID(nodeID, master, o.targetNodeIDs) + } + if o.Port > 0 && master.Target().Port > 0 && int(o.Port) != master.Target().Port { + return false + } + if o.User != "" && master.Target().User != "" && o.User != master.Target().User { + return false + } + if o.JumpHost != "" && isMasterInJumpSpec(nodeID, master, o.JumpHost) { + return false + } + if o.Host != "" || o.Target.Selector != "" { + return matchesTargetSelector(nodeID, master, o.Host, o.Target.Selector) + } + return true +} + +func isMasterInJumpSpec(nodeID string, master *ssh.MuxServer, jumpSpec string) bool { + for hop := range strings.SplitSeq(jumpSpec, ",") { + hop = strings.TrimSpace(hop) + if hop == "" { + continue + } + if matchesTargetSelector(nodeID, master, hop, hop) { + return true + } + } + return false +} + +func matchesAnyTargetNodeID(nodeID string, master *ssh.MuxServer, targetIDs []string) bool { + candidates := []string{ + strings.TrimSpace(nodeID), + strings.TrimPrefix(strings.TrimSpace(nodeID), config.OpenSSHNodePrefix), + } + if master != nil { + orig := strings.TrimSpace(master.Target().OriginalHost) + candidates = append(candidates, + orig, + strings.TrimPrefix(orig, config.OpenSSHNodePrefix), + ) + } + for _, tid := range targetIDs { + want := strings.TrimSpace(tid) + if want == "" { + continue + } + cleanWant := strings.TrimPrefix(want, config.OpenSSHNodePrefix) + for _, c := range candidates { + if c != "" && (c == want || c == cleanWant) { + return true + } + } + } + return false +} + +func matchesTargetSelector(nodeID string, master *ssh.MuxServer, host, selector string) bool { + candidates := []string{ + strings.TrimSpace(nodeID), + strings.TrimPrefix(strings.TrimSpace(nodeID), config.OpenSSHNodePrefix), + } + if master != nil { + orig := strings.TrimSpace(master.Target().OriginalHost) + candidates = append(candidates, + orig, + strings.TrimPrefix(orig, config.OpenSSHNodePrefix), + strings.TrimSpace(master.Target().Host), + ) + } + for _, want := range []string{strings.TrimSpace(host), strings.TrimSpace(selector)} { + if want == "" { + continue + } + cleanWant := strings.TrimPrefix(want, config.OpenSSHNodePrefix) + for _, c := range candidates { + if c != "" && (c == want || c == cleanWant) { + return true + } + } + } + return false +} + +func isTargetSpecificOption(opt string) bool { + key, _, ok := strings.Cut(strings.TrimSpace(opt), "=") + if !ok { + return false + } + switch strings.ToLower(strings.TrimSpace(key)) { + case "port", "user", "proxyjump", "identityfile": + return true + default: + return false + } +} + +func (o *SshOptions) resolveMuxDaemonTarget(nodeID string, master *ssh.MuxServer) (host string, port int, user string) { + rawSelector := nodeID + if rawSelector == "" && master != nil { + rawSelector = master.Target().OriginalHost + } + if rawSelector == "" { + rawSelector = o.Host + } + host = strings.TrimPrefix(rawSelector, config.OpenSSHNodePrefix) + if host == "" && master != nil { + host = master.Target().Host + } + if master != nil && master.Target().Port > 0 { + port = master.Target().Port + } else { + port = int(o.Port) + } + if master != nil && master.Target().User != "" { + user = master.Target().User + } else { + user = o.User + } + return host, port, user +} + +// jumpChainMasters returns the subset of ownedMasters that belong to targetMaster's +// transitive ProxyJump chain so multi-host handoffs never shut down unrelated +// target masters. +func jumpChainMasters(targetMaster *ssh.MuxServer, ownedMasters []*ssh.MuxServer) []*ssh.MuxServer { + if targetMaster == nil || len(ownedMasters) == 0 { + return nil + } + byHost := make(map[string]*ssh.MuxServer, len(ownedMasters)) + for _, m := range ownedMasters { + if m == nil || m == targetMaster { + continue + } + if host := strings.TrimSpace(m.Target().OriginalHost); host != "" { + byHost[host] = m + } + if host := strings.TrimSpace(m.Target().Host); host != "" { + byHost[host] = m + } + } + var chain []*ssh.MuxServer + seen := make(map[*ssh.MuxServer]bool) + queue := []string{targetMaster.Target().JumpHost} + for len(queue) > 0 { + raw := queue[0] + queue = queue[1:] + for hop := range strings.SplitSeq(raw, ",") { + hop = strings.TrimSpace(hop) + if hop == "" { + continue + } + if hopMaster, ok := byHost[hop]; ok && !seen[hopMaster] { + seen[hopMaster] = true + chain = append(chain, hopMaster) + if next := hopMaster.Target().JumpHost; next != "" { + queue = append(queue, next) + } + } + } + } + return chain +} + +// releaseMastersForHandoff drops the connector's active-session references, +// reserves the target and any intermediate jump masters for handoff, and shuts +// them down so the detached daemon can claim the control sockets cleanly. +func releaseMastersForHandoff(connector *ssh.Connector, master *ssh.MuxServer, expandedPath string) (bool, error) { + var jumpMasters []*ssh.MuxServer + var released []*ssh.MuxServer + if connector != nil { + if !connector.OwnsMaster(master) { + return false, nil + } + if connector.HasMasterSession(master) && connector.ReleaseMaster(master) { + released = append(released, master) + } + jumpMasters = jumpChainMasters(master, connector.Masters()) + } + restoreReleased := func() { + if connector == nil { + return + } + for _, m := range released { + connector.AcquireMaster(m) + } + } + if !master.PrepareHandoff() { + restoreReleased() + logger.DefaultLogger().Debugf("mux: not handing off master on %q while it is busy or stopping", expandedPath) + return false, nil + } + reserved := []*ssh.MuxServer{master} + for _, m := range jumpMasters { + if !m.PersistEnabled() { + continue + } + if connector != nil && connector.HasMasterSession(m) && connector.ReleaseMaster(m) { + released = append(released, m) + } + if !m.PrepareHandoff() { + for _, r := range reserved { + r.CancelHandoff() + } + restoreReleased() + logger.DefaultLogger().Debugf("mux: not handing off master on %q while jump master %q is busy or stopping", expandedPath, m.ControlPath()) + return false, nil + } + reserved = append(reserved, m) + } + if err := master.Shutdown(); err != nil { + return false, fmt.Errorf("release control socket before daemon handoff failed: %w", err) + } + for _, m := range jumpMasters { + _ = m.Shutdown() + } + return true, nil +} + +// waitForControlSocket blocks until a master answers on path, the deadline +// elapses, the caller cancels, or the daemon process we are waiting for exits. +func waitForControlSocket(ctx context.Context, path string, timeout time.Duration, daemonExited <-chan struct{}) error { + deadline := time.Now().Add(timeout) + var lastErr error + daemonExitErr := func() error { + if lastErr == nil { + return fmt.Errorf("master daemon exited before it answered on %q", path) + } + return fmt.Errorf("master daemon exited before it answered on %q: %w", path, lastErr) + } + + for { + if daemonExited != nil { + select { + case <-daemonExited: + return daemonExitErr() + default: + } + } + + probeCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) + client, err := ssh.DialMuxClient(probeCtx, path) + if err == nil { + _, lastErr = client.AliveCheck(probeCtx) + _ = client.Close() + if lastErr == nil { + cancel() + return nil + } + } else { + lastErr = err + } + cancel() + + if err := ctx.Err(); err != nil { + return fmt.Errorf("waiting for master on %q canceled: %w", path, err) + } + remaining := time.Until(deadline) + if remaining <= 0 { + return fmt.Errorf("master did not answer on %q within %s: %w", path, timeout, lastErr) + } + if remaining > controlSocketPollInterval { + remaining = controlSocketPollInterval + } + + // Waiting on the timer alone would hide an early daemon exit until the + // next poll; wake on it (and on cancellation) instead. + timer := time.NewTimer(remaining) + select { + case <-ctx.Done(): + timer.Stop() + return fmt.Errorf("waiting for master on %q canceled: %w", path, ctx.Err()) + case <-daemonExited: + timer.Stop() + return daemonExitErr() + case <-timer.C: + } + } +} + +// controlSocketPollInterval bounds how long the handoff waits between probes. +const controlSocketPollInterval = 100 * time.Millisecond + func (o *SshOptions) runConnection(ctx context.Context, isChild bool) (err error) { configPath, keyPath, pathErr := utils.GetConfigFilePath() if pathErr != nil { @@ -430,7 +1210,12 @@ func (o *SshOptions) runConnection(ctx context.Context, isChild bool) (err error if optErr != nil { return optErr } - connector := newCLIConnectorWithAdapterOptions(provider, adpOpts, ssh.WithLogger(logger.DefaultLogger()), ssh.WithInteractionHandler(o.interaction), ssh.WithDiagnostics(o.diagnostics)) + var connector *ssh.Connector + if isMuxDaemonChild() { + connector = newNonInteractiveConnector(provider, adpOpts, ssh.WithLogger(logger.DefaultLogger()), ssh.WithDiagnostics(o.diagnostics)) + } else { + connector = newCLIConnectorWithAdapterOptions(provider, o.interaction, adpOpts, ssh.WithLogger(logger.DefaultLogger()), ssh.WithDiagnostics(o.diagnostics)) + } defer func() { joinConnectorCloseError(&err, connector) }() @@ -452,6 +1237,17 @@ func (o *SshOptions) runConnection(ctx context.Context, isChild bool) (err error runCtx, runCancel := context.WithCancel(ctx) defer runCancel() + // Keep a persistent master alive across the process boundary. Registered + // here so it runs after tunnels are closed but before the client and + // connector are torn down, and it never changes the command's exit status. + // Pass the outer ctx (not runCtx) so that normal tunnel closure does not + // look like user cancellation to handoffPersistentMaster. + defer func() { + if handoffErr := o.handoffPersistentMaster(ctx, connector, provider, nodeID); handoffErr != nil { + logger.DefaultLogger().Debugf("mux: master handoff failed: %v", handoffErr) + } + }() + // Keep output on the command context while tunnels own their child context. tunnels, err := o.startTunnels(ctx, runCancel, client) if err != nil { @@ -463,14 +1259,18 @@ func (o *SshOptions) runConnection(ctx context.Context, isChild bool) (err error } }() - if o.NoCmd { - if !isChild && !o.diagnostic.quiet { - if err := tunnels.reportReady(ctx, os.Stdout, nodeID); err != nil { - return err - } + return o.runConnectedSession(runCtx, connector, client, provider, nodeID, tunnels, isChild) +} + +func (o *SshOptions) runConnectedSession(runCtx context.Context, connector *ssh.Connector, client *ssh.Client, provider *config.Repository, nodeID string, tunnels *sshTunnelGroup, isChild bool) error { + master := o.getActiveMuxServer(runCtx, provider, nodeID) + if isMuxDaemonChild() { + if err := prepareMuxDaemonSession(connector, master, nodeID); err != nil { + return err } - <-runCtx.Done() - return nil + } + if o.NoCmd { + return o.runNoCmdSession(runCtx, connector, client, master, nodeID, tunnels, isChild) } if o.Command != "" || o.stdinScript { @@ -480,6 +1280,72 @@ func (o *SshOptions) runConnection(ctx context.Context, isChild bool) (err error return o.runShell(runCtx, client) } +func (o *SshOptions) runNoCmdSession(runCtx context.Context, connector *ssh.Connector, client *ssh.Client, master *ssh.MuxServer, nodeID string, tunnels *sshTunnelGroup, isChild bool) error { + if !isChild && !o.diagnostic.quiet { + if o.control.MasterCount > 0 && master == nil && (connector == nil || len(connector.Masters()) == 0) { + _, _ = fmt.Fprintln(os.Stderr, "warning: -M specified without an active ControlPath; connection sharing master is not listening") + } + if err := tunnels.reportReady(runCtx, os.Stdout, nodeID); err != nil { + return err + } + } + // A non-daemon -N master keeps the foreground-session reference acquired + // by Connector until the operator stops it, and drops that reference on + // -O stop so the process exits once any client sessions drain. + if master != nil && !isMuxDaemonChild() { + master.ReleaseForegroundOnStop() + } + var masterWait <-chan struct{} + if master != nil { + masterWait = master.Wait() + } + select { + case <-runCtx.Done(): + case <-masterWait: + } + if isMuxDaemonChild() && runCtx.Err() == nil && connector != nil { + _ = client.Close() + waitForDaemonJumpMasters(runCtx, connector, master) + } + return nil +} + +func prepareMuxDaemonSession(connector *ssh.Connector, master *ssh.MuxServer, nodeID string) error { + if master == nil || (connector != nil && !connector.OwnsMaster(master)) { + return fmt.Errorf("multiplexing daemon did not acquire control socket for %s", nodeID) + } + if connector != nil { + // Release the foreground reference on the terminal master so its idle + // timer can run. Any upstream ProxyJump masters owned by this daemon keep + // their reference until the terminal master exits, preventing an earlier + // jump idle timeout from tearing down the target's transport. + connector.ReleaseMaster(master) + } + return nil +} + +func waitForDaemonJumpMasters(ctx context.Context, connector *ssh.Connector, targetMaster *ssh.MuxServer) { + masters := connector.Masters() + for i := len(masters) - 1; i >= 0; i-- { + m := masters[i] + if m == nil || m == targetMaster || !m.PersistEnabled() { + continue + } + connector.ReleaseMaster(m) + select { + case <-ctx.Done(): + return + case <-m.Wait(): + } + } +} + +// isMuxDaemonChild reports whether this process is the detached master daemon +// started by handoffPersistentMaster. +func isMuxDaemonChild() bool { + return os.Getenv(sshMuxDaemonEnv) == "true" +} + func sshConnectionError(nodeID string, err error) error { return fmt.Errorf("[%s] %s: %w", nodeID, i18n.T("ssh_connection_failed_label"), err) } @@ -550,7 +1416,7 @@ func (o *SshOptions) resolveNode(ctx context.Context, provider *config.Repositor } } - shouldRemember := utils.EffectiveRememberPolicy(o.Remember, provider.Snapshot()) == utils.RememberPolicyAlways + shouldRemember := !isMuxDaemonChild() && utils.EffectiveRememberPolicy(o.Remember, provider.Snapshot()) == utils.RememberPolicyAlways res, err := provider.PrepareNodeContext(ctx, config.EnsureNodeOptions{ Target: o.Target, @@ -564,8 +1430,8 @@ func (o *SshOptions) resolveNode(ctx context.Context, provider *config.Repositor if err != nil { return "", false, err } - if res.Created { - return res.NodeID, true, nil + if res.Created || isMuxDaemonChild() { + return res.NodeID, res.Created, nil } updated, err := update(ctx, res.NodeID, o, provider, shouldRemember) return res.NodeID, updated, err @@ -785,6 +1651,14 @@ func updateIdentityFields(identity *models.Identity, o *SshOptions) bool { } func update(ctx context.Context, nodeID string, o *SshOptions, provider *config.Repository, shouldRemember bool) (bool, error) { + if strings.HasPrefix(nodeID, config.OpenSSHNodePrefix) { + if o.JumpHost != "" { + if _, err := provider.ResolveProxyJumpChain(o.JumpHost); err != nil { + return false, fmt.Errorf("resolve SSH proxy jump %q failed: %w", o.JumpHost, err) + } + } + return false, nil + } if o.Password == "" && o.IdentityFile == "" && o.JumpHost == "" && !o.Sudo && o.Alias == "" && len(o.Tags) == 0 { return false, nil } @@ -839,27 +1713,31 @@ func (o *SshOptions) effectiveInterpreter(client *ssh.Client) ssh.Interpreter { return ssh.InterpreterServer } -func (o *SshOptions) runPipedShell(ctx context.Context, client *ssh.Client) error { - strict, err := commandOutputSupported(ctx) +func (o *SshOptions) runPipedShellWithStreams(ctx context.Context, client *ssh.Client, stdin io.Reader, stdout, stderr io.Writer) error { + strict, err := streamOutputSupported(ctx, stdout, stderr) if err != nil { return err } if o.execution.enabled() || strict { - return client.RunShellWithoutPTY(ctx, os.Stdin, os.Stdout, os.Stderr) + return client.RunShellWithoutPTY(ctx, stdin, stdout, stderr) } - return client.RunShellWithIO(ctx, os.Stdin, os.Stdout, os.Stderr) + return client.RunShellWithIO(ctx, stdin, stdout, stderr) } func (o *SshOptions) runCommand(ctx context.Context, client *ssh.Client, command string) error { + return o.runCommandWithStreams(ctx, client, command, os.Stdin, os.Stdout, os.Stderr) +} + +func (o *SshOptions) runCommandWithStreams(ctx context.Context, client *ssh.Client, command string, stdin io.Reader, stdout, stderr io.Writer) error { if o.commandPlan != nil { - return client.RunCommandPlanWithIO(ctx, *o.commandPlan, os.Stdin, os.Stdout, os.Stderr) + return client.RunCommandPlanWithIO(ctx, *o.commandPlan, stdin, stdout, stderr) } if !o.Sudo && o.Command == "" && o.stdinScript { if o.effectiveInterpreter(client) == ssh.InterpreterServer { - return o.runPipedShell(ctx, client) + return o.runPipedShellWithStreams(ctx, client, stdin, stdout, stderr) } } - if !o.Sudo && o.Command != "" { + if !o.Sudo && command != "" { var execCfg *ssh.ExecutionConfig if client != nil { execCfg = client.Execution() @@ -868,13 +1746,13 @@ func (o *SshOptions) runCommand(ctx context.Context, client *ssh.Client, command if err != nil { return fmt.Errorf("plan command: %w", err) } - strict, err := commandOutputSupported(ctx) + strict, err := streamOutputSupported(ctx, stdout, stderr) if err != nil { return err } if strict { - return client.RunCommandPlanWithIO(ctx, plan, os.Stdin, os.Stdout, os.Stderr) + return client.RunCommandPlanWithIO(ctx, plan, stdin, stdout, stderr) } } - return client.RunCommandWithIO(ctx, command, o.Sudo, os.Stdin, os.Stdout, os.Stderr) + return client.RunCommandWithIO(ctx, command, o.Sudo, stdin, stdout, stderr) } diff --git a/cmd/ssh_control.go b/cmd/ssh_control.go new file mode 100644 index 00000000..04630618 --- /dev/null +++ b/cmd/ssh_control.go @@ -0,0 +1,694 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "os" + "strconv" + "strings" + "syscall" + + "github.com/wentf9/xops-cli/cmd/utils" + "github.com/wentf9/xops-cli/core/ssh" + "github.com/wentf9/xops-cli/internal/sshenv" + "github.com/wentf9/xops-cli/pkg/adapter" + "github.com/wentf9/xops-cli/pkg/config" + "github.com/wentf9/xops-cli/pkg/i18n" + "github.com/wentf9/xops-cli/pkg/logger" + cryptoSSH "golang.org/x/crypto/ssh" +) + +// ExitCoder is implemented by errors that specify a process exit code. +type ExitCoder interface { + ExitCode() int +} + +// ControlExitError represents an OpenSSH control command exit status. +type ControlExitError struct { + Code int + Message string + // Reported marks a message already written to stderr in its final + // OpenSSH-compatible form, so the top-level handler must not print it again. + // Usage failures raised while parsing flags are not reported yet. + Reported bool +} + +func (e *ControlExitError) Error() string { + return e.Message +} + +func (e *ControlExitError) ExitCode() int { + return e.Code +} + +// ExitCodeFromError extracts the exit code if err implements ExitCoder. +func ExitCodeFromError(err error) (int, bool) { + var coder ExitCoder + if errors.As(err, &coder) { + return coder.ExitCode(), true + } + return 0, false +} + +// ErrorAlreadyReported reports whether err's message was already written to +// stderr in its final form and must not be printed a second time. +func ErrorAlreadyReported(err error) bool { + var control *ControlExitError + if errors.As(err, &control) { + return control.Reported + } + return false +} + +type controlStreams struct { + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +func defaultControlStreams() controlStreams { + return controlStreams{ + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: os.Stderr, + } +} + +func controlError(stderr io.Writer, msg string) error { + _, _ = fmt.Fprintln(stderr, msg) + return &ControlExitError{Code: 255, Message: msg, Reported: true} +} + +func controlConnectError(stderr io.Writer, socketPath string, err error) error { + errText := formatSyscallError(err) + msg := fmt.Sprintf("Control socket connect(%s): %s", socketPath, errText) + _, _ = fmt.Fprintln(stderr, msg) + return &ControlExitError{Code: 255, Message: msg, Reported: true} +} + +func formatSyscallError(err error) string { + if err == nil { + return "" + } + var sysErr syscall.Errno + if errors.As(err, &sysErr) { + switch sysErr { + case syscall.ENOENT: + return "No such file or directory" + case syscall.ECONNREFUSED: + return "Connection refused" + case syscall.EACCES: + return "Permission denied" + default: + return sysErr.Error() + } + } + errStr := strings.ToLower(err.Error()) + if strings.Contains(errStr, "no such file or directory") { + return "No such file or directory" + } + if strings.Contains(errStr, "connection refused") || strings.Contains(errStr, "wsaeconnrefused") { + return "Connection refused" + } + if strings.Contains(errStr, "permission denied") { + return "Permission denied" + } + return err.Error() +} + +// ExecuteControlCommand executes the -O subcommand on the active master process. +func ExecuteControlCommand(ctx context.Context, o *SshOptions) error { + return executeControlCommandWithStreams(ctx, o, defaultControlStreams()) +} + +func executeControlCommandWithStreams(ctx context.Context, o *SshOptions, streams controlStreams) error { + if err := o.control.Validate(); err != nil { + return controlError(streams.Stderr, err.Error()) + } + // -o Port/User/IdentityFile/ProxyJump are applied by the command's Complete + // step, where the parsed flag set is available to keep primary flags winning. + if err := o.ensureArgsParsed(); err != nil { + return controlError(streams.Stderr, err.Error()) + } + + expandedPath, target, provider, nodeID, err := resolveControlContext(ctx, o, streams.Stderr) + if err != nil { + return err + } + + cmdName := strings.ToLower(strings.TrimSpace(o.control.CtlCmd)) + switch cmdName { + case "check": + return executeCheck(ctx, expandedPath, streams) + case "exit": + return executeExit(ctx, expandedPath, streams) + case "stop": + return executeStop(ctx, expandedPath, streams) + case "forward": + return executeForward(ctx, expandedPath, o, streams) + case "cancel": + return executeCancel(ctx, expandedPath, o, streams) + case "proxy": + return executeProxy(ctx, expandedPath, target, provider, nodeID, o, streams) + case "proxy-stream": + return executeProxyStream(ctx, expandedPath, streams) + case "conninfo": + return executeInfo(ctx, expandedPath, "connection", streams) + case "channels": + return executeInfo(ctx, expandedPath, "channels", streams) + default: + return controlError(streams.Stderr, fmt.Sprintf("unknown control command %q", cmdName)) + } +} + +// resolveControlSocketPath resolves the control socket for a -O request without +// mutating configuration. A -O request is a query against an existing master, so +// it must never create, prepare, or persist a node as a side effect. +func resolveControlSocketPath(ctx context.Context, o *SshOptions, stderr io.Writer) (string, error) { + expandedPath, _, _, _, err := resolveControlContext(ctx, o, stderr) + return expandedPath, err +} + +func resolveControlContext(ctx context.Context, o *SshOptions, stderr io.Writer) (string, ssh.TargetContext, config.ConfigProvider, string, error) { + if ctx != nil { + if err := ctx.Err(); err != nil { + return "", ssh.TargetContext{}, nil, "", err + } + } + if o != nil { + if err := o.ensureGeneralOptionsApplied(); err != nil { + return "", ssh.TargetContext{}, nil, "", controlError(stderr, err.Error()) + } + } + + provider, providerErr := loadReadOnlyControlProvider() + nodeID := "" + connTarget := effectiveConnectionTarget(o, "") + if provider != nil && strings.TrimSpace(connTarget.Selector) != "" { + resolved, err := resolveControlNodeID(provider, connTarget) + if err != nil { + return "", ssh.TargetContext{}, nil, "", controlError(stderr, fmt.Sprintf("resolve target %q failed: %v", connTarget.Selector, err)) + } + nodeID = resolved + } else if provider == nil && providerErr != nil && strings.TrimSpace(o.control.ControlPath) == "" { + // Without configuration only an explicit -S path can be resolved; report + // the configuration problem instead of silently using a wrong socket. + return "", ssh.TargetContext{}, nil, "", controlError(stderr, fmt.Sprintf("%s: %v", i18n.T("ssh_err_load_config"), providerErr)) + } + + rawPath := strings.TrimSpace(o.control.ControlPath) + if rawPath == "none" { + return "", ssh.TargetContext{}, nil, "", controlError(stderr, "ControlPath must be specified when using -O") + } + + target := controlTarget(o, provider, nodeID) + if rawPath != "" { + expanded, err := ssh.ExpandControlPath(rawPath, sshenv.Discover(), target) + if err != nil { + return "", ssh.TargetContext{}, nil, "", controlError(stderr, fmt.Sprintf("expand ControlPath %q failed: %v", rawPath, err)) + } + return expanded, target, provider, nodeID, nil + } + + // No -S: resolve from session (-o) / node / global / OpenSSH layers. An empty + // nodeID still honours -o ControlPath and the global default for ad-hoc hosts. + expanded, err := controlSocketPath(o, provider, nodeID) + if err != nil { + return "", ssh.TargetContext{}, nil, "", controlError(stderr, fmt.Sprintf("resolve control path failed: %v", err)) + } + if expanded == "" || expanded == "none" { + return "", ssh.TargetContext{}, nil, "", controlError(stderr, "ControlPath must be specified when using -O") + } + return expanded, target, provider, nodeID, nil +} + +// resolveControlNodeID resolves a ConnectionTarget against a read-only +// ConfigProvider, returning the matched saved or OpenSSH virtual node ID when +// the effective (user, host, port) matches an existing node, or "" when the +// target is an unremembered override or ad-hoc destination. +func resolveControlNodeID(provider config.ConfigProvider, target config.ConnectionTarget) (string, error) { + selector := strings.TrimSpace(target.Selector) + if provider == nil || selector == "" { + return "", nil + } + resolved, err := config.ResolveTarget(provider, target) + if err != nil { + return "", err + } + return resolved.MatchedNodeID, nil +} + +func setProviderOpenSSHAlias(provider config.ConfigProvider, nodeID, alias string) { + if p, ok := provider.(interface{ SetNodeOpenSSHAlias(string, string) }); ok { + p.SetNodeOpenSSHAlias(nodeID, alias) + } +} + +// loadReadOnlyControlProvider loads the local configuration as a read-only view. +// It deliberately avoids the durable repository so control queries cannot apply +// pending node changes or rewrite configuration files. +func loadReadOnlyControlProvider() (config.ConfigProvider, error) { + configPath, keyPath, err := utils.GetConfigFilePath() + if err != nil { + return nil, fmt.Errorf("get config file path: %w", err) + } + configStore := config.NewDefaultStore(configPath, keyPath) + cfg, err := configStore.Load() + if err != nil { + return nil, err + } + provider, err := config.NewProvider(cfg) + if err != nil { + logger.DefaultLogger().Debugf("mux: load OpenSSH config failed (%v); falling back to local config", err) + return config.NewProviderWithoutOpenSSH(cfg), nil + } + return provider, nil +} + +func executeCheck(ctx context.Context, socketPath string, streams controlStreams) error { + client, err := ssh.DialMuxClient(ctx, socketPath) + if err != nil { + return controlConnectError(streams.Stderr, socketPath, err) + } + defer func() { _ = client.Close() }() + + pid, err := client.AliveCheck(ctx) + if err != nil { + return controlError(streams.Stderr, fmt.Sprintf("master alive check failed: %v", err)) + } + + _, _ = fmt.Fprintf(streams.Stderr, "Master running (pid=%d)\n", pid) + return nil +} + +func executeExit(ctx context.Context, socketPath string, streams controlStreams) error { + client, err := ssh.DialMuxClient(ctx, socketPath) + if err != nil { + return controlConnectError(streams.Stderr, socketPath, err) + } + defer func() { _ = client.Close() }() + + if err := client.Terminate(ctx); err != nil { + return controlError(streams.Stderr, fmt.Sprintf("master exit failed: %v", err)) + } + + _, _ = fmt.Fprintln(streams.Stderr, "Exit request sent.") + return nil +} + +func executeStop(ctx context.Context, socketPath string, streams controlStreams) error { + client, err := ssh.DialMuxClient(ctx, socketPath) + if err != nil { + return controlConnectError(streams.Stderr, socketPath, err) + } + defer func() { _ = client.Close() }() + + if err := client.StopListening(ctx); err != nil { + return controlError(streams.Stderr, fmt.Sprintf("master stop listening failed: %v", err)) + } + + _, _ = fmt.Fprintln(streams.Stderr, "Stop listening request sent.") + return nil +} + +func executeForward(ctx context.Context, socketPath string, o *SshOptions, streams controlStreams) error { + reqs, err := parseForwardRequestsFromOptions(o) + if err != nil { + return controlError(streams.Stderr, err.Error()) + } + if len(reqs) == 0 { + return controlError(streams.Stderr, "no forwardings specified for -O forward (use -L, -R, or -D)") + } + + client, err := ssh.DialMuxClient(ctx, socketPath) + if err != nil { + return controlConnectError(streams.Stderr, socketPath, err) + } + defer func() { _ = client.Close() }() + + for _, req := range reqs { + allocPort, err := client.OpenForward(ctx, req) + if err != nil { + msg := err.Error() + if !strings.HasPrefix(msg, "forwarding request failed:") { + msg = fmt.Sprintf("forwarding request failed: %s", msg) + } + return controlError(streams.Stderr, msg) + } + if allocPort > 0 { + _, _ = fmt.Fprintf(streams.Stderr, "Allocated port %d for remote forward\n", allocPort) + } + } + + _, _ = fmt.Fprintln(streams.Stderr, "Forwarding request sent.") + return nil +} + +func executeCancel(ctx context.Context, socketPath string, o *SshOptions, streams controlStreams) error { + reqs, err := parseForwardRequestsFromOptions(o) + if err != nil { + return controlError(streams.Stderr, err.Error()) + } + if len(reqs) == 0 { + return controlError(streams.Stderr, "no forwardings specified for -O cancel (use -L, -R, or -D)") + } + + client, err := ssh.DialMuxClient(ctx, socketPath) + if err != nil { + return controlConnectError(streams.Stderr, socketPath, err) + } + defer func() { _ = client.Close() }() + + for _, req := range reqs { + if err := client.CloseForward(ctx, req); err != nil { + msg := err.Error() + if !strings.HasPrefix(msg, "cancel forward failed:") { + msg = fmt.Sprintf("cancel forward failed: %s", msg) + } + return controlError(streams.Stderr, msg) + } + } + + _, _ = fmt.Fprintln(streams.Stderr, "Forwarding cancel request sent.") + return nil +} + +func executeProxy( + ctx context.Context, + socketPath string, + target ssh.TargetContext, + provider config.ConfigProvider, + nodeID string, + o *SshOptions, + streams controlStreams, +) error { + if o.commandPlan == nil { + if err := o.prepareCommandPlan(); err != nil { + return controlError(streams.Stderr, err.Error()) + } + } + if o.StdinRedirect { + devNull, err := os.Open(os.DevNull) + if err != nil { + return controlError(streams.Stderr, fmt.Sprintf("open %s for SSH stdin failed: %v", os.DevNull, err)) + } + defer func() { _ = devNull.Close() }() + streams.Stdin = devNull + } + + cfg, muxOpts := buildControlProxyClientConfig(target, provider, nodeID, o) + + client, err := ssh.DialControlProxyClient(ctx, socketPath, target, sshenv.Discover(), cfg, logger.DefaultLogger(), muxOpts...) + if err != nil { + if errors.Is(err, ssh.ErrUnauthorizedPeer) || + errors.Is(err, ssh.ErrControlProxyRejected) || + errors.Is(err, ssh.ErrControlTargetMismatch) { + return controlError(streams.Stderr, fmt.Sprintf("Control socket connect(%s): %v", socketPath, err)) + } + return controlConnectError(streams.Stderr, socketPath, err) + } + defer func() { _ = client.Close() }() + + if o.NoCmd { + if waitErr := client.Wait(ctx); waitErr != nil && !errors.Is(waitErr, context.Canceled) { + return mapControlProxySessionError(streams.Stderr, waitErr) + } + return nil + } + + var sessionErr error + if o.Command != "" || o.stdinScript { + sessionErr = o.runCommandWithStreams(ctx, client, o.Command, streams.Stdin, streams.Stdout, streams.Stderr) + } else { + stdinFile, _ := streams.Stdin.(*os.File) + ioStreams := ssh.InteractiveIO{ + Stdin: stdinFile, + Stdout: streams.Stdout, + Stderr: streams.Stderr, + } + if o.Sudo { + sessionErr = client.ShellWithSudoIO(ctx, ioStreams) + } else { + sessionErr = client.ShellWithIO(ctx, ioStreams) + } + } + + return mapControlProxySessionError(streams.Stderr, sessionErr) +} + +func buildControlProxyClientConfig( + target ssh.TargetContext, + provider config.ConfigProvider, + nodeID string, + o *SshOptions, +) (*ssh.ClientConfig, []ssh.MuxClientOption) { + var ( + cfg *ssh.ClientConfig + snapshot *config.Configuration + muxOpts []ssh.MuxClientOption + ) + if provider != nil { + snapshot = provider.Snapshot() + sessionAuth := adapter.SessionAuth{ + Password: o.Password, + Passphrase: o.Passphrase, + KeyPath: utils.ToAbsolutePath(o.IdentityFile), + Remember: false, + OTP: o.OTP, + ProxyJump: o.JumpHost, + } + adpOpts := []adapter.Option{ + adapter.WithCredentialRecording(false), + adapter.WithGlobalSessionAuth(sessionAuth), + } + if nodeID != "" { + adpOpts = append(adpOpts, adapter.WithSessionAuthOverride(nodeID, sessionAuth)) + } + if reg, regErr := utils.GetCredentialRegistry(snapshot); regErr == nil && reg != nil { + adpOpts = append(adpOpts, adapter.WithCredentialSource(reg)) + } + adp := adapter.NewSSHAdapter(provider, adpOpts...) + if nodeID != "" { + if resolvedCfg, err := adp.GetConfig(nodeID); err == nil && resolvedCfg != nil { + normalizeControlProxyResolvedConfig(resolvedCfg, target) + cfg = resolvedCfg + muxOpts = append(muxOpts, ssh.WithMuxConnectionProvider(adp), ssh.WithMuxSecretResolver(adp)) + } + } + } + + if cfg == nil { + port := target.Port + if port <= 0 { + port = 22 + } + var globalExec *ssh.ExecutionConfig + var globalPattern string + if snapshot != nil { + globalExec = snapshot.Execution + globalPattern = snapshot.PasswordPromptPattern + } + resolvedExec, _ := ssh.ResolveExecution(nil, globalExec) + cfg = &ssh.ClientConfig{ + Address: target.Host, + Port: port, + User: target.User, + Password: o.Password, + SudoMode: ssh.SudoModeAuto, + ProxyJump: target.JumpHost, + Execution: resolvedExec, + PasswordPromptPattern: globalPattern, + } + } + + prompter := o.interaction + if prompter == nil && !o.StdinRedirect && !o.stdinScript { + if cliHandler := newCLIInteractionHandler(); cliHandler.canRemember { + prompter = cliHandler + } + } + if prompter != nil { + muxOpts = append(muxOpts, ssh.WithMuxSecretPrompter(prompter)) + } + if snapshot != nil && snapshot.PasswordPromptPattern != "" { + muxOpts = append(muxOpts, ssh.WithMuxPasswordPromptPattern(snapshot.PasswordPromptPattern)) + } + return cfg, muxOpts +} + +func normalizeControlProxyResolvedConfig(resolvedCfg *ssh.ClientConfig, target ssh.TargetContext) { + if resolvedCfg.Port <= 0 { + resolvedCfg.Port = target.Port + if resolvedCfg.Port <= 0 { + resolvedCfg.Port = 22 + } + } + if resolvedCfg.User == "" { + resolvedCfg.User = target.User + } +} + +func mapControlProxySessionError(stderr io.Writer, err error) error { + if err == nil { + return nil + } + var exitErr *cryptoSSH.ExitError + if errors.As(err, &exitErr) { + code := exitErr.ExitStatus() + if code == 0 { + code = 255 + } + return &ControlExitError{Code: code, Message: exitErr.Error(), Reported: true} + } + var cmdExitErr *ssh.CommandExitError + if errors.As(err, &cmdExitErr) && cmdExitErr.ExitCode != nil { + return &ControlExitError{Code: int(*cmdExitErr.ExitCode), Message: cmdExitErr.Error(), Reported: true} + } + return controlError(stderr, err.Error()) +} + +func executeProxyStream(ctx context.Context, socketPath string, streams controlStreams) error { + // The shared dialer performs the same peer-UID check, HELLO exchange, and + // MUX_C_PROXY switch as every other client path. A private handshake here + // would skip the peer validation and let a forged socket pass. + conn, err := ssh.DialMuxProxyStream(ctx, socketPath) + if err != nil { + if errors.Is(err, ssh.ErrUnauthorizedPeer) { + return controlError(streams.Stderr, fmt.Sprintf("Control socket connect(%s): %v", socketPath, err)) + } + if errors.Is(err, ssh.ErrControlProxyRejected) { + return controlError(streams.Stderr, fmt.Sprintf("Control socket connect(%s): %v", socketPath, err)) + } + return controlConnectError(streams.Stderr, socketPath, err) + } + defer func() { _ = conn.Close() }() + + // Bi-directional pipe between streams.Stdin/streams.Stdout and the raw socket connection. + // Do not call conn.CloseWrite() when streams.Stdin hits EOF immediately (e.g. `< /dev/null`), + // because half-closing the Unix control socket causes the Master's readUnpaddedPacket loop + // to observe EOF and tear down the proxy session before buffered server packets are read. + stdoutDone := make(chan struct{}) + go func() { + _, _ = io.Copy(conn, streams.Stdin) + }() + go func() { + _, _ = io.Copy(streams.Stdout, conn) + close(stdoutDone) + }() + + select { + case <-ctx.Done(): + _ = conn.Close() + <-stdoutDone + return ctx.Err() + case <-stdoutDone: + return nil + } +} + +func executeInfo(ctx context.Context, socketPath string, infoType string, streams controlStreams) error { + client, err := ssh.DialMuxClient(ctx, socketPath) + if err != nil { + return controlConnectError(streams.Stderr, socketPath, err) + } + defer func() { _ = client.Close() }() + + info, err := client.RequestInfo(ctx, infoType) + if err != nil { + return controlError(streams.Stderr, err.Error()) + } + + _, _ = fmt.Fprint(streams.Stdout, info) + return nil +} + +func parseForwardRequestsFromOptions(o *SshOptions) ([]ssh.ForwardRequest, error) { + var reqs []ssh.ForwardRequest + + for _, lArg := range o.LocalForwards { + req, err := parseSingleForward(lArg, ssh.ForwardLocal) + if err != nil { + return nil, err + } + reqs = append(reqs, req) + } + + for _, rArg := range o.RemoteForwards { + req, err := parseSingleForward(rArg, ssh.ForwardRemote) + if err != nil { + return nil, err + } + reqs = append(reqs, req) + } + + if o.DynamicForward != "" { + req, err := parseSingleDynamicForward(o.DynamicForward) + if err != nil { + return nil, err + } + reqs = append(reqs, req) + } + + return reqs, nil +} + +func parseSingleForward(arg string, fwdType ssh.ForwardType) (ssh.ForwardRequest, error) { + var req ssh.ForwardRequest + req.Type = fwdType + + bAddr, dAddr, err := parseForwardArg(arg) + if err != nil { + return req, err + } + + bHost, bPortStr, err := net.SplitHostPort(bAddr) + if err != nil { + return req, fmt.Errorf("split bind addr failed: %w", err) + } + bPort, err := strconv.ParseUint(bPortStr, 10, 16) + if err != nil { + return req, fmt.Errorf("parse bind port failed: %w", err) + } + + dHost, dPortStr, err := net.SplitHostPort(dAddr) + if err != nil { + return req, fmt.Errorf("split dest addr failed: %w", err) + } + dPort, err := strconv.ParseUint(dPortStr, 10, 16) + if err != nil { + return req, fmt.Errorf("parse dest port failed: %w", err) + } + + req.ListenHost = bHost + req.ListenPort = uint32(bPort) + req.ConnectHost = dHost + req.ConnectPort = uint32(dPort) + + return req, nil +} + +func parseSingleDynamicForward(arg string) (ssh.ForwardRequest, error) { + var req ssh.ForwardRequest + req.Type = ssh.ForwardDynamic + + listenAddr, err := parseDynamicForwardArg(arg) + if err != nil { + return req, err + } + + host, portStr, err := net.SplitHostPort(listenAddr) + if err != nil { + return req, fmt.Errorf("split dynamic forward addr failed: %w", err) + } + port, err := strconv.ParseUint(portStr, 10, 16) + if err != nil { + return req, fmt.Errorf("parse dynamic forward port failed: %w", err) + } + + req.ListenHost = host + req.ListenPort = uint32(port) + return req, nil +} diff --git a/cmd/ssh_control_adversarial_test.go b/cmd/ssh_control_adversarial_test.go new file mode 100644 index 00000000..2153a136 --- /dev/null +++ b/cmd/ssh_control_adversarial_test.go @@ -0,0 +1,576 @@ +package cmd + +import ( + "bytes" + "context" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + + "github.com/spf13/cobra" + "github.com/wentf9/xops-cli/core/concurrent" + "github.com/wentf9/xops-cli/core/ssh" + "github.com/wentf9/xops-cli/pkg/adapter" + "github.com/wentf9/xops-cli/pkg/config" + "github.com/wentf9/xops-cli/pkg/models" +) + +// mockMasterServer runs a mock unix domain socket server implementing OpenSSH PROTOCOL.mux v4. +type mockMasterServer struct { + listener net.Listener + sockPath string + extensions []ssh.MuxExtension + alivePID uint32 + exitErr string + stopErr string + infoResp map[string]string + infoErr map[string]string +} + +func newMockMasterServer(t *testing.T) *mockMasterServer { + t.Helper() + tmpDir, err := os.MkdirTemp("", "mock-mux-*") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + sock := filepath.Join(tmpDir, "master.sock") + ln, err := net.Listen("unix", sock) + if err != nil { + _ = os.RemoveAll(tmpDir) + t.Fatalf("Listen unix failed: %v", err) + } + + m := &mockMasterServer{ + listener: ln, + sockPath: sock, + alivePID: uint32(os.Getpid()), + extensions: []ssh.MuxExtension{ + {Name: "info", Value: "1"}, + }, + infoResp: make(map[string]string), + infoErr: make(map[string]string), + } + + t.Cleanup(func() { + _ = ln.Close() + _ = os.RemoveAll(tmpDir) + }) + + go m.serve() + return m +} + +func (m *mockMasterServer) serve() { + for { + conn, err := m.listener.Accept() + if err != nil { + return + } + go m.handleConn(conn) + } +} + +func (m *mockMasterServer) handleConn(conn net.Conn) { + defer func() { _ = conn.Close() }() + + // Handshake + pktType, _, err := ssh.ReadMuxPacket(conn) + if err != nil || pktType != ssh.MUX_MSG_HELLO { + return + } + _ = ssh.WriteMuxPacket(conn, ssh.MUX_MSG_HELLO, ssh.EncodeMuxHello(ssh.MuxProtoVersion, m.extensions)) + + for { + msgType, payload, err := ssh.ReadMuxPacket(conn) + if err != nil { + return + } + switch msgType { + case ssh.MUX_C_ALIVE_CHECK: + reqID, _ := ssh.DecodeMuxRequestID(payload) + _ = ssh.WriteMuxPacket(conn, ssh.MUX_S_ALIVE, ssh.EncodeMuxAlive(reqID, m.alivePID)) + + case ssh.MUX_C_TERMINATE: + reqID, _ := ssh.DecodeMuxRequestID(payload) + if m.exitErr != "" { + _ = ssh.WriteMuxPacket(conn, ssh.MUX_S_FAILURE, ssh.EncodeMuxError(reqID, m.exitErr)) + } else { + _ = ssh.WriteMuxPacket(conn, ssh.MUX_S_OK, ssh.EncodeMuxClientRequestID(reqID)) + return + } + + case ssh.MUX_C_STOP_LISTENING: + reqID, _ := ssh.DecodeMuxRequestID(payload) + if m.stopErr != "" { + _ = ssh.WriteMuxPacket(conn, ssh.MUX_S_FAILURE, ssh.EncodeMuxError(reqID, m.stopErr)) + } else { + _ = ssh.WriteMuxPacket(conn, ssh.MUX_S_OK, ssh.EncodeMuxClientRequestID(reqID)) + } + + case ssh.MUX_C_EXT_INFO: + reqID, name, _ := ssh.DecodeMuxExtInfo(payload) + if errMsg, ok := m.infoErr[name]; ok { + _ = ssh.WriteMuxPacket(conn, ssh.MUX_S_EXT_INFO, ssh.EncodeMuxExtInfoResponse(reqID, 0, errMsg)) + } else if resp, ok := m.infoResp[name]; ok { + _ = ssh.WriteMuxPacket(conn, ssh.MUX_S_EXT_INFO, ssh.EncodeMuxExtInfoResponse(reqID, 1, resp)) + } else { + _ = ssh.WriteMuxPacket(conn, ssh.MUX_S_FAILURE, ssh.EncodeMuxError(reqID, "unsupported info: "+name)) + } + + case ssh.MUX_C_OPEN_FWD: + req, _ := ssh.DecodeMuxFwdRequest(payload) + _ = ssh.WriteMuxPacket(conn, ssh.MUX_S_OK, ssh.EncodeMuxClientRequestID(req.RequestID)) + + case ssh.MUX_C_CLOSE_FWD: + req, _ := ssh.DecodeMuxFwdRequest(payload) + _ = ssh.WriteMuxPacket(conn, ssh.MUX_S_OK, ssh.EncodeMuxClientRequestID(req.RequestID)) + } + } +} + +// 1. Adversarial Test: -M flag variations (single, double, triple, short-flags, flag override of -o) +func TestAdversarial_MasterFlagVariations(t *testing.T) { + tests := []struct { + name string + args []string + wantMaster ssh.ControlMasterMode + }{ + { + name: "single -M", + args: []string{"-M", "dummyhost"}, + wantMaster: ssh.ControlMasterYes, + }, + { + name: "double -M -M", + args: []string{"-M", "-M", "dummyhost"}, + wantMaster: ssh.ControlMasterAsk, + }, + { + name: "combined -MM", + args: []string{"-MM", "dummyhost"}, + wantMaster: ssh.ControlMasterAsk, + }, + { + name: "triple -M -M -M", + args: []string{"-M", "-M", "-M", "dummyhost"}, + wantMaster: ssh.ControlMasterAsk, + }, + { + name: "-M overrides -o ControlMaster=auto", + args: []string{"-M", "-o", "ControlMaster=auto", "dummyhost"}, + wantMaster: ssh.ControlMasterYes, + }, + { + name: "-M -M overrides -o ControlMaster=no", + args: []string{"-M", "-M", "-o", "ControlMaster=no", "dummyhost"}, + wantMaster: ssh.ControlMasterAsk, + }, + { + name: "no -M respects -o ControlMaster=auto", + args: []string{"-o", "ControlMaster=auto", "dummyhost"}, + wantMaster: ssh.ControlMasterAuto, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + opts := NewSshOptions() + cmd := &cobra.Command{Use: "ssh"} + opts.control.RegisterSSHFlags(cmd) + + if err := cmd.ParseFlags(tc.args); err != nil { + t.Fatalf("ParseFlags failed: %v", err) + } + sessionOpts, err := opts.control.ToSessionControlOptions() + if err != nil { + t.Fatalf("ToSessionControlOptions failed: %v", err) + } + if sessionOpts.Master == nil || *sessionOpts.Master != tc.wantMaster { + t.Fatalf("expected Master %q, got %v", tc.wantMaster, sessionOpts.Master) + } + }) + } +} + +// 2. Adversarial Test: -S none completely disables connection sharing even when lower tiers have ControlMaster auto & ControlPersist +func TestAdversarial_ControlPathNone_DisablesSharing(t *testing.T) { + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Control: &ssh.ControlConfig{ + Master: ssh.ControlMasterAuto, + Path: "/tmp/global.sock", + Persist: "1h", + }, + } + cfg.Hosts.Set("host1", models.Host{Address: "192.168.1.100"}) + cfg.Identities.Set("id1", models.Identity{User: "alice", AuthType: "auto"}) + cfg.Nodes.Set("persistent-node", models.Node{ + HostRef: "host1", + IdentityRef: "id1", + Control: &ssh.ControlConfig{ + Master: ssh.ControlMasterAuto, + Path: "/tmp/lower-tier.sock", + Persist: "10m", + }, + }) + + provider, err := config.NewProvider(cfg) + if err != nil { + t.Fatalf("NewProvider failed: %v", err) + } + adp := adapter.NewSSHAdapter(provider) + + // Case A: With -S none + var sessionOptsNone adapter.SessionControlOptions + sessionOptsNone.SetPath("none") + + resNone, err := adp.ResolveControl("persistent-node", sessionOptsNone) + if err != nil { + t.Fatalf("ResolveControl failed: %v", err) + } + if resNone == nil { + t.Fatal("expected non-nil ControlConfig with Master=no and Path=none") + } + if resNone.Master != ssh.ControlMasterNo { + t.Fatalf("expected Master=no, got %q", resNone.Master) + } + if resNone.Path != "none" { + t.Fatalf("expected Path=none, got %q", resNone.Path) + } + if resNone.Persist != "" { + t.Fatalf("expected empty Persist when Path=none, got %q", resNone.Persist) + } + + // Case B: Verify that without -S none, persistent-node resolves to ControlMasterAuto and Persist 10m + resNormal, err := adp.ResolveControl("persistent-node", adapter.SessionControlOptions{}) + if err != nil { + t.Fatalf("ResolveControl failed: %v", err) + } + if resNormal == nil || resNormal.Master != ssh.ControlMasterAuto || resNormal.Persist != "10m" { + t.Fatalf("expected normal resolve to have auto and 10m, got %+v", resNormal) + } +} + +// 3. Adversarial Test: -O check outputs "Master running (pid=)\n" to stderr, exit 0; exits 255 on missing/dead socket +func TestAdversarial_ControlCheck_AliveAndErrors(t *testing.T) { + // 3.1 Master is alive + server := newMockMasterServer(t) + server.alivePID = 54321 + + var stdout, stderr bytes.Buffer + streams := controlStreams{Stdin: strings.NewReader(""), Stdout: &stdout, Stderr: &stderr} + opts := &SshOptions{ + control: SSHControlFlags{ + CtlCmd: "check", + ControlPath: server.sockPath, + }, + } + + err := executeControlCommandWithStreams(context.Background(), opts, streams) + if err != nil { + t.Fatalf("unexpected check error: %v", err) + } + expectedOutput := "Master running (pid=54321)\n" + if stderr.String() != expectedOutput { + t.Fatalf("expected stderr %q, got %q", expectedOutput, stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("expected stdout empty, got %q", stdout.String()) + } + + // 3.2 Socket does not exist -> exit code 255 + stderr exact string + stderr.Reset() + missingSock := filepath.Join(os.TempDir(), "non_existent_check_12345.sock") + optsMissing := &SshOptions{ + control: SSHControlFlags{ + CtlCmd: "check", + ControlPath: missingSock, + }, + } + err = executeControlCommandWithStreams(context.Background(), optsMissing, streams) + if err == nil { + t.Fatal("expected error on missing socket, got nil") + } + code, ok := ExitCodeFromError(err) + if !ok || code != 255 { + t.Fatalf("expected exit code 255, got code=%d (ok=%v)", code, ok) + } + expectedErr := fmt.Sprintf("Control socket connect(%s): No such file or directory\n", missingSock) + if stderr.String() != expectedErr { + t.Fatalf("expected stderr %q, got %q", expectedErr, stderr.String()) + } + + // 3.3 Socket is stale (ECONNREFUSED) -> exit code 255 + stderr exact string + stderr.Reset() + tmpDir, _ := os.MkdirTemp("", "stale-test-*") + defer func() { _ = os.RemoveAll(tmpDir) }() + staleSock := filepath.Join(tmpDir, "stale.sock") + fd, _ := syscall.Socket(syscall.AF_UNIX, syscall.SOCK_STREAM, 0) + sa := &syscall.SockaddrUnix{Name: staleSock} + _ = syscall.Bind(fd, sa) + _ = syscall.Close(fd) + + optsStale := &SshOptions{ + control: SSHControlFlags{ + CtlCmd: "check", + ControlPath: staleSock, + }, + } + err = executeControlCommandWithStreams(context.Background(), optsStale, streams) + if err == nil { + t.Fatal("expected error on stale socket, got nil") + } + code, ok = ExitCodeFromError(err) + if !ok || code != 255 { + t.Fatalf("expected exit code 255, got %d", code) + } + expectedStaleErr := fmt.Sprintf("Control socket connect(%s): Connection refused\n", staleSock) + if stderr.String() != expectedStaleErr { + t.Fatalf("expected stderr %q, got %q", expectedStaleErr, stderr.String()) + } +} + +// 4. Adversarial Test: -O exit outputs "Exit request sent.\n" to stderr, exit 0; exits 255 on error +func TestAdversarial_ControlExit_SuccessAndFailure(t *testing.T) { + // 4.1 Success + server := newMockMasterServer(t) + var stdout, stderr bytes.Buffer + streams := controlStreams{Stdin: strings.NewReader(""), Stdout: &stdout, Stderr: &stderr} + opts := &SshOptions{ + control: SSHControlFlags{ + CtlCmd: "exit", + ControlPath: server.sockPath, + }, + } + + if err := executeControlCommandWithStreams(context.Background(), opts, streams); err != nil { + t.Fatalf("unexpected exit error: %v", err) + } + if stderr.String() != "Exit request sent.\n" { + t.Fatalf("expected stderr 'Exit request sent.\\n', got %q", stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("expected stdout empty, got %q", stdout.String()) + } + + // 4.2 Master rejects exit (failure packet) -> exit code 255 + serverFail := newMockMasterServer(t) + serverFail.exitErr = "active sessions remaining" + stderr.Reset() + optsFail := &SshOptions{ + control: SSHControlFlags{ + CtlCmd: "exit", + ControlPath: serverFail.sockPath, + }, + } + err := executeControlCommandWithStreams(context.Background(), optsFail, streams) + if err == nil { + t.Fatal("expected error on exit failure, got nil") + } + code, ok := ExitCodeFromError(err) + if !ok || code != 255 { + t.Fatalf("expected exit code 255, got %d", code) + } + if !strings.Contains(stderr.String(), "active sessions remaining") { + t.Fatalf("expected stderr to contain error reason, got %q", stderr.String()) + } +} + +// 5. Adversarial Test: -O stop outputs "Stop listening request sent.\n" to stderr, exit 0; exits 255 on error +func TestAdversarial_ControlStop_SuccessAndFailure(t *testing.T) { + // 5.1 Success + server := newMockMasterServer(t) + var stdout, stderr bytes.Buffer + streams := controlStreams{Stdin: strings.NewReader(""), Stdout: &stdout, Stderr: &stderr} + opts := &SshOptions{ + control: SSHControlFlags{ + CtlCmd: "stop", + ControlPath: server.sockPath, + }, + } + + if err := executeControlCommandWithStreams(context.Background(), opts, streams); err != nil { + t.Fatalf("unexpected stop error: %v", err) + } + if stderr.String() != "Stop listening request sent.\n" { + t.Fatalf("expected stderr 'Stop listening request sent.\\n', got %q", stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("expected stdout empty, got %q", stdout.String()) + } + + // 5.2 Server failure -> exit code 255 + serverFail := newMockMasterServer(t) + serverFail.stopErr = "cannot unlink socket" + stderr.Reset() + optsFail := &SshOptions{ + control: SSHControlFlags{ + CtlCmd: "stop", + ControlPath: serverFail.sockPath, + }, + } + err := executeControlCommandWithStreams(context.Background(), optsFail, streams) + if err == nil { + t.Fatal("expected error on stop failure, got nil") + } + code, ok := ExitCodeFromError(err) + if !ok || code != 255 { + t.Fatalf("expected exit code 255, got %d", code) + } + if !strings.Contains(stderr.String(), "cannot unlink socket") { + t.Fatalf("expected stderr to contain error reason, got %q", stderr.String()) + } +} + +// 6. Adversarial Test: -O conninfo and -O channels via MUX_C_EXT_INFO +func TestAdversarial_ControlInfo_ConninfoAndChannels(t *testing.T) { + server := newMockMasterServer(t) + server.infoResp["connection"] = "Master socket: /tmp/master.sock\nPID: 123\n" + server.infoResp["channels"] = "Active Channels:\n- Session 1: pty\n- LocalForward 8080->80\n" + + // 6.1 conninfo + { + var stdout, stderr bytes.Buffer + streams := controlStreams{Stdin: strings.NewReader(""), Stdout: &stdout, Stderr: &stderr} + opts := &SshOptions{ + control: SSHControlFlags{ + CtlCmd: "conninfo", + ControlPath: server.sockPath, + }, + } + if err := executeControlCommandWithStreams(context.Background(), opts, streams); err != nil { + t.Fatalf("conninfo failed: %v", err) + } + if stdout.String() != server.infoResp["connection"] { + t.Fatalf("expected stdout %q, got %q", server.infoResp["connection"], stdout.String()) + } + if stderr.Len() != 0 { + t.Fatalf("expected stderr empty, got %q", stderr.String()) + } + } + + // 6.2 channels + { + var stdout, stderr bytes.Buffer + streams := controlStreams{Stdin: strings.NewReader(""), Stdout: &stdout, Stderr: &stderr} + opts := &SshOptions{ + control: SSHControlFlags{ + CtlCmd: "channels", + ControlPath: server.sockPath, + }, + } + if err := executeControlCommandWithStreams(context.Background(), opts, streams); err != nil { + t.Fatalf("channels failed: %v", err) + } + if stdout.String() != server.infoResp["channels"] { + t.Fatalf("expected stdout %q, got %q", server.infoResp["channels"], stdout.String()) + } + if stderr.Len() != 0 { + t.Fatalf("expected stderr empty, got %q", stderr.String()) + } + } + + // 6.3 Server returns error on query -> exits 255 + { + server.infoErr["connection"] = "connection query disabled by policy" + var stdout, stderr bytes.Buffer + streams := controlStreams{Stdin: strings.NewReader(""), Stdout: &stdout, Stderr: &stderr} + opts := &SshOptions{ + control: SSHControlFlags{ + CtlCmd: "conninfo", + ControlPath: server.sockPath, + }, + } + err := executeControlCommandWithStreams(context.Background(), opts, streams) + if err == nil { + t.Fatal("expected error on infoErr, got nil") + } + code, ok := ExitCodeFromError(err) + if !ok || code != 255 { + t.Fatalf("expected exit code 255, got %d", code) + } + if !strings.Contains(stderr.String(), "connection query disabled by policy") { + t.Fatalf("expected stderr to contain error reason, got %q", stderr.String()) + } + } +} + +// 7. Adversarial Test: Missing forward flags for -O forward and -O cancel exits 255 +func TestAdversarial_ForwardCancel_MissingOptions(t *testing.T) { + server := newMockMasterServer(t) + + // Forward without -L, -R, or -D + { + var stdout, stderr bytes.Buffer + streams := controlStreams{Stdin: strings.NewReader(""), Stdout: &stdout, Stderr: &stderr} + opts := &SshOptions{ + control: SSHControlFlags{ + CtlCmd: "forward", + ControlPath: server.sockPath, + }, + } + err := executeControlCommandWithStreams(context.Background(), opts, streams) + if err == nil { + t.Fatal("expected error on empty forward, got nil") + } + code, ok := ExitCodeFromError(err) + if !ok || code != 255 { + t.Fatalf("expected exit code 255, got %d", code) + } + if !strings.Contains(stderr.String(), "no forwardings specified for -O forward") { + t.Fatalf("unexpected stderr: %q", stderr.String()) + } + } + + // Cancel without -L, -R, or -D + { + var stdout, stderr bytes.Buffer + streams := controlStreams{Stdin: strings.NewReader(""), Stdout: &stdout, Stderr: &stderr} + opts := &SshOptions{ + control: SSHControlFlags{ + CtlCmd: "cancel", + ControlPath: server.sockPath, + }, + } + err := executeControlCommandWithStreams(context.Background(), opts, streams) + if err == nil { + t.Fatal("expected error on empty cancel, got nil") + } + code, ok := ExitCodeFromError(err) + if !ok || code != 255 { + t.Fatalf("expected exit code 255, got %d", code) + } + if !strings.Contains(stderr.String(), "no forwardings specified for -O cancel") { + t.Fatalf("unexpected stderr: %q", stderr.String()) + } + } +} + +// 8. Adversarial Test: Missing ControlPath on unconfigured host exits 255 +func TestAdversarial_MissingControlPath_Exits255(t *testing.T) { + var stdout, stderr bytes.Buffer + streams := controlStreams{Stdin: strings.NewReader(""), Stdout: &stdout, Stderr: &stderr} + opts := &SshOptions{ + Host: "unconfigured-host-9876", + control: SSHControlFlags{ + CtlCmd: "check", + }, + } + err := executeControlCommandWithStreams(context.Background(), opts, streams) + if err == nil { + t.Fatal("expected error on missing ControlPath, got nil") + } + code, ok := ExitCodeFromError(err) + if !ok || code != 255 { + t.Fatalf("expected exit code 255, got %d", code) + } + if !strings.Contains(stderr.String(), "ControlPath must be specified when using -O") { + t.Fatalf("expected missing ControlPath error, got %q", stderr.String()) + } +} diff --git a/cmd/ssh_control_guard_test.go b/cmd/ssh_control_guard_test.go new file mode 100644 index 00000000..a058ae60 --- /dev/null +++ b/cmd/ssh_control_guard_test.go @@ -0,0 +1,2712 @@ +package cmd + +import ( + "context" + "errors" + "net" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "testing" + "time" + + cmdutils "github.com/wentf9/xops-cli/cmd/utils" + "github.com/wentf9/xops-cli/core/concurrent" + "github.com/wentf9/xops-cli/core/ssh" + "github.com/wentf9/xops-cli/core/testutil/sshfixture" + "github.com/wentf9/xops-cli/pkg/adapter" + "github.com/wentf9/xops-cli/pkg/config" + "github.com/wentf9/xops-cli/pkg/models" + cryptoSSH "golang.org/x/crypto/ssh" +) + +// TestControlFlagsValidateRejectsInvalidOption verifies that -o is validated +// before a command resolves or prepares a node, and also when -S makes path +// resolution return early. Otherwise a typo is silently ignored. +func TestControlFlagsValidateRejectsInvalidOption(t *testing.T) { + for _, name := range []string{"ssh", "explicit -S"} { + flags := SSHControlFlags{CtlCmd: "check", Options: []string{"ControlMaste=yes"}} + if name == "explicit -S" { + flags.ControlPath = "/tmp/explicit.sock" + } + if err := flags.Validate(); err == nil { + t.Fatalf("%s: expected an unsupported -o option error", name) + } + } + + // A valid option set must keep working. + valid := SSHControlFlags{CtlCmd: "check", Options: []string{"ControlPersist=10m", "Port=2222", "User=ops"}} + if err := valid.Validate(); err != nil { + t.Fatalf("valid -o options were rejected: %v", err) + } + + // SshOptions.Validate runs the control validation before touching a node. + options := NewSshOptions() + options.Host = "192.0.2.10" + options.control = SSHControlFlags{Options: []string{"Bogus=1"}} + err := options.Validate() + if err == nil || !strings.Contains(err.Error(), "unsupported -o option") { + t.Fatalf("expected an unsupported -o option error from Validate, got %v", err) + } +} + +// TestParentDaemonDisablesNodeControlSharing verifies that a -f parent cannot +// claim the control socket the detached child must own, even when the session +// supplies -M/-S/-o for that node. A global disable would be outranked by the +// node-scoped session override. +func TestParentDaemonDisablesNodeControlSharing(t *testing.T) { + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + Control: &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "/tmp/global.sock", Persist: "1h"}, + } + cfg.Hosts.Set("host-1", models.Host{Address: "192.0.2.1", Port: 22}) + cfg.Identities.Set("id-1", models.Identity{User: "ops", AuthType: "auto"}) + cfg.Nodes.Set("web-01", models.Node{ + HostRef: "host-1", + IdentityRef: "id-1", + Control: &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "/tmp/node.sock", Persist: "10m"}, + }) + provider := config.NewProviderWithoutOpenSSH(cfg) + + options := NewSshOptions() + options.Remember = "never" + options.control.MasterCount = 1 // -M + + adpOpts, err := options.buildAdapterOptions("web-01", cfg, nil) + if err != nil { + t.Fatalf("buildAdapterOptions failed: %v", err) + } + + // Baseline: the session -M wins and keeps the node's control path, so the + // parent would bind the socket without the dedicated disable. + sessionOnly, err := adapter.NewSSHAdapter(provider, adpOpts...).GetControlConfig("web-01") + if err != nil { + t.Fatalf("ResolveControl failed: %v", err) + } + if sessionOnly == nil || sessionOnly.Master != ssh.ControlMasterYes || sessionOnly.Path != "/tmp/node.sock" { + t.Fatalf("expected the session -M master on the node path, got %+v", sessionOnly) + } + + adpOpts = append(adpOpts, disableSessionControlForParent("web-01")) + disabled, err := adapter.NewSSHAdapter(provider, adpOpts...).GetControlConfig("web-01") + if err != nil { + t.Fatalf("ResolveControl with the parent disable failed: %v", err) + } + if disabled == nil || disabled.Path != "none" || disabled.Master != ssh.ControlMasterNo || disabled.Persist != "" { + t.Fatalf("expected the -f parent to disable connection sharing, got %+v", disabled) + } + + // ProxyJump hops must also have connection sharing disabled in the -f parent. + cfg.Nodes.Set("jump-01", models.Node{ + HostRef: "host-1", + IdentityRef: "id-1", + Control: &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "/tmp/jump.sock", Persist: "10m"}, + }) + jumpDisabled, err := adapter.NewSSHAdapter(provider, adpOpts...).GetControlConfig("jump-01") + if err != nil { + t.Fatalf("ResolveControl for jump hop with parent disable failed: %v", err) + } + if jumpDisabled == nil || jumpDisabled.Path != "none" || jumpDisabled.Master != ssh.ControlMasterNo || jumpDisabled.Persist != "" { + t.Fatalf("expected the -f parent to disable jump hop connection sharing, got %+v", jumpDisabled) + } +} + +// TestSftpExecScpValidateControlAndExtraArgs verifies that SftpOptions rejects +// unexpected positional arguments and that ExecOptions/ScpOptions validate -o +// control flags even when Validate is called directly. +func TestSftpExecScpValidateControlAndExtraArgs(t *testing.T) { + sftpOpts := NewSftpOptions() + sftpOpts.args = []string{"web-01", "unexpected-arg"} + if err := sftpOpts.Validate(); err == nil || !strings.Contains(err.Error(), "unexpected positional argument") { + t.Fatalf("expected SftpOptions.Validate to reject extra positional args, got %v", err) + } + + execOpts := NewExecOptions() + execOpts.Host = "web-01" + execOpts.Command = "uptime" + execOpts.control = SSHControlFlags{Options: []string{"BogusOption=1"}} + if err := execOpts.Validate(); err == nil || !strings.Contains(err.Error(), "unsupported -o option") { + t.Fatalf("expected ExecOptions.Validate to reject invalid -o option, got %v", err) + } + + scpOpts := NewScpOptions() + scpOpts.Source = "local.txt" + scpOpts.Dest = "web-01:/tmp/remote.txt" + scpOpts.control = SSHControlFlags{Options: []string{"BogusOption=1"}} + if err := scpOpts.Validate(); err == nil || !strings.Contains(err.Error(), "unsupported -o option") { + t.Fatalf("expected ScpOptions.Validate to reject invalid -o option, got %v", err) + } +} + +// TestMuxDaemonArgsBuildsSSHCommandForNonSSHInvocations verifies that when a +// persistent master is started by scp/sftp/exec, muxDaemonArgs constructs an +// `ssh -N` command line rather than re-running the transfer/batch subcommand. +func TestMuxDaemonArgsBuildsSSHCommandForNonSSHInvocations(t *testing.T) { + opts := NewSshOptions() + opts.Port = 2222 + opts.User = "deploy" + opts.IdentityFile = "/tmp/id_ed25519" + opts.JumpHost = "bastion" + opts.Remember = "never" + opts.control.Options = []string{"ControlPersist=10m"} + + args := opts.muxDaemonArgs("web-01", "/tmp/cm.sock", nil) + joined := strings.Join(args, " ") + for _, want := range []string{ + "ssh -N --host web-01 -S /tmp/cm.sock", + "-p 2222", + "-l deploy", + "-i /tmp/id_ed25519", + "-J bastion", + "--remember never", + "-o ControlPersist=10m", + } { + if !strings.Contains(joined, want) { + t.Fatalf("muxDaemonArgs = %q, missing %q", joined, want) + } + } +} + +type staticHostKeyTrust struct{} + +func (staticHostKeyTrust) Verify(context.Context, ssh.HostKeyRequest, cryptoSSH.PublicKey) error { + return nil +} + +// TestDrainOwnedMastersWaitsForSecondarySession verifies that when a foreground +// session finishes while another client session is attached to the connector's +// master, drainOwnedMasters waits for the secondary session to finish before +// allowing handoff or CloseAll to shut the master down. +func TestDrainOwnedMastersWaitsForSecondarySession(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + fixture, err := sshfixture.New(ctx) + if err != nil { + t.Fatalf("sshfixture.New failed: %v", err) + } + defer func() { _ = fixture.Close() }() + + host, portStr, err := net.SplitHostPort(fixture.Address) + if err != nil { + t.Fatalf("SplitHostPort failed: %v", err) + } + port, err := strconv.Atoi(portStr) + if err != nil { + t.Fatalf("Atoi failed: %v", err) + } + + sockPath := filepath.Join(t.TempDir(), "drain.sock") + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + } + cfg.Hosts.Set("h1", models.Host{Address: host, Port: uint16(port)}) + cfg.Identities.Set("id1", models.Identity{User: "fixture", AuthType: "password", Password: sshfixture.Password}) + cfg.Nodes.Set("web-01", models.Node{ + HostRef: "h1", + IdentityRef: "id1", + Control: &ssh.ControlConfig{ + Master: ssh.ControlMasterAuto, + Path: sockPath, + Persist: "10m", + }, + }) + provider := config.NewProviderWithoutOpenSSH(cfg) + connector := adapter.NewConnector(provider, ssh.WithHostKeyVerifier(staticHostKeyTrust{})) + connector.AcceptNewHostKey.Store(true) + defer func() { _ = connector.CloseAll() }() + + if _, err := connector.Connect(ctx, "web-01"); err != nil { + t.Fatalf("connector.Connect failed: %v", err) + } + + master := ssh.GetActiveMuxServer(sockPath) + if master == nil { + t.Fatal("expected active MuxServer on sockPath") + } + + // Attach a secondary proxy client + proxyCli, proxyConn, err := ssh.DialControlProxy(ctx, sockPath, master.Target()) + if err != nil { + t.Fatalf("DialControlProxy failed: %v", err) + } + defer func() { + _ = proxyCli.Close() + _ = proxyConn.Close() + }() + + deadline := time.Now().Add(2 * time.Second) + for master.ActiveSessions() < 2 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if master.ActiveSessions() != 2 { + t.Fatalf("expected ActiveSessions == 2, got %d", master.ActiveSessions()) + } + + done := make(chan error, 1) + go func() { + done <- drainOwnedMasters(ctx, connector) + }() + + select { + case err := <-done: + t.Fatalf("drainOwnedMasters returned while secondary proxy client was still active: %v", err) + case <-time.After(80 * time.Millisecond): + } + + // Close the secondary proxy client; drainOwnedMasters must now unblock and + // releaseMastersForHandoff must succeed cleanly. + _ = proxyCli.Close() + _ = proxyConn.Close() + + select { + case err := <-done: + if err != nil { + t.Fatalf("drainOwnedMasters failed: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("drainOwnedMasters did not return after secondary proxy client disconnected") + } + + ready, err := releaseMastersForHandoff(connector, master, sockPath) + if err != nil || !ready { + t.Fatalf("expected releaseMastersForHandoff to succeed after drain, got ready=%v, err=%v", ready, err) + } +} + +// TestReleaseMastersForHandoffRestoresSessionOnFailure verifies that if +// PrepareHandoff refuses handoff (for example because a forward is still +// active), releaseMastersForHandoff restores the connector's foreground session +// reference so the master's idle timer cannot fire while waiting for drain. +func TestReleaseMastersForHandoffRestoresSessionOnFailure(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + fixture, err := sshfixture.New(ctx) + if err != nil { + t.Fatalf("sshfixture.New failed: %v", err) + } + defer func() { _ = fixture.Close() }() + + host, portStr, err := net.SplitHostPort(fixture.Address) + if err != nil { + t.Fatalf("SplitHostPort failed: %v", err) + } + port, err := strconv.Atoi(portStr) + if err != nil { + t.Fatalf("Atoi failed: %v", err) + } + + sockPath := filepath.Join(t.TempDir(), "restore.sock") + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + } + cfg.Hosts.Set("h1", models.Host{Address: host, Port: uint16(port)}) + cfg.Identities.Set("id1", models.Identity{User: "fixture", AuthType: "password", Password: sshfixture.Password}) + cfg.Nodes.Set("web-01", models.Node{ + HostRef: "h1", + IdentityRef: "id1", + Control: &ssh.ControlConfig{ + Master: ssh.ControlMasterAuto, + Path: sockPath, + Persist: "1s", + }, + }) + provider := config.NewProviderWithoutOpenSSH(cfg) + connector := adapter.NewConnector(provider, ssh.WithHostKeyVerifier(staticHostKeyTrust{})) + connector.AcceptNewHostKey.Store(true) + defer func() { _ = connector.CloseAll() }() + + if _, err := connector.Connect(ctx, "web-01"); err != nil { + t.Fatalf("connector.Connect failed: %v", err) + } + master := ssh.GetActiveMuxServer(sockPath) + if master == nil { + t.Fatal("expected active MuxServer on sockPath") + } + + muxCli, err := ssh.DialMuxClient(ctx, sockPath) + if err != nil { + t.Fatalf("DialMuxClient failed: %v", err) + } + defer func() { _ = muxCli.Close() }() + + fwd := ssh.ForwardRequest{Type: ssh.ForwardLocal, ListenHost: "127.0.0.1", ConnectHost: "127.0.0.1", ConnectPort: 22} + if _, err := muxCli.OpenForward(ctx, fwd); err != nil { + t.Fatalf("OpenForward failed: %v", err) + } + + ready, err := releaseMastersForHandoff(connector, master, sockPath) + if err != nil || ready { + t.Fatalf("expected releaseMastersForHandoff to refuse while forward is active, got ready=%v, err=%v", ready, err) + } + if !connector.HasMasterSession(master) || master.ActiveSessions() != 1 { + t.Fatalf("expected foreground session to be restored (has=%v, active=%d)", connector.HasMasterSession(master), master.ActiveSessions()) + } + + // Wait 1.2s (> Persist 1s); master must stay alive because the foreground + // session reference was restored. + time.Sleep(1200 * time.Millisecond) + select { + case <-master.Wait(): + t.Fatal("master shut down on idle timeout after handoff rollback") + default: + } + + if err := muxCli.CloseForward(ctx, fwd); err != nil { + t.Fatalf("CloseForward failed: %v", err) + } + ready, err = releaseMastersForHandoff(connector, master, sockPath) + if err != nil || !ready { + t.Fatalf("expected releaseMastersForHandoff to succeed after forward closed, got ready=%v, err=%v", ready, err) + } +} + +// TestScpResolvedTargetNodeIDsIncludesDerivedUserOverride verifies that when +// scp targets an existing host with a different user@ prefix, resolvedTargetNodeIDs +// includes the derived user@host:port node ID that PrepareNodeContext creates. +func TestScpResolvedTargetNodeIDsIncludesDerivedUserOverride(t *testing.T) { + repo := setupNonDefaultPortWithProxyJumpRepo(t) + scpOpts := NewScpOptions() + scpOpts.Source = "local.txt" + scpOpts.Dest = "test@10.0.0.5:/tmp/remote.txt" + + ids := scpOpts.resolvedTargetNodeIDs(repo) + found := false + for _, id := range ids { + if id == "test@10.0.0.5:2222" { + found = true + break + } + } + if !found { + t.Fatalf("resolvedTargetNodeIDs = %v, missing derived node ID test@10.0.0.5:2222", ids) + } +} + +// TestCompleteDoesNotEnableStdinScriptForControlCommand verifies that piped or +// /dev/null stdin during `xops ssh -O ...` does not set o.stdinScript = true +// (which would fail Validate when combined with -N or other flags). +func TestCompleteDoesNotEnableStdinScriptForControlCommand(t *testing.T) { + opts := NewSshOptions() + opts.NoCmd = true + opts.control = SSHControlFlags{ + CtlCmd: "proxy", + ControlPath: "/tmp/test.sock", + } + cmd := newCmdSshWithOptions(opts) + if err := opts.Complete(cmd, []string{"web-01"}); err != nil { + t.Fatalf("Complete failed: %v", err) + } + if opts.stdinScript { + t.Fatal("expected stdinScript to remain false when -O control command is set") + } + if err := opts.Validate(); err != nil { + t.Fatalf("Validate failed for -N -O proxy with non-TTY test stdin: %v", err) + } +} + +// TestHandoffPersistentMasterNotSkippedAfterTunnelClose verifies that closing +// sshTunnelGroup (which cancels runCtx) does not prevent handoffPersistentMaster +// from running when the outer command context ctx is still active. +func TestHandoffPersistentMasterNotSkippedAfterTunnelClose(t *testing.T) { + outerCtx, cancelOuter := context.WithCancel(context.Background()) + defer cancelOuter() + + runCtx, cancelRun := context.WithCancel(outerCtx) + tunnels, _ := newSSHTunnelGroup(outerCtx, cancelRun) + if err := tunnels.Close(); err != nil { + t.Fatalf("tunnels.Close failed: %v", err) + } + if runCtx.Err() == nil { + t.Fatal("expected tunnels.Close to cancel runCtx") + } + if outerCtx.Err() != nil { + t.Fatal("expected outerCtx to remain active after normal tunnel close") + } +} + +// TestSshDoubleDashHostCommand verifies that `xops ssh -- ` assigns +// the first positional argument after `--` to Host and the remainder to Command +// when --host was not specified via flag. +func TestSshDoubleDashHostCommand(t *testing.T) { + opts := NewSshOptions() + cmd := newCmdSshWithOptions(opts) + if err := cmd.Flags().Parse([]string{"--", "web-01", "uname", "-a"}); err != nil { + t.Fatalf("Flags().Parse failed: %v", err) + } + if err := opts.Complete(cmd, cmd.Flags().Args()); err != nil { + t.Fatalf("Complete failed: %v", err) + } + opts.stdinScript = false + if err := opts.Validate(); err != nil { + t.Fatalf("Validate failed: %v", err) + } + if opts.Host != "web-01" { + t.Fatalf("expected Host=%q, got %q", "web-01", opts.Host) + } + if opts.Command != "uname -a" { + t.Fatalf("expected Command=%q, got %q", "uname -a", opts.Command) + } +} + +// TestMultiHostHandoff_PreservesAllTargetMasters verifies that releasing one +// target master for handoff only releases that master and its ProxyJump chain, +// leaving unrelated target masters in the same connector intact for subsequent +// handoff iterations (e.g. remote-to-remote `xops scp hostA:file hostB:file`). +func TestMultiHostHandoff_PreservesAllTargetMasters(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + fixA, err := sshfixture.New(ctx) + if err != nil { + t.Fatalf("sshfixture.New(A) failed: %v", err) + } + defer func() { _ = fixA.Close() }() + fixB, err := sshfixture.New(ctx) + if err != nil { + t.Fatalf("sshfixture.New(B) failed: %v", err) + } + defer func() { _ = fixB.Close() }() + + hostA, portAStr, _ := net.SplitHostPort(fixA.Address) + portA, _ := strconv.Atoi(portAStr) + hostB, portBStr, _ := net.SplitHostPort(fixB.Address) + portB, _ := strconv.Atoi(portBStr) + + tmpDir := t.TempDir() + sockA := filepath.Join(tmpDir, "hostA.sock") + sockB := filepath.Join(tmpDir, "hostB.sock") + + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + } + cfg.Identities.Set("id1", models.Identity{User: "fixture", AuthType: "password", Password: sshfixture.Password}) + cfg.Hosts.Set("hA", models.Host{Address: hostA, Port: uint16(portA)}) + cfg.Hosts.Set("hB", models.Host{Address: hostB, Port: uint16(portB)}) + cfg.Nodes.Set("nodeA", models.Node{ + HostRef: "hA", + IdentityRef: "id1", + Control: &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: sockA, Persist: "10m"}, + }) + cfg.Nodes.Set("nodeB", models.Node{ + HostRef: "hB", + IdentityRef: "id1", + Control: &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: sockB, Persist: "10m"}, + }) + + provider := config.NewProviderWithoutOpenSSH(cfg) + connector := adapter.NewConnector(provider, ssh.WithHostKeyVerifier(staticHostKeyTrust{})) + connector.AcceptNewHostKey.Store(true) + defer func() { _ = connector.CloseAll() }() + + if _, err := connector.Connect(ctx, "nodeA"); err != nil { + t.Fatalf("Connect(nodeA) failed: %v", err) + } + if _, err := connector.Connect(ctx, "nodeB"); err != nil { + t.Fatalf("Connect(nodeB) failed: %v", err) + } + + masterA := ssh.GetActiveMuxServer(sockA) + masterB := ssh.GetActiveMuxServer(sockB) + if masterA == nil || masterB == nil { + t.Fatalf("expected both masterA (%v) and masterB (%v) to exist", masterA, masterB) + } + + // Releasing masterA for handoff must NOT shut down masterB or remove it from connector! + readyA, err := releaseMastersForHandoff(connector, masterA, sockA) + if err != nil || !readyA { + t.Fatalf("releaseMastersForHandoff(masterA) failed: ready=%v err=%v", readyA, err) + } + + if gotB := ssh.GetActiveMuxServer(sockB); gotB != masterB || !connector.HasMasterSession(masterB) { + t.Fatalf("releasing masterA destroyed unrelated target masterB (got %v, hasSession=%v)", gotB, connector.HasMasterSession(masterB)) + } + + readyB, err := releaseMastersForHandoff(connector, masterB, sockB) + if err != nil || !readyB { + t.Fatalf("releaseMastersForHandoff(masterB) failed: ready=%v err=%v", readyB, err) + } +} + +// TestHandoffSkipsImplicitTier4Control verifies Decision 1.A: when multiplexing +// configuration comes solely from Tier 4 (~/.ssh/config) without explicit CLI +// flags or xops config, ResolveControl marks ImplicitTier4=true and +// handoffMasterInstance does NOT spawn a background daemon on command exit +// (releasing the in-process master cleanly instead). +func TestHandoffSkipsImplicitTier4Control(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + fix, err := sshfixture.New(ctx) + if err != nil { + t.Fatalf("sshfixture.New failed: %v", err) + } + defer func() { _ = fix.Close() }() + + host, portStr, _ := net.SplitHostPort(fix.Address) + port, _ := strconv.Atoi(portStr) + sockPath := filepath.Join(t.TempDir(), "tier4.sock") + + openSSHContent := "Host web-tier4\n" + + " ControlMaster auto\n" + + " ControlPath " + sockPath + "\n" + + " ControlPersist 10m\n" + parser, err := config.NewOpenSSHParserFromReader(strings.NewReader(openSSHContent)) + if err != nil { + t.Fatalf("NewOpenSSHParserFromReader failed: %v", err) + } + + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + } + cfg.Hosts.Set("h1", models.Host{Address: host, Port: uint16(port)}) + cfg.Identities.Set("id1", models.Identity{User: "fixture", AuthType: "password", Password: sshfixture.Password}) + cfg.Nodes.Set("web-tier4", models.Node{ + HostRef: "h1", + IdentityRef: "id1", + }) + + provider := config.NewProviderWithOpenSSHParser(cfg, parser) + adp := adapter.NewSSHAdapter(provider) + + ctrl, err := adp.GetControlConfig("web-tier4") + if err != nil { + t.Fatalf("GetControlConfig failed: %v", err) + } + if ctrl == nil || !ctrl.ImplicitTier4 { + t.Fatalf("expected ImplicitTier4=true for pure Tier 4 config, got %+v", ctrl) + } + + connector := ssh.NewConnector(adp, ssh.WithHostKeyVerifier(staticHostKeyTrust{})) + connector.AcceptNewHostKey.Store(true) + defer func() { _ = connector.CloseAll() }() + + if _, err := connector.Connect(ctx, "web-tier4"); err != nil { + t.Fatalf("Connect(web-tier4) failed: %v", err) + } + master := ssh.GetActiveMuxServer(sockPath) + if master == nil { + t.Fatal("expected in-process master to start during session") + } + + opts := NewSshOptions() + if err := opts.handoffMasterInstance(ctx, connector, master, "web-tier4"); err != nil { + t.Fatalf("handoffMasterInstance returned unexpected error: %v", err) + } + + // Because ImplicitTier4 is true, handoffMasterInstance must NOT have detached + // a background daemon; closing the connector now shuts down the in-process + // master and removes the socket. + if err := connector.CloseAll(); err != nil { + t.Fatalf("connector.CloseAll failed: %v", err) + } + if _, dialErr := ssh.DialMuxClient(ctx, sockPath); dialErr == nil { + t.Fatal("expected no detached background master to remain on sockPath for ImplicitTier4 config") + } + + // R2-5: Even if Tier 1 overrides -S (ControlPath), inheriting ControlPersist + // solely from Tier 4 must keep ImplicitTier4=true. + overridePath := filepath.Join(t.TempDir(), "override.sock") + adpWithPathOverride := adapter.NewSSHAdapter( + provider, + adapter.WithSessionControlOverride("web-tier4", adapter.SessionControlOptions{Path: &overridePath}), + ) + ctrlOverride, err := adpWithPathOverride.GetControlConfig("web-tier4") + if err != nil { + t.Fatalf("GetControlConfig with path override failed: %v", err) + } + if ctrlOverride == nil || !ctrlOverride.ImplicitTier4 { + t.Fatalf("expected ImplicitTier4=true when Persist comes solely from Tier 4, got %+v", ctrlOverride) + } +} + +// TestMuxDaemonArgsAndTargetResolutionForVirtualAndPendingNodes verifies R2-1: +// muxDaemonArgs strips "openssh:" and falls back to master.Target() for port/user, +// and PrepareNodeContext resolves "openssh:" and unconfirmed "user@ip:port" +// selectors without wrapping them as IPv6 literals. +func TestMuxDaemonArgsAndTargetResolutionForVirtualAndPendingNodes(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + fix, err := sshfixture.New(ctx) + if err != nil { + t.Fatalf("sshfixture.New failed: %v", err) + } + defer func() { _ = fix.Close() }() + + host, portStr, _ := net.SplitHostPort(fix.Address) + port, _ := strconv.Atoi(portStr) + sockPath := filepath.Join(t.TempDir(), "r2-1.sock") + + rawClient, err := cryptoSSH.Dial("tcp", fix.Address, &cryptoSSH.ClientConfig{ + User: "fixture", + Auth: []cryptoSSH.AuthMethod{cryptoSSH.Password(sshfixture.Password)}, + HostKeyCallback: cryptoSSH.InsecureIgnoreHostKey(), + }) + if err != nil { + t.Fatalf("cryptoSSH.Dial failed: %v", err) + } + defer func() { _ = rawClient.Close() }() + + master, err := ssh.StartMuxServer(ctx, rawClient, ssh.MuxMasterOptions{ + Path: sockPath, + Target: ssh.TargetContext{ + Host: host, + Port: port, + User: "fixture", + OriginalHost: "openssh:" + host, + Control: &ssh.ControlConfig{ + Master: ssh.ControlMasterAuto, + Path: sockPath, + Persist: "10m", + }, + }, + }) + if err != nil { + t.Fatalf("StartMuxServer failed: %v", err) + } + defer func() { _ = master.Shutdown() }() + + // Simulate scp/exec where o.Port == 0 and o.User == "" on SshOptions. + opts := NewSshOptions() + args := opts.muxDaemonArgs("openssh:"+host, sockPath, master) + joined := strings.Join(args, " ") + wantPrefix := "ssh -N --host " + host + " -S " + sockPath + if !strings.Contains(joined, wantPrefix) { + t.Fatalf("muxDaemonArgs = %q, expected %q (with openssh: stripped)", joined, wantPrefix) + } + if !strings.Contains(joined, "-p "+strconv.Itoa(port)) || !strings.Contains(joined, "-l fixture") { + t.Fatalf("muxDaemonArgs = %q, expected fallback -p %d and -l fixture from master.Target()", joined, port) + } + + // Verify PrepareNodeContext resolves unconfirmed "user@host:port" and "openssh:host" + // selectors to clean host addresses rather than bracketed IPv6 literals. + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + } + store := config.NewDefaultStore(filepath.Join(t.TempDir(), "config.yaml"), filepath.Join(t.TempDir(), "key")) + repo, err := config.NewRepositoryWithoutOpenSSH(cfg, store) + if err != nil { + t.Fatalf("NewRepositoryWithoutOpenSSH failed: %v", err) + } + + for _, sel := range []string{"openssh:" + host, "fixture@" + host + ":" + strconv.Itoa(port)} { + res, err := repo.PrepareNodeContext(ctx, config.EnsureNodeOptions{ + Target: config.ConnectionTarget{ + Selector: sel, + User: "fixture", + HasUser: true, + Port: uint16(port), + HasPort: true, + }, + }) + if err != nil { + t.Fatalf("PrepareNodeContext(%q) failed: %v", sel, err) + } + _, hostCfg, _, err := repo.Resolve(res.NodeID) + if err != nil { + t.Fatalf("Resolve(%q) failed: %v", res.NodeID, err) + } + if hostCfg.Address != host { + t.Fatalf("Selector %q resolved to Address %q, want %q", sel, hostCfg.Address, host) + } + } +} + +// TestMuxDaemonArgsIsolationAndRememberSuppression verifies R3-1: +// - muxDaemonArgs always appends "--remember never". +// - When master != nil, muxDaemonArgs prefers master.Target().Port, User, and +// JumpHost over SshOptions fields and drops target-specific -o options +// (Port, User, ProxyJump) so jump-master or multi-host handoffs never inherit +// the foreground target's flags. +// - When isMuxDaemonChild() is true (XOPS_CLI_SSH_MUX_DAEMON=true), resolveNode +// does not persist or mutate nodes even if Remember was set to "always". +func TestMuxDaemonArgsIsolationAndRememberSuppression(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + fix, err := sshfixture.New(ctx) + if err != nil { + t.Fatalf("sshfixture.New failed: %v", err) + } + defer func() { _ = fix.Close() }() + + host, portStr, _ := net.SplitHostPort(fix.Address) + port, _ := strconv.Atoi(portStr) + sockPath := filepath.Join(t.TempDir(), "r3-1.sock") + + rawClient, err := cryptoSSH.Dial("tcp", fix.Address, &cryptoSSH.ClientConfig{ + User: "fixture", + Auth: []cryptoSSH.AuthMethod{cryptoSSH.Password(sshfixture.Password)}, + HostKeyCallback: cryptoSSH.InsecureIgnoreHostKey(), + }) + if err != nil { + t.Fatalf("cryptoSSH.Dial failed: %v", err) + } + defer func() { _ = rawClient.Close() }() + + jumpMaster, err := ssh.StartMuxServer(ctx, rawClient, ssh.MuxMasterOptions{ + Path: sockPath, + Target: ssh.TargetContext{ + Host: host, + Port: port, + User: "jumpuser", + OriginalHost: "bastion", + JumpHost: "", + Control: &ssh.ControlConfig{ + Master: ssh.ControlMasterAuto, + Path: sockPath, + Persist: "5m", + }, + }, + }) + if err != nil { + t.Fatalf("StartMuxServer failed: %v", err) + } + defer func() { _ = jumpMaster.Shutdown() }() + + opts := NewSshOptions() + opts.Host = "target-host" + opts.Port = 2222 + opts.User = "targetuser" + opts.IdentityFile = "/tmp/target-only-id" + opts.JumpHost = "bastion" + opts.control.Options = []string{"ProxyJump=bastion", "Port=2222", "User=targetuser", "IdentityFile=/tmp/target-only-id", "ControlMaster=auto"} + + args := opts.muxDaemonArgs("bastion", sockPath, jumpMaster) + joined := strings.Join(args, " ") + if !strings.Contains(joined, "--remember never") { + t.Fatalf("expected --remember never in muxDaemonArgs, got %q", joined) + } + if !strings.Contains(joined, "-p "+strconv.Itoa(port)) || !strings.Contains(joined, "-l jumpuser") { + t.Fatalf("expected jumpMaster's port %d and user jumpuser, got %q", port, joined) + } + if strings.Contains(joined, "-J ") || strings.Contains(joined, "ProxyJump=") || strings.Contains(joined, "2222") || strings.Contains(joined, "targetuser") || strings.Contains(joined, "target-only-id") || strings.Contains(joined, "-i ") { + t.Fatalf("expected target-specific JumpHost/Port/User/IdentityFile options to be stripped for jumpMaster, got %q", joined) + } + + // Verify resolveNode in mux daemon child mode never persists an unconfirmed target. + t.Setenv(sshMuxDaemonEnv, "true") + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + } + store := config.NewDefaultStore(filepath.Join(t.TempDir(), "config.yaml"), filepath.Join(t.TempDir(), "key")) + repo, err := config.NewRepositoryWithoutOpenSSH(cfg, store) + if err != nil { + t.Fatalf("NewRepositoryWithoutOpenSSH failed: %v", err) + } + daemonOpts := NewSshOptions() + daemonOpts.Host = host + daemonOpts.Port = uint16(port) + daemonOpts.User = "fixture" + daemonOpts.Remember = "always" + daemonOpts.IdentityFile = "/tmp/test-key" + daemonOpts.Target = config.ConnectionTarget{ + Selector: host, + User: "fixture", + HasUser: true, + Port: uint16(port), + HasPort: true, + } + + nodeID, _, err := daemonOpts.resolveNode(ctx, repo) + if err != nil { + t.Fatalf("resolveNode in daemon mode failed: %v", err) + } + adpOpts, err := daemonOpts.buildAdapterOptions(nodeID, repo.Snapshot(), repo) + if err != nil { + t.Fatalf("buildAdapterOptions in daemon mode failed: %v", err) + } + adp := adapter.NewSSHAdapter(repo, adpOpts...) + if _, err := adp.UpdateAuth(ctx, nodeID, "tok", "secret", "", ""); err != nil { + t.Fatalf("UpdateAuth in daemon mode failed: %v", err) + } + if cfg.Nodes.Count() != 0 { + t.Fatalf("expected 0 persisted nodes in mux daemon child mode, got %d", cfg.Nodes.Count()) + } +} + +// TestOpenSSHVirtualNodeOverridesDoNotFailOrPersist verifies R3-2: +// OpenSSH virtual nodes ("openssh:") work with CLI overrides (-i, -J) in +// ssh, scp, and exec without failing with ErrNodeNotFound or writing bogus +// TestOpenSSHVirtualNodeOverridesDoNotFailOrPersist verifies R3-2 and R4-1: +// When connecting to an OpenSSH virtual node with CLI overrides (-i, -J, etc.), +// ssh/scp/exec must not attempt to persist or mutate the read-only +// "openssh:" entries into Repository, MUST apply -J (ProxyJump) via +// SessionAuth override, and MUST fail fast if -J cannot be resolved. +func TestOpenSSHVirtualNodeOverridesDoNotFailOrPersist(t *testing.T) { + ctx := context.Background() + content := "Host demo-alias\n HostName 127.0.0.1\n User demo\n Port 2200\nHost bastion\n HostName 127.0.0.1\n User jump\n Port 2201\n" + parser, err := config.NewOpenSSHParserFromReader(strings.NewReader(content)) + if err != nil { + t.Fatalf("NewOpenSSHParserFromReader failed: %v", err) + } + + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + } + store := config.NewDefaultStore(filepath.Join(t.TempDir(), "config.yaml"), filepath.Join(t.TempDir(), "key")) + repo, err := config.NewRepositoryWithOpenSSHParser(cfg, store, parser) + if err != nil { + t.Fatalf("NewRepositoryWithOpenSSHParser failed: %v", err) + } + + // 1. ssh resolveNode + buildAdapterOptions with -i and -J on an OpenSSH virtual node. + verifyOpenSSHVirtualNodeSSHOverride(t, ctx, repo, cfg) + + // 2. scp getOrCreateNodeForPath + credentialConnectorWithInteraction with -i and -J. + scpOpts := NewScpOptions() + scpOpts.Source = "demo-alias:/tmp/file.txt" + scpOpts.Dest = "/tmp/local.txt" + scpOpts.IdentityFile = "/tmp/override_id_ed25519" + scpOpts.JumpHost = "bastion" + scpNodeID, _, err := scpOpts.getOrCreateNodeForPath(ctx, repo, PathInfo{Host: "demo-alias", Path: "/tmp/file.txt", IsRemote: true}, "") + if err != nil { + t.Fatalf("ScpOptions.getOrCreateNodeForPath for OpenSSH alias failed: %v", err) + } + if scpNodeID != "openssh:demo-alias" { + t.Fatalf("expected scpNodeID %q, got %q", "openssh:demo-alias", scpNodeID) + } + if cfg.Nodes.Count() != 0 { + t.Fatalf("expected 0 persisted nodes after ScpOptions.getOrCreateNodeForPath, got %d", cfg.Nodes.Count()) + } + conn, err := scpOpts.credentialConnectorWithInteraction(repo, cfg, newCLIInteractionHandler()) + if err != nil || conn == nil { + t.Fatalf("expected non-nil connector from ScpOptions.credentialConnectorWithInteraction, err=%v", err) + } + + // 3. exec getOrCreateNode + buildAdapterOptions on an OpenSSH virtual node. + verifyOpenSSHVirtualNodeExecOverride(t, ctx, repo, cfg) +} + +func verifyOpenSSHVirtualNodeSSHOverride(t *testing.T, ctx context.Context, repo *config.Repository, cfg *config.Configuration) { + t.Helper() + sshOpts := NewSshOptions() + sshOpts.Host = "demo-alias" + sshOpts.IdentityFile = "/tmp/override_id_ed25519" + sshOpts.JumpHost = "bastion" + sshOpts.Target = config.ConnectionTarget{ + Selector: "demo-alias", + ProxyJump: "bastion", + HasProxyJump: true, + } + nodeID, _, err := sshOpts.resolveNode(ctx, repo) + if err != nil { + t.Fatalf("SshOptions.resolveNode for OpenSSH alias with overrides failed: %v", err) + } + if nodeID != "openssh:demo-alias" { + t.Fatalf("expected nodeID %q, got %q", "openssh:demo-alias", nodeID) + } + if cfg.Nodes.Count() != 0 { + t.Fatalf("expected 0 persisted nodes after SshOptions.resolveNode, got %d", cfg.Nodes.Count()) + } + sshAdpOpts, err := sshOpts.buildAdapterOptions(nodeID, cfg, repo) + if err != nil { + t.Fatalf("SshOptions.buildAdapterOptions failed: %v", err) + } + sshAdp := adapter.NewSSHAdapter(repo, sshAdpOpts...) + sshClientCfg, err := sshAdp.GetConfig(nodeID) + if err != nil { + t.Fatalf("sshAdp.GetConfig(%q) failed: %v", nodeID, err) + } + if sshClientCfg.ProxyJump != "openssh:bastion" { + t.Fatalf("expected sshClientCfg.ProxyJump == %q, got %q", "openssh:bastion", sshClientCfg.ProxyJump) + } + + // Invalid -J on OpenSSH virtual node must fail validation in update. + sshBadJump := NewSshOptions() + sshBadJump.Host = "demo-alias" + sshBadJump.JumpHost = "nonexistent-jump-alias" + sshBadJump.Target = config.ConnectionTarget{Selector: "demo-alias", ProxyJump: "nonexistent-jump-alias", HasProxyJump: true} + if _, _, err := sshBadJump.resolveNode(ctx, repo); err == nil { + t.Fatal("expected SshOptions.resolveNode to fail on nonexistent ProxyJump override") + } +} + +func verifyOpenSSHVirtualNodeExecOverride(t *testing.T, ctx context.Context, repo *config.Repository, cfg *config.Configuration) { + t.Helper() + execOpts := NewExecOptions() + execOpts.IdentityFile = "/tmp/override_id_ed25519" + execOpts.JumpHost = "bastion" + execNodeID, _, err := execOpts.getOrCreateNode(ctx, repo, config.ConnectionTarget{ + Selector: "demo-alias", + ProxyJump: "bastion", + HasProxyJump: true, + }, cmdutils.HostInfo{ + Host: "demo-alias", + KeyPath: "/tmp/override_id_ed25519", + }) + if err != nil { + t.Fatalf("ExecOptions.getOrCreateNode for OpenSSH alias failed: %v", err) + } + if execNodeID != "openssh:demo-alias" { + t.Fatalf("expected execNodeID %q, got %q", "openssh:demo-alias", execNodeID) + } + if cfg.Nodes.Count() != 0 { + t.Fatalf("expected 0 persisted nodes after ExecOptions.getOrCreateNode, got %d", cfg.Nodes.Count()) + } + execAdpOpts, err := execOpts.buildAdapterOptions([]execHostTask{{ + nodeID: execNodeID, + host: "demo-alias", + keyPath: "/tmp/override_id_ed25519", + }}, cfg, repo) + if err != nil { + t.Fatalf("ExecOptions.buildAdapterOptions failed: %v", err) + } + execAdp := adapter.NewSSHAdapter(repo, execAdpOpts...) + execClientCfg, err := execAdp.GetConfig(execNodeID) + if err != nil { + t.Fatalf("execAdp.GetConfig(%q) failed: %v", execNodeID, err) + } + if execClientCfg.ProxyJump != "openssh:bastion" { + t.Fatalf("expected execClientCfg.ProxyJump == %q, got %q", "openssh:bastion", execClientCfg.ProxyJump) + } +} + +// TestHandoffConnectorMastersDoesNotSkipPersistentJumpForNonPersistentTarget verifies R3-3: +// When a target node's master does NOT have ControlPersist enabled (e.g. +// ControlMaster=auto, ControlPersist=no), handoffConnectorMasters does not add +// its JumpHost to the suppressed jumps map, allowing a persistent jump master +// (with ImplicitTier4=true so we can test the loop without spawning an OS process) +// to be visited by handoffMasterInstance. +func TestHandoffConnectorMastersDoesNotSkipPersistentJumpForNonPersistentTarget(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + fix, err := sshfixture.New(ctx) + if err != nil { + t.Fatalf("sshfixture.New failed: %v", err) + } + defer func() { _ = fix.Close() }() + + host, portStr, _ := net.SplitHostPort(fix.Address) + port, _ := strconv.Atoi(portStr) + jumpSock := filepath.Join(t.TempDir(), "jump.sock") + targetSock := filepath.Join(t.TempDir(), "target.sock") + + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + } + cfg.Hosts.Set("h-jump", models.Host{Address: host, Port: uint16(port)}) + cfg.Hosts.Set("h-target", models.Host{Address: host, Port: uint16(port)}) + cfg.Identities.Set("id1", models.Identity{User: "fixture", AuthType: "password", Password: sshfixture.Password}) + cfg.Nodes.Set("bastion", models.Node{ + HostRef: "h-jump", + IdentityRef: "id1", + Control: &ssh.ControlConfig{ + Master: ssh.ControlMasterAuto, + Path: jumpSock, + Persist: "5m", + }, + }) + cfg.Nodes.Set("app", models.Node{ + HostRef: "h-target", + IdentityRef: "id1", + ProxyJump: "bastion", + Control: &ssh.ControlConfig{ + Master: ssh.ControlMasterAuto, + Path: targetSock, + Persist: "no", + }, + }) + + provider := config.NewProviderWithoutOpenSSH(cfg) + adp := adapter.NewSSHAdapter(provider) + connector := ssh.NewConnector(adp, ssh.WithHostKeyVerifier(staticHostKeyTrust{})) + connector.AcceptNewHostKey.Store(true) + defer func() { _ = connector.CloseAll() }() + + if _, err := connector.Connect(ctx, "app"); err != nil { + t.Fatalf("Connect(app) via bastion failed: %v", err) + } + + jumpMaster := ssh.GetActiveMuxServer(jumpSock) + targetMaster := ssh.GetActiveMuxServer(targetSock) + if jumpMaster == nil || targetMaster == nil { + t.Fatalf("expected both jumpMaster (%v) and targetMaster (%v) to be active", jumpMaster, targetMaster) + } + if !jumpMaster.PersistEnabled() { + t.Fatal("expected jumpMaster.PersistEnabled() == true") + } + if targetMaster.PersistEnabled() { + t.Fatal("expected targetMaster.PersistEnabled() == false") + } + + // Verify persistentJumpHops used by handoffConnectorMasters: non-persistent + // targetMaster must NOT mark "bastion" as a suppressed jump hop. + jumps := persistentJumpHops(connector.Masters()) + if jumps["bastion"] { + t.Fatal("expected bastion NOT to be suppressed in jumps when targetMaster is non-persistent") + } +} + +// TestMuxDaemonKeepsJumpMasterReferenceUntilTargetExits verifies R4-2: +// 1. prepareMuxDaemonSession releases only the terminal targetMaster while +// keeping the foreground session reference on upstream jumpMaster so a +// shorter jump ControlPersist cannot tear down targetMaster's tunnel. +// 2. waitForDaemonJumpMasters releases and waits for jumpMaster after +// targetMaster exits. +// 3. canHandoffMaster returns false for ImplicitTier4 or ControlMasterAsk, so +// persistentJumpHops does not suppress a persistent jump hop when the target +// master cannot be handed off. +func TestMuxDaemonKeepsJumpMasterReferenceUntilTargetExits(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + fix, err := sshfixture.New(ctx) + if err != nil { + t.Fatalf("sshfixture.New failed: %v", err) + } + defer func() { _ = fix.Close() }() + + host, portStr, _ := net.SplitHostPort(fix.Address) + port, _ := strconv.Atoi(portStr) + jumpSock := filepath.Join(t.TempDir(), "jump-daemon.sock") + targetSock := filepath.Join(t.TempDir(), "target-daemon.sock") + + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + } + cfg.Hosts.Set("h-jump", models.Host{Address: host, Port: uint16(port)}) + cfg.Hosts.Set("h-target", models.Host{Address: host, Port: uint16(port)}) + cfg.Identities.Set("id1", models.Identity{User: "fixture", AuthType: "password", Password: sshfixture.Password}) + cfg.Nodes.Set("bastion", models.Node{ + HostRef: "h-jump", + IdentityRef: "id1", + Control: &ssh.ControlConfig{ + Master: ssh.ControlMasterAuto, + Path: jumpSock, + Persist: "1s", + }, + }) + cfg.Nodes.Set("app", models.Node{ + HostRef: "h-target", + IdentityRef: "id1", + ProxyJump: "bastion", + Control: &ssh.ControlConfig{ + Master: ssh.ControlMasterAutoAsk, + Path: targetSock, + Persist: "5m", + }, + }) + + provider := config.NewProviderWithoutOpenSSH(cfg) + adp := adapter.NewSSHAdapter(provider) + connector := ssh.NewConnector(adp, ssh.WithHostKeyVerifier(staticHostKeyTrust{})) + connector.AcceptNewHostKey.Store(true) + defer func() { _ = connector.CloseAll() }() + + client, err := connector.Connect(ctx, "app") + if err != nil { + t.Fatalf("Connect(app) via bastion failed: %v", err) + } + defer func() { _ = client.Close() }() + + jumpMaster := ssh.GetActiveMuxServer(jumpSock) + targetMaster := ssh.GetActiveMuxServer(targetSock) + if jumpMaster == nil || targetMaster == nil { + t.Fatalf("expected both jumpMaster (%v) and targetMaster (%v) to be active", jumpMaster, targetMaster) + } + + // Because targetMaster has ControlMasterAutoAsk, canHandoffMaster(targetMaster) + // must be false and persistentJumpHops must not suppress "bastion". + if canHandoffMaster(targetMaster) { + t.Fatal("expected canHandoffMaster(targetMaster) == false for ControlMasterAutoAsk") + } + if !canHandoffMaster(jumpMaster) { + t.Fatal("expected canHandoffMaster(jumpMaster) == true") + } + if jumps := persistentJumpHops(connector.Masters()); jumps["bastion"] { + t.Fatal("expected bastion NOT to be suppressed when targetMaster has ControlMasterAutoAsk") + } + + if err := prepareMuxDaemonSession(connector, targetMaster, "app"); err != nil { + t.Fatalf("prepareMuxDaemonSession failed: %v", err) + } + if targetMaster.ActiveSessions() != 0 { + t.Fatalf("expected targetMaster.ActiveSessions() == 0 after prepareMuxDaemonSession, got %d", targetMaster.ActiveSessions()) + } + if jumpMaster.ActiveSessions() != 1 { + t.Fatalf("expected jumpMaster.ActiveSessions() == 1 while targetMaster is alive, got %d", jumpMaster.ActiveSessions()) + } + + // Once targetMaster exits and waitForDaemonJumpMasters runs, jumpMaster + // releases its foreground reference (ActiveSessions drops to 0) and exits + // after its 1s persist timer. + _ = targetMaster.Shutdown() + _ = client.Close() + waitForDaemonJumpMasters(ctx, connector, targetMaster) + if jumpMaster.ActiveSessions() != 0 { + t.Fatalf("expected jumpMaster.ActiveSessions() == 0 after waitForDaemonJumpMasters, got %d", jumpMaster.ActiveSessions()) + } + select { + case <-jumpMaster.Wait(): + default: + t.Fatal("expected jumpMaster to have exited after waitForDaemonJumpMasters") + } +} + +// TestPrepareNodeContextDisambiguatesSelectorAndOpenSSHVirtualNode verifies R4-3: +// 1. When two configured nodes share the same (user, address, port), explicitly +// specifying -p / -l alongside a node ID or alias selector narrows matches +// to the named node instead of returning AmbiguousNodeError. +// 2. When an OpenSSH virtual node matches the explicit user and port, +// PrepareNodeContext returns "openssh:" in-memory without persisting. +func TestPrepareNodeContextDisambiguatesSelectorAndOpenSSHVirtualNode(t *testing.T) { + ctx := context.Background() + content := "Host demo-alias\n HostName 10.0.0.50\n User demo\n Port 2222\n" + parser, err := config.NewOpenSSHParserFromReader(strings.NewReader(content)) + if err != nil { + t.Fatalf("NewOpenSSHParserFromReader failed: %v", err) + } + + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + } + cfg.Hosts.Set("h1", models.Host{Address: "10.0.0.10", Port: 22}) + cfg.Identities.Set("id1", models.Identity{User: "root", AuthType: "password"}) + cfg.Nodes.Set("node-a", models.Node{HostRef: "h1", IdentityRef: "id1", Alias: []string{"alias-a"}}) + cfg.Nodes.Set("node-b", models.Node{HostRef: "h1", IdentityRef: "id1", Alias: []string{"alias-b"}}) + + store := config.NewDefaultStore(filepath.Join(t.TempDir(), "config.yaml"), filepath.Join(t.TempDir(), "key")) + repo, err := config.NewRepositoryWithOpenSSHParser(cfg, store, parser) + if err != nil { + t.Fatalf("NewRepositoryWithOpenSSHParser failed: %v", err) + } + + // 1a. Selector is exact node ID "node-a" with explicit -p 22 -l root. + res, err := repo.PrepareNodeContext(ctx, config.EnsureNodeOptions{ + Target: config.ConnectionTarget{ + Selector: "node-a", + User: "root", + HasUser: true, + Port: 22, + HasPort: true, + }, + }) + if err != nil { + t.Fatalf("PrepareNodeContext(node-a, root, 22) failed: %v", err) + } + if res.NodeID != "node-a" || res.Created { + t.Fatalf("expected existing node-a, got NodeID=%q Created=%v", res.NodeID, res.Created) + } + + // 1b. Selector is exact alias "alias-b" with explicit -p 22. + resAlias, err := repo.PrepareNodeContext(ctx, config.EnsureNodeOptions{ + Target: config.ConnectionTarget{ + Selector: "alias-b", + Port: 22, + HasPort: true, + }, + }) + if err != nil { + t.Fatalf("PrepareNodeContext(alias-b, 22) failed: %v", err) + } + if resAlias.NodeID != "node-b" || resAlias.Created { + t.Fatalf("expected existing node-b, got NodeID=%q Created=%v", resAlias.NodeID, resAlias.Created) + } + + // 2. OpenSSH virtual node with matching explicit user and port resolves to + // "openssh:demo-alias" without creating a pending/persisted node. + resOpenSSH, err := repo.PrepareNodeContext(ctx, config.EnsureNodeOptions{ + Target: config.ConnectionTarget{ + Selector: "demo-alias", + User: "demo", + HasUser: true, + Port: 2222, + HasPort: true, + }, + }) + if err != nil { + t.Fatalf("PrepareNodeContext(demo-alias, demo, 2222) failed: %v", err) + } + if resOpenSSH.NodeID != "openssh:demo-alias" || resOpenSSH.Created { + t.Fatalf("expected existing openssh:demo-alias, got NodeID=%q Created=%v", resOpenSSH.NodeID, resOpenSSH.Created) + } +} + +// TestControlTargetNormalizesProxyJump verifies R5-1: +// controlTarget must normalize explicit -J / ProxyJump overrides via +// provider.ResolveProxyJumpChain so ControlPath token expansion (%j, %C) and +// -O proxy target matching agree with Connector.buildTargetContext. +func TestControlTargetNormalizesProxyJump(t *testing.T) { + content := "Host demo-target\n HostName 10.0.0.20\n User demo\n Port 2200\nHost bastion\n HostName 10.0.0.1\n User jump\n Port 2201\n" + parser, err := config.NewOpenSSHParserFromReader(strings.NewReader(content)) + if err != nil { + t.Fatalf("NewOpenSSHParserFromReader failed: %v", err) + } + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + } + provider := config.NewProviderWithOpenSSHParser(cfg, parser) + + opts := NewSshOptions() + opts.Host = "demo-target" + opts.JumpHost = "bastion" + opts.Target = config.ConnectionTarget{ + Selector: "demo-target", + ProxyJump: "bastion", + HasProxyJump: true, + } + + target := controlTarget(opts, provider, "openssh:demo-target") + if target.JumpHost != "openssh:bastion" { + t.Fatalf("expected normalized JumpHost %q, got %q", "openssh:bastion", target.JumpHost) + } + masterTarget := ssh.TargetContext{ + Host: "10.0.0.20", + Port: 2200, + User: "demo", + OriginalHost: "openssh:demo-target", + JumpHost: "openssh:bastion", + } + if !target.MatchesMasterTarget(masterTarget.CanonicalTargetIdentity()) { + t.Fatalf("expected controlTarget to match master target with normalized JumpHost") + } +} + +// TestScpResolvedTargetNodeIDsIncludesTagAndHostFile verifies R5-2: +// ScpOptions.resolvedTargetNodeIDs must resolve nodes from --tag (o.Tag) and +// -I (o.HostFile) so batch SCP does not fall back to global session auth/control. +func TestScpResolvedTargetNodeIDsIncludesTagAndHostFile(t *testing.T) { + content := "Host file-alias\n HostName 10.0.0.30\n User fileuser\n Port 2202\n" + parser, err := config.NewOpenSSHParserFromReader(strings.NewReader(content)) + if err != nil { + t.Fatalf("NewOpenSSHParserFromReader failed: %v", err) + } + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + } + cfg.Hosts.Set("h1", models.Host{Address: "10.0.0.40", Port: 22}) + cfg.Identities.Set("id1", models.Identity{User: "ops", AuthType: "password"}) + cfg.Nodes.Set("tagged-node", models.Node{HostRef: "h1", IdentityRef: "id1", Tags: []string{"prod"}}) + + store := config.NewDefaultStore(filepath.Join(t.TempDir(), "config.yaml"), filepath.Join(t.TempDir(), "key")) + repo, err := config.NewRepositoryWithOpenSSHParser(cfg, store, parser) + if err != nil { + t.Fatalf("NewRepositoryWithOpenSSHParser failed: %v", err) + } + + // 1. --tag mode + tagOpts := NewScpOptions() + tagOpts.Tag = "prod" + tagOpts.Source = "/tmp/local.txt" + tagOpts.Dest = "/tmp/remote.txt" + tagIDs := tagOpts.resolvedTargetNodeIDs(repo) + if !slices.Contains(tagIDs, "tagged-node") { + t.Fatalf("expected resolvedTargetNodeIDs for --tag prod to contain %q, got %v", "tagged-node", tagIDs) + } + + // 2. -I (HostFile) mode + hostFile := filepath.Join(t.TempDir(), "hosts.txt") + if err := os.WriteFile(hostFile, []byte("file-alias\n"), 0600); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + fileOpts := NewScpOptions() + fileOpts.HostFile = hostFile + fileOpts.Source = "/tmp/local.txt" + fileOpts.Dest = "/tmp/remote.txt" + fileIDs := fileOpts.resolvedTargetNodeIDs(repo) + if !slices.Contains(fileIDs, "openssh:file-alias") { + t.Fatalf("expected resolvedTargetNodeIDs for -I hosts.txt to contain %q, got %v", "openssh:file-alias", fileIDs) + } +} + +// TestResolveExplicitOverrideNodeFindsCustomNamedNode verifies R5-3: +// When a control command specifies -l / -p against a selector that resolves to +// a base node, resolveExplicitOverrideNode matches a custom-named node sharing +// the overridden (user, address, port) tuple. +func TestResolveExplicitOverrideNodeFindsCustomNamedNode(t *testing.T) { + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + } + cfg.Hosts.Set("h-default", models.Host{Address: "10.0.0.5", Port: 22}) + cfg.Hosts.Set("h-ops", models.Host{Address: "10.0.0.5", Port: 2222}) + cfg.Identities.Set("id-root", models.Identity{User: "root", AuthType: "password"}) + cfg.Identities.Set("id-ops", models.Identity{User: "ops", AuthType: "password"}) + cfg.Nodes.Set("base-node", models.Node{HostRef: "h-default", IdentityRef: "id-root", Alias: []string{"web"}}) + cfg.Nodes.Set("custom-ops-node", models.Node{HostRef: "h-ops", IdentityRef: "id-ops"}) + + provider := config.NewProviderWithoutOpenSSH(cfg) + nodeID, err := resolveControlNodeID(provider, config.ConnectionTarget{ + Selector: "web", + User: "ops", + HasUser: true, + Port: 2222, + HasPort: true, + }) + if err != nil { + t.Fatalf("resolveControlNodeID failed: %v", err) + } + if nodeID != "custom-ops-node" { + t.Fatalf("expected custom-ops-node, got %q", nodeID) + } +} + +// TestDerivedNodeFromOpenSSHAliasInheritsTier4Control verifies R6-3: +// When a user overrides -l or -p on a concrete OpenSSH Host alias (e.g. demo-alias), +// the derived node (ops@10.0.0.20:2200) still inherits Tier 4 ControlConfig from +// Host demo-alias and expands the exact same ControlPath as `xops ssh -O check -l ops demo-alias`. +func TestDerivedNodeFromOpenSSHAliasInheritsTier4Control(t *testing.T) { + content := "Host demo-alias\n HostName 10.0.0.20\n User demo\n Port 2200\n ControlMaster auto\n ControlPath /tmp/cm-%n-%r@%h:%p\n ControlPersist 60s\n" + parser, err := config.NewOpenSSHParserFromReader(strings.NewReader(content)) + if err != nil { + t.Fatalf("NewOpenSSHParserFromReader failed: %v", err) + } + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + } + store := config.NewDefaultStore(filepath.Join(t.TempDir(), "config.yaml"), filepath.Join(t.TempDir(), "key")) + repo, err := config.NewRepositoryWithOpenSSHParser(cfg, store, parser) + if err != nil { + t.Fatalf("NewRepositoryWithOpenSSHParser failed: %v", err) + } + + target := config.ConnectionTarget{ + Selector: "demo-alias", + User: "ops", + HasUser: true, + } + res, err := repo.PrepareNodeContext(context.Background(), config.EnsureNodeOptions{Target: target}) + if err != nil { + t.Fatalf("PrepareNodeContext failed: %v", err) + } + if res.NodeID != "ops@10.0.0.20:2200" { + t.Fatalf("expected derived node ops@10.0.0.20:2200, got %q", res.NodeID) + } + + adp := adapter.NewSSHAdapter(repo) + ctrl, err := adp.ResolveControl(res.NodeID, adapter.SessionControlOptions{}) + if err != nil { + t.Fatalf("ResolveControl(%q) failed: %v", res.NodeID, err) + } + if ctrl == nil || ctrl.Master != ssh.ControlMasterAuto || ctrl.Path != "/tmp/cm-%n-%r@%h:%p" || ctrl.Persist != "60s" { + t.Fatalf("expected Tier 4 ControlConfig from demo-alias, got %+v", ctrl) + } + + sshOpts := NewSshOptions() + sshOpts.Host = "demo-alias" + sshOpts.User = "ops" + sshOpts.Target = target + connectSock, err := controlSocketPath(sshOpts, repo, res.NodeID) + if err != nil { + t.Fatalf("controlSocketPath(connect) failed: %v", err) + } + + // Simulate read-only -O check before derived node is persisted to disk. + readOnlyProvider := config.NewProviderWithOpenSSHParser(cfg, parser) + ctlNodeID, err := resolveControlNodeID(readOnlyProvider, target) + if err != nil { + t.Fatalf("resolveControlNodeID failed: %v", err) + } + ctlSock, err := controlSocketPath(sshOpts, readOnlyProvider, ctlNodeID) + if err != nil { + t.Fatalf("controlSocketPath(-O) failed: %v", err) + } + if connectSock != ctlSock { + t.Fatalf("expected connect socket %q to equal -O socket %q", connectSock, ctlSock) + } +} + +// TestControlCandidateFallbackAndPortOverrideJumpAlignment verifies R7-2 and R7-3: +// 1. Multi-candidate nodes with inconsistent ports return AmbiguousNodeError on -O -l, +// while multi-candidate nodes with consistent (Address, Port) inherit port and ProxyJump. +// 2. Explicit port override on a local node clears ProxyJump when no node on the new port shares it. +// 3. FindConcreteHostsByAddress does not match a concrete Host block with a different port, +// and derived nodes track their originating OpenSSHAlias when multiple aliases share HostName/Port. +func TestControlCandidateFallbackAndPortOverrideJumpAlignment(t *testing.T) { + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + } + cfg.Hosts.Set("h-22", models.Host{Address: "10.0.0.5", Port: 22}) + cfg.Hosts.Set("h-2222", models.Host{Address: "10.0.0.5", Port: 2222}) + cfg.Identities.Set("id-root", models.Identity{User: "root", AuthType: "password"}) + cfg.Identities.Set("id-admin", models.Identity{User: "admin", AuthType: "password"}) + + // Case 1a: Same IP, different ports -> -l test without -p must return AmbiguousNodeError. + cfg.Nodes.Set("root@10.0.0.5:22", models.Node{HostRef: "h-22", IdentityRef: "id-root"}) + cfg.Nodes.Set("root@10.0.0.5:2222", models.Node{HostRef: "h-2222", IdentityRef: "id-root"}) + providerInconsistent := config.NewProviderWithoutOpenSSH(cfg) + if _, err := resolveControlNodeID(providerInconsistent, config.ConnectionTarget{ + Selector: "10.0.0.5", + User: "test", + HasUser: true, + }); err == nil { + t.Fatal("expected AmbiguousNodeError when candidates have different ports and -p is not specified") + } + + // Case 1b: Same IP, same port (2222), different users -> -l test inherits port 2222 and ProxyJump. + cfg.Nodes.Remove("root@10.0.0.5:22") + cfg.Nodes.Set("root@10.0.0.5:2222", models.Node{HostRef: "h-2222", IdentityRef: "id-root", ProxyJump: "upstream"}) + cfg.Nodes.Set("admin@10.0.0.5:2222", models.Node{HostRef: "h-2222", IdentityRef: "id-admin", ProxyJump: "upstream"}) + providerConsistent := config.NewProviderWithoutOpenSSH(cfg) + targetSamePort := config.ConnectionTarget{ + Selector: "10.0.0.5", + User: "test", + HasUser: true, + } + nodeID, err := resolveControlNodeID(providerConsistent, targetSamePort) + if err != nil { + t.Fatalf("resolveControlNodeID for consistent port candidates failed: %v", err) + } + optsSamePort := NewSshOptions() + optsSamePort.Host = "10.0.0.5" + optsSamePort.User = "test" + optsSamePort.Target = targetSamePort + ctxSamePort := controlTarget(optsSamePort, providerConsistent, nodeID) + if ctxSamePort.Port != 2222 || ctxSamePort.JumpHost != "upstream" || ctxSamePort.OriginalHost != "test@10.0.0.5:2222" { + t.Fatalf("unexpected controlTarget for consistent candidates: %+v", ctxSamePort) + } + + // Case 2: Local node with explicit port override (-p 2223) does not inherit port 2222's ProxyJump. + targetDiffPort := config.ConnectionTarget{ + Selector: "10.0.0.5", + User: "test", + HasUser: true, + Port: 2223, + HasPort: true, + } + nodeIDDiffPort, err := resolveControlNodeID(providerConsistent, targetDiffPort) + if err != nil { + t.Fatalf("resolveControlNodeID for explicit port override failed: %v", err) + } + optsDiffPort := NewSshOptions() + optsDiffPort.Host = "10.0.0.5" + optsDiffPort.User = "test" + optsDiffPort.Port = 2223 + optsDiffPort.Target = targetDiffPort + ctxDiffPort := controlTarget(optsDiffPort, providerConsistent, nodeIDDiffPort) + if ctxDiffPort.JumpHost != "" { + t.Fatalf("expected empty JumpHost when local node port is overridden to 2223, got %q", ctxDiffPort.JumpHost) + } + + // Case 3 (R7-3): Two concrete aliases sharing HostName/Port and another alias on a different port. + sshCfgText := strings.Join([]string{ + "Host alias-a", + " HostName 10.0.0.30", + " Port 2200", + " ControlMaster auto", + " ControlPath /tmp/cm-a-%C", + " ControlPersist 10s", + "Host alias-b", + " HostName 10.0.0.30", + " Port 2200", + " ControlMaster auto", + " ControlPath /tmp/cm-b-%C", + " ControlPersist 20s", + "Host alias-2222", + " HostName 10.0.0.40", + " Port 2222", + " ControlMaster auto", + " ControlPath /tmp/cm-2222-%C", + "Host *", + " ControlMaster auto", + " ControlPath /tmp/cm-star-%C", + }, "\n") + parser, err := config.NewOpenSSHParserFromReader(strings.NewReader(sshCfgText)) + if err != nil { + t.Fatalf("NewOpenSSHParserFromReader failed: %v", err) + } + cfgR73 := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + } + cfgR73.Hosts.Set("h-40-22", models.Host{Address: "10.0.0.40", Port: 22}) + cfgR73.Identities.Set("id-root", models.Identity{User: "root", AuthType: "password"}) + cfgR73.Nodes.Set("root@10.0.0.40:22", models.Node{HostRef: "h-40-22", IdentityRef: "id-root"}) + + store := config.NewDefaultStore(filepath.Join(t.TempDir(), "config.yaml"), filepath.Join(t.TempDir(), "key")) + repo, err := config.NewRepositoryWithOpenSSHParser(cfgR73, store, parser) + if err != nil { + t.Fatalf("NewRepositoryWithOpenSSHParser failed: %v", err) + } + adp := adapter.NewSSHAdapter(repo) + + // Node on 10.0.0.40:22 must fall back to Host *, not Host alias-2222 (Port 2222). + ctrl40, err := adp.ResolveControl("root@10.0.0.40:22", adapter.SessionControlOptions{}) + if err != nil { + t.Fatalf("ResolveControl(root@10.0.0.40:22) failed: %v", err) + } + if ctrl40 == nil || ctrl40.Path != "/tmp/cm-star-%C" { + t.Fatalf("expected Host * ControlPath /tmp/cm-star-%%C for port 22 node, got %+v", ctrl40) + } + + // Derived node from alias-b must resolve alias-b's ControlPath (/tmp/cm-b-%C), not alias-a's. + resB, err := repo.PrepareNodeContext(context.Background(), config.EnsureNodeOptions{ + Target: config.ConnectionTarget{Selector: "alias-b", User: "ops", HasUser: true}, + }) + if err != nil { + t.Fatalf("PrepareNodeContext(alias-b) failed: %v", err) + } + ctrlB, err := adp.ResolveControl(resB.NodeID, adapter.SessionControlOptions{}) + if err != nil { + t.Fatalf("ResolveControl(%q) failed: %v", resB.NodeID, err) + } + if ctrlB == nil || ctrlB.Path != "/tmp/cm-b-%C" || ctrlB.Persist != "20s" { + t.Fatalf("expected alias-b ControlConfig (/tmp/cm-b-%%C, 20s), got %+v", ctrlB) + } +} + +// TestAdHocOverrideWithWildcardOpenSSHAndSameIPJumpIdentityIsolation verifies R8-1 and R8-2: +// 1. When ~/.ssh/config has a wildcard `Host *` (e.g. Port 2200) and the user connects to +// an ad-hoc IP (not a concrete Host block) with `-l ops`, both PrepareNodeContext, +// resolveControlNodeID + controlTarget, and ScpOptions.resolvedTargetNodeIDs agree on +// `ops@:22` instead of pulling `Port 2200` from `GetVirtualNode("openssh:")`. +// 2. When a jump master and target master share the same IP on different ports (e.g. +// 127.0.0.1:2201 and 127.0.0.1:2200), shouldPassIdentityFile strictly matches node IDs +// and port/user so the target keeps `-i` while the jump master strips it. +func TestAdHocOverrideWithWildcardOpenSSHAndSameIPJumpIdentityIsolation(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + sshCfgText := strings.Join([]string{ + "Host *", + " Port 2200", + " ControlMaster auto", + " ControlPath /tmp/cm-adhoc-%n-%C", + }, "\n") + parser, err := config.NewOpenSSHParserFromReader(strings.NewReader(sshCfgText)) + if err != nil { + t.Fatalf("NewOpenSSHParserFromReader failed: %v", err) + } + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + } + readOnlyProvider := config.NewProviderWithOpenSSHParser(cfg, parser) + + store := config.NewDefaultStore(filepath.Join(t.TempDir(), "config.yaml"), filepath.Join(t.TempDir(), "key")) + repo, err := config.NewRepositoryWithOpenSSHParser(cfg, store, parser) + if err != nil { + t.Fatalf("NewRepositoryWithOpenSSHParser failed: %v", err) + } + + target := config.ConnectionTarget{ + Selector: "10.0.0.88", + User: "ops", + HasUser: true, + } + res, err := repo.PrepareNodeContext(ctx, config.EnsureNodeOptions{Target: target}) + if err != nil { + t.Fatalf("PrepareNodeContext failed: %v", err) + } + if res.NodeID != "ops@10.0.0.88:22" { + t.Fatalf("expected PrepareNodeContext nodeID ops@10.0.0.88:22, got %q", res.NodeID) + } + + sshOpts := NewSshOptions() + sshOpts.Host = "10.0.0.88" + sshOpts.User = "ops" + sshOpts.Target = target + connectSock, err := controlSocketPath(sshOpts, repo, res.NodeID) + if err != nil { + t.Fatalf("controlSocketPath(connect) failed: %v", err) + } + + ctlNodeID, err := resolveControlNodeID(readOnlyProvider, target) + if err != nil { + t.Fatalf("resolveControlNodeID failed: %v", err) + } + ctlSock, err := controlSocketPath(sshOpts, readOnlyProvider, ctlNodeID) + if err != nil { + t.Fatalf("controlSocketPath(-O) failed: %v", err) + } + if connectSock != ctlSock { + t.Fatalf("expected connect socket %q to equal -O socket %q", connectSock, ctlSock) + } + + scpOpts := NewScpOptions() + scpOpts.User = "ops" + scpOpts.Source = "10.0.0.88:/tmp/a" + scpOpts.Dest = "/tmp/b" + scpIDs := scpOpts.resolvedTargetNodeIDs(repo) + if !slices.Contains(scpIDs, "ops@10.0.0.88:22") { + t.Fatalf("expected scp resolvedTargetNodeIDs to contain ops@10.0.0.88:22, got %v", scpIDs) + } + + // Verify R8-2: same IP (127.0.0.1), different ports for jump (2201) and target (2200). + fix, err := sshfixture.New(ctx) + if err != nil { + t.Fatalf("sshfixture.New failed: %v", err) + } + defer func() { _ = fix.Close() }() + host, portStr, _ := net.SplitHostPort(fix.Address) + port, _ := strconv.Atoi(portStr) + + rawClient, err := cryptoSSH.Dial("tcp", fix.Address, &cryptoSSH.ClientConfig{ + User: "fixture", + Auth: []cryptoSSH.AuthMethod{cryptoSSH.Password(sshfixture.Password)}, + HostKeyCallback: cryptoSSH.InsecureIgnoreHostKey(), + }) + if err != nil { + t.Fatalf("cryptoSSH.Dial failed: %v", err) + } + defer func() { _ = rawClient.Close() }() + + tmpDir := t.TempDir() + jumpMaster, err := ssh.StartMuxServer(ctx, rawClient, ssh.MuxMasterOptions{ + Path: filepath.Join(tmpDir, "jump.sock"), + Target: ssh.TargetContext{ + Host: host, + Port: port + 1, + User: "jump", + OriginalHost: "jump@" + host + ":" + strconv.Itoa(port+1), + Control: &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: filepath.Join(tmpDir, "jump.sock"), Persist: "5m"}, + }, + }) + if err != nil { + t.Fatalf("StartMuxServer(jump) failed: %v", err) + } + defer func() { _ = jumpMaster.Shutdown() }() + + targetMaster, err := ssh.StartMuxServer(ctx, rawClient, ssh.MuxMasterOptions{ + Path: filepath.Join(tmpDir, "target.sock"), + Target: ssh.TargetContext{ + Host: host, + Port: port, + User: "root", + OriginalHost: "root@" + host + ":" + strconv.Itoa(port), + JumpHost: "jump@" + host + ":" + strconv.Itoa(port+1), + Control: &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: filepath.Join(tmpDir, "target.sock"), Persist: "5m"}, + }, + }) + if err != nil { + t.Fatalf("StartMuxServer(target) failed: %v", err) + } + defer func() { _ = targetMaster.Shutdown() }() + + sameIPOpts := NewSshOptions() + sameIPOpts.Host = host + sameIPOpts.Port = uint16(port) + sameIPOpts.User = "root" + sameIPOpts.IdentityFile = "/tmp/target-key" + sameIPOpts.targetNodeIDs = []string{"root@" + host + ":" + strconv.Itoa(port)} + + jumpArgs := strings.Join(sameIPOpts.muxDaemonArgs(jumpMaster.Target().OriginalHost, jumpMaster.ControlPath(), jumpMaster), " ") + if strings.Contains(jumpArgs, "-i ") || strings.Contains(jumpArgs, "target-key") { + t.Fatalf("expected jumpMaster on same IP different port to strip -i, got %q", jumpArgs) + } + targetArgs := strings.Join(sameIPOpts.muxDaemonArgs(targetMaster.Target().OriginalHost, targetMaster.ControlPath(), targetMaster), " ") + if !strings.Contains(targetArgs, "-i /tmp/target-key") { + t.Fatalf("expected targetMaster on same IP to keep -i /tmp/target-key, got %q", targetArgs) + } +} + +// TestMuxDaemonKeyPathAndScpIsolationAndHostFlagAndLookupOpenSSHAlias verifies R9-1 through R9-4: +// - R9-1: muxDaemonArgs forwards master.Target().KeyPath when o.IdentityFile is empty or when +// a jump master has its own configured KeyPath distinct from the target's CLI -i. +// - R9-2: ScpOptions.resolvedTargetNodeIDs excludes the un-overridden base node when user/port +// is overridden (preventing ProxyJump self-loops) and ignores colons in o.Source/o.Dest when +// o.Host, o.Tag, or o.HostFile is used. +// - R9-3: SshOptions.parseTarget parses --host user@host:port and --host [::1], and +// controlTarget / resolveControlNodeID handles bracketed IPv6 and custom-ID nodes. +// - R9-4: PrepareNodeContext preserves OpenSSHAlias when resolving via lookup[selector], and +// controlTarget inherits ProxyJump from a node at (host, port) even when base is openssh:. +func TestMuxDaemonKeyPathAndScpIsolationAndHostFlagAndLookupOpenSSHAlias(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + // 1. Verify R9-1: master.Target().KeyPath is passed to muxDaemonArgs. + fix, err := sshfixture.New(ctx) + if err != nil { + t.Fatalf("sshfixture.New failed: %v", err) + } + defer func() { _ = fix.Close() }() + host, portStr, _ := net.SplitHostPort(fix.Address) + port, _ := strconv.Atoi(portStr) + + rawClient, err := cryptoSSH.Dial("tcp", fix.Address, &cryptoSSH.ClientConfig{ + User: "fixture", + Auth: []cryptoSSH.AuthMethod{cryptoSSH.Password(sshfixture.Password)}, + HostKeyCallback: cryptoSSH.InsecureIgnoreHostKey(), + }) + if err != nil { + t.Fatalf("cryptoSSH.Dial failed: %v", err) + } + defer func() { _ = rawClient.Close() }() + + tmpDir := t.TempDir() + derivedMaster, err := ssh.StartMuxServer(ctx, rawClient, ssh.MuxMasterOptions{ + Path: filepath.Join(tmpDir, "derived.sock"), + Target: ssh.TargetContext{ + Host: host, + Port: port, + User: "ops", + KeyPath: "/tmp/ssh-config-derived-key", + OriginalHost: "ops@" + host + ":" + strconv.Itoa(port), + Control: &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: filepath.Join(tmpDir, "derived.sock"), Persist: "5m"}, + }, + }) + if err != nil { + t.Fatalf("StartMuxServer(derived) failed: %v", err) + } + defer func() { _ = derivedMaster.Shutdown() }() + + // Even when o.IdentityFile is empty, muxDaemonArgs passes -i master.Target().KeyPath. + noFlagOpts := NewSshOptions() + derivedArgs := strings.Join(noFlagOpts.muxDaemonArgs(derivedMaster.Target().OriginalHost, derivedMaster.ControlPath(), derivedMaster), " ") + if !strings.Contains(derivedArgs, "-i /tmp/ssh-config-derived-key") { + t.Fatalf("expected muxDaemonArgs to include -i /tmp/ssh-config-derived-key from master.Target().KeyPath, got %q", derivedArgs) + } + + // Jump master with its own KeyPath keeps its own KeyPath instead of inheriting target's CLI -i. + jumpWithKey, err := ssh.StartMuxServer(ctx, rawClient, ssh.MuxMasterOptions{ + Path: filepath.Join(tmpDir, "jump-key.sock"), + Target: ssh.TargetContext{ + Host: host, + Port: port + 1, + User: "jump", + KeyPath: "/tmp/jump-own-key", + OriginalHost: "jump@" + host + ":" + strconv.Itoa(port+1), + Control: &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: filepath.Join(tmpDir, "jump-key.sock"), Persist: "5m"}, + }, + }) + if err != nil { + t.Fatalf("StartMuxServer(jumpWithKey) failed: %v", err) + } + defer func() { _ = jumpWithKey.Shutdown() }() + + withTargetFlagOpts := NewSshOptions() + withTargetFlagOpts.IdentityFile = "/tmp/target-cli-key" + withTargetFlagOpts.targetNodeIDs = []string{"ops@" + host + ":" + strconv.Itoa(port)} + jumpWithKeyArgs := strings.Join(withTargetFlagOpts.muxDaemonArgs(jumpWithKey.Target().OriginalHost, jumpWithKey.ControlPath(), jumpWithKey), " ") + if !strings.Contains(jumpWithKeyArgs, "-i /tmp/jump-own-key") || strings.Contains(jumpWithKeyArgs, "target-cli-key") { + t.Fatalf("expected jumpWithKey muxDaemonArgs to use -i /tmp/jump-own-key and strip target-cli-key, got %q", jumpWithKeyArgs) + } + + // R10-4: when jump master's own configured KeyPath equals target's CLI -i, jump master keeps -i. + withSharedKeyOpts := NewSshOptions() + withSharedKeyOpts.IdentityFile = "/tmp/jump-own-key" + withSharedKeyOpts.targetNodeIDs = []string{"ops@" + host + ":" + strconv.Itoa(port)} + if gotKey := withSharedKeyOpts.resolveMuxDaemonIdentityFile(jumpWithKey.Target().OriginalHost, jumpWithKey); gotKey != "/tmp/jump-own-key" { + t.Fatalf("expected jump master to keep its configured KeyPath when equal to target -i, got %q", gotKey) + } + + // 2. Verify R9-2, R9-3, and R9-4. + verifyScpIsolationAndHostFlagAndLookupAlias(t, ctx) +} + +func verifyScpIsolationAndHostFlagAndLookupAlias(t *testing.T, ctx context.Context) { + t.Helper() + sshCfgText := strings.Join([]string{ + "Host gw", + " HostName 10.0.0.10", + " User root", + " Port 22", + " ControlMaster auto", + " ControlPath /tmp/cm-gw-%C", + " ControlPersist 30s", + }, "\n") + parser, err := config.NewOpenSSHParserFromReader(strings.NewReader(sshCfgText)) + if err != nil { + t.Fatalf("NewOpenSSHParserFromReader failed: %v", err) + } + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + } + cfg.Hosts.Set("h-gw-22", models.Host{Address: "10.0.0.10", Port: 22}) + cfg.Hosts.Set("h-gw-2222", models.Host{Address: "10.0.0.10", Port: 2222}) + cfg.Identities.Set("id-root", models.Identity{User: "root", AuthType: "password"}) + // Persisted node derived earlier from Host gw has OpenSSHAlias: "gw". + cfg.Nodes.Set("root@10.0.0.10:22", models.Node{ + HostRef: "h-gw-22", + IdentityRef: "id-root", + OpenSSHAlias: "gw", + }) + // Container node on 10.0.0.10:2222 jumps through gw. + cfg.Nodes.Set("container-node", models.Node{ + HostRef: "h-gw-2222", + IdentityRef: "id-root", + ProxyJump: "openssh:gw", + }) + + store := config.NewDefaultStore(filepath.Join(t.TempDir(), "config.yaml"), filepath.Join(t.TempDir(), "key")) + repo, err := config.NewRepositoryWithOpenSSHParser(cfg, store, parser) + if err != nil { + t.Fatalf("NewRepositoryWithOpenSSHParser failed: %v", err) + } + + // R9-2: SCP to gw:/tmp/file with -P 2222 -J gw must only include root@10.0.0.10:2222, + // never base node openssh:gw or root@10.0.0.10:22, and -H mode ignores colons in local Source. + verifyScpIsolationAndHostFlag(t, repo) + + // R9-4a: PrepareNodeContext via lookup["10.0.0.10"] with -l ops -p 22 preserves OpenSSHAlias "gw". + resLookup, err := repo.PrepareNodeContext(ctx, config.EnsureNodeOptions{ + Target: config.ConnectionTarget{Selector: "10.0.0.10", User: "ops", HasUser: true, Port: 22, HasPort: true}, + }) + if err != nil { + t.Fatalf("PrepareNodeContext(10.0.0.10, -l ops -p 22) failed: %v", err) + } + nodeLookup, _, _, err := repo.Resolve(resLookup.NodeID) + if err != nil { + t.Fatalf("Resolve(%q) failed: %v", resLookup.NodeID, err) + } + if nodeLookup.OpenSSHAlias != "gw" { + t.Fatalf("expected derived node from 10.0.0.10 lookup to preserve OpenSSHAlias %q, got %q", "gw", nodeLookup.OpenSSHAlias) + } + + // R9-4b: controlTarget for `gw -p 2222` (where base node is openssh:gw) inherits + // ProxyJump "openssh:gw" from container-node at (10.0.0.10, 2222). + provider := config.NewProviderWithOpenSSHParser(cfg, parser) + targetPortOverride := config.ConnectionTarget{Selector: "gw", Port: 2222, HasPort: true} + ctlNodeID, err := resolveControlNodeID(provider, targetPortOverride) + if err != nil { + t.Fatalf("resolveControlNodeID(gw -p 2222) failed: %v", err) + } + if ctlNodeID != "container-node" { + t.Fatalf("expected resolveControlNodeID(gw -p 2222) to resolve custom-ID container-node, got %q", ctlNodeID) + } + ctlOpts := NewSshOptions() + ctlOpts.Host = "gw" + ctlOpts.Port = 2222 + ctlOpts.Target = targetPortOverride + ctlCtx := controlTarget(ctlOpts, provider, "openssh:gw") + if ctlCtx.JumpHost != "openssh:gw" { + t.Fatalf("expected controlTarget(openssh:gw, -p 2222) to inherit JumpHost %q from 10.0.0.10:2222, got %q", "openssh:gw", ctlCtx.JumpHost) + } +} + +func verifyScpIsolationAndHostFlag(t *testing.T, repo *config.Repository) { + t.Helper() + scpOpts := NewScpOptions() + scpOpts.Port = 2222 + scpOpts.JumpHost = "gw" + scpOpts.Source = "gw:/tmp/file" + scpOpts.Dest = "/tmp/out" + scpIDs := scpOpts.resolvedTargetNodeIDs(repo) + if !slices.Contains(scpIDs, "root@10.0.0.10:2222") { + t.Fatalf("expected resolvedTargetNodeIDs to contain root@10.0.0.10:2222, got %v", scpIDs) + } + if slices.Contains(scpIDs, "openssh:gw") || slices.Contains(scpIDs, "root@10.0.0.10:22") { + t.Fatalf("expected resolvedTargetNodeIDs to exclude un-overridden base node, got %v", scpIDs) + } + scpHostMode := NewScpOptions() + scpHostMode.Host = "gw" + scpHostMode.Port = 2222 + scpHostMode.User = "ops" + scpHostMode.Source = "./archive:2026.tar.gz" + scpHostMode.Dest = "/tmp/remote.tar.gz" + hostModeIDs := scpHostMode.resolvedTargetNodeIDs(repo) + if !slices.Contains(hostModeIDs, "ops@10.0.0.10:2222") { + t.Fatalf("expected -H mode to ignore colon in local Source and include ops@10.0.0.10:2222, got %v", hostModeIDs) + } + if slices.Contains(hostModeIDs, "openssh:gw") || slices.Contains(hostModeIDs, "root@10.0.0.10:22") { + t.Fatalf("expected -H mode to exclude base port-22 nodes, got %v", hostModeIDs) + } + + // R9-3: SshOptions.parseTarget parses --host user@host:port and --host [::1]. + flagOpts := NewSshOptions() + flagOpts.Host = "ops@10.0.0.10:2222" + if err := flagOpts.parseTarget(false); err != nil { + t.Fatalf("parseTarget(--host ops@10.0.0.10:2222) failed: %v", err) + } + if flagOpts.Host != "10.0.0.10" || flagOpts.User != "ops" || flagOpts.Port != 2222 { + t.Fatalf("unexpected parseTarget result: Host=%q User=%q Port=%d", flagOpts.Host, flagOpts.User, flagOpts.Port) + } + ipv6Opts := NewSshOptions() + ipv6Opts.Host = "[::1]" + if err := ipv6Opts.parseTarget(false); err != nil { + t.Fatalf("parseTarget(--host [::1]) failed: %v", err) + } + if ipv6Opts.Host != "::1" { + t.Fatalf("expected parseTarget(--host [::1]) to strip brackets to ::1, got %q", ipv6Opts.Host) + } +} + +func TestRound10TargetDisambiguationAndControlResolution(t *testing.T) { + verifyRound10RawSelectorAndOpenSSHStep4(t) + verifyRound10Tier2AndTier4ControlResolution(t) + verifyRound10ControlAmbiguityAndSharedJumpKey(t) +} + +func newEmptyTestConfig() *config.Configuration { + return config.NewProviderWithoutOpenSSH(nil).Snapshot() +} + +func verifyRound10RawSelectorAndOpenSSHStep4(t *testing.T) { + t.Helper() + sshCfgText := ` +Host demo-alias + HostName 10.0.0.10 + User demo + Port 2200 + +Host 10.0.0.10 + HostName 10.0.0.10 + User wronguser + Port 2200 + IdentityFile /tmp/wrong_ip_key + ProxyJump wrongjump +` + parser, err := config.NewOpenSSHParserFromReader(strings.NewReader(sshCfgText)) + if err != nil { + t.Fatalf("parse ssh_config: %v", err) + } + cfg := newEmptyTestConfig() + cfg.Hosts.Set("10.0.0.10:22", models.Host{Address: "10.0.0.10", Port: 22}) + cfg.Identities.Set("root@10.0.0.10", models.Identity{User: "root", AuthType: "auto"}) + cfg.Nodes.Set("root@10.0.0.10:22", models.Node{HostRef: "10.0.0.10:22", IdentityRef: "root@10.0.0.10"}) + cfg.Nodes.Set("web-prod", models.Node{HostRef: "10.0.0.10:22", IdentityRef: "root@10.0.0.10", Alias: []string{"web-alias"}}) + store := config.NewDefaultStore(filepath.Join(t.TempDir(), "config.yaml"), filepath.Join(t.TempDir(), "key")) + repo, err := config.NewRepositoryWithOpenSSHParser(cfg, store, parser) + if err != nil { + t.Fatalf("create repo: %v", err) + } + + // R10-1: exact canonical node ID root@10.0.0.10:22 disambiguates against web-prod. + opts := NewSshOptions() + opts.Host = "root@10.0.0.10:22" + if err := opts.parseTarget(false); err != nil { + t.Fatalf("parseTarget: %v", err) + } + res, err := repo.PrepareNodeContext(context.Background(), config.EnsureNodeOptions{Target: opts.Target}) + if err != nil || res.NodeID != "root@10.0.0.10:22" { + t.Fatalf("expected PrepareNodeContext(root@10.0.0.10:22) to resolve root@10.0.0.10:22, got %q (err=%v)", res.NodeID, err) + } + ctlID, err := resolveControlNodeID(repo, opts.Target) + if err != nil || ctlID != "root@10.0.0.10:22" { + t.Fatalf("expected resolveControlNodeID(root@10.0.0.10:22) to resolve root@10.0.0.10:22, got %q (err=%v)", ctlID, err) + } + + // R10-1 Step 4: composite user@host:port selector does not match Host 10.0.0.10 block. + daemonRes, err := repo.PrepareNodeContext(context.Background(), config.EnsureNodeOptions{ + Target: config.ConnectionTarget{ + Selector: "ops@10.0.0.10:2200", + RawSelector: "ops@10.0.0.10:2200", + User: "ops", + HasUser: true, + Port: 2200, + HasPort: true, + }, + }) + if err != nil { + t.Fatalf("PrepareNodeContext(ops@10.0.0.10:2200): %v", err) + } + snap, err := repo.ResolveConnection(daemonRes.NodeID) + if err != nil { + t.Fatalf("ResolveConnection(%q): %v", daemonRes.NodeID, err) + } + if snap.Node.ProxyJump != "" || snap.Identity.KeyPath != "" { + t.Fatalf("expected composite selector ops@10.0.0.10:2200 not to inherit Host 10.0.0.10 ProxyJump/KeyPath, got jump=%q key=%q", snap.Node.ProxyJump, snap.Identity.KeyPath) + } +} + +func verifyRound10Tier2AndTier4ControlResolution(t *testing.T) { + t.Helper() + sshCfgText := ` +Host app + HostName 10.0.0.20 + User ubuntu + Port 2200 + ControlMaster auto + ControlPath /tmp/app-%r@%h:%p-%n + ControlPersist 60s +` + parser, err := config.NewOpenSSHParserFromReader(strings.NewReader(sshCfgText)) + if err != nil { + t.Fatalf("parse ssh_config: %v", err) + } + cfg := newEmptyTestConfig() + cfg.Control = &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "/tmp/global-%n", Persist: "30s"} + cfg.Hosts.Set("10.0.0.10:22", models.Host{Address: "10.0.0.10", Port: 22}) + cfg.Identities.Set("ubuntu@10.0.0.10", models.Identity{User: "ubuntu", AuthType: "auto"}) + cfg.Nodes.Set("prod", models.Node{ + HostRef: "10.0.0.10:22", + IdentityRef: "ubuntu@10.0.0.10", + Control: &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "/tmp/prod-tier2-%n", Persist: "30s"}, + }) + // Saved node from a previous `xops ssh -p 2222 app` where OpenSSHAlias was not serialized to YAML. + cfg.Hosts.Set("10.0.0.20:2222", models.Host{Address: "10.0.0.20", Port: 2222}) + cfg.Identities.Set("ubuntu@10.0.0.20", models.Identity{User: "ubuntu", AuthType: "auto"}) + cfg.Nodes.Set("ubuntu@10.0.0.20:2222", models.Node{ + HostRef: "10.0.0.20:2222", + IdentityRef: "ubuntu@10.0.0.20", + }) + store := config.NewDefaultStore(filepath.Join(t.TempDir(), "config.yaml"), filepath.Join(t.TempDir(), "key")) + repo, err := config.NewRepositoryWithOpenSSHParser(cfg, store, parser) + if err != nil { + t.Fatalf("create repo: %v", err) + } + + // R10-2 (Part 1): unremembered override `-l root prod` uses Tier 3 global, not prod's Tier 2. + overrideOpts := NewSshOptions() + overrideOpts.Host = "prod" + overrideOpts.User = "root" + overrideOpts.Target = config.ConnectionTarget{Selector: "prod", User: "root", HasUser: true} + ctlNodeID, err := resolveControlNodeID(repo, overrideOpts.Target) + if err != nil { + t.Fatalf("resolveControlNodeID(-l root prod): %v", err) + } + sockPath, err := controlSocketPath(overrideOpts, repo, ctlNodeID) + if err != nil { + t.Fatalf("controlSocketPath(-l root prod): %v", err) + } + if sockPath != "/tmp/global-root@10.0.0.10:22" { + t.Fatalf("expected unremembered override on prod to use global Tier 3 path, got %q", sockPath) + } + + // R10-2 (Part 2): saved node ubuntu@10.0.0.20:2222 retains OpenSSHAlias="app" when resolved via selector "app". + cfgNoGlobal := repo.Snapshot() + cfgNoGlobal.Control = nil + store2 := config.NewDefaultStore(filepath.Join(t.TempDir(), "config2.yaml"), filepath.Join(t.TempDir(), "key2")) + repoTier4, err := config.NewRepositoryWithOpenSSHParser(cfgNoGlobal, store2, parser) + if err != nil { + t.Fatalf("create repoTier4: %v", err) + } + appOpts := NewSshOptions() + appOpts.Host = "app" + appOpts.Port = 2222 + appOpts.Target = config.ConnectionTarget{Selector: "app", Port: 2222, HasPort: true} + res, err := repoTier4.PrepareNodeContext(context.Background(), config.EnsureNodeOptions{Target: appOpts.Target}) + if err != nil || res.NodeID != "ubuntu@10.0.0.20:2222" { + t.Fatalf("PrepareNodeContext(-p 2222 app) = %q, err=%v", res.NodeID, err) + } + appSock, err := controlSocketPath(appOpts, repoTier4, res.NodeID) + if err != nil || appSock != "/tmp/app-ubuntu@10.0.0.20:2222-ubuntu@10.0.0.20:2222" { + t.Fatalf("expected Tier 4 OpenSSH ControlPath for saved override node, got %q (err=%v)", appSock, err) + } +} + +func verifyRound10ControlAmbiguityAndSharedJumpKey(t *testing.T) { + t.Helper() + cfg := newEmptyTestConfig() + cfg.Hosts.Set("db.internal:22", models.Host{Address: "db.internal", Port: 22}) + cfg.Identities.Set("postgres@db", models.Identity{User: "postgres", AuthType: "auto"}) + cfg.Nodes.Set("node-1", models.Node{HostRef: "db.internal:22", IdentityRef: "postgres@db", Alias: []string{"db.internal"}}) + cfg.Nodes.Set("node-2", models.Node{HostRef: "db.internal:22", IdentityRef: "postgres@db"}) + provider := config.NewProviderWithoutOpenSSH(cfg) + + // R10-3: filterAmbiguousControlCandidates narrows by unique alias. + nodeID, err := resolveControlNodeID(provider, config.ConnectionTarget{ + Selector: "db.internal", + User: "postgres", + HasUser: true, + }) + if err != nil || nodeID != "node-1" { + t.Fatalf("expected resolveControlNodeID(db.internal) to narrow to alias owner node-1, got %q (err=%v)", nodeID, err) + } +} + +func TestRound11UnifiedTargetAndControlResolution(t *testing.T) { + verifyRound11OpenSSHAliasRePrepareAndConfirm(t) + verifyRound11ThreeWayAliasTombstone(t) + verifyRound11Tier4OpenSSHSpecificityAndLocalShadow(t) + verifyRound11OpenSSHPortOverrideAndSavedNode(t) + verifyRound11UnifiedInvariantMatrix(t) +} + +func verifyRound11OpenSSHAliasRePrepareAndConfirm(t *testing.T) { + t.Helper() + ctx := context.Background() + sshCfgText := ` +Host app + HostName 10.0.0.10 + User ubuntu + Port 2200 + ControlMaster auto + ControlPath /tmp/cm-app-%r@%h:%p +` + parser, err := config.NewOpenSSHParserFromReader(strings.NewReader(sshCfgText)) + if err != nil { + t.Fatalf("parse ssh_config: %v", err) + } + cfg := newEmptyTestConfig() + store := config.NewDefaultStore(filepath.Join(t.TempDir(), "config.yaml"), filepath.Join(t.TempDir(), "key")) + repo, err := config.NewRepositoryWithOpenSSHParser(cfg, store, parser) + if err != nil { + t.Fatalf("create repo: %v", err) + } + + // 1. Prepare via raw IP first (OpenSSHAlias == ""), then re-prepare via OpenSSH alias "app" + // (OpenSSHAlias == "app"). Must not fail with ErrConfigConflict and must preserve "app". + rawTarget := config.ConnectionTarget{Selector: "10.0.0.10", User: "root", HasUser: true, Port: 2200, HasPort: true} + res1, err := repo.PrepareNodeContext(ctx, config.EnsureNodeOptions{Target: rawTarget}) + if err != nil || res1.NodeID != "root@10.0.0.10:2200" { + t.Fatalf("PrepareNodeContext(10.0.0.10): got %q, err=%v", res1.NodeID, err) + } + aliasTarget := config.ConnectionTarget{Selector: "app", User: "root", HasUser: true} + res2, err := repo.PrepareNodeContext(ctx, config.EnsureNodeOptions{Target: aliasTarget}) + if err != nil || res2.NodeID != "root@10.0.0.10:2200" { + t.Fatalf("PrepareNodeContext(app) after raw IP failed with conflict: got %q, err=%v", res2.NodeID, err) + } + snap, err := repo.ResolveConnection(res2.NodeID) + if err != nil || snap.Node.OpenSSHAlias != "app" { + t.Fatalf("expected pending connection to retain OpenSSHAlias='app', got %q (err=%v)", snap.Node.OpenSSHAlias, err) + } + + // 2. ConfirmNodeContext must succeed even if another writer already saved root@10.0.0.10:2200 + // without OpenSSHAlias, and must keep OpenSSHAlias in session memory + Frozen(). + if err := repo.ConfirmNodeContext(ctx, res2.NodeID); err != nil { + t.Fatalf("ConfirmNodeContext(%q) failed: %v", res2.NodeID, err) + } + snapAfter, err := repo.ResolveConnection(res2.NodeID) + if err != nil || snapAfter.Node.OpenSSHAlias != "app" { + t.Fatalf("expected confirmed node to retain session OpenSSHAlias='app', got %q (err=%v)", snapAfter.Node.OpenSSHAlias, err) + } + frozen := repo.Frozen() + frozenSnap, err := frozen.ResolveConnection(res2.NodeID) + if err != nil || frozenSnap.Node.OpenSSHAlias != "app" { + t.Fatalf("expected Frozen() provider to retain OpenSSHAlias='app', got %q (err=%v)", frozenSnap.Node.OpenSSHAlias, err) + } +} + +func verifyRound11ThreeWayAliasTombstone(t *testing.T) { + t.Helper() + cfg := newEmptyTestConfig() + cfg.Hosts.Set("h1", models.Host{Address: "10.0.0.1", Port: 22}) + cfg.Identities.Set("id1", models.Identity{User: "root", AuthType: "auto"}) + cfg.Nodes.Set("node-1", models.Node{HostRef: "h1", IdentityRef: "id1", Alias: []string{"dup"}}) + cfg.Nodes.Set("node-2", models.Node{HostRef: "h1", IdentityRef: "id1", Alias: []string{"dup"}}) + cfg.Nodes.Set("node-3", models.Node{HostRef: "h1", IdentityRef: "id1", Alias: []string{"dup"}}) + + provider := config.NewProviderWithoutOpenSSH(cfg) + if got := provider.FindAlias("dup"); got != "" { + t.Fatalf("expected FindAlias('dup') to be empty when 3 nodes share alias, got %q", got) + } + var ambig *config.AmbiguousNodeError + if _, err := config.ResolveTarget(provider, config.ConnectionTarget{Selector: "dup"}); !errors.As(err, &ambig) { + t.Fatalf("expected ResolveTarget('dup') to return AmbiguousNodeError when 3 nodes share alias, got %v", err) + } + if _, err := config.ResolveTarget(provider, config.ConnectionTarget{Selector: "dup", User: "root", HasUser: true}); !errors.As(err, &ambig) { + t.Fatalf("expected ResolveTarget('dup', -l root) to return AmbiguousNodeError when 3 nodes share alias, got %v", err) + } +} + +func verifyRound11Tier4OpenSSHSpecificityAndLocalShadow(t *testing.T) { + t.Helper() + sshCfgText := ` +Host prod + HostName 192.168.99.99 + ControlMaster auto + ControlPath /tmp/wrong-openssh-prod-%C + +Host web-alias + HostName 10.0.0.10 + Port 2200 + ControlMaster auto + ControlPath /tmp/specific-web-alias-%C + +Host 10.0.0.10 + HostName 10.0.0.10 + ControlMaster auto + ControlPath /tmp/generic-ip-%C + +Host single-addr-host + HostName 10.0.0.55 + Port 2200 + ControlMaster auto + ControlPath /tmp/single-addr-host-%C +` + parser, err := config.NewOpenSSHParserFromReader(strings.NewReader(sshCfgText)) + if err != nil { + t.Fatalf("parse ssh_config: %v", err) + } + cfg := newEmptyTestConfig() + cfg.Hosts.Set("h-prod", models.Host{Address: "10.0.0.10", Port: 2200}) + cfg.Hosts.Set("h-55", models.Host{Address: "10.0.0.55", Port: 2222}) + cfg.Identities.Set("id-root", models.Identity{User: "root", AuthType: "auto"}) + // Saved local node "prod" has Alias "web-alias" and points to 10.0.0.10:2200, NOT 192.168.99.99. + cfg.Nodes.Set("prod", models.Node{ + HostRef: "h-prod", + IdentityRef: "id-root", + Alias: []string{"web-alias"}, + }) + // Saved node on 10.0.0.55:2222 (port overridden from single-addr-host on 10.0.0.55:2200). + cfg.Nodes.Set("root@10.0.0.55:2222", models.Node{ + HostRef: "h-55", + IdentityRef: "id-root", + }) + provider := config.NewProviderWithOpenSSHParser(cfg, parser) + + // 1. Local node "prod" with Alias "web-alias" must use Host web-alias (not Host prod or Host 10.0.0.10). + optsProd := NewSshOptions() + optsProd.Host = "prod" + optsProd.Target = config.ConnectionTarget{Selector: "prod"} + sockProd, err := controlSocketPath(optsProd, provider, "prod") + if err != nil { + t.Fatalf("controlSocketPath(prod): %v", err) + } + if !strings.HasPrefix(sockProd, "/tmp/specific-web-alias-") { + t.Fatalf("expected Tier 4 lookup for local node 'prod' (Alias: web-alias) to pick /tmp/specific-web-alias-*, got %q", sockProd) + } + + // 2. Saved node root@10.0.0.55:2222 accessed via OpenSSH alias "single-addr-host" with -p 2222 + // records OpenSSHAlias="single-addr-host" and resolves its ControlPath (/tmp/single-addr-host-%C). + opts55 := NewSshOptions() + opts55.Host = "single-addr-host" + opts55.Port = 2222 + opts55.Target = config.ConnectionTarget{Selector: "single-addr-host", Port: 2222, HasPort: true} + sock55, err := controlSocketPath(opts55, provider, "root@10.0.0.55:2222") + if err != nil { + t.Fatalf("controlSocketPath(single-addr-host -p 2222): %v", err) + } + if !strings.HasPrefix(sock55, "/tmp/single-addr-host-") { + t.Fatalf("expected OpenSSH alias with -p 2222 on saved node to use /tmp/single-addr-host-*, got %q", sock55) + } +} + +func verifyRound11OpenSSHPortOverrideAndSavedNode(t *testing.T) { + t.Helper() + sshCfgText := ` +Host demo + HostName 10.0.0.70 + User ubuntu + Port 2200 +` + parser, err := config.NewOpenSSHParserFromReader(strings.NewReader(sshCfgText)) + if err != nil { + t.Fatalf("parse ssh_config: %v", err) + } + cfg := newEmptyTestConfig() + cfg.Hosts.Set("10.0.0.70:2200", models.Host{Address: "10.0.0.70", Port: 2200}) + cfg.Identities.Set("ubuntu@10.0.0.70", models.Identity{User: "ubuntu", AuthType: "auto"}) + cfg.Nodes.Set("saved-demo", models.Node{ + HostRef: "10.0.0.70:2200", + IdentityRef: "ubuntu@10.0.0.70", + ProxyJump: "saved-jump", + }) + provider := config.NewProviderWithOpenSSHParser(cfg, parser) + + // 1. OpenSSH alias "demo" with explicit matching -p 2200 resolves to saved node "saved-demo" + // (inheriting saved-jump) just like PrepareNodeContext. + resMatch, err := config.ResolveTarget(provider, config.ConnectionTarget{ + Selector: "demo", + Port: 2200, + HasPort: true, + }) + if err != nil { + t.Fatalf("ResolveTarget(demo -p 2200): %v", err) + } + if resMatch.NodeID != "saved-demo" || resMatch.MatchedNodeID != "saved-demo" || resMatch.ProxyJump != "saved-jump" { + t.Fatalf("expected ResolveTarget(demo -p 2200) to match saved-demo with ProxyJump saved-jump, got %+v", resMatch) + } + + // 2. OpenSSH alias "demo" with overridden -p 2222 (not in saved nodes) has MatchedNodeID == "" + // and NodeID == "ubuntu@10.0.0.70:2222". + resOverride, err := config.ResolveTarget(provider, config.ConnectionTarget{ + Selector: "demo", + Port: 2222, + HasPort: true, + }) + if err != nil { + t.Fatalf("ResolveTarget(demo -p 2222): %v", err) + } + if resOverride.MatchedNodeID != "" || resOverride.NodeID != "ubuntu@10.0.0.70:2222" { + t.Fatalf("expected unremembered override on OpenSSH alias to have MatchedNodeID='', NodeID='ubuntu@10.0.0.70:2222', got %+v", resOverride) + } +} + +func verifyRound11UnifiedInvariantMatrix(t *testing.T) { + t.Helper() + ctx := context.Background() + sshCfgText := ` +Host app-alias + HostName 10.0.0.20 + User dev + Port 2200 + IdentityFile /tmp/app_key + ProxyJump jump-alias + ControlMaster auto + ControlPath /tmp/cm-inv-%n-%r@%h:%p-%j + ControlPersist 30s + +Host jump-alias + HostName 10.0.0.1 + User jump + Port 22 +` + parser, err := config.NewOpenSSHParserFromReader(strings.NewReader(sshCfgText)) + if err != nil { + t.Fatalf("parse ssh_config: %v", err) + } + baseCfg := newEmptyTestConfig() + baseCfg.Control = &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "/tmp/cm-global-%n-%r@%h:%p-%j", Persist: "30s"} + baseCfg.Hosts.Set("10.0.0.10:22", models.Host{Address: "10.0.0.10", Port: 22, Alias: []string{"host-alias"}}) + baseCfg.Identities.Set("root@10.0.0.10", models.Identity{User: "root", AuthType: "auto", KeyPath: "/tmp/root_key"}) + baseCfg.Nodes.Set("web-node", models.Node{ + HostRef: "10.0.0.10:22", + IdentityRef: "root@10.0.0.10", + Alias: []string{"web"}, + ProxyJump: "openssh:jump-alias", + Control: &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "/tmp/cm-node-%n-%r@%h:%p-%j", Persist: "30s"}, + }) + + targets := []config.ConnectionTarget{ + {Selector: "web-node"}, + {Selector: "web"}, + {Selector: "host-alias"}, + {Selector: "10.0.0.10"}, + {Selector: "web", User: "root", HasUser: true, Port: 22, HasPort: true}, + {Selector: "web", User: "ops", HasUser: true}, + {Selector: "web", Port: 2222, HasPort: true}, + {Selector: "app-alias"}, + {Selector: "app-alias", User: "dev", HasUser: true, Port: 2200, HasPort: true}, + {Selector: "app-alias", User: "ops", HasUser: true}, + {Selector: "app-alias", Port: 2222, HasPort: true}, + {Selector: "10.0.0.99", User: "admin", HasUser: true, Port: 2222, HasPort: true}, + {Selector: "ops@web"}, + {Selector: "ops@web:2222"}, + {Selector: "[::1]"}, + {Selector: "openssh:app-alias"}, + {Selector: "openssh:app-alias", User: "dev", HasUser: true, Port: 2200, HasPort: true}, + {Selector: "openssh:app-alias", User: "ops", HasUser: true}, + {Selector: "openssh:jumpuser@10.0.0.10:2201"}, + {Selector: "web-node", ProxyJump: "none", HasProxyJump: true}, + {Selector: "app-alias", ProxyJump: "none", HasProxyJump: true}, + } + + for _, target := range targets { + store := config.NewDefaultStore(filepath.Join(t.TempDir(), "config.yaml"), filepath.Join(t.TempDir(), "key")) + repo, err := config.NewRepositoryWithOpenSSHParser(baseCfg.Snapshot(), store, parser) + if err != nil { + t.Fatalf("create repo for %+v: %v", target, err) + } + ensureStore := config.NewDefaultStore(filepath.Join(t.TempDir(), "ensure.yaml"), filepath.Join(t.TempDir(), "ensure.key")) + if err := ensureStore.Save(baseCfg.Snapshot()); err != nil { + t.Fatalf("save ensureStore for %+v: %v", target, err) + } + ensureRepo, err := config.NewRepositoryWithOpenSSHParser(baseCfg.Snapshot(), ensureStore, parser) + if err != nil { + t.Fatalf("create ensureRepo for %+v: %v", target, err) + } + readOnlyProvider := config.NewProviderWithOpenSSHParser(baseCfg.Snapshot(), parser) + + prepRes, prepErr := repo.PrepareNodeContext(ctx, config.EnsureNodeOptions{Target: target}) + ensureRes, ensureErr := ensureRepo.EnsureNodeContext(ctx, config.EnsureNodeOptions{Target: target}) + resTarget, resErr := config.ResolveTarget(readOnlyProvider, target) + if (prepErr != nil) != (resErr != nil) || (prepErr != nil) != (ensureErr != nil) { + t.Fatalf("target %+v: Prepare err=%v vs Ensure err=%v vs ResolveTarget err=%v", target, prepErr, ensureErr, resErr) + } + if prepErr != nil { + continue + } + if prepRes.NodeID != resTarget.NodeID || prepRes.Created != resTarget.Created || + ensureRes.NodeID != resTarget.NodeID || ensureRes.Created != resTarget.Created { + t.Fatalf("target %+v: Prepare=(%q,%v) Ensure=(%q,%v) ResolveTarget=(%q,%v)", + target, prepRes.NodeID, prepRes.Created, ensureRes.NodeID, ensureRes.Created, resTarget.NodeID, resTarget.Created) + } + + opts := NewSshOptions() + opts.Host = target.Selector + opts.User = target.User + opts.Port = target.Port + opts.JumpHost = target.ProxyJump + opts.Target = target + + connectCtx := controlTarget(opts, repo, prepRes.NodeID) + ctlNodeID, err := resolveControlNodeID(readOnlyProvider, target) + if err != nil { + t.Fatalf("target %+v: resolveControlNodeID failed: %v", target, err) + } + ctlCtx := controlTarget(opts, readOnlyProvider, ctlNodeID) + if connectCtx.CanonicalTargetIdentity() != ctlCtx.CanonicalTargetIdentity() { + t.Fatalf("target %+v: connect TargetContext %+v != control TargetContext %+v", target, connectCtx, ctlCtx) + } + + connectSock, err1 := controlSocketPath(opts, repo, prepRes.NodeID) + ctlSock, err2 := controlSocketPath(opts, readOnlyProvider, ctlNodeID) + if err1 != nil || err2 != nil || connectSock != ctlSock { + t.Fatalf("target %+v: connect socket (%q, %v) != control socket (%q, %v)", target, connectSock, err1, ctlSock, err2) + } + } + + verifyRound12NormalizedTargetAndEffectiveControlNodeID(t) + verifyRound13OpenSSHVirtualNodeAndProxyJumpNoneAndNilProvider(t) +} + +func verifyRound12NormalizedTargetAndEffectiveControlNodeID(t *testing.T) { + t.Helper() + sshCfgText := ` +Host gw + HostName 10.0.0.10 + User root + Port 22 + ControlMaster auto + ControlPath /tmp/tier4-gw-%n + ControlPersist 30s +` + parser, err := config.NewOpenSSHParserFromReader(strings.NewReader(sshCfgText)) + if err != nil { + t.Fatalf("parse ssh_config: %v", err) + } + cfg := newEmptyTestConfig() + cfg.Hosts.Set("10.0.0.10:2222", models.Host{Address: "10.0.0.10", Port: 2222}) + cfg.Identities.Set("root@10.0.0.10", models.Identity{User: "root", AuthType: "auto"}) + cfg.Nodes.Set("gw-2222", models.Node{ + HostRef: "10.0.0.10:2222", + IdentityRef: "root@10.0.0.10", + Control: &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "/tmp/tier2-gw2222-%n", Persist: "30s"}, + }) + provider := config.NewProviderWithOpenSSHParser(cfg, parser) + + // R12-2 & R12-3: calling controlSocketPath with nodeID="openssh:gw" and Port=2222 + // resolves OriginalHost="gw-2222" and uses gw-2222's Tier 2 Node.Control. + opts := NewSshOptions() + opts.Host = "gw" + opts.Port = 2222 + sock, err := controlSocketPath(opts, provider, "openssh:gw") + if err != nil { + t.Fatalf("controlSocketPath(openssh:gw, -p 2222): %v", err) + } + if sock != "/tmp/tier2-gw2222-gw-2222" { + t.Fatalf("expected Tier 2 socket path /tmp/tier2-gw2222-gw-2222, got %q", sock) + } +} + +func verifyRound13OpenSSHVirtualNodeAndProxyJumpNoneAndNilProvider(t *testing.T) { + t.Helper() + ctx := context.Background() + sshCfgText := ` +Host app-alias + HostName 10.0.0.20 + User dev + Port 2200 + ProxyJump jump-alias + ControlMaster auto + ControlPath /tmp/tier4-app-%n-%j + +Host jump-alias + HostName 10.0.0.1 + User jump + Port 22 +` + parser, err := config.NewOpenSSHParserFromReader(strings.NewReader(sshCfgText)) + if err != nil { + t.Fatalf("parse ssh_config: %v", err) + } + cfg := newEmptyTestConfig() + store := config.NewDefaultStore(filepath.Join(t.TempDir(), "config.yaml"), filepath.Join(t.TempDir(), "key")) + repo, err := config.NewRepositoryWithOpenSSHParser(cfg, store, parser) + if err != nil { + t.Fatalf("create repo: %v", err) + } + + // R13-1a: controlSocketPath and controlTarget with empty opts.Host and nodeID="openssh:app-alias" + // must keep OriginalHost="openssh:app-alias" (not strip "openssh:" into "dev@10.0.0.20:2200"). + emptyHostOpts := NewSshOptions() + tCtx := controlTarget(emptyHostOpts, repo, "openssh:app-alias") + if tCtx.OriginalHost != "openssh:app-alias" { + t.Fatalf("expected OriginalHost='openssh:app-alias' when opts.Host is empty, got %q", tCtx.OriginalHost) + } + sock, err := controlSocketPath(emptyHostOpts, repo, "openssh:app-alias") + if err != nil || sock != "/tmp/tier4-app-openssh:app-alias-openssh:jump-alias" { + t.Fatalf("unexpected socket path for openssh:app-alias with empty opts.Host: %q (err=%v)", sock, err) + } + + // R13-1b: pending node with custom IdentityFile preserves KeyPath in ResolveTarget when queried by nodeID. + prep, err := repo.PrepareNodeContext(ctx, config.EnsureNodeOptions{ + Target: config.ConnectionTarget{Selector: "10.0.0.88", User: "ops", HasUser: true, Port: 2222, HasPort: true}, + IdentityFile: "/tmp/pending-custom-key", + }) + if err != nil { + t.Fatalf("PrepareNodeContext(10.0.0.88): %v", err) + } + pendingCtx := controlTarget(NewSshOptions(), repo, prep.NodeID) + if pendingCtx.KeyPath != "/tmp/pending-custom-key" { + t.Fatalf("expected controlTarget on pending node %q to retain KeyPath '/tmp/pending-custom-key', got %q", prep.NodeID, pendingCtx.KeyPath) + } + + // R13-2: controlTarget with provider == nil matches provider != nil for composite and explicit targets. + compOpts := NewSshOptions() + compOpts.Host = "ops@10.0.0.10:2222" + nilCtx1 := controlTarget(compOpts, nil, "") + if nilCtx1.Host != "10.0.0.10" || nilCtx1.User != "ops" || nilCtx1.Port != 2222 || nilCtx1.OriginalHost != "ops@10.0.0.10:2222" { + t.Fatalf("unexpected nil-provider controlTarget for composite Host: %+v", nilCtx1) + } + expOpts := NewSshOptions() + expOpts.Host = "10.0.0.10" + expOpts.User = "ops" + expOpts.Port = 2222 + expOpts.JumpHost = "none" + nilCtx2 := controlTarget(expOpts, nil, "") + if nilCtx2.OriginalHost != "ops@10.0.0.10:2222" || nilCtx2.JumpHost != "" { + t.Fatalf("unexpected nil-provider controlTarget for explicit flags: %+v", nilCtx2) + } + + // R13-3: -J none / ProxyJump=none disables ProxyJump across ResolveProxyJumpChain, controlSocketPath, and SSHAdapter. + noneOpts := NewSshOptions() + noneOpts.Host = "app-alias" + noneOpts.JumpHost = "none" + noneSock, err := controlSocketPath(noneOpts, repo, "openssh:app-alias") + if err != nil || noneSock != "/tmp/tier4-app-openssh:app-alias-" { + t.Fatalf("expected -J none to clear %s in ControlPath, got %q (err=%v)", "%j", noneSock, err) + } + adp := adapter.NewSSHAdapter(repo, adapter.WithSessionAuthOverride("openssh:app-alias", adapter.SessionAuth{ProxyJump: "none"})) + clientCfg, err := adp.GetConfig("openssh:app-alias") + if err != nil || clientCfg.ProxyJump != "" { + t.Fatalf("expected SessionAuth{ProxyJump: 'none'} to clear clientCfg.ProxyJump, got %q (err=%v)", clientCfg.ProxyJump, err) + } + + verifyRound15ControlProxySudoAndScpTagParity(t) + verifyRound16ControlEdgeCases(t) +} + +func verifyRound15ControlProxySudoAndScpTagParity(t *testing.T) { + t.Helper() + ctx := context.Background() + + // 1. Verify buildControlProxyClientConfig wires session password and read-only SecretResolver + // both when nodeID is known and when nodeID is empty (ad-hoc -S socket). + cfg := newEmptyTestConfig() + cfg.PasswordPromptPattern = `(?i)custom-pass:` + cfg.Hosts.Set("10.0.0.50:22", models.Host{Address: "10.0.0.50", Port: 22}) + cfg.Identities.Set("ops@10.0.0.50", models.Identity{User: "ops", AuthType: "password"}) + cfg.Nodes.Set("web-50", models.Node{ + HostRef: "10.0.0.50:22", + IdentityRef: "ops@10.0.0.50", + SudoMode: models.SudoModeSudo, + }) + provider := config.NewProviderWithoutOpenSSH(cfg) + + sshOpts := NewSshOptions() + sshOpts.Password = "session-sudo-secret" + target := ssh.TargetContext{Host: "10.0.0.50", User: "ops", Port: 22, OriginalHost: "web-50"} + + knownCfg, knownMuxOpts := buildControlProxyClientConfig(target, provider, "web-50", sshOpts) + if knownCfg == nil || knownCfg.NodeID != "web-50" { + t.Fatalf("expected known node ClientConfig for web-50, got %+v", knownCfg) + } + if len(knownMuxOpts) == 0 { + t.Fatalf("expected MuxClientOptions to be populated for known node") + } + + adhocCfg, _ := buildControlProxyClientConfig(target, provider, "", sshOpts) + if adhocCfg == nil || adhocCfg.Password != "session-sudo-secret" || adhocCfg.PasswordPromptPattern != `(?i)custom-pass:` { + t.Fatalf("expected ad-hoc ClientConfig to retain session password and global prompt pattern, got %+v", adhocCfg) + } + + // 2. Verify scp --tag target resolution parity between collectTargetPaths and runBatch + // when a tagged node has empty identity.User or port 0. + tagCfg := newEmptyTestConfig() + tagCfg.Hosts.Set("10.0.0.60:0", models.Host{Address: "10.0.0.60", Port: 0}) + tagCfg.Identities.Set("default-id", models.Identity{User: "", AuthType: "auto"}) + tagCfg.Nodes.Set("tagged-node", models.Node{ + HostRef: "10.0.0.60:0", + IdentityRef: "default-id", + Tags: []string{"batch-tag"}, + }) + store := config.NewDefaultStore(filepath.Join(t.TempDir(), "config.yaml"), filepath.Join(t.TempDir(), "key")) + tagRepo, err := config.NewRepositoryWithoutOpenSSH(tagCfg, store) + if err != nil { + t.Fatalf("create tagRepo: %v", err) + } + + scpOpts := NewScpOptions() + scpOpts.Tag = "batch-tag" + scpOpts.Source = "local.txt" + scpOpts.Dest = ":/tmp/remote.txt" + + paths := scpOpts.collectTargetPaths(tagRepo) + if len(paths) != 1 { + t.Fatalf("expected 1 target path from collectTargetPaths, got %d", len(paths)) + } + batchAddr := PathInfo{Host: "tagged-node", IsRemote: true} + if paths[0] != batchAddr { + t.Fatalf("expected collectTargetPaths path %+v to match runBatch path %+v", paths[0], batchAddr) + } + collectTarget, err := scpOpts.resolveTargetForPath(paths[0]) + if err != nil { + t.Fatalf("resolveTargetForPath(collectTargetPaths) failed: %v", err) + } + prepCollect, err := tagRepo.PrepareNodeContext(ctx, config.EnsureNodeOptions{ + Target: collectTarget, + }) + if err != nil { + t.Fatalf("PrepareNodeContext(collectTargetPaths) failed: %v", err) + } + batchTarget, err := scpOpts.resolveTargetForPath(batchAddr) + if err != nil { + t.Fatalf("resolveTargetForPath(runBatch) failed: %v", err) + } + prepBatch, err := tagRepo.PrepareNodeContext(ctx, config.EnsureNodeOptions{ + Target: batchTarget, + }) + if err != nil { + t.Fatalf("PrepareNodeContext(runBatch) failed: %v", err) + } + if prepCollect.NodeID != "tagged-node" || prepBatch.NodeID != "tagged-node" || prepCollect.Created || prepBatch.Created { + t.Fatalf("expected both collectTargetPaths and runBatch to match existing 'tagged-node' without creating a new node, got collect=%+v batch=%+v", prepCollect, prepBatch) + } +} + +func verifyRound16ControlEdgeCases(t *testing.T) { + t.Helper() + ctx := context.Background() + target := ssh.TargetContext{Host: "10.0.0.50", User: "ops", Port: 22, OriginalHost: "web-50"} + + // 1. Round 16: resolveEffectiveControlNodeID strips user@ and :port from openssh: composite nodeID. + aliasCfg := newEmptyTestConfig() + aliasProvider := config.NewProviderWithoutOpenSSH(aliasCfg) + effNodeID := resolveEffectiveControlNodeID(aliasProvider, "openssh:ops@my-jump-alias:2222", "ops@10.0.0.77:2222") + if effNodeID != "ops@10.0.0.77:2222" { + t.Fatalf("expected effective node ID 'ops@10.0.0.77:2222', got %q", effNodeID) + } + if gotAlias := aliasProvider.NodeOpenSSHAlias("ops@10.0.0.77:2222"); gotAlias != "my-jump-alias" { + t.Fatalf("expected clean OpenSSH alias 'my-jump-alias', got %q", gotAlias) + } + + // 2. Round 16: Validate skips prepareCommandPlan for non-proxy -O commands, and ExecuteControlCommand validates control options. + checkOpts := NewSshOptions() + checkOpts.Host = "10.0.0.50" + checkOpts.control.CtlCmd = "check" + if err := checkOpts.Validate(); err != nil { + t.Fatalf("expected Validate() on -O check to succeed, got %v", err) + } + if checkOpts.commandPlan != nil { + t.Fatalf("expected -O check Validate() not to populate commandPlan") + } + + invalidCtlOpts := NewSshOptions() + invalidCtlOpts.Host = "10.0.0.50" + invalidCtlOpts.control.CtlCmd = "invalid-subcommand" + if err := ExecuteControlCommand(ctx, invalidCtlOpts); err == nil { + t.Fatalf("expected direct ExecuteControlCommand with invalid CtlCmd to fail validation") + } + + // 3. Round 16: buildControlProxyClientConfig does not inject non-TTY prompter when StdinRedirect is set. + redirectOpts := NewSshOptions() + redirectOpts.StdinRedirect = true + _, redirectMuxOpts := buildControlProxyClientConfig(target, nil, "", redirectOpts) + if len(redirectMuxOpts) != 0 { + t.Fatalf("expected no default CLI prompter mux option when StdinRedirect is true and interaction is nil, got %d options", len(redirectMuxOpts)) + } +} diff --git a/cmd/ssh_control_resolution_test.go b/cmd/ssh_control_resolution_test.go new file mode 100644 index 00000000..664e70f7 --- /dev/null +++ b/cmd/ssh_control_resolution_test.go @@ -0,0 +1,537 @@ +package cmd + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/wentf9/xops-cli/core/ssh" + "github.com/wentf9/xops-cli/internal/sshenv" + "github.com/wentf9/xops-cli/pkg/config" +) + +const controlResolutionConfig = `schema_version: 2 +control: + master: auto + path: "%s" +nodes: + web-01: + host_ref: host-web-01 + identity_ref: id-web-01 + sudo_mode: none +hosts: + host-web-01: + address: 10.20.30.40 + port: 2222 +identities: + id-web-01: + user: deploy + auth_type: auto +` + +// writeControlTestConfig installs a schema v2 configuration in a temporary +// XOPS_CONFIG_DIR and returns the expected expanded control path. +func writeControlTestConfig(t *testing.T, pattern string) string { + t.Helper() + directory := t.TempDir() + t.Setenv("XOPS_CONFIG_DIR", directory) + + expanded, err := ssh.ExpandControlPath(pattern, sshenv.Discover(), ssh.TargetContext{ + Host: "10.20.30.40", + Port: 2222, + User: "deploy", + }) + if err != nil { + t.Fatalf("ExpandControlPath failed: %v", err) + } + content := fmt.Sprintf(controlResolutionConfig, pattern) + if err := os.WriteFile(filepath.Join(directory, "xops_config.yaml"), []byte(content), 0o600); err != nil { + t.Fatalf("write config failed: %v", err) + } + return expanded +} + +// TestControlCommandExpandsResolvedTargetTokens verifies that -O expands %h/%p/%r +// from the resolved node instead of the raw CLI fields. A mismatch silently +// targets a socket the master never created. +func TestControlCommandExpandsResolvedTargetTokens(t *testing.T) { + pattern := "~/.ssh/cm-%r@%h:%p-%C" + expected := writeControlTestConfig(t, pattern) + + var stderr bytes.Buffer + options := &SshOptions{ + Host: "web-01", + Target: config.ConnectionTarget{ + Selector: "web-01", + }, + control: SSHControlFlags{CtlCmd: "check"}, + } + + path, err := resolveControlSocketPath(context.Background(), options, &stderr) + if err != nil { + t.Fatalf("resolveControlSocketPath failed: %v", err) + } + if path != expected { + t.Fatalf("expected resolved control path %q, got %q", expected, path) + } + if strings.Contains(path, "web-01") { + t.Fatalf("control path %q still contains the node selector instead of the resolved host", path) + } +} + +// TestControlCommandDoesNotPersistNodes verifies that a read-only -O query never +// creates or rewrites configuration entries. +func TestControlCommandDoesNotPersistNodes(t *testing.T) { + directory := t.TempDir() + t.Setenv("XOPS_CONFIG_DIR", directory) + + configPath := filepath.Join(directory, "xops_config.yaml") + original := `schema_version: 2 +nodes: {} +hosts: {} +identities: {} +` + if err := os.WriteFile(configPath, []byte(original), 0o600); err != nil { + t.Fatalf("write config failed: %v", err) + } + + var stderr bytes.Buffer + options := &SshOptions{ + Host: "unconfigured-host", + Target: config.ConnectionTarget{ + Selector: "unconfigured-host", + }, + control: SSHControlFlags{CtlCmd: "check"}, + } + + if _, err := resolveControlSocketPath(context.Background(), options, &stderr); err == nil { + t.Fatal("expected a missing ControlPath error") + } + + after, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("read config failed: %v", err) + } + if string(after) != original { + t.Fatalf("-O mutated the configuration file:\n%s", after) + } +} + +// TestControlCommandAmbiguousSelectorFails verifies that selector ambiguity is +// reported as an error instead of silently resolving to an empty node. +func TestControlCommandAmbiguousSelectorFails(t *testing.T) { + directory := t.TempDir() + t.Setenv("XOPS_CONFIG_DIR", directory) + content := `schema_version: 2 +nodes: + node-a: + alias: [shared] + host_ref: host-a + identity_ref: id + sudo_mode: none + node-b: + alias: [shared] + host_ref: host-a + identity_ref: id + sudo_mode: none +hosts: + host-a: + address: 10.0.0.1 + port: 22 +identities: + id: + user: deploy + auth_type: auto +` + if err := os.WriteFile(filepath.Join(directory, "xops_config.yaml"), []byte(content), 0o600); err != nil { + t.Fatalf("write config failed: %v", err) + } + + var stderr bytes.Buffer + options := &SshOptions{ + Host: "shared", + Target: config.ConnectionTarget{Selector: "shared"}, + control: SSHControlFlags{CtlCmd: "check", ControlPath: "/tmp/explicit.sock"}, + } + + if _, err := resolveControlSocketPath(context.Background(), options, &stderr); err == nil { + t.Fatal("expected an ambiguous selector error") + } + if !strings.Contains(stderr.String(), "resolve target") { + t.Fatalf("expected a resolution error on stderr, got %q", stderr.String()) + } +} + +// TestControlCommandExplicitPathUsesResolvedTarget verifies that an explicit -S +// pattern still expands against the resolved node, not the raw selector. +func TestControlCommandExplicitPathUsesResolvedTarget(t *testing.T) { + directory := t.TempDir() + t.Setenv("XOPS_CONFIG_DIR", directory) + content := fmt.Sprintf(controlResolutionConfig, "~/.ssh/ignored-%C") + if err := os.WriteFile(filepath.Join(directory, "xops_config.yaml"), []byte(content), 0o600); err != nil { + t.Fatalf("write config failed: %v", err) + } + + explicitPattern := "/tmp/explicit-%r@%h:%p.sock" + expected, err := ssh.ExpandControlPath(explicitPattern, sshenv.Discover(), ssh.TargetContext{ + Host: "10.20.30.40", + Port: 2222, + User: "deploy", + }) + if err != nil { + t.Fatalf("ExpandControlPath failed: %v", err) + } + + var stderr bytes.Buffer + options := &SshOptions{ + Host: "web-01", + Target: config.ConnectionTarget{ + Selector: "web-01", + }, + control: SSHControlFlags{CtlCmd: "check", ControlPath: explicitPattern}, + } + + path, err := resolveControlSocketPath(context.Background(), options, &stderr) + if err != nil { + t.Fatalf("resolveControlSocketPath failed: %v", err) + } + if path != expected { + t.Fatalf("expected %q, got %q", expected, path) + } +} + +// TestControlCommandAdHocHostUsesOptionControlPath verifies that an ad-hoc host +// (no inventory node) still resolves a control path supplied through -o. Without +// it `-O check` on such a host wrongly reports "ControlPath must be specified". +func TestControlCommandAdHocHostUsesOptionControlPath(t *testing.T) { + directory := t.TempDir() + t.Setenv("XOPS_CONFIG_DIR", directory) + if err := os.WriteFile(filepath.Join(directory, "xops_config.yaml"), []byte("schema_version: 2\nnodes: {}\nhosts: {}\nidentities: {}\n"), 0o600); err != nil { + t.Fatalf("write config failed: %v", err) + } + + pattern := "/tmp/adhoc-%r@%h-%p.sock" + expected, err := ssh.ExpandControlPath(pattern, sshenv.Discover(), ssh.TargetContext{ + Host: "192.0.2.10", + Port: 2222, + User: "ops", + }) + if err != nil { + t.Fatalf("ExpandControlPath failed: %v", err) + } + + var stderr bytes.Buffer + options := &SshOptions{ + Host: "192.0.2.10", + Target: config.ConnectionTarget{ + Selector: "192.0.2.10", + User: "ops", + HasUser: true, + Port: 2222, + HasPort: true, + }, + control: SSHControlFlags{ + CtlCmd: "check", + Options: []string{"ControlPath=" + pattern}, + }, + } + + path, err := resolveControlSocketPath(context.Background(), options, &stderr) + if err != nil { + t.Fatalf("resolveControlSocketPath failed: %v (stderr %q)", err, stderr.String()) + } + if path != expected { + t.Fatalf("expected %q, got %q", expected, path) + } +} + +// TestControlCommandGlobalDefaultHonorsCLIOverrides verifies that a control path +// from the global default expands with the CLI -l/-p overrides, matching what the +// connector used when it created the master. +func TestControlCommandGlobalDefaultHonorsCLIOverrides(t *testing.T) { + pattern := "~/.ssh/cm-%r@%h-%p" + directory := t.TempDir() + t.Setenv("XOPS_CONFIG_DIR", directory) + content := fmt.Sprintf(`schema_version: 2 +control: + master: auto + path: "%s" +nodes: + web-01: + host_ref: host-web-01 + identity_ref: id-web-01 + sudo_mode: none +hosts: + host-web-01: + address: 10.20.30.40 + port: 2222 +identities: + id-web-01: + user: deploy + auth_type: auto +`, pattern) + if err := os.WriteFile(filepath.Join(directory, "xops_config.yaml"), []byte(content), 0o600); err != nil { + t.Fatalf("write config failed: %v", err) + } + + var stderr bytes.Buffer + options := &SshOptions{ + Host: "web-01", + User: "override", + Port: 2200, + Target: config.ConnectionTarget{ + Selector: "web-01", + User: "override", + HasUser: true, + Port: 2200, + HasPort: true, + }, + control: SSHControlFlags{CtlCmd: "check"}, + } + + path, err := resolveControlSocketPath(context.Background(), options, &stderr) + if err != nil { + t.Fatalf("resolveControlSocketPath failed: %v (stderr %q)", err, stderr.String()) + } + expected, err := ssh.ExpandControlPath(pattern, sshenv.Discover(), ssh.TargetContext{ + Host: "10.20.30.40", + Port: 2200, + User: "override", + }) + if err != nil { + t.Fatalf("ExpandControlPath failed: %v", err) + } + if path != expected { + t.Fatalf("expected %q, got %q", expected, path) + } +} + +// TestControlCommandAppliesOptionPortAndUser verifies that -o Port/User reach the +// control path expansion for an ad-hoc host even without a primary flag. +func TestControlCommandAppliesOptionPortAndUser(t *testing.T) { + directory := t.TempDir() + t.Setenv("XOPS_CONFIG_DIR", directory) + if err := os.WriteFile(filepath.Join(directory, "xops_config.yaml"), []byte("schema_version: 2\nnodes: {}\nhosts: {}\nidentities: {}\n"), 0o600); err != nil { + t.Fatalf("write config failed: %v", err) + } + + pattern := "/tmp/opt-%r-%p.sock" + var stderr bytes.Buffer + options := &SshOptions{ + Host: "192.0.2.20", + Target: config.ConnectionTarget{ + Selector: "192.0.2.20", + }, + control: SSHControlFlags{ + CtlCmd: "check", + Options: []string{"ControlPath=" + pattern, "Port=2222", "User=ops"}, + }, + } + + // The CLI applies -o target options during Complete, before any control query. + if err := ApplyGeneralOptions(options, options.control.Options, nil); err != nil { + t.Fatalf("ApplyGeneralOptions failed: %v", err) + } + options.Target.User, options.Target.HasUser = options.User, true + options.Target.Port, options.Target.HasPort = options.Port, true + + path, err := resolveControlSocketPath(context.Background(), options, &stderr) + if err != nil { + t.Fatalf("resolveControlSocketPath failed: %v (stderr %q)", err, stderr.String()) + } + if path != "/tmp/opt-ops-2222.sock" { + t.Fatalf("expected /tmp/opt-ops-2222.sock, got %q", path) + } +} + +// TestControlSocketPathAdHocHostUsesOpenSSHConfig verifies that an ad-hoc host +// with no inventory node still resolves ~/.ssh/config control options. The +// connector uses the same Tier 4 source when it creates the master, so skipping +// it here would compute a different socket than the one the master owns. +func TestControlSocketPathAdHocHostUsesOpenSSHConfig(t *testing.T) { + parser, err := config.NewOpenSSHParserFromReader(strings.NewReader(` +Host 192.0.2.77 + HostName 192.0.2.77 + ControlMaster auto + ControlPath ~/.ssh/adhoc-%r@%h-%p.sock + ControlPersist 10m +`)) + if err != nil { + t.Fatalf("parse openssh config failed: %v", err) + } + provider := config.NewProviderWithOpenSSHParser(&config.Configuration{}, parser) + + options := NewSshOptions() + options.Host = "192.0.2.77" + options.User = "ops" + options.Target = config.ConnectionTarget{ + Selector: "192.0.2.77", + User: "ops", + HasUser: true, + } + + path, err := controlSocketPath(options, provider, "") + if err != nil { + t.Fatalf("controlSocketPath failed: %v", err) + } + expected, err := ssh.ExpandControlPath("~/.ssh/adhoc-%r@%h-%p.sock", sshenv.Discover(), ssh.TargetContext{ + Host: "192.0.2.77", + Port: 22, + User: "ops", + OriginalHost: "192.0.2.77", + }) + if err != nil { + t.Fatalf("ExpandControlPath failed: %v", err) + } + if path != expected { + t.Fatalf("expected %q, got %q", expected, path) + } +} + +// TestControlSocketPathValidatesAdHocSessionOptions verifies that ad-hoc hosts go +// through the same merged-control validation as inventory nodes: an active master +// mode without any ControlPath must fail instead of silently disabling sharing. +func TestControlSocketPathValidatesAdHocSessionOptions(t *testing.T) { + provider := config.NewProviderWithOpenSSHParser(&config.Configuration{}, &config.OpenSSHParser{}) + + options := NewSshOptions() + options.Host = "192.0.2.99" + options.Target = config.ConnectionTarget{Selector: "192.0.2.99"} + options.control.Options = []string{"ControlMaster=yes"} + + if _, err := controlSocketPath(options, provider, ""); err == nil { + t.Fatal("expected ControlPath validation to reject an active master without a path") + } +} + +// TestControlCommandDisambiguatesMultiPortHostAndDefaultLocalUser verifies that: +// 1. -O resolves a multi-port/multi-user host when user@ or :port is supplied. +// 2. controlTarget defaults %r to the local user on ad-hoc hosts without -l. +func TestControlCommandDisambiguatesMultiPortHostAndDefaultLocalUser(t *testing.T) { + directory := t.TempDir() + t.Setenv("XOPS_CONFIG_DIR", directory) + content := `schema_version: 2 +control: + master: auto + path: "/tmp/cm-%r@%h-%p.sock" +nodes: + root@10.0.0.1:22: + host_ref: h-22 + identity_ref: id-root + sudo_mode: none + ops@10.0.0.1:2222: + host_ref: h-2222 + identity_ref: id-ops + sudo_mode: none +hosts: + h-22: + address: 10.0.0.1 + port: 22 + h-2222: + address: 10.0.0.1 + port: 2222 +identities: + id-root: + user: root + auth_type: auto + id-ops: + user: ops + auth_type: auto +` + if err := os.WriteFile(filepath.Join(directory, "xops_config.yaml"), []byte(content), 0o600); err != nil { + t.Fatalf("write config failed: %v", err) + } + + var stderr bytes.Buffer + opts := NewSshOptions() + opts.args = []string{"ops@10.0.0.1:2222"} + opts.control = SSHControlFlags{CtlCmd: "check"} + if err := opts.parseArgs(); err != nil { + t.Fatalf("parseArgs failed: %v", err) + } + + path, err := resolveControlSocketPath(context.Background(), opts, &stderr) + if err != nil { + t.Fatalf("resolveControlSocketPath failed on disambiguated host: %v (stderr: %q)", err, stderr.String()) + } + if path != "/tmp/cm-ops@10.0.0.1-2222.sock" { + t.Fatalf("expected /tmp/cm-ops@10.0.0.1-2222.sock, got %q", path) + } + + // Ad-hoc host without explicit -l must default %r to sshenv.Discover().LocalUser + adhocOpts := NewSshOptions() + adhocOpts.args = []string{"192.0.2.55"} + adhocOpts.control = SSHControlFlags{CtlCmd: "check", ControlPath: "/tmp/adhoc-%r@%h:%p.sock"} + if err := adhocOpts.parseArgs(); err != nil { + t.Fatalf("parseArgs failed: %v", err) + } + adhocPath, err := resolveControlSocketPath(context.Background(), adhocOpts, &stderr) + if err != nil { + t.Fatalf("resolveControlSocketPath failed on ad-hoc host: %v", err) + } + localUser := sshenv.Discover().LocalUser + wantAdhoc := fmt.Sprintf("/tmp/adhoc-%s@192.0.2.55:22.sock", localUser) + if adhocPath != wantAdhoc { + t.Fatalf("expected %q, got %q", wantAdhoc, adhocPath) + } +} + +// TestControlCommandSelectsDerivedUserNodeOverBaseNode verifies that when the +// configuration contains both a base node keyed by address ("10.0.0.5") and a +// derived user node ("test@10.0.0.5:2222"), `xops ssh -O check test@10.0.0.5` +// resolves the derived node's control configuration rather than the base node's. +func TestControlCommandSelectsDerivedUserNodeOverBaseNode(t *testing.T) { + directory := t.TempDir() + t.Setenv("XOPS_CONFIG_DIR", directory) + content := `schema_version: 2 +nodes: + "10.0.0.5": + host_ref: h-base + identity_ref: id-root + sudo_mode: none + control: + master: auto + path: "/tmp/base-%n.sock" + "test@10.0.0.5:2222": + host_ref: h-base + identity_ref: id-test + sudo_mode: none + control: + master: auto + path: "/tmp/derived-%r@%h-%p.sock" +hosts: + h-base: + address: 10.0.0.5 + port: 2222 +identities: + id-root: + user: root + auth_type: auto + id-test: + user: test + auth_type: auto +` + if err := os.WriteFile(filepath.Join(directory, "xops_config.yaml"), []byte(content), 0o600); err != nil { + t.Fatalf("write config failed: %v", err) + } + + var stderr bytes.Buffer + opts := NewSshOptions() + opts.args = []string{"test@10.0.0.5"} + opts.control = SSHControlFlags{CtlCmd: "check"} + if err := opts.parseArgs(); err != nil { + t.Fatalf("parseArgs failed: %v", err) + } + + path, err := resolveControlSocketPath(context.Background(), opts, &stderr) + if err != nil { + t.Fatalf("resolveControlSocketPath failed: %v (stderr: %q)", err, stderr.String()) + } + if path != "/tmp/derived-test@10.0.0.5-2222.sock" { + t.Fatalf("expected /tmp/derived-test@10.0.0.5-2222.sock, got %q", path) + } +} diff --git a/cmd/ssh_control_test.go b/cmd/ssh_control_test.go new file mode 100644 index 00000000..00fb0b77 --- /dev/null +++ b/cmd/ssh_control_test.go @@ -0,0 +1,939 @@ +package cmd + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/wentf9/xops-cli/core/ssh" + "github.com/wentf9/xops-cli/core/testutil/sshfixture" + "github.com/wentf9/xops-cli/pkg/config" + "github.com/wentf9/xops-cli/pkg/models" + cryptoSSH "golang.org/x/crypto/ssh" +) + +func createTestMuxMasterServer(t *testing.T) (string, net.Listener) { + t.Helper() + tmpDir, err := os.MkdirTemp("", "ctrl-test-*") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + sockPath := filepath.Join(tmpDir, "master.sock") + ln, err := net.Listen("unix", sockPath) + if err != nil { + _ = os.RemoveAll(tmpDir) + t.Fatalf("Listen unix failed: %v", err) + } + t.Cleanup(func() { + _ = ln.Close() + _ = os.RemoveAll(tmpDir) + }) + return sockPath, ln +} + +func handleTestMasterConnection(conn net.Conn, pid uint32) { + defer func() { _ = conn.Close() }() + + // Handshake + pktType, _, err := ssh.ReadMuxPacket(conn) + if err != nil || pktType != ssh.MUX_MSG_HELLO { + return + } + exts := []ssh.MuxExtension{{Name: "info", Value: "1"}} + _ = ssh.WriteMuxPacket(conn, ssh.MUX_MSG_HELLO, ssh.EncodeMuxHello(ssh.MuxProtoVersion, exts)) + + for { + msgType, payload, err := ssh.ReadMuxPacket(conn) + if err != nil { + return + } + switch msgType { + case ssh.MUX_C_ALIVE_CHECK: + reqID, _ := ssh.DecodeMuxRequestID(payload) + _ = ssh.WriteMuxPacket(conn, ssh.MUX_S_ALIVE, ssh.EncodeMuxAlive(reqID, pid)) + case ssh.MUX_C_TERMINATE: + reqID, _ := ssh.DecodeMuxRequestID(payload) + _ = ssh.WriteMuxPacket(conn, ssh.MUX_S_OK, ssh.EncodeMuxClientRequestID(reqID)) + return + case ssh.MUX_C_STOP_LISTENING: + reqID, _ := ssh.DecodeMuxRequestID(payload) + _ = ssh.WriteMuxPacket(conn, ssh.MUX_S_OK, ssh.EncodeMuxClientRequestID(reqID)) + case ssh.MUX_C_OPEN_FWD: + req, _ := ssh.DecodeMuxFwdRequest(payload) + _ = ssh.WriteMuxPacket(conn, ssh.MUX_S_OK, ssh.EncodeMuxClientRequestID(req.RequestID)) + case ssh.MUX_C_CLOSE_FWD: + req, _ := ssh.DecodeMuxFwdRequest(payload) + _ = ssh.WriteMuxPacket(conn, ssh.MUX_S_OK, ssh.EncodeMuxClientRequestID(req.RequestID)) + case ssh.MUX_C_PROXY: + reqID, _ := ssh.DecodeMuxRequestID(payload) + _ = ssh.WriteMuxPacket(conn, ssh.MUX_S_PROXY, ssh.EncodeMuxClientRequestID(reqID)) + // Echo server for proxy test + buf := make([]byte, 1024) + for { + n, err := conn.Read(buf) + if n > 0 { + _, _ = conn.Write(buf[:n]) + } + if err != nil { + return + } + } + case ssh.MUX_C_EXT_INFO: + reqID, name, _ := ssh.DecodeMuxExtInfo(payload) + switch name { + case "connection": + _ = ssh.WriteMuxPacket(conn, ssh.MUX_S_EXT_INFO, ssh.EncodeMuxExtInfoResponse(reqID, 1, "Master info test connection\n")) + case "channels": + _ = ssh.WriteMuxPacket(conn, ssh.MUX_S_EXT_INFO, ssh.EncodeMuxExtInfoResponse(reqID, 1, "Active Forwardings:\n- 1:127.0.0.1:8080\n")) + default: + _ = ssh.WriteMuxPacket(conn, ssh.MUX_S_FAILURE, ssh.EncodeMuxError(reqID, "unknown info")) + } + } + } +} + +func TestControl_Check_Alive(t *testing.T) { + sockPath, ln := createTestMuxMasterServer(t) + expectedPID := uint32(12345) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go handleTestMasterConnection(conn, expectedPID) + } + }() + + var stdout, stderr bytes.Buffer + streams := controlStreams{ + Stdin: strings.NewReader(""), + Stdout: &stdout, + Stderr: &stderr, + } + + opts := &SshOptions{ + control: SSHControlFlags{ + CtlCmd: "check", + ControlPath: sockPath, + }, + } + + err := executeControlCommandWithStreams(context.Background(), opts, streams) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expectedStderr := fmt.Sprintf("Master running (pid=%d)\n", expectedPID) + if stderr.String() != expectedStderr { + t.Fatalf("expected stderr %q, got %q", expectedStderr, stderr.String()) + } +} + +func TestControl_Check_NoSuchFile(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "ctrl-missing-*") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + nonExistentSock := filepath.Join(tmpDir, "non_existent.sock") + + var stdout, stderr bytes.Buffer + streams := controlStreams{ + Stdin: strings.NewReader(""), + Stdout: &stdout, + Stderr: &stderr, + } + + opts := &SshOptions{ + control: SSHControlFlags{ + CtlCmd: "check", + ControlPath: nonExistentSock, + }, + } + + err = executeControlCommandWithStreams(context.Background(), opts, streams) + if err == nil { + t.Fatal("expected error, got nil") + } + + code, ok := ExitCodeFromError(err) + if !ok || code != 255 { + t.Fatalf("expected exit code 255, got code=%d (ok=%v)", code, ok) + } + + expectedPrefix := fmt.Sprintf("Control socket connect(%s): No such file or directory", nonExistentSock) + if !strings.Contains(stderr.String(), expectedPrefix) { + t.Fatalf("expected stderr to contain %q, got %q", expectedPrefix, stderr.String()) + } +} + +func TestControl_Check_ConnectionRefused(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "ctrl-refused-*") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + staleSock := filepath.Join(tmpDir, "stale.sock") + fd, err := syscall.Socket(syscall.AF_UNIX, syscall.SOCK_STREAM, 0) + if err != nil { + t.Fatalf("syscall.Socket failed: %v", err) + } + sa := &syscall.SockaddrUnix{Name: staleSock} + if err := syscall.Bind(fd, sa); err != nil { + _ = syscall.Close(fd) + t.Fatalf("syscall.Bind failed: %v", err) + } + _ = syscall.Close(fd) // Close without unlink creates stale socket file + + var stdout, stderr bytes.Buffer + streams := controlStreams{ + Stdin: strings.NewReader(""), + Stdout: &stdout, + Stderr: &stderr, + } + + opts := &SshOptions{ + control: SSHControlFlags{ + CtlCmd: "check", + ControlPath: staleSock, + }, + } + + err = executeControlCommandWithStreams(context.Background(), opts, streams) + if err == nil { + t.Fatal("expected error, got nil") + } + + code, ok := ExitCodeFromError(err) + if !ok || code != 255 { + t.Fatalf("expected exit code 255, got code=%d (ok=%v)", code, ok) + } + + expectedPrefix := fmt.Sprintf("Control socket connect(%s): Connection refused", staleSock) + if !strings.Contains(stderr.String(), expectedPrefix) { + t.Fatalf("expected stderr to contain %q, got %q", expectedPrefix, stderr.String()) + } +} + +func TestControl_Exit_Success(t *testing.T) { + sockPath, ln := createTestMuxMasterServer(t) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go handleTestMasterConnection(conn, 12345) + } + }() + + var stdout, stderr bytes.Buffer + streams := controlStreams{ + Stdin: strings.NewReader(""), + Stdout: &stdout, + Stderr: &stderr, + } + + opts := &SshOptions{ + control: SSHControlFlags{ + CtlCmd: "exit", + ControlPath: sockPath, + }, + } + + err := executeControlCommandWithStreams(context.Background(), opts, streams) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expectedStderr := "Exit request sent.\n" + if stderr.String() != expectedStderr { + t.Fatalf("expected stderr %q, got %q", expectedStderr, stderr.String()) + } +} + +func TestControl_Stop_Success(t *testing.T) { + sockPath, ln := createTestMuxMasterServer(t) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go handleTestMasterConnection(conn, 12345) + } + }() + + var stdout, stderr bytes.Buffer + streams := controlStreams{ + Stdin: strings.NewReader(""), + Stdout: &stdout, + Stderr: &stderr, + } + + opts := &SshOptions{ + control: SSHControlFlags{ + CtlCmd: "stop", + ControlPath: sockPath, + }, + } + + err := executeControlCommandWithStreams(context.Background(), opts, streams) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expectedStderr := "Stop listening request sent.\n" + if stderr.String() != expectedStderr { + t.Fatalf("expected stderr %q, got %q", expectedStderr, stderr.String()) + } +} + +func TestControl_Forward_LocalRemoteDynamic(t *testing.T) { + sockPath, ln := createTestMuxMasterServer(t) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go handleTestMasterConnection(conn, 12345) + } + }() + + var stdout, stderr bytes.Buffer + streams := controlStreams{ + Stdin: strings.NewReader(""), + Stdout: &stdout, + Stderr: &stderr, + } + + opts := &SshOptions{ + LocalForwards: []string{"8080:localhost:80"}, + RemoteForwards: []string{"9090:localhost:90"}, + DynamicForward: "1080", + control: SSHControlFlags{ + CtlCmd: "forward", + ControlPath: sockPath, + }, + } + + err := executeControlCommandWithStreams(context.Background(), opts, streams) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expectedStderr := "Forwarding request sent.\n" + if stderr.String() != expectedStderr { + t.Fatalf("expected stderr %q, got %q", expectedStderr, stderr.String()) + } +} + +func TestControl_Cancel_Success(t *testing.T) { + sockPath, ln := createTestMuxMasterServer(t) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go handleTestMasterConnection(conn, 12345) + } + }() + + var stdout, stderr bytes.Buffer + streams := controlStreams{ + Stdin: strings.NewReader(""), + Stdout: &stdout, + Stderr: &stderr, + } + + opts := &SshOptions{ + LocalForwards: []string{"8080:localhost:80"}, + control: SSHControlFlags{ + CtlCmd: "cancel", + ControlPath: sockPath, + }, + } + + err := executeControlCommandWithStreams(context.Background(), opts, streams) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expectedStderr := "Forwarding cancel request sent.\n" + if stderr.String() != expectedStderr { + t.Fatalf("expected stderr %q, got %q", expectedStderr, stderr.String()) + } +} + +func TestControl_Proxy_BidirectionalPipe(t *testing.T) { + sockPath, ln := createTestMuxMasterServer(t) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go handleTestMasterConnection(conn, 12345) + } + }() + + input := "echo hello from test proxy input\n" + var stdout, stderr bytes.Buffer + streams := controlStreams{ + Stdin: strings.NewReader(input), + Stdout: &stdout, + Stderr: &stderr, + } + + opts := &SshOptions{ + control: SSHControlFlags{ + CtlCmd: "proxy-stream", + ControlPath: sockPath, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + err := executeControlCommandWithStreams(ctx, opts, streams) + if err != nil && !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("unexpected error: %v", err) + } + + if stdout.String() != input { + t.Fatalf("expected echoed output %q, got %q", input, stdout.String()) + } +} + +func TestControl_Info_ConnectionAndChannels(t *testing.T) { + sockPath, ln := createTestMuxMasterServer(t) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go handleTestMasterConnection(conn, 12345) + } + }() + + // 1. conninfo + { + var stdout, stderr bytes.Buffer + streams := controlStreams{ + Stdin: strings.NewReader(""), + Stdout: &stdout, + Stderr: &stderr, + } + opts := &SshOptions{ + control: SSHControlFlags{ + CtlCmd: "conninfo", + ControlPath: sockPath, + }, + } + if err := executeControlCommandWithStreams(context.Background(), opts, streams); err != nil { + t.Fatalf("conninfo error: %v", err) + } + if !strings.Contains(stdout.String(), "Master info test connection") { + t.Fatalf("unexpected conninfo output: %q", stdout.String()) + } + } + + // 2. channels + { + var stdout, stderr bytes.Buffer + streams := controlStreams{ + Stdin: strings.NewReader(""), + Stdout: &stdout, + Stderr: &stderr, + } + opts := &SshOptions{ + control: SSHControlFlags{ + CtlCmd: "channels", + ControlPath: sockPath, + }, + } + if err := executeControlCommandWithStreams(context.Background(), opts, streams); err != nil { + t.Fatalf("channels error: %v", err) + } + if !strings.Contains(stdout.String(), "Active Forwardings:") { + t.Fatalf("unexpected channels output: %q", stdout.String()) + } + } +} + +func TestControl_MissingControlPath(t *testing.T) { + var stdout, stderr bytes.Buffer + streams := controlStreams{ + Stdin: strings.NewReader(""), + Stdout: &stdout, + Stderr: &stderr, + } + + // Host not in config, no -S specified + opts := &SshOptions{ + Host: "non_existent_unconfigured_host_12345", + control: SSHControlFlags{ + CtlCmd: "check", + }, + } + + err := executeControlCommandWithStreams(context.Background(), opts, streams) + if err == nil { + t.Fatal("expected error on missing ControlPath, got nil") + } + + code, ok := ExitCodeFromError(err) + if !ok || code != 255 { + t.Fatalf("expected exit code 255, got %d", code) + } + + if !strings.Contains(stderr.String(), "ControlPath must be specified when using -O") { + t.Fatalf("expected missing ControlPath error, got stderr: %q", stderr.String()) + } +} + +// TestControlProxyRejectsForeignMaster verifies that -O proxy performs the same +// peer-UID validation as every other control path instead of hand-rolling the +// handshake and trusting whatever listens on the path. +func TestControlProxyRejectsForeignMaster(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("peer credentials are unavailable on Windows") + } + + sockPath, ln := createTestMuxMasterServer(t) + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go handleTestMasterConnection(conn, 1) + } + }() + + // A raw connection proves the endpoint answers, so a failure below is the + // client's own validation rather than an unreachable socket. + raw, err := net.Dial("unix", sockPath) + if err != nil { + t.Fatalf("dial failed: %v", err) + } + _ = raw.Close() + + stream, err := ssh.DialMuxProxyStream(context.Background(), sockPath, ssh.WithExpectedPeerUID(uint32(os.Geteuid())+1)) + if err == nil { + _ = stream.Close() + t.Fatal("expected the proxy stream to be rejected for a foreign peer UID") + } + if !errors.Is(err, ssh.ErrUnauthorizedPeer) { + t.Fatalf("expected ErrUnauthorizedPeer, got %v", err) + } + + // The same endpoint is accepted when the expectation matches the owner. + stream, err = ssh.DialMuxProxyStream(context.Background(), sockPath) + if err != nil { + t.Fatalf("DialMuxProxyStream with the local UID failed: %v", err) + } + if err := stream.Close(); err != nil { + t.Fatalf("close proxy stream failed: %v", err) + } +} + +func TestControl_ForwardAndCancel_ErrorPrefixNotDuplicated(t *testing.T) { + sockPath, ln := createTestMuxMasterServer(t) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer func() { _ = c.Close() }() + pktType, _, err := ssh.ReadMuxPacket(c) + if err != nil || pktType != ssh.MUX_MSG_HELLO { + return + } + _ = ssh.WriteMuxPacket(c, ssh.MUX_MSG_HELLO, ssh.EncodeMuxHello(ssh.MuxProtoVersion, nil)) + msgType, payload, err := ssh.ReadMuxPacket(c) + if err != nil { + return + } + req, _ := ssh.DecodeMuxFwdRequest(payload) + switch msgType { + case ssh.MUX_C_OPEN_FWD: + _ = ssh.WriteMuxPacket(c, ssh.MUX_S_FAILURE, ssh.EncodeMuxError(req.RequestID, "forwarding already exists")) + case ssh.MUX_C_CLOSE_FWD: + _ = ssh.WriteMuxPacket(c, ssh.MUX_S_FAILURE, ssh.EncodeMuxError(req.RequestID, "forwarding not found")) + } + }(conn) + } + }() + + // 1. Forward error must not duplicate "forwarding request failed: " + { + var stdout, stderr bytes.Buffer + streams := controlStreams{Stdin: strings.NewReader(""), Stdout: &stdout, Stderr: &stderr} + opts := &SshOptions{ + LocalForwards: []string{"8080:localhost:80"}, + control: SSHControlFlags{CtlCmd: "forward", ControlPath: sockPath}, + } + err := executeControlCommandWithStreams(context.Background(), opts, streams) + if err == nil { + t.Fatal("expected forward error, got nil") + } + if got := stderr.String(); strings.Count(got, "forwarding request failed:") != 1 { + t.Fatalf("expected single 'forwarding request failed:' prefix, got: %q", got) + } + } + + // 2. Cancel error must not duplicate "cancel forward failed: " + { + var stdout, stderr bytes.Buffer + streams := controlStreams{Stdin: strings.NewReader(""), Stdout: &stdout, Stderr: &stderr} + opts := &SshOptions{ + LocalForwards: []string{"8080:localhost:80"}, + control: SSHControlFlags{CtlCmd: "cancel", ControlPath: sockPath}, + } + err := executeControlCommandWithStreams(context.Background(), opts, streams) + if err == nil { + t.Fatal("expected cancel error, got nil") + } + if got := stderr.String(); strings.Count(got, "cancel forward failed:") != 1 { + t.Fatalf("expected single 'cancel forward failed:' prefix, got: %q", got) + } + } +} + +func TestControl_Proxy_EmptyStdinReceivesInitialServerPacket(t *testing.T) { + sockPath, ln := createTestMuxMasterServer(t) + + const serverGreeting = "SSH-PROXY-INITIAL-PACKET\n" + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer func() { _ = c.Close() }() + pktType, _, err := ssh.ReadMuxPacket(c) + if err != nil || pktType != ssh.MUX_MSG_HELLO { + return + } + _ = ssh.WriteMuxPacket(c, ssh.MUX_MSG_HELLO, ssh.EncodeMuxHello(ssh.MuxProtoVersion, nil)) + msgType, payload, err := ssh.ReadMuxPacket(c) + if err != nil || msgType != ssh.MUX_C_PROXY { + return + } + reqID, _ := ssh.DecodeMuxRequestID(payload) + _ = ssh.WriteMuxPacket(c, ssh.MUX_S_PROXY, ssh.EncodeMuxClientRequestID(reqID)) + // Simulate master writing initial packet and then closing when done + _, _ = c.Write([]byte(serverGreeting)) + }(conn) + } + }() + + var stdout, stderr bytes.Buffer + streams := controlStreams{ + Stdin: strings.NewReader(""), // immediate EOF like `< /dev/null` + Stdout: &stdout, + Stderr: &stderr, + } + opts := &SshOptions{ + control: SSHControlFlags{ + CtlCmd: "proxy-stream", + ControlPath: sockPath, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + if err := executeControlCommandWithStreams(ctx, opts, streams); err != nil { + t.Fatalf("executeControlCommandWithStreams(proxy-stream) failed: %v", err) + } + if stdout.String() != serverGreeting { + t.Fatalf("expected initial server packet %q on stdout, got %q", serverGreeting, stdout.String()) + } +} + +func runControlProxyTestCommand(ctx context.Context, t *testing.T, args []string) (string, string, error) { + t.Helper() + return runControlProxyTestCommandWithStdin(ctx, t, args, strings.NewReader(""), false) +} + +func runControlProxyTestCommandWithStdin(ctx context.Context, t *testing.T, args []string, stdin io.Reader, stdinScript bool) (string, string, error) { + t.Helper() + opts := NewSshOptions() + cmd := newCmdSshWithOptions(opts) + if err := cmd.Flags().Parse(args); err != nil { + t.Fatalf("Flags().Parse failed: %v", err) + } + if err := opts.Complete(cmd, cmd.Flags().Args()); err != nil { + t.Fatalf("Complete failed: %v", err) + } + opts.stdinScript = stdinScript + if err := opts.Validate(); err != nil { + return "", "", err + } + var stdout, stderr bytes.Buffer + err := executeControlCommandWithStreams(ctx, opts, controlStreams{ + Stdin: stdin, + Stdout: &stdout, + Stderr: &stderr, + }) + return stdout.String(), stderr.String(), err +} + +func setupControlProxySessionFixture(ctx context.Context, t *testing.T) (string, string) { + t.Helper() + fixture, err := sshfixture.NewWithOptions(ctx, sshfixture.Options{ + ExecHandler: func(cmd string, ch cryptoSSH.Channel) { + switch strings.TrimSpace(cmd) { + case "exit 42": + _, _ = io.WriteString(ch.Stderr(), "remote-fail-42\n") + _, _ = ch.SendRequest("exit-status", false, cryptoSSH.Marshal(struct{ Status uint32 }{42})) + default: + _, _ = io.WriteString(ch, "P1_PROXY_OK:"+cmd+"\n") + _, _ = ch.SendRequest("exit-status", false, cryptoSSH.Marshal(struct{ Status uint32 }{0})) + } + }, + ShellHandler: func(ch cryptoSSH.Channel, requests <-chan *cryptoSSH.Request) { + go cryptoSSH.DiscardRequests(requests) + data, _ := io.ReadAll(ch) + _, _ = io.WriteString(ch, "SHELL_PIPED:"+strings.TrimSpace(string(data))+"\n") + _, _ = ch.SendRequest("exit-status", false, cryptoSSH.Marshal(struct{ Status uint32 }{0})) + _ = ch.Close() + }, + }) + if err != nil { + t.Fatalf("sshfixture.NewWithOptions failed: %v", err) + } + t.Cleanup(func() { _ = fixture.Close() }) + + host, portText, err := net.SplitHostPort(fixture.Address) + if err != nil { + t.Fatalf("SplitHostPort failed: %v", err) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatalf("Atoi failed: %v", err) + } + + rawClient, err := cryptoSSH.Dial("tcp", fixture.Address, &cryptoSSH.ClientConfig{ + User: "fixture", + Auth: []cryptoSSH.AuthMethod{cryptoSSH.Password(sshfixture.Password)}, + HostKeyCallback: cryptoSSH.FixedHostKey(fixture.HostKey), + Timeout: 5 * time.Second, + }) + if err != nil { + t.Fatalf("cryptoSSH.Dial failed: %v", err) + } + t.Cleanup(func() { _ = rawClient.Close() }) + + tmpDir, err := os.MkdirTemp("", "ctrl-proxy-session-*") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(tmpDir) }) + sockPath := filepath.Join(tmpDir, "master.sock") + + muxServer, err := ssh.NewMuxServer(ssh.MuxServerOptions{ + ControlPath: sockPath, + SSHClient: rawClient, + Target: ssh.TargetContext{Host: host, Port: port, User: "fixture"}, + PersistEnabled: true, + PersistIndefinite: true, + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + go func() { _ = muxServer.Serve() }() + t.Cleanup(func() { _ = muxServer.Shutdown() }) + + return sockPath, fmt.Sprintf("fixture@%s:%d", host, port) +} + +func TestControl_Proxy_ExecutesSessionAndPropagatesExitCode(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + sockPath, targetAddr := setupControlProxySessionFixture(ctx, t) + + // 1. xops ssh -O proxy -S -- 'echo P1_PROXY_OK' + stdout, stderr, err := runControlProxyTestCommand(ctx, t, []string{"-O", "proxy", "-S", sockPath, targetAddr, "--", "echo P1_PROXY_OK"}) + if err != nil || strings.TrimSpace(stdout) != "P1_PROXY_OK:echo P1_PROXY_OK" { + t.Fatalf("unexpected proxy result: err=%v stdout=%q stderr=%q", err, stdout, stderr) + } + + // 2. Positional with piped script (stdinScript=true) must NOT treat as a command + stdout, stderr, err = runControlProxyTestCommandWithStdin(ctx, t, []string{"-O", "proxy", "-S", sockPath, targetAddr}, strings.NewReader("echo piped1\n"), true) + if err != nil || strings.TrimSpace(stdout) != "SHELL_PIPED:echo piped1" { + t.Fatalf("unexpected piped shell result: err=%v stdout=%q stderr=%q", err, stdout, stderr) + } + + // 3. Non-TTY stdin (/dev/null, stdinScript=false) without command fails immediately with terminal requirement + devNull, err := os.Open(os.DevNull) + if err != nil { + t.Fatalf("open DevNull failed: %v", err) + } + defer func() { _ = devNull.Close() }() + _, stderr, err = runControlProxyTestCommandWithStdin(ctx, t, []string{"-O", "proxy", "-S", sockPath, targetAddr}, devNull, false) + if code, ok := ExitCodeFromError(err); !ok || code != 255 || !strings.Contains(stderr, "interactive stdin requires a local terminal") { + t.Fatalf("expected non-TTY interactive failure (255), got code=%d ok=%v err=%v stderr=%q", code, ok, err, stderr) + } + + // 4. Missing target host for -O proxy is rejected in Validate() with exit code 255 + _, _, err = runControlProxyTestCommand(ctx, t, []string{"-O", "proxy", "-S", sockPath}) + if code, ok := ExitCodeFromError(err); !ok || code != 255 { + t.Fatalf("expected missing target host to fail with 255, got code=%d ok=%v err=%v", code, ok, err) + } + + // 5. Non-zero remote exit code propagation (42) + _, stderr, err = runControlProxyTestCommand(ctx, t, []string{"-O", "proxy", "-S", sockPath, targetAddr, "--", "exit 42"}) + if code, ok := ExitCodeFromError(err); !ok || code != 42 || !ErrorAlreadyReported(err) || !strings.Contains(stderr, "remote-fail-42") { + t.Fatalf("unexpected exit 42 result: code=%d ok=%v err=%v stderr=%q", code, ok, err, stderr) + } + + // 6. Target mismatch fails closed with exit code 255 + _, stderr, err = runControlProxyTestCommand(ctx, t, []string{"-O", "proxy", "-S", sockPath, "fixture@wrong.example.com:22", "--", "echo should_not_run"}) + if code, ok := ExitCodeFromError(err); !ok || code != 255 || !strings.Contains(stderr, "control socket target mismatch") { + t.Fatalf("unexpected target mismatch result: code=%d ok=%v err=%v stderr=%q", code, ok, err, stderr) + } +} + +func TestProgrammaticGeneralOptions_AppliedWithoutComplete(t *testing.T) { + // 1. Programmatic SshOptions (without Complete) applies -o Port/User/IdentityFile/ProxyJump, + // while explicit struct fields take precedence over -o options. + progOpts := &SshOptions{ + args: []string{"10.11.12.13"}, + control: SSHControlFlags{ + Options: []string{ + "Port=2202", + "User=optuser", + "IdentityFile=/tmp/opt_id_ed25519", + "ProxyJump=jump-opt", + }, + }, + } + if err := progOpts.ensureArgsParsed(); err != nil { + t.Fatalf("progOpts.ensureArgsParsed() failed: %v", err) + } + if progOpts.Port != 2202 || progOpts.User != "optuser" || progOpts.IdentityFile != "/tmp/opt_id_ed25519" || progOpts.JumpHost != "jump-opt" { + t.Fatalf("unexpected programmatic option fields: Port=%d User=%q IdentityFile=%q JumpHost=%q", + progOpts.Port, progOpts.User, progOpts.IdentityFile, progOpts.JumpHost) + } + if progOpts.Target.Port != 2202 || !progOpts.Target.HasPort || progOpts.Target.User != "optuser" || !progOpts.Target.HasUser { + t.Fatalf("expected Target to reflect -o Port and User, got %+v", progOpts.Target) + } + + // Explicit struct fields take precedence over -o when flags == nil + explicitWinsOpts := &SshOptions{ + args: []string{"10.11.12.13"}, + Port: 2299, + User: "explicituser", + IdentityFile: "/tmp/explicit_key", + JumpHost: "explicit-jump", + control: SSHControlFlags{ + Options: []string{ + "Port=2202", + "User=optuser", + "IdentityFile=/tmp/opt_id_ed25519", + "ProxyJump=jump-opt", + }, + }, + } + if err := explicitWinsOpts.ensureArgsParsed(); err != nil { + t.Fatalf("explicitWinsOpts.ensureArgsParsed() failed: %v", err) + } + if explicitWinsOpts.Port != 2299 || explicitWinsOpts.User != "explicituser" || + explicitWinsOpts.IdentityFile != "/tmp/explicit_key" || explicitWinsOpts.JumpHost != "explicit-jump" { + t.Fatalf("expected explicit struct fields to win over -o, got Port=%d User=%q IdentityFile=%q JumpHost=%q", + explicitWinsOpts.Port, explicitWinsOpts.User, explicitWinsOpts.IdentityFile, explicitWinsOpts.JumpHost) + } +} + +func TestBuildControlProxyClientConfig_NormalizesAndPropagatesJump(t *testing.T) { + scpOpts := &ScpOptions{ + SshOptions: SshOptions{ + control: SSHControlFlags{ + Options: []string{"Port=2244", "User=scpopt", "ProxyJump=scp-jump"}, + }, + }, + } + scpTarget, err := scpOpts.resolveTargetForPath(PathInfo{Host: "10.22.33.44", Path: "/tmp/a", IsRemote: true}) + if err != nil { + t.Fatalf("scpOpts.resolveTargetForPath failed: %v", err) + } + if scpTarget.Port != 2244 || !scpTarget.HasPort || scpTarget.User != "scpopt" || !scpTarget.HasUser || scpTarget.ProxyJump != "scp-jump" || !scpTarget.HasProxyJump { + t.Fatalf("unexpected scpTarget from programmatic -o: %+v", scpTarget) + } + cfg := config.NewProviderWithoutOpenSSH(nil).Snapshot() + cfg.Hosts.Set("h-zero", models.Host{Address: "10.55.66.77", Port: 0}) + cfg.Identities.Set("id-empty", models.Identity{User: "", AuthType: "password", Password: "pw"}) + cfg.Nodes.Set("node-zero", models.Node{HostRef: "h-zero", IdentityRef: "id-empty"}) + provider := config.NewProviderWithoutOpenSSH(cfg) + + proxyOpts := &SshOptions{ + JumpHost: "10.55.66.1:22", + } + targetCtx := ssh.TargetContext{ + Host: "10.55.66.77", + Port: 22, + User: "fallbackuser", + JumpHost: "openssh:10.55.66.1:22", + } + clientCfg, _ := buildControlProxyClientConfig(targetCtx, provider, "node-zero", proxyOpts) + if clientCfg == nil { + t.Fatal("expected non-nil clientCfg from buildControlProxyClientConfig") + } + if clientCfg.Port != 22 { + t.Fatalf("expected clientCfg.Port == 22, got %d", clientCfg.Port) + } + if clientCfg.User == "" { + t.Fatalf("expected non-empty clientCfg.User, got %q", clientCfg.User) + } + if clientCfg.ProxyJump != "openssh:10.55.66.1:22" { + t.Fatalf("expected clientCfg.ProxyJump == 'openssh:10.55.66.1:22', got %q", clientCfg.ProxyJump) + } + + // Verify controlSocketPath applies -o User/Port even when called directly without Complete() + directSockOpts := &SshOptions{ + Host: "10.55.66.77", + control: SSHControlFlags{ + ControlPath: "/tmp/mux-%r@%h:%p", + Options: []string{"User=directopt", "Port=2288"}, + }, + } + sockPath, err := controlSocketPath(directSockOpts, provider, "") + if err != nil { + t.Fatalf("controlSocketPath failed: %v", err) + } + if sockPath != "/tmp/mux-directopt@10.55.66.77:2288" { + t.Fatalf("expected expanded socket path '/tmp/mux-directopt@10.55.66.77:2288', got %q", sockPath) + } +} diff --git a/cmd/ssh_interaction.go b/cmd/ssh_interaction.go index f24196df..0daeb03c 100644 --- a/cmd/ssh_interaction.go +++ b/cmd/ssh_interaction.go @@ -95,8 +95,15 @@ func (h *cliInteractionHandler) rememberOptions(policy string, cfg *config.Confi return enabled, opts } -func newCLIConnectorWithAdapterOptions(provider config.ConfigProvider, adpOpts []adapter.Option, opts ...ssh.Option) *ssh.Connector { - opts = append([]ssh.Option{ssh.WithInteractionHandler(newCLIInteractionHandler())}, opts...) +// newCLIConnectorWithAdapterOptions builds the interactive CLI connector. The +// explicit handler is required: WithInteractionHandler only installs secret and +// host-key prompts, so the master connection-sharing confirmer must be wired to +// the same handler (and prompt gate) as the rest of the session. +func newCLIConnectorWithAdapterOptions(provider config.ConfigProvider, interaction *cliInteractionHandler, adpOpts []adapter.Option, opts ...ssh.Option) *ssh.Connector { + if interaction == nil { + interaction = newCLIInteractionHandler() + } + opts = append([]ssh.Option{ssh.WithInteractionHandler(interaction), ssh.WithSessionConfirmer(interaction)}, opts...) return adapter.NewConnectorWithAdapterOptions(provider, adpOpts, opts...) } @@ -178,6 +185,30 @@ func (h *cliInteractionHandler) ConfirmHostKey(ctx context.Context, request ssh. return strings.EqualFold(response, "yes") || strings.EqualFold(response, "y"), nil } +// ConfirmSession implements ssh.SessionConfirmer for ControlMaster=ask/autoask +// (-M -M). It fails closed: a non-interactive or erroring prompt denies access. +func (h *cliInteractionHandler) ConfirmSession(ctx context.Context, action string) (bool, error) { + if h == nil || h.terminal == nil { + return false, fmt.Errorf("cli session confirmation is not configured") + } + if !h.canRemember { + return false, nil + } + release, err := h.acquireGate(ctx) + if err != nil { + return false, err + } + defer release() + + prompt := fmt.Sprintf("Allow connection sharing %s on this master? (yes/no) ", action) + response, err := h.terminal.ReadLine(ctx, prompt) + if err != nil { + return false, fmt.Errorf("read session confirmation failed: %w", err) + } + response = strings.TrimSpace(response) + return strings.EqualFold(response, "yes") || strings.EqualFold(response, "y"), nil +} + func formatSecretPrompt(req ssh.SecretRequest) string { switch req.Kind { case ssh.SecretKindKeyboardInteractive: diff --git a/cmd/ssh_option_flag.go b/cmd/ssh_option_flag.go new file mode 100644 index 00000000..c97de45b --- /dev/null +++ b/cmd/ssh_option_flag.go @@ -0,0 +1,366 @@ +package cmd + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/wentf9/xops-cli/cmd/utils" + "github.com/wentf9/xops-cli/core/ssh" + "github.com/wentf9/xops-cli/internal/sshenv" + "github.com/wentf9/xops-cli/pkg/adapter" + "github.com/wentf9/xops-cli/pkg/config" + "github.com/wentf9/xops-cli/pkg/i18n" +) + +// Environment markers shared with the detached background processes. +const ( + // sshBackgroundChildEnv marks the child that runs the real SSH session after + // -f validates connectivity in the parent. + sshBackgroundChildEnv = "XOPS_CLI_SSH_BG_CHILD" + // sshMuxDaemonEnv marks the child that only serves a persistent control + // socket, keeping the master alive after the foreground session ended. + sshMuxDaemonEnv = "XOPS_CLI_SSH_MUX_DAEMON" +) + +// Valid control commands for -O +var validControlCommands = map[string]bool{ + "check": true, + "exit": true, + "stop": true, + "forward": true, + "cancel": true, + "proxy": true, + "proxy-stream": true, + "conninfo": true, + "channels": true, +} + +// SSHControlFlags encapsulates OpenSSH-compatible multiplexing and session control flags. +type SSHControlFlags struct { + MasterCount int // -M count: 1 -> ControlMaster=yes, >=2 -> ControlMaster=ask + ControlPath string // -S or "none" + CtlCmd string // -O + Options []string // -o Key=Value options +} + +// RegisterSSHFlags registers -M, -S, -o, -O flags for xops ssh. +func (f *SSHControlFlags) RegisterSSHFlags(cmd *cobra.Command) { + cmd.Flags().CountVarP(&f.MasterCount, "master", "M", i18n.T("flag_ssh_master")) + cmd.Flags().StringVarP(&f.ControlPath, "control-path", "S", "", i18n.T("flag_ssh_control_path")) + cmd.Flags().StringArrayVarP(&f.Options, "option", "o", nil, i18n.T("flag_ssh_option")) + cmd.Flags().StringVarP(&f.CtlCmd, "control-command", "O", "", i18n.T("flag_ssh_control_command")) +} + +// RegisterCommonFlags registers -S and -o flags for scp, sftp, and exec. +func (f *SSHControlFlags) RegisterCommonFlags(cmd *cobra.Command) { + cmd.Flags().StringVarP(&f.ControlPath, "control-path", "S", "", i18n.T("flag_ssh_control_path")) + cmd.Flags().StringArrayVarP(&f.Options, "option", "o", nil, i18n.T("flag_ssh_option")) +} + +// ToSessionControlOptions converts SSHControlFlags into adapter.SessionControlOptions. +// Precedence: explicit CLI flags (-M, -S) override values parsed from -o options. +func (f *SSHControlFlags) ToSessionControlOptions() (adapter.SessionControlOptions, error) { + var sessionOpts adapter.SessionControlOptions + + // 1. Parse -o options first if provided + if len(f.Options) > 0 { + parsed, err := adapter.ParseOptionStrings(f.Options) + if err != nil { + return sessionOpts, err + } + sessionOpts = parsed + } + + // 2. Command-line -M flag overrides any ControlMaster from -o + if f.MasterCount == 1 { + sessionOpts.SetMaster(ssh.ControlMasterYes) + } else if f.MasterCount >= 2 { + sessionOpts.SetMaster(ssh.ControlMasterAsk) + } + + // 3. Command-line -S flag overrides any ControlPath from -o + if f.ControlPath != "" { + sessionOpts.SetPath(f.ControlPath) + } + + return sessionOpts, nil +} + +// Validate validates control flags and detects conflicting combinations. +func (f *SSHControlFlags) Validate() error { + // Reject unknown or malformed -o values here, before a command resolves or + // prepares a node. Path resolution alone would skip them whenever -S is given + // and an explicit control socket short-circuits the merged configuration. + if _, err := f.ToSessionControlOptions(); err != nil { + return f.controlUsageError(err) + } + if f.CtlCmd != "" { + cmdLower := strings.ToLower(strings.TrimSpace(f.CtlCmd)) + if !validControlCommands[cmdLower] { + return f.controlUsageError(fmt.Errorf("unknown control command %q (supported: check, exit, stop, forward, cancel, proxy, proxy-stream, conninfo, channels)", f.CtlCmd)) + } + if f.MasterCount > 0 { + return f.controlUsageError(fmt.Errorf("control command (-O) cannot be combined with master mode (-M)")) + } + if strings.TrimSpace(f.ControlPath) == "none" { + return f.controlUsageError(fmt.Errorf("control command (-O) cannot be specified with -S none")) + } + } + return nil +} + +// controlUsageError tags a flag or combination failure of a control command with +// the OpenSSH exit status: every -O failure exits 255, including arguments that +// were rejected before the socket was contacted. +func (f *SSHControlFlags) controlUsageError(err error) error { + if err == nil || strings.TrimSpace(f.CtlCmd) == "" { + return err + } + return &ControlExitError{Code: 255, Message: err.Error()} +} + +// ControlCommandWithoutTarget reports whether -O addresses an explicit -S socket +// and therefore does not need a destination argument. Without -S the socket can +// only be derived from a resolved node or an inventory/global default. +// Note that `-O proxy` opens a multiplexed session and always requires a target +// destination (matching OpenSSH `ssh -O proxy`), whereas `-O proxy-stream` and +// management queries can address `-S` directly without a target. +func (f *SSHControlFlags) ControlCommandWithoutTarget() bool { + cmdLower := strings.ToLower(strings.TrimSpace(f.CtlCmd)) + if cmdLower == "" || cmdLower == "proxy" { + return false + } + path := strings.TrimSpace(f.ControlPath) + return path != "" && path != "none" +} + +func effectiveConnectionTarget(opts *SshOptions, nodeID string) config.ConnectionTarget { + var target config.ConnectionTarget + if opts != nil { + target = opts.Target + if strings.TrimSpace(target.Selector) == "" { + target.Selector = strings.TrimSpace(opts.Host) + } + if strings.TrimSpace(opts.User) != "" && (!target.HasUser || target.User != strings.TrimSpace(opts.User)) { + target.User = strings.TrimSpace(opts.User) + target.HasUser = true + } + if opts.Port > 0 && (!target.HasPort || target.Port != opts.Port) { + target.Port = opts.Port + target.HasPort = true + } + if strings.TrimSpace(opts.JumpHost) != "" && (!target.HasProxyJump || target.ProxyJump != strings.TrimSpace(opts.JumpHost)) { + target.ProxyJump = strings.TrimSpace(opts.JumpHost) + target.HasProxyJump = true + } + } + if strings.TrimSpace(target.Selector) == "" && strings.TrimSpace(nodeID) != "" { + target.Selector = strings.TrimSpace(nodeID) + } + return target +} + +// controlSocketPath resolves and expands the effective control path for nodeID. +// It mirrors Connector.buildTargetContext so every caller expands %h/%p/%r/%j/%C +// against the same resolved target instead of raw CLI fields. +// +// nodeID may be empty for an ad-hoc host that resolves to no inventory node; the +// session/global/OpenSSH layers are still consulted and the expansion falls back +// to the CLI-supplied target. +func controlSocketPath(opts *SshOptions, provider config.ConfigProvider, nodeID string) (string, error) { + if opts == nil { + return "", nil + } + if err := opts.ensureGeneralOptionsApplied(); err != nil { + return "", err + } + sessionOpts, err := opts.control.ToSessionControlOptions() + if err != nil { + return "", err + } + connTarget := effectiveConnectionTarget(opts, nodeID) + target := controlTarget(opts, provider, nodeID) + controlNodeID := resolveEffectiveControlNodeID(provider, nodeID, target.OriginalHost) + ctrl, err := resolveControlConfig(provider, controlNodeID, connTarget.Selector, sessionOpts) + if err != nil { + return "", err + } + if ctrl == nil || ctrl.Path == "" || ctrl.Path == "none" { + return "", nil + } + return ssh.ExpandControlPath(ctrl.Path, sshenv.Discover(), target) +} + +func resolveEffectiveControlNodeID(provider config.ConfigProvider, nodeID, originalHost string) string { + if originalHost == "" || originalHost == nodeID { + return nodeID + } + if strings.HasPrefix(nodeID, config.OpenSSHNodePrefix) { + alias := strings.TrimPrefix(nodeID, config.OpenSSHNodePrefix) + if _, parsedHost, _, err := utils.ParseAddr(alias); err == nil && parsedHost != "" { + alias = parsedHost + } + setProviderOpenSSHAlias(provider, originalHost, alias) + return originalHost + } + if provider != nil { + if baseNode, ok := provider.GetNode(nodeID); ok && baseNode.OpenSSHAlias != "" { + setProviderOpenSSHAlias(provider, originalHost, baseNode.OpenSSHAlias) + } + } + return originalHost +} + +// resolveControlConfig resolves the effective multiplexing config for nodeID with +// the given session overrides. When the selector matches no inventory node the +// adapter still merges session, global, and OpenSSH layers, so an ad-hoc host is +// validated exactly like a managed one. +func resolveControlConfig(provider config.ConfigProvider, nodeID, selector string, sessionOpts adapter.SessionControlOptions) (*ssh.ControlConfig, error) { + if provider == nil { + if sessionOpts.IsEmpty() { + return nil, nil + } + return ssh.EffectiveControl(mergeSessionControl(sessionOpts)) + } + lookupKey := nodeID + if lookupKey == "" { + // ResolveControl treats an unknown selector as "no node layer" and falls + // back to session > global > OpenSSH, which is the ad-hoc case. + lookupKey = selector + } + return adapter.NewSSHAdapter(provider).ResolveControl(lookupKey, sessionOpts) +} + +// mergeSessionControl converts session overrides into one ControlConfig layer. +func mergeSessionControl(sessionOpts adapter.SessionControlOptions) *ssh.ControlConfig { + ctrl := &ssh.ControlConfig{} + if sessionOpts.Master != nil { + ctrl.Master = *sessionOpts.Master + } + if sessionOpts.Path != nil { + ctrl.Path = *sessionOpts.Path + } + if sessionOpts.Persist != nil { + ctrl.Persist = *sessionOpts.Persist + } + return ctrl +} + +// controlTarget builds the token expansion context. The resolved target supplies +// address, port, user, and jump host; explicit CLI values still win, mirroring +// Connector.buildTargetContext. +func controlTarget(opts *SshOptions, provider config.ConfigProvider, nodeID string) ssh.TargetContext { + connTarget := effectiveConnectionTarget(opts, nodeID) + if strings.TrimSpace(connTarget.Selector) != "" { + effectiveProvider := provider + if effectiveProvider == nil { + effectiveProvider = config.NewProviderWithoutOpenSSH(nil) + } + if resolved, err := config.ResolveTarget(effectiveProvider, connTarget); err == nil { + return targetContextFromResolvedTarget(resolved) + } + } + + jump := strings.TrimSpace(connTarget.ProxyJump) + if strings.EqualFold(jump, "none") { + jump = "" + } + target := ssh.TargetContext{ + OriginalHost: connTarget.Selector, + JumpHost: jump, + } + if opts != nil { + target.Host = strings.TrimPrefix(strings.TrimSuffix(strings.TrimSpace(opts.Host), "]"), "[") + target.Port = int(opts.Port) + target.User = opts.User + } + if connTarget.HasUser && connTarget.User != "" { + target.User = connTarget.User + } + if connTarget.HasPort && connTarget.Port > 0 { + target.Port = int(connTarget.Port) + } + if target.User == "" { + target.User = sshenv.Discover().LocalUser + } + if target.Port <= 0 { + target.Port = 22 + } + return target +} + +func targetContextFromResolvedTarget(resolved config.ResolvedTarget) ssh.TargetContext { + user := resolved.User + if user == "" { + user = sshenv.Discover().LocalUser + } + port := int(resolved.Port) + if port <= 0 { + port = 22 + } + orig := resolved.NodeID + if orig == "" && resolved.Host != "" && user != "" && port > 0 { + orig = config.FormatNodeID(user, resolved.Host, uint16(port)) + } + return ssh.TargetContext{ + Host: resolved.Host, + Port: port, + User: user, + OriginalHost: orig, + JumpHost: resolved.ProxyJump, + KeyPath: resolved.KeyPath, + } +} + +// ApplyGeneralOptions parses non-multiplexing OpenSSH options from -o +// (such as Port, User, IdentityFile, ProxyJump) and applies them to SshOptions +// if they were not already explicitly set via primary CLI flags. +func ApplyGeneralOptions(opts *SshOptions, optionStrings []string, flags *pflag.FlagSet) error { + for _, opt := range optionStrings { + opt = strings.TrimSpace(opt) + if opt == "" { + continue + } + key, val, ok := strings.Cut(opt, "=") + if !ok { + return fmt.Errorf("invalid -o option format (expected Key=Value): %q", opt) + } + key = strings.ToLower(strings.TrimSpace(key)) + val = strings.TrimSpace(val) + + switch key { + case "port": + p, err := strconv.ParseUint(val, 10, 16) + if err != nil || p == 0 { + return fmt.Errorf("invalid Port in -o option: %q", val) + } + if shouldApplyGeneralOption(flags, "port", opts.Port == 0) { + opts.Port = uint16(p) + } + case "user": + if shouldApplyGeneralOption(flags, "login", opts.User == "") { + opts.User = val + } + case "identityfile": + if shouldApplyGeneralOption(flags, "identity", opts.IdentityFile == "") { + opts.IdentityFile = val + } + case "proxyjump": + if shouldApplyGeneralOption(flags, "jump", opts.JumpHost == "") { + opts.JumpHost = val + } + } + } + opts.appliedGeneralOpts = true + return nil +} + +func shouldApplyGeneralOption(flags *pflag.FlagSet, flagName string, unsetWhenProgrammatic bool) bool { + if flags == nil { + return unsetWhenProgrammatic + } + return !flags.Changed(flagName) +} diff --git a/cmd/ssh_option_flag_test.go b/cmd/ssh_option_flag_test.go new file mode 100644 index 00000000..22f31224 --- /dev/null +++ b/cmd/ssh_option_flag_test.go @@ -0,0 +1,566 @@ +package cmd + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/wentf9/xops-cli/core/ssh" + cryptoSSH "golang.org/x/crypto/ssh" +) + +func TestMasterCountFlag(t *testing.T) { + tests := []struct { + name string + count int + wantMaster ssh.ControlMasterMode + wantNil bool + }{ + {name: "count 0", count: 0, wantNil: true}, + {name: "count 1 (-M)", count: 1, wantMaster: ssh.ControlMasterYes}, + {name: "count 2 (-MM or -M -M)", count: 2, wantMaster: ssh.ControlMasterAsk}, + {name: "count 3", count: 3, wantMaster: ssh.ControlMasterAsk}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + flags := SSHControlFlags{MasterCount: tc.count} + sessionOpts, err := flags.ToSessionControlOptions() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tc.wantNil { + if sessionOpts.Master != nil { + t.Fatalf("expected nil Master, got %v", *sessionOpts.Master) + } + } else { + if sessionOpts.Master == nil || *sessionOpts.Master != tc.wantMaster { + t.Fatalf("expected Master %v, got %v", tc.wantMaster, sessionOpts.Master) + } + } + }) + } +} + +func TestControlPathFlag(t *testing.T) { + flags := SSHControlFlags{ControlPath: "/tmp/test.sock"} + sessionOpts, err := flags.ToSessionControlOptions() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sessionOpts.Path == nil || *sessionOpts.Path != "/tmp/test.sock" { + t.Fatalf("expected Path /tmp/test.sock, got %v", sessionOpts.Path) + } + + flagsNone := SSHControlFlags{ControlPath: "none"} + sessionOptsNone, err := flagsNone.ToSessionControlOptions() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sessionOptsNone.Path == nil || *sessionOptsNone.Path != "none" { + t.Fatalf("expected Path 'none', got %v", sessionOptsNone.Path) + } +} + +func TestOptionFlagParsing(t *testing.T) { + flags := SSHControlFlags{ + Options: []string{ + "ControlMaster=auto", + "ControlPath=/tmp/opt.sock", + "ControlPersist=10m", + }, + } + sessionOpts, err := flags.ToSessionControlOptions() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sessionOpts.Master == nil || *sessionOpts.Master != ssh.ControlMasterAuto { + t.Fatalf("expected Master auto, got %v", sessionOpts.Master) + } + if sessionOpts.Path == nil || *sessionOpts.Path != "/tmp/opt.sock" { + t.Fatalf("expected Path /tmp/opt.sock, got %v", sessionOpts.Path) + } + if sessionOpts.Persist == nil || *sessionOpts.Persist != "10m" { + t.Fatalf("expected Persist 10m, got %v", sessionOpts.Persist) + } + + // Invalid format + flagsBadFormat := SSHControlFlags{Options: []string{"NoEqualsSign"}} + if _, err := flagsBadFormat.ToSessionControlOptions(); err == nil { + t.Fatal("expected error on missing '=' in -o option") + } + + // Invalid ControlMaster + flagsBadMaster := SSHControlFlags{Options: []string{"ControlMaster=invalid"}} + if _, err := flagsBadMaster.ToSessionControlOptions(); err == nil { + t.Fatal("expected error on invalid ControlMaster mode") + } +} + +func TestFlagPrecedenceOverOptions(t *testing.T) { + // -M overrides -o ControlMaster=auto + flags := SSHControlFlags{ + MasterCount: 1, + Options: []string{"ControlMaster=auto"}, + } + sessionOpts, err := flags.ToSessionControlOptions() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sessionOpts.Master == nil || *sessionOpts.Master != ssh.ControlMasterYes { + t.Fatalf("expected -M (yes) to override -o (auto), got %v", sessionOpts.Master) + } + + // -S overrides -o ControlPath=/other.sock + flagsPath := SSHControlFlags{ + ControlPath: "/explicit.sock", + Options: []string{"ControlPath=/other.sock"}, + } + sessionOptsPath, err := flagsPath.ToSessionControlOptions() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sessionOptsPath.Path == nil || *sessionOptsPath.Path != "/explicit.sock" { + t.Fatalf("expected -S (/explicit.sock) to override -o, got %v", sessionOptsPath.Path) + } +} + +func TestControlCommandValidation(t *testing.T) { + validCmds := []string{"check", "exit", "stop", "forward", "cancel", "proxy", "conninfo", "channels", "CHECK", " exit "} + for _, cmd := range validCmds { + f := SSHControlFlags{CtlCmd: cmd} + if err := f.Validate(); err != nil { + t.Fatalf("expected valid command %q, got error: %v", cmd, err) + } + } + + // Invalid command + fInvalid := SSHControlFlags{CtlCmd: "restart"} + if err := fInvalid.Validate(); err == nil || !strings.Contains(err.Error(), "unknown control command") { + t.Fatalf("expected unknown control command error, got: %v", err) + } + + // Conflict with -M + fConflictM := SSHControlFlags{CtlCmd: "check", MasterCount: 1} + if err := fConflictM.Validate(); err == nil || !strings.Contains(err.Error(), "cannot be combined with master mode") { + t.Fatalf("expected conflict with -M error, got: %v", err) + } + + // Conflict with -S none + fConflictNone := SSHControlFlags{CtlCmd: "check", ControlPath: "none"} + if err := fConflictNone.Validate(); err == nil || !strings.Contains(err.Error(), "cannot be specified with -S none") { + t.Fatalf("expected conflict with -S none error, got: %v", err) + } +} + +func TestApplyGeneralOptions(t *testing.T) { + opts := &SshOptions{} + cmd := &cobra.Command{} + cmd.Flags().Uint16P("port", "p", 22, "") + cmd.Flags().StringVarP(&opts.User, "login", "l", "", "") + cmd.Flags().StringVarP(&opts.IdentityFile, "identity", "i", "", "") + cmd.Flags().StringVarP(&opts.JumpHost, "jump", "J", "", "") + + options := []string{ + "Port=2222", + "User=alice", + "IdentityFile=/path/to/key", + "ProxyJump=jump.example.com", + } + + if err := ApplyGeneralOptions(opts, options, cmd.Flags()); err != nil { + t.Fatalf("ApplyGeneralOptions failed: %v", err) + } + + if opts.Port != 2222 { + t.Fatalf("expected Port 2222, got %d", opts.Port) + } + if opts.User != "alice" { + t.Fatalf("expected User alice, got %q", opts.User) + } + if opts.IdentityFile != "/path/to/key" { + t.Fatalf("expected IdentityFile /path/to/key, got %q", opts.IdentityFile) + } + if opts.JumpHost != "jump.example.com" { + t.Fatalf("expected JumpHost jump.example.com, got %q", opts.JumpHost) + } +} + +func TestControlFlagsRegistration(t *testing.T) { + // 1. ssh command must have -M, -S, -o, -O + sshCmd := NewCmdSsh() + for _, flagName := range []string{"master", "control-path", "option", "control-command"} { + if sshCmd.Flags().Lookup(flagName) == nil { + t.Errorf("ssh command missing expected flag %q", flagName) + } + } + + // 2. scp command must have -S, -o, but NOT master or control-command + scpCmd := NewCmdScp() + for _, flagName := range []string{"control-path", "option"} { + if scpCmd.Flags().Lookup(flagName) == nil { + t.Errorf("scp command missing expected flag %q", flagName) + } + } + for _, flagName := range []string{"master", "control-command"} { + if scpCmd.Flags().Lookup(flagName) != nil { + t.Errorf("scp command has unexpected flag %q", flagName) + } + } + + // 3. sftp command must have -S, -o, but NOT master or control-command + sftpCmd := NewCmdSftp() + for _, flagName := range []string{"control-path", "option"} { + if sftpCmd.Flags().Lookup(flagName) == nil { + t.Errorf("sftp command missing expected flag %q", flagName) + } + } + for _, flagName := range []string{"master", "control-command"} { + if sftpCmd.Flags().Lookup(flagName) != nil { + t.Errorf("sftp command has unexpected flag %q", flagName) + } + } + + // 4. exec command must have -S, -o, but NOT master or control-command + execCmd := NewCmdExec() + for _, flagName := range []string{"control-path", "option"} { + if execCmd.Flags().Lookup(flagName) == nil { + t.Errorf("exec command missing expected flag %q", flagName) + } + } + for _, flagName := range []string{"master", "control-command"} { + if execCmd.Flags().Lookup(flagName) != nil { + t.Errorf("exec command has unexpected flag %q", flagName) + } + } +} + +// TestApplyGeneralOptionsRespectsExplicitFlags verifies that -o values only fill +// gaps. An explicit -p/-l/-i/-J must always win over the same key in -o. +func TestApplyGeneralOptionsRespectsExplicitFlags(t *testing.T) { + cmd := &cobra.Command{Use: "ssh"} + options := NewSshOptions() + options.control.RegisterSSHFlags(cmd) + cmd.Flags().Uint16VarP(&options.Port, "port", "p", 0, "") + cmd.Flags().StringVarP(&options.User, "login", "l", "", "") + cmd.Flags().StringVarP(&options.IdentityFile, "identity", "i", "", "") + cmd.Flags().StringVarP(&options.JumpHost, "jump", "J", "", "") + + if err := cmd.ParseFlags([]string{ + "-p", "2200", "-l", "explicit", + "-o", "Port=9999", "-o", "User=from-option", "-o", "IdentityFile=/tmp/k", "-o", "ProxyJump=bastion", + }); err != nil { + t.Fatalf("ParseFlags failed: %v", err) + } + + if err := ApplyGeneralOptions(options, options.control.Options, cmd.Flags()); err != nil { + t.Fatalf("ApplyGeneralOptions failed: %v", err) + } + if options.Port != 2200 { + t.Fatalf("explicit -p must win over -o Port, got %d", options.Port) + } + if options.User != "explicit" { + t.Fatalf("explicit -l must win over -o User, got %q", options.User) + } + if options.IdentityFile != "/tmp/k" { + t.Fatalf("expected -o IdentityFile to apply, got %q", options.IdentityFile) + } + if options.JumpHost != "bastion" { + t.Fatalf("expected -o ProxyJump to apply, got %q", options.JumpHost) + } +} + +// TestControlFlagsRejectUnknownOptionKey verifies the CLI surfaces a mistyped -o +// key instead of silently ignoring it. +func TestControlFlagsRejectUnknownOptionKey(t *testing.T) { + flags := SSHControlFlags{Options: []string{"ControlMaste=yes"}} + if _, err := flags.ToSessionControlOptions(); err == nil { + t.Fatal("expected an unsupported -o option error") + } +} + +// TestMuxDaemonChildNormalizesSession verifies that the detached ControlPersist +// daemon ignores stdin credentials and the original command. With stdio on +// /dev/null a --password-stdin read would fail the child and silently break +// connection persistence. +func TestMuxDaemonChildNormalizesSession(t *testing.T) { + t.Setenv(sshMuxDaemonEnv, "true") + + options := NewSshOptions() + cmd := &cobra.Command{Use: "ssh"} + options.control.RegisterSSHFlags(cmd) + cmd.Flags().BoolVar(&options.PasswordStdin, "password-stdin", false, "") + cmd.Flags().BoolVar(&options.OTPStdin, "otp-stdin", false, "") + + if err := cmd.ParseFlags([]string{"-M", "--password-stdin", "web-01", "uptime"}); err != nil { + t.Fatalf("ParseFlags failed: %v", err) + } + if err := options.Complete(cmd, []string{"web-01", "uptime"}); err != nil { + t.Fatalf("Complete failed for the multiplexing daemon child: %v", err) + } + if !options.NoCmd { + t.Fatal("expected the daemon child to run in -N mode") + } + if options.Command != "" { + t.Fatalf("expected the daemon child to drop the original command, got %q", options.Command) + } + if options.PasswordStdin || options.OTPStdin { + t.Fatal("expected the daemon child to skip stdin credential reads") + } + if !options.StdinRedirect { + t.Fatal("expected the daemon child to redirect stdin") + } +} + +// TestMuxDaemonChildSkipsMasterHandoff verifies that the daemon child neither +// spawns another daemon nor claims a foreground session, so its idle timer can +// still expire and terminate the process. +func TestMuxDaemonChildSkipsMasterHandoff(t *testing.T) { + t.Setenv(sshMuxDaemonEnv, "true") + + options := NewSshOptions() + options.Host = "web-01" + options.NoCmd = true + if err := options.handoffPersistentMaster(context.Background(), nil, nil, "web-01"); err != nil { + t.Fatalf("handoffPersistentMaster must be a no-op for the daemon child: %v", err) + } + + // A -N foreground master never hands the socket over either. + t.Setenv(sshMuxDaemonEnv, "") + noCmd := NewSshOptions() + noCmd.Host = "web-01" + noCmd.NoCmd = true + if err := noCmd.handoffPersistentMaster(context.Background(), nil, nil, "web-01"); err != nil { + t.Fatalf("handoffPersistentMaster must be a no-op for -N: %v", err) + } + + // A cancelled session must not resurrect the master the user just stopped. + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + stopped := NewSshOptions() + stopped.Host = "web-01" + if err := stopped.handoffPersistentMaster(cancelled, nil, nil, "web-01"); err != nil { + t.Fatalf("handoffPersistentMaster must be a no-op after cancellation: %v", err) + } +} + +// TestMuxDaemonChildIgnoresPositionalCommand verifies that Validate does not +// rebuild a command from the preserved positional arguments after the daemon +// normalization. Otherwise the daemon would plan and possibly run it. +func TestMuxDaemonChildIgnoresPositionalCommand(t *testing.T) { + t.Setenv(sshMuxDaemonEnv, "true") + + options := NewSshOptions() + cmd := &cobra.Command{Use: "ssh"} + options.control.RegisterSSHFlags(cmd) + cmd.Flags().Uint16VarP(&options.Port, "port", "p", 0, "") + cmd.Flags().StringVarP(&options.User, "login", "l", "", "") + cmd.Flags().StringVarP(&options.IdentityFile, "identity", "i", "", "") + cmd.Flags().StringVarP(&options.JumpHost, "jump", "J", "", "") + cmd.Flags().StringVar(&options.Remember, "remember", "", "") + + args := []string{"web-01", "uptime", "&&", "whoami"} + if err := cmd.ParseFlags([]string{"-M"}); err != nil { + t.Fatalf("ParseFlags failed: %v", err) + } + if err := options.Complete(cmd, args); err != nil { + t.Fatalf("Complete failed: %v", err) + } + if err := options.Validate(); err != nil { + t.Fatalf("Validate failed for the multiplexing daemon child: %v", err) + } + if !options.NoCmd { + t.Fatal("expected the daemon child to stay in -N mode") + } + if options.Command != "" { + t.Fatalf("daemon child rebuilt a command: %q", options.Command) + } + if options.commandPlan != nil { + t.Fatal("daemon child prepared a command plan") + } + if options.Host != "web-01" { + t.Fatalf("expected the target host to survive normalization, got %q", options.Host) + } +} + +// TestHandoffSkipsOneTimePassword verifies that a consumed --otp is never +// replayed by the detached daemon. Replaying it cannot succeed and can trip +// server-side MFA lockouts. +func TestHandoffSkipsOneTimePassword(t *testing.T) { + options := NewSshOptions() + options.Host = "web-01" + options.OTP = "123456" + if err := options.handoffPersistentMaster(context.Background(), nil, nil, "web-01"); err != nil { + t.Fatalf("handoffPersistentMaster must skip a one-time-password session: %v", err) + } +} + +// TestWaitForControlSocketStopsWhenDaemonExits verifies that a daemon which dies +// immediately does not stall the completed command for the full readiness +// timeout, and that the reported error stays well formed when no probe has run +// yet (no "%!w()" artifact). +func TestWaitForControlSocketStopsWhenDaemonExits(t *testing.T) { + exited := make(chan struct{}) + close(exited) + + socketPath := filepath.Join(t.TempDir(), "never.sock") + start := time.Now() + err := waitForControlSocket(context.Background(), socketPath, 10*time.Second, exited) + if err == nil { + t.Fatal("expected an error when the daemon exits before the socket is ready") + } + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Fatalf("waited %s for an already-exited daemon", elapsed) + } + message := err.Error() + if !strings.Contains(message, "master daemon exited before it answered") { + t.Fatalf("unexpected error message %q", message) + } + if strings.Contains(message, "nil") || strings.Contains(message, "%!w") { + t.Fatalf("error message carries a nil wrapping artifact: %q", message) + } +} + +// TestWaitForControlSocketWakesOnDaemonExit verifies that an exit arriving during +// the poll interval interrupts the wait immediately instead of being observed +// only on the next probe. +func TestWaitForControlSocketWakesOnDaemonExit(t *testing.T) { + exited := make(chan struct{}) + socketPath := filepath.Join(t.TempDir(), "late.sock") + + go func() { + time.Sleep(controlSocketPollInterval / 2) + close(exited) + }() + + start := time.Now() + err := waitForControlSocket(context.Background(), socketPath, 30*time.Second, exited) + if err == nil { + t.Fatal("expected an error when the daemon exits during the wait") + } + if elapsed := time.Since(start); elapsed > controlSocketPollInterval+500*time.Millisecond { + t.Fatalf("waited %s; the exit should wake the wait during the poll interval", elapsed) + } + if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected the last probe error to be preserved, got %v", err) + } +} + +// TestWaitForControlSocketHonorsCancellation verifies that a canceled caller stops +// the wait promptly and reports cancellation rather than a probe error. +func TestWaitForControlSocketHonorsCancellation(t *testing.T) { + socketPath := filepath.Join(t.TempDir(), "canceled.sock") + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(controlSocketPollInterval / 2) + cancel() + }() + + start := time.Now() + err := waitForControlSocket(ctx, socketPath, 30*time.Second, nil) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } + if elapsed := time.Since(start); elapsed > controlSocketPollInterval+500*time.Millisecond { + t.Fatalf("waited %s for a canceled caller", elapsed) + } +} + +// TestControlUsageFailuresExit255 verifies that rejected control commands and +// flags report the OpenSSH exit status. Exiting 1 made `-O` scriptable failures +// (bad subcommand, invalid combination) indistinguishable from a crash, and the +// message must still be printable by the top-level handler. +func TestControlUsageFailuresExit255(t *testing.T) { + cases := []struct { + name string + flags SSHControlFlags + }{ + {name: "unknown command", flags: SSHControlFlags{CtlCmd: "restart"}}, + {name: "with master mode", flags: SSHControlFlags{CtlCmd: "check", MasterCount: 1}}, + {name: "with -S none", flags: SSHControlFlags{CtlCmd: "check", ControlPath: "none"}}, + {name: "invalid -o", flags: SSHControlFlags{CtlCmd: "check", Options: []string{"ControlMaste=yes"}}}, + {name: "invalid -o Port", flags: SSHControlFlags{CtlCmd: "check", Options: []string{"Port=invalid"}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.flags.Validate() + if err == nil { + t.Fatal("expected a usage error") + } + code, ok := ExitCodeFromError(err) + if !ok || code != 255 { + t.Fatalf("expected exit code 255, got code=%d (ok=%v)", code, ok) + } + if ErrorAlreadyReported(err) { + t.Fatal("a usage error has not been printed yet and must be reported by the caller") + } + }) + } + + // Without -O the same flag errors stay ordinary errors with exit status 1. + plain := SSHControlFlags{Options: []string{"ControlMaste=yes"}} + if err := plain.Validate(); err == nil { + t.Fatal("expected an unsupported -o option error") + } else if code, ok := ExitCodeFromError(err); ok { + t.Fatalf("a non-control invocation must not use an OpenSSH exit code, got %d", code) + } +} + +// TestControlCommandAllowsExplicitSocketWithoutTarget verifies that -O against an +// explicit -S socket works without a destination argument. resolveControlSocketPath +// already supported it, but Validate rejected the invocation as "no host" first. +func TestControlCommandAllowsExplicitSocketWithoutTarget(t *testing.T) { + options := NewSshOptions() + options.control = SSHControlFlags{CtlCmd: "check", ControlPath: "/tmp/explicit.sock"} + if err := options.Validate(); err != nil { + t.Fatalf("Validate rejected -O with an explicit control socket and no target: %v", err) + } + + // Without -S the socket has to come from a resolved target, so the argument + // is still required and its absence must stay an error with exit code 255. + missingTarget := NewSshOptions() + missingTarget.control = SSHControlFlags{CtlCmd: "check"} + if err := missingTarget.Validate(); err == nil { + t.Fatal("expected -O without -S and without a target to be rejected") + } else if code, ok := ExitCodeFromError(err); !ok || code != 255 { + t.Fatalf("expected exit code 255 for -O missing target, got code=%d (ok=%v)", code, ok) + } +} + +// TestReleaseMastersForHandoffClosesTargetSocket verifies that +// releaseMastersForHandoff reserves and shuts down a persistent target master. +func TestReleaseMastersForHandoffClosesTargetSocket(t *testing.T) { + targetSock := filepath.Join(t.TempDir(), "target.sock") + targetMaster, err := ssh.StartMuxServer(context.Background(), nilDummySSHClient(t), ssh.MuxMasterOptions{ + Path: targetSock, + Target: ssh.TargetContext{Control: &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: targetSock, Persist: "10m"}}, + InitialActiveSessions: 1, + }) + if err != nil { + t.Fatalf("StartMuxServer failed: %v", err) + } + defer func() { _ = targetMaster.Shutdown() }() + + targetMaster.RemoveForegroundSession() + ready, err := releaseMastersForHandoff(nil, targetMaster, targetSock) + if err != nil { + t.Fatalf("releaseMastersForHandoff failed: %v", err) + } + if !ready { + t.Fatal("expected releaseMastersForHandoff to succeed") + } + select { + case <-targetMaster.Wait(): + case <-time.After(2 * time.Second): + t.Fatal("target master did not shut down during handoff release") + } +} + +func nilDummySSHClient(_ *testing.T) *cryptoSSH.Client { + return &cryptoSSH.Client{} +} diff --git a/core/ssh/adversarial_lifecycle_test.go b/core/ssh/adversarial_lifecycle_test.go new file mode 100644 index 00000000..e0529c27 --- /dev/null +++ b/core/ssh/adversarial_lifecycle_test.go @@ -0,0 +1,706 @@ +package ssh + +import ( + "context" + "fmt" + "io" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" + + cryptoSSH "golang.org/x/crypto/ssh" + + "github.com/wentf9/xops-cli/core/testutil/sshfixture" +) + +// TestAdversarial_ControlPersist_ForegroundSessionProtection verifies that +// an interactive foreground master does NOT terminate prematurely after +// PersistDuration expires while the session is active, nor does a finishing +// proxy client inadvertently start the idle countdown while the foreground session is ongoing. +func TestAdversarial_ControlPersist_ForegroundSessionProtection(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "adv-fg-test-*") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + sockPath := filepath.Join(tmpDir, "fg_protect.sock") + persistDuration := 150 * time.Millisecond + + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: sockPath, + SSHClient: newDummySSHClient(), + PersistEnabled: true, + PersistDuration: persistDuration, + InitialActiveSessions: 1, // Foreground session active + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + defer func() { _ = server.Shutdown() }() + + go func() { _ = server.Serve() }() + + if server.ActiveSessions() != 1 { + t.Fatalf("expected activeSessions == 1, got %d", server.ActiveSessions()) + } + + // 1. Wait longer than 2x persistDuration while foreground session is active + time.Sleep(350 * time.Millisecond) + + select { + case <-server.Wait(): + t.Fatal("CRITICAL: foreground master terminated prematurely while session was active!") + default: + // Passed: server is still running + } + + // 2. Perform AliveCheck via client to verify server responds properly + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + client, err := DialMuxClient(ctx, sockPath) + if err != nil { + t.Fatalf("DialMuxClient failed during active foreground session: %v", err) + } + defer func() { _ = client.Close() }() + + pid, err := client.AliveCheck(ctx) + if err != nil || pid == 0 { + t.Fatalf("AliveCheck failed during foreground session: %v, pid=%d", err, pid) + } + + // 3. Connect a proxy client, verify active sessions increments + proxyRawConn, err := connectTestProxyClient(sockPath) + if err != nil { + t.Fatalf("connectTestProxyClient failed: %v", err) + } + + if server.ActiveSessions() != 2 { + _ = proxyRawConn.Close() + t.Fatalf("expected activeSessions == 2 with proxy connected, got %d", server.ActiveSessions()) + } + + // Disconnect the proxy client + _ = proxyRawConn.Close() + + // Wait for server to process proxy disconnect + deadline := time.Now().Add(200 * time.Millisecond) + for server.ActiveSessions() != 1 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if server.ActiveSessions() != 1 { + t.Fatalf("expected activeSessions to return to 1 after proxy disconnect, got %d", server.ActiveSessions()) + } + + // Wait another 300ms (> persistDuration) to verify proxy exit did NOT trigger idle shutdown + time.Sleep(300 * time.Millisecond) + select { + case <-server.Wait(): + t.Fatal("CRITICAL: proxy exit triggered idle shutdown while foreground session was still active!") + default: + // Passed + } + + // 4. End the foreground session + server.RemoveForegroundSession() + if server.ActiveSessions() != 0 { + t.Fatalf("expected activeSessions == 0 after RemoveForegroundSession, got %d", server.ActiveSessions()) + } + + // Now idle timer should expire and server should terminate within [persistDuration, persistDuration + 500ms] + select { + case <-server.Wait(): + // Succeeded: server exited cleanly on idle timeout + case <-time.After(1 * time.Second): + t.Fatal("server failed to terminate after foreground session removed and idle timeout elapsed") + } + + // Verify socket file was unlinked + if socketPathExists(sockPath) { + t.Fatalf("expected socket %q to be unlinked after shutdown", sockPath) + } +} + +func connectTestProxyClient(sockPath string) (net.Conn, error) { + proxyRawConn, err := net.Dial("unix", sockPath) + if err != nil { + return nil, fmt.Errorf("dial proxy: %w", err) + } + if err := performClientMuxHandshake(proxyRawConn); err != nil { + _ = proxyRawConn.Close() + return nil, fmt.Errorf("handshake: %w", err) + } + if err := WriteMuxPacket(proxyRawConn, MUX_C_PROXY, EncodeMuxRequestID(10)); err != nil { + _ = proxyRawConn.Close() + return nil, fmt.Errorf("send proxy packet: %w", err) + } + respType, _, err := ReadMuxPacket(proxyRawConn) + if err != nil || respType != MUX_S_PROXY { + _ = proxyRawConn.Close() + return nil, fmt.Errorf("expected MUX_S_PROXY, got 0x%x (err: %w)", respType, err) + } + return proxyRawConn, nil +} + +// TestAdversarial_ControlPersist_ResetTimerOnNewClient verifies that incoming +// client connections properly cancel and reset the idle timeout countdown. +func TestAdversarial_ControlPersist_ResetTimerOnNewClient(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "adv-reset-test-*") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + sockPath := filepath.Join(tmpDir, "reset.sock") + persistDuration := 250 * time.Millisecond + + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: sockPath, + SSHClient: newDummySSHClient(), + PersistEnabled: true, + PersistDuration: persistDuration, + InitialActiveSessions: 0, // Starts idle + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + defer func() { _ = server.Shutdown() }() + + go func() { _ = server.Serve() }() + + // Wait 120ms (nearly halfway through persistDuration) + time.Sleep(120 * time.Millisecond) + + // Connect a proxy client + proxyConn, err := net.Dial("unix", sockPath) + if err != nil { + t.Fatalf("dial proxyConn failed: %v", err) + } + defer func() { _ = proxyConn.Close() }() + + if err := performClientMuxHandshake(proxyConn); err != nil { + t.Fatalf("handshake failed: %v", err) + } + if err := WriteMuxPacket(proxyConn, MUX_C_PROXY, EncodeMuxRequestID(1)); err != nil { + t.Fatalf("send MUX_C_PROXY failed: %v", err) + } + respType, _, err := ReadMuxPacket(proxyConn) + if err != nil || respType != MUX_S_PROXY { + t.Fatalf("expected MUX_S_PROXY, got 0x%x (err: %v)", respType, err) + } + + // Hold connection for 300ms (> original persistDuration) + time.Sleep(300 * time.Millisecond) + + select { + case <-server.Wait(): + t.Fatal("CRITICAL: server shut down while proxy client was actively connected!") + default: + // OK: alive + } + + // Close proxy connection + _ = proxyConn.Close() + + // Wait for activeSessions to hit 0 + deadline := time.Now().Add(200 * time.Millisecond) + for server.ActiveSessions() != 0 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + + // At this point, a fresh persistDuration timer (250ms) must have started. + // After 100ms, server should still be alive + time.Sleep(100 * time.Millisecond) + select { + case <-server.Wait(): + t.Fatal("server shut down too early before new persistDuration elapsed") + default: + // OK + } + + // After another 250ms (total 350ms post-disconnect > 250ms), server must be shut down + select { + case <-server.Wait(): + // OK: Clean shutdown + case <-time.After(500 * time.Millisecond): + t.Fatal("server failed to shut down after new persistDuration elapsed") + } +} + +// TestAdversarial_BackgroundDaemon_CleanExitOnSignalOrCommand verifies that +// background daemons terminate cleanly when MUX_C_TERMINATE (-O exit) is received. +func TestAdversarial_BackgroundDaemon_CleanExitOnSignalOrCommand(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "adv-exit-test-*") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + sockPath := filepath.Join(tmpDir, "exit_cmd.sock") + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: sockPath, + SSHClient: newDummySSHClient(), + PersistEnabled: true, + PersistIndefinite: true, // indefinite persist, only exits on -O exit + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + defer func() { _ = server.Shutdown() }() + + go func() { _ = server.Serve() }() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + client, err := DialMuxClient(ctx, sockPath) + if err != nil { + t.Fatalf("DialMuxClient failed: %v", err) + } + defer func() { _ = client.Close() }() + + // Send -O exit + if err := client.Terminate(ctx); err != nil { + t.Fatalf("Terminate (-O exit) failed: %v", err) + } + + // Server should shut down cleanly within 500ms + select { + case <-server.Wait(): + // Passed: Clean shutdown + case <-time.After(1 * time.Second): + t.Fatal("server did not terminate after receiving MUX_C_TERMINATE") + } + + // Socket must be removed + if socketPathExists(sockPath) { + t.Fatalf("expected socket %q to be unlinked after -O exit", sockPath) + } +} + +// TestAdversarial_BackgroundDaemon_StopListeningDrain verifies that +// MUX_C_STOP_LISTENING unlinks the socket immediately so no new clients can join, +// while allowing active proxy clients to complete their work before final shutdown. +func TestAdversarial_BackgroundDaemon_StopListeningDrain(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "adv-stop-test-*") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + sockPath := filepath.Join(tmpDir, "stop_drain.sock") + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: sockPath, + SSHClient: newDummySSHClient(), + PersistEnabled: true, + PersistIndefinite: true, + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + defer func() { _ = server.Shutdown() }() + + go func() { _ = server.Serve() }() + + // 1. Establish an active proxy connection (Client 1) + proxyConn, err := net.Dial("unix", sockPath) + if err != nil { + t.Fatalf("dial proxy failed: %v", err) + } + defer func() { _ = proxyConn.Close() }() + + if err := performClientMuxHandshake(proxyConn); err != nil { + t.Fatalf("proxy handshake failed: %v", err) + } + if err := WriteMuxPacket(proxyConn, MUX_C_PROXY, EncodeMuxRequestID(1)); err != nil { + t.Fatalf("send proxy failed: %v", err) + } + respType, _, err := ReadMuxPacket(proxyConn) + if err != nil || respType != MUX_S_PROXY { + t.Fatalf("expected MUX_S_PROXY, got 0x%x (err: %v)", respType, err) + } + + if server.ActiveSessions() != 1 { + t.Fatalf("expected activeSessions == 1, got %d", server.ActiveSessions()) + } + + // 2. Client 2 sends StopListening + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + ctlCli, err := DialMuxClient(ctx, sockPath) + if err != nil { + t.Fatalf("DialMuxClient for StopListening failed: %v", err) + } + if err := ctlCli.StopListening(ctx); err != nil { + _ = ctlCli.Close() + t.Fatalf("StopListening failed: %v", err) + } + _ = ctlCli.Close() + + // Socket file should be unlinked on disk + time.Sleep(30 * time.Millisecond) + if socketPathExists(sockPath) { + t.Fatalf("expected socket %q to be unlinked after StopListening", sockPath) + } + + // New connection attempts should fail immediately with ENOENT or Connection Refused + _, dialErr := net.Dial("unix", sockPath) + if dialErr == nil { + t.Fatal("expected dial to fail on unlinked socket after StopListening") + } + + // BUT the server must NOT have shut down yet, because Client 1 is still active + select { + case <-server.Wait(): + t.Fatal("CRITICAL: server shut down before active proxy client finished!") + default: + // Passed + } + + // 3. Now Client 1 finishes and closes + _ = proxyConn.Close() + + // Once the last session finishes, server committed to shutdown committed by stoppedListening + select { + case <-server.Wait(): + // Passed: Gracefully shut down after session drain + case <-time.After(1 * time.Second): + t.Fatal("server failed to shut down after drained sessions reached 0") + } +} + +// TestAdversarial_StaleSocket_CrashedMasterDeadlockPrevention verifies that +// when a master process crashes or closes abruptly while proxy clients are connected, +// the clients unblock with EOF/error without hanging or deadlocking. +func TestAdversarial_StaleSocket_CrashedMasterDeadlockPrevention(t *testing.T) { + fixtureCtx, fixtureCancel := context.WithTimeout(context.Background(), 15*time.Second) + defer fixtureCancel() + + fixture, err := sshfixture.NewWithOptions(fixtureCtx, sshfixture.Options{ + ExecHandler: func(cmd string, ch cryptoSSH.Channel) { + _, _ = io.WriteString(ch, "alive_output\n") + _, _ = ch.SendRequest("exit-status", false, cryptoSSH.Marshal(struct{ Status uint32 }{0})) + }, + }) + if err != nil { + t.Fatalf("sshfixture.New failed: %v", err) + } + defer func() { _ = fixture.Close() }() + + host, portStr, err := net.SplitHostPort(fixture.Address) + if err != nil { + t.Fatal(err) + } + port, err := strconv.Atoi(portStr) + if err != nil { + t.Fatal(err) + } + + tmpDir, err := os.MkdirTemp("", "adv-crash-test-*") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + sockPath := filepath.Join(tmpDir, "crashed_master.sock") + + // Connect master client to test SSH fixture + sshConfig := &cryptoSSH.ClientConfig{ + User: "fixture", + Auth: []cryptoSSH.AuthMethod{cryptoSSH.Password(sshfixture.Password)}, + HostKeyCallback: cryptoSSH.FixedHostKey(fixture.HostKey), + Timeout: 3 * time.Second, + } + rawClient, err := cryptoSSH.Dial("tcp", fixture.Address, sshConfig) + if err != nil { + t.Fatalf("cryptoSSH.Dial failed: %v", err) + } + + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: sockPath, + SSHClient: rawClient, + }) + if err != nil { + _ = rawClient.Close() + t.Fatalf("NewMuxServer failed: %v", err) + } + go func() { _ = server.Serve() }() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Client dials via DialControlProxy + target := TargetContext{ + Host: host, + Port: port, + User: "fixture", + } + proxyClient, proxyConn, err := DialControlProxy(ctx, sockPath, target) + if err != nil { + _ = server.Shutdown() + _ = rawClient.Close() + t.Fatalf("DialControlProxy failed: %v", err) + } + defer func() { + _ = proxyClient.Close() + _ = proxyConn.Close() + }() + + // Open a session and verify it works + session, err := proxyClient.NewSession() + if err != nil { + _ = server.Shutdown() + _ = rawClient.Close() + t.Fatalf("NewSession failed: %v", err) + } + out, err := session.Output("echo master_alive") + if err != nil || string(out) != "alive_output\n" { + _ = session.Close() + _ = server.Shutdown() + _ = rawClient.Close() + t.Fatalf("session output failed: %v (output: %q)", err, string(out)) + } + _ = session.Close() + + // Open another session + hangSession, err := proxyClient.NewSession() + if err != nil { + _ = server.Shutdown() + _ = rawClient.Close() + t.Fatalf("hangSession NewSession failed: %v", err) + } + defer func() { _ = hangSession.Close() }() + + // SIMULATE CRASH: Abruptly kill the master server and underlying transport + _ = server.Shutdown() + _ = rawClient.Close() + + // The client channel or reading must unblock with an error within a bounded deadline + doneChan := make(chan error, 1) + go func() { + err := hangSession.Run("sleep 10") + doneChan <- err + }() + + select { + case runErr := <-doneChan: + // Succeeded: client unblocked promptly + if runErr == nil { + t.Fatal("expected error from crashed master, got nil") + } + case <-time.After(2 * time.Second): + t.Fatal("CRITICAL DEADLOCK: client hung indefinitely after master crashed!") + } +} + +// TestAdversarial_ConcurrencyStress_UnderRace executes simultaneous client connects, +// proxies, alive checks, ext info queries, and abrupt disconnects concurrently +// to verify zero data races, deadlocks, or state corruption. +func TestAdversarial_ConcurrencyStress_UnderRace(t *testing.T) { + fixtureCtx, fixtureCancel := context.WithTimeout(context.Background(), 15*time.Second) + defer fixtureCancel() + + fixture, err := sshfixture.NewWithOptions(fixtureCtx, sshfixture.Options{ + ExecHandler: func(cmd string, ch cryptoSSH.Channel) { + _, _ = io.WriteString(ch, "test_output\n") + _, _ = ch.SendRequest("exit-status", false, cryptoSSH.Marshal(struct{ Status uint32 }{0})) + }, + }) + if err != nil { + t.Fatalf("sshfixture.New failed: %v", err) + } + defer func() { _ = fixture.Close() }() + + host, portStr, err := net.SplitHostPort(fixture.Address) + if err != nil { + t.Fatal(err) + } + port, err := strconv.Atoi(portStr) + if err != nil { + t.Fatal(err) + } + + tmpDir, err := os.MkdirTemp("", "adv-race-test-*") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + sockPath := filepath.Join(tmpDir, "stress.sock") + + sshConfig := &cryptoSSH.ClientConfig{ + User: "fixture", + Auth: []cryptoSSH.AuthMethod{cryptoSSH.Password(sshfixture.Password)}, + HostKeyCallback: cryptoSSH.FixedHostKey(fixture.HostKey), + Timeout: 5 * time.Second, + } + rawClient, err := cryptoSSH.Dial("tcp", fixture.Address, sshConfig) + if err != nil { + t.Fatalf("cryptoSSH.Dial failed: %v", err) + } + defer func() { _ = rawClient.Close() }() + + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: sockPath, + SSHClient: rawClient, + PersistEnabled: true, + PersistIndefinite: true, + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + defer func() { _ = server.Shutdown() }() + + go func() { _ = server.Serve() }() + + var wg sync.WaitGroup + errCh := make(chan error, 100) + + target := TargetContext{ + Host: host, + Port: port, + User: "fixture", + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + runProxyExecWorkers(ctx, &wg, errCh, sockPath, target, 5) + runAliveCheckWorkers(ctx, &wg, errCh, sockPath, 5) + runExtInfoWorkers(ctx, &wg, errCh, sockPath, 5) + runChurnWorkers(&wg, sockPath, 5) + + wg.Wait() + close(errCh) + + var errs []error + for e := range errCh { + errs = append(errs, e) + } + if len(errs) > 0 { + t.Fatalf("concurrency stress failed with %d errors: %v", len(errs), errs[0]) + } +} + +func runProxyExecWorkers(ctx context.Context, wg *sync.WaitGroup, errCh chan<- error, sockPath string, target TargetContext, count int) { + for i := 0; i < count; i++ { + wg.Add(1) + go func(workerID int) { + defer wg.Done() + for iter := 0; iter < 5; iter++ { + cli, pConn, err := DialControlProxy(ctx, sockPath, target) + if err != nil { + errCh <- fmt.Errorf("worker %d proxy dial: %w", workerID, err) + return + } + sess, err := cli.NewSession() + if err != nil { + _ = cli.Close() + _ = pConn.Close() + errCh <- fmt.Errorf("worker %d session: %w", workerID, err) + return + } + out, err := sess.Output("echo test_output") + _ = sess.Close() + _ = cli.Close() + _ = pConn.Close() + if err != nil || string(out) != "test_output\n" { + errCh <- fmt.Errorf("worker %d exec failed: %w, out: %s", workerID, err, string(out)) + return + } + } + }(i) + } +} + +func runAliveCheckWorkers(ctx context.Context, wg *sync.WaitGroup, errCh chan<- error, sockPath string, count int) { + for i := 0; i < count; i++ { + wg.Add(1) + go func(workerID int) { + defer wg.Done() + for iter := 0; iter < 10; iter++ { + cli, err := DialMuxClient(ctx, sockPath) + if err != nil { + errCh <- fmt.Errorf("worker %d alive dial: %w", workerID, err) + return + } + pid, err := clientAliveCheckWithTimeout(cli, ctx) + _ = cli.Close() + if err != nil || pid == 0 { + errCh <- fmt.Errorf("worker %d alive check failed: %w", workerID, err) + return + } + } + }(i) + } +} + +func runExtInfoWorkers(ctx context.Context, wg *sync.WaitGroup, errCh chan<- error, sockPath string, count int) { + for i := 0; i < count; i++ { + wg.Add(1) + go func(workerID int) { + defer wg.Done() + for iter := 0; iter < 10; iter++ { + cli, err := DialMuxClient(ctx, sockPath) + if err != nil { + errCh <- fmt.Errorf("worker %d info dial: %w", workerID, err) + return + } + info, err := cli.RequestInfo(ctx, "connection") + if err != nil || !strings.Contains(info, "OpenSSH multiplexing") { + _ = cli.Close() + errCh <- fmt.Errorf("worker %d info req: %w", workerID, err) + return + } + _ = cli.Close() + } + }(i) + } +} + +func runChurnWorkers(wg *sync.WaitGroup, sockPath string, count int) { + for i := 0; i < count; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for iter := 0; iter < 20; iter++ { + c, err := net.Dial("unix", sockPath) + if err == nil { + _ = c.Close() + } + } + }() + } +} + +func performClientMuxHandshake(conn net.Conn) error { + exts := []MuxExtension{{Name: "info", Value: "1"}} + if err := WriteMuxPacket(conn, MUX_MSG_HELLO, EncodeMuxHello(MuxProtoVersion, exts)); err != nil { + return err + } + msgType, payload, err := ReadMuxPacket(conn) + if err != nil { + return err + } + if msgType != MUX_MSG_HELLO { + return fmt.Errorf("expected MUX_MSG_HELLO, got 0x%08x", msgType) + } + serverVer, _, err := DecodeMuxHello(payload) + if err != nil || serverVer != MuxProtoVersion { + return fmt.Errorf("unsupported server proto ver %d", serverVer) + } + return nil +} + +func clientAliveCheckWithTimeout(cli *MuxClient, ctx context.Context) (uint32, error) { + cCtx, cancel := context.WithTimeout(ctx, 1*time.Second) + defer cancel() + return cli.AliveCheck(cCtx) +} diff --git a/core/ssh/client.go b/core/ssh/client.go index e065d2a4..b3342dee 100644 --- a/core/ssh/client.go +++ b/core/ssh/client.go @@ -482,7 +482,7 @@ func (c *Client) runWithoutLoginWithConfig(ctx context.Context, cmd string, conf func (c *Client) runRaw(ctx context.Context, wrappedCmd string, config *RunConfig) (output string, retErr error) { session, err := c.newSessionContext(ctx) if err != nil { - return "", fmt.Errorf("failed to create new session: %w", err) + return "", err } defer func() { retErr = errors.Join(retErr, c.closeSessionBounded(session)) }() @@ -506,7 +506,7 @@ func (c *Client) RunScript(ctx context.Context, scriptContent string, opts ...Ru session, err := c.newSessionContext(ctx) if err != nil { - return "", fmt.Errorf("failed to create new session: %w", err) + return "", err } defer func() { retErr = errors.Join(retErr, c.closeSessionBounded(session)) }() @@ -563,7 +563,7 @@ func (s *streamReader) Close() error { func (c *Client) RunStream(ctx context.Context, cmd string) (io.ReadCloser, error) { session, err := c.newSessionContext(ctx) if err != nil { - return nil, fmt.Errorf("failed to create new session: %w", err) + return nil, err } stdout, err := session.StdoutPipe() diff --git a/core/ssh/connector.go b/core/ssh/connector.go index 7d245660..76b4ae2c 100644 --- a/core/ssh/connector.go +++ b/core/ssh/connector.go @@ -23,14 +23,20 @@ import ( // Connector 负责创建 SSH 连接 type Connector struct { - environment Environment - hostKeyVerifier HostKeyVerifier - keySource KeySource - plans map[string]*planEntry - allPlans map[*planEntry]struct{} - retiredPlans map[string]struct{} - disablePlanCache bool - provider ConnectionProvider + environment Environment + hostKeyVerifier HostKeyVerifier + keySource KeySource + plans map[string]*planEntry + allPlans map[*planEntry]struct{} + retiredPlans map[string]struct{} + disablePlanCache bool + provider ConnectionProvider + controlProvider ControlProvider + sessionConfirmer SessionConfirmer + // masters tracks the multiplexing masters this connector started so CloseAll + // releases the control socket and SSH transport it owns. Guarded by lifecycleMu. + masters map[*MuxServer]*masterOwnership + masterOrder []*MuxServer secretResolver SecretResolver credentialRecorder CredentialRecorder secretPrompter SecretPrompter @@ -191,13 +197,43 @@ func (c *Connector) getLogger() logger.DebugLogger { return logger.NopLogger } +// WithControlProvider configures the ControlProvider for multiplexing. +func WithControlProvider(provider ControlProvider) Option { + return func(c *Connector) { + c.controlProvider = provider + } +} + +// SessionConfirmer authorizes connection-sharing requests on an ask/autoask +// master. Implementations must honor ctx, return promptly, and fail closed. +type SessionConfirmer interface { + ConfirmSession(ctx context.Context, action string) (bool, error) +} + +// WithSessionConfirmer injects the confirmation source used by ask/autoask +// masters. Without one, every request against such a master is rejected. +func WithSessionConfirmer(confirmer SessionConfirmer) Option { + if nilCapability(confirmer) { + confirmer = nil + } + return func(c *Connector) { + c.sessionConfirmer = confirmer + } +} + type connectionPlanNode struct { - name string - cfg *ClientConfig + name string + cfg *ClientConfig + control *ControlConfig } var errProxyTransportClosed = errors.New("proxy transport generation is closed") +// errJumpChainRequired reports that a jump-chained target was neither cached nor +// reachable through an active control master, so its jump chain has to be built +// before the target itself can be connected. +var errJumpChainRequired = errors.New("connection requires its proxy jump chain") + // Connect 根据节点名称建立 SSH 连接。 // ProxyJump 链会先完整解析并检测环,再从最底层跳板机开始逐层建立连接。 func (c *Connector) Connect(ctx context.Context, nodeName string) (*Client, error) { @@ -231,14 +267,81 @@ func (c *Connector) Connect(ctx context.Context, nodeName string) (*Client, erro } } +// reuseJumpChainedTarget reuses the final target without rebuilding its jump +// chain. Cached intermediate hops are still validated first: a bastion that +// stops answering while it keeps forwarding would otherwise go unnoticed, +// because the target's own probe only exercises the outermost session. A hop +// without a cached transport contributes nothing to check, which is exactly the +// case where the target's control master must be used without dialing the +// bastion. Hops go through the node's shared task so the probe runs on the +// connector lifecycle: one caller's cancellation must not interrupt a transport +// other callers still use. The validated hops are returned so the chain build +// can reuse them instead of probing the same transports a second time. +func (c *Connector) reuseJumpChainedTarget(ctx context.Context, plan []connectionPlanNode) (*Client, map[string]*Client, error) { + validated := make(map[string]*Client, len(plan)-1) + for index := len(plan) - 1; index > 0; index-- { + planNode := plan[index] + cached, ok := c.clients.Get(planNode.name) + if !ok || cached.SSHClient.Conn == nil { + continue + } + hopClient, err := c.connectPlannedNode(ctx, planNode, nil, true) + if err != nil { + // Only "not reusable" falls back to building the chain; a canceled caller + // or a closed connector must surface as itself so the caller stops here and + // the failure keeps the hop it happened on. + c.getLogger().Debugf("mux: cached jump hop %s is not reusable (%v)", planNode.name, err) + if errors.Is(err, errJumpChainRequired) { + // The cached hop was stale and evicted; evict any in-process cached + // target that rode on it, but still let plan[0] attach to an external + // control master socket if one exists on disk. + if staleTarget, ok := c.clients.Get(plan[0].name); ok { + c.evictCachedClient(plan[0].name, staleTarget) + _ = staleTarget.interrupt() + } + validated = nil + break + } + return nil, nil, err + } + // This transport was validated a moment ago on the connector lifecycle; + // probing it again while the chain is rebuilt would repeat a network + // round trip per cached hop. + validated[planNode.name] = hopClient + } + client, err := c.connectPlannedNode(ctx, plan[0], nil, true) + return client, validated, err +} + func (c *Connector) connectPlan(ctx context.Context, plan []connectionPlanNode) (*Client, error) { var connected *Client + // A cached target transport or an active control master already carries the + // whole jump chain, so reusing it must not dial (and re-authenticate to) the + // bastion first. Probe the final target alone before building the chain; an + // unreusable target reports errJumpChainRequired and falls through below. + var validatedHops map[string]*Client + if len(plan) > 1 { + reused, validated, err := c.reuseJumpChainedTarget(ctx, plan) + if err == nil { + return reused, nil + } + if !errors.Is(err, errJumpChainRequired) { + return nil, err + } + validatedHops = validated + } for index := len(plan) - 1; index >= 0; index-- { if err := ctx.Err(); err != nil { return nil, fmt.Errorf("connect node '%s' canceled: %w", plan[0].name, err) } planNode := plan[index] + if validated, ok := validatedHops[planNode.name]; ok && validated != nil { + // Already validated in this call; reuse it instead of dialing the hop + // again. It still carries the whole chain for the next hop below. + connected = validated + continue + } dialer := c.baseDialer if dialer == nil { dialer = &net.Dialer{Timeout: c.getHandshakeTimeout()} @@ -251,7 +354,7 @@ func (c *Connector) connectPlan(ctx context.Context, plan []connectionPlanNode) } var err error - connected, err = c.connectPlannedNode(ctx, planNode, dialer) + connected, err = c.connectPlannedNode(ctx, planNode, dialer, false) if err != nil { return nil, err } @@ -307,12 +410,15 @@ func (c *Connector) resolveConnectionPlan(ctx context.Context, nodeName string) return nil, fmt.Errorf("config for node '%s' is nil", current) } nodeCfg := *cfg + if nodeCfg.Port <= 0 { + nodeCfg.Port = 22 + } if !nodeCfg.HasOriginalProxyJump { nodeCfg.OriginalProxyJump = nodeCfg.ProxyJump nodeCfg.HasOriginalProxyJump = true } positions[current] = len(plan) - plan = append(plan, connectionPlanNode{name: current, cfg: &nodeCfg}) + plan = append(plan, connectionPlanNode{name: current, cfg: &nodeCfg, control: c.resolveControl(current)}) jumps := splitProxyJumpChain(nodeCfg.ProxyJump) if len(jumps) > 1 { @@ -334,15 +440,18 @@ func (c *Connector) resolveConnectionPlan(ctx context.Context, nodeName string) return nil, fmt.Errorf("config for proxy jump %q is nil", jumpName) } cloned := *jumpCfg + if cloned.Port <= 0 { + cloned.Port = 22 + } cloned.OriginalProxyJump = jumpCfg.ProxyJump cloned.HasOriginalProxyJump = true if index > 0 { - cloned.ProxyJump = jumps[index-1] + cloned.ProxyJump = strings.Join(jumps[:index], ",") } else { cloned.ProxyJump = "" } positions[jumpName] = len(plan) - plan = append(plan, connectionPlanNode{name: jumpName, cfg: &cloned}) + plan = append(plan, connectionPlanNode{name: jumpName, cfg: &cloned, control: c.resolveControl(jumpName)}) } return plan, nil } @@ -351,6 +460,27 @@ func (c *Connector) resolveConnectionPlan(ctx context.Context, nodeName string) return plan, nil } +// expectedPeerUID is the identity a control socket endpoint must run as. It +// mirrors StartMuxServer so a client and the master it talks to agree even when +// the host injects a UID that differs from the effective one (containers). +func (c *Connector) expectedPeerUID() uint32 { + return expectedPeerUIDFromEnvironment(c.environment) +} + +func (c *Connector) resolveControl(nodeName string) *ControlConfig { + if c.controlProvider != nil { + if ctrl, err := c.controlProvider.GetControlConfig(nodeName); err == nil && ctrl != nil { + return ctrl + } + } + if cp, ok := c.provider.(ControlProvider); ok { + if ctrl, err := cp.GetControlConfig(nodeName); err == nil && ctrl != nil { + return ctrl + } + } + return nil +} + func splitProxyJumpChain(proxyJump string) []string { var jumps []string for jump := range strings.SplitSeq(proxyJump, ",") { @@ -362,33 +492,52 @@ func splitProxyJumpChain(proxyJump string) []string { return jumps } -func (c *Connector) connectPlannedNode(ctx context.Context, planNode connectionPlanNode, dialer Dialer) (*Client, error) { - // 缓存复检、失效驱逐和重建位于同一个共享任务内。任务仅绑定 Connector 生命周期, - // 单个调用者取消只停止等待,不会关闭连接池中由其他调用者共享的连接。 - resultCh := c.sf.DoChan(planNode.name, func() (any, error) { - sharedCtx, finish, err := c.beginSharedConnect() - if err != nil { - return nil, err +// connectPlannedNode runs the cache/multiplex/connect decision for one node in a +// shared task. reuseOnly stops before dialing so a jump-chained target can be +// reused without its bastion; a caller that finds errJumpChainRequired retries +// with the full chain once the reuse-only task has finished. +func (c *Connector) connectPlannedNode(ctx context.Context, planNode connectionPlanNode, dialer Dialer, reuseOnly bool) (*Client, error) { + for { + // 缓存复检、失效驱逐和重建位于同一个共享任务内。任务仅绑定 Connector 生命周期, + // 单个调用者取消只停止等待,不会关闭连接池中由其他调用者共享的连接。 + resultCh := c.sf.DoChan(planNode.name, func() (any, error) { + sharedCtx, finish, err := c.beginSharedConnect() + if err != nil { + return nil, err + } + defer finish() + return c.connectNode(sharedCtx, planNode, dialer, reuseOnly) + }) + + client, err := c.awaitPlannedNode(ctx, planNode.name, resultCh) + if err != nil && !reuseOnly && errors.Is(err, errJumpChainRequired) { + // This call joined another caller's reuse-only task, so the target was + // reported as not reusable. The task is finished; asking again runs the + // full chain. Each repeat needs another caller to start a fresh probe and + // the wait stays bounded by ctx, so a full-mode call never surfaces the + // internal sentinel. + continue } - defer finish() - return c.connectNode(sharedCtx, planNode, dialer) - }) + return client, err + } +} +func (c *Connector) awaitPlannedNode(ctx context.Context, nodeName string, resultCh <-chan singleflight.Result) (*Client, error) { select { case <-ctx.Done(): - return nil, fmt.Errorf("connect node '%s' canceled: %w", planNode.name, ctx.Err()) + return nil, fmt.Errorf("connect node '%s' canceled: %w", nodeName, ctx.Err()) case <-c.lifecycleCtx.Done(): return nil, ErrConnectorClosed case result := <-resultCh: if err := ctx.Err(); err != nil { - return nil, fmt.Errorf("connect node '%s' canceled: %w", planNode.name, err) + return nil, fmt.Errorf("connect node '%s' canceled: %w", nodeName, err) } if result.Err != nil { return nil, result.Err } client, ok := result.Val.(*Client) if !ok || client == nil { - return nil, fmt.Errorf("shared connect for node '%s' returned invalid client", planNode.name) + return nil, fmt.Errorf("shared connect for node '%s' returned invalid client", nodeName) } return client, nil } @@ -404,7 +553,7 @@ func (c *Connector) beginSharedConnect() (context.Context, func(), error) { return c.lifecycleCtx, c.connectWG.Done, nil } -func (c *Connector) connectNode(ctx context.Context, planNode connectionPlanNode, dialer Dialer) (*Client, error) { +func (c *Connector) connectNode(ctx context.Context, planNode connectionPlanNode, dialer Dialer, reuseOnly bool) (*Client, error) { nodeName := planNode.name var probeErr error if cachedClient, ok := c.clients.Get(nodeName); ok { @@ -428,6 +577,18 @@ func (c *Connector) connectNode(ctx context.Context, planNode connectionPlanNode if proxy, ok := dialer.(*SSHProxyDialer); ok && proxy.transportClosed() { return nil, fmt.Errorf("rebuild proxy chain for '%s': %w", nodeName, errors.Join(errProxyTransportClosed, probeErr)) } + + // Multiplexing Check: + if client, err := c.tryConnectMultiplex(ctx, planNode); err != nil { + return nil, err + } else if client != nil { + return client, nil + } + + if reuseOnly { + return nil, errJumpChainRequired + } + client, err := c.initializeConnection(ctx, planNode, dialer) // Another caller can invalidate the same root after the check above. if proxy, ok := dialer.(*SSHProxyDialer); err != nil && ok && proxy.transportClosed() && ctx.Err() == nil { @@ -612,6 +773,10 @@ func (c *Connector) initializeConnection(ctx context.Context, planNode connectio return nil, err } c.startKeepAliveFor(nodeName, pooled) + // Start multiplexing master if configured. Pass rootConn (this hop's own + // connection, not the bastion's transportRoot) so shutting down a jump-target + // master never closes the underlying bastion TCP connection. + c.maybeStartMasterListener(planNode, rawClient, rootConn) // 返回封装的 Client,注入组件,且 Client 不持有 AuthMaterial client.cfgMu.Lock() client.connCfg = cfg.ToConnectionConfig() @@ -619,6 +784,489 @@ func (c *Connector) initializeConnection(ctx context.Context, planNode connectio return client, nil } +func (c *Connector) buildTargetContext(planNode connectionPlanNode) TargetContext { + cfg := planNode.cfg + port := cfg.Port + if port <= 0 { + port = 22 + } + user := cfg.User + if user == "" { + user = c.environment.LocalUser + } + originalHost := cfg.NodeID + if originalHost == "" { + originalHost = planNode.name + } + return TargetContext{ + Host: cfg.Address, + Port: port, + User: user, + OriginalHost: originalHost, + JumpHost: cfg.ProxyJump, + KeyPath: cfg.KeyPath, + Control: planNode.control, + } +} + +func isFatalMultiplexSecurityError(err error) bool { + return errors.Is(err, ErrUnauthorizedPeer) || + errors.Is(err, ErrControlTargetMismatch) || + errors.Is(err, ErrControlProxyRejected) +} + +func (c *Connector) verifyMuxMasterTarget(muxClient *MuxClient, expandedPath string, target TargetContext) error { + if muxClient == nil { + return nil + } + masterTarget, ok := muxClient.Extension(muxTargetExtensionName) + if !ok || target.MatchesMasterTarget(masterTarget) { + return nil + } + return fmt.Errorf("%w: control socket %q is connected to %s, cannot reuse for %s", + ErrControlTargetMismatch, + expandedPath, + FormatTargetIdentity(masterTarget), + FormatTargetIdentity(target.CanonicalTargetIdentity()), + ) +} + +// warnOrDebugf logs a warning via optional Warnf(string, ...any) capability on +// DebugLogger when warn is true, falling back to Debugf with a "warning: " prefix. +func (c *Connector) warnOrDebugf(warn bool, format string, args ...any) { + l := c.getLogger() + if warn { + if warner, ok := l.(interface{ Warnf(string, ...any) }); ok { + warner.Warnf(format, args...) + return + } + l.Debugf("warning: "+format, args...) + return + } + l.Debugf(format, args...) +} + +func (c *Connector) tryConnectMultiplex(ctx context.Context, planNode connectionPlanNode) (*Client, error) { + target := c.buildTargetContext(planNode) + ctrl := target.Control + if ctrl == nil || ctrl.Path == "" || ctrl.Path == "none" { + return nil, nil // Multiplexing disabled + } + + explicitMaster := ctrl.Master == ControlMasterYes || ctrl.Master == ControlMasterAsk + + // 1. Expand ControlPath tokens + expandedPath, err := ExpandControlPath(ctrl.Path, c.environment, target) + if err != nil { + c.warnOrDebugf(explicitMaster, "mux: expand control path %q failed: %v; continuing without connection sharing", ctrl.Path, err) + return nil, nil + } + if err := ValidateControlPath(expandedPath); err != nil { + c.warnOrDebugf(explicitMaster, "mux: control path %q validation failed: %v; continuing without connection sharing", expandedPath, err) + return nil, nil + } + + // 2. Check if socket file exists on disk and capture initial file identity + origFi, statErr := os.Lstat(expandedPath) + if statErr != nil || !isControlSocketFileInfo(origFi) { + c.getLogger().Debugf("mux: socket %q does not exist or is not a socket", expandedPath) + return nil, nil + } + + // 3. Socket file exists: probe master health via AliveCheck + client, probeDone, probeErr := c.probeActiveMuxMaster(ctx, planNode, expandedPath, target, origFi, explicitMaster, ctrl.Master) + if probeDone || probeErr != nil { + return client, probeErr + } + + return nil, nil +} + +func (c *Connector) probeActiveMuxMaster(ctx context.Context, planNode connectionPlanNode, expandedPath string, target TargetContext, origFi os.FileInfo, explicitMaster bool, masterMode ControlMasterMode) (*Client, bool, error) { + probeTimeout := c.getHandshakeTimeout() + if probeTimeout > 2*time.Second { + probeTimeout = 2 * time.Second + } + probeCtx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + + muxClient, dialErr := DialMuxClient(probeCtx, expandedPath, WithExpectedPeerUID(c.expectedPeerUID())) + if dialErr == nil { + if targetErr := c.verifyMuxMasterTarget(muxClient, expandedPath, target); targetErr != nil { + _ = muxClient.Close() + if explicitMaster { + c.warnOrDebugf(true, "mux: active master on %q belongs to a different target (%v); not attaching in %s mode", expandedPath, targetErr, masterMode) + return nil, true, nil + } + return nil, true, targetErr + } + + serverPID, checkErr := muxClient.AliveCheck(probeCtx) + _ = muxClient.Close() + + if checkErr == nil { + // -M means "become the master". Attaching to an existing master would + // silently demote the request, so the caller falls back to its own + // connection; the later bind reports the conflict. + if explicitMaster { + c.getLogger().Debugf("mux: master already running on %q (pid=%d); not attaching in %s mode", expandedPath, serverPID, masterMode) + return nil, true, nil + } + c.getLogger().Debugf("mux: active master running on %q (pid=%d) for node %s", expandedPath, serverPID, planNode.name) + client, proxyErr := c.tryDialAndPublishProxy(ctx, planNode, expandedPath, target) + if proxyErr == nil { + return client, true, nil + } + dialErr = proxyErr + } else { + dialErr = checkErr + } + } + + // 4. Handle stale socket safely without clobbering newly started masters. Every + // active master mode must be able to replace a socket left by a crash, + // including the direct-bind fallback used on Windows. A slow/timed-out master + // is NOT unlinked (matches OpenSSH: only ECONNREFUSED indicates a dead listener), + // and ControlMaster=no never unlinks sockets it will not replace. + if dialErr != nil { + if isFatalMultiplexSecurityError(dialErr) { + return nil, true, dialErr + } + canReplaceStale := masterMode != "" && masterMode != ControlMasterNo + if isConnectionRefused(dialErr) && canReplaceStale { + c.getLogger().Debugf("mux: stale socket detected on %q: %v", expandedPath, dialErr) + client, err := c.handleStaleControlSocket(ctx, planNode, expandedPath, target, origFi) + return client, true, err + } + c.getLogger().Debugf("mux: error connecting to master on %q: %v", expandedPath, dialErr) + } + return nil, false, nil +} + +func (c *Connector) dialAndPublishProxy(ctx context.Context, planNode connectionPlanNode, expandedPath string, target TargetContext) (*Client, error) { + client, err := c.tryDialAndPublishProxy(ctx, planNode, expandedPath, target) + if err != nil { + if isFatalMultiplexSecurityError(err) { + return nil, err + } + c.getLogger().Debugf("mux: proxy dial on %q failed: %v; falling back to direct connection", expandedPath, err) + return nil, nil + } + return client, nil +} + +func (c *Connector) tryDialAndPublishProxy(ctx context.Context, planNode connectionPlanNode, expandedPath string, target TargetContext) (*Client, error) { + dialCtx, cancel := context.WithTimeout(ctx, c.getHandshakeTimeout()) + defer cancel() + rawClient, proxyConn, proxyErr := DialControlProxy(dialCtx, expandedPath, target, WithExpectedPeerUID(c.expectedPeerUID())) + if proxyErr != nil { + return nil, proxyErr + } + + pooled := &PooledClient{ + SSHClient: rawClient, + RootConn: proxyConn.RawConn(), + } + if err := c.publishClient(planNode.name, pooled); err != nil { + _ = rawClient.Close() + _ = proxyConn.Close() + return nil, err + } + c.startKeepAliveFor(planNode.name, pooled) + logger.EmitDiagnostic(c.diagnostics, logger.Diagnostic{ + Kind: logger.ConnectionReused, + NodeID: planNode.name, + Host: planNode.cfg.Address, + User: planNode.cfg.User, + Port: planNode.cfg.Port, + }) + return c.wrapCachedClient(planNode.cfg, pooled), nil +} + +func (c *Connector) handleStaleControlSocket(ctx context.Context, planNode connectionPlanNode, expandedPath string, target TargetContext, origFi os.FileInfo) (*Client, error) { + currFi, err := os.Lstat(expandedPath) + if err != nil || !isControlSocketFileInfo(currFi) { + c.getLogger().Debugf("mux: stale socket %q already unlinked by another process", expandedPath) + return nil, nil + } + + ctrl := target.Control + explicitMaster := ctrl != nil && (ctrl.Master == ControlMasterYes || ctrl.Master == ControlMasterAsk) + + // 1. If file identity changed, another process replaced the socket + if !os.SameFile(origFi, currFi) { + return c.probeLiveSocketBeforeUnlink(ctx, planNode, expandedPath, target, explicitMaster, 2*time.Second) + } + + // 2. File identity is unchanged. Perform a brief probe dial to ensure the socket didn't just activate + if client, err, handled := c.tryAttachReactivatedSocket(ctx, planNode, expandedPath, target, explicitMaster); handled { + return client, err + } + + // 3. Atomically confirm file identity directly before unlinking to close TOCTOU window + if finalFi, finalErr := os.Lstat(expandedPath); finalErr == nil && isControlSocketFileInfo(finalFi) && os.SameFile(origFi, finalFi) { + c.getLogger().Debugf("mux: unlinking verified stale control socket %q", expandedPath) + _ = os.Remove(expandedPath) + } + + return nil, nil +} + +func (c *Connector) probeLiveSocketBeforeUnlink( + ctx context.Context, + planNode connectionPlanNode, + expandedPath string, + target TargetContext, + explicitMaster bool, + timeout time.Duration, +) (*Client, error) { + c.getLogger().Debugf("mux: control socket %q was replaced by another process; probing new master", expandedPath) + probeCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + muxCli, probeErr := DialMuxClient(probeCtx, expandedPath, WithExpectedPeerUID(c.expectedPeerUID())) + if probeErr != nil { + if isFatalMultiplexSecurityError(probeErr) { + return nil, probeErr + } + return nil, nil + } + if targetErr := c.verifyMuxMasterTarget(muxCli, expandedPath, target); targetErr != nil { + _ = muxCli.Close() + if explicitMaster { + return nil, nil + } + return nil, targetErr + } + if _, checkErr := muxCli.AliveCheck(probeCtx); checkErr == nil { + _ = muxCli.Close() + if explicitMaster { + return nil, nil + } + return c.dialAndPublishProxy(ctx, planNode, expandedPath, target) + } + _ = muxCli.Close() + // Replaced socket belongs to another process; never unlink + return nil, nil +} + +func (c *Connector) tryAttachReactivatedSocket( + ctx context.Context, + planNode connectionPlanNode, + expandedPath string, + target TargetContext, + explicitMaster bool, +) (*Client, error, bool) { + probeCtx, cancel := context.WithTimeout(ctx, 100*time.Millisecond) + defer cancel() + muxCli, probeErr := DialMuxClient(probeCtx, expandedPath, WithExpectedPeerUID(c.expectedPeerUID())) + if probeErr != nil { + if isFatalMultiplexSecurityError(probeErr) { + return nil, probeErr, true + } + return nil, nil, false + } + if targetErr := c.verifyMuxMasterTarget(muxCli, expandedPath, target); targetErr != nil { + _ = muxCli.Close() + if explicitMaster { + return nil, nil, true + } + return nil, targetErr, true + } + if _, checkErr := muxCli.AliveCheck(probeCtx); checkErr == nil { + _ = muxCli.Close() + if explicitMaster { + return nil, nil, true + } + client, err := c.dialAndPublishProxy(ctx, planNode, expandedPath, target) + return client, err, true + } + _ = muxCli.Close() + return nil, nil, false +} + +func (c *Connector) maybeStartMasterListener(planNode connectionPlanNode, rawClient *ssh.Client, rootConn net.Conn) { + target := c.buildTargetContext(planNode) + ctrl := target.Control + if ctrl == nil || ctrl.Path == "" || ctrl.Path == "none" { + return + } + if ctrl.Master != ControlMasterYes && ctrl.Master != ControlMasterAuto && ctrl.Master != ControlMasterAsk && ctrl.Master != ControlMasterAutoAsk { + return + } + + expandedPath, err := ExpandControlPath(ctrl.Path, c.environment, target) + if err != nil || ValidateControlPath(expandedPath) != nil { + return + } + + server, err := StartMuxServer(context.Background(), rawClient, MuxMasterOptions{ + Path: expandedPath, + Target: target, + Environment: c.environment, + RootConn: rootConn, + InitialActiveSessions: 1, + ConfirmSession: c.confirmSessionCallback(planNode.name), + }) + if err != nil { + // A concurrent master already owns this path (or the bind was rejected). + // OpenSSH reports the conflict and continues without sharing. + explicitMaster := ctrl.Master == ControlMasterYes || ctrl.Master == ControlMasterAsk + c.warnOrDebugf(explicitMaster, "mux: start master server on %q failed: %v; continuing without connection sharing", expandedPath, err) + return + } + c.trackMaster(server) +} + +// masterOwnership records this connector's hold on a master it started. +// A connector that keeps using a transport is a live session for that master: +// without the reference a bounded ControlPersist idle timer, or another client +// disconnecting, would tear down a transport still in use by this process. +type masterOwnership struct { + foregroundSession bool +} + +// trackMaster records an owned master so CloseAll can release it. A connector +// that is already closed shuts the master down immediately. +func (c *Connector) trackMaster(server *MuxServer) { + if server == nil { + return + } + c.lifecycleMu.Lock() + if c.closed { + c.lifecycleMu.Unlock() + _ = server.Shutdown() + return + } + if c.masters == nil { + c.masters = make(map[*MuxServer]*masterOwnership) + } + if _, exists := c.masters[server]; !exists { + c.masterOrder = append(c.masterOrder, server) + } + c.masters[server] = &masterOwnership{foregroundSession: true} + c.lifecycleMu.Unlock() +} + +// ReleaseMaster drops this connector's active-session reference to a master it +// started, without stopping the master. A ControlPersist handoff needs exactly +// that: the connector keeps its transport counted so the idle timer cannot kill +// a live session, but the detached daemon that inherits the socket must be able +// to take the master over. It reports whether this connector owned server; the +// master stays owned, so CloseAll still stops it if no handoff happens. +func (c *Connector) ReleaseMaster(server *MuxServer) bool { + if c == nil || server == nil { + return false + } + c.lifecycleMu.Lock() + ownership := c.masters[server] + release := ownership != nil && ownership.foregroundSession + if release { + ownership.foregroundSession = false + } + c.lifecycleMu.Unlock() + if release { + server.RemoveForegroundSession() + } + return ownership != nil +} + +// AcquireMaster restores this connector's active-session reference on a master +// it owns after a handoff reservation was rolled back. +func (c *Connector) AcquireMaster(server *MuxServer) bool { + if c == nil || server == nil { + return false + } + c.lifecycleMu.Lock() + if c.closed { + c.lifecycleMu.Unlock() + return false + } + ownership := c.masters[server] + if ownership == nil { + c.lifecycleMu.Unlock() + return false + } + if ownership.foregroundSession { + c.lifecycleMu.Unlock() + return true + } + c.lifecycleMu.Unlock() + + if !server.AddForegroundSession() { + return false + } + + c.lifecycleMu.Lock() + ownership = c.masters[server] + if c.closed || ownership == nil || ownership.foregroundSession { + c.lifecycleMu.Unlock() + server.RemoveForegroundSession() + return ownership != nil && ownership.foregroundSession + } + ownership.foregroundSession = true + c.lifecycleMu.Unlock() + return true +} + +// OwnsMaster reports whether server was started by this connector. +func (c *Connector) OwnsMaster(server *MuxServer) bool { + if c == nil || server == nil { + return false + } + c.lifecycleMu.Lock() + defer c.lifecycleMu.Unlock() + _, ok := c.masters[server] + return ok +} + +// HasMasterSession reports whether this connector still holds its initial +// active-session reference on server. +func (c *Connector) HasMasterSession(server *MuxServer) bool { + if c == nil || server == nil { + return false + } + c.lifecycleMu.Lock() + defer c.lifecycleMu.Unlock() + ownership := c.masters[server] + return ownership != nil && ownership.foregroundSession +} + +// Masters returns a snapshot of the multiplexing masters this connector started +// in deterministic creation order (outermost jump master to innermost target). +func (c *Connector) Masters() []*MuxServer { + if c == nil { + return nil + } + c.lifecycleMu.Lock() + defer c.lifecycleMu.Unlock() + masters := make([]*MuxServer, 0, len(c.masterOrder)) + for _, master := range c.masterOrder { + if _, ok := c.masters[master]; ok { + masters = append(masters, master) + } + } + return masters +} + +// confirmSessionCallback adapts the injected SessionConfirmer into the bounded, +// fail-closed callback a master uses for ask/autoask mode. +func (c *Connector) confirmSessionCallback(nodeName string) func(string) bool { + if c.sessionConfirmer == nil { + return nil + } + confirmer := c.sessionConfirmer + return func(action string) bool { + ctx, cancel := context.WithTimeout(c.lifecycleCtx, c.getInteractionTimeout()) + defer cancel() + approved, err := confirmer.ConfirmSession(ctx, action) + if err != nil { + c.getLogger().Debugf("mux: session confirmation for node %s (%s) failed: %v", nodeName, action, err) + return false + } + return approved + } +} + func (c *Connector) publishClient(nodeName string, client *PooledClient) error { c.lifecycleMu.Lock() defer c.lifecycleMu.Unlock() @@ -1031,10 +1679,30 @@ func (c *Connector) CloseAll() error { for plan := range c.allPlans { planConnectors = append(planConnectors, plan) } + ownedMasters := make([]*MuxServer, 0, len(c.masterOrder)) + for i := len(c.masterOrder) - 1; i >= 0; i-- { + master := c.masterOrder[i] + if _, ok := c.masters[master]; ok { + ownedMasters = append(ownedMasters, master) + } + } + c.masters = nil + c.masterOrder = nil clear(c.plans) clear(c.retiredPlans) c.lifecycleMu.Unlock() + var closeErrs []error + + // Masters own the control socket, its registry entry, and the SSH transport + // they serve. Closing the connector without them would leave a dead socket + // file that later commands must detect and unlink. + for _, master := range ownedMasters { + if err := master.Shutdown(); err != nil { + closeErrs = append(closeErrs, err) + } + } + // Closing forbids new publication. Snapshot the existing clients without // holding map locks during network cleanup, then interrupt their physical // transports BEFORE joining connection workers. A downstream handshake can @@ -1044,7 +1712,6 @@ func (c *Connector) CloseAll() error { pooledClients = append(pooledClients, client) return true }) - var closeErrs []error for _, plan := range planConnectors { if err := c.closePlan(plan); err != nil { closeErrs = append(closeErrs, err) diff --git a/core/ssh/connector_mux_reuse_test.go b/core/ssh/connector_mux_reuse_test.go new file mode 100644 index 00000000..c48f7895 --- /dev/null +++ b/core/ssh/connector_mux_reuse_test.go @@ -0,0 +1,222 @@ +package ssh + +import ( + "context" + "fmt" + "net" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/wentf9/xops-cli/core/testutil/sshfixture" +) + +// jumpChainProvider serves one configuration per node, unlike testMuxProvider +// which answers every lookup with the same node. +type jumpChainProvider struct { + cfgs map[string]*ClientConfig + ctrl map[string]*ControlConfig +} + +func (p *jumpChainProvider) GetConfig(nodeID string) (*ClientConfig, error) { + cfg, ok := p.cfgs[nodeID] + if !ok { + return nil, fmt.Errorf("unknown node %q", nodeID) + } + cloned := *cfg + return &cloned, nil +} + +func (p *jumpChainProvider) GetControlConfig(nodeID string) (*ControlConfig, error) { + return p.ctrl[nodeID], nil +} + +// staticControlProvider points every node at one control path, which is what a +// live adapter does while the active configuration is being resolved. +type staticControlProvider struct{ ctrl *ControlConfig } + +func (p staticControlProvider) GetControlConfig(string) (*ControlConfig, error) { + return p.ctrl, nil +} + +// planFixtureSecrets returns the fixture password for the captured plan version. +type planFixtureSecrets struct{} + +func (planFixtureSecrets) ResolveSecret(ctx context.Context, req SecretRequest) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if req.VersionToken != "v1" { + return nil, ErrSnapshotMismatch + } + return []byte(sshfixture.Password), nil +} + +func fixtureEndpoint(t *testing.T, address string) (string, int) { + t.Helper() + host, portText, err := net.SplitHostPort(address) + if err != nil { + t.Fatalf("SplitHostPort(%q) failed: %v", address, err) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatalf("Atoi(%q) failed: %v", portText, err) + } + return host, port +} + +// TestConnectReusesJumpChainedTargetWithoutBastionAuth verifies that an active +// control master on a jump-chained target is reused without dialing and +// re-authenticating to the bastion. Otherwise every reused command replays the +// jump host's authentication, including its one-time factors, and defeats the +// purpose of connection sharing. +func TestConnectReusesJumpChainedTargetWithoutBastionAuth(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) + defer cancel() + + bastion, err := sshfixture.NewWithOptions(ctx, sshfixture.Options{}) + if err != nil { + t.Fatalf("bastion fixture failed: %v", err) + } + defer func() { _ = bastion.Close() }() + target, err := sshfixture.NewWithOptions(ctx, sshfixture.Options{}) + if err != nil { + t.Fatalf("target fixture failed: %v", err) + } + defer func() { _ = target.Close() }() + + bastionHost, bastionPort := fixtureEndpoint(t, bastion.Address) + targetHost, targetPort := fixtureEndpoint(t, target.Address) + socketPath := filepath.Join(t.TempDir(), "chain.sock") + + provider := &jumpChainProvider{ + cfgs: map[string]*ClientConfig{ + "jump": { + NodeID: "jump", Address: bastionHost, Port: bastionPort, + User: "fixture", AuthType: "password", Password: sshfixture.Password, + }, + "target": { + NodeID: "target", Address: targetHost, Port: targetPort, + User: "fixture", AuthType: "password", Password: sshfixture.Password, + ProxyJump: "jump", + }, + }, + ctrl: map[string]*ControlConfig{ + "target": {Master: ControlMasterAuto, Path: socketPath}, + }, + } + + first := newTestConnector(provider, WithHostKeyVerifier(&fixtureTrust{})) + defer func() { + if err := first.CloseAll(); err != nil { + t.Logf("CloseAll failed: %v", err) + } + }() + if _, err := first.Connect(ctx, "target"); err != nil { + t.Fatalf("initial jump-chained connect failed: %v", err) + } + waitForCondition(t, 10*time.Second, func() bool { + return GetActiveMuxServer(socketPath) != nil + }, "connector did not start a control master on the target") + if got := bastion.Authenticated.Load(); got != 1 { + t.Fatalf("initial connect authenticated %d times on the bastion, want 1", got) + } + + second := newTestConnector(provider, WithHostKeyVerifier(&fixtureTrust{})) + defer func() { + if err := second.CloseAll(); err != nil { + t.Logf("CloseAll failed: %v", err) + } + }() + client, err := second.Connect(ctx, "target") + if err != nil { + t.Fatalf("reuse connect failed: %v", err) + } + if got := bastion.Authenticated.Load(); got != 1 { + t.Fatalf("reuse re-authenticated to the bastion: %d authentications, want 1", got) + } + if got := target.Authenticated.Load(); got != 1 { + t.Fatalf("reuse re-authenticated to the target: %d authentications, want 1", got) + } + + session, err := client.sshClient.NewSession() + if err != nil { + t.Fatalf("NewSession through the reused master failed: %v", err) + } + defer func() { _ = session.Close() }() + output, err := session.Output("reuse-check") + if err != nil { + t.Fatalf("command through the reused master failed: %v", err) + } + if string(output) != "fixture-output" { + t.Fatalf("reused command output = %q, want %q", output, "fixture-output") + } +} + +// TestPlanConnectionDoesNotAttachToControlMaster verifies that a captured +// connection plan never attaches to an existing control master. The plan is a +// frozen generation keyed by scope, hops and trust versions, while a control +// socket is addressed only by %l%h%p%r%j, so joining one would reuse a transport +// admitted under another scope or credential version and bypass RetirePlan. +func TestPlanConnectionDoesNotAttachToControlMaster(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) + defer cancel() + + fixture, err := sshfixture.NewWithOptions(ctx, sshfixture.Options{}) + if err != nil { + t.Fatalf("fixture failed: %v", err) + } + defer func() { _ = fixture.Close() }() + host, port := fixtureEndpoint(t, fixture.Address) + + socketPath := filepath.Join(t.TempDir(), "existing.sock") + master, err := NewMuxServer(MuxServerOptions{ + ControlPath: socketPath, + SSHClient: newDummySSHClient(), + PersistEnabled: true, + PersistIndefinite: true, + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + go func() { _ = master.Serve() }() + defer func() { _ = master.Shutdown() }() + + connector := NewConnector(&forbiddenPlanProvider{}, + WithSecretResolver(planFixtureSecrets{}), + WithHostKeyVerifier(&fixtureTrust{}), + WithControlProvider(staticControlProvider{ctrl: &ControlConfig{Master: ControlMasterAuto, Path: socketPath}}), + ) + defer func() { + if err := connector.CloseAll(); err != nil { + t.Logf("CloseAll failed: %v", err) + } + }() + + plan := ConnectionPlan{Scope: "runtime", Hops: []ConnectionConfig{{ + NodeID: "node", Address: host, Port: port, User: "fixture", AuthType: "password", AuthUpdateToken: "v1", + }}} + lease, err := connector.ConnectPlan(ctx, plan) + if err != nil { + t.Fatalf("ConnectPlan failed: %v", err) + } + defer func() { + if err := lease.Close(); err != nil { + t.Error(err) + } + }() + + session, err := lease.Client.sshClient.NewSession() + if err != nil { + t.Fatalf("plan attached to the control master instead of dialing its own transport: %v", err) + } + defer func() { _ = session.Close() }() + output, err := session.Output("plan-check") + if err != nil { + t.Fatalf("command on the plan transport failed: %v", err) + } + if string(output) != "fixture-output" { + t.Fatalf("plan command output = %q, want %q", output, "fixture-output") + } +} diff --git a/core/ssh/connector_mux_test.go b/core/ssh/connector_mux_test.go new file mode 100644 index 00000000..55767a60 --- /dev/null +++ b/core/ssh/connector_mux_test.go @@ -0,0 +1,650 @@ +package ssh + +import ( + "context" + "errors" + "net" + "os" + "path/filepath" + "strconv" + "syscall" + "testing" + "time" + + logger "github.com/wentf9/xops-cli/core/log" + "github.com/wentf9/xops-cli/core/testutil/sshfixture" +) + +type testMuxProvider struct { + cfg *ClientConfig + ctrl *ControlConfig +} + +func (p *testMuxProvider) GetConfig(nodeID string) (*ClientConfig, error) { + return p.cfg, nil +} + +func (p *testMuxProvider) GetControlConfig(nodeID string) (*ControlConfig, error) { + return p.ctrl, nil +} + +func TestConnector_ActiveMasterReuse(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "mux-connector-test-*") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + sockPath := filepath.Join(tmpDir, "master.sock") + dummyClient := newDummySSHClient() + + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: sockPath, + SSHClient: dummyClient, + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + defer func() { _ = server.Shutdown() }() + go func() { _ = server.Serve() }() + + provider := &testMuxProvider{ + cfg: &ClientConfig{ + NodeID: "test-node", + Address: "192.0.2.1", + Port: 22, + User: "testuser", + AuthType: "password", + }, + ctrl: &ControlConfig{ + Master: ControlMasterAuto, + Path: sockPath, + }, + } + + recorder := &diagnosticRecorder{} + connector := newTestConnector(provider, WithDiagnostics(recorder)) + defer func() { _ = connector.CloseAll() }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + client, err := connector.Connect(ctx, "test-node") + if err != nil { + t.Fatalf("connector.Connect failed: %v", err) + } + if client == nil { + t.Fatal("expected non-nil client") + } + + // Verify connection reuse diagnostic was emitted + reused := false + for _, diag := range recorder.snapshot() { + if diag.Kind == logger.ConnectionReused && diag.NodeID == "test-node" { + reused = true + break + } + } + if !reused { + t.Fatal("expected logger.ConnectionReused diagnostic to be emitted") + } +} + +func TestConnector_StaleSocketAutoUnlink(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "mux-stale-unlink-*") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + sockPath := filepath.Join(tmpDir, "stale.sock") + fd, err := syscall.Socket(syscall.AF_UNIX, syscall.SOCK_STREAM, 0) + if err != nil { + t.Fatalf("syscall.Socket failed: %v", err) + } + sa := &syscall.SockaddrUnix{Name: sockPath} + if err := syscall.Bind(fd, sa); err != nil { + _ = syscall.Close(fd) + t.Fatalf("syscall.Bind failed: %v", err) + } + _ = syscall.Close(fd) + + if !socketPathExists(sockPath) { + t.Fatalf("expected stale socket to exist before connect") + } + + // Start real in-memory test SSH server for fallback direct dial + address, stopServer := startTestMultiAuthSSHServer(t, nil, "fixture") + defer stopServer() + + host, portStr, err := net.SplitHostPort(address) + if err != nil { + t.Fatal(err) + } + port, err := strconv.Atoi(portStr) + if err != nil { + t.Fatal(err) + } + + provider := &testMuxProvider{ + cfg: &ClientConfig{ + NodeID: "stale-node", + Address: host, + Port: port, + User: "fixture", + AuthType: "password", + Password: "fixture", + }, + ctrl: &ControlConfig{ + Master: ControlMasterAuto, + Path: sockPath, + }, + } + + connector := newTestConnector(provider, WithHostKeyVerifier(&fixtureTrust{})) + defer func() { _ = connector.CloseAll() }() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + client, err := connector.Connect(ctx, "stale-node") + if err != nil { + t.Fatalf("connector.Connect failed: %v", err) + } + if client == nil { + t.Fatal("expected non-nil client") + } + + // Verify the stale socket was unlinked and recreated by the background master! + var recreated bool + for range 50 { + if socketPathExists(sockPath) { + recreated = true + break + } + time.Sleep(10 * time.Millisecond) + } + if !recreated { + t.Fatalf("expected master socket to be recreated by new master on %q", sockPath) + } +} + +func TestConnector_ControlMasterDisabled(t *testing.T) { + address, stopServer := startTestMultiAuthSSHServer(t, nil, "fixture") + defer stopServer() + + host, portStr, err := net.SplitHostPort(address) + if err != nil { + t.Fatal(err) + } + port, err := strconv.Atoi(portStr) + if err != nil { + t.Fatal(err) + } + + provider := &testMuxProvider{ + cfg: &ClientConfig{ + NodeID: "direct-node", + Address: host, + Port: port, + User: "fixture", + AuthType: "password", + Password: "fixture", + }, + ctrl: &ControlConfig{ + Master: ControlMasterNo, + Path: "none", // explicitly disabled + }, + } + + connector := newTestConnector(provider, WithHostKeyVerifier(&fixtureTrust{})) + defer func() { _ = connector.CloseAll() }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + client, err := connector.Connect(ctx, "direct-node") + if err != nil { + t.Fatalf("connector.Connect failed: %v", err) + } + if client == nil { + t.Fatal("expected non-nil client") + } +} + +// TestConnector_StaleSocketRecoveryDoesNotDemoteExplicitMaster verifies that +// when a stale control socket is replaced by a live master before removal, a +// caller requesting ControlMaster=yes/ask (-M) is not demoted to a proxy client. +func TestConnector_StaleSocketRecoveryDoesNotDemoteExplicitMaster(t *testing.T) { + tmpDir := t.TempDir() + staleFile := filepath.Join(tmpDir, "stale-orig") + if err := os.WriteFile(staleFile, []byte("x"), 0o600); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + origFi, err := os.Stat(staleFile) + if err != nil { + t.Fatalf("Stat failed: %v", err) + } + + sockPath := filepath.Join(tmpDir, "master.sock") + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: sockPath, + SSHClient: newDummySSHClient(), + PersistEnabled: true, + PersistIndefinite: true, + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + defer func() { _ = server.Shutdown() }() + go func() { _ = server.Serve() }() + + connector := newTestConnector(&testMuxProvider{}) + defer func() { _ = connector.CloseAll() }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + for _, mode := range []ControlMasterMode{ControlMasterYes, ControlMasterAsk} { + target := TargetContext{ + Host: "192.0.2.1", + Port: 22, + User: "fixture", + Control: &ControlConfig{Master: mode, Path: sockPath}, + } + planNode := connectionPlanNode{ + name: "explicit-master", + cfg: &ClientConfig{NodeID: "explicit-master", Address: "192.0.2.1", Port: 22, User: "fixture"}, + control: target.Control, + } + // 1. Replaced socket (origFi != currFi) + client, err := connector.handleStaleControlSocket(ctx, planNode, sockPath, target, origFi) + if err != nil { + t.Fatalf("handleStaleControlSocket (%s, replaced) failed: %v", mode, err) + } + if client != nil { + t.Fatalf("handleStaleControlSocket (%s, replaced) attached as proxy client instead of refusing demotion", mode) + } + + // 2. Same file identity that became alive before unlink (origFi == currFi) + sameFi, statErr := os.Stat(sockPath) + if statErr != nil { + t.Fatalf("Stat(sockPath) failed: %v", statErr) + } + client, err = connector.handleStaleControlSocket(ctx, planNode, sockPath, target, sameFi) + if err != nil { + t.Fatalf("handleStaleControlSocket (%s, sameFi) failed: %v", mode, err) + } + if client != nil { + t.Fatalf("handleStaleControlSocket (%s, sameFi) attached as proxy client instead of refusing demotion", mode) + } + if !socketPathExists(sockPath) { + t.Fatalf("handleStaleControlSocket (%s) unlinked a live master socket", mode) + } + } +} + +type multiNodeMuxProvider struct { + cfgs map[string]*ClientConfig + ctrls map[string]*ControlConfig +} + +func (p *multiNodeMuxProvider) GetConfig(nodeID string) (*ClientConfig, error) { + if cfg, ok := p.cfgs[nodeID]; ok { + return cfg, nil + } + return nil, errors.New("node not found: " + nodeID) +} + +func (p *multiNodeMuxProvider) GetControlConfig(nodeID string) (*ControlConfig, error) { + if ctrl, ok := p.ctrls[nodeID]; ok { + return ctrl, nil + } + return nil, nil +} + +// TestTryConnectMultiplex_NeverDeletesRegularFile verifies that if ControlPath +// points to an existing regular file (or non-socket), tryConnectMultiplex and +// handleStaleControlSocket never unlink it. +func TestTryConnectMultiplex_NeverDeletesRegularFile(t *testing.T) { + tmpDir := t.TempDir() + regularFile := filepath.Join(tmpDir, "important-data.txt") + const wantContent = "do-not-delete-me" + if err := os.WriteFile(regularFile, []byte(wantContent), 0o600); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + provider := &testMuxProvider{ + cfg: &ClientConfig{ + NodeID: "reg-file-node", + Address: "192.0.2.1", + Port: 22, + User: "fixture", + AuthType: "password", + }, + ctrl: &ControlConfig{ + Master: ControlMasterAuto, + Path: regularFile, + }, + } + connector := newTestConnector(provider) + defer func() { _ = connector.CloseAll() }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + planNode := connectionPlanNode{ + name: "reg-file-node", + cfg: provider.cfg, + control: provider.ctrl, + } + client, err := connector.tryConnectMultiplex(ctx, planNode) + if err != nil { + t.Fatalf("tryConnectMultiplex returned unexpected error: %v", err) + } + if client != nil { + t.Fatalf("expected nil client for regular file, got %v", client) + } + + got, readErr := os.ReadFile(regularFile) + if readErr != nil { + t.Fatalf("regular file was deleted or became unreadable: %v", readErr) + } + if string(got) != wantContent { + t.Fatalf("regular file content changed: got %q, want %q", string(got), wantContent) + } +} + +// TestTryConnectMultiplex_TimeoutDoesNotUnlinkLiveMaster verifies that a slow or +// unresponsive Unix socket that times out duringMUX_MSG_HELLO is not treated as +// a dead stale socket and is NOT unlinked on disk. +func TestTryConnectMultiplex_TimeoutDoesNotUnlinkLiveMaster(t *testing.T) { + tmpDir := t.TempDir() + sockPath := filepath.Join(tmpDir, "slow-master.sock") + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("Listen unix failed: %v", err) + } + defer func() { _ = ln.Close() }() + + // Accept connections and hold them open without sending MUX_MSG_HELLO so the + // probe times out instead of returning ECONNREFUSED. + done := make(chan struct{}) + defer close(done) + go func() { + for { + conn, acceptErr := ln.Accept() + if acceptErr != nil { + return + } + go func(c net.Conn) { + <-done + _ = c.Close() + }(conn) + } + }() + + provider := &testMuxProvider{ + cfg: &ClientConfig{ + NodeID: "slow-node", + Address: "192.0.2.1", + Port: 22, + User: "fixture", + AuthType: "password", + }, + ctrl: &ControlConfig{ + Master: ControlMasterAuto, + Path: sockPath, + }, + } + connector := newTestConnector(provider) + defer func() { _ = connector.CloseAll() }() + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + + planNode := connectionPlanNode{ + name: "slow-node", + cfg: provider.cfg, + control: provider.ctrl, + } + client, _ := connector.tryConnectMultiplex(ctx, planNode) + if client != nil { + t.Fatalf("expected nil client on probe timeout, got %v", client) + } + + fi, statErr := os.Lstat(sockPath) + if statErr != nil { + t.Fatalf("live master socket was unlinked after probe timeout: %v", statErr) + } + if !isControlSocketFileInfo(fi) { + t.Fatalf("expected socket mode to remain intact, got %v", fi.Mode()) + } +} + +// TestProxyJump_TargetMasterShutdownPreservesBastion verifies that shutting down +// the target hop's MuxServer only closes the target hop's SSH transport and does +// NOT close the bastion hop's physical transport. +func TestProxyJump_TargetMasterShutdownPreservesBastion(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + bastionFix, err := sshfixture.New(ctx) + if err != nil { + t.Fatalf("sshfixture.New(bastion) failed: %v", err) + } + defer func() { _ = bastionFix.Close() }() + + targetFix, err := sshfixture.New(ctx) + if err != nil { + t.Fatalf("sshfixture.New(target) failed: %v", err) + } + defer func() { _ = targetFix.Close() }() + + bHost, bPortStr, err := net.SplitHostPort(bastionFix.Address) + if err != nil { + t.Fatal(err) + } + bPort, _ := strconv.Atoi(bPortStr) + + tHost, tPortStr, err := net.SplitHostPort(targetFix.Address) + if err != nil { + t.Fatal(err) + } + tPort, _ := strconv.Atoi(tPortStr) + + tmpDir := t.TempDir() + bastionSock := filepath.Join(tmpDir, "bastion.sock") + targetSock := filepath.Join(tmpDir, "target.sock") + + provider := &multiNodeMuxProvider{ + cfgs: map[string]*ClientConfig{ + "bastion": { + NodeID: "bastion", + Address: bHost, + Port: bPort, + User: "fixture", + AuthType: "password", + Password: sshfixture.Password, + }, + "target": { + NodeID: "target", + Address: tHost, + Port: tPort, + User: "fixture", + AuthType: "password", + Password: sshfixture.Password, + ProxyJump: "bastion", + }, + }, + ctrls: map[string]*ControlConfig{ + "bastion": { + Master: ControlMasterAuto, + Path: bastionSock, + Persist: "yes", + }, + "target": { + Master: ControlMasterAuto, + Path: targetSock, + Persist: "yes", + }, + }, + } + + connector := newTestConnector(provider, WithHostKeyVerifier(&fixtureTrust{})) + defer func() { _ = connector.CloseAll() }() + + targetClient, err := connector.Connect(ctx, "target") + if err != nil { + t.Fatalf("Connect(target) failed: %v", err) + } + if targetClient == nil { + t.Fatal("expected non-nil target client") + } + + targetMaster := GetActiveMuxServer(targetSock) + bastionMaster := GetActiveMuxServer(bastionSock) + if targetMaster == nil || bastionMaster == nil { + t.Fatalf("expected both targetMaster (%v) and bastionMaster (%v) to be started", targetMaster, bastionMaster) + } + + // Shut down the target master (simulating `xops ssh -O exit target`). + if err := targetMaster.Shutdown(); err != nil { + t.Fatalf("targetMaster.Shutdown() failed: %v", err) + } + + // Bastion's underlying SSH transport and mux socket must remain alive! + bastionMuxClient, err := DialMuxClient(ctx, bastionSock) + if err != nil { + t.Fatalf("DialMuxClient(bastionSock) failed after target master shutdown: %v", err) + } + defer func() { _ = bastionMuxClient.Close() }() + if _, err := bastionMuxClient.AliveCheck(ctx); err != nil { + t.Fatalf("bastion master AliveCheck failed after target master shutdown: %v", err) + } + + bastionPooled, ok := connector.clients.Get("bastion") + if !ok || bastionPooled == nil || bastionPooled.SSHClient == nil { + t.Fatal("expected cached bastion client to remain in connector") + } + if _, _, err := bastionPooled.SSHClient.SendRequest("keepalive@openssh.com", true, nil); err != nil { + t.Fatalf("bastion SSH transport was killed when target master shut down: %v", err) + } +} + +// TestDialAndPublishProxy_HonorsCancelWithoutDeadline verifies that +// DialControlProxy unblocks immediately when its context is cancelled even if +// the context carries no deadline. +func TestDialAndPublishProxy_HonorsCancelWithoutDeadline(t *testing.T) { + tmpDir := t.TempDir() + sockPath := filepath.Join(tmpDir, "hang-proxy.sock") + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("Listen unix failed: %v", err) + } + defer func() { _ = ln.Close() }() + + accepted := make(chan struct{}, 1) + done := make(chan struct{}) + defer close(done) + go func() { + conn, acceptErr := ln.Accept() + if acceptErr != nil { + return + } + select { + case accepted <- struct{}{}: + default: + } + <-done + _ = conn.Close() + }() + + ctx, cancel := context.WithCancel(context.Background()) + errCh := make(chan error, 1) + go func() { + _, _, dialErr := DialControlProxy(ctx, sockPath, TargetContext{Host: "192.0.2.1", Port: 22, User: "fixture"}) + errCh <- dialErr + }() + + select { + case <-accepted: + case <-time.After(2 * time.Second): + t.Fatal("DialControlProxy did not connect to unix socket") + } + + cancel() + + select { + case dialErr := <-errCh: + if dialErr == nil { + t.Fatal("expected DialControlProxy to fail after context cancellation") + } + case <-time.After(2 * time.Second): + t.Fatal("DialControlProxy remained blocked after context without deadline was cancelled") + } +} + +// TestConnectorMastersOrderAndMultiHopProxyJumpChain verifies R6-1 and R6-2: +// 1. Connector.Masters() returns masters in deterministic creation order +// (outermost jump to innermost target), never randomized map order. +// 2. Intermediate hops in a comma-separated ProxyJump chain (jump1,jump2,jump3) +// retain their full upstream prefix chain (e.g. jump3 has ProxyJump="jump1,jump2"). +func TestConnectorMastersOrderAndMultiHopProxyJumpChain(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + provider := &multiNodeMuxProvider{ + cfgs: map[string]*ClientConfig{ + "jump1": {NodeID: "jump1", Address: "10.0.0.1", Port: 22, User: "u1"}, + "jump2": {NodeID: "jump2", Address: "10.0.0.2", Port: 22, User: "u2"}, + "jump3": {NodeID: "jump3", Address: "10.0.0.3", Port: 22, User: "u3"}, + "target": {NodeID: "target", Address: "10.0.0.4", Port: 22, User: "u4", ProxyJump: "jump1,jump2,jump3"}, + }, + ctrls: map[string]*ControlConfig{}, + } + connector := newTestConnector(provider, WithHostKeyVerifier(&fixtureTrust{})) + defer func() { _ = connector.CloseAll() }() + + // Verify R6-2: resolveConnectionPlan preserves full prefix jump chain on jump3. + plan, err := connector.resolveConnectionPlan(ctx, "target") + if err != nil { + t.Fatalf("resolveConnectionPlan(target) failed: %v", err) + } + if len(plan) != 4 { + t.Fatalf("expected 4 plan nodes, got %d", len(plan)) + } + // plan is [target, jump3, jump2, jump1] + if plan[1].name != "jump3" || plan[1].cfg.ProxyJump != "jump1,jump2" { + t.Fatalf("expected jump3 ProxyJump=%q, got name=%q ProxyJump=%q", "jump1,jump2", plan[1].name, plan[1].cfg.ProxyJump) + } + if plan[2].name != "jump2" || plan[2].cfg.ProxyJump != "jump1" { + t.Fatalf("expected jump2 ProxyJump=%q, got name=%q ProxyJump=%q", "jump1", plan[2].name, plan[2].cfg.ProxyJump) + } + if plan[3].name != "jump1" || plan[3].cfg.ProxyJump != "" { + t.Fatalf("expected jump1 ProxyJump empty, got name=%q ProxyJump=%q", plan[3].name, plan[3].cfg.ProxyJump) + } + + // Verify R6-1: trackMaster / Masters() preserves deterministic registration order. + m1 := &MuxServer{target: TargetContext{OriginalHost: "jump1"}, shutdownChan: make(chan struct{})} + m2 := &MuxServer{target: TargetContext{OriginalHost: "jump2"}, shutdownChan: make(chan struct{})} + m3 := &MuxServer{target: TargetContext{OriginalHost: "jump3"}, shutdownChan: make(chan struct{})} + m4 := &MuxServer{target: TargetContext{OriginalHost: "target"}, shutdownChan: make(chan struct{})} + connector.trackMaster(m1) + connector.trackMaster(m2) + connector.trackMaster(m3) + connector.trackMaster(m4) + + want := []*MuxServer{m1, m2, m3, m4} + for iter := 0; iter < 20; iter++ { + got := connector.Masters() + if len(got) != len(want) { + t.Fatalf("iter %d: expected %d masters, got %d", iter, len(want), len(got)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("iter %d: masters[%d] = %v, want %v", iter, i, got[i].Target().OriginalHost, want[i].Target().OriginalHost) + } + } + } +} diff --git a/core/ssh/control_config.go b/core/ssh/control_config.go new file mode 100644 index 00000000..7ede51a1 --- /dev/null +++ b/core/ssh/control_config.go @@ -0,0 +1,557 @@ +package ssh + +import ( + "crypto/sha1" + "encoding/hex" + "errors" + "fmt" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + "time" +) + +// ControlMasterMode specifies connection sharing master behavior. +// Compatible with OpenSSH ssh_config(5) ControlMaster options. +type ControlMasterMode string + +const ( + // ControlMasterNo disables listening on the control socket. + // If a control socket exists and is active, client will reuse it; otherwise connects normally. + ControlMasterNo ControlMasterMode = "no" + + // ControlMasterYes listens on the control socket as master. + ControlMasterYes ControlMasterMode = "yes" + + // ControlMasterAsk listens as master, prompting confirmation before opening new sessions. + ControlMasterAsk ControlMasterMode = "ask" + + // ControlMasterAuto opportunistically attempts client reuse on ControlPath; + // if none exists or the socket is stale, it establishes master listening on ControlPath. + ControlMasterAuto ControlMasterMode = "auto" + + // ControlMasterAutoAsk opportunistic reuse like auto, but prompting confirmation like ask. + ControlMasterAutoAsk ControlMasterMode = "autoask" +) + +// ParseControlMasterMode normalizes string inputs into a valid ControlMasterMode. +// Supports OpenSSH keywords ("no", "yes", "ask", "auto", "autoask") and boolean aliases ("true", "false"). +func ParseControlMasterMode(val string) (ControlMasterMode, error) { + switch strings.ToLower(strings.TrimSpace(val)) { + case "no", "false", "": + return ControlMasterNo, nil + case "yes", "true": + return ControlMasterYes, nil + case "ask": + return ControlMasterAsk, nil + case "auto": + return ControlMasterAuto, nil + case "autoask": + return ControlMasterAutoAsk, nil + default: + return "", fmt.Errorf("unsupported ControlMaster option %q", val) + } +} + +// ControlConfig specifies the connection multiplexing parameters for an SSH connection. +// Matches the contract defined in PROJECT.md and Schema V2. +type ControlConfig struct { + Master ControlMasterMode `json:"master,omitempty" yaml:"master,omitempty"` + Path string `json:"path,omitempty" yaml:"path,omitempty"` + Persist string `json:"persist,omitempty" yaml:"persist,omitempty"` + // ImplicitTier4 is true when multiplexing was resolved solely from ~/.ssh/config + // (Tier 4) without any CLI flag or xops node/global configuration. + ImplicitTier4 bool `json:"-" yaml:"-"` +} + +// IsEnabled returns true if multiplexing is enabled (either master or reuse). +func (c *ControlConfig) IsEnabled() bool { + if c == nil { + return false + } + return c.Path != "" && c.Path != "none" +} + +// Clone returns a deep copy of ControlConfig. +func (c *ControlConfig) Clone() *ControlConfig { + if c == nil { + return nil + } + cloned := *c + return &cloned +} + +// Equal reports whether two ControlConfig values are equal. +func (c *ControlConfig) Equal(other *ControlConfig) bool { + if c == other { + return true + } + if c == nil || other == nil { + return false + } + return c.Master == other.Master && c.Path == other.Path && c.Persist == other.Persist +} + +// ResolveControl merges a child (higher priority) multiplexing config with a +// parent (lower priority) config according to inheritance rules: +// - Master inherits from parent unless child explicitly sets it. +// - Path inherits from parent unless child explicitly sets it. +// - Persist inherits from parent unless child explicitly sets it. +// - If child sets Path="none", multiplexing is disabled for the child. +func ResolveControl(child, parent *ControlConfig) (*ControlConfig, error) { + if child == nil && parent == nil { + return nil, nil + } + if child == nil { + return parent.Clone(), nil + } + if parent == nil { + res := child.Clone() + if res.Path == "none" { + res.Master = ControlMasterNo + res.Persist = "" + } + return res, nil + } + + res := &ControlConfig{ + Master: parent.Master, + Path: parent.Path, + Persist: parent.Persist, + } + + if child.Master != "" { + res.Master = child.Master + } + if child.Path != "" { + res.Path = child.Path + } + if child.Persist != "" { + res.Persist = child.Persist + } + + // If child sets Path="none", multiplexing is disabled for the child. + if child.Path == "none" { + res.Master = ControlMasterNo + res.Persist = "" + } + + return res, nil +} + +// EffectiveControl resolves a hierarchy of multiplexing configs from highest +// priority to lowest priority (e.g. session CLI overrides, node, global, openssh). +func EffectiveControl(layers ...*ControlConfig) (*ControlConfig, error) { + var effective *ControlConfig + for i := len(layers) - 1; i >= 0; i-- { + var err error + effective, err = ResolveControl(layers[i], effective) + if err != nil { + return nil, err + } + } + return effective, nil +} + +// ControlProvider provides multiplexing configuration for a node. +type ControlProvider interface { + GetControlConfig(nodeID string) (*ControlConfig, error) +} + +// TargetContext supplies destination, port, user, and routing context for ControlPath token expansion. +type TargetContext struct { + Host string // Target host or IP address (%h) + Port int // Target port (%p), defaults to 22 if <= 0 + User string // Target remote username (%r) + OriginalHost string // Original command-line host or alias (%n), defaults to Host if empty + JumpHost string // ProxyJump host if any (%j), used in %C connection hash calculation + KeyPath string // Effective private key path for this target, if any + Control *ControlConfig // Multiplexing control configuration +} + +const muxTargetExtensionName = "target@xops" + +// ErrControlTargetMismatch indicates that an active control socket belongs to +// a master connected to a different remote destination than the requested target. +var ErrControlTargetMismatch = errors.New("control socket target mismatch") + +// CanonicalTargetIdentity returns a normalized "user@host:port[via jump]" +// descriptor used to verify that a static ControlPath is not accidentally +// reused across different destination hosts, ports, users, or jump hosts. +// Returns "" if Host is empty. +func (t TargetContext) CanonicalTargetIdentity() string { + host := strings.ToLower(strings.TrimSpace(t.Host)) + if host == "" { + return "" + } + port := t.Port + if port <= 0 { + port = 22 + } + user := strings.TrimSpace(t.User) + jump := strings.ToLower(strings.TrimSpace(t.JumpHost)) + return fmt.Sprintf("host=%s;port=%d;user=%s;jump=%s", host, port, user, jump) +} + +// MatchesMasterTarget reports whether the requested target is compatible with +// the master target identity advertised via the "target@xops" hello extension. +func (t TargetContext) MatchesMasterTarget(masterIdentity string) bool { + if strings.TrimSpace(masterIdentity) == "" || strings.TrimSpace(t.Host) == "" { + return true + } + var mHost, mUser, mJump string + mPort := 22 + for _, part := range strings.Split(masterIdentity, ";") { + kv := strings.SplitN(part, "=", 2) + if len(kv) != 2 { + continue + } + switch kv[0] { + case "host": + mHost = strings.ToLower(strings.TrimSpace(kv[1])) + case "port": + if p, err := strconv.Atoi(strings.TrimSpace(kv[1])); err == nil && p > 0 { + mPort = p + } + case "user": + mUser = strings.TrimSpace(kv[1]) + case "jump": + mJump = strings.ToLower(strings.TrimSpace(kv[1])) + } + } + if mHost == "" { + return true + } + tHost := strings.ToLower(strings.TrimSpace(t.Host)) + tPort := t.Port + if tPort <= 0 { + tPort = 22 + } + tUser := strings.TrimSpace(t.User) + tJump := strings.ToLower(strings.TrimSpace(t.JumpHost)) + + if tHost != mHost || tPort != mPort || tJump != mJump { + return false + } + if tUser != "" && mUser != "" && tUser != mUser { + return false + } + return true +} + +// FormatTargetIdentity formats a canonical target identity string for human-readable error messages. +func FormatTargetIdentity(identity string) string { + var host, user, jump string + port := 22 + for _, part := range strings.Split(identity, ";") { + kv := strings.SplitN(part, "=", 2) + if len(kv) != 2 { + continue + } + switch kv[0] { + case "host": + host = kv[1] + case "port": + if p, err := strconv.Atoi(kv[1]); err == nil && p > 0 { + port = p + } + case "user": + user = kv[1] + case "jump": + jump = kv[1] + } + } + if host == "" { + return identity + } + base := fmt.Sprintf("%s:%d", host, port) + if user != "" { + base = fmt.Sprintf("%s@%s", user, base) + } + if jump != "" { + base = fmt.Sprintf("%s (via %s)", base, jump) + } + return base +} + +var envVarRegex = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`) + +// ExpandControlPath expands tokens (%%, %h, %p, %r, %n, %u, %l, %L, %i, %d, %j, %C), tilde (~), +// and environment variables (${VAR}) in rawPath according to OpenSSH ssh_config(5) rules. +func ExpandControlPath(rawPath string, env Environment, target TargetContext) (string, error) { + if rawPath == "" || rawPath == "none" { + return rawPath, nil + } + + // 1. Expand ${VAR} environment variables + expanded, err := expandEnvironmentVariables(rawPath, env) + if err != nil { + return "", err + } + + // 2. Expand leading tilde (~) + expanded, err = expandTilde(expanded, env.HomeDir) + if err != nil { + return "", err + } + + // 3. Expand percent tokens (%) + return expandPercentTokens(expanded, env, target) +} + +func expandEnvironmentVariables(raw string, env Environment) (string, error) { + if !strings.Contains(raw, "${") { + return raw, nil + } + + var matchErr error + result := envVarRegex.ReplaceAllStringFunc(raw, func(m string) string { + if matchErr != nil { + return m + } + varName := m[2 : len(m)-1] + val, ok := lookupEnv(env, varName) + if !ok || val == "" { + matchErr = fmt.Errorf("env var ${%s} has no value", varName) + return m + } + return val + }) + if matchErr != nil { + return "", fmt.Errorf("invalid environment variable expansion: %w", matchErr) + } + return result, nil +} + +// lookupEnv reads a ControlPath ${VAR} value from the injected environment. The +// core never inspects the process environment on its own. +func lookupEnv(env Environment, key string) (string, bool) { + if env.LookupEnv != nil { + return env.LookupEnv(key) + } + return "", false +} + +func expandTilde(path string, homeDir string) (string, error) { + if path == "~" { + if homeDir == "" { + return "", errors.New("cannot expand '~': home directory is not set") + } + return homeDir, nil + } + if strings.HasPrefix(path, "~/") { + if homeDir == "" { + return "", errors.New("cannot expand '~': home directory is not set") + } + return filepath.Join(homeDir, path[2:]), nil + } + if runtime.GOOS == "windows" && strings.HasPrefix(path, `~\`) { + if homeDir == "" { + return "", errors.New("cannot expand '~': home directory is not set") + } + return filepath.Join(homeDir, path[2:]), nil + } + return path, nil +} + +func expandPercentTokens(raw string, env Environment, target TargetContext) (string, error) { + portStr := "22" + if target.Port > 0 { + portStr = strconv.Itoa(target.Port) + } + + origHost := target.OriginalHost + if origHost == "" { + origHost = target.Host + } + + remoteUser := target.User + if remoteUser == "" { + remoteUser = env.LocalUser + } + + shortLocalHost := env.LocalHost + if idx := strings.Index(shortLocalHost, "."); idx != -1 { + shortLocalHost = shortLocalHost[:idx] + } + + // Compute %C connection hash: SHA-1 over %l%h%p%r%j + connHash := ComputeConnectionHash(env.LocalHost, target.Host, portStr, remoteUser, target.JumpHost) + + var sb strings.Builder + sb.Grow(len(raw) + 32) + + for i := 0; i < len(raw); i++ { + if raw[i] == '%' { + if i+1 >= len(raw) { + return "", errors.New("trailing '%' in ControlPath") + } + i++ + switch raw[i] { + case '%': + sb.WriteByte('%') + case 'h': + sb.WriteString(target.Host) + case 'p': + sb.WriteString(portStr) + case 'r': + sb.WriteString(remoteUser) + case 'n': + sb.WriteString(origHost) + case 'u': + sb.WriteString(env.LocalUser) + case 'l': + sb.WriteString(env.LocalHost) + case 'L': + sb.WriteString(shortLocalHost) + case 'i': + sb.WriteString(env.LocalUID) + case 'd': + sb.WriteString(env.HomeDir) + case 'j': + sb.WriteString(target.JumpHost) + case 'C': + sb.WriteString(connHash) + default: + return "", fmt.Errorf("unknown ControlPath token %%%c", raw[i]) + } + } else { + sb.WriteByte(raw[i]) + } + } + return sb.String(), nil +} + +// ComputeConnectionHash computes the 40-character lowercase hexadecimal SHA-1 hash +// matching OpenSSH ssh_connection_hash(%l%h%p%r%j). +func ComputeConnectionHash(localHost, host, portStr, user, jumpHost string) string { + hasher := sha1.New() + hasher.Write([]byte(localHost)) + hasher.Write([]byte(host)) + hasher.Write([]byte(portStr)) + hasher.Write([]byte(user)) + hasher.Write([]byte(jumpHost)) + return hex.EncodeToString(hasher.Sum(nil)) +} + +// MaxControlPathLength returns the maximum socket path length allowed by the OS sockaddr_un buffer. +// Linux / Windows AF_UNIX: 108 bytes. +// Darwin (macOS) / BSD: 104 bytes. +func MaxControlPathLength() int { + switch runtime.GOOS { + case "darwin", "freebsd", "openbsd", "netbsd": + return 104 + default: + return 108 + } +} + +// ValidateControlPath validates that the expanded socket path is non-empty and fits within OS limits. +func ValidateControlPath(expandedPath string) error { + if expandedPath == "" { + return errors.New("ControlPath is empty") + } + if expandedPath == "none" { + return nil + } + maxLen := MaxControlPathLength() + if len(expandedPath) >= maxLen { + return fmt.Errorf("ControlPath too long ('%s' >= %d bytes)", expandedPath, maxLen) + } + return nil +} + +// ParseControlPersist parses OpenSSH ControlPersist specifications: +// - "no" / "false" / "": disabled +// - "yes" / "true" / "0": enabled indefinitely (indefinite=true, timeout=0) +// - duration: time formats documented in sshd_config(5), e.g. "10m", "1h", "600s", "600", "1d", "1w", "1h30m" +func ParseControlPersist(val string) (enabled bool, indefinite bool, timeout time.Duration, err error) { + trimmed := strings.ToLower(strings.TrimSpace(val)) + switch trimmed { + case "", "no", "false": + return false, false, 0, nil + case "yes", "true", "0": + return true, true, 0, nil + } + + d, err := parseOpenSSHDuration(trimmed) + if err != nil { + return false, false, 0, fmt.Errorf("bad ControlPersist argument %q: %w", val, err) + } + return true, false, d, nil +} + +// parseOpenSSHDuration parses a sequence of integer+qualifier units according to sshd_config(5) TIME FORMATS: +// +// seconds +// s | S seconds +// m | M minutes +// h | H hours +// d | D days +// w | W weeks +// +// Example: "600" -> 600s, "10m" -> 600s, "1h30m" -> 5400s. +func parseOpenSSHDuration(s string) (time.Duration, error) { + if s == "" { + return 0, errors.New("empty duration string") + } + + // Bare integer represents seconds + if val, err := strconv.ParseInt(s, 10, 64); err == nil { + if val <= 0 { + return 0, fmt.Errorf("duration must be positive: %d", val) + } + return time.Duration(val) * time.Second, nil + } + + var total time.Duration + remaining := s + for len(remaining) > 0 { + // Read numeric digits + i := 0 + for i < len(remaining) && remaining[i] >= '0' && remaining[i] <= '9' { + i++ + } + if i == 0 { + return 0, fmt.Errorf("invalid time format %q", s) + } + num, err := strconv.ParseInt(remaining[:i], 10, 64) + if err != nil || num < 0 { + return 0, fmt.Errorf("invalid number %q in %q", remaining[:i], s) + } + remaining = remaining[i:] + + // Read unit qualifier + if len(remaining) == 0 { + total += time.Duration(num) * time.Second + break + } + + unitChar := remaining[0] + remaining = remaining[1:] + + var unit time.Duration + switch unitChar { + case 's', 'S': + unit = time.Second + case 'm', 'M': + unit = time.Minute + case 'h', 'H': + unit = time.Hour + case 'd', 'D': + unit = 24 * time.Hour + case 'w', 'W': + unit = 7 * 24 * time.Hour + default: + return 0, fmt.Errorf("unknown time qualifier '%c' in %q", unitChar, s) + } + total += time.Duration(num) * unit + } + + if total <= 0 { + return 0, fmt.Errorf("duration must be positive: %v", total) + } + return total, nil +} diff --git a/core/ssh/control_config_test.go b/core/ssh/control_config_test.go new file mode 100644 index 00000000..b2e4a5dd --- /dev/null +++ b/core/ssh/control_config_test.go @@ -0,0 +1,573 @@ +package ssh + +import ( + "path/filepath" + "strings" + "testing" + "time" +) + +func TestControlPath_StandardTokens(t *testing.T) { + env := Environment{ + HomeDir: "/home/wuyue", + LocalUser: "wuyue", + LocalHost: "desktop.internal.net", + LocalUID: "1000", + } + target := TargetContext{ + Host: "web.corp.internal", + Port: 2222, + User: "deploy", + OriginalHost: "web.corp.internal", + } + + pattern := "~/.ssh/mux-%u@%h:%p-%r-%n-%L-%i-%%" + expanded, err := ExpandControlPath(pattern, env, target) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := filepath.Join("/home/wuyue", ".ssh/mux-wuyue@web.corp.internal:2222-deploy-web.corp.internal-desktop-1000-%") + if expanded != expected { + t.Fatalf("expected %q, got %q", expected, expanded) + } +} + +func TestControlPath_ConnectionHash_OpenSSHExact(t *testing.T) { + // Probe matching: LocalHost: "cachyos-x8664", Host: "example.com", Port: 2222, User: "user", JumpHost: "" + hash := ComputeConnectionHash("cachyos-x8664", "example.com", "2222", "user", "") + expected := "0b0117f298c0491db0094f1e54b915bc9cd61bea" + if hash != expected { + t.Fatalf("expected hash %q, got %q", expected, hash) + } + + env := Environment{ + LocalHost: "cachyos-x8664", + } + target := TargetContext{ + Host: "example.com", + Port: 2222, + User: "user", + } + expanded, err := ExpandControlPath("/tmp/ssh-%C.sock", env, target) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expectedPath := "/tmp/ssh-0b0117f298c0491db0094f1e54b915bc9cd61bea.sock" + if expanded != expectedPath { + t.Fatalf("expected %q, got %q", expectedPath, expanded) + } +} + +func TestControlPath_ConnectionHash_WithProxyJump(t *testing.T) { + hash := ComputeConnectionHash("cachyos-x8664", "example.com", "2222", "user", "jumphost") + expected := "0cc142fc042f7e309a8b5a01cc61af09717fb809" + if hash != expected { + t.Fatalf("expected hash %q, got %q", expected, hash) + } +} + +func TestControlPath_TildeExpansion(t *testing.T) { + env := Environment{HomeDir: "/home/testuser"} + target := TargetContext{Host: "host.local", User: "root"} + + // 1. ~/sock + res, err := ExpandControlPath("~/sock", env, target) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if expected := filepath.Join("/home/testuser", "sock"); res != expected { + t.Fatalf("expected %q, got %q", expected, res) + } + + // 2. ~ + res, err = ExpandControlPath("~", env, target) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res != "/home/testuser" { + t.Fatalf("expected /home/testuser, got %q", res) + } + + // 3. /tmp/~not-home/sock + res, err = ExpandControlPath("/tmp/~not-home/sock", env, target) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res != "/tmp/~not-home/sock" { + t.Fatalf("expected unchanged, got %q", res) + } +} + +func TestControlPath_EnvVarExpansion(t *testing.T) { + env := Environment{ + LookupEnv: func(key string) (string, bool) { + if key == "XDG_RUNTIME_DIR" { + return "/run/user/1000", true + } + if key == "RUN_ID" { + return "xyz123", true + } + return "", false + }, + } + target := TargetContext{Host: "host"} + + res, err := ExpandControlPath("${XDG_RUNTIME_DIR}/ssh-${RUN_ID}/sock", env, target) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res != "/run/user/1000/ssh-xyz123/sock" { + t.Fatalf("unexpected expansion: %q", res) + } +} + +func TestControlPath_MissingEnvVar_Error(t *testing.T) { + env := Environment{ + LookupEnv: func(key string) (string, bool) { + return "", false + }, + } + target := TargetContext{Host: "host"} + + _, err := ExpandControlPath("/tmp/${UNSET_VAR_XYZ}/sock", env, target) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "env var ${UNSET_VAR_XYZ} has no value") { + t.Fatalf("unexpected error message: %v", err) + } +} + +func TestControlPath_UnknownToken_Error(t *testing.T) { + env := Environment{} + target := TargetContext{Host: "host"} + + _, err := ExpandControlPath("/tmp/cm-%z", env, target) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "unknown ControlPath token %z") { + t.Fatalf("unexpected error message: %v", err) + } +} + +func TestControlPath_TrailingPercent_Error(t *testing.T) { + env := Environment{} + target := TargetContext{Host: "host"} + + _, err := ExpandControlPath("/tmp/cm-%", env, target) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "trailing '%' in ControlPath") { + t.Fatalf("unexpected error message: %v", err) + } +} + +func TestControlPath_DefaultPortAndHost(t *testing.T) { + env := Environment{} + target := TargetContext{Host: "server.com"} + + res, err := ExpandControlPath("/tmp/cm-%n:%p", env, target) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res != "/tmp/cm-server.com:22" { + t.Fatalf("expected /tmp/cm-server.com:22, got %q", res) + } +} + +func TestValidateControlPath_Limits(t *testing.T) { + maxLen := MaxControlPathLength() + + shortPath := strings.Repeat("a", maxLen-1) + if err := ValidateControlPath(shortPath); err != nil { + t.Fatalf("expected valid for len %d, got: %v", len(shortPath), err) + } + + exactPath := strings.Repeat("a", maxLen) + if err := ValidateControlPath(exactPath); err == nil { + t.Fatalf("expected error for len %d, got nil", len(exactPath)) + } + + overPath := strings.Repeat("a", maxLen+10) + if err := ValidateControlPath(overPath); err == nil { + t.Fatalf("expected error for len %d, got nil", len(overPath)) + } + + if err := ValidateControlPath(""); err == nil { + t.Fatal("expected error for empty path") + } + if err := ValidateControlPath("none"); err != nil { + t.Fatalf("expected nil for none, got %v", err) + } +} + +func TestParseControlPersist_Booleans(t *testing.T) { + tests := []struct { + input string + enabled bool + indefinite bool + timeout time.Duration + }{ + {"", false, false, 0}, + {"no", false, false, 0}, + {"false", false, false, 0}, + {"yes", true, true, 0}, + {"true", true, true, 0}, + {"0", true, true, 0}, + } + for _, tc := range tests { + en, ind, dur, err := ParseControlPersist(tc.input) + if err != nil { + t.Fatalf("input %q: unexpected error: %v", tc.input, err) + } + if en != tc.enabled || ind != tc.indefinite || dur != tc.timeout { + t.Fatalf("input %q: got en=%v ind=%v dur=%v, expected en=%v ind=%v dur=%v", + tc.input, en, ind, dur, tc.enabled, tc.indefinite, tc.timeout) + } + } +} + +func TestParseControlPersist_Durations(t *testing.T) { + tests := []struct { + input string + expected time.Duration + }{ + {"600", 600 * time.Second}, + {"10m", 10 * time.Minute}, + {"1h30m", 90 * time.Minute}, + {"1d", 24 * time.Hour}, + {"1w", 7 * 24 * time.Hour}, + {"10s", 10 * time.Second}, + } + for _, tc := range tests { + en, ind, dur, err := ParseControlPersist(tc.input) + if err != nil { + t.Fatalf("input %q: unexpected error: %v", tc.input, err) + } + if !en || ind || dur != tc.expected { + t.Fatalf("input %q: got en=%v ind=%v dur=%v, expected en=true ind=false dur=%v", + tc.input, en, ind, dur, tc.expected) + } + } +} + +func TestParseControlPersist_Invalid(t *testing.T) { + invalids := []string{"-5", "invalid", "10x", "0m", "m"} + for _, inv := range invalids { + _, _, _, err := ParseControlPersist(inv) + if err == nil { + t.Fatalf("input %q: expected error, got nil", inv) + } + } +} + +func TestParseControlMasterMode(t *testing.T) { + tests := []struct { + input string + expected ControlMasterMode + }{ + {"no", ControlMasterNo}, + {"false", ControlMasterNo}, + {"", ControlMasterNo}, + {"yes", ControlMasterYes}, + {"true", ControlMasterYes}, + {"ask", ControlMasterAsk}, + {"auto", ControlMasterAuto}, + {"autoask", ControlMasterAutoAsk}, + } + for _, tc := range tests { + m, err := ParseControlMasterMode(tc.input) + if err != nil { + t.Fatalf("input %q: unexpected error: %v", tc.input, err) + } + if m != tc.expected { + t.Fatalf("input %q: expected %v, got %v", tc.input, tc.expected, m) + } + } + + if _, err := ParseControlMasterMode("invalid"); err == nil { + t.Fatal("expected error for invalid mode, got nil") + } +} + +func TestControlConfig_CloneAndEqual(t *testing.T) { + var nilCtrl *ControlConfig + if nilCtrl.Clone() != nil { + t.Fatal("expected nil clone for nil ControlConfig") + } + if !nilCtrl.Equal(nil) { + t.Fatal("expected nil ControlConfig equal nil") + } + + orig := &ControlConfig{ + Master: ControlMasterAuto, + Path: "~/.ssh/cm-%r@%h:%p", + Persist: "10m", + } + + if orig.Equal(nil) || nilCtrl.Equal(orig) { + t.Fatal("non-nil ControlConfig should not equal nil") + } + + cloned := orig.Clone() + if !orig.Equal(cloned) { + t.Fatal("cloned ControlConfig should equal original") + } + + // Mutate clone to ensure deep copy + cloned.Master = ControlMasterYes + if orig.Equal(cloned) { + t.Fatal("modifying cloned Master should break equality") + } + cloned.Master = orig.Master + + cloned.Path = "/tmp/sock" + if orig.Equal(cloned) { + t.Fatal("modifying cloned Path should break equality") + } + cloned.Path = orig.Path + + cloned.Persist = "20m" + if orig.Equal(cloned) { + t.Fatal("modifying cloned Persist should break equality") + } +} + +func TestControlConfig_ResolveControl(t *testing.T) { + // 1. Both nil + res, err := ResolveControl(nil, nil) + if err != nil || res != nil { + t.Fatalf("expected nil, nil; got %v, %v", res, err) + } + + // 2. Child only + child := &ControlConfig{Master: ControlMasterYes, Path: "/tmp/sock"} + res, err = ResolveControl(child, nil) + if err != nil || !res.Equal(child) { + t.Fatalf("expected child clone, got %v, %v", res, err) + } + + // 3. Parent only + parent := &ControlConfig{Master: ControlMasterAuto, Path: "~/.ssh/cm-%h", Persist: "5m"} + res, err = ResolveControl(nil, parent) + if err != nil || !res.Equal(parent) { + t.Fatalf("expected parent clone, got %v, %v", res, err) + } + + // 4. Child overrides parent partially (field-by-field) + child = &ControlConfig{Master: ControlMasterAsk} + res, err = ResolveControl(child, parent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := &ControlConfig{Master: ControlMasterAsk, Path: "~/.ssh/cm-%h", Persist: "5m"} + if !res.Equal(expected) { + t.Fatalf("expected %v, got %v", expected, res) + } + + // 5. Child overrides all (with valid path) + child = &ControlConfig{Master: ControlMasterAsk, Path: "/tmp/custom.sock", Persist: "10m"} + res, err = ResolveControl(child, parent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !res.Equal(child) { + t.Fatalf("expected %v, got %v", child, res) + } + + // 6. Child disables multiplexing with Path="none" (overrides active parent) + child = &ControlConfig{Path: "none"} + res, err = ResolveControl(child, parent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expectedDisabled := &ControlConfig{Master: ControlMasterNo, Path: "none", Persist: ""} + if !res.Equal(expectedDisabled) { + t.Fatalf("expected %v, got %v", expectedDisabled, res) + } + if res.IsEnabled() { + t.Fatal("expected IsEnabled() to be false when Path is none") + } + + // 7. Child sets Path="none" with nil parent + res, err = ResolveControl(&ControlConfig{Path: "none"}, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !res.Equal(expectedDisabled) { + t.Fatalf("expected %v, got %v", expectedDisabled, res) + } + if res.IsEnabled() { + t.Fatal("expected IsEnabled() to be false when Path is none") + } + + // 8. Child sets Path="none" with explicit child Persist (Persist must still be cleared) + child = &ControlConfig{Path: "none", Persist: "10m"} + res, err = ResolveControl(child, parent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !res.Equal(expectedDisabled) { + t.Fatalf("expected %v, got %v", expectedDisabled, res) + } +} + +func TestControlConfig_EffectiveControl(t *testing.T) { + // Tier 4: OpenSSH + t4 := &ControlConfig{Master: ControlMasterAuto, Path: "~/.ssh/cm-%r@%h:%p", Persist: "10m"} + // Tier 3: Global + t3 := &ControlConfig{Persist: "15m"} + // Tier 2: Node + t2 := &ControlConfig{Path: "/tmp/node-%h.sock"} + // Tier 1: CLI + t1 := &ControlConfig{Master: ControlMasterYes} + + // Layers ordered highest to lowest priority: t1, t2, t3, t4 + eff, err := EffectiveControl(t1, t2, t3, t4) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := &ControlConfig{ + Master: ControlMasterYes, + Path: "/tmp/node-%h.sock", + Persist: "15m", + } + if !eff.Equal(expected) { + t.Fatalf("expected %v, got %v", expected, eff) + } + + // Test with nil layers + eff2, err := EffectiveControl(nil, t2, nil, t4) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected2 := &ControlConfig{ + Master: ControlMasterAuto, + Path: "/tmp/node-%h.sock", + Persist: "10m", + } + if !eff2.Equal(expected2) { + t.Fatalf("expected %v, got %v", expected2, eff2) + } + + // All nil + eff3, err := EffectiveControl(nil, nil) + if err != nil || eff3 != nil { + t.Fatalf("expected nil, nil; got %v, %v", eff3, err) + } + + // Test EffectiveControl where a higher-priority layer disables multiplexing via Path="none" + effDisabled, err := EffectiveControl( + nil, // Tier 1: CLI (nil) + &ControlConfig{Path: "none"}, // Tier 2: Node opts out + &ControlConfig{Master: ControlMasterAuto, Path: "~/.ssh/cm-%r@%h:%p", Persist: "10m"}, // Tier 3: Global + &ControlConfig{Master: ControlMasterYes, Path: "~/.ssh/mux.sock", Persist: "1h"}, // Tier 4: OpenSSH + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expectedEffDisabled := &ControlConfig{ + Master: ControlMasterNo, + Path: "none", + Persist: "", + } + if !effDisabled.Equal(expectedEffDisabled) { + t.Fatalf("expected %v, got %v", expectedEffDisabled, effDisabled) + } + if effDisabled.IsEnabled() { + t.Fatal("expected effective control to be disabled") + } +} + +func TestTargetContext_CanonicalTargetIdentityAndMatching(t *testing.T) { + t1 := TargetContext{ + Host: "Web.Corp.Internal", + Port: 2222, + User: "deploy", + } + id1 := t1.CanonicalTargetIdentity() + if id1 != "host=web.corp.internal;port=2222;user=deploy;jump=" { + t.Fatalf("unexpected canonical identity: %q", id1) + } + if got := FormatTargetIdentity(id1); got != "deploy@web.corp.internal:2222" { + t.Fatalf("unexpected formatted identity: %q", got) + } + + // Same host (case-insensitive), port, and user matches + same := TargetContext{Host: "web.corp.internal", Port: 2222, User: "deploy"} + if !same.MatchesMasterTarget(id1) { + t.Fatal("expected same target to match master identity") + } + + // Different host must not match + diffHost := TargetContext{Host: "db.corp.internal", Port: 2222, User: "deploy"} + if diffHost.MatchesMasterTarget(id1) { + t.Fatal("expected different host to fail target match") + } + + // Different port must not match + diffPort := TargetContext{Host: "web.corp.internal", Port: 22, User: "deploy"} + if diffPort.MatchesMasterTarget(id1) { + t.Fatal("expected different port to fail target match") + } + + // Different user must not match + diffUser := TargetContext{Host: "web.corp.internal", Port: 2222, User: "root"} + if diffUser.MatchesMasterTarget(id1) { + t.Fatal("expected different user to fail target match") + } + + // Different jump host must not match + withJump := TargetContext{Host: "web.corp.internal", Port: 2222, User: "deploy", JumpHost: "bastion"} + if withJump.MatchesMasterTarget(id1) { + t.Fatal("expected different jump host to fail target match") + } +} + +func TestExpandControlPath_AndConnectorTargetContext_EmptyUserFallsBackToLocalUser(t *testing.T) { + env := Environment{ + HomeDir: "/home/localdev", + LocalHost: "workstation.local", + LocalUser: "localdev", + LocalUID: "1000", + } + + emptyUserTarget := TargetContext{ + Host: "10.0.0.10", + Port: 22, + User: "", + } + explicitUserTarget := TargetContext{ + Host: "10.0.0.10", + Port: 22, + User: "localdev", + } + + gotEmpty, err := ExpandControlPath("~/.ssh/cm-%r-%C", env, emptyUserTarget) + if err != nil { + t.Fatalf("ExpandControlPath(emptyUser) failed: %v", err) + } + gotExplicit, err := ExpandControlPath("~/.ssh/cm-%r-%C", env, explicitUserTarget) + if err != nil { + t.Fatalf("ExpandControlPath(explicitUser) failed: %v", err) + } + if gotEmpty != gotExplicit { + t.Fatalf("expected empty TargetContext.User to fall back to env.LocalUser: got %q vs %q", gotEmpty, gotExplicit) + } + + conn := NewConnector(nil, WithEnvironment(env)) + targetCtx := conn.buildTargetContext(connectionPlanNode{ + name: "adhoc-node", + cfg: &ClientConfig{ + Address: "10.0.0.10", + Port: 0, + User: "", + }, + }) + if targetCtx.User != "localdev" || targetCtx.Port != 22 { + t.Fatalf("expected buildTargetContext to populate User='localdev' and Port=22, got %+v", targetCtx) + } +} diff --git a/core/ssh/environment.go b/core/ssh/environment.go index a8ce56fc..20bc976d 100644 --- a/core/ssh/environment.go +++ b/core/ssh/environment.go @@ -47,6 +47,13 @@ type Environment struct { ResolveKeyPath func(string) string InteractiveIO InteractiveIO InputBridge InputBridge + + // Multiplexing and path expansion parameters injected by host + HomeDir string + LocalUser string + LocalHost string + LocalUID string + LookupEnv func(string) (string, bool) } func WithEnvironment(environment Environment) Option { diff --git a/core/ssh/execute.go b/core/ssh/execute.go index 7a391606..4cd0e871 100644 --- a/core/ssh/execute.go +++ b/core/ssh/execute.go @@ -378,7 +378,7 @@ func (c *Client) runWithSudo(ctx context.Context, command string, password []byt session, err := c.newSessionContext(ctx) if err != nil { - return "", fmt.Errorf("failed to create new session: %w", err) + return "", err } defer func() { retErr = errors.Join(retErr, c.closeSessionBounded(session)) }() @@ -864,7 +864,7 @@ func (c *Client) runSessionWithInput(ctx context.Context, request string, start } session, err := c.newSessionContext(ctx) if err != nil { - return fmt.Errorf("failed to create new session: %w", err) + return err } defer func() { retErr = errors.Join(retErr, c.closeSessionBounded(session)) }() diff --git a/core/ssh/mux_client.go b/core/ssh/mux_client.go new file mode 100644 index 00000000..3cd7809e --- /dev/null +++ b/core/ssh/mux_client.go @@ -0,0 +1,575 @@ +package ssh + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "runtime" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + cryptoSSH "golang.org/x/crypto/ssh" +) + +// DefaultMuxHandshakeTimeout bounds control-socket handshakes and RPCs when the +// caller's context carries no deadline of its own. +const DefaultMuxHandshakeTimeout = 10 * time.Second + +// bindConnContext bounds I/O on conn by the earlier of ctx.Deadline() and +// defaultTimeout, and interrupts in-flight reads/writes immediately if ctx is +// canceled even when ctx has no deadline. +func bindConnContext(ctx context.Context, conn net.Conn, defaultTimeout time.Duration) func() { + if ctx == nil { + ctx = context.Background() + } + deadline, hasDeadline := ctx.Deadline() + if defaultTimeout > 0 { + fallback := time.Now().Add(defaultTimeout) + if !hasDeadline || fallback.Before(deadline) { + deadline = fallback + hasDeadline = true + } + } + if hasDeadline { + _ = conn.SetDeadline(deadline) + } + stop := context.AfterFunc(ctx, func() { + _ = conn.SetDeadline(time.Now()) + }) + return func() { + stop() + if ctx.Err() == nil { + _ = conn.SetDeadline(time.Time{}) + } + } +} + +// ForwardType defines the port forwarding direction in OpenSSH PROTOCOL.mux. +type ForwardType uint32 + +const ( + ForwardLocal ForwardType = ForwardType(MUX_FWD_LOCAL) + ForwardRemote ForwardType = ForwardType(MUX_FWD_REMOTE) + ForwardDynamic ForwardType = ForwardType(MUX_FWD_DYNAMIC) +) + +// ForwardRequest represents a dynamic, local, or remote forwarding request. +type ForwardRequest struct { + Type ForwardType + ListenHost string + ListenPort uint32 + ConnectHost string + ConnectPort uint32 +} + +// MuxClientOption configures a control-socket client connection. +type MuxClientOption func(*muxClientOptions) + +type muxClientOptions struct { + expectedUID uint32 + provider ConnectionProvider + resolver SecretResolver + prompter SecretPrompter + passwordPromptPattern string +} + +// WithExpectedPeerUID overrides the local user identity that the master endpoint +// must run as. Zero means "expect root" on platforms that expose peer +// credentials; it never disables the check, because a client running as root +// would otherwise accept a socket planted by any other local user. Platforms +// without peer credentials already treat the check as a no-op. +func WithExpectedPeerUID(uid uint32) MuxClientOption { + return func(options *muxClientOptions) { options.expectedUID = uid } +} + +// WithMuxConnectionProvider configures the connection provider used by a +// control-proxy client to inspect node configuration without persisting edits. +func WithMuxConnectionProvider(provider ConnectionProvider) MuxClientOption { + return func(options *muxClientOptions) { options.provider = provider } +} + +// WithMuxSecretResolver configures the secret resolver used by a control-proxy +// client for privilege elevation over the multiplexed connection. +func WithMuxSecretResolver(resolver SecretResolver) MuxClientOption { + return func(options *muxClientOptions) { options.resolver = resolver } +} + +// WithMuxSecretPrompter configures the interactive secret prompter used by a +// control-proxy client when privilege elevation requires user input. +func WithMuxSecretPrompter(prompter SecretPrompter) MuxClientOption { + return func(options *muxClientOptions) { options.prompter = prompter } +} + +// WithMuxPasswordPromptPattern configures the fallback password prompt regex +// used by a control-proxy client when node configuration does not specify one. +func WithMuxPasswordPromptPattern(pattern string) MuxClientOption { + return func(options *muxClientOptions) { options.passwordPromptPattern = pattern } +} + +// MuxClient manages communication with an OpenSSH multiplexing master process. +type MuxClient struct { + socketPath string + expectedUID uint32 + conn net.Conn + reqID atomic.Uint32 + serverVer uint32 + extensions map[string]string + closed atomic.Bool + closeMu sync.Mutex + ioMu sync.Mutex +} + +// DialMuxClient connects to the control socket and exchanges MUX_MSG_HELLO. +// The endpoint must belong to the effective local user (or root); a socket +// planted by another user in a shared directory is rejected before any request +// is sent. +func DialMuxClient(ctx context.Context, socketPath string, opts ...MuxClientOption) (*MuxClient, error) { + options := muxClientOptions{expectedUID: localPeerUID()} + for _, opt := range opts { + if opt != nil { + opt(&options) + } + } + + var dialer net.Dialer + conn, err := dialer.DialContext(ctx, "unix", socketPath) + if err != nil { + return nil, fmt.Errorf("dial mux socket %q: %w", socketPath, err) + } + + unbind := bindConnContext(ctx, conn, DefaultMuxHandshakeTimeout) + defer unbind() + + if err := checkPeerUID(conn, options.expectedUID); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("control socket %q rejected: %w", socketPath, err) + } + + client := &MuxClient{ + socketPath: socketPath, + expectedUID: options.expectedUID, + conn: conn, + extensions: make(map[string]string), + } + + if err := client.handshake(); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("mux handshake failed on %q: %w", socketPath, err) + } + + return client, nil +} + +func (m *MuxClient) nextID() uint32 { + return m.reqID.Add(1) +} + +// Close closes the underlying socket connection. +func (m *MuxClient) Close() error { + if m.closed.Swap(true) { + return nil + } + m.closeMu.Lock() + defer m.closeMu.Unlock() + if m.conn != nil { + return m.conn.Close() + } + return nil +} + +// Extension returns the value of an extension advertised by the master in MUX_MSG_HELLO. +func (m *MuxClient) Extension(name string) (string, bool) { + if m == nil || m.extensions == nil { + return "", false + } + val, ok := m.extensions[name] + return val, ok +} + +func (m *MuxClient) handshake() error { + // Client sends HELLO + if err := WriteMuxPacket(m.conn, MUX_MSG_HELLO, EncodeMuxHello(MuxProtoVersion, nil)); err != nil { + return fmt.Errorf("send hello: %w", err) + } + + // Client reads HELLO + msgType, payload, err := ReadMuxPacket(m.conn) + if err != nil { + return fmt.Errorf("read hello: %w", err) + } + if msgType != MUX_MSG_HELLO { + return fmt.Errorf("expected MUX_MSG_HELLO (0x01), got 0x%08x", msgType) + } + + serverVer, extensions, err := DecodeMuxHello(payload) + if err != nil { + return fmt.Errorf("decode hello response: %w", err) + } + m.serverVer = serverVer + if m.serverVer != MuxProtoVersion { + return fmt.Errorf("unsupported mux server version %d (expected %d)", m.serverVer, MuxProtoVersion) + } + for _, ext := range extensions { + m.extensions[ext.Name] = ext.Value + } + return nil +} + +// AliveCheck queries the master process alive status and returns the server PID. +func (m *MuxClient) AliveCheck(ctx context.Context) (uint32, error) { + m.ioMu.Lock() + defer m.ioMu.Unlock() + + unbind := bindConnContext(ctx, m.conn, DefaultMuxHandshakeTimeout) + defer unbind() + + reqID := m.nextID() + if err := WriteMuxPacket(m.conn, MUX_C_ALIVE_CHECK, EncodeMuxRequestID(reqID)); err != nil { + return 0, fmt.Errorf("send alive check: %w", err) + } + + msgType, payload, err := ReadMuxPacket(m.conn) + if err != nil { + return 0, fmt.Errorf("read alive check response: %w", err) + } + + switch msgType { + case MUX_S_ALIVE: + respID, pid, err := DecodeMuxAlive(payload) + if err != nil { + return 0, fmt.Errorf("decode alive response: %w", err) + } + if respID != reqID { + return 0, fmt.Errorf("request id mismatch: expected %d, got %d", reqID, respID) + } + return pid, nil + + case MUX_S_PERMISSION_DENIED, MUX_S_FAILURE: + _, reason, _ := DecodeMuxError(payload) + return 0, fmt.Errorf("alive check rejected (0x%08x): %s", msgType, reason) + + default: + return 0, fmt.Errorf("unexpected alive check response type: 0x%08x", msgType) + } +} + +// Terminate requests the master process to terminate and disconnect the session. +func (m *MuxClient) Terminate(ctx context.Context) error { + m.ioMu.Lock() + defer m.ioMu.Unlock() + + unbind := bindConnContext(ctx, m.conn, DefaultMuxHandshakeTimeout) + defer unbind() + + reqID := m.nextID() + if err := WriteMuxPacket(m.conn, MUX_C_TERMINATE, EncodeMuxRequestID(reqID)); err != nil { + return fmt.Errorf("send terminate: %w", err) + } + + msgType, payload, err := ReadMuxPacket(m.conn) + if err != nil { + return fmt.Errorf("read terminate response: %w", err) + } + + switch msgType { + case MUX_S_OK: + return nil + case MUX_S_PERMISSION_DENIED: + _, reason, _ := DecodeMuxError(payload) + return fmt.Errorf("master refused termination request: %s", reason) + case MUX_S_FAILURE: + _, reason, _ := DecodeMuxError(payload) + return fmt.Errorf("termination failed: %s", reason) + default: + return fmt.Errorf("unexpected terminate response type: 0x%08x", msgType) + } +} + +// StopListening requests the master to unlink the control socket and stop accepting new clients. +func (m *MuxClient) StopListening(ctx context.Context) error { + m.ioMu.Lock() + defer m.ioMu.Unlock() + + unbind := bindConnContext(ctx, m.conn, DefaultMuxHandshakeTimeout) + defer unbind() + + reqID := m.nextID() + if err := WriteMuxPacket(m.conn, MUX_C_STOP_LISTENING, EncodeMuxRequestID(reqID)); err != nil { + return fmt.Errorf("send stop listening: %w", err) + } + + msgType, payload, err := ReadMuxPacket(m.conn) + if err != nil { + return fmt.Errorf("read stop listening response: %w", err) + } + + switch msgType { + case MUX_S_OK: + return nil + case MUX_S_FAILURE: + _, reason, _ := DecodeMuxError(payload) + return fmt.Errorf("master refused stop listening request: %s", reason) + default: + return fmt.Errorf("unexpected stop listening response type: 0x%08x", msgType) + } +} + +// OpenForward requests port forwarding on the master. Returns dynamically allocated remote port if requested. +func (m *MuxClient) OpenForward(ctx context.Context, req ForwardRequest) (uint32, error) { + m.ioMu.Lock() + defer m.ioMu.Unlock() + + unbind := bindConnContext(ctx, m.conn, DefaultMuxHandshakeTimeout) + defer unbind() + + reqID := m.nextID() + fwdReq := MuxFwdRequest{ + RequestID: reqID, + FwdType: uint32(req.Type), + ListenHost: req.ListenHost, + ListenPort: req.ListenPort, + ConnectHost: req.ConnectHost, + ConnectPort: req.ConnectPort, + } + + if err := WriteMuxPacket(m.conn, MUX_C_OPEN_FWD, EncodeMuxFwdRequest(fwdReq)); err != nil { + return 0, fmt.Errorf("send open forward: %w", err) + } + + msgType, respPayload, err := ReadMuxPacket(m.conn) + if err != nil { + return 0, fmt.Errorf("read open forward response: %w", err) + } + + switch msgType { + case MUX_S_OK: + return 0, nil + case MUX_S_REMOTE_PORT: + _, allocatedPort, err := DecodeMuxRemotePort(respPayload) + if err != nil { + return 0, fmt.Errorf("decode remote port: %w", err) + } + return allocatedPort, nil + case MUX_S_FAILURE, MUX_S_PERMISSION_DENIED: + _, reason, _ := DecodeMuxError(respPayload) + return 0, fmt.Errorf("forwarding request failed: %s", reason) + default: + return 0, fmt.Errorf("unexpected open forward response: 0x%08x", msgType) + } +} + +// CloseForward cancels an active port forwarding on the master. +func (m *MuxClient) CloseForward(ctx context.Context, req ForwardRequest) error { + m.ioMu.Lock() + defer m.ioMu.Unlock() + + unbind := bindConnContext(ctx, m.conn, DefaultMuxHandshakeTimeout) + defer unbind() + + reqID := m.nextID() + fwdReq := MuxFwdRequest{ + RequestID: reqID, + FwdType: uint32(req.Type), + ListenHost: req.ListenHost, + ListenPort: req.ListenPort, + ConnectHost: req.ConnectHost, + ConnectPort: req.ConnectPort, + } + + if err := WriteMuxPacket(m.conn, MUX_C_CLOSE_FWD, EncodeMuxFwdRequest(fwdReq)); err != nil { + return fmt.Errorf("send close forward: %w", err) + } + + msgType, respPayload, err := ReadMuxPacket(m.conn) + if err != nil { + return fmt.Errorf("read close forward response: %w", err) + } + + switch msgType { + case MUX_S_OK: + return nil + case MUX_S_FAILURE: + _, reason, _ := DecodeMuxError(respPayload) + return fmt.Errorf("cancel forward failed: %s", reason) + default: + return fmt.Errorf("unexpected close forward response: 0x%08x", msgType) + } +} + +// HasExtension returns true if the master server advertised the specified extension. +func (m *MuxClient) HasExtension(name string) bool { + m.closeMu.Lock() + defer m.closeMu.Unlock() + _, ok := m.extensions[name] + return ok +} + +// RequestInfo sends a MUX_C_EXT_INFO query to the master process ("connection" or "channels"). +func (m *MuxClient) RequestInfo(ctx context.Context, name string) (string, error) { + m.ioMu.Lock() + defer m.ioMu.Unlock() + + if !m.HasExtension("info") { + return "", fmt.Errorf("mux server does not support info request") + } + + unbind := bindConnContext(ctx, m.conn, DefaultMuxHandshakeTimeout) + defer unbind() + + reqID := m.nextID() + if err := WriteMuxPacket(m.conn, MUX_C_EXT_INFO, EncodeMuxExtInfo(reqID, name)); err != nil { + return "", fmt.Errorf("send ext info: %w", err) + } + + msgType, payload, err := ReadMuxPacket(m.conn) + if err != nil { + return "", fmt.Errorf("read ext info response: %w", err) + } + + switch msgType { + case MUX_S_EXT_INFO: + respID, status, msg, err := DecodeMuxExtInfoResponse(payload) + if err != nil { + return "", fmt.Errorf("decode ext info response: %w", err) + } + if respID != reqID { + return "", fmt.Errorf("request id mismatch: expected %d, got %d", reqID, respID) + } + if status == 0 { + return "", fmt.Errorf("master returned error: %s", msg) + } + return msg, nil + + case MUX_S_FAILURE, MUX_S_PERMISSION_DENIED: + _, reason, _ := DecodeMuxError(payload) + return "", fmt.Errorf("ext info rejected: %s", reason) + + default: + return "", fmt.Errorf("unexpected ext info response type: 0x%08x", msgType) + } +} + +// ErrControlProxyRejected reports that the master refused a MUX_C_PROXY request. +var ErrControlProxyRejected = errors.New("control master rejected proxy mode") + +// DialMuxProxyStream connects to the control socket, verifies the endpoint's peer +// user, completes the HELLO handshake, and switches to MUX_C_PROXY transport +// mode. The returned connection carries raw SSH transport bytes and is owned by +// the caller. It is the caller-facing equivalent of DialControlProxy for cases +// that need the socket itself rather than a cryptoSSH.Client. +func DialMuxProxyStream(ctx context.Context, socketPath string, opts ...MuxClientOption) (net.Conn, error) { + options := muxClientOptions{expectedUID: localPeerUID()} + for _, opt := range opts { + if opt != nil { + opt(&options) + } + } + + var dialer net.Dialer + conn, err := dialer.DialContext(ctx, "unix", socketPath) + if err != nil { + return nil, fmt.Errorf("dial control socket %q: %w", socketPath, err) + } + + unbind := bindConnContext(ctx, conn, DefaultMuxHandshakeTimeout) + defer unbind() + fail := func(err error) (net.Conn, error) { + _ = conn.Close() + return nil, err + } + + if err := checkPeerUID(conn, options.expectedUID); err != nil { + return fail(fmt.Errorf("control socket %q rejected: %w", socketPath, err)) + } + + const requestID uint32 = 1 + if err := WriteMuxPacket(conn, MUX_MSG_HELLO, EncodeMuxHello(MuxProtoVersion, nil)); err != nil { + return fail(fmt.Errorf("send hello on %q: %w", socketPath, err)) + } + msgType, payload, err := ReadMuxPacket(conn) + if err != nil { + return fail(fmt.Errorf("read hello on %q: %w", socketPath, err)) + } + if msgType != MUX_MSG_HELLO { + return fail(fmt.Errorf("unexpected hello response 0x%08x on %q", msgType, socketPath)) + } + version, _, err := DecodeMuxHello(payload) + if err != nil { + return fail(fmt.Errorf("decode hello on %q: %w", socketPath, err)) + } + if version != MuxProtoVersion { + return fail(fmt.Errorf("unsupported mux server version %d on %q", version, socketPath)) + } + + if err := WriteMuxPacket(conn, MUX_C_PROXY, EncodeMuxRequestID(requestID)); err != nil { + return fail(fmt.Errorf("send proxy request on %q: %w", socketPath, err)) + } + msgType, payload, err = ReadMuxPacket(conn) + if err != nil { + return fail(fmt.Errorf("read proxy response on %q: %w", socketPath, err)) + } + switch msgType { + case MUX_S_PROXY: + respID, err := DecodeMuxClientRequestID(payload) + if err != nil { + return fail(fmt.Errorf("decode proxy response on %q: %w", socketPath, err)) + } + if respID != requestID { + return fail(fmt.Errorf("proxy response request id mismatch on %q: expected %d, got %d", socketPath, requestID, respID)) + } + case MUX_S_PERMISSION_DENIED, MUX_S_FAILURE: + _, reason, _ := DecodeMuxError(payload) + return fail(fmt.Errorf("%w on %q: %s", ErrControlProxyRejected, socketPath, reason)) + default: + return fail(fmt.Errorf("%w on %q: unexpected response 0x%08x", ErrControlProxyRejected, socketPath, msgType)) + } + + if err := ctx.Err(); err != nil { + return fail(err) + } + return conn, nil +} + +// Proxy switches the multiplexed connection into SSH transport proxy mode, +// returning an authenticated *cryptoSSH.Client wrapping muxProxySSHConn. +func (m *MuxClient) Proxy(ctx context.Context, target TargetContext) (*cryptoSSH.Client, *muxProxySSHConn, error) { + return DialControlProxy(ctx, m.socketPath, target, WithExpectedPeerUID(m.expectedUID)) +} + +func isConnectionRefused(err error) bool { + if err == nil { + return false + } + var sysErr syscall.Errno + if errors.As(err, &sysErr) && sysErr == syscall.ECONNREFUSED { + return true + } + errStr := strings.ToLower(err.Error()) + return strings.Contains(errStr, "connection refused") || strings.Contains(errStr, "wsaeconnrefused") +} + +// isControlSocketFileInfo reports whether fi describes a valid control socket +// entry on the current platform. On Unix only actual socket inodes qualify so a +// regular file at ControlPath is never mistaken for a stale socket and unlinked. +func isControlSocketFileInfo(fi os.FileInfo) bool { + if fi == nil { + return false + } + if fi.Mode()&os.ModeSocket != 0 { + return true + } + return runtime.GOOS == "windows" && fi.Mode().IsRegular() +} + +func socketPathExists(path string) bool { + fi, err := os.Lstat(path) + if err != nil { + return false + } + return isControlSocketFileInfo(fi) +} diff --git a/core/ssh/mux_client_test.go b/core/ssh/mux_client_test.go new file mode 100644 index 00000000..08a1d61c --- /dev/null +++ b/core/ssh/mux_client_test.go @@ -0,0 +1,336 @@ +package ssh + +import ( + "context" + "net" + "os" + "path/filepath" + "syscall" + "testing" + "time" +) + +func createTestUnixSocket(t *testing.T) (string, net.Listener) { + t.Helper() + tmpDir, err := os.MkdirTemp("", "mux-test-*") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + sockPath := filepath.Join(tmpDir, "test.sock") + ln, err := net.Listen("unix", sockPath) + if err != nil { + _ = os.RemoveAll(tmpDir) + t.Fatalf("Listen unix failed: %v", err) + } + t.Cleanup(func() { + _ = ln.Close() + _ = os.RemoveAll(tmpDir) + }) + return sockPath, ln +} + +func TestMuxClient_Handshake(t *testing.T) { + sockPath, ln := createTestUnixSocket(t) + + // Server goroutine + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer func() { _ = conn.Close() }() + + // Read HELLO from client + pktType, payload, err := ReadMuxPacket(conn) + if err != nil || pktType != MUX_MSG_HELLO { + return + } + ver, _, _ := DecodeMuxHello(payload) + if ver != MuxProtoVersion { + return + } + + // Send HELLO response with extension + exts := []MuxExtension{{Name: "test-ext", Value: "active"}} + _ = WriteMuxPacket(conn, MUX_MSG_HELLO, EncodeMuxHello(MuxProtoVersion, exts)) + }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + client, err := DialMuxClient(ctx, sockPath) + if err != nil { + t.Fatalf("DialMuxClient failed: %v", err) + } + defer func() { _ = client.Close() }() + + if client.serverVer != MuxProtoVersion { + t.Fatalf("expected server version %d, got %d", MuxProtoVersion, client.serverVer) + } + if client.extensions["test-ext"] != "active" { + t.Fatalf("expected extension test-ext=active, got %v", client.extensions) + } +} + +func TestMuxClient_Handshake_VersionMismatch(t *testing.T) { + sockPath, ln := createTestUnixSocket(t) + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer func() { _ = conn.Close() }() + + _, _, _ = ReadMuxPacket(conn) + // Reply with incompatible version 3 + _ = WriteMuxPacket(conn, MUX_MSG_HELLO, EncodeMuxHello(3, nil)) + }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + _, err := DialMuxClient(ctx, sockPath) + if err == nil { + t.Fatal("expected version mismatch error, got nil") + } +} + +func TestMuxClient_AliveCheck(t *testing.T) { + sockPath, ln := createTestUnixSocket(t) + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer func() { _ = conn.Close() }() + + // Handshake + _, _, _ = ReadMuxPacket(conn) + _ = WriteMuxPacket(conn, MUX_MSG_HELLO, EncodeMuxHello(MuxProtoVersion, nil)) + + // Alive check + pktType, payload, err := ReadMuxPacket(conn) + if err != nil || pktType != MUX_C_ALIVE_CHECK { + return + } + reqID, _ := DecodeMuxRequestID(payload) + _ = WriteMuxPacket(conn, MUX_S_ALIVE, EncodeMuxAlive(reqID, 8888)) + }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + client, err := DialMuxClient(ctx, sockPath) + if err != nil { + t.Fatalf("DialMuxClient failed: %v", err) + } + defer func() { _ = client.Close() }() + + pid, err := client.AliveCheck(ctx) + if err != nil { + t.Fatalf("AliveCheck failed: %v", err) + } + if pid != 8888 { + t.Fatalf("expected pid 8888, got %d", pid) + } +} + +func TestMuxClient_Terminate_StopListening(t *testing.T) { + sockPath, ln := createTestUnixSocket(t) + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer func() { _ = conn.Close() }() + + // Handshake + _, _, _ = ReadMuxPacket(conn) + _ = WriteMuxPacket(conn, MUX_MSG_HELLO, EncodeMuxHello(MuxProtoVersion, nil)) + + // 1. Terminate + pktType, payload, _ := ReadMuxPacket(conn) + if pktType == MUX_C_TERMINATE { + reqID, _ := DecodeMuxRequestID(payload) + _ = WriteMuxPacket(conn, MUX_S_OK, EncodeMuxClientRequestID(reqID)) + } + + // 2. Stop Listening + pktType, payload, _ = ReadMuxPacket(conn) + if pktType == MUX_C_STOP_LISTENING { + reqID, _ := DecodeMuxRequestID(payload) + _ = WriteMuxPacket(conn, MUX_S_OK, EncodeMuxClientRequestID(reqID)) + } + }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + client, err := DialMuxClient(ctx, sockPath) + if err != nil { + t.Fatalf("DialMuxClient failed: %v", err) + } + defer func() { _ = client.Close() }() + + if err := client.Terminate(ctx); err != nil { + t.Fatalf("Terminate failed: %v", err) + } + if err := client.StopListening(ctx); err != nil { + t.Fatalf("StopListening failed: %v", err) + } +} + +func TestMuxClient_Forwarding(t *testing.T) { + sockPath, ln := createTestUnixSocket(t) + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer func() { _ = conn.Close() }() + + // Handshake + _, _, _ = ReadMuxPacket(conn) + _ = WriteMuxPacket(conn, MUX_MSG_HELLO, EncodeMuxHello(MuxProtoVersion, nil)) + + // Open Forward + pktType, payload, _ := ReadMuxPacket(conn) + if pktType == MUX_C_OPEN_FWD { + req, _ := DecodeMuxFwdRequest(payload) + _ = WriteMuxPacket(conn, MUX_S_REMOTE_PORT, EncodeMuxRemotePort(req.RequestID, 54321)) + } + + // Close Forward + pktType, payload, _ = ReadMuxPacket(conn) + if pktType == MUX_C_CLOSE_FWD { + req, _ := DecodeMuxFwdRequest(payload) + _ = WriteMuxPacket(conn, MUX_S_OK, EncodeMuxClientRequestID(req.RequestID)) + } + }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + client, err := DialMuxClient(ctx, sockPath) + if err != nil { + t.Fatalf("DialMuxClient failed: %v", err) + } + defer func() { _ = client.Close() }() + + fwdReq := ForwardRequest{ + Type: ForwardRemote, + ListenHost: "0.0.0.0", + ListenPort: 0, + ConnectHost: "127.0.0.1", + ConnectPort: 8080, + } + + allocPort, err := client.OpenForward(ctx, fwdReq) + if err != nil { + t.Fatalf("OpenForward failed: %v", err) + } + if allocPort != 54321 { + t.Fatalf("expected allocated port 54321, got %d", allocPort) + } + + if err := client.CloseForward(ctx, fwdReq); err != nil { + t.Fatalf("CloseForward failed: %v", err) + } +} + +func TestMuxClient_StaleSocketDetection(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "mux-stale-*") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(tmpDir) }) + + sockPath := filepath.Join(tmpDir, "stale.sock") + fd, err := syscall.Socket(syscall.AF_UNIX, syscall.SOCK_STREAM, 0) + if err != nil { + t.Fatalf("syscall.Socket failed: %v", err) + } + sa := &syscall.SockaddrUnix{Name: sockPath} + if err := syscall.Bind(fd, sa); err != nil { + _ = syscall.Close(fd) + t.Fatalf("syscall.Bind failed: %v", err) + } + // Close fd without unlinking the path -> creates real dead/stale socket file! + _ = syscall.Close(fd) + + if !socketPathExists(sockPath) { + t.Fatalf("expected socket file %q to exist on disk", sockPath) + } + + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + _, err = DialMuxClient(ctx, sockPath) + if err == nil { + t.Fatal("expected dial error on closed socket, got nil") + } + if !isConnectionRefused(err) { + t.Fatalf("expected isConnectionRefused=true for err: %v", err) + } +} + +func TestMuxClient_RequestInfo(t *testing.T) { + sockPath, ln := createTestUnixSocket(t) + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer func() { _ = conn.Close() }() + + // Handshake + pktType, _, _ := ReadMuxPacket(conn) + if pktType != MUX_MSG_HELLO { + return + } + exts := []MuxExtension{{Name: "info", Value: "1"}} + _ = WriteMuxPacket(conn, MUX_MSG_HELLO, EncodeMuxHello(MuxProtoVersion, exts)) + + // Request + pktType, payload, err := ReadMuxPacket(conn) + if err != nil || pktType != MUX_C_EXT_INFO { + return + } + reqID, name, err := DecodeMuxExtInfo(payload) + if err != nil { + return + } + + if name == "connection" { + _ = WriteMuxPacket(conn, MUX_S_EXT_INFO, EncodeMuxExtInfoResponse(reqID, 1, "test connection info")) + } else { + _ = WriteMuxPacket(conn, MUX_S_FAILURE, EncodeMuxError(reqID, "unsupported info type")) + } + }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + client, err := DialMuxClient(ctx, sockPath) + if err != nil { + t.Fatalf("DialMuxClient failed: %v", err) + } + defer func() { _ = client.Close() }() + + if !client.HasExtension("info") { + t.Fatal("expected client to have info extension") + } + + info, err := client.RequestInfo(ctx, "connection") + if err != nil { + t.Fatalf("RequestInfo failed: %v", err) + } + if info != "test connection info" { + t.Fatalf("expected 'test connection info', got %q", info) + } +} diff --git a/core/ssh/mux_lifecycle_test.go b/core/ssh/mux_lifecycle_test.go new file mode 100644 index 00000000..a177b41c --- /dev/null +++ b/core/ssh/mux_lifecycle_test.go @@ -0,0 +1,495 @@ +package ssh + +import ( + "context" + "errors" + "net" + "path/filepath" + "runtime" + "sync" + "testing" + "time" + + "github.com/wentf9/xops-cli/core/testutil/sshfixture" +) + +// TestControlClientRejectsForeignMaster verifies that a client refuses a control +// socket whose peer is not the expected local user. Without this check any local +// user could plant a socket in a shared directory and receive the session. +func TestControlClientRejectsForeignMaster(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("peer credentials are unavailable on Windows") + } + + socketPath := filepath.Join(t.TempDir(), "foreign.sock") + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: socketPath, + SSHClient: newDummySSHClient(), + PersistEnabled: true, + PersistIndefinite: true, + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + defer func() { _ = server.Shutdown() }() + go func() { _ = server.Serve() }() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Baseline: the real local UID is accepted. + client, err := DialMuxClient(ctx, socketPath) + if err != nil { + t.Fatalf("DialMuxClient with the local UID failed: %v", err) + } + if _, err := client.AliveCheck(ctx); err != nil { + _ = client.Close() + t.Fatalf("AliveCheck failed: %v", err) + } + _ = client.Close() + + // A different expected UID must be rejected before any request is sent. + foreignUID := localPeerUID() + 1 + if _, err := DialMuxClient(ctx, socketPath, WithExpectedPeerUID(foreignUID)); !errors.Is(err, ErrUnauthorizedPeer) { + t.Fatalf("expected ErrUnauthorizedPeer from DialMuxClient, got %v", err) + } + + rawConn, err := net.Dial("unix", socketPath) + if err != nil { + t.Fatalf("dial failed: %v", err) + } + defer func() { _ = rawConn.Close() }() + if err := checkPeerUID(rawConn, foreignUID); !errors.Is(err, ErrUnauthorizedPeer) { + t.Fatalf("expected ErrUnauthorizedPeer from checkPeerUID, got %v", err) + } + if err := checkPeerUID(rawConn, localPeerUID()); err != nil { + t.Fatalf("expected local UID to pass, got %v", err) + } +} + +// TestMuxServer_AskModeRequiresConfirmation verifies that ask/autoask masters do +// not admit sessions silently and fail closed when no confirmer is configured. +func TestMuxServer_AskModeRequiresConfirmation(t *testing.T) { + start := func(t *testing.T, confirm func(string) bool) *MuxServer { + t.Helper() + socketPath := filepath.Join(t.TempDir(), "ask.sock") + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: socketPath, + SSHClient: newDummySSHClient(), + PersistEnabled: true, + PersistIndefinite: true, + AskConfirmation: true, + ConfirmSession: confirm, + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + go func() { _ = server.Serve() }() + t.Cleanup(func() { _ = server.Shutdown() }) + return server + } + + t.Run("no confirmer rejects", func(t *testing.T) { + server := start(t, nil) + + proxyConn, err := connectTestProxyClient(server.ControlPath()) + if err != nil { + if proxyConn != nil { + _ = proxyConn.Close() + } + // MUX_S_PERMISSION_DENIED surfaces as a handshake failure. + return + } + _ = proxyConn.Close() + t.Fatal("expected proxy admission to be rejected without a confirmer") + }) + + t.Run("denied confirmer rejects", func(t *testing.T) { + server := start(t, func(string) bool { return false }) + if _, err := connectTestProxyClient(server.ControlPath()); err == nil { + t.Fatal("expected proxy admission to be rejected when the confirmer denies") + } + }) + + t.Run("approved confirmer admits", func(t *testing.T) { + var mu sync.Mutex + var actions []string + server := start(t, func(action string) bool { + mu.Lock() + defer mu.Unlock() + actions = append(actions, action) + return true + }) + + proxyConn, err := connectTestProxyClient(server.ControlPath()) + if err != nil { + t.Fatalf("expected approved proxy session to be admitted: %v", err) + } + _ = proxyConn.Close() + + mu.Lock() + defer mu.Unlock() + if len(actions) != 1 || actions[0] != "proxy session" { + t.Fatalf("expected one proxy session confirmation, got %v", actions) + } + }) + + t.Run("not-ask master skips confirmation", func(t *testing.T) { + socketPath := filepath.Join(t.TempDir(), "plain.sock") + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: socketPath, + SSHClient: newDummySSHClient(), + PersistEnabled: true, + PersistIndefinite: true, + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + defer func() { _ = server.Shutdown() }() + go func() { _ = server.Serve() }() + + proxyConn, err := connectTestProxyClient(socketPath) + if err != nil { + t.Fatalf("non-ask master must admit sessions: %v", err) + } + _ = proxyConn.Close() + }) +} + +// TestConnectorCloseAllReleasesOwnedMaster verifies that a connector releases the +// control socket it owns. Leaving the socket behind makes the next command treat +// a dead master as alive. +func TestConnectorCloseAllReleasesOwnedMaster(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + fixture := startMuxFixture(t, ctx, sshfixture.Options{}) + + socketPath := filepath.Join(t.TempDir(), "owned.sock") + provider := &testMuxProvider{ + cfg: &ClientConfig{ + NodeID: "owned-node", + Address: fixture.Host, + Port: fixture.Port, + User: "fixture", + AuthType: "password", + Password: sshfixture.Password, + }, + ctrl: &ControlConfig{Master: ControlMasterAuto, Path: socketPath}, + } + + connector := newTestConnector(provider, WithHostKeyVerifier(&fixtureTrust{})) + if _, err := connector.Connect(ctx, "owned-node"); err != nil { + _ = connector.CloseAll() + t.Fatalf("connect failed: %v", err) + } + + waitForCondition(t, 5*time.Second, func() bool { + return GetActiveMuxServer(socketPath) != nil + }, "connector did not start a master") + + if err := connector.CloseAll(); err != nil { + t.Fatalf("CloseAll failed: %v", err) + } + + if GetActiveMuxServer(socketPath) != nil { + t.Fatal("CloseAll left the master registered") + } + if socketPathExists(socketPath) { + t.Fatalf("CloseAll left a stale control socket at %q", socketPath) + } +} + +func waitForCondition(t *testing.T, timeout time.Duration, condition func() bool, message string) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if condition() { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal(message) +} + +// TestStartMuxServerDefaultsPeerUIDCheck verifies that a host which omits +// Environment.LocalUID cannot silently disable the master-side peer credential +// check, and that an explicit or malformed value never weakens it either. +func TestStartMuxServerDefaultsPeerUIDCheck(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + + start := func(t *testing.T, localUID string) *MuxServer { + t.Helper() + server, err := StartMuxServer(ctx, newDummySSHClient(), MuxMasterOptions{ + Path: filepath.Join(t.TempDir(), "peer-uid.sock"), + Environment: Environment{LocalUID: localUID}, + }) + if err != nil { + t.Fatalf("StartMuxServer failed: %v", err) + } + t.Cleanup(func() { _ = server.Shutdown() }) + return server + } + + t.Run("missing LocalUID falls back to the effective user", func(t *testing.T) { + server := start(t, "") + if !server.checkPeerUID { + t.Fatal("peer UID check was silently disabled without Environment.LocalUID") + } + if server.expectedUID != localPeerUID() { + t.Fatalf("expected UID %d, got %d", localPeerUID(), server.expectedUID) + } + }) + + t.Run("explicit LocalUID wins", func(t *testing.T) { + server := start(t, "1234") + if !server.checkPeerUID || server.expectedUID != 1234 { + t.Fatalf("expected the explicit UID 1234 to be enforced, got check=%v uid=%d", server.checkPeerUID, server.expectedUID) + } + }) + + t.Run("malformed LocalUID keeps the check enabled", func(t *testing.T) { + server := start(t, "not-a-uid") + if !server.checkPeerUID { + t.Fatal("malformed Environment.LocalUID disabled the peer UID check") + } + if server.expectedUID != localPeerUID() { + t.Fatalf("expected the effective UID %d as fallback, got %d", localPeerUID(), server.expectedUID) + } + }) +} + +// TestControlClientRequiresExactPeerUID verifies that the client-side peer check +// is never skipped for uid 0. A client running as root previously treated the +// expected UID 0 as "no check" and accepted a socket planted by any other local +// user, which is exactly the hijack the check exists to stop. +func TestControlClientRequiresExactPeerUID(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("peer credentials are unavailable on Windows") + } + if localPeerUID() == 0 { + t.Skip("this process already runs as root, so uid 0 is the correct peer") + } + + socketPath := filepath.Join(t.TempDir(), "root-expectation.sock") + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: socketPath, + SSHClient: newDummySSHClient(), + PersistEnabled: true, + PersistIndefinite: true, + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + defer func() { _ = server.Shutdown() }() + go func() { _ = server.Serve() }() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Expecting root must reject this non-root master instead of accepting it. + if _, err := DialMuxClient(ctx, socketPath, WithExpectedPeerUID(0)); !errors.Is(err, ErrUnauthorizedPeer) { + t.Fatalf("expected ErrUnauthorizedPeer from DialMuxClient, got %v", err) + } + if _, _, err := DialControlProxy(ctx, socketPath, TargetContext{}, WithExpectedPeerUID(0)); !errors.Is(err, ErrUnauthorizedPeer) { + t.Fatalf("expected ErrUnauthorizedPeer from DialControlProxy, got %v", err) + } + if _, err := DialMuxProxyStream(ctx, socketPath, WithExpectedPeerUID(0)); !errors.Is(err, ErrUnauthorizedPeer) { + t.Fatalf("expected ErrUnauthorizedPeer from DialMuxProxyStream, got %v", err) + } +} + +// TestConnectorOwnedMasterSurvivesIdleTimeout verifies that the connector's own +// transport counts as an active session on the master it started. Without that +// reference a bounded ControlPersist timer fires while this process still uses +// the transport, and the master shuts the connection down mid-session. +func TestConnectorOwnedMasterSurvivesIdleTimeout(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) + defer cancel() + + fixture, err := sshfixture.NewWithOptions(ctx, sshfixture.Options{}) + if err != nil { + t.Fatalf("fixture failed: %v", err) + } + defer func() { _ = fixture.Close() }() + host, port := fixtureEndpoint(t, fixture.Address) + + socketPath := filepath.Join(t.TempDir(), "owned-idle.sock") + provider := &testMuxProvider{ + cfg: &ClientConfig{ + NodeID: "idle-node", + Address: host, + Port: port, + User: "fixture", + AuthType: "password", + Password: sshfixture.Password, + }, + ctrl: &ControlConfig{Master: ControlMasterAuto, Path: socketPath, Persist: "100ms"}, + } + + connector := newTestConnector(provider, WithHostKeyVerifier(&fixtureTrust{})) + defer func() { + if closeErr := connector.CloseAll(); closeErr != nil { + t.Logf("CloseAll failed: %v", closeErr) + } + }() + + client, err := connector.Connect(ctx, "idle-node") + if err != nil { + t.Fatalf("connect failed: %v", err) + } + waitForCondition(t, 10*time.Second, func() bool { + return GetActiveMuxServer(socketPath) != nil + }, "connector did not start a control master") + + // Longer than several persist durations: an unowned master would have shut + // down and closed this transport by now. + time.Sleep(600 * time.Millisecond) + if GetActiveMuxServer(socketPath) == nil { + t.Fatal("bounded ControlPersist idle timeout shut down a master whose transport is still in use") + } + + session, err := client.sshClient.NewSession() + if err != nil { + t.Fatalf("the transport was closed under the connector: %v", err) + } + output, err := session.Output("after-idle") + _ = session.Close() + if err != nil { + t.Fatalf("command over the owned master failed after the idle window: %v", err) + } + if string(output) != "fixture-output" { + t.Fatalf("command output = %q, want %q", output, "fixture-output") + } + if got := fixture.Authenticated.Load(); got != 1 { + t.Fatalf("expected the seeded connection to be reused, got %d authentications", got) + } +} + +// TestMuxServerStopListeningRetiresIdleMaster verifies that -O stop actually +// ends a master that has no session to drain, including a -N master that opted +// into ReleaseForegroundOnStop. +func TestMuxServerStopListeningRetiresIdleMaster(t *testing.T) { + for _, tc := range []struct { + name string + initialSessions int32 + releaseOnStop bool + }{ + {name: "idle daemon", initialSessions: 0, releaseOnStop: false}, + {name: "no-cmd master", initialSessions: 1, releaseOnStop: true}, + } { + t.Run(tc.name, func(t *testing.T) { + socketPath := filepath.Join(t.TempDir(), "stop-idle.sock") + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: socketPath, + SSHClient: newDummySSHClient(), + PersistEnabled: !tc.releaseOnStop, + PersistIndefinite: !tc.releaseOnStop, + InitialActiveSessions: tc.initialSessions, + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + if tc.releaseOnStop { + server.ReleaseForegroundOnStop() + } + go func() { _ = server.Serve() }() + defer func() { _ = server.Shutdown() }() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client, err := DialMuxClient(ctx, socketPath) + if err != nil { + t.Fatalf("DialMuxClient failed: %v", err) + } + if err := client.StopListening(ctx); err != nil { + _ = client.Close() + t.Fatalf("StopListening failed: %v", err) + } + _ = client.Close() + + select { + case <-server.Wait(): + case <-time.After(5 * time.Second): + t.Fatal("an idle master kept running after -O stop") + } + if socketPathExists(socketPath) { + t.Fatalf("control socket %q survived -O stop", socketPath) + } + }) + } +} + +// TestConnectorReleaseMasterDropsOnlySessionReference verifies the handoff +// contract: releasing drops the connector's active-session reference so a +// ControlPersist handoff can take the master over, without stopping the master +// this process still owns (CloseAll must remain able to stop it). +func TestConnectorReleaseMasterDropsOnlySessionReference(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) + defer cancel() + + fixture, err := sshfixture.NewWithOptions(ctx, sshfixture.Options{}) + if err != nil { + t.Fatalf("fixture failed: %v", err) + } + defer func() { _ = fixture.Close() }() + host, port := fixtureEndpoint(t, fixture.Address) + + socketPath := filepath.Join(t.TempDir(), "release.sock") + provider := &testMuxProvider{ + cfg: &ClientConfig{ + NodeID: "release-node", + Address: host, + Port: port, + User: "fixture", + AuthType: "password", + Password: sshfixture.Password, + }, + ctrl: &ControlConfig{Master: ControlMasterAuto, Path: socketPath, Persist: "0"}, + } + + connector := newTestConnector(provider, WithHostKeyVerifier(&fixtureTrust{})) + defer func() { + if closeErr := connector.CloseAll(); closeErr != nil { + t.Logf("CloseAll failed: %v", closeErr) + } + }() + if _, err := connector.Connect(ctx, "release-node"); err != nil { + t.Fatalf("connect failed: %v", err) + } + var master *MuxServer + waitForCondition(t, 10*time.Second, func() bool { + master = GetActiveMuxServer(socketPath) + return master != nil + }, "connector did not start a control master") + + if got := master.ActiveSessions(); got != 1 { + t.Fatalf("the owning connector must count as one session, got %d", got) + } + if !connector.ReleaseMaster(master) { + t.Fatal("ReleaseMaster did not report the connector as the owner") + } + if got := master.ActiveSessions(); got != 0 { + t.Fatalf("expected the session reference to be dropped, got %d", got) + } + // A repeated release (base process and daemon child both call it) must not + // drive the counter negative. + connector.ReleaseMaster(master) + if got := master.ActiveSessions(); got != 0 { + t.Fatalf("repeated ReleaseMaster changed the session count to %d", got) + } + if GetActiveMuxServer(socketPath) != master { + t.Fatal("ReleaseMaster stopped a master the connector still owns") + } + + if err := connector.CloseAll(); err != nil { + t.Fatalf("CloseAll failed: %v", err) + } + if GetActiveMuxServer(socketPath) != nil { + t.Fatal("CloseAll left a released master running") + } + if socketPathExists(socketPath) { + t.Fatalf("CloseAll left the control socket at %q", socketPath) + } +} diff --git a/core/ssh/mux_passenger_fd_test.go b/core/ssh/mux_passenger_fd_test.go new file mode 100644 index 00000000..7f8b5140 --- /dev/null +++ b/core/ssh/mux_passenger_fd_test.go @@ -0,0 +1,687 @@ +//go:build linux || darwin || freebsd + +package ssh + +import ( + "bytes" + "errors" + "io" + "net" + "os" + "path/filepath" + "syscall" + "testing" + "time" + + cryptoSSH "golang.org/x/crypto/ssh" + "golang.org/x/sys/unix" +) + +// newPassengerFDChannel returns the receiving end as a *net.UnixConn plus the +// raw peer descriptor a test can use to send SCM_RIGHTS messages. +func newPassengerFDChannel(t *testing.T) (*net.UnixConn, int) { + t.Helper() + sv, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM, 0) + if err != nil { + t.Fatalf("socketpair failed: %v", err) + } + file := os.NewFile(uintptr(sv[0]), "passenger-fd-test") + if file == nil { + _ = unix.Close(sv[0]) + _ = unix.Close(sv[1]) + t.Fatalf("wrap socketpair end in a file failed") + } + // FileConn duplicates the descriptor, so the *os.File must be closed here. + conn, err := net.FileConn(file) + _ = file.Close() + if err != nil { + _ = unix.Close(sv[1]) + t.Fatalf("wrap socketpair end in a connection failed: %v", err) + } + t.Cleanup(func() { + _ = conn.Close() + _ = unix.Close(sv[1]) + }) + unixConn, ok := conn.(*net.UnixConn) + if !ok { + t.Fatalf("socketpair end is %T, want *net.UnixConn", conn) + } + return unixConn, sv[1] +} + +// sendDescriptor transmits one descriptor in its own control message, which is +// what OpenSSH's mm_send_fd does for every session descriptor. +func sendDescriptor(t *testing.T, peer int, fd int) { + t.Helper() + if err := unix.Sendmsg(peer, []byte{0}, unix.UnixRights(fd), nil, 0); err != nil { + t.Fatalf("send descriptor %d failed: %v", fd, err) + } +} + +// TestRecvFDsAccumulatesSplitControlMessages verifies that descriptors arriving +// one control message at a time (the OpenSSH passenger layout) are accumulated. +// A single recvmsg would either fail or lose descriptors, which broke every +// passenger session that did not happen to batch its three descriptors. +func TestRecvFDsAccumulatesSplitControlMessages(t *testing.T) { + conn, peer := newPassengerFDChannel(t) + + first, firstWriter, err := os.Pipe() + if err != nil { + t.Fatalf("pipe failed: %v", err) + } + defer func() { _ = firstWriter.Close() }() + second, secondWriter, err := os.Pipe() + if err != nil { + t.Fatalf("pipe failed: %v", err) + } + defer func() { _ = secondWriter.Close() }() + + sendDescriptor(t, peer, int(first.Fd())) + sendDescriptor(t, peer, int(second.Fd())) + // The sender keeps its own references; the receiver owns the duplicates. + _ = first.Close() + _ = second.Close() + + fds, err := recvFDs(conn, 2) + if err != nil { + t.Fatalf("recvFDs failed: %v", err) + } + defer closeFDRange(fds) + if len(fds) != 2 { + t.Fatalf("received %d descriptors, want 2", len(fds)) + } + + if _, err := firstWriter.Write([]byte("first")); err != nil { + t.Fatalf("write first pipe failed: %v", err) + } + if _, err := secondWriter.Write([]byte("second")); err != nil { + t.Fatalf("write second pipe failed: %v", err) + } + assertReceived := func(fd int, want string) { + t.Helper() + buffer := make([]byte, len(want)) + if _, err := unix.Read(fd, buffer); err != nil { + t.Fatalf("read from received descriptor %d failed: %v", fd, err) + } + if string(buffer) != want { + t.Fatalf("received descriptor %d yielded %q, want %q", fd, buffer, want) + } + } + assertReceived(fds[0], "first") + assertReceived(fds[1], "second") + + for _, fd := range fds { + flags, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0) + if err != nil { + t.Fatalf("read descriptor flags for %d failed: %v", fd, err) + } + if flags&unix.FD_CLOEXEC == 0 { + t.Fatalf("received descriptor %d is not close-on-exec", fd) + } + } +} + +// TestRecvFDsClosesSurplusDescriptors verifies that a peer cannot retain extra +// descriptors by sending more than the request needs: the surplus is closed +// rather than handed to the caller. +func TestRecvFDsClosesSurplusDescriptors(t *testing.T) { + conn, peer := newPassengerFDChannel(t) + + needed, neededWriter, err := os.Pipe() + if err != nil { + t.Fatalf("pipe failed: %v", err) + } + defer func() { _ = neededWriter.Close() }() + surplus, surplusWriter, err := os.Pipe() + if err != nil { + t.Fatalf("pipe failed: %v", err) + } + defer func() { _ = surplusWriter.Close() }() + + if err := unix.Sendmsg(peer, []byte{0}, unix.UnixRights(int(needed.Fd()), int(surplus.Fd())), nil, 0); err != nil { + t.Fatalf("send descriptors failed: %v", err) + } + _ = needed.Close() + _ = surplus.Close() + + fds, err := recvFDs(conn, 1) + if err != nil { + t.Fatalf("recvFDs failed: %v", err) + } + defer closeFDRange(fds) + if len(fds) != 1 { + t.Fatalf("received %d descriptors, want 1", len(fds)) + } + if _, err := neededWriter.Write([]byte("kept")); err != nil { + t.Fatalf("write to the retained descriptor failed: %v", err) + } + if _, err := surplusWriter.Write([]byte("x")); !errors.Is(err, syscall.EPIPE) { + t.Fatalf("expected surplus pipe write to fail with EPIPE after surplus fd was closed, got %v", err) + } +} + +// TestRecvFilesOwnsDescriptors verifies the single-owner contract: closing the +// returned file closes the descriptor once, and a repeated Close is a no-op that +// cannot free a descriptor another goroutine has since reused. +func TestRecvFilesOwnsDescriptors(t *testing.T) { + conn, peer := newPassengerFDChannel(t) + + pipeRead, pipeWrite, err := os.Pipe() + if err != nil { + t.Fatalf("pipe failed: %v", err) + } + defer func() { _ = pipeWrite.Close() }() + sendDescriptor(t, peer, int(pipeRead.Fd())) + _ = pipeRead.Close() + + files, err := recvFiles(conn, "passenger", "stdin") + if err != nil { + t.Fatalf("recvFiles failed: %v", err) + } + if len(files) != 1 || files[0] == nil { + t.Fatalf("recvFiles returned %d files, want 1", len(files)) + } + + received := int(files[0].Fd()) + if err := files[0].Close(); err != nil { + t.Fatalf("closing the owning file failed: %v", err) + } + if _, err := unix.FcntlInt(uintptr(received), unix.F_GETFD, 0); err == nil { + t.Fatalf("descriptor %d is still open after its owning file was closed", received) + } + + // Reuse the descriptor number so a second close would be observable. + canaryRead, canaryWrite, err := os.Pipe() + if err != nil { + t.Fatalf("canary pipe failed: %v", err) + } + defer func() { _ = canaryRead.Close() }() + defer func() { _ = canaryWrite.Close() }() + + if err := files[0].Close(); !errors.Is(err, os.ErrClosed) { + t.Fatalf("expected a repeated close to report os.ErrClosed, got %v", err) + } + if _, err := unix.FcntlInt(uintptr(received), unix.F_GETFD, 0); err != nil { + t.Fatalf("repeated close touched the descriptor number %d now owned by another file: %v", received, err) + } +} + +type ptyRejectingSSHConn struct { + *dummySSHConn +} + +type ptyRejectingChannel struct { + reqs chan *cryptoSSH.Request +} + +func (c *ptyRejectingChannel) Read([]byte) (int, error) { return 0, io.EOF } +func (c *ptyRejectingChannel) Write(b []byte) (int, error) { return len(b), nil } +func (c *ptyRejectingChannel) Close() error { return nil } +func (c *ptyRejectingChannel) CloseWrite() error { return nil } +func (c *ptyRejectingChannel) Stderr() io.ReadWriter { return c } +func (c *ptyRejectingChannel) SendRequest(name string, _ bool, _ []byte) (bool, error) { + switch name { + case "pty-req": + return false, nil + case "exec", "shell": + c.reqs <- &cryptoSSH.Request{ + Type: "exit-status", + WantReply: false, + Payload: cryptoSSH.Marshal(struct{ Status uint32 }{0}), + } + close(c.reqs) + return true, nil + default: + return true, nil + } +} + +func (c *ptyRejectingSSHConn) OpenChannel(name string, _ []byte) (cryptoSSH.Channel, <-chan *cryptoSSH.Request, error) { + if name != "session" { + return nil, nil, errors.New("unsupported channel") + } + reqs := make(chan *cryptoSSH.Request, 1) + return &ptyRejectingChannel{reqs: reqs}, reqs, nil +} + +func sendSessionPipeFDs(t *testing.T, clientConn net.Conn) { + t.Helper() + unixClient, ok := clientConn.(*net.UnixConn) + if !ok { + t.Fatalf("expected *net.UnixConn, got %T", clientConn) + } + rawConn, err := unixClient.SyscallConn() + if err != nil { + t.Fatalf("SyscallConn failed: %v", err) + } + for range 3 { + r, w, pipeErr := os.Pipe() + if pipeErr != nil { + t.Fatalf("os.Pipe failed: %v", pipeErr) + } + var sendErr error + _ = rawConn.Control(func(fd uintptr) { + sendErr = unix.Sendmsg(int(fd), []byte{0}, unix.UnixRights(int(r.Fd())), nil, 0) + }) + _ = r.Close() + _ = w.Close() + if sendErr != nil { + t.Fatalf("send fd failed: %v", sendErr) + } + } +} + +// TestPassengerSession_TTYAllocFailSentAfterSessionOpenedWithSessionID verifies +// that when PTY allocation fails in passenger mode, the master sends +// MUX_S_SESSION_OPENED(reqID, sessionID) first, followed by +// MUX_S_TTY_ALLOC_FAIL(sessionID) and MUX_S_EXIT_MESSAGE(sessionID, exitCode). +func TestPassengerSession_TTYAllocFailSentAfterSessionOpenedWithSessionID(t *testing.T) { + chans := make(chan cryptoSSH.NewChannel) + reqs := make(chan *cryptoSSH.Request) + conn := &ptyRejectingSSHConn{dummySSHConn: &dummySSHConn{chans: chans, reqs: reqs}} + sshClient := cryptoSSH.NewClient(conn, chans, reqs) + defer func() { _ = sshClient.Close() }() + + sockPath := filepath.Join(t.TempDir(), "pty-fail.sock") + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: sockPath, + SSHClient: sshClient, + PersistEnabled: true, + PersistIndefinite: true, + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + defer func() { _ = server.Shutdown() }() + go func() { _ = server.Serve() }() + + clientConn, err := net.Dial("unix", sockPath) + if err != nil { + t.Fatalf("dial unix socket failed: %v", err) + } + defer func() { _ = clientConn.Close() }() + + if err := performClientMuxHandshake(clientConn); err != nil { + t.Fatalf("mux handshake failed: %v", err) + } + + const reqID uint32 = 42 + reqPayload := EncodeMuxNewSession(MuxNewSessionRequest{ + RequestID: reqID, + WantTTY: true, + TermType: "xterm-256color", + Command: "echo ok", + }) + if err := WriteMuxPacket(clientConn, MUX_C_NEW_SESSION, reqPayload); err != nil { + t.Fatalf("send MUX_C_NEW_SESSION failed: %v", err) + } + sendSessionPipeFDs(t, clientConn) + + // 1. First packet MUST be MUX_S_SESSION_OPENED(reqID=42, sessionID=1) + pktType, payload, err := ReadMuxPacket(clientConn) + if err != nil { + t.Fatalf("read first response packet failed: %v", err) + } + if pktType != MUX_S_SESSION_OPENED { + t.Fatalf("expected first packet MUX_S_SESSION_OPENED (0x%08x), got 0x%08x", MUX_S_SESSION_OPENED, pktType) + } + gotReqID, sessionID, err := DecodeMuxSessionOpened(payload) + if err != nil { + t.Fatalf("DecodeMuxSessionOpened failed: %v", err) + } + if gotReqID != reqID { + t.Fatalf("session opened request ID = %d, want %d", gotReqID, reqID) + } + + // 2. Second packet MUST be MUX_S_TTY_ALLOC_FAIL(sessionID) + pktType, payload, err = ReadMuxPacket(clientConn) + if err != nil { + t.Fatalf("read second response packet failed: %v", err) + } + if pktType != MUX_S_TTY_ALLOC_FAIL { + t.Fatalf("expected second packet MUX_S_TTY_ALLOC_FAIL (0x%08x), got 0x%08x", MUX_S_TTY_ALLOC_FAIL, pktType) + } + ttyFailSessionID, err := DecodeMuxTTYAllocFail(payload) + if err != nil { + t.Fatalf("DecodeMuxTTYAllocFail failed: %v", err) + } + if ttyFailSessionID != sessionID { + t.Fatalf("MUX_S_TTY_ALLOC_FAIL carried ID %d, want sessionID %d (not reqID %d)", ttyFailSessionID, sessionID, reqID) + } + + // 3. Third packet MUST be MUX_S_EXIT_MESSAGE(sessionID, 0) + pktType, payload, err = ReadMuxPacket(clientConn) + if err != nil { + t.Fatalf("read third response packet failed: %v", err) + } + if pktType != MUX_S_EXIT_MESSAGE { + t.Fatalf("expected third packet MUX_S_EXIT_MESSAGE (0x%08x), got 0x%08x", MUX_S_EXIT_MESSAGE, pktType) + } + exitSessionID, exitCode, err := DecodeMuxExitMessage(payload) + if err != nil { + t.Fatalf("DecodeMuxExitMessage failed: %v", err) + } + if exitSessionID != sessionID || exitCode != 0 { + t.Fatalf("exit message = (session=%d, code=%d), want (session=%d, code=0)", exitSessionID, exitCode, sessionID) + } +} + +type subsystemEchoSSHConn struct { + *dummySSHConn +} + +type subsystemEchoChannel struct { + reqs chan *cryptoSSH.Request + inR *io.PipeReader + inW *io.PipeWriter + outR *io.PipeReader + outW *io.PipeWriter + stderr bytes.Buffer +} + +func newSubsystemEchoChannel() (*subsystemEchoChannel, <-chan *cryptoSSH.Request) { + reqs := make(chan *cryptoSSH.Request, 1) + inR, inW := io.Pipe() + outR, outW := io.Pipe() + return &subsystemEchoChannel{ + reqs: reqs, + inR: inR, + inW: inW, + outR: outR, + outW: outW, + }, reqs +} + +func (c *subsystemEchoChannel) Read(b []byte) (int, error) { return c.outR.Read(b) } +func (c *subsystemEchoChannel) Write(b []byte) (int, error) { return c.inW.Write(b) } +func (c *subsystemEchoChannel) CloseWrite() error { return c.inW.Close() } +func (c *subsystemEchoChannel) Close() error { + _ = c.inW.Close() + _ = c.outW.Close() + return nil +} +func (c *subsystemEchoChannel) Stderr() io.ReadWriter { return &c.stderr } +func (c *subsystemEchoChannel) SendRequest(name string, _ bool, payload []byte) (bool, error) { + if name != "subsystem" { + return false, nil + } + var sub struct{ Subsystem string } + if err := cryptoSSH.Unmarshal(payload, &sub); err != nil || sub.Subsystem != "sftp" { + return false, nil + } + go func() { + // Wait until client stdin echoes and closes (CloseWrite -> EOF), then + // reply on stdout and emit exit-status 0. + data, _ := io.ReadAll(c.inR) + _, _ = c.outW.Write(append([]byte("sftp-echo:"), data...)) + _ = c.outW.Close() + c.reqs <- &cryptoSSH.Request{ + Type: "exit-status", + WantReply: false, + Payload: cryptoSSH.Marshal(struct{ Status uint32 }{0}), + } + close(c.reqs) + }() + return true, nil +} + +func (c *subsystemEchoSSHConn) OpenChannel(name string, _ []byte) (cryptoSSH.Channel, <-chan *cryptoSSH.Request, error) { + if name != "session" { + return nil, nil, errors.New("unsupported channel") + } + ch, reqs := newSubsystemEchoChannel() + return ch, reqs, nil +} + +// TestPassengerSession_SubsystemAndStdinEOF verifies that a passenger subsystem +// request (e.g. native sftp) starts I/O properly, propagates stdin EOF via +// CloseWrite so the remote subsystem terminates cleanly, and returns exit +// status 0 instead of failing immediately with exit status 255. +func TestPassengerSession_SubsystemAndStdinEOF(t *testing.T) { + chans := make(chan cryptoSSH.NewChannel) + reqs := make(chan *cryptoSSH.Request) + conn := &subsystemEchoSSHConn{dummySSHConn: &dummySSHConn{chans: chans, reqs: reqs}} + sshClient := cryptoSSH.NewClient(conn, chans, reqs) + defer func() { _ = sshClient.Close() }() + + sockPath := filepath.Join(t.TempDir(), "sftp-subsys.sock") + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: sockPath, + SSHClient: sshClient, + PersistEnabled: true, + PersistIndefinite: true, + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + defer func() { _ = server.Shutdown() }() + go func() { _ = server.Serve() }() + + clientConn, err := net.Dial("unix", sockPath) + if err != nil { + t.Fatalf("dial unix socket failed: %v", err) + } + defer func() { _ = clientConn.Close() }() + + if err := performClientMuxHandshake(clientConn); err != nil { + t.Fatalf("mux handshake failed: %v", err) + } + + const reqID uint32 = 9 + reqPayload := EncodeMuxNewSession(MuxNewSessionRequest{ + RequestID: reqID, + Subsystem: true, + Command: "sftp", + }) + if err := WriteMuxPacket(clientConn, MUX_C_NEW_SESSION, reqPayload); err != nil { + t.Fatalf("send MUX_C_NEW_SESSION failed: %v", err) + } + + stdinR, stdinW, err := os.Pipe() + if err != nil { + t.Fatalf("stdin pipe failed: %v", err) + } + stdoutR, stdoutW, err := os.Pipe() + if err != nil { + t.Fatalf("stdout pipe failed: %v", err) + } + stderrR, stderrW, err := os.Pipe() + if err != nil { + t.Fatalf("stderr pipe failed: %v", err) + } + defer func() { + _ = stdinW.Close() + _ = stdoutR.Close() + _ = stderrR.Close() + }() + + unixClient := clientConn.(*net.UnixConn) + rawConn, err := unixClient.SyscallConn() + if err != nil { + t.Fatalf("SyscallConn failed: %v", err) + } + for _, f := range []*os.File{stdinR, stdoutW, stderrW} { + var sendErr error + _ = rawConn.Control(func(fd uintptr) { + sendErr = unix.Sendmsg(int(fd), []byte{0}, unix.UnixRights(int(f.Fd())), nil, 0) + }) + _ = f.Close() + if sendErr != nil { + t.Fatalf("send fd failed: %v", sendErr) + } + } + + if _, err := stdinW.Write([]byte("ls /tmp\nquit\n")); err != nil { + t.Fatalf("write stdin failed: %v", err) + } + _ = stdinW.Close() // Trigger EOF propagation to remote subsystem + + pktType, _, err := ReadMuxPacket(clientConn) + if err != nil { + t.Fatalf("read MUX_S_SESSION_OPENED failed: %v", err) + } + if pktType != MUX_S_SESSION_OPENED { + t.Fatalf("expected MUX_S_SESSION_OPENED, got 0x%08x", pktType) + } + + outBytes, err := io.ReadAll(stdoutR) + if err != nil { + t.Fatalf("read stdout pipe failed: %v", err) + } + if string(outBytes) != "sftp-echo:ls /tmp\nquit\n" { + t.Fatalf("unexpected subsystem stdout: %q", string(outBytes)) + } + + pktType, payload, err := ReadMuxPacket(clientConn) + if err != nil { + t.Fatalf("read MUX_S_EXIT_MESSAGE failed: %v", err) + } + if pktType != MUX_S_EXIT_MESSAGE { + t.Fatalf("expected MUX_S_EXIT_MESSAGE, got 0x%08x", pktType) + } + _, exitCode, err := DecodeMuxExitMessage(payload) + if err != nil { + t.Fatalf("DecodeMuxExitMessage failed: %v", err) + } + if exitCode != 0 { + t.Fatalf("expected subsystem exit code 0, got %d", exitCode) + } +} + +type immediateExitSSHConn struct { + *dummySSHConn +} + +type immediateExitChannel struct { + reqs chan *cryptoSSH.Request +} + +func (c *immediateExitChannel) Read([]byte) (int, error) { return 0, io.EOF } +func (c *immediateExitChannel) Write(b []byte) (int, error) { return len(b), nil } +func (c *immediateExitChannel) Close() error { return nil } +func (c *immediateExitChannel) CloseWrite() error { return nil } +func (c *immediateExitChannel) Stderr() io.ReadWriter { return c } +func (c *immediateExitChannel) SendRequest(name string, _ bool, _ []byte) (bool, error) { + if name == "exec" || name == "shell" { + c.reqs <- &cryptoSSH.Request{ + Type: "exit-status", + WantReply: false, + Payload: cryptoSSH.Marshal(struct{ Status uint32 }{0}), + } + close(c.reqs) + } + return true, nil +} + +func (c *immediateExitSSHConn) OpenChannel(name string, _ []byte) (cryptoSSH.Channel, <-chan *cryptoSSH.Request, error) { + if name != "session" { + return nil, nil, errors.New("unsupported channel") + } + reqs := make(chan *cryptoSSH.Request, 1) + return &immediateExitChannel{reqs: reqs}, reqs, nil +} + +// TestPassengerSession_BlockingPipeDoesNotDeadlockOnExit verifies that when a +// passenger client passes a blocking pipe fd (created via unix.Pipe2 without +// O_NONBLOCK, mimicking a blocking TTY or pipe from native /usr/bin/ssh) whose +// write end remains open in the client, remote command exit wakes the stdin +// pump immediately via unix.Poll without mutating O_NONBLOCK on the shared open +// file description. +func TestPassengerSession_BlockingPipeDoesNotDeadlockOnExit(t *testing.T) { + chans := make(chan cryptoSSH.NewChannel) + reqs := make(chan *cryptoSSH.Request) + conn := &immediateExitSSHConn{dummySSHConn: &dummySSHConn{chans: chans, reqs: reqs}} + sshClient := cryptoSSH.NewClient(conn, chans, reqs) + defer func() { _ = sshClient.Close() }() + + sockPath := filepath.Join(t.TempDir(), "blocking-pipe.sock") + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: sockPath, + SSHClient: sshClient, + PersistEnabled: true, + PersistIndefinite: true, + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + defer func() { _ = server.Shutdown() }() + go func() { _ = server.Serve() }() + + clientConn, err := net.Dial("unix", sockPath) + if err != nil { + t.Fatalf("dial unix socket failed: %v", err) + } + defer func() { _ = clientConn.Close() }() + + if err := performClientMuxHandshake(clientConn); err != nil { + t.Fatalf("mux handshake failed: %v", err) + } + + const reqID uint32 = 77 + reqPayload := EncodeMuxNewSession(MuxNewSessionRequest{ + RequestID: reqID, + Command: "true", + }) + if err := WriteMuxPacket(clientConn, MUX_C_NEW_SESSION, reqPayload); err != nil { + t.Fatalf("send MUX_C_NEW_SESSION failed: %v", err) + } + + // Create raw blocking pipes (no O_NONBLOCK) so os.NewFile does not register + // them with the nonblocking poller. Keep stdinPipe[1] open in the test! + var stdinPipe, stdoutPipe, stderrPipe [2]int + if err := unix.Pipe2(stdinPipe[:], unix.O_CLOEXEC); err != nil { + t.Fatalf("unix.Pipe2(stdin) failed: %v", err) + } + defer func() { _ = unix.Close(stdinPipe[1]) }() + if err := unix.Pipe2(stdoutPipe[:], unix.O_CLOEXEC); err != nil { + t.Fatalf("unix.Pipe2(stdout) failed: %v", err) + } + defer func() { _ = unix.Close(stdoutPipe[0]) }() + if err := unix.Pipe2(stderrPipe[:], unix.O_CLOEXEC); err != nil { + t.Fatalf("unix.Pipe2(stderr) failed: %v", err) + } + defer func() { _ = unix.Close(stderrPipe[0]) }() + + unixClient := clientConn.(*net.UnixConn) + rawConn, err := unixClient.SyscallConn() + if err != nil { + t.Fatalf("SyscallConn failed: %v", err) + } + for _, fdToSend := range []int{stdinPipe[0], stdoutPipe[1], stderrPipe[1]} { + var sendErr error + _ = rawConn.Control(func(sockFD uintptr) { + sendErr = unix.Sendmsg(int(sockFD), []byte{0}, unix.UnixRights(fdToSend), nil, 0) + }) + _ = unix.Close(fdToSend) + if sendErr != nil { + t.Fatalf("send fd failed: %v", sendErr) + } + } + + if err := clientConn.SetReadDeadline(time.Now().Add(3 * time.Second)); err != nil { + t.Fatalf("SetReadDeadline failed: %v", err) + } + + pktType, _, err := ReadMuxPacket(clientConn) + if err != nil || pktType != MUX_S_SESSION_OPENED { + t.Fatalf("expected MUX_S_SESSION_OPENED, got pkt=0x%08x err=%v", pktType, err) + } + + pktType, payload, err := ReadMuxPacket(clientConn) + if err != nil || pktType != MUX_S_EXIT_MESSAGE { + t.Fatalf("expected MUX_S_EXIT_MESSAGE without deadlock, got pkt=0x%08x err=%v", pktType, err) + } + _, exitCode, err := DecodeMuxExitMessage(payload) + if err != nil || exitCode != 0 { + t.Fatalf("unexpected exit message: code=%d err=%v", exitCode, err) + } + + // Verify the shared open file description did NOT have O_NONBLOCK mutated. + flags, err := unix.FcntlInt(uintptr(stdinPipe[1]), unix.F_GETFL, 0) + if err != nil { + t.Fatalf("FcntlInt(F_GETFL) failed: %v", err) + } + if flags&unix.O_NONBLOCK != 0 { + t.Fatalf("passenger session mutated O_NONBLOCK on shared open file description (flags=0x%x)", flags) + } +} diff --git a/core/ssh/mux_peer_darwin.go b/core/ssh/mux_peer_darwin.go new file mode 100644 index 00000000..ec7ea098 --- /dev/null +++ b/core/ssh/mux_peer_darwin.go @@ -0,0 +1,17 @@ +//go:build darwin + +package ssh + +import ( + "fmt" + + "golang.org/x/sys/unix" +) + +func getPeerUID(fd int) (uint32, error) { + xucred, err := unix.GetsockoptXucred(fd, unix.SOL_LOCAL, unix.LOCAL_PEERCRED) + if err != nil { + return 0, fmt.Errorf("getsockopt LOCAL_PEERCRED failed: %w", err) + } + return xucred.Uid, nil +} diff --git a/core/ssh/mux_peer_freebsd.go b/core/ssh/mux_peer_freebsd.go new file mode 100644 index 00000000..88eec255 --- /dev/null +++ b/core/ssh/mux_peer_freebsd.go @@ -0,0 +1,17 @@ +//go:build freebsd + +package ssh + +import ( + "fmt" + + "golang.org/x/sys/unix" +) + +func getPeerUID(fd int) (uint32, error) { + xucred, err := unix.GetsockoptXucred(fd, unix.SOL_LOCAL, unix.LOCAL_PEERCRED) + if err != nil { + return 0, fmt.Errorf("getsockopt LOCAL_PEERCRED failed: %w", err) + } + return xucred.Uid, nil +} diff --git a/core/ssh/mux_peer_linux.go b/core/ssh/mux_peer_linux.go new file mode 100644 index 00000000..09d771b0 --- /dev/null +++ b/core/ssh/mux_peer_linux.go @@ -0,0 +1,17 @@ +//go:build linux + +package ssh + +import ( + "fmt" + + "golang.org/x/sys/unix" +) + +func getPeerUID(fd int) (uint32, error) { + ucred, err := unix.GetsockoptUcred(fd, unix.SOL_SOCKET, unix.SO_PEERCRED) + if err != nil { + return 0, fmt.Errorf("getsockopt SO_PEERCRED failed: %w", err) + } + return ucred.Uid, nil +} diff --git a/core/ssh/mux_protocol.go b/core/ssh/mux_protocol.go new file mode 100644 index 00000000..5062ddc6 --- /dev/null +++ b/core/ssh/mux_protocol.go @@ -0,0 +1,613 @@ +package ssh + +import ( + "encoding/binary" + "fmt" + "io" + "slices" +) + +// OpenSSH PROTOCOL.mux version 4. +const ( + MuxProtoVersion uint32 = 4 + MuxProtocolVersion uint32 = 4 +) + +// Client -> Master request message types. +const ( + MUX_MSG_HELLO uint32 = 0x00000001 + MUX_C_NEW_SESSION uint32 = 0x10000002 + MUX_C_ALIVE_CHECK uint32 = 0x10000004 + MUX_C_TERMINATE uint32 = 0x10000005 + MUX_C_OPEN_FWD uint32 = 0x10000006 + MUX_C_CLOSE_FWD uint32 = 0x10000007 + MUX_C_NEW_STDIO_FWD uint32 = 0x10000008 + MUX_C_STOP_LISTENING uint32 = 0x10000009 + MUX_C_PROXY uint32 = 0x1000000f + MUX_C_EXT_INFO uint32 = 0x20000001 +) + +// Master -> Client response message types. +const ( + MUX_S_OK uint32 = 0x80000001 + MUX_S_PERMISSION_DENIED uint32 = 0x80000002 + MUX_S_FAILURE uint32 = 0x80000003 + MUX_S_EXIT_MESSAGE uint32 = 0x80000004 + MUX_S_ALIVE uint32 = 0x80000005 + MUX_S_SESSION_OPENED uint32 = 0x80000006 + MUX_S_REMOTE_PORT uint32 = 0x80000007 + MUX_S_TTY_ALLOC_FAIL uint32 = 0x80000008 + MUX_S_PROXY uint32 = 0x8000000f + MUX_S_EXT_INFO uint32 = 0x90000001 +) + +// Forwarding types. +const ( + MUX_FWD_LOCAL uint32 = 1 + MUX_FWD_REMOTE uint32 = 2 + MUX_FWD_DYNAMIC uint32 = 3 +) + +// MUX_SPECIAL_PORT_UNIX indicates a Unix domain socket path rather than a TCP port. +const MUX_SPECIAL_PORT_UNIX uint32 = 0xfffffffe + +// Packet limits to defend against resource exhaustion DOS. +const ( + MaxMuxPacketLength = 256 * 1024 // 256 KB + MaxMuxStringLength = 64 * 1024 // 64 KB +) + +// ReadMuxPacket reads a single framed MUX packet from r. +// Wire format: +// +// uint32 packet_length (big-endian: 4 bytes type + payload length) +// uint32 packet_type (big-endian opcode) +// bytes payload +func ReadMuxPacket(r io.Reader) (packetType uint32, payload []byte, err error) { + var header [8]byte + if _, err := io.ReadFull(r, header[:]); err != nil { + return 0, nil, err + } + pktLen := binary.BigEndian.Uint32(header[0:4]) + if pktLen < 4 { + return 0, nil, fmt.Errorf("invalid mux packet length %d (must be >= 4)", pktLen) + } + if pktLen > MaxMuxPacketLength { + return 0, nil, fmt.Errorf("mux packet length %d exceeds maximum %d", pktLen, MaxMuxPacketLength) + } + + packetType = binary.BigEndian.Uint32(header[4:8]) + payloadLen := pktLen - 4 + if payloadLen == 0 { + return packetType, nil, nil + } + + payload = make([]byte, payloadLen) + if _, err := io.ReadFull(r, payload); err != nil { + return 0, nil, fmt.Errorf("failed to read mux packet payload: %w", err) + } + return packetType, payload, nil +} + +// WriteMuxPacket writes a single framed MUX packet to w in a single atomic Write call. +func WriteMuxPacket(w io.Writer, packetType uint32, payload []byte) error { + totalLen := 4 + len(payload) + if totalLen > MaxMuxPacketLength { + return fmt.Errorf("mux packet length %d exceeds maximum %d", totalLen, MaxMuxPacketLength) + } + buf := make([]byte, 4+totalLen) + binary.BigEndian.PutUint32(buf[0:4], uint32(totalLen)) + binary.BigEndian.PutUint32(buf[4:8], packetType) + copy(buf[8:], payload) + _, err := w.Write(buf) + return err +} + +type muxWriter struct { + buf []byte +} + +func newMuxWriter() *muxWriter { + return &muxWriter{buf: make([]byte, 0, 128)} +} + +func (w *muxWriter) WriteUint32(v uint32) { + b := [4]byte{} + binary.BigEndian.PutUint32(b[:], v) + w.buf = append(w.buf, b[:]...) +} + +func (w *muxWriter) WriteBool(b bool) { + if b { + w.buf = append(w.buf, 1) + } else { + w.buf = append(w.buf, 0) + } +} + +func (w *muxWriter) WriteString(s string) { + w.WriteUint32(uint32(len(s))) + w.buf = append(w.buf, s...) +} + +func (w *muxWriter) WriteBytes(p []byte) { + w.WriteUint32(uint32(len(p))) + w.buf = append(w.buf, p...) +} + +func (w *muxWriter) Bytes() []byte { + return w.buf +} + +type muxReader struct { + data []byte + off int +} + +func newMuxReader(data []byte) *muxReader { + return &muxReader{data: data, off: 0} +} + +func (r *muxReader) Remaining() int { + return len(r.data) - r.off +} + +func (r *muxReader) ReadUint32() (uint32, error) { + if r.Remaining() < 4 { + return 0, io.ErrUnexpectedEOF + } + v := binary.BigEndian.Uint32(r.data[r.off : r.off+4]) + r.off += 4 + return v, nil +} + +func (r *muxReader) ReadBool() (bool, error) { + if r.Remaining() < 1 { + return false, io.ErrUnexpectedEOF + } + b := r.data[r.off] != 0 + r.off += 1 + return b, nil +} + +func (r *muxReader) ReadString() (string, error) { + n, err := r.ReadUint32() + if err != nil { + return "", err + } + if n > MaxMuxStringLength { + return "", fmt.Errorf("string length %d exceeds maximum %d", n, MaxMuxStringLength) + } + if r.Remaining() < int(n) { + return "", io.ErrUnexpectedEOF + } + s := string(r.data[r.off : r.off+int(n)]) + r.off += int(n) + return s, nil +} + +func (r *muxReader) ReadBytes() ([]byte, error) { + n, err := r.ReadUint32() + if err != nil { + return nil, err + } + if n > MaxMuxPacketLength { + return nil, fmt.Errorf("byte slice length %d exceeds maximum %d", n, MaxMuxPacketLength) + } + if r.Remaining() < int(n) { + return nil, io.ErrUnexpectedEOF + } + b := slices.Clone(r.data[r.off : r.off+int(n)]) + r.off += int(n) + return b, nil +} + +// MuxExtension represents an extension name/value pair in MUX_MSG_HELLO. +type MuxExtension struct { + Name string + Value string +} + +// MuxFwdRequest represents port forwarding parameters for OPEN_FWD and CLOSE_FWD. +type MuxFwdRequest struct { + RequestID uint32 + FwdType uint32 + ListenHost string + ListenPort uint32 + ConnectHost string + ConnectPort uint32 +} + +// MuxNewSessionRequest represents passenger session creation parameters. +type MuxNewSessionRequest struct { + RequestID uint32 + Reserved string + WantTTY bool + WantX11 bool + WantAgent bool + Subsystem bool + EscapeChar uint32 + TermType string + Command string + Env []string +} + +// MuxNewStdioFwdRequest represents stdio forwarding parameters. +type MuxNewStdioFwdRequest struct { + RequestID uint32 + Reserved string + ConnectHost string + ConnectPort uint32 +} + +// 1. MUX_MSG_HELLO (0x00000001) +func EncodeMuxHello(version uint32, extensions []MuxExtension) []byte { + w := newMuxWriter() + w.WriteUint32(version) + for _, ext := range extensions { + w.WriteString(ext.Name) + w.WriteString(ext.Value) + } + return w.Bytes() +} + +func DecodeMuxHello(payload []byte) (version uint32, extensions []MuxExtension, err error) { + r := newMuxReader(payload) + version, err = r.ReadUint32() + if err != nil { + return 0, nil, fmt.Errorf("decode hello version: %w", err) + } + for r.Remaining() > 0 { + name, err := r.ReadString() + if err != nil { + return 0, nil, fmt.Errorf("decode hello ext name: %w", err) + } + val, err := r.ReadString() + if err != nil { + return 0, nil, fmt.Errorf("decode hello ext value: %w", err) + } + extensions = append(extensions, MuxExtension{Name: name, Value: val}) + } + return version, extensions, nil +} + +// 2. Simple Request ID requests (ALIVE_CHECK, TERMINATE, STOP_LISTENING, PROXY) +func EncodeMuxRequestID(requestID uint32) []byte { + w := newMuxWriter() + w.WriteUint32(requestID) + return w.Bytes() +} + +func DecodeMuxRequestID(payload []byte) (requestID uint32, err error) { + r := newMuxReader(payload) + requestID, err = r.ReadUint32() + if err != nil { + return 0, fmt.Errorf("decode request id: %w", err) + } + if r.Remaining() != 0 { + return 0, fmt.Errorf("trailing %d bytes after request id", r.Remaining()) + } + return requestID, nil +} + +// 3. MUX_S_ALIVE (0x80000005) +func EncodeMuxAlive(requestID uint32, pid uint32) []byte { + w := newMuxWriter() + w.WriteUint32(requestID) + w.WriteUint32(pid) + return w.Bytes() +} + +func DecodeMuxAlive(payload []byte) (requestID uint32, pid uint32, err error) { + r := newMuxReader(payload) + if requestID, err = r.ReadUint32(); err != nil { + return 0, 0, err + } + if pid, err = r.ReadUint32(); err != nil { + return 0, 0, err + } + if r.Remaining() != 0 { + return 0, 0, fmt.Errorf("trailing %d bytes in alive packet", r.Remaining()) + } + return requestID, pid, nil +} + +// 4. MUX_S_OK (0x80000001) & MUX_S_PROXY (0x8000000f) +func EncodeMuxClientRequestID(clientRequestID uint32) []byte { + return EncodeMuxRequestID(clientRequestID) +} + +func DecodeMuxClientRequestID(payload []byte) (clientRequestID uint32, err error) { + return DecodeMuxRequestID(payload) +} + +// 5. MUX_S_FAILURE (0x80000003) & MUX_S_PERMISSION_DENIED (0x80000002) +func EncodeMuxError(clientRequestID uint32, reason string) []byte { + w := newMuxWriter() + w.WriteUint32(clientRequestID) + w.WriteString(reason) + return w.Bytes() +} + +func DecodeMuxError(payload []byte) (clientRequestID uint32, reason string, err error) { + r := newMuxReader(payload) + if clientRequestID, err = r.ReadUint32(); err != nil { + return 0, "", err + } + if reason, err = r.ReadString(); err != nil { + return 0, "", err + } + if r.Remaining() != 0 { + return 0, "", fmt.Errorf("trailing %d bytes in error packet", r.Remaining()) + } + return clientRequestID, reason, nil +} + +// 6. MUX_C_OPEN_FWD (0x10000006) & MUX_C_CLOSE_FWD (0x10000007) +func EncodeMuxFwdRequest(req MuxFwdRequest) []byte { + w := newMuxWriter() + w.WriteUint32(req.RequestID) + w.WriteUint32(req.FwdType) + w.WriteString(req.ListenHost) + w.WriteUint32(req.ListenPort) + w.WriteString(req.ConnectHost) + w.WriteUint32(req.ConnectPort) + return w.Bytes() +} + +func DecodeMuxFwdRequest(payload []byte) (req MuxFwdRequest, err error) { + r := newMuxReader(payload) + if req.RequestID, err = r.ReadUint32(); err != nil { + return req, err + } + if req.FwdType, err = r.ReadUint32(); err != nil { + return req, err + } + if req.ListenHost, err = r.ReadString(); err != nil { + return req, err + } + if req.ListenPort, err = r.ReadUint32(); err != nil { + return req, err + } + if req.ConnectHost, err = r.ReadString(); err != nil { + return req, err + } + if req.ConnectPort, err = r.ReadUint32(); err != nil { + return req, err + } + if r.Remaining() != 0 { + return req, fmt.Errorf("trailing %d bytes in fwd request", r.Remaining()) + } + return req, nil +} + +// 7. MUX_S_REMOTE_PORT (0x80000007) +func EncodeMuxRemotePort(clientRequestID uint32, allocatedPort uint32) []byte { + w := newMuxWriter() + w.WriteUint32(clientRequestID) + w.WriteUint32(allocatedPort) + return w.Bytes() +} + +func DecodeMuxRemotePort(payload []byte) (clientRequestID uint32, allocatedPort uint32, err error) { + r := newMuxReader(payload) + if clientRequestID, err = r.ReadUint32(); err != nil { + return 0, 0, err + } + if allocatedPort, err = r.ReadUint32(); err != nil { + return 0, 0, err + } + if r.Remaining() != 0 { + return 0, 0, fmt.Errorf("trailing %d bytes in remote port packet", r.Remaining()) + } + return clientRequestID, allocatedPort, nil +} + +// 8. MUX_C_NEW_SESSION (0x10000002) +func EncodeMuxNewSession(req MuxNewSessionRequest) []byte { + w := newMuxWriter() + w.WriteUint32(req.RequestID) + w.WriteString(req.Reserved) + w.WriteUint32(boolToUint32(req.WantTTY)) + w.WriteUint32(boolToUint32(req.WantX11)) + w.WriteUint32(boolToUint32(req.WantAgent)) + w.WriteUint32(boolToUint32(req.Subsystem)) + w.WriteUint32(req.EscapeChar) + w.WriteString(req.TermType) + w.WriteString(req.Command) + for _, env := range req.Env { + w.WriteString(env) + } + return w.Bytes() +} + +func boolToUint32(b bool) uint32 { + if b { + return 1 + } + return 0 +} + +func DecodeMuxNewSession(payload []byte) (req MuxNewSessionRequest, err error) { + r := newMuxReader(payload) + if req.RequestID, err = r.ReadUint32(); err != nil { + return req, err + } + if req.Reserved, err = r.ReadString(); err != nil { + return req, err + } + var wantTTY, wantX11, wantAgent, subsystem uint32 + if wantTTY, err = r.ReadUint32(); err != nil { + return req, err + } + if wantX11, err = r.ReadUint32(); err != nil { + return req, err + } + if wantAgent, err = r.ReadUint32(); err != nil { + return req, err + } + if subsystem, err = r.ReadUint32(); err != nil { + return req, err + } + req.WantTTY = wantTTY != 0 + req.WantX11 = wantX11 != 0 + req.WantAgent = wantAgent != 0 + req.Subsystem = subsystem != 0 + if req.EscapeChar, err = r.ReadUint32(); err != nil { + return req, err + } + if req.TermType, err = r.ReadString(); err != nil { + return req, err + } + if req.Command, err = r.ReadString(); err != nil { + return req, err + } + for r.Remaining() > 0 { + envItem, err := r.ReadString() + if err != nil { + return req, err + } + req.Env = append(req.Env, envItem) + } + return req, nil +} + +// 9. MUX_S_SESSION_OPENED (0x80000006) +func EncodeMuxSessionOpened(clientRequestID uint32, sessionID uint32) []byte { + w := newMuxWriter() + w.WriteUint32(clientRequestID) + w.WriteUint32(sessionID) + return w.Bytes() +} + +func DecodeMuxSessionOpened(payload []byte) (clientRequestID uint32, sessionID uint32, err error) { + r := newMuxReader(payload) + if clientRequestID, err = r.ReadUint32(); err != nil { + return 0, 0, err + } + if sessionID, err = r.ReadUint32(); err != nil { + return 0, 0, err + } + if r.Remaining() != 0 { + return 0, 0, fmt.Errorf("trailing %d bytes in session opened packet", r.Remaining()) + } + return clientRequestID, sessionID, nil +} + +// 10. MUX_S_EXIT_MESSAGE (0x80000004) +func EncodeMuxExitMessage(sessionID uint32, exitValue uint32) []byte { + w := newMuxWriter() + w.WriteUint32(sessionID) + w.WriteUint32(exitValue) + return w.Bytes() +} + +func DecodeMuxExitMessage(payload []byte) (sessionID uint32, exitValue uint32, err error) { + r := newMuxReader(payload) + if sessionID, err = r.ReadUint32(); err != nil { + return 0, 0, err + } + if exitValue, err = r.ReadUint32(); err != nil { + return 0, 0, err + } + if r.Remaining() != 0 { + return 0, 0, fmt.Errorf("trailing %d bytes in exit message", r.Remaining()) + } + return sessionID, exitValue, nil +} + +// 11. MUX_S_TTY_ALLOC_FAIL (0x80000008) +func EncodeMuxTTYAllocFail(sessionID uint32) []byte { + w := newMuxWriter() + w.WriteUint32(sessionID) + return w.Bytes() +} + +func DecodeMuxTTYAllocFail(payload []byte) (sessionID uint32, err error) { + r := newMuxReader(payload) + if sessionID, err = r.ReadUint32(); err != nil { + return 0, err + } + if r.Remaining() != 0 { + return 0, fmt.Errorf("trailing %d bytes in tty alloc fail", r.Remaining()) + } + return sessionID, nil +} + +// 12. MUX_C_NEW_STDIO_FWD (0x10000008) +func EncodeMuxNewStdioFwd(req MuxNewStdioFwdRequest) []byte { + w := newMuxWriter() + w.WriteUint32(req.RequestID) + w.WriteString(req.Reserved) + w.WriteString(req.ConnectHost) + w.WriteUint32(req.ConnectPort) + return w.Bytes() +} + +func DecodeMuxNewStdioFwd(payload []byte) (req MuxNewStdioFwdRequest, err error) { + r := newMuxReader(payload) + if req.RequestID, err = r.ReadUint32(); err != nil { + return req, err + } + if req.Reserved, err = r.ReadString(); err != nil { + return req, err + } + if req.ConnectHost, err = r.ReadString(); err != nil { + return req, err + } + if req.ConnectPort, err = r.ReadUint32(); err != nil { + return req, err + } + if r.Remaining() != 0 { + return req, fmt.Errorf("trailing %d bytes in stdio fwd request", r.Remaining()) + } + return req, nil +} + +// 13. MUX_C_EXT_INFO (0x20000001) +func EncodeMuxExtInfo(requestID uint32, name string) []byte { + w := newMuxWriter() + w.WriteUint32(requestID) + w.WriteString(name) + return w.Bytes() +} + +func DecodeMuxExtInfo(payload []byte) (requestID uint32, name string, err error) { + r := newMuxReader(payload) + if requestID, err = r.ReadUint32(); err != nil { + return 0, "", err + } + if name, err = r.ReadString(); err != nil { + return 0, "", err + } + if r.Remaining() != 0 { + return 0, "", fmt.Errorf("trailing %d bytes in ext info request", r.Remaining()) + } + return requestID, name, nil +} + +// 14. MUX_S_EXT_INFO (0x90000001) +func EncodeMuxExtInfoResponse(clientRequestID uint32, status uint32, msg string) []byte { + w := newMuxWriter() + w.WriteUint32(clientRequestID) + w.WriteUint32(status) + w.WriteString(msg) + return w.Bytes() +} + +func DecodeMuxExtInfoResponse(payload []byte) (clientRequestID uint32, status uint32, msg string, err error) { + r := newMuxReader(payload) + if clientRequestID, err = r.ReadUint32(); err != nil { + return 0, 0, "", err + } + if status, err = r.ReadUint32(); err != nil { + return 0, 0, "", err + } + if msg, err = r.ReadString(); err != nil { + return 0, 0, "", err + } + if r.Remaining() != 0 { + return 0, 0, "", fmt.Errorf("trailing %d bytes in ext info response", r.Remaining()) + } + return clientRequestID, status, msg, nil +} diff --git a/core/ssh/mux_protocol_test.go b/core/ssh/mux_protocol_test.go new file mode 100644 index 00000000..925d92bb --- /dev/null +++ b/core/ssh/mux_protocol_test.go @@ -0,0 +1,388 @@ +package ssh + +import ( + "bytes" + "errors" + "io" + "reflect" + "strings" + "testing" +) + +func TestMuxPacket_FramingRoundtrip(t *testing.T) { + buf := new(bytes.Buffer) + payload := []byte("hello multiplex world 12345") + + if err := WriteMuxPacket(buf, MUX_C_ALIVE_CHECK, payload); err != nil { + t.Fatalf("WriteMuxPacket failed: %v", err) + } + + pktType, readPayload, err := ReadMuxPacket(buf) + if err != nil { + t.Fatalf("ReadMuxPacket failed: %v", err) + } + if pktType != MUX_C_ALIVE_CHECK { + t.Fatalf("expected packet type 0x%08x, got 0x%08x", MUX_C_ALIVE_CHECK, pktType) + } + if !bytes.Equal(readPayload, payload) { + t.Fatalf("expected payload %q, got %q", payload, readPayload) + } +} + +func TestMuxPacket_InvalidLengthBounds(t *testing.T) { + // 1. Length < 4 + shortPkt := []byte{0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01} + r := bytes.NewReader(shortPkt) + _, _, err := ReadMuxPacket(r) + if err == nil || !strings.Contains(err.Error(), "invalid mux packet length") { + t.Fatalf("expected length error, got: %v", err) + } + + // 2. Length > MaxMuxPacketLength + hugePkt := []byte{0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01} // 512KB > 256KB + r = bytes.NewReader(hugePkt) + _, _, err = ReadMuxPacket(r) + if err == nil || !strings.Contains(err.Error(), "exceeds maximum") { + t.Fatalf("expected max length error, got: %v", err) + } +} + +func TestMuxPacket_TruncatedPayload(t *testing.T) { + // Length header says 14 bytes (4 type + 10 payload), but only 4 bytes payload provided + truncated := []byte{ + 0x00, 0x00, 0x00, 0x0e, // len = 14 + 0x10, 0x00, 0x00, 0x04, // MUX_C_ALIVE_CHECK + 0x01, 0x02, 0x03, 0x04, // only 4 bytes of payload + } + r := bytes.NewReader(truncated) + _, _, err := ReadMuxPacket(r) + if err == nil || !strings.Contains(err.Error(), "failed to read mux packet payload") { + t.Fatalf("expected truncated payload error, got: %v", err) + } +} + +func TestMuxCodec_Primitives(t *testing.T) { + w := newMuxWriter() + w.WriteUint32(0x12345678) + w.WriteBool(true) + w.WriteBool(false) + w.WriteString("test string") + w.WriteBytes([]byte{0xaa, 0xbb, 0xcc}) + + r := newMuxReader(w.Bytes()) + + u, err := r.ReadUint32() + if err != nil || u != 0x12345678 { + t.Fatalf("ReadUint32: got %x, err: %v", u, err) + } + + b1, err := r.ReadBool() + if err != nil || !b1 { + t.Fatalf("ReadBool(1): got %v, err: %v", b1, err) + } + + b2, err := r.ReadBool() + if err != nil || b2 { + t.Fatalf("ReadBool(2): got %v, err: %v", b2, err) + } + + s, err := r.ReadString() + if err != nil || s != "test string" { + t.Fatalf("ReadString: got %q, err: %v", s, err) + } + + bs, err := r.ReadBytes() + if err != nil || !bytes.Equal(bs, []byte{0xaa, 0xbb, 0xcc}) { + t.Fatalf("ReadBytes: got %v, err: %v", bs, err) + } + + if r.Remaining() != 0 { + t.Fatalf("expected 0 remaining bytes, got %d", r.Remaining()) + } + + // Unexpected EOF on empty reader + emptyR := newMuxReader(nil) + if _, err := emptyR.ReadUint32(); !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("expected ErrUnexpectedEOF, got %v", err) + } + if _, err := emptyR.ReadBool(); !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("expected ErrUnexpectedEOF, got %v", err) + } +} + +func TestMuxMsg_Hello(t *testing.T) { + exts := []MuxExtension{ + {Name: "ext1@openssh.com", Value: "val1"}, + {Name: "ext2@xops.internal", Value: "val2"}, + } + payload := EncodeMuxHello(MuxProtoVersion, exts) + + version, decodedExts, err := DecodeMuxHello(payload) + if err != nil { + t.Fatalf("DecodeMuxHello failed: %v", err) + } + if version != MuxProtoVersion { + t.Fatalf("expected version %d, got %d", MuxProtoVersion, version) + } + if !reflect.DeepEqual(decodedExts, exts) { + t.Fatalf("extensions mismatch: expected %+v, got %+v", exts, decodedExts) + } +} + +func TestMuxMsg_AliveCheck_And_Alive(t *testing.T) { + // Request + reqPayload := EncodeMuxRequestID(42) + reqID, err := DecodeMuxRequestID(reqPayload) + if err != nil || reqID != 42 { + t.Fatalf("DecodeMuxRequestID: got %d, err: %v", reqID, err) + } + + // Response + alivePayload := EncodeMuxAlive(42, 9999) + respID, pid, err := DecodeMuxAlive(alivePayload) + if err != nil || respID != 42 || pid != 9999 { + t.Fatalf("DecodeMuxAlive: got req=%d pid=%d, err: %v", respID, pid, err) + } +} + +func TestMuxMsg_Terminate_Stop_Proxy(t *testing.T) { + for _, id := range []uint32{1, 100, 999999} { + payload := EncodeMuxClientRequestID(id) + decID, err := DecodeMuxClientRequestID(payload) + if err != nil || decID != id { + t.Fatalf("DecodeMuxClientRequestID(%d): got %d, err: %v", id, decID, err) + } + } +} + +func TestMuxMsg_ErrorResponses(t *testing.T) { + payload := EncodeMuxError(123, "permission denied for client") + id, reason, err := DecodeMuxError(payload) + if err != nil { + t.Fatalf("DecodeMuxError failed: %v", err) + } + if id != 123 || reason != "permission denied for client" { + t.Fatalf("got id=%d reason=%q", id, reason) + } +} + +func TestMuxMsg_PortForwarding(t *testing.T) { + req := MuxFwdRequest{ + RequestID: 55, + FwdType: MUX_FWD_REMOTE, + ListenHost: "0.0.0.0", + ListenPort: 0, // dynamically allocate + ConnectHost: "127.0.0.1", + ConnectPort: 8080, + } + payload := EncodeMuxFwdRequest(req) + decoded, err := DecodeMuxFwdRequest(payload) + if err != nil { + t.Fatalf("DecodeMuxFwdRequest failed: %v", err) + } + if !reflect.DeepEqual(decoded, req) { + t.Fatalf("fwd request mismatch: expected %+v, got %+v", req, decoded) + } + + // Remote port allocation response + respPayload := EncodeMuxRemotePort(55, 34567) + reqID, allocPort, err := DecodeMuxRemotePort(respPayload) + if err != nil || reqID != 55 || allocPort != 34567 { + t.Fatalf("DecodeMuxRemotePort: got req=%d port=%d, err: %v", reqID, allocPort, err) + } +} + +func TestMuxMsg_NewSession(t *testing.T) { + req := MuxNewSessionRequest{ + RequestID: 77, + Reserved: "", + WantTTY: true, + WantX11: false, + WantAgent: true, + Subsystem: false, + EscapeChar: 0x1e, + TermType: "xterm-256color", + Command: "/bin/bash -l", + Env: []string{"LANG=en_US.UTF-8", "TERM=xterm-256color"}, + } + payload := EncodeMuxNewSession(req) + decoded, err := DecodeMuxNewSession(payload) + if err != nil { + t.Fatalf("DecodeMuxNewSession failed: %v", err) + } + if !reflect.DeepEqual(decoded, req) { + t.Fatalf("new session request mismatch: expected %+v, got %+v", req, decoded) + } + + // Session opened response + openPayload := EncodeMuxSessionOpened(77, 1) + reqID, sessID, err := DecodeMuxSessionOpened(openPayload) + if err != nil || reqID != 77 || sessID != 1 { + t.Fatalf("DecodeMuxSessionOpened: got req=%d sess=%d, err: %v", reqID, sessID, err) + } + + // Exit message + exitPayload := EncodeMuxExitMessage(1, 0) + sID, exitVal, err := DecodeMuxExitMessage(exitPayload) + if err != nil || sID != 1 || exitVal != 0 { + t.Fatalf("DecodeMuxExitMessage: got sess=%d exit=%d, err: %v", sID, exitVal, err) + } + + // TTY Alloc Fail + failPayload := EncodeMuxTTYAllocFail(1) + sID, err = DecodeMuxTTYAllocFail(failPayload) + if err != nil || sID != 1 { + t.Fatalf("DecodeMuxTTYAllocFail: got sess=%d, err: %v", sID, err) + } +} + +func TestMuxMsg_NewStdioFwd(t *testing.T) { + req := MuxNewStdioFwdRequest{ + RequestID: 88, + Reserved: "", + ConnectHost: "internal.database", + ConnectPort: 5432, + } + payload := EncodeMuxNewStdioFwd(req) + decoded, err := DecodeMuxNewStdioFwd(payload) + if err != nil { + t.Fatalf("DecodeMuxNewStdioFwd failed: %v", err) + } + if !reflect.DeepEqual(decoded, req) { + t.Fatalf("stdio fwd request mismatch: expected %+v, got %+v", req, decoded) + } +} + +func TestMuxMsg_NewSession_OpenSSHWireFixture(t *testing.T) { + // Exact OpenSSH PROTOCOL.mux v4 wire layout for MUX_C_NEW_SESSION: + // uint32 request_id (1) + // string reserved ("") + // uint32 want_tty (1) + // uint32 want_x11 (0) + // uint32 want_agent (1) + // uint32 subsystem (0) + // uint32 escape_char (0xffffffff) + // string term ("xterm") + // string command ("echo hi") + // string env[0] ("LC_ALL=C") + wire := []byte{ + 0x00, 0x00, 0x00, 0x01, // request_id = 1 + 0x00, 0x00, 0x00, 0x00, // reserved = "" + 0x00, 0x00, 0x00, 0x01, // want_tty = 1 (uint32) + 0x00, 0x00, 0x00, 0x00, // want_x11 = 0 (uint32) + 0x00, 0x00, 0x00, 0x01, // want_agent = 1 (uint32) + 0x00, 0x00, 0x00, 0x00, // subsystem = 0 (uint32) + 0xff, 0xff, 0xff, 0xff, // escape_char = 0xffffffff + 0x00, 0x00, 0x00, 0x05, 'x', 't', 'e', 'r', 'm', // term = "xterm" + 0x00, 0x00, 0x00, 0x07, 'e', 'c', 'h', 'o', ' ', 'h', 'i', // command = "echo hi" + 0x00, 0x00, 0x00, 0x08, 'L', 'C', '_', 'A', 'L', 'L', '=', 'C', // env[0] = "LC_ALL=C" + } + + decoded, err := DecodeMuxNewSession(wire) + if err != nil { + t.Fatalf("DecodeMuxNewSession on OpenSSH wire fixture failed: %v", err) + } + want := MuxNewSessionRequest{ + RequestID: 1, + Reserved: "", + WantTTY: true, + WantX11: false, + WantAgent: true, + Subsystem: false, + EscapeChar: 0xffffffff, + TermType: "xterm", + Command: "echo hi", + Env: []string{"LC_ALL=C"}, + } + if !reflect.DeepEqual(decoded, want) { + t.Fatalf("decoded mismatch:\ngot: %+v\nwant: %+v", decoded, want) + } + reencoded := EncodeMuxNewSession(want) + if !bytes.Equal(reencoded, wire) { + t.Fatalf("reencoded bytes do not match OpenSSH wire layout:\ngot: %x\nwant: %x", reencoded, wire) + } +} + +func TestMuxMsg_NewStdioFwd_OpenSSHWireFixture(t *testing.T) { + // Exact OpenSSH PROTOCOL.mux v4 wire layout for MUX_C_NEW_STDIO_FWD: + // uint32 request_id (1) + // string reserved ("") + // string connect_host ("127.0.0.1") + // uint32 connect_port (22) + wire := []byte{ + 0x00, 0x00, 0x00, 0x01, // request_id = 1 + 0x00, 0x00, 0x00, 0x00, // reserved = "" + 0x00, 0x00, 0x00, 0x09, '1', '2', '7', '.', '0', '.', '0', '.', '1', // connect_host + 0x00, 0x00, 0x00, 0x16, // connect_port = 22 (uint32) + } + + decoded, err := DecodeMuxNewStdioFwd(wire) + if err != nil { + t.Fatalf("DecodeMuxNewStdioFwd on OpenSSH wire fixture failed: %v", err) + } + want := MuxNewStdioFwdRequest{ + RequestID: 1, + Reserved: "", + ConnectHost: "127.0.0.1", + ConnectPort: 22, + } + if !reflect.DeepEqual(decoded, want) { + t.Fatalf("decoded mismatch:\ngot: %+v\nwant: %+v", decoded, want) + } + reencoded := EncodeMuxNewStdioFwd(want) + if !bytes.Equal(reencoded, wire) { + t.Fatalf("reencoded bytes do not match OpenSSH wire layout:\ngot: %x\nwant: %x", reencoded, wire) + } +} + +func TestMuxMsg_MalformedPayloads(t *testing.T) { + // Truncated payload for DecodeMuxAlive + if _, _, err := DecodeMuxAlive([]byte{0x00, 0x01}); err == nil { + t.Fatal("expected error on truncated alive payload, got nil") + } + + // Truncated payload for DecodeMuxError + if _, _, err := DecodeMuxError([]byte{0x00, 0x01}); err == nil { + t.Fatal("expected error on truncated error payload, got nil") + } + + // Truncated payload for DecodeMuxFwdRequest + if _, err := DecodeMuxFwdRequest([]byte{0x00, 0x01}); err == nil { + t.Fatal("expected error on truncated fwd payload, got nil") + } + + // Truncated payload for DecodeMuxNewSession + if _, err := DecodeMuxNewSession([]byte{0x00, 0x01}); err == nil { + t.Fatal("expected error on truncated new session payload, got nil") + } +} + +func TestMuxMsg_ExtInfo(t *testing.T) { + // Request encoding/decoding + reqEncoded := EncodeMuxExtInfo(101, "connection") + reqID, name, err := DecodeMuxExtInfo(reqEncoded) + if err != nil { + t.Fatalf("DecodeMuxExtInfo failed: %v", err) + } + if reqID != 101 || name != "connection" { + t.Fatalf("ext info request mismatch: reqID=%d, name=%q", reqID, name) + } + + // Response encoding/decoding + respEncoded := EncodeMuxExtInfoResponse(101, 1, "Master running ok") + clientReqID, status, msg, err := DecodeMuxExtInfoResponse(respEncoded) + if err != nil { + t.Fatalf("DecodeMuxExtInfoResponse failed: %v", err) + } + if clientReqID != 101 || status != 1 || msg != "Master running ok" { + t.Fatalf("ext info response mismatch: clientReqID=%d, status=%d, msg=%q", clientReqID, status, msg) + } + + // Truncated payloads + if _, _, err := DecodeMuxExtInfo([]byte{0x00, 0x01}); err == nil { + t.Fatal("expected error on truncated ext info request") + } + if _, _, _, err := DecodeMuxExtInfoResponse([]byte{0x00, 0x01}); err == nil { + t.Fatal("expected error on truncated ext info response") + } +} diff --git a/core/ssh/mux_proxy_close_test.go b/core/ssh/mux_proxy_close_test.go new file mode 100644 index 00000000..6fc7db6d --- /dev/null +++ b/core/ssh/mux_proxy_close_test.go @@ -0,0 +1,322 @@ +package ssh + +import ( + "context" + "encoding/binary" + "errors" + "io" + "net" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + cryptoSSH "golang.org/x/crypto/ssh" + + "github.com/wentf9/xops-cli/core/testutil/sshfixture" +) + +// stubRemoteChannel is a channel with no transport behind it, for exercising the +// proxy relay state machine without a live SSH server. +type stubRemoteChannel struct { + closed atomic.Bool +} + +func (c *stubRemoteChannel) Read([]byte) (int, error) { return 0, io.EOF } +func (c *stubRemoteChannel) Write(data []byte) (int, error) { return len(data), nil } +func (c *stubRemoteChannel) CloseWrite() error { return nil } +func (c *stubRemoteChannel) SendRequest(string, bool, []byte) (bool, error) { return true, nil } +func (c *stubRemoteChannel) Stderr() io.ReadWriter { return nil } +func (c *stubRemoteChannel) Close() error { + c.closed.Store(true) + return nil +} + +func encodeChannelIDs(values ...uint32) []byte { + w := newMuxWriter() + for _, value := range values { + w.WriteUint32(value) + } + return w.Bytes() +} + +func newStubProxySession(t *testing.T) (*proxySessionState, *proxyChan, net.Conn) { + t.Helper() + conn, peer := net.Pipe() + t.Cleanup(func() { + _ = conn.Close() + _ = peer.Close() + }) + ps := &proxySessionState{ + server: &MuxServer{}, + conn: conn, + global: newOrderedQueue[proxyGlobalRequest](), + } + pc := &proxyChan{ + remoteCh: &stubRemoteChannel{}, + clientCh: 7, + serverCh: 3, + clientWindow: newClientFlowWindow(4096), + clientMaxPacket: 1024, + requests: newOrderedQueue[proxyChannelRequest](), + input: newOrderedQueue[proxyChannelInput](), + pendingRemoteReqs: newRemoteRequestQueue(), + } + ps.channels.Store(pc.serverCh, pc) + return ps, pc, peer +} + +// TestProxyEchoesClientInitiatedChannelClose verifies that a channel the client +// closed receives exactly one SSH_MSG_CHANNEL_CLOSE back. RFC 4254 requires that +// answer, and x/crypto only releases the client-side channel (unblocking readers) +// after receiving it. +func TestProxyEchoesClientInitiatedChannelClose(t *testing.T) { + ps, pc, peer := newStubProxySession(t) + + // net.Pipe is synchronous, so the peer must read while the close is written. + type readResult struct { + packet []byte + err error + } + readCh := make(chan readResult, 1) + if err := peer.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatalf("SetReadDeadline failed: %v", err) + } + go func() { + packet, err := readUnpaddedPacket(peer) + readCh <- readResult{packet: packet, err: err} + }() + ps.handleClose(encodeChannelIDs(pc.serverCh)) + + result := <-readCh + if result.err != nil { + t.Fatalf("reading the close echo failed: %v", result.err) + } + packet := result.packet + if len(packet) < 5 || packet[0] != msgChannelClose { + t.Fatalf("expected SSH_MSG_CHANNEL_CLOSE, got %v", packet) + } + if channel := binary.BigEndian.Uint32(packet[1:5]); channel != pc.clientCh { + t.Fatalf("close echo addressed channel %d, want %d", channel, pc.clientCh) + } + if !pc.remoteCh.(*stubRemoteChannel).closed.Load() { + t.Fatal("handleClose did not close the remote channel") + } + + // A second close (for example the client's answer to a master-initiated + // close) must not produce another packet, and nothing may follow the close. + if err := peer.SetReadDeadline(time.Now().Add(200 * time.Millisecond)); err != nil { + t.Fatalf("SetReadDeadline failed: %v", err) + } + if _, err := readUnpaddedPacket(peer); err == nil { + t.Fatal("duplicate SSH_MSG_CHANNEL_CLOSE was written") + } + if err := ps.writeChannelPacket(pc, []byte{msgChannelData, 0, 0, 0, 0}); !errors.Is(err, io.EOF) { + t.Fatalf("expected a closed channel to reject further packets, got %v", err) + } + if err := ps.writeChannelPacket(pc, nil); !errors.Is(err, io.EOF) { + t.Fatalf("expected a closed channel to reject the close echo write, got %v", err) + } +} + +// TestProxySessionStopsWritingAfterClose verifies that a torn-down proxy session +// writes nothing further. Waking relay workers after the connection is gone must +// not push a close (or any other packet) into a dead transport. +func TestProxySessionStopsWritingAfterClose(t *testing.T) { + ps, pc, _ := newStubProxySession(t) + + // net.Pipe writes block without a reader; a stray write must fail fast instead + // of hanging the test, and closeSent records whether a write was attempted. + if err := ps.conn.SetWriteDeadline(time.Now().Add(200 * time.Millisecond)); err != nil { + t.Fatalf("SetWriteDeadline failed: %v", err) + } + ps.closeAll() + + if err := ps.writeChannelPacket(pc, []byte{msgChannelData, 0, 0, 0, 0}); !errors.Is(err, io.EOF) { + t.Fatalf("expected a closed session to reject channel packets, got %v", err) + } + if err := ps.writePacket([]byte{msgRequestSuccess}); err == nil { + t.Fatal("expected a closed session to reject session-level packets") + } + ps.closeChannel(pc) + if pc.closeSent { + t.Fatal("closeChannel wrote a close packet after the session ended") + } +} + +// TestProxyReleasesClientChannelAfterClientClose verifies the observable effect +// of the close echo: after the client closes a session, reading its output must +// return EOF instead of blocking until the connection dies. +func TestProxyReleasesClientChannelAfterClientClose(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) + defer cancel() + + release := make(chan struct{}) + fixture := startMuxFixture(t, ctx, sshfixture.Options{ + ExecHandler: func(command string, channel cryptoSSH.Channel) { + if command == "hold" { + <-release + return + } + _, _ = io.WriteString(channel, "ok\n") + _, _ = channel.SendRequest("exit-status", false, cryptoSSH.Marshal(struct{ Status uint32 }{0})) + }, + }) + defer close(release) + + client, proxyConn, err := DialControlProxy(ctx, fixture.SocketPath, fixture.Target) + if err != nil { + t.Fatalf("DialControlProxy failed: %v", err) + } + defer func() { + _ = client.Close() + _ = proxyConn.Close() + }() + + // The remote command never finishes, so the channel stays open until the + // client closes it explicitly. + session, err := client.NewSession() + if err != nil { + t.Fatalf("NewSession failed: %v", err) + } + stdout, err := session.StdoutPipe() + if err != nil { + t.Fatalf("StdoutPipe failed: %v", err) + } + if err := session.Start("hold"); err != nil { + t.Fatalf("Start failed: %v", err) + } + if err := session.Close(); err != nil { + t.Fatalf("closing the session failed: %v", err) + } + + readDone := make(chan error, 1) + go func() { + var buffer [1]byte + _, err := stdout.Read(buffer[:]) + readDone <- err + }() + select { + case err := <-readDone: + if !errors.Is(err, io.EOF) { + t.Fatalf("expected EOF on the closed channel, got %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("client channel was not released after it sent SSH_MSG_CHANNEL_CLOSE") + } + + // The connection must survive the close: a second session still works. + second, err := client.NewSession() + if err != nil { + t.Fatalf("NewSession after a client-initiated close failed: %v", err) + } + defer func() { _ = second.Close() }() + output, err := second.Output("echo ok") + if err != nil { + t.Fatalf("command after a client-initiated close failed: %v", err) + } + if string(output) != "ok\n" { + t.Fatalf("output after a client-initiated close = %q, want %q", output, "ok\n") + } +} + +// probeFixtureConn answers keepalives on demand and records every Close, so a +// test can tell whether a shared cached connection was interrupted. +type probeFixtureConn struct { + cryptoSSH.Conn + requestSeen chan struct{} + release chan struct{} + requestCount atomic.Int32 + closeCount atomic.Int32 +} + +func (c *probeFixtureConn) SendRequest(string, bool, []byte) (bool, []byte, error) { + if c.requestCount.Add(1) == 1 { + close(c.requestSeen) + } + <-c.release + return true, nil, nil +} + +func (c *probeFixtureConn) OpenChannel(string, []byte) (cryptoSSH.Channel, <-chan *cryptoSSH.Request, error) { + return nil, nil, errors.New("fixture target unreachable") +} + +func (c *probeFixtureConn) Close() error { + c.closeCount.Add(1) + return nil +} + +// TestJumpHopProbeSurvivesCallerCancellation verifies that canceling one caller +// while a cached jump hop is being validated does not interrupt that shared +// transport. The probe belongs to the connector's shared task, not to the +// caller that happens to trigger it. +func TestJumpHopProbeSurvivesCallerCancellation(t *testing.T) { + hopConn := &probeFixtureConn{ + requestSeen: make(chan struct{}), + release: make(chan struct{}), + } + // Unblock the shared probe on every path, including failures, so a regression + // cannot stall connector shutdown on a keepalive that never answers. The defer + // is registered after the connector's so it runs before CloseAll joins it. + releaseHopProbe := sync.OnceFunc(func() { close(hopConn.release) }) + provider := &jumpChainProvider{ + cfgs: map[string]*ClientConfig{ + "jump": { + NodeID: "jump", Address: "192.0.2.30", Port: 22, User: "ops", AuthType: "password", Password: "secret", + }, + "target": { + NodeID: "target", Address: "192.0.2.31", Port: 22, User: "ops", AuthType: "password", Password: "secret", + ProxyJump: "jump", + }, + }, + } + connector := newTestConnector(provider, WithInteractionHandler(&mockUI{})) + defer func() { + if err := connector.CloseAll(); err != nil { + t.Logf("CloseAll failed: %v", err) + } + }() + defer releaseHopProbe() + connector.clients.Set("jump", &PooledClient{SSHClient: &cryptoSSH.Client{Conn: hopConn}}) + + firstCtx, cancelFirst := context.WithCancel(context.Background()) + firstDone := make(chan error, 1) + go func() { + _, err := connector.Connect(firstCtx, "target") + firstDone <- err + }() + select { + case <-hopConn.requestSeen: + case <-time.After(5 * time.Second): + t.Fatal("cached jump hop was never probed") + } + + cancelFirst() + select { + case err := <-firstDone: + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected caller cancellation, got %v", err) + } + // The cancellation must be reported for the hop that was being validated, + // not masked into a "rebuild the chain" fallback. + if !strings.Contains(err.Error(), "'jump'") { + t.Fatalf("cancellation lost the hop that was being probed: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("canceled caller did not return") + } + + // Let the shared probe finish, then drive a second connection through the + // same hop. It fails at the unreachable target, but the hop must stay usable. + releaseHopProbe() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := connector.Connect(ctx, "target"); err == nil { + t.Fatal("expected the unreachable target to fail the connection") + } + if got := hopConn.closeCount.Load(); got != 0 { + t.Fatalf("canceling a caller closed the shared jump transport %d times", got) + } +} diff --git a/core/ssh/mux_proxy_conn.go b/core/ssh/mux_proxy_conn.go new file mode 100644 index 00000000..994f2d2e --- /dev/null +++ b/core/ssh/mux_proxy_conn.go @@ -0,0 +1,343 @@ +package ssh + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "net" + "strings" + "sync" + "sync/atomic" + + logger "github.com/wentf9/xops-cli/core/log" + cryptoSSH "golang.org/x/crypto/ssh" +) + +// muxProxySSHConn implements golang.org/x/crypto/ssh.Conn over an OpenSSH +// ControlMaster socket in proxy mode. +type muxProxySSHConn struct { + cryptoSSH.Conn + rawConn net.Conn + target TargetContext + remoteAddr net.Addr + localAddr net.Addr + user string + closed atomic.Bool + closeOnce sync.Once + closeErr error +} + +type targetTCPAddr struct { + network string + address string +} + +func (a *targetTCPAddr) Network() string { return a.network } +func (a *targetTCPAddr) String() string { return a.address } + +func newMuxProxySSHConn(underlying cryptoSSH.Conn, raw net.Conn, target TargetContext) *muxProxySSHConn { + port := target.Port + if port <= 0 { + port = 22 + } + remoteStr := fmt.Sprintf("%s:%d", target.Host, port) + remoteAddr := &targetTCPAddr{network: "tcp", address: remoteStr} + + localAddr := raw.LocalAddr() + if localAddr == nil { + localAddr = &targetTCPAddr{network: "unix", address: "local-controlmaster"} + } + + return &muxProxySSHConn{ + Conn: underlying, + rawConn: raw, + target: target, + remoteAddr: remoteAddr, + localAddr: localAddr, + user: target.User, + } +} + +func (m *muxProxySSHConn) User() string { + if m.user != "" { + return m.user + } + if m.Conn != nil { + return m.Conn.User() + } + return "" +} + +func (m *muxProxySSHConn) RemoteAddr() net.Addr { + if m.remoteAddr != nil { + return m.remoteAddr + } + if m.Conn != nil { + return m.Conn.RemoteAddr() + } + return &targetTCPAddr{network: "tcp", address: "unknown"} +} + +func (m *muxProxySSHConn) LocalAddr() net.Addr { + if m.localAddr != nil { + return m.localAddr + } + if m.Conn != nil { + return m.Conn.LocalAddr() + } + return &targetTCPAddr{network: "unix", address: "local-controlmaster"} +} + +func (m *muxProxySSHConn) RawConn() net.Conn { + return m.rawConn +} + +func (m *muxProxySSHConn) Target() TargetContext { + return m.target +} + +func (m *muxProxySSHConn) Close() error { + m.closeOnce.Do(func() { + m.closed.Store(true) + var errs []error + if m.Conn != nil { + if err := m.Conn.Close(); err != nil && !errors.Is(err, net.ErrClosed) { + errs = append(errs, err) + } + } + if m.rawConn != nil { + if err := m.rawConn.Close(); err != nil && !errors.Is(err, net.ErrClosed) { + errs = append(errs, err) + } + } + m.closeErr = errors.Join(errs...) + }) + return m.closeErr +} + +// muxHandshakeInspectorConn wraps a control socket connection during the initial +// NewControlClientConn handshake so target identity (advertised in the server's +// MUX_MSG_HELLO) and proxy rejection packets are inspected on the exact same +// socket connection without a separate TOCTOU probe connection. +type muxHandshakeInspectorConn struct { + net.Conn + socketPath string + target TargetContext + inspecting atomic.Bool + readBuf bytes.Buffer + packetsSeen int + mismatchErr error + rejectedErr error +} + +func (c *muxHandshakeInspectorConn) Read(p []byte) (int, error) { + n, err := c.Conn.Read(p) + if n > 0 && c.inspecting.Load() { + if c.readBuf.Len()+n <= 2*MaxMuxPacketLength { + _, _ = c.readBuf.Write(p[:n]) + c.inspectBufferedPackets() + } else { + c.mismatchErr = fmt.Errorf("control proxy handshake on %q failed: handshake buffer exceeded %d bytes", c.socketPath, 2*MaxMuxPacketLength) + c.inspecting.Store(false) + } + if c.mismatchErr != nil { + return 0, c.mismatchErr + } + } + return n, err +} + +func (c *muxHandshakeInspectorConn) inspectBufferedPackets() { + for c.packetsSeen < 2 && c.readBuf.Len() >= 4 { + raw := c.readBuf.Bytes() + pktLen := binary.BigEndian.Uint32(raw[:4]) + if pktLen < 4 || pktLen > MaxMuxPacketLength { + c.mismatchErr = fmt.Errorf("control proxy handshake on %q failed: invalid mux packet length %d", c.socketPath, pktLen) + c.inspecting.Store(false) + return + } + if len(raw) < 8 { + return + } + totalLen := 4 + int(pktLen) + if len(raw) < totalLen { + return + } + msgType := binary.BigEndian.Uint32(raw[4:8]) + payload := raw[8:totalLen] + c.packetsSeen++ + _ = c.readBuf.Next(totalLen) + + switch c.packetsSeen { + case 1: + if c.target.CanonicalTargetIdentity() != "" { + if msgType != MUX_MSG_HELLO { + c.mismatchErr = fmt.Errorf("control proxy handshake on %q failed: expected MUX_MSG_HELLO, got 0x%08x", c.socketPath, msgType) + c.inspecting.Store(false) + return + } + _, exts, err := DecodeMuxHello(payload) + if err != nil { + c.mismatchErr = fmt.Errorf("control proxy handshake on %q failed: %w", c.socketPath, err) + c.inspecting.Store(false) + return + } + for _, ext := range exts { + if ext.Name == muxTargetExtensionName && !c.target.MatchesMasterTarget(ext.Value) { + c.mismatchErr = fmt.Errorf( + "control socket %q is bound to target %s, cannot reuse for %s: %w", + c.socketPath, + FormatTargetIdentity(ext.Value), + FormatTargetIdentity(c.target.CanonicalTargetIdentity()), + ErrControlTargetMismatch, + ) + c.inspecting.Store(false) + return + } + } + } + case 2: + if msgType == MUX_S_PERMISSION_DENIED || msgType == MUX_S_FAILURE { + _, reason, _ := DecodeMuxError(payload) + c.rejectedErr = fmt.Errorf("control proxy handshake failed on %q: %w: %s (0x%08x)", c.socketPath, ErrControlProxyRejected, reason, msgType) + } + c.inspecting.Store(false) + } + } +} + +// DialControlProxy connects to an OpenSSH ControlMaster socket in proxy mode, +// wraps the resulting connection in muxProxySSHConn, and returns a standard *cryptoSSH.Client. +// The endpoint must belong to the effective local user (or root); see +// DialMuxClient. +func DialControlProxy(ctx context.Context, socketPath string, target TargetContext, opts ...MuxClientOption) (*cryptoSSH.Client, *muxProxySSHConn, error) { + options := muxClientOptions{expectedUID: localPeerUID()} + for _, opt := range opts { + if opt != nil { + opt(&options) + } + } + + var dialer net.Dialer + rawConn, err := dialer.DialContext(ctx, "unix", socketPath) + if err != nil { + return nil, nil, fmt.Errorf("dial control socket %q: %w", socketPath, err) + } + + unbind := bindConnContext(ctx, rawConn, DefaultMuxHandshakeTimeout) + defer unbind() + + if err := checkPeerUID(rawConn, options.expectedUID); err != nil { + _ = rawConn.Close() + return nil, nil, fmt.Errorf("control socket %q rejected: %w", socketPath, err) + } + + inspector := &muxHandshakeInspectorConn{ + Conn: rawConn, + socketPath: socketPath, + target: target, + } + inspector.inspecting.Store(true) + + // Standard library proxy handshake (sends HELLO + PROXY, receives HELLO + PROXY) + sshConn, chans, reqs, err := cryptoSSH.NewControlClientConn(inspector) + inspector.inspecting.Store(false) + inspector.readBuf.Reset() + if err != nil { + _ = rawConn.Close() + if inspector.mismatchErr != nil { + return nil, nil, inspector.mismatchErr + } + if inspector.rejectedErr != nil { + return nil, nil, inspector.rejectedErr + } + errMsg := err.Error() + if strings.Contains(errMsg, "2147483650") || strings.Contains(errMsg, "2147483651") || + strings.Contains(strings.ToLower(errMsg), "permission denied") { + return nil, nil, fmt.Errorf("control proxy handshake failed on %q: %w: %w", socketPath, ErrControlProxyRejected, err) + } + return nil, nil, fmt.Errorf("control proxy handshake failed on %q: %w", socketPath, err) + } + if inspector.mismatchErr != nil { + _ = sshConn.Close() + _ = rawConn.Close() + return nil, nil, inspector.mismatchErr + } + + proxyConn := newMuxProxySSHConn(sshConn, rawConn, target) + sshClient := cryptoSSH.NewClient(proxyConn, chans, reqs) + + return sshClient, proxyConn, nil +} + +// DialControlProxyClient connects to an OpenSSH ControlMaster socket in +// MUX_C_PROXY mode, verifies target identity if target.Host is specified, and +// returns a wrapped *Client ready to execute SSH sessions over the multiplexed +// connection without falling back to direct TCP. The caller owns the returned +// *Client and must Close it when done. +func DialControlProxyClient( + ctx context.Context, + socketPath string, + target TargetContext, + env Environment, + cfg *ClientConfig, + l logger.DebugLogger, + opts ...MuxClientOption, +) (*Client, error) { + options := muxClientOptions{expectedUID: expectedPeerUIDFromEnvironment(env)} + for _, opt := range opts { + if opt != nil { + opt(&options) + } + } + + dialOpts := append([]MuxClientOption{WithExpectedPeerUID(options.expectedUID)}, opts...) + rawClient, proxyConn, err := DialControlProxy(ctx, socketPath, target, dialOpts...) + if err != nil { + return nil, err + } + + effectiveCfg := cfg + if effectiveCfg == nil { + port := target.Port + if port <= 0 { + port = 22 + } + effectiveCfg = &ClientConfig{ + Address: target.Host, + Port: port, + User: target.User, + } + } else if effectiveCfg.Port <= 0 || effectiveCfg.User == "" { + cloned := *effectiveCfg + if cloned.Port <= 0 { + if target.Port > 0 { + cloned.Port = target.Port + } else { + cloned.Port = 22 + } + } + if cloned.User == "" { + cloned.User = target.User + } + effectiveCfg = &cloned + } + + client := newClientWithComponents( + rawClient, + proxyConn.RawConn(), + effectiveCfg, + options.provider, + options.resolver, + options.prompter, + nil, + options.passwordPromptPattern, + 0, + 0, + l, + ) + client.environment = env + return client, nil +} diff --git a/core/ssh/mux_proxy_conn_test.go b/core/ssh/mux_proxy_conn_test.go new file mode 100644 index 00000000..a721c3bb --- /dev/null +++ b/core/ssh/mux_proxy_conn_test.go @@ -0,0 +1,127 @@ +package ssh + +import ( + "context" + "errors" + "net" + "os" + "path/filepath" + "runtime" + "strconv" + "testing" + "time" +) + +func TestMuxProxySSHConn_MetadataEnrichment(t *testing.T) { + c1, c2 := net.Pipe() + defer func() { _ = c1.Close() }() + defer func() { _ = c2.Close() }() + + target := TargetContext{ + Host: "example.org", + Port: 2222, + User: "deployuser", + } + + proxyConn := newMuxProxySSHConn(nil, c1, target) + + if proxyConn.User() != "deployuser" { + t.Fatalf("expected user deployuser, got %q", proxyConn.User()) + } + if proxyConn.RemoteAddr().String() != "example.org:2222" { + t.Fatalf("expected remote addr example.org:2222, got %q", proxyConn.RemoteAddr().String()) + } + if proxyConn.RemoteAddr().Network() != "tcp" { + t.Fatalf("expected tcp network, got %q", proxyConn.RemoteAddr().Network()) + } + if proxyConn.RawConn() != c1 { + t.Fatalf("expected raw conn c1, got %v", proxyConn.RawConn()) + } + if proxyConn.Target().Host != "example.org" { + t.Fatalf("expected target host example.org, got %q", proxyConn.Target().Host) + } + + if err := proxyConn.Close(); err != nil { + t.Fatalf("unexpected close error: %v", err) + } + // Second close should be idempotent + if err := proxyConn.Close(); err != nil { + t.Fatalf("second close error: %v", err) + } +} + +func TestValidateTargetCompatibility_ZeroPortNormalization(t *testing.T) { + cur := ConnectionConfig{ + Address: "10.0.0.1", + Port: 22, + User: "root", + } + newCfg := &ClientConfig{ + Address: "10.0.0.1", + Port: 0, + User: "root", + } + if err := validateTargetCompatibility(cur, newCfg); err != nil { + t.Fatalf("expected Port=0 and Port=22 to be compatible, got %v", err) + } + curZero := ConnectionConfig{ + Address: "10.0.0.1", + Port: 0, + User: "root", + } + newCfg22 := &ClientConfig{ + Address: "10.0.0.1", + Port: 22, + User: "root", + } + if err := validateTargetCompatibility(curZero, newCfg22); err != nil { + t.Fatalf("expected cur.Port=0 and newCfg.Port=22 to be compatible, got %v", err) + } + newCfgMismatch := &ClientConfig{ + Address: "10.0.0.1", + Port: 2222, + User: "root", + } + if err := validateTargetCompatibility(curZero, newCfgMismatch); !errors.Is(err, ErrSnapshotMismatch) { + t.Fatalf("expected ErrSnapshotMismatch for cur.Port=0 vs newCfg.Port=2222, got %v", err) + } +} + +func TestDialControlProxyClient_RespectsEnvironmentPeerUID(t *testing.T) { + if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { + t.Skip("peer UID verification is only supported on unix platforms") + } + if os.Geteuid() == 0 { + t.Skip("root peer UID is always accepted") + } + + tmpDir, err := os.MkdirTemp("", "proxy-uid-test-*") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + sockPath := filepath.Join(tmpDir, "mux.sock") + + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("Listen unix failed: %v", err) + } + defer func() { _ = ln.Close() }() + + go func() { + conn, acceptErr := ln.Accept() + if acceptErr == nil { + _ = conn.Close() + } + }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + wrongUID := strconv.Itoa(os.Geteuid() + 1000) + env := Environment{LocalUID: wrongUID} + _, err = DialControlProxyClient(ctx, sockPath, TargetContext{Host: "127.0.0.1", Port: 22, User: "root"}, env, nil, nil) + if !errors.Is(err, ErrUnauthorizedPeer) { + t.Fatalf("expected ErrUnauthorizedPeer when Environment.LocalUID mismatches socket owner, got %v", err) + } +} diff --git a/core/ssh/mux_proxy_flow_test.go b/core/ssh/mux_proxy_flow_test.go new file mode 100644 index 00000000..e80acb15 --- /dev/null +++ b/core/ssh/mux_proxy_flow_test.go @@ -0,0 +1,563 @@ +package ssh + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + cryptoSSH "golang.org/x/crypto/ssh" + + "github.com/wentf9/xops-cli/core/testutil/sshfixture" +) + +// TestProxyRelaysOutputBeyondClientWindow verifies that the master respects the +// receive window and maximum packet size the proxy client advertised. Relaying +// without credit overruns the client's 2 MiB window, and x/crypto then fails +// "ssh: remote side wrote too much" and drops the whole multiplexed connection. +func TestProxyRelaysOutputBeyondClientWindow(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) + defer cancel() + + const total = 6 << 20 + fixture := startMuxFixture(t, ctx, sshfixture.Options{ + ExecHandler: func(_ string, channel cryptoSSH.Channel) { + chunk := bytes.Repeat([]byte("x"), 32<<10) + for written := 0; written < total; { + n, err := channel.Write(chunk) + written += n + if err != nil { + return + } + } + _, _ = channel.SendRequest("exit-status", false, cryptoSSH.Marshal(struct{ Status uint32 }{0})) + }, + }) + + client, proxyConn, err := DialControlProxy(ctx, fixture.SocketPath, fixture.Target) + if err != nil { + t.Fatalf("DialControlProxy failed: %v", err) + } + defer func() { + _ = client.Close() + _ = proxyConn.Close() + }() + + session, err := client.NewSession() + if err != nil { + t.Fatalf("NewSession failed: %v", err) + } + defer func() { _ = session.Close() }() + + stdout, err := session.StdoutPipe() + if err != nil { + t.Fatalf("StdoutPipe failed: %v", err) + } + if err := session.Start("stream-big"); err != nil { + t.Fatalf("Start failed: %v", err) + } + + // Do not consume for a moment: a relay that ignores the client window pushes + // past it immediately, which is exactly the regression under test. + time.Sleep(500 * time.Millisecond) + + received, err := io.Copy(io.Discard, stdout) + if err != nil { + t.Fatalf("reading %d bytes through the proxy failed: %v", total, err) + } + if received != total { + t.Fatalf("received %d bytes through the proxy, want %d", received, total) + } + if err := session.Wait(); err != nil { + t.Fatalf("remote command through the proxy failed: %v", err) + } +} + +// TestProxyStalledChannelDoesNotBlockOtherChannels verifies that one channel +// whose remote side stopped reading stdin cannot stall the shared proxy read +// loop. Relaying input inline would block every other channel on the session. +func TestProxyStalledChannelDoesNotBlockOtherChannels(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) + defer cancel() + + release := make(chan struct{}) + fixture := startMuxFixture(t, ctx, sshfixture.Options{ + ExecHandler: func(command string, channel cryptoSSH.Channel) { + if command == "hold-stdin" { + // Never read stdin: the master's remote channel window fills up. + <-release + return + } + _, _ = io.WriteString(channel, "fast\n") + _, _ = channel.SendRequest("exit-status", false, cryptoSSH.Marshal(struct{ Status uint32 }{0})) + }, + }) + defer close(release) + + client, proxyConn, err := DialControlProxy(ctx, fixture.SocketPath, fixture.Target) + if err != nil { + t.Fatalf("DialControlProxy failed: %v", err) + } + defer func() { + _ = client.Close() + _ = proxyConn.Close() + }() + + stalled, err := client.NewSession() + if err != nil { + t.Fatalf("NewSession for the stalled channel failed: %v", err) + } + // No session Close here: closing the session blocks on the channel write lock + // while the transport write is stalled, so the client teardown below runs first. + stdin, err := stalled.StdinPipe() + if err != nil { + t.Fatalf("StdinPipe failed: %v", err) + } + if err := stalled.Start("hold-stdin"); err != nil { + t.Fatalf("Start for the stalled channel failed: %v", err) + } + + // Push more than the window this master advertises so the relay and the + // client writer both reach a blocked state. + payload := bytes.Repeat([]byte("x"), 4<<20) + go func() { + _, _ = io.Copy(stdin, bytes.NewReader(payload)) + }() + time.Sleep(500 * time.Millisecond) + + type result struct { + output []byte + err error + } + done := make(chan result, 1) + go func() { + session, err := client.NewSession() + if err != nil { + done <- result{err: err} + return + } + defer func() { _ = session.Close() }() + output, err := session.Output("echo fast") + done <- result{output: output, err: err} + }() + + select { + case got := <-done: + if got.err != nil { + t.Fatalf("unrelated channel failed while another channel stalled: %v", got.err) + } + if string(got.output) != "fast\n" { + t.Fatalf("unrelated channel output = %q, want %q", got.output, "fast\n") + } + case <-time.After(5 * time.Second): + t.Fatal("a channel with an exhausted remote window blocked an unrelated channel") + } +} + +// TestMuxServerPrepareHandoffRequiresIdleMaster verifies that a ControlPersist +// handoff is refused while another session or forward depends on the master, or +// after -O stop committed it to shutdown. Releasing it would cut those users off +// or resurrect a stopped master. +func TestMuxServerPrepareHandoffRequiresIdleMaster(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second) + defer cancel() + + newServer := func(t *testing.T) *MuxServer { + t.Helper() + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: t.TempDir() + "/handoff.sock", + SSHClient: newDummySSHClient(), + PersistEnabled: true, + PersistIndefinite: true, + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + go func() { _ = server.Serve() }() + t.Cleanup(func() { _ = server.Shutdown() }) + return server + } + + t.Run("idle master is reserved", func(t *testing.T) { + server := newServer(t) + if !server.PrepareHandoff() { + t.Fatal("expected an idle master to be reserved for handoff") + } + }) + + t.Run("active session blocks handoff", func(t *testing.T) { + server := newServer(t) + proxyConn, err := connectTestProxyClient(server.ControlPath()) + if err != nil { + t.Fatalf("connectTestProxyClient failed: %v", err) + } + waitForCondition(t, 5*time.Second, func() bool { + return server.ActiveSessions() == 1 + }, "proxy session was not counted") + if server.PrepareHandoff() { + _ = proxyConn.Close() + t.Fatal("expected handoff to be refused while a session uses the master") + } + _ = proxyConn.Close() + waitForCondition(t, 5*time.Second, func() bool { + return server.ActiveSessions() == 0 + }, "proxy session was not released") + }) + + t.Run("owned forward blocks handoff", func(t *testing.T) { + server := newServer(t) + client, err := DialMuxClient(ctx, server.ControlPath()) + if err != nil { + t.Fatalf("DialMuxClient failed: %v", err) + } + defer func() { _ = client.Close() }() + + forward := ForwardRequest{Type: ForwardLocal, ListenHost: "127.0.0.1", ConnectHost: "127.0.0.1", ConnectPort: 22} + if _, err := client.OpenForward(ctx, forward); err != nil { + t.Fatalf("OpenForward failed: %v", err) + } + if server.PrepareHandoff() { + t.Fatal("expected handoff to be refused while a forward belongs to the master") + } + if err := client.CloseForward(ctx, forward); err != nil { + t.Fatalf("CloseForward failed: %v", err) + } + if !server.PrepareHandoff() { + t.Fatal("expected handoff to be allowed after the forward was cancelled") + } + }) + + t.Run("stopped master blocks handoff", func(t *testing.T) { + server := newServer(t) + client, err := DialMuxClient(ctx, server.ControlPath()) + if err != nil { + t.Fatalf("DialMuxClient failed: %v", err) + } + defer func() { _ = client.Close() }() + if err := client.StopListening(ctx); err != nil { + t.Fatalf("StopListening failed: %v", err) + } + if server.PrepareHandoff() { + t.Fatal("expected handoff to be refused after -O stop") + } + }) +} + +func TestMuxServerWaitForDrainAndCancelHandoff(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second) + defer cancel() + + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: t.TempDir() + "/drain.sock", + SSHClient: newDummySSHClient(), + PersistEnabled: true, + PersistIndefinite: true, + InitialActiveSessions: 1, + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + go func() { _ = server.Serve() }() + t.Cleanup(func() { _ = server.Shutdown() }) + + proxyConn, err := connectTestProxyClient(server.ControlPath()) + if err != nil { + t.Fatalf("connectTestProxyClient failed: %v", err) + } + waitForCondition(t, 5*time.Second, func() bool { + return server.ActiveSessions() == 2 + }, "proxy session was not counted") + + drained := make(chan error, 1) + go func() { + drained <- server.WaitForDrain(ctx, 1) + }() + + select { + case err := <-drained: + t.Fatalf("WaitForDrain returned while secondary session was still active: %v", err) + case <-time.After(80 * time.Millisecond): + } + + _ = proxyConn.Close() + select { + case err := <-drained: + if err != nil { + t.Fatalf("WaitForDrain failed after secondary session closed: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("WaitForDrain did not unblock after secondary session closed") + } + + server.RemoveForegroundSession() + if !server.PrepareHandoff() { + t.Fatal("expected PrepareHandoff to succeed after foreground session was released") + } + server.CancelHandoff() + if !server.PrepareHandoff() { + t.Fatal("expected PrepareHandoff to succeed again after CancelHandoff") + } +} + +// TestMuxServerDynamicRemoteForwardCancel verifies that remote forwards opened +// with ListenPort == 0 do not collide with each other and can be cancelled by +// the allocated port or by 0. +func TestMuxServerDynamicRemoteForwardCancel(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second) + defer cancel() + + fixture := startMuxFixture(t, ctx, sshfixture.Options{}) + client, err := DialMuxClient(ctx, fixture.SocketPath) + if err != nil { + t.Fatalf("DialMuxClient failed: %v", err) + } + defer func() { _ = client.Close() }() + + req1 := ForwardRequest{ + Type: ForwardRemote, + ListenHost: "127.0.0.1", + ListenPort: 0, + ConnectHost: "127.0.0.1", + ConnectPort: 8080, + } + port1, err := client.OpenForward(ctx, req1) + if err != nil || port1 == 0 { + t.Fatalf("first dynamic OpenForward failed: port=%d, err=%v", port1, err) + } + + req2 := ForwardRequest{ + Type: ForwardRemote, + ListenHost: "127.0.0.1", + ListenPort: 0, + ConnectHost: "127.0.0.1", + ConnectPort: 8081, + } + port2, err := client.OpenForward(ctx, req2) + if err != nil || port2 == 0 { + t.Fatalf("second dynamic OpenForward failed: port=%d, err=%v", port2, err) + } + + // Cancel first forward by allocated port + cancelByAllocated := req1 + cancelByAllocated.ListenPort = port1 + if err := client.CloseForward(ctx, cancelByAllocated); err != nil { + t.Fatalf("CloseForward by allocated port %d failed: %v", port1, err) + } + + // Cancel second forward by port 0 + if err := client.CloseForward(ctx, req2); err != nil { + t.Fatalf("CloseForward by port 0 failed: %v", err) + } +} + +// TestProxyChannel_RemoteEarlyEOFDoesNotTruncateStdin verifies that when a +// remote command closes its stdout early (sending SSH_MSG_CHANNEL_EOF) but +// continues reading client stdin for > 1.5s before exiting, the proxy channel +// does not prematurely send SSH_MSG_CHANNEL_CLOSE and truncate client stdin. +func TestProxyChannel_RemoteEarlyEOFDoesNotTruncateStdin(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second) + defer cancel() + + receivedCh := make(chan string, 1) + fixture := startMuxFixture(t, ctx, sshfixture.Options{ + ExecHandler: func(command string, channel cryptoSSH.Channel) { + if command != "early-stdout-eof" { + return + } + // Close remote stdout immediately (sends SSH_MSG_CHANNEL_EOF to master). + _ = channel.CloseWrite() + data, _ := io.ReadAll(channel) + receivedCh <- string(data) + _, _ = channel.SendRequest("exit-status", false, cryptoSSH.Marshal(struct{ Status uint32 }{0})) + }, + }) + + client, proxyConn, err := DialControlProxy(ctx, fixture.SocketPath, fixture.Target) + if err != nil { + t.Fatalf("DialControlProxy failed: %v", err) + } + defer func() { + _ = client.Close() + _ = proxyConn.Close() + }() + + session, err := client.NewSession() + if err != nil { + t.Fatalf("NewSession failed: %v", err) + } + defer func() { _ = session.Close() }() + + stdin, err := session.StdinPipe() + if err != nil { + t.Fatalf("StdinPipe failed: %v", err) + } + if err := session.Start("early-stdout-eof"); err != nil { + t.Fatalf("Start failed: %v", err) + } + + if _, err := io.WriteString(stdin, "part1-"); err != nil { + t.Fatalf("write part1 failed: %v", err) + } + // Sleep longer than the old 1s timeout after remote stdout EOF. + time.Sleep(1500 * time.Millisecond) + if _, err := io.WriteString(stdin, "part2"); err != nil { + t.Fatalf("write part2 after 1.5s failed (channel closed early): %v", err) + } + if err := stdin.Close(); err != nil { + t.Fatalf("stdin.Close failed: %v", err) + } + + if err := session.Wait(); err != nil { + t.Fatalf("session.Wait failed: %v", err) + } + + select { + case got := <-receivedCh: + if got != "part1-part2" { + t.Fatalf("remote command received %q, want %q", got, "part1-part2") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for remote command to finish reading stdin") + } +} + +// TestMuxServer_PrepareHandoffAndWaitForDrain_RespectActiveForwardStreams verifies +// that after -O cancel removes a port-forward listener (forwardCount == 0), an +// already-established forwarded TCP stream (activeForwardStreams > 0) still blocks +// PrepareHandoff and WaitForDrain until the stream finishes. +func TestMuxServer_PrepareHandoffAndWaitForDrain_RespectActiveForwardStreams(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + echoLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen echo failed: %v", err) + } + defer func() { _ = echoLn.Close() }() + _, echoPortStr, _ := net.SplitHostPort(echoLn.Addr().String()) + echoPort, _ := strconv.Atoi(echoPortStr) + + go func() { + for { + conn, err := echoLn.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer func() { _ = c.Close() }() + _, _ = io.Copy(c, c) + }(conn) + } + }() + + fixture := startMuxFixture(t, ctx, sshfixture.Options{}) + + muxCli, err := DialMuxClient(ctx, fixture.SocketPath) + if err != nil { + t.Fatalf("DialMuxClient failed: %v", err) + } + defer func() { _ = muxCli.Close() }() + + // Bind an ephemeral local port first to know the exact port for OpenForward/CloseForward. + probeLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("probe listen failed: %v", err) + } + _, fwdPortStr, _ := net.SplitHostPort(probeLn.Addr().String()) + fwdPort, _ := strconv.Atoi(fwdPortStr) + _ = probeLn.Close() + + spec := ForwardRequest{ + Type: ForwardLocal, + ListenHost: "127.0.0.1", + ListenPort: uint32(fwdPort), + ConnectHost: "127.0.0.1", + ConnectPort: uint32(echoPort), + } + + if _, err := muxCli.OpenForward(ctx, spec); err != nil { + t.Fatalf("OpenForward failed: %v", err) + } + + // Open an active TCP stream through the local forward. + fwdConn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", fwdPort)) + if err != nil { + t.Fatalf("dial local forward failed: %v", err) + } + defer func() { _ = fwdConn.Close() }() + + if _, err := fwdConn.Write([]byte("ping")); err != nil { + t.Fatalf("write to forward failed: %v", err) + } + buf := make([]byte, 4) + if _, err := io.ReadFull(fwdConn, buf); err != nil || string(buf) != "ping" { + t.Fatalf("read from forward failed: got %q, err %v", string(buf), err) + } + + // Cancel the forward listener (-O cancel) while fwdConn is still open. + if err := muxCli.CloseForward(ctx, spec); err != nil { + t.Fatalf("CloseForward failed: %v", err) + } + + // Even though forwardCount() is now 0, activeForwardStreams is still 1. + if fixture.Master.PrepareHandoff() { + t.Fatal("expected PrepareHandoff() to return false while forwarded TCP stream is still active") + } + + drainCtx, drainCancel := context.WithTimeout(ctx, 150*time.Millisecond) + defer drainCancel() + if err := fixture.Master.WaitForDrain(drainCtx, 0); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected WaitForDrain to block while forwarded stream is active, got %v", err) + } + + // Close the active stream; WaitForDrain and PrepareHandoff should now succeed. + _ = fwdConn.Close() + if err := fixture.Master.WaitForDrain(ctx, 0); err != nil { + t.Fatalf("expected WaitForDrain to succeed after forwarded stream closed, got %v", err) + } + if !fixture.Master.PrepareHandoff() { + t.Fatal("expected PrepareHandoff() to succeed after forwarded stream closed") + } +} + +// TestDialControlProxy_FailsClosedOnOversizedPacket verifies that +// muxHandshakeInspectorConn fails closed when a control socket peer sends an +// oversized packet length header (> MaxMuxPacketLength). +func TestDialControlProxy_FailsClosedOnOversizedPacket(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + sockPath := filepath.Join(t.TempDir(), "oversized.sock") + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("Listen unix failed: %v", err) + } + defer func() { _ = ln.Close() }() + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer func() { _ = conn.Close() }() + var hdr [8]byte + binary.BigEndian.PutUint32(hdr[0:4], MaxMuxPacketLength+100) + binary.BigEndian.PutUint32(hdr[4:8], MUX_MSG_HELLO) + _, _ = conn.Write(hdr[:]) + _, _ = io.Copy(io.Discard, conn) + }() + + target := TargetContext{Host: "10.0.0.1", Port: 22, User: "alice"} + _, _, dialErr := DialControlProxy(ctx, sockPath, target) + if dialErr == nil || !strings.Contains(dialErr.Error(), "invalid mux packet length") { + t.Fatalf("expected invalid mux packet length error, got %v", dialErr) + } +} diff --git a/core/ssh/mux_proxy_test.go b/core/ssh/mux_proxy_test.go new file mode 100644 index 00000000..e8cff585 --- /dev/null +++ b/core/ssh/mux_proxy_test.go @@ -0,0 +1,793 @@ +package ssh + +import ( + "bytes" + "context" + "errors" + "io" + "net" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "testing" + "time" + + cryptoSSH "golang.org/x/crypto/ssh" + + "github.com/wentf9/xops-cli/core/testutil/sshfixture" +) + +// muxFixture is one in-memory SSH server plus a master transport attached to a +// proxy master listening on a temporary control socket. +type muxFixture struct { + Server *sshfixture.Server + Master *MuxServer + SocketPath string + Host string + Port int + Target TargetContext + RawClient *cryptoSSH.Client + FixtureHost string +} + +func startMuxFixture(t *testing.T, ctx context.Context, options sshfixture.Options) *muxFixture { + t.Helper() + + fixture, err := sshfixture.NewWithOptions(ctx, options) + if err != nil { + t.Fatalf("sshfixture.NewWithOptions failed: %v", err) + } + + host, portText, err := net.SplitHostPort(fixture.Address) + if err != nil { + _ = fixture.Close() + t.Fatalf("SplitHostPort failed: %v", err) + } + port, err := strconv.Atoi(portText) + if err != nil { + _ = fixture.Close() + t.Fatalf("Atoi failed: %v", err) + } + + rawClient, err := cryptoSSH.Dial("tcp", fixture.Address, &cryptoSSH.ClientConfig{ + User: "fixture", + Auth: []cryptoSSH.AuthMethod{cryptoSSH.Password(sshfixture.Password)}, + HostKeyCallback: cryptoSSH.FixedHostKey(fixture.HostKey), + Timeout: 5 * time.Second, + }) + if err != nil { + _ = fixture.Close() + t.Fatalf("cryptoSSH.Dial master failed: %v", err) + } + + socketPath := filepath.Join(t.TempDir(), "master.sock") + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: socketPath, + SSHClient: rawClient, + PersistEnabled: true, + PersistIndefinite: true, + }) + if err != nil { + _ = rawClient.Close() + _ = fixture.Close() + t.Fatalf("NewMuxServer failed: %v", err) + } + go func() { _ = server.Serve() }() + + muxFixture := &muxFixture{ + Server: fixture, + Master: server, + SocketPath: socketPath, + Host: host, + Port: port, + Target: TargetContext{Host: host, Port: port, User: "fixture"}, + RawClient: rawClient, + FixtureHost: host, + } + t.Cleanup(func() { + _ = server.Shutdown() + _ = rawClient.Close() + _ = fixture.Close() + }) + return muxFixture +} + +// TestMuxProxy_ForwardsStderr verifies that remote stderr reaches the proxy +// client. Dropping SSH_MSG_CHANNEL_EXTENDED_DATA silently loses all remote +// diagnostics produced through a shared connection. +func TestMuxProxy_ForwardsStderr(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + fixture := startMuxFixture(t, ctx, sshfixture.Options{ + ExecHandler: func(_ string, ch cryptoSSH.Channel) { + _, _ = io.WriteString(ch, "stdout-line\n") + _, _ = io.WriteString(ch.Stderr(), "stderr-line\n") + _, _ = ch.SendRequest("exit-status", false, cryptoSSH.Marshal(struct{ Status uint32 }{3})) + }, + }) + + client, proxyConn, err := DialControlProxy(ctx, fixture.SocketPath, fixture.Target) + if err != nil { + t.Fatalf("DialControlProxy failed: %v", err) + } + defer func() { + _ = client.Close() + _ = proxyConn.Close() + }() + + session, err := client.NewSession() + if err != nil { + t.Fatalf("NewSession failed: %v", err) + } + defer func() { _ = session.Close() }() + + var stdout, stderr bytes.Buffer + session.Stdout = &stdout + session.Stderr = &stderr + + err = session.Run("emit-both") + var exitErr *cryptoSSH.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitStatus() != 3 { + t.Fatalf("expected exit status 3, got %v", err) + } + if got := stdout.String(); got != "stdout-line\n" { + t.Fatalf("unexpected stdout %q", got) + } + if got := stderr.String(); got != "stderr-line\n" { + t.Fatalf("expected stderr line through proxy, got %q", got) + } +} + +// TestMuxProxy_LargeStderrDoesNotDeadlock verifies that stderr larger than the +// SSH channel window keeps flowing. An unconsumed stderr stream blocks the +// remote process once its window is exhausted. +func TestMuxProxy_LargeStderrDoesNotDeadlock(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + const payloadSize = 512 * 1024 + payload := strings.Repeat("e", payloadSize) + fixture := startMuxFixture(t, ctx, sshfixture.Options{ + ExecHandler: func(_ string, ch cryptoSSH.Channel) { + _, _ = io.WriteString(ch.Stderr(), payload) + _, _ = ch.SendRequest("exit-status", false, cryptoSSH.Marshal(struct{ Status uint32 }{0})) + }, + }) + + client, proxyConn, err := DialControlProxy(ctx, fixture.SocketPath, fixture.Target) + if err != nil { + t.Fatalf("DialControlProxy failed: %v", err) + } + defer func() { + _ = client.Close() + _ = proxyConn.Close() + }() + + session, err := client.NewSession() + if err != nil { + t.Fatalf("NewSession failed: %v", err) + } + defer func() { _ = session.Close() }() + + var stdout bytes.Buffer + session.Stdout = &stdout + + var stderrMu sync.Mutex + stderrBytes := 0 + session.Stderr = writerFunc(func(p []byte) (int, error) { + stderrMu.Lock() + defer stderrMu.Unlock() + stderrBytes += len(p) + return len(p), nil + }) + + runDone := make(chan error, 1) + go func() { runDone <- session.Run("flood-stderr") }() + + select { + case err := <-runDone: + if err != nil { + t.Fatalf("session.Run failed: %v", err) + } + case <-time.After(15 * time.Second): + t.Fatal("proxy session deadlocked while forwarding large stderr") + } + + stderrMu.Lock() + got := stderrBytes + stderrMu.Unlock() + if got != payloadSize { + t.Fatalf("expected %d stderr bytes through proxy, got %d", payloadSize, got) + } +} + +type writerFunc func([]byte) (int, error) + +func (f writerFunc) Write(p []byte) (int, error) { return f(p) } + +// TestMuxProxy_ConcurrentExecKeepsRequestRepliesOrdered reproduces the class of +// failure where a channel close is written while a want-reply request (exec) is +// still in flight, making the proxy client fail with io.EOF. +func TestMuxProxy_ConcurrentExecKeepsRequestRepliesOrdered(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + fixture := startMuxFixture(t, ctx, sshfixture.Options{ + ExecHandler: func(_ string, ch cryptoSSH.Channel) { + _, _ = io.WriteString(ch, "test_output\n") + _, _ = ch.SendRequest("exit-status", false, cryptoSSH.Marshal(struct{ Status uint32 }{0})) + }, + }) + + const workers, iterations = 5, 5 + var ( + waitGroup sync.WaitGroup + failMu sync.Mutex + failures []string + ) + for worker := 0; worker < workers; worker++ { + waitGroup.Add(1) + go func(workerID int) { + defer waitGroup.Done() + for iteration := 0; iteration < iterations; iteration++ { + client, proxyConn, err := DialControlProxy(ctx, fixture.SocketPath, fixture.Target) + if err != nil { + failMu.Lock() + failures = append(failures, err.Error()) + failMu.Unlock() + return + } + session, err := client.NewSession() + if err != nil { + _ = client.Close() + _ = proxyConn.Close() + failMu.Lock() + failures = append(failures, err.Error()) + failMu.Unlock() + return + } + out, err := session.Output("echo ordered") + _ = session.Close() + _ = client.Close() + _ = proxyConn.Close() + if err != nil || string(out) != "test_output\n" { + failMu.Lock() + failures = append(failures, "worker "+strconv.Itoa(workerID)+" iteration "+strconv.Itoa(iteration)) + failMu.Unlock() + } + } + }(worker) + } + waitGroup.Wait() + + if len(failures) > 0 { + t.Fatalf("proxy exec failures: %d (%s)", len(failures), strings.Join(failures, "; ")) + } +} + +// TestMuxProxy_ForwardsGlobalRequests verifies that proxy clients can use +// global requests. Remote forwarding (tcpip-forward) fails when the master +// rejects every global request locally. +func TestMuxProxy_ForwardsGlobalRequests(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + fixture := startMuxFixture(t, ctx, sshfixture.Options{}) + + client, proxyConn, err := DialControlProxy(ctx, fixture.SocketPath, fixture.Target) + if err != nil { + t.Fatalf("DialControlProxy failed: %v", err) + } + defer func() { + _ = client.Close() + _ = proxyConn.Close() + }() + + // A keepalive-style want-reply global request must be answered by the remote + // server rather than rejected locally by the master. + ok, _, err := client.SendRequest("keepalive@openssh.com", true, nil) + if err != nil { + t.Fatalf("global request through proxy failed: %v", err) + } + if !ok { + t.Fatal("expected remote server to accept the keepalive global request") + } +} + +// TestMuxServer_DynamicForwardSOCKS5 verifies that -O forward -D terminates +// SOCKS5 CONNECT requests on the master's transport instead of closing them. +func TestMuxServer_DynamicForwardSOCKS5(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + echoListener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen echo target failed: %v", err) + } + defer func() { _ = echoListener.Close() }() + go func() { + for { + conn, acceptErr := echoListener.Accept() + if acceptErr != nil { + return + } + go func(c net.Conn) { + defer func() { _ = c.Close() }() + _, _ = io.Copy(c, c) + }(conn) + } + }() + + fixture := startMuxFixture(t, ctx, sshfixture.Options{}) + + dynamicListener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen dynamic port failed: %v", err) + } + defer func() { _ = dynamicListener.Close() }() + go fixture.Master.serveDynamicForward(dynamicListener) + + socksAddr := dynamicListener.Addr().String() + conn, err := net.Dial("tcp", socksAddr) + if err != nil { + t.Fatalf("dial SOCKS5 listener failed: %v", err) + } + defer func() { _ = conn.Close() }() + + performSocks5HandshakeAndConnect(t, conn, echoListener.Addr().String()) + + payload := []byte("proxy-dynamic-forward") + if _, err := conn.Write(payload); err != nil { + t.Fatalf("write through SOCKS5 tunnel failed: %v", err) + } + got := make([]byte, len(payload)) + if err := conn.SetReadDeadline(time.Now().Add(10 * time.Second)); err != nil { + t.Fatalf("set read deadline failed: %v", err) + } + if _, err := io.ReadFull(conn, got); err != nil { + t.Fatalf("read through SOCKS5 tunnel failed: %v", err) + } + if !bytes.Equal(got, payload) { + t.Fatalf("unexpected echo payload %q", got) + } +} + +// TestMuxServer_ShutdownKeepsReplacementSocket verifies that a draining master +// does not unlink a control socket that a newer master already owns. +func TestMuxServer_ShutdownKeepsReplacementSocket(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("hard-link socket replacement is not used on Windows") + } + + directory := t.TempDir() + socketPath := filepath.Join(directory, "replace.sock") + + draining, err := NewMuxServer(MuxServerOptions{ + ControlPath: socketPath, + SSHClient: newDummySSHClient(), + PersistEnabled: true, + PersistIndefinite: true, + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + go func() { _ = draining.Serve() }() + + // The admin stops listening, which unlinks the path and lets a replacement + // master take it over. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + control, err := DialMuxClient(ctx, socketPath) + if err != nil { + t.Fatalf("DialMuxClient failed: %v", err) + } + if err := control.StopListening(ctx); err != nil { + _ = control.Close() + t.Fatalf("StopListening failed: %v", err) + } + _ = control.Close() + + replacement, err := StartMuxServer(ctx, newDummySSHClient(), MuxMasterOptions{ + Path: socketPath, + Target: TargetContext{Control: &ControlConfig{Master: ControlMasterYes, Path: socketPath}}, + }) + if err != nil { + t.Fatalf("replacement StartMuxServer failed: %v", err) + } + if got := GetActiveMuxServer(socketPath); got != replacement { + t.Fatalf("replacement master was not registered, got %v", got) + } + defer func() { _ = replacement.Shutdown() }() + + if got := GetActiveMuxServer(socketPath); got != replacement { + t.Fatalf("expected the replacement master to own the registry entry, got %v", got) + } + + // Draining the old master must not remove the replacement socket or entry. + if err := draining.Shutdown(); err != nil { + t.Fatalf("draining Shutdown failed: %v", err) + } + if !socketPathExists(socketPath) { + t.Fatal("draining master unlinked the replacement control socket") + } + if got := GetActiveMuxServer(socketPath); got != replacement { + t.Fatalf("draining master removed the replacement registry entry, got %v", got) + } + + control, err = DialMuxClient(ctx, socketPath) + if err != nil { + t.Fatalf("replacement master is unreachable after draining shutdown: %v", err) + } + if _, err := control.AliveCheck(ctx); err != nil { + _ = control.Close() + t.Fatalf("replacement master alive check failed: %v", err) + } + _ = control.Close() +} + +// TestMuxProxy_RemoteChannelRequestDoesNotBlockForever verifies that a remote +// want-reply channel request which the proxy client never answers is released +// when the channel ends. A pending remote request that is neither relayed nor +// rejected keeps the server blocked after the client is gone. +func TestMuxProxy_RemoteChannelRequestDoesNotBlockForever(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + type requestOutcome struct { + accepted bool + err error + } + outcome := make(chan requestOutcome, 1) + + fixture := startMuxFixture(t, ctx, sshfixture.Options{ + ExecHandler: func(_ string, ch cryptoSSH.Channel) { + // Ask the client for a reply without blocking the handler, then finish + // the session. The client (x/crypto Session) never answers, so only the + // master's teardown path can release this request. + go func() { + accepted, err := ch.SendRequest("keepalive@openssh.com", true, nil) + outcome <- requestOutcome{accepted: accepted, err: err} + }() + _, _ = io.WriteString(ch, "handler-finished\n") + _, _ = ch.SendRequest("exit-status", false, cryptoSSH.Marshal(struct{ Status uint32 }{0})) + }, + }) + + client, proxyConn, err := DialControlProxy(ctx, fixture.SocketPath, fixture.Target) + if err != nil { + t.Fatalf("DialControlProxy failed: %v", err) + } + defer func() { + _ = client.Close() + _ = proxyConn.Close() + }() + + session, err := client.NewSession() + if err != nil { + t.Fatalf("NewSession failed: %v", err) + } + out, err := session.Output("leave-request-unanswered") + _ = session.Close() + if err != nil { + t.Fatalf("session.Output failed: %v", err) + } + if string(out) != "handler-finished\n" { + t.Fatalf("unexpected output %q", out) + } + + select { + case result := <-outcome: + if result.err == nil && result.accepted { + t.Fatalf("expected the unanswered request to be rejected, got accepted") + } + case <-time.After(10 * time.Second): + t.Fatal("remote channel request was never released after the channel ended") + } +} + +// TestMuxServer_ShutdownClosesForwardedStreams verifies that Shutdown releases +// accepted forwarding connections instead of leaving idle readers running. +func TestMuxServer_ShutdownClosesForwardedStreams(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + echoListener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen echo target failed: %v", err) + } + defer func() { _ = echoListener.Close() }() + go func() { + for { + conn, acceptErr := echoListener.Accept() + if acceptErr != nil { + return + } + go func(c net.Conn) { + defer func() { _ = c.Close() }() + _, _ = io.Copy(c, c) + }(conn) + } + }() + + fixture := startMuxFixture(t, ctx, sshfixture.Options{}) + + // Start a local forwarding listener owned by the master, registered exactly as + // handleOpenFwd does so Shutdown can close it. + forwardListener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen forward port failed: %v", err) + } + t.Cleanup(func() { _ = forwardListener.Close() }) + fixture.Master.forwards.Store("mux-test-local-forward", forwardListener) + go fixture.Master.serveLocalForward(forwardListener, echoListener.Addr().String()) + + clientConn, err := net.Dial("tcp", forwardListener.Addr().String()) + if err != nil { + t.Fatalf("dial forwarding listener failed: %v", err) + } + defer func() { _ = clientConn.Close() }() + + payload := []byte("forwarded-before-shutdown") + if _, err := clientConn.Write(payload); err != nil { + t.Fatalf("write through forward failed: %v", err) + } + echoed := make([]byte, len(payload)) + if err := clientConn.SetReadDeadline(time.Now().Add(10 * time.Second)); err != nil { + t.Fatalf("set read deadline failed: %v", err) + } + if _, err := io.ReadFull(clientConn, echoed); err != nil { + t.Fatalf("read through forward failed: %v", err) + } + + // An idle forwarded stream must be released by Shutdown. + if err := fixture.Master.Shutdown(); err != nil { + t.Fatalf("Shutdown failed: %v", err) + } + if err := clientConn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { + t.Fatalf("set read deadline failed: %v", err) + } + buf := make([]byte, 1) + if _, err := clientConn.Read(buf); err == nil { + t.Fatal("expected the forwarded stream to be closed by Shutdown") + } + + if err := fixture.Master.Shutdown(); err != nil { + t.Fatalf("second Shutdown failed: %v", err) + } +} + +type failingOpenChannelSSHConn struct { + *dummySSHConn + openErr error +} + +func (f *failingOpenChannelSSHConn) OpenChannel(string, []byte) (cryptoSSH.Channel, <-chan *cryptoSSH.Request, error) { + return nil, nil, f.openErr +} + +// TestMuxProxy_OpenChannelFailurePreservesReasonAndHintsMaxSessions verifies that +// openRemoteChannel unwraps *cryptoSSH.OpenChannelError instead of double-wrapping +// "ssh: rejected: connect failed (...)" and appends a MaxSessions hint on session +// "open failed". +func TestMuxProxy_OpenChannelFailurePreservesReasonAndHintsMaxSessions(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + chans := make(chan cryptoSSH.NewChannel) + reqs := make(chan *cryptoSSH.Request) + conn := &failingOpenChannelSSHConn{ + dummySSHConn: &dummySSHConn{chans: chans, reqs: reqs}, + openErr: &cryptoSSH.OpenChannelError{ + Reason: cryptoSSH.ConnectionFailed, + Message: "open failed", + }, + } + rawClient := cryptoSSH.NewClient(conn, chans, reqs) + defer func() { _ = rawClient.Close() }() + + sockPath := filepath.Join(t.TempDir(), "maxsessions.sock") + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: sockPath, + SSHClient: rawClient, + PersistEnabled: true, + PersistIndefinite: true, + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + defer func() { _ = server.Shutdown() }() + go func() { _ = server.Serve() }() + + client, proxyConn, err := DialControlProxy(ctx, sockPath, TargetContext{Host: "127.0.0.1", Port: 22, User: "test"}) + if err != nil { + t.Fatalf("DialControlProxy failed: %v", err) + } + defer func() { + _ = client.Close() + _ = proxyConn.Close() + }() + + _, err = client.NewSession() + if err == nil { + t.Fatal("expected NewSession error, got nil") + } + errMsg := err.Error() + if strings.Count(errMsg, "ssh: rejected:") > 1 { + t.Fatalf("expected single ssh: rejected prefix, got double-wrapped error: %q", errMsg) + } + if !strings.Contains(errMsg, "MaxSessions limit may be reached") { + t.Fatalf("expected MaxSessions diagnostic hint in error, got: %q", errMsg) + } +} + +// TestMuxProxy_InitialPacketAndPingPong verifies that entering MUX_C_PROXY mode +// immediately emits the server's initial global request packet (so `-O proxy < /dev/null` +// receives connection-protocol output) and answers SSH_MSG_PING (192) with SSH_MSG_PONG (193). +func TestMuxProxy_InitialPacketAndPingPong(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + fixture := startMuxFixture(t, ctx, sshfixture.Options{}) + + stream, err := DialMuxProxyStream(ctx, fixture.SocketPath) + if err != nil { + t.Fatalf("DialMuxProxyStream failed: %v", err) + } + defer func() { _ = stream.Close() }() + + // 1. Read the initial packet sent by the master upon entering proxy mode + if err := stream.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatalf("SetReadDeadline failed: %v", err) + } + initPkt, err := readUnpaddedPacket(stream) + if err != nil { + t.Fatalf("read initial proxy packet failed: %v", err) + } + if len(initPkt) == 0 || initPkt[0] != msgGlobalRequest { + t.Fatalf("expected initial packet to be msgGlobalRequest (%d), got %v", msgGlobalRequest, initPkt) + } + + // 2. Send SSH_MSG_PING (192) and verify SSH_MSG_PONG (193) reply + pingPayload := []byte{msgPing, 0, 0, 0, 4, 'p', 'i', 'n', 'g'} + if err := writeUnpaddedPacket(stream, pingPayload); err != nil { + t.Fatalf("write ping packet failed: %v", err) + } + pongPkt, err := readUnpaddedPacket(stream) + if err != nil { + t.Fatalf("read pong packet failed: %v", err) + } + expectedPong := []byte{msgPong, 0, 0, 0, 4, 'p', 'i', 'n', 'g'} + if !bytes.Equal(pongPkt, expectedPong) { + t.Fatalf("expected pong %v, got %v", expectedPong, pongPkt) + } +} + +// TestConnector_MultiplexTargetMismatchAndAskRejectionFailClosed verifies that: +// 1. Reusing a static ControlPath pointing to a different target host fails closed +// with ErrControlTargetMismatch instead of connecting to the wrong host. +// 2. When an ask/autoask master rejects a session confirmation, Connector fails +// closed with ErrControlProxyRejected instead of silently falling back to a +// direct connection. +func TestConnector_MultiplexTargetMismatchAndAskRejectionFailClosed(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + fixture := startMuxFixture(t, ctx, sshfixture.Options{}) + + // Advertise target identity and ask-mode rejection on the fixture's master + // before any client connects to the control socket. + fixture.Master.target = TargetContext{ + Host: "host-a.example.com", + Port: fixture.Port, + User: "fixture", + } + fixture.Master.askConfirmation = true + fixture.Master.confirmSession = func(string) bool { return false } + + provider := &staticMuxTestProvider{ + configs: map[string]*ClientConfig{ + "node-b": { + NodeID: "node-b", + Address: "host-b.example.com", + Port: fixture.Port, + User: "fixture", + AuthType: "password", + Password: sshfixture.Password, + }, + "node-a": { + NodeID: "node-a", + Address: "host-a.example.com", + Port: fixture.Port, + User: "fixture", + AuthType: "password", + Password: sshfixture.Password, + }, + }, + controls: map[string]*ControlConfig{ + "node-b": { + Master: ControlMasterAuto, + Path: fixture.SocketPath, + }, + "node-a": { + Master: ControlMasterAuto, + Path: fixture.SocketPath, + }, + }, + } + + connector := NewConnector(provider, WithControlProvider(provider)) + defer func() { _ = connector.CloseAll() }() + + // 1. Connecting to node-b via a socket bound to host-a must fail closed with ErrControlTargetMismatch + _, err := connector.Connect(ctx, "node-b") + if !errors.Is(err, ErrControlTargetMismatch) { + t.Fatalf("expected ErrControlTargetMismatch when reusing static socket for wrong host, got: %v", err) + } + + // 2. Verify node-a (matching target) fails closed with ErrControlProxyRejected when ask master denies confirmation + _, err = connector.Connect(ctx, "node-a") + if !errors.Is(err, ErrControlProxyRejected) { + t.Fatalf("expected ErrControlProxyRejected when ask master denies confirmation, got: %v", err) + } +} + +type staticMuxTestProvider struct { + configs map[string]*ClientConfig + controls map[string]*ControlConfig +} + +func (p *staticMuxTestProvider) GetConfig(nodeID string) (*ClientConfig, error) { + if cfg, ok := p.configs[nodeID]; ok { + cloned := *cfg + return &cloned, nil + } + return nil, errors.New("node not found") +} + +func (p *staticMuxTestProvider) GetControlConfig(nodeID string) (*ControlConfig, error) { + if ctrl, ok := p.controls[nodeID]; ok { + return ctrl.Clone(), nil + } + return nil, nil +} + +// TestDialControlProxyClient_ExecutesSessionAndValidatesTarget verifies that +// DialControlProxyClient wraps a MUX_C_PROXY connection in *Client, executes +// remote commands without direct TCP fallback, and rejects target mismatches. +func TestDialControlProxyClient_ExecutesSessionAndValidatesTarget(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + fixture := startMuxFixture(t, ctx, sshfixture.Options{ + ExecHandler: func(cmd string, ch cryptoSSH.Channel) { + _, _ = io.WriteString(ch, "proxy-client-out:"+cmd+"\n") + _, _ = ch.SendRequest("exit-status", false, cryptoSSH.Marshal(struct{ Status uint32 }{0})) + }, + }) + fixture.Master.target = fixture.Target + + // 1. Target mismatch fails closed with ErrControlTargetMismatch + wrongTarget := TargetContext{Host: "other.example.com", Port: fixture.Port, User: "fixture"} + _, err := DialControlProxyClient(ctx, fixture.SocketPath, wrongTarget, Environment{}, nil, nil) + if !errors.Is(err, ErrControlTargetMismatch) { + t.Fatalf("expected ErrControlTargetMismatch, got: %v", err) + } + + // 2. Empty target (e.g. -O proxy -S -- cmd) succeeds and runs command + cliNoTarget, err := DialControlProxyClient(ctx, fixture.SocketPath, TargetContext{}, Environment{}, nil, nil) + if err != nil { + t.Fatalf("DialControlProxyClient with empty target failed: %v", err) + } + defer func() { _ = cliNoTarget.Close() }() + + out, err := cliNoTarget.Run(ctx, "echo ok", WithInterpreter(InterpreterServer), WithLaunchDialect(LaunchUnknown)) + if err != nil { + t.Fatalf("cliNoTarget.Run failed: %v", err) + } + if strings.TrimSpace(out) != "proxy-client-out:echo ok" { + t.Fatalf("unexpected output: %q", out) + } +} diff --git a/core/ssh/mux_rights_other.go b/core/ssh/mux_rights_other.go new file mode 100644 index 00000000..423202bc --- /dev/null +++ b/core/ssh/mux_rights_other.go @@ -0,0 +1,50 @@ +//go:build !(linux || darwin || freebsd) + +package ssh + +import ( + "errors" + "fmt" + "net" + "os" + "path/filepath" + "strings" +) + +var errPassengerUnsupported = errors.New("passenger mode with fd passing is not supported on Windows; use MUX_C_PROXY") + +func bindAndLinkSocket(targetPath string) (net.Listener, error) { + if err := os.MkdirAll(filepath.Dir(targetPath), 0700); err != nil { + return nil, fmt.Errorf("create socket directory failed: %w", err) + } + + // Windows AF_UNIX does not support hard-linking socket reparse points. + // Bind targetPath directly. + listener, err := net.Listen("unix", targetPath) + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "address already in use") || strings.Contains(strings.ToLower(err.Error()), "access is denied") { + return nil, fmt.Errorf("%w: %s", ErrMasterAlreadyExists, targetPath) + } + return nil, err + } + return listener, nil +} + +func checkPeerUID(conn net.Conn, expectedUID uint32) error { + // On Windows, AF_UNIX sockets are protected by NTFS DACLs / security descriptors. + // SO_PEERCRED is not available in Winsock. + return nil +} + +// localPeerUID reports zero because this platform exposes no peer credentials. +func localPeerUID() uint32 { + return 0 +} + +func handlePassengerSession(s *MuxServer, conn net.Conn, reqID uint32, payload []byte) error { + return sendFailure(conn, reqID, errPassengerUnsupported.Error()) +} + +func handlePassengerStdioFwd(s *MuxServer, conn net.Conn, reqID uint32, payload []byte) error { + return sendFailure(conn, reqID, errPassengerUnsupported.Error()) +} diff --git a/core/ssh/mux_rights_unix.go b/core/ssh/mux_rights_unix.go new file mode 100644 index 00000000..676f410a --- /dev/null +++ b/core/ssh/mux_rights_unix.go @@ -0,0 +1,706 @@ +//go:build linux || darwin || freebsd + +package ssh + +import ( + "crypto/rand" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + cryptoSSH "golang.org/x/crypto/ssh" + "golang.org/x/sys/unix" +) + +var muxTempSeq atomic.Uint64 + +func nextMuxTempToken() uint32 { + var b [4]byte + if _, err := rand.Read(b[:]); err == nil { + return binary.BigEndian.Uint32(b[:]) & 0xffff + } + seq := muxTempSeq.Add(1) + return (uint32(os.Getpid())&0xff)<<8 | uint32(seq&0xff) +} + +func disableUnixListenerUnlinkOnClose(listener net.Listener) { + if unixLn, ok := listener.(*net.UnixListener); ok { + unixLn.SetUnlinkOnClose(false) + } +} + +func isAddressAlreadyInUse(err error) bool { + if err == nil { + return false + } + var sysErr syscall.Errno + if errors.As(err, &sysErr) && (sysErr == syscall.EADDRINUSE || sysErr == syscall.EEXIST) { + return true + } + errStr := strings.ToLower(err.Error()) + return strings.Contains(errStr, "address already in use") || strings.Contains(errStr, "file exists") +} + +func bindAndLinkSocket(targetPath string) (net.Listener, error) { + if err := os.MkdirAll(filepath.Dir(targetPath), 0700); err != nil { + return nil, fmt.Errorf("create socket directory failed: %w", err) + } + + maxPathLen := MaxControlPathLength() - 1 // 107 on Linux, 103 on Darwin/BSD + targetLen := len(targetPath) + suffixBudget := maxPathLen - targetLen + dir := filepath.Dir(targetPath) + dirBudget := maxPathLen - (len(dir) + 1) + + useSuffixTemp := suffixBudget >= 8 + useDirTemp := !useSuffixTemp && dirBudget >= 8 + + if !useSuffixTemp && !useDirTemp { + // Fallback: Direct bind on targetPath when path length budget is exhausted. + listener, err := net.Listen("unix", targetPath) + if err != nil { + if isAddressAlreadyInUse(err) { + return nil, fmt.Errorf("%w: %s", ErrMasterAlreadyExists, targetPath) + } + return nil, fmt.Errorf("listen on control socket failed: %w", err) + } + disableUnixListenerUnlinkOnClose(listener) + if err := os.Chmod(targetPath, 0600); err != nil { + _ = listener.Close() + _ = os.Remove(targetPath) + return nil, fmt.Errorf("chmod control socket failed: %w", err) + } + return listener, nil + } + + // Linking Strategy: Bind tempPath -> Chmod 0600 -> Atomic Link to targetPath -> Unlink tempPath. + var ( + listener net.Listener + tempPath string + err error + ) + for attempt := 0; attempt < 4; attempt++ { + val := nextMuxTempToken() + if useSuffixTemp { + tempPath = fmt.Sprintf("%s.m%04x.t", targetPath, val) + } else { + tempPath = filepath.Join(dir, fmt.Sprintf(".m%04x.t", val)) + } + listener, err = net.Listen("unix", tempPath) + if err == nil { + break + } + if !isAddressAlreadyInUse(err) { + return nil, fmt.Errorf("listen on temp control socket failed: %w", err) + } + } + if err != nil { + return nil, fmt.Errorf("listen on temp control socket failed: %w", err) + } + disableUnixListenerUnlinkOnClose(listener) + defer func() { + _ = os.Remove(tempPath) + }() + + // Enforce 0600 permissions without mutating process-global umask. + if err := os.Chmod(tempPath, 0600); err != nil { + _ = listener.Close() + return nil, fmt.Errorf("chmod temp control socket failed: %w", err) + } + + err = os.Link(tempPath, targetPath) + if err != nil { + if errors.Is(err, os.ErrExist) || errors.Is(err, syscall.EEXIST) { + if retryReplaceStaleSocketLink(tempPath, targetPath) { + return listener, nil + } + _ = listener.Close() + return nil, fmt.Errorf("%w: %s", ErrMasterAlreadyExists, targetPath) + } + _ = listener.Close() + return nil, fmt.Errorf("link control socket failed: %w", err) + } + + return listener, nil +} + +func retryReplaceStaleSocketLink(tempPath, targetPath string) bool { + origFi, statErr := os.Lstat(targetPath) + if statErr != nil || !isControlSocketFileInfo(origFi) { + return false + } + probeConn, probeErr := net.DialTimeout("unix", targetPath, 200*time.Millisecond) + if probeErr == nil { + _ = probeConn.Close() + return false + } + if !isConnectionRefused(probeErr) { + return false + } + // Re-verify SameFile before unlinking so a concurrent new master is never clobbered. + currFi, currErr := os.Lstat(targetPath) + if currErr != nil || !isControlSocketFileInfo(currFi) || !os.SameFile(origFi, currFi) { + return false + } + _ = os.Remove(targetPath) + return os.Link(tempPath, targetPath) == nil +} + +func checkPeerUID(conn net.Conn, expectedUID uint32) error { + sc, ok := conn.(syscall.Conn) + if !ok { + return fmt.Errorf("connection does not support raw socket control") + } + rawConn, err := sc.SyscallConn() + if err != nil { + return err + } + + var peerUID uint32 + var credErr error + err = rawConn.Control(func(fd uintptr) { + peerUID, credErr = getPeerUID(int(fd)) + }) + if err != nil { + return err + } + if credErr != nil { + return credErr + } + + if peerUID != expectedUID && peerUID != 0 { + return fmt.Errorf("%w: peer UID %d does not match expected UID %d", ErrUnauthorizedPeer, peerUID, expectedUID) + } + return nil +} + +// localPeerUID reports the effective local user used to validate a control +// socket endpoint. +func localPeerUID() uint32 { + euid := os.Geteuid() + if euid < 0 { + return 0 + } + return uint32(euid) +} + +func handlePassengerSession(s *MuxServer, conn net.Conn, reqID uint32, payload []byte) error { + if err := s.authorize("passenger session"); err != nil { + return sendPermissionDenied(conn, reqID, err.Error()) + } + unixConn, ok := conn.(*net.UnixConn) + if !ok { + return sendFailure(conn, reqID, "passenger mode requires unix domain socket") + } + + files, err := recvFiles(unixConn, "passenger-session", "stdin", "stdout", "stderr") + if err != nil { + return sendFailure(conn, reqID, fmt.Sprintf("receive session file descriptors failed: %v", err)) + } + // Exactly one owner: the received descriptors belong to these files, so they + // must never be closed again as raw descriptors. A second unix.Close on the + // same number can hit an unrelated descriptor a concurrent goroutine reused. + defer closeFiles(files) + + req, err := DecodeMuxNewSession(payload) + if err != nil { + return sendFailure(conn, reqID, fmt.Sprintf("malformed new session request: %v", err)) + } + + if !s.addActiveSession() { + return sendFailure(conn, reqID, "mux master is shutting down") + } + defer s.removeActiveSession() + + s.trackStream(conn) + defer s.untrackStream(conn) + + ch, reqs, err := s.sshClient.OpenChannel("session", nil) + if err != nil { + return sendFailure(conn, reqID, fmt.Sprintf("open ssh session failed: %v", err)) + } + defer func() { _ = ch.Close() }() + + for _, env := range req.Env { + if k, v, ok := strings.Cut(env, "="); ok && k != "" { + _, _ = ch.SendRequest("env", false, cryptoSSH.Marshal(struct { + Name string + Value string + }{Name: k, Value: v})) + } + } + + ttyAllocFailed := false + if req.WantTTY { + termType := req.TermType + if termType == "" { + termType = "xterm" + } + ptyReq := struct { + Term string + Columns uint32 + Rows uint32 + Width uint32 + Height uint32 + Modelist string + }{ + Term: termType, + Columns: 80, + Rows: 24, + Width: 640, + Height: 480, + Modelist: "\x00", + } + if accepted, err := ch.SendRequest("pty-req", true, cryptoSSH.Marshal(ptyReq)); err != nil || !accepted { + ttyAllocFailed = true + } + } + + if err := startPassengerChannel(ch, req); err != nil { + return sendFailure(conn, reqID, err.Error()) + } + + const sessionID uint32 = 1 + if err := sendSessionOpened(conn, reqID, sessionID); err != nil { + return err + } + if ttyAllocFailed { + if err := sendTTYAllocFail(conn, sessionID); err != nil { + return err + } + } + + exitCode := runPassengerChannelIO(conn, ch, reqs, files) + return sendExitMessage(conn, sessionID, exitCode) +} + +func startPassengerChannel(ch cryptoSSH.Channel, req MuxNewSessionRequest) error { + switch { + case req.Subsystem: + payload := cryptoSSH.Marshal(struct{ Subsystem string }{Subsystem: req.Command}) + accepted, err := ch.SendRequest("subsystem", true, payload) + if err != nil { + return fmt.Errorf("subsystem failed: %w", err) + } + if !accepted { + return fmt.Errorf("subsystem %q request rejected", req.Command) + } + case req.Command == "": + accepted, err := ch.SendRequest("shell", true, nil) + if err != nil { + return fmt.Errorf("shell failed: %w", err) + } + if !accepted { + return errors.New("shell request rejected") + } + default: + payload := cryptoSSH.Marshal(struct{ Command string }{Command: req.Command}) + accepted, err := ch.SendRequest("exec", true, payload) + if err != nil { + return fmt.Errorf("start command failed: %w", err) + } + if !accepted { + return errors.New("exec request rejected") + } + } + return nil +} + +// copyFromPollableFD copies bytes from a received passenger descriptor (which +// may be a blocking TTY or pipe without O_NONBLOCK) into dst until EOF or until +// stop is closed. Using unix.Poll with a private wakeup pipe avoids both: +// 1. mutating O_NONBLOCK on a shared open file description (which would corrupt +// the caller's parent shell TTY), and +// 2. blocking inside a raw read() syscall that os.File.Close() cannot interrupt. +func copyFromPollableFD(dst io.Writer, src *os.File, stop <-chan struct{}) error { + rawConn, err := src.SyscallConn() + if err != nil { + _, copyErr := io.Copy(dst, src) + return copyErr + } + + var wakePipe [2]int + if err := unix.Pipe(wakePipe[:]); err != nil { + _, copyErr := io.Copy(dst, src) + return copyErr + } + unix.CloseOnExec(wakePipe[0]) + unix.CloseOnExec(wakePipe[1]) + _ = unix.SetNonblock(wakePipe[0], true) + _ = unix.SetNonblock(wakePipe[1], true) + + var closeWakeWrite sync.Once + triggerWake := func() { + closeWakeWrite.Do(func() { + _ = unix.Close(wakePipe[1]) + }) + } + done := make(chan struct{}) + go func() { + select { + case <-stop: + triggerWake() + case <-done: + } + }() + defer func() { + close(done) + triggerWake() + _ = unix.Close(wakePipe[0]) + }() + + buf := make([]byte, 32*1024) + var copyErr error + ctrlErr := rawConn.Control(func(fd uintptr) { + srcFD := int(fd) + for { + pfds := []unix.PollFd{ + {Fd: int32(srcFD), Events: unix.POLLIN}, + {Fd: int32(wakePipe[0]), Events: unix.POLLIN}, + } + _, pollErr := unix.Poll(pfds, -1) + if pollErr != nil { + if errors.Is(pollErr, syscall.EINTR) { + continue + } + copyErr = pollErr + return + } + if pfds[1].Revents != 0 { + return + } + if pfds[0].Revents&(unix.POLLIN|unix.POLLHUP|unix.POLLERR|unix.POLLNVAL) == 0 { + continue + } + n, readErr := unix.Read(srcFD, buf) + if n > 0 { + if _, writeErr := dst.Write(buf[:n]); writeErr != nil { + copyErr = writeErr + return + } + } + if readErr != nil { + if errors.Is(readErr, syscall.EINTR) || errors.Is(readErr, syscall.EAGAIN) { + continue + } + copyErr = readErr + return + } + if n == 0 { + return + } + } + }) + if copyErr != nil { + return copyErr + } + return ctrlErr +} + +func runPassengerChannelIO(conn net.Conn, ch cryptoSSH.Channel, reqs <-chan *cryptoSSH.Request, files []*os.File) uint32 { + stdinFile, stdoutFile, stderrFile := files[0], files[1], files[2] + + var ( + exitCode atomic.Uint32 + hasExit atomic.Bool + ) + exitCode.Store(255) + + reqsDone := make(chan struct{}) + go func() { + defer close(reqsDone) + for r := range reqs { + recognized := false + switch r.Type { + case "exit-status": + var status struct{ Status uint32 } + if err := cryptoSSH.Unmarshal(r.Payload, &status); err == nil { + exitCode.Store(status.Status) + hasExit.Store(true) + recognized = true + } + case "exit-signal": + var sig struct { + Signal string + CoreDumped bool + Error string + Lang string + } + if err := cryptoSSH.Unmarshal(r.Payload, &sig); err == nil { + if !hasExit.Load() { + exitCode.Store(255) + hasExit.Store(true) + } + recognized = true + } + } + if r.WantReply { + _ = r.Reply(recognized, nil) + } + } + }() + + var outWG sync.WaitGroup + outWG.Add(2) + go func() { + defer outWG.Done() + _, _ = io.Copy(stdoutFile, ch) + }() + go func() { + defer outWG.Done() + _, _ = io.Copy(stderrFile, ch.Stderr()) + }() + + stopStdin := make(chan struct{}) + var stopStdinOnce sync.Once + cancelStdin := func() { stopStdinOnce.Do(func() { close(stopStdin) }) } + + stdinDone := make(chan struct{}) + go func() { + defer close(stdinDone) + _ = copyFromPollableFD(ch, stdinFile, stopStdin) + _ = ch.CloseWrite() + }() + + outDone := make(chan struct{}) + go func() { + outWG.Wait() + <-reqsDone + close(outDone) + }() + + // Watch the control socket while the remote channel runs: if the passenger + // client terminates early (closing conn), abort the remote channel so neither + // the remote process nor the copy workers leak. + connClosed := make(chan struct{}) + var stopConnWatch func() bool + if d, ok := conn.(interface{ SetReadDeadline(time.Time) error }); ok { + go func() { + defer close(connClosed) + var buf [1]byte + _, _ = conn.Read(buf[:]) + }() + stopConnWatch = func() bool { + _ = d.SetReadDeadline(time.Now()) + <-connClosed + _ = d.SetReadDeadline(time.Time{}) + return true + } + } else { + stopConnWatch = func() bool { return false } + } + + select { + case <-outDone: + stopConnWatch() + case <-connClosed: + cancelStdin() + _ = ch.Close() + <-stdinDone + closeFiles(files) + <-outDone + return exitCode.Load() + } + + // Wake the stdin poller and close inherited descriptors before sending + // MUX_S_EXIT_MESSAGE so the passenger client sees EOF on its stdout/stderr + // pipes and our local stdin reader unblocks immediately even when the caller + // passed a blocking TTY or pipe descriptor. + cancelStdin() + <-stdinDone + _ = stdoutFile.Close() + _ = stderrFile.Close() + _ = stdinFile.Close() + _ = ch.Close() + + if !hasExit.Load() { + return 0 + } + return exitCode.Load() +} + +func handlePassengerStdioFwd(s *MuxServer, conn net.Conn, reqID uint32, payload []byte) error { + if err := s.authorize("passenger stdio forward"); err != nil { + return sendPermissionDenied(conn, reqID, err.Error()) + } + unixConn, ok := conn.(*net.UnixConn) + if !ok { + return sendFailure(conn, reqID, "passenger stdio forward requires unix domain socket") + } + + files, err := recvFiles(unixConn, "passenger-stdio", "in", "out") + if err != nil { + return sendFailure(conn, reqID, fmt.Sprintf("receive stdio file descriptors failed: %v", err)) + } + // The received descriptors belong to these files; see handlePassengerSession. + defer closeFiles(files) + + fwdReq, err := DecodeMuxNewStdioFwd(payload) + if err != nil { + return sendFailure(conn, reqID, fmt.Sprintf("malformed stdio fwd request: %v", err)) + } + + if !s.addActiveSession() { + return sendFailure(conn, reqID, "mux master is shutting down") + } + defer s.removeActiveSession() + + s.trackStream(conn) + defer s.untrackStream(conn) + + targetAddr := net.JoinHostPort(fwdReq.ConnectHost, strconv.FormatUint(uint64(fwdReq.ConnectPort), 10)) + stream, err := s.sshClient.Dial("tcp", targetAddr) + if err != nil { + return sendFailure(conn, reqID, fmt.Sprintf("remote dial failed: %v", err)) + } + defer func() { _ = stream.Close() }() + + if err := sendSessionOpened(conn, reqID, 1); err != nil { + return err + } + + stdinFile, stdoutFile := files[0], files[1] + + stopStdin := make(chan struct{}) + stdinDone := make(chan error, 1) + stdoutDone := make(chan error, 1) + go func() { + copyErr := copyFromPollableFD(stream, stdinFile, stopStdin) + if cw, ok := stream.(interface{ CloseWrite() error }); ok { + _ = cw.CloseWrite() + } + stdinDone <- copyErr + }() + go func() { + _, copyErr := io.Copy(stdoutFile, stream) + stdoutDone <- copyErr + }() + + // Wait for remote -> stdout to finish (or stdin error if it fails), while + // allowing stdin EOF to half-close the remote stream so request/response + // protocols (like HTTP or banner probes with short stdin) still receive the + // remote server's response before tearing down both descriptors. + outErr := <-stdoutDone + close(stopStdin) + inErr := <-stdinDone + _ = stream.Close() + _ = stdinFile.Close() + _ = stdoutFile.Close() + + firstErr := outErr + if firstErr == nil && !errors.Is(inErr, os.ErrClosed) { + firstErr = inErr + } + + exitStatus := uint32(0) + if firstErr != nil && !errors.Is(firstErr, io.EOF) && !errors.Is(firstErr, os.ErrClosed) { + exitStatus = 1 + } + return sendExitMessage(conn, 1, exitStatus) +} + +// passengerFDReceiveTimeout bounds how long a master waits for a passenger +// client to hand over its session descriptors. The peer is local, so this only +// stops a stuck or malicious peer from pinning a handler goroutine forever. +const passengerFDReceiveTimeout = 5 * time.Second + +// recvFiles receives exactly count inherited descriptors and hands each one to +// an *os.File. The files own the descriptors from that point on: the caller must +// close them and must never close the raw descriptor numbers again. A second +// unix.Close on a reused number would close an unrelated descriptor owned by +// another goroutine. +func recvFiles(conn *net.UnixConn, prefix string, names ...string) ([]*os.File, error) { + fds, err := recvFDs(conn, len(names)) + if err != nil { + return nil, err + } + files := make([]*os.File, 0, len(fds)) + for index, fd := range fds { + file := os.NewFile(uintptr(fd), prefix+"-"+names[index]) + if file == nil { + _ = unix.Close(fd) + closeFDRange(fds[index+1:]) + closeFiles(files) + return nil, fmt.Errorf("wrap received descriptor %d in a file failed", fd) + } + files = append(files, file) + } + return files, nil +} + +// recvFDs receives exactly count descriptors from conn and marks each of them +// close-on-exec. OpenSSH's mm_send_fd sends one descriptor per control message, +// so a single recvmsg is not guaranteed to carry all of them; descriptors beyond +// the requested count are closed instead of being returned to the caller. +func recvFDs(conn *net.UnixConn, count int) ([]int, error) { + if count <= 0 { + return nil, nil + } + if err := conn.SetReadDeadline(time.Now().Add(passengerFDReceiveTimeout)); err != nil { + return nil, fmt.Errorf("set descriptor receive deadline failed: %w", err) + } + defer func() { _ = conn.SetReadDeadline(time.Time{}) }() + + fds := make([]int, 0, count) + fail := func(format string, args ...any) ([]int, error) { + closeFDRange(fds) + return nil, fmt.Errorf(format, args...) + } + + var data [1]byte + for len(fds) < count { + // Reserve room for the descriptors still missing: an undersized buffer + // truncates the control message and silently drops descriptors. + oob := make([]byte, unix.CmsgSpace((count-len(fds))*4)) + _, oobn, flags, _, err := conn.ReadMsgUnix(data[:], oob) + if err != nil { + return fail("receive descriptors: %w", err) + } + messages, parseCtrlErr := unix.ParseSocketControlMessage(oob[:oobn]) + for messageIndex := range messages { + received, parseErr := unix.ParseUnixRights(&messages[messageIndex]) + if parseErr != nil { + continue + } + for _, fd := range received { + unix.CloseOnExec(fd) + if flags&unix.MSG_CTRUNC != 0 || parseCtrlErr != nil || len(fds) >= count { + _ = unix.Close(fd) + continue + } + fds = append(fds, fd) + } + } + if flags&unix.MSG_CTRUNC != 0 { + return fail("control message truncated") + } + if parseCtrlErr != nil { + return fail("parse control message: %w", parseCtrlErr) + } + } + return fds, nil +} + +func closeFDRange(fds []int) { + for _, fd := range fds { + _ = unix.Close(fd) + } +} + +// closeFiles releases every owned file. It is safe to call after the handler +// already closed an individual file: os.File tracks its own closed state. +func closeFiles(files []*os.File) { + for _, file := range files { + _ = file.Close() + } +} diff --git a/core/ssh/mux_server.go b/core/ssh/mux_server.go new file mode 100644 index 00000000..d3bc2f74 --- /dev/null +++ b/core/ssh/mux_server.go @@ -0,0 +1,2285 @@ +package ssh + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "os" + "slices" + "strconv" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + cryptoSSH "golang.org/x/crypto/ssh" +) + +var ( + // ErrMasterAlreadyExists indicates a master is already listening on the socket path. + ErrMasterAlreadyExists = errors.New("mux master already exists on control path") + // ErrServerClosed indicates the multiplex server has shut down. + ErrServerClosed = errors.New("mux server closed") + // ErrUnauthorizedPeer indicates the peer UID check failed. + ErrUnauthorizedPeer = errors.New("unauthorized peer UID") +) + +// RFC 4254 Connection Protocol packet type opcodes. +const ( + msgGlobalRequest byte = 80 + msgRequestSuccess byte = 81 + msgRequestFailure byte = 82 + msgChannelOpen byte = 90 + msgChannelOpenConfirmation byte = 91 + msgChannelOpenFailure byte = 92 + msgChannelWindowAdjust byte = 93 + msgChannelData byte = 94 + msgChannelExtendedData byte = 95 + msgChannelEOF byte = 96 + msgChannelClose byte = 97 + msgChannelRequest byte = 98 + msgChannelSuccess byte = 99 + msgChannelFailure byte = 100 + msgPing byte = 192 + msgPong byte = 193 +) + +// extendedDataStderr is RFC 4254 section 5.2 SSH_EXTENDED_DATA_STDERR. +const extendedDataStderr uint32 = 1 + +const ( + // proxyAdvertisedWindow is the per-channel receive window this master offers + // a proxy client. Credit is returned only after data reached the remote + // channel, so unrelayed client input stays bounded by this window. + proxyAdvertisedWindow uint32 = 2 * 1024 * 1024 + // proxyAdvertisedMaxPacket bounds one client-to-master data chunk. + proxyAdvertisedMaxPacket uint32 = 32 * 1024 + // maxProxyQueuedInput bounds client input that was accepted but not yet + // forwarded. The window above normally caps it; this limit stops a peer that + // ignores the window from growing the relay queue without bound. + maxProxyQueuedInput = int64(2) * int64(proxyAdvertisedWindow) + // minProxyChannelPacket is the smallest maximum packet size a peer may + // advertise (4-byte length, 1-byte padding length, message type). + minProxyChannelPacket uint32 = 9 + // maxFlowWindow is the largest representable SSH channel window (2^31). + maxFlowWindow = int64(1) << 31 +) + +// MuxServerOptions configures the multiplex master server. +type MuxServerOptions struct { + ControlPath string + ExpectedUID uint32 + CheckPeerUID bool + PersistEnabled bool + PersistDuration time.Duration + PersistIndefinite bool + // ConfirmSession authorizes a proxy session, passenger session, or forwarding + // request on an ask/autoask master. Nil rejects every such request. + ConfirmSession func(action string) bool + AskConfirmation bool + SSHClient *cryptoSSH.Client + RootConn net.Conn // Optional underlying conn to close on Shutdown + InitialActiveSessions int32 // Initial active session count (e.g. 1 for foreground master) + Target TargetContext // Optional target identity advertised via target@xops hello extension +} + +// MuxServer implements the OpenSSH PROTOCOL.mux v4 server engine. +type MuxServer struct { + controlPath string + expectedUID uint32 + checkPeerUID bool + persistEnabled bool + persistDuration time.Duration + persistIndefinite bool + sshClient *cryptoSSH.Client + rootConn net.Conn + + listener net.Listener + activeSessions atomic.Int32 + activeForwardStreams atomic.Int32 + idleTimer *time.Timer + idleGen uint64 // generation counter for idle timer, protected by idleMu + shuttingDown bool // true when server committed to shutdown, protected by idleMu + idleNotify chan struct{} // closed and replaced under idleMu when session/forward/stop state changes + idleMu sync.Mutex + target TargetContext + + closed atomic.Bool + stoppedListening atomic.Bool + releaseForegroundOnStop atomic.Bool + foregroundReleasedOnStop atomic.Bool + shutdownOnce sync.Once + shutdownChan chan struct{} + origFi os.FileInfo + askConfirmation bool + confirmSession func(action string) bool + + forwards sync.Map // string -> io.Closer + // liveStreams holds every accepted connection the master is currently piping: + // proxy sockets and forwarded client connections. Shutdown closes them so + // idle readers unblock instead of outliving the master. + liveStreams sync.Map // net.Conn -> struct{} +} + +// NewMuxServer creates and binds a new OpenSSH multiplex master server. +func NewMuxServer(opts MuxServerOptions) (*MuxServer, error) { + if strings.TrimSpace(opts.ControlPath) == "" { + return nil, fmt.Errorf("control path cannot be empty") + } + if opts.SSHClient == nil { + return nil, fmt.Errorf("ssh client cannot be nil") + } + + listener, err := bindAndLinkSocket(opts.ControlPath) + if err != nil { + return nil, err + } + + origFi, _ := os.Stat(opts.ControlPath) + + server := &MuxServer{ + controlPath: opts.ControlPath, + expectedUID: opts.ExpectedUID, + checkPeerUID: opts.CheckPeerUID, + persistEnabled: opts.PersistEnabled, + persistDuration: opts.PersistDuration, + persistIndefinite: opts.PersistIndefinite, + sshClient: opts.SSHClient, + rootConn: opts.RootConn, + listener: listener, + shutdownChan: make(chan struct{}), + idleNotify: make(chan struct{}), + origFi: origFi, + askConfirmation: opts.AskConfirmation, + confirmSession: opts.ConfirmSession, + target: opts.Target, + } + + if opts.InitialActiveSessions > 0 { + server.activeSessions.Store(opts.InitialActiveSessions) + } + + if server.persistEnabled && !server.persistIndefinite && server.persistDuration > 0 && server.activeSessions.Load() == 0 { + server.idleMu.Lock() + server.idleGen++ + gen := server.idleGen + server.idleTimer = time.AfterFunc(server.persistDuration, func() { + server.onIdleTimeout(gen) + }) + server.idleMu.Unlock() + } + + return server, nil +} + +var activeMuxServers sync.Map // string (controlPath) -> *MuxServer + +// GetActiveMuxServer returns the active MuxServer listening on the given control path, if any. +func GetActiveMuxServer(controlPath string) *MuxServer { + if val, ok := activeMuxServers.Load(controlPath); ok { + return val.(*MuxServer) + } + return nil +} + +// MuxMasterOptions carries the master configuration a Connector applies for one +// resolved control path. +type MuxMasterOptions struct { + Path string + Target TargetContext + Environment Environment + RootConn net.Conn + // InitialActiveSessions is the number of sessions the caller already holds + // on the new master. A connector passes 1 for its own transport: the master + // exists to serve it, so an idle timer must not tear it down while the + // caller keeps using it. + InitialActiveSessions int32 + // ConfirmSession authorizes proxy sessions, passenger sessions, and + // forwarding requests on an ask/autoask master. Nil rejects them. + ConfirmSession func(action string) bool +} + +// StartMuxServer starts the master for opts.Path and returns it. The caller owns +// the returned server and must shut it down; nothing else releases the control +// socket or the SSH transport it references. +func StartMuxServer(ctx context.Context, sshClient *cryptoSSH.Client, opts MuxMasterOptions) (*MuxServer, error) { + target := opts.Target + env := opts.Environment + + expectedUID := expectedPeerUIDFromEnvironment(env) + checkUID := true + + var persistEnabled, persistIndefinite bool + var persistDuration time.Duration + if target.Control != nil && target.Control.Persist != "" { + en, ind, dur, err := ParseControlPersist(target.Control.Persist) + if err == nil { + persistEnabled = en + persistIndefinite = ind + persistDuration = dur + } + } + + masterMode := ControlMasterNo + if target.Control != nil { + masterMode = target.Control.Master + } + askConfirmation := masterMode == ControlMasterAsk || masterMode == ControlMasterAutoAsk + + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: opts.Path, + ExpectedUID: expectedUID, + CheckPeerUID: checkUID, + PersistEnabled: persistEnabled, + PersistDuration: persistDuration, + PersistIndefinite: persistIndefinite, + SSHClient: sshClient, + RootConn: opts.RootConn, + InitialActiveSessions: opts.InitialActiveSessions, + AskConfirmation: askConfirmation, + ConfirmSession: opts.ConfirmSession, + }) + if err != nil { + return nil, err + } + server.target = target + + activeMuxServers.Store(opts.Path, server) + + go func() { + if err := server.Serve(); err != nil { + // A fatal accept failure (EMFILE, ENOBUFS) leaves a socket nobody + // accepts on and no other path to s.Wait(). Retire the master instead + // of leaving -N and the ControlPersist daemon blocked forever. A nil + // return means Shutdown or -O stop already owns the teardown, so the + // draining sessions must not be cut off here. + _ = server.Shutdown() + } + }() + + if ctx != nil { + go func() { + select { + case <-ctx.Done(): + _ = server.Shutdown() + case <-server.Wait(): + } + }() + } + + return server, nil +} + +// expectedPeerUIDFromEnvironment derives the UID a control socket endpoint must +// run as. The check defaults to the effective local user so a host that omits +// LocalUID cannot silently disable it. An explicit LocalUID still wins, and a +// malformed value falls back to the effective UID instead of dropping the check. +// Platforms without peer credentials treat the check as a no-op. +func expectedPeerUIDFromEnvironment(env Environment) uint32 { + expectedUID := localPeerUID() + if env.LocalUID != "" { + if uid, err := strconv.ParseUint(env.LocalUID, 10, 32); err == nil { + expectedUID = uint32(uid) + } + } + return expectedUID +} + +// PersistEnabled reports whether this master survives the end of the foreground +// session (`ControlPersist` other than "no"). +func (s *MuxServer) PersistEnabled() bool { + if s == nil { + return false + } + return s.persistEnabled +} + +// PersistIndefinite reports whether the master keeps running until an explicit +// stop (-O exit/stop) instead of an idle timeout. +func (s *MuxServer) PersistIndefinite() bool { + if s == nil { + return false + } + return s.persistIndefinite +} + +// isShuttingDown reports whether this master already committed to shutting down. +func (s *MuxServer) isShuttingDown() bool { + if s == nil { + return true + } + s.idleMu.Lock() + defer s.idleMu.Unlock() + return s.shuttingDown +} + +// PrepareHandoff reserves the master for a ControlPersist handoff. It succeeds +// only when no other session or forward depends on this process: shutting the +// master down would otherwise cut those users off. On success the server is +// marked as shutting down so no new session or forward is admitted before the +// caller releases it. +func (s *MuxServer) PrepareHandoff() bool { + if s == nil { + return false + } + s.idleMu.Lock() + defer s.idleMu.Unlock() + if s.shuttingDown || s.closed.Load() || s.stoppedListening.Load() || s.activeSessions.Load() > 0 { + return false + } + if s.forwardCount() > 0 || s.activeForwardStreams.Load() > 0 { + return false + } + s.shuttingDown = true + s.idleGen++ + if s.idleTimer != nil { + s.idleTimer.Stop() + s.idleTimer = nil + } + return true +} + +// CancelHandoff releases a reservation made by PrepareHandoff when a multi-hop +// handoff could not reserve every master in the chain. +func (s *MuxServer) CancelHandoff() { + if s == nil { + return + } + s.idleMu.Lock() + defer s.idleMu.Unlock() + if s.closed.Load() || s.stoppedListening.Load() { + return + } + s.shuttingDown = false + s.notifyIdleLocked() +} + +// StoppedListening reports whether -O stop has unlinked the control socket. +func (s *MuxServer) StoppedListening() bool { + if s == nil { + return false + } + return s.stoppedListening.Load() +} + +// Target returns the resolved connection target metadata for this master. +func (s *MuxServer) Target() TargetContext { + if s == nil { + return TargetContext{} + } + return s.target +} + +// WaitForDrain blocks until activeSessions <= maxSessions and no active +// forwards remain on the master, or until the master stops listening, shuts +// down, or ctx is canceled. +func (s *MuxServer) WaitForDrain(ctx context.Context, maxSessions int32) error { + if s == nil { + return nil + } + for { + s.idleMu.Lock() + ready := s.shuttingDown || s.closed.Load() || s.stoppedListening.Load() || + (s.activeSessions.Load() <= maxSessions && s.forwardCount() == 0 && s.activeForwardStreams.Load() == 0) + notify := s.idleNotify + s.idleMu.Unlock() + if ready { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-s.shutdownChan: + return nil + case <-notify: + } + } +} + +func (s *MuxServer) notifyIdleLocked() { + if s.idleNotify != nil { + close(s.idleNotify) + s.idleNotify = make(chan struct{}) + } +} + +func (s *MuxServer) forwardCount() int { + count := 0 + s.forwards.Range(func(_, _ any) bool { + count++ + return true + }) + return count +} + +// Serve enters the client connection accept loop. +func (s *MuxServer) Serve() error { + type deadliner interface { + SetDeadline(t time.Time) error + } + dl, hasDeadline := s.listener.(deadliner) + + for { + if hasDeadline && !s.closed.Load() && !s.stoppedListening.Load() { + _ = dl.SetDeadline(time.Now().Add(50 * time.Millisecond)) + } + + conn, err := s.listener.Accept() + if err != nil { + if s.closed.Load() || s.stoppedListening.Load() { + return nil + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + // Socket accept timed out; check whether the control socket still exists on disk + currFi, statErr := os.Lstat(s.controlPath) + if statErr != nil { + if os.IsNotExist(statErr) || errors.Is(statErr, os.ErrNotExist) || errors.Is(statErr, syscall.ENOENT) { + // Control socket was unlinked from filesystem: stop listening and + // retire only once active sessions finish draining (never kill an + // in-use foreground or proxy session). + s.retireUnlinkedOrReplacedListener() + return nil + } + } else if s.origFi != nil && !os.SameFile(s.origFi, currFi) { + // Control socket was replaced on disk + s.retireUnlinkedOrReplacedListener() + return nil + } + continue + } + return fmt.Errorf("accept control connection failed: %w", err) + } + + go s.handleClient(conn) + } +} + +// retireUnlinkedOrReplacedListener stops accepting new connections when the +// control socket entry on disk was unlinked or replaced externally, while +// letting any active foreground or proxy sessions drain cleanly before Shutdown. +func (s *MuxServer) retireUnlinkedOrReplacedListener() { + if s.stoppedListening.CompareAndSwap(false, true) { + _ = s.listener.Close() + activeMuxServers.CompareAndDelete(s.controlPath, s) + s.idleMu.Lock() + s.notifyIdleLocked() + s.idleMu.Unlock() + } + if s.releaseForegroundOnStop.Swap(false) { + s.foregroundReleasedOnStop.Store(true) + s.removeActiveSession() + return + } + s.shutdownWhenIdle() +} + +func (s *MuxServer) handleClient(conn net.Conn) { + s.trackStream(conn) + defer func() { + _ = conn.Close() + s.untrackStream(conn) + }() + + if s.checkPeerUID { + if err := checkPeerUID(conn, s.expectedUID); err != nil { + _ = sendPermissionDenied(conn, 0, fmt.Sprintf("peer UID check failed: %v", err)) + return + } + } + + _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + if err := s.handshakeHello(conn); err != nil { + return + } + _ = conn.SetReadDeadline(time.Time{}) + + for { + msgType, reqPayload, err := ReadMuxPacket(conn) + if err != nil { + return + } + if !s.dispatchRequest(conn, msgType, reqPayload) { + return + } + } +} + +func (s *MuxServer) handshakeHello(conn net.Conn) error { + pktType, payload, err := ReadMuxPacket(conn) + if err != nil { + return err + } + if pktType != MUX_MSG_HELLO { + _ = sendFailure(conn, 0, fmt.Sprintf("expected MUX_MSG_HELLO (0x01), got 0x%08x", pktType)) + return errors.New("expected hello") + } + clientVer, _, err := DecodeMuxHello(payload) + if err != nil || clientVer != MuxProtoVersion { + _ = sendFailure(conn, 0, fmt.Sprintf("unsupported client protocol version %d", clientVer)) + return errors.New("unsupported version") + } + exts := []MuxExtension{{Name: "info", Value: "1"}} + if targetID := s.target.CanonicalTargetIdentity(); targetID != "" { + exts = append(exts, MuxExtension{Name: muxTargetExtensionName, Value: targetID}) + } + return WriteMuxPacket(conn, MUX_MSG_HELLO, EncodeMuxHello(MuxProtoVersion, exts)) +} + +func peekMuxRequestID(reqPayload []byte) uint32 { + if len(reqPayload) >= 4 { + return binary.BigEndian.Uint32(reqPayload[:4]) + } + return 0 +} + +func (s *MuxServer) dispatchRequest(conn net.Conn, msgType uint32, reqPayload []byte) bool { + switch msgType { + case MUX_C_ALIVE_CHECK: + reqID, err := DecodeMuxRequestID(reqPayload) + if err != nil { + _ = sendFailure(conn, peekMuxRequestID(reqPayload), "malformed alive check request") + return false + } + return s.handleAliveCheck(conn, reqID) == nil + + case MUX_C_TERMINATE: + reqID, err := DecodeMuxRequestID(reqPayload) + if err != nil { + _ = sendFailure(conn, peekMuxRequestID(reqPayload), "malformed terminate request") + return false + } + _ = s.handleTerminate(conn, reqID) + return false + + case MUX_C_STOP_LISTENING: + reqID, err := DecodeMuxRequestID(reqPayload) + if err != nil { + _ = sendFailure(conn, peekMuxRequestID(reqPayload), "malformed stop listening request") + return false + } + return s.handleStopListening(conn, reqID) == nil + + case MUX_C_PROXY: + reqID, err := DecodeMuxRequestID(reqPayload) + if err != nil { + _ = sendFailure(conn, peekMuxRequestID(reqPayload), "malformed proxy request") + return false + } + _ = s.handleProxy(conn, reqID) + return false + + case MUX_C_OPEN_FWD: + req, err := DecodeMuxFwdRequest(reqPayload) + if err != nil { + _ = sendFailure(conn, peekMuxRequestID(reqPayload), "malformed open forward request") + return false + } + return s.handleOpenFwd(conn, req) == nil + + case MUX_C_CLOSE_FWD: + req, err := DecodeMuxFwdRequest(reqPayload) + if err != nil { + _ = sendFailure(conn, peekMuxRequestID(reqPayload), "malformed close forward request") + return false + } + return s.handleCloseFwd(conn, req) == nil + + case MUX_C_NEW_SESSION: + req, err := DecodeMuxNewSession(reqPayload) + if err != nil { + _ = sendFailure(conn, peekMuxRequestID(reqPayload), "malformed new session request") + return false + } + _ = handlePassengerSession(s, conn, req.RequestID, reqPayload) + return false + + case MUX_C_NEW_STDIO_FWD: + req, err := DecodeMuxNewStdioFwd(reqPayload) + if err != nil { + _ = sendFailure(conn, peekMuxRequestID(reqPayload), "malformed new stdio fwd request") + return false + } + _ = handlePassengerStdioFwd(s, conn, req.RequestID, reqPayload) + return false + + case MUX_C_EXT_INFO: + reqID, name, err := DecodeMuxExtInfo(reqPayload) + if err != nil { + _ = sendFailure(conn, peekMuxRequestID(reqPayload), "malformed ext info request") + return false + } + return s.handleExtInfo(conn, reqID, name) == nil + + default: + _ = sendFailure(conn, peekMuxRequestID(reqPayload), fmt.Sprintf("unsupported multiplex request: 0x%08x", msgType)) + return false + } +} + +func (s *MuxServer) handleAliveCheck(conn net.Conn, reqID uint32) error { + pid := uint32(os.Getpid()) + return sendAlive(conn, reqID, pid) +} + +func (s *MuxServer) handleExtInfo(conn net.Conn, reqID uint32, name string) error { + var msg string + switch name { + case "connection": + targetLine := "" + if targetID := s.target.CanonicalTargetIdentity(); targetID != "" { + targetLine = fmt.Sprintf("Target: %s\n", FormatTargetIdentity(targetID)) + } + msg = fmt.Sprintf("OpenSSH multiplexing control connection\nSocket: %s\n%sControlMaster PID: %d\nActive sessions: %d\n", + s.controlPath, targetLine, os.Getpid(), s.activeSessions.Load()) + case "channels": + var b strings.Builder + b.WriteString("Active Forwardings:\n") + count := 0 + s.forwards.Range(func(key, value any) bool { + fmt.Fprintf(&b, "- %v\n", key) + count++ + return true + }) + if count == 0 { + b.WriteString("(none)\n") + } + msg = b.String() + default: + return sendFailure(conn, reqID, fmt.Sprintf("unknown info type %q", name)) + } + return WriteMuxPacket(conn, MUX_S_EXT_INFO, EncodeMuxExtInfoResponse(reqID, 1, msg)) +} + +// handleTerminate always stops the master. The ask/autoask confirmation gates +// admission of new sessions and forwards, not administrative requests from the +// socket's owner: a master configured without a confirmer must stay stoppable, +// and the 0600 socket plus the peer-UID check already bound who may send one. +func (s *MuxServer) handleTerminate(conn net.Conn, reqID uint32) error { + _ = sendOk(conn, reqID) + go func() { + time.Sleep(10 * time.Millisecond) // Allow response packet to flush + _ = s.Shutdown() + }() + return nil +} + +// handleStopListening unlinks the socket immediately and then retires a master +// that has nothing left to serve. Without the second step a ControlPersist +// daemon sitting idle would never stop: the shutdown paths that watch +// stoppedListening only run when the last active session is released, and there +// is no such session to release. +func (s *MuxServer) handleStopListening(conn net.Conn, reqID uint32) error { + if s.stoppedListening.CompareAndSwap(false, true) { + _ = s.listener.Close() + s.unlinkControlSocketFileIfOurs() + s.idleMu.Lock() + s.notifyIdleLocked() + s.idleMu.Unlock() + } + if err := sendOk(conn, reqID); err != nil { + return err + } + if s.releaseForegroundOnStop.Swap(false) { + s.foregroundReleasedOnStop.Store(true) + s.removeActiveSession() + return nil + } + s.shutdownWhenIdle() + return nil +} + +// shutdownWhenIdle commits a stopped master to shutdown unless a session is +// still draining. idleMu makes the admission decision atomic against +// addActiveSession, which refuses to start sessions on a shutting-down master. +func (s *MuxServer) shutdownWhenIdle() { + s.idleMu.Lock() + if s.shuttingDown || s.closed.Load() || s.activeSessions.Load() > 0 || s.activeForwardStreams.Load() > 0 { + s.idleMu.Unlock() + return + } + s.shuttingDown = true + s.idleGen++ + if s.idleTimer != nil { + s.idleTimer.Stop() + s.idleTimer = nil + } + s.idleMu.Unlock() + + go func() { _ = s.Shutdown() }() +} + +func (s *MuxServer) handleProxy(conn net.Conn, reqID uint32) error { + if err := s.authorize("proxy session"); err != nil { + return sendPermissionDenied(conn, reqID, err.Error()) + } + if !s.addActiveSession() { + return sendFailure(conn, reqID, "mux master is shutting down") + } + defer s.removeActiveSession() + + if err := sendProxy(conn, reqID); err != nil { + return err + } + + s.trackStream(conn) + defer s.untrackStream(conn) + + return s.serveProxyConn(conn) +} + +// ErrSessionNotConfirmed reports that a confirmation-mode master (ControlMaster +// ask/autoask) rejected a request because no approval was granted. +var ErrSessionNotConfirmed = errors.New("connection sharing request was not approved") + +// authorize applies the confirmation policy of an ask/autoask master. Any other +// mode, and a server without a confirmation callback, is unaffected. +func (s *MuxServer) authorize(action string) error { + if s == nil || !s.askConfirmation { + return nil + } + if s.confirmSession == nil { + return fmt.Errorf("%w: %s", ErrSessionNotConfirmed, action) + } + if !s.confirmSession(action) { + return fmt.Errorf("%w: %s", ErrSessionNotConfirmed, action) + } + return nil +} + +func normalizeForwardListenHost(listenHost string) string { + trimmed := strings.TrimSpace(listenHost) + if trimmed == "" { + return "127.0.0.1" + } + if trimmed == "*" { + return "0.0.0.0" + } + return listenHost +} + +func forwardKey(fwdType uint32, listenHost string, listenPort uint32, connectHost string, connectPort uint32) string { + return fmt.Sprintf("%d:%s:%d:%s:%d", fwdType, normalizeForwardListenHost(listenHost), listenPort, connectHost, connectPort) +} + +func (s *MuxServer) handleOpenFwd(conn net.Conn, req MuxFwdRequest) error { + if s.isShuttingDown() { + // Shutdown already took its listener snapshot; accepting a forward now would + // leave a listener that outlives the master it belongs to. + return sendFailure(conn, req.RequestID, "mux master is shutting down") + } + req.ListenHost = normalizeForwardListenHost(req.ListenHost) + key := forwardKey(req.FwdType, req.ListenHost, req.ListenPort, req.ConnectHost, req.ConnectPort) + if req.FwdType != MUX_FWD_REMOTE || req.ListenPort != 0 { + if _, exists := s.forwards.Load(key); exists { + if err := s.authorize("forward"); err != nil { + return sendPermissionDenied(conn, req.RequestID, err.Error()) + } + return sendOk(conn, req.RequestID) + } + } + if err := s.authorize("forward"); err != nil { + return sendPermissionDenied(conn, req.RequestID, err.Error()) + } + + switch req.FwdType { + case MUX_FWD_LOCAL: + addr := net.JoinHostPort(req.ListenHost, strconv.Itoa(int(req.ListenPort))) + ln, err := net.Listen("tcp", addr) + if err != nil { + return sendFailure(conn, req.RequestID, fmt.Sprintf("bind local port %s failed: %v", addr, err)) + } + if reason := s.publishForwardOrReason(key, ln); reason != "" { + return sendFailure(conn, req.RequestID, reason) + } + go func(k string, l net.Listener) { + defer s.retireForward(k, l) + s.serveLocalForward(l, net.JoinHostPort(req.ConnectHost, strconv.Itoa(int(req.ConnectPort)))) + }(key, ln) + return sendOk(conn, req.RequestID) + + case MUX_FWD_REMOTE: + return s.openRemoteForward(conn, req, key) + + case MUX_FWD_DYNAMIC: + addr := net.JoinHostPort(req.ListenHost, strconv.Itoa(int(req.ListenPort))) + ln, err := net.Listen("tcp", addr) + if err != nil { + return sendFailure(conn, req.RequestID, fmt.Sprintf("bind dynamic port %s failed: %v", addr, err)) + } + if reason := s.publishForwardOrReason(key, ln); reason != "" { + return sendFailure(conn, req.RequestID, reason) + } + go func(k string, l net.Listener) { + defer s.retireForward(k, l) + s.serveDynamicForward(l) + }(key, ln) + return sendOk(conn, req.RequestID) + + default: + return sendFailure(conn, req.RequestID, fmt.Sprintf("unsupported forward type %d", req.FwdType)) + } +} + +func (s *MuxServer) openRemoteForward(conn net.Conn, req MuxFwdRequest, key string) error { + addr := net.JoinHostPort(req.ListenHost, strconv.Itoa(int(req.ListenPort))) + ln, err := s.sshClient.Listen("tcp", addr) + if err != nil { + return sendFailure(conn, req.RequestID, fmt.Sprintf("remote listen %s failed: %v", addr, err)) + } + var allocatedPort uint32 + if req.ListenPort == 0 { + if tcpAddr, ok := ln.Addr().(*net.TCPAddr); ok && tcpAddr.Port > 0 { + allocatedPort = uint32(tcpAddr.Port) + key = forwardKey(req.FwdType, req.ListenHost, allocatedPort, req.ConnectHost, req.ConnectPort) + } + } + if reason := s.publishForwardOrReason(key, ln); reason != "" { + return sendFailure(conn, req.RequestID, reason) + } + go func(k string, l net.Listener) { + defer s.retireForward(k, l) + s.serveRemoteForward(l, net.JoinHostPort(req.ConnectHost, strconv.Itoa(int(req.ConnectPort)))) + }(key, ln) + + if req.ListenPort == 0 && allocatedPort > 0 { + return sendRemotePort(conn, req.RequestID, allocatedPort) + } + return sendOk(conn, req.RequestID) +} + +func (s *MuxServer) publishForwardOrReason(key string, listener io.Closer) string { + accepted, duplicate := s.publishForward(key, listener) + if accepted { + return "" + } + if duplicate { + return "forwarding already exists" + } + return "mux master is shutting down" +} + +// publishForward registers a listener this master now owns. Shutdown and +// PrepareHandoff synchronize on idleMu, so a forward never slips in after the +// master committed to handoff or shutdown, and LoadOrStore prevents concurrent +// identical requests from overwriting and leaking an existing listener. +func (s *MuxServer) publishForward(key string, listener io.Closer) (accepted bool, duplicate bool) { + s.idleMu.Lock() + if s.shuttingDown || s.closed.Load() { + s.idleMu.Unlock() + _ = listener.Close() + return false, false + } + if _, loaded := s.forwards.LoadOrStore(key, listener); loaded { + s.idleMu.Unlock() + _ = listener.Close() + return false, true + } + s.idleMu.Unlock() + return true, false +} + +func (s *MuxServer) retireForward(key string, listener io.Closer) { + if s.forwards.CompareAndDelete(key, listener) { + s.idleMu.Lock() + s.notifyIdleLocked() + s.idleMu.Unlock() + } +} + +// trackStream registers an accepted connection for shutdown-driven teardown. +func (s *MuxServer) trackStream(conn net.Conn) { + if conn == nil { + return + } + s.liveStreams.Store(conn, struct{}{}) + if s.closed.Load() { + // Shutdown may have already taken its snapshot; close immediately. + _ = conn.Close() + } +} + +func (s *MuxServer) untrackStream(conn net.Conn) { + if conn == nil { + return + } + s.liveStreams.Delete(conn) +} + +func (s *MuxServer) serveLocalForward(ln net.Listener, targetAddr string) { + defer func() { _ = ln.Close() }() + for { + c, err := ln.Accept() + if err != nil { + return + } + go func(local net.Conn) { + if !s.addActiveForwardStream() { + _ = local.Close() + return + } + s.trackStream(local) + defer func() { + _ = local.Close() + s.untrackStream(local) + s.removeActiveForwardStream() + }() + remote, err := s.sshClient.Dial("tcp", targetAddr) + if err != nil { + return + } + s.trackStream(remote) + defer func() { + _ = remote.Close() + s.untrackStream(remote) + }() + pipeBidirectional(local, remote) + }(c) + } +} + +func (s *MuxServer) serveRemoteForward(ln net.Listener, localAddr string) { + defer func() { _ = ln.Close() }() + for { + c, err := ln.Accept() + if err != nil { + return + } + go func(remote net.Conn) { + if !s.addActiveForwardStream() { + _ = remote.Close() + return + } + s.trackStream(remote) + defer func() { + _ = remote.Close() + s.untrackStream(remote) + s.removeActiveForwardStream() + }() + local, err := net.Dial("tcp", localAddr) + if err != nil { + return + } + s.trackStream(local) + defer func() { + _ = local.Close() + s.untrackStream(local) + }() + pipeBidirectional(local, remote) + }(c) + } +} + +func (s *MuxServer) serveDynamicForward(ln net.Listener) { + defer func() { _ = ln.Close() }() + for { + c, err := ln.Accept() + if err != nil { + return + } + go s.serveDynamicConn(c) + } +} + +// serveDynamicConn terminates one SOCKS5 CONNECT on the master's SSH transport. +// It reuses the shared SOCKS5 negotiation used by Client.Socks5Forward so the +// master advertises the same protocol subset and error replies. +func (s *MuxServer) serveDynamicConn(conn net.Conn) { + if !s.addActiveForwardStream() { + _ = conn.Close() + return + } + s.trackStream(conn) + defer func() { + _ = conn.Close() + s.untrackStream(conn) + s.removeActiveForwardStream() + }() + + destAddr, err := handshakeAndParseRequest(conn) + if err != nil { + return + } + + remoteConn, err := s.sshClient.Dial("tcp", destAddr) + if err != nil { + // 0x03 == host unreachable; matches Client.handleSocks5. + _ = writeSocks5Reply(conn, []byte{0x05, 0x03, 0x00, 0x01, 0, 0, 0, 0, 0, 0}) + return + } + s.trackStream(remoteConn) + defer func() { + _ = remoteConn.Close() + s.untrackStream(remoteConn) + }() + + if err := conn.SetDeadline(time.Time{}); err != nil { + return + } + if err := writeSocks5Reply(conn, []byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); err != nil { + return + } + + pipeBidirectional(conn, remoteConn) +} + +// pipeBidirectional copies both directions to completion. A one-way EOF only +// half-closes the peer so in-flight bytes in the other direction are not +// truncated, and the function does not return before both copies have exited. +func pipeBidirectional(c1, c2 io.ReadWriteCloser) { + var copies sync.WaitGroup + pipe := func(dst, src io.ReadWriteCloser) { + defer copies.Done() + _, _ = io.Copy(dst, src) + if halfCloser, ok := dst.(interface{ CloseWrite() error }); ok { + _ = halfCloser.CloseWrite() + return + } + _ = dst.Close() + } + copies.Add(2) + go pipe(c1, c2) + go pipe(c2, c1) + copies.Wait() +} + +func (s *MuxServer) handleCloseFwd(conn net.Conn, req MuxFwdRequest) error { + req.ListenHost = normalizeForwardListenHost(req.ListenHost) + key := forwardKey(req.FwdType, req.ListenHost, req.ListenPort, req.ConnectHost, req.ConnectPort) + val, ok := s.forwards.LoadAndDelete(key) + if !ok && req.FwdType == MUX_FWD_REMOTE && req.ListenPort == 0 { + prefix := fmt.Sprintf("%d:%s:", req.FwdType, req.ListenHost) + suffix := fmt.Sprintf(":%s:%d", req.ConnectHost, req.ConnectPort) + var matchedKey string + s.forwards.Range(func(k, _ any) bool { + ks, isStr := k.(string) + if isStr && strings.HasPrefix(ks, prefix) && strings.HasSuffix(ks, suffix) { + matchedKey = ks + return false + } + return true + }) + if matchedKey != "" { + val, ok = s.forwards.LoadAndDelete(matchedKey) + } + } + if ok { + if closer, ok := val.(io.Closer); ok { + _ = closer.Close() + } + s.idleMu.Lock() + s.notifyIdleLocked() + s.idleMu.Unlock() + return sendOk(conn, req.RequestID) + } + return sendFailure(conn, req.RequestID, "forwarding not found") +} + +type proxyChan struct { + remoteCh cryptoSSH.Channel + clientCh uint32 + serverCh uint32 + // clientWindow tracks the proxy client's receive window for the + // master-to-client direction, and clientMaxPacket bounds one data packet. + // Both come from the client's channel open; ignoring them makes the client's + // SSH mux abort the whole multiplexed connection once its window overruns. + clientWindow *clientFlowWindow + clientMaxPacket int + // reqsDone closes when the remote side stops sending channel requests. + reqsDone chan struct{} + // requests serializes client-to-server channel requests. RFC 4254 channel + // responses carry no request ID, so requests and their replies must stay in + // wire order instead of running one goroutine per request. + requests *orderedQueue[proxyChannelRequest] + // requestsDone closes once the request worker has drained every queued + // request and written its reply. + requestsDone chan struct{} + // input relays client-to-master data in wire order. Writing it inline would + // let one channel whose remote window is exhausted stall the shared proxy + // read loop, and with it every other channel on the session. + input *orderedQueue[proxyChannelInput] + queuedIn atomic.Int64 + // pendingRemoteReqs holds remote-initiated channel requests that asked for a + // reply and are waiting for the proxy client's answer. RFC 4254 pairs replies + // positionally, so they are answered in arrival order. + pendingRemoteReqs *remoteRequestQueue + // closeSent is guarded by proxySessionState.writeMu. It records that the one + // SSH_MSG_CHANNEL_CLOSE for this channel has been written, so no later packet + // can follow it. + closeSent bool +} + +// proxyChannelInput is one ordered client-to-master channel event. +type proxyChannelInput struct { + kind byte + extendedType uint32 + payload []byte +} + +// clientFlowWindow tracks the credit granted by the proxy client for data this +// master writes to it. Credit starts at the window advertised in the client's +// channel open and is replenished by SSH_MSG_CHANNEL_WINDOW_ADJUST. +type clientFlowWindow struct { + mu sync.Mutex + cond *sync.Cond + credit int64 + closed bool +} + +func newClientFlowWindow(size uint32) *clientFlowWindow { + window := &clientFlowWindow{credit: int64(size)} + window.cond = sync.NewCond(&window.mu) + return window +} + +// add applies a window-adjust delta. RFC 4254 forbids growing a channel window +// beyond 2^31, so an overflow is treated as a protocol violation. +func (w *clientFlowWindow) add(delta uint32) error { + if delta == 0 { + return nil + } + w.mu.Lock() + defer w.mu.Unlock() + if w.closed { + return net.ErrClosed + } + if w.credit+int64(delta) > maxFlowWindow { + return fmt.Errorf("channel window overflow: %d + %d", w.credit, delta) + } + w.credit += int64(delta) + w.cond.Broadcast() + return nil +} + +// reserve blocks until the client has granted exactly n bytes and consumes that +// credit. n must not exceed the client's advertised maximum packet size, so the +// reservation is always written by one data packet and never over-reserves the +// window shared with the other remote stream. io.EOF reports that the channel +// ended while waiting for credit. +func (w *clientFlowWindow) reserve(n int) error { + if n == 0 { + return nil + } + w.mu.Lock() + defer w.mu.Unlock() + for int64(n) > w.credit { + if w.closed { + return io.EOF + } + w.cond.Wait() + } + w.credit -= int64(n) + return nil +} + +// close releases every sender blocked in reserve. +func (w *clientFlowWindow) close() { + w.mu.Lock() + w.closed = true + w.cond.Broadcast() + w.mu.Unlock() +} + +// remoteRequestQueue is a FIFO of remote-initiated want-reply channel requests. +// The proxy client answers them in order, and unanswered requests are rejected +// when the channel ends so the remote side never blocks. +type remoteRequestQueue struct { + mu sync.Mutex + items []*cryptoSSH.Request +} + +func newRemoteRequestQueue() *remoteRequestQueue { + return &remoteRequestQueue{} +} + +func (q *remoteRequestQueue) push(req *cryptoSSH.Request) { + q.mu.Lock() + q.items = append(q.items, req) + q.mu.Unlock() +} + +func (q *remoteRequestQueue) pop() (*cryptoSSH.Request, bool) { + q.mu.Lock() + defer q.mu.Unlock() + if len(q.items) == 0 { + return nil, false + } + req := q.items[0] + q.items[0] = nil + q.items = q.items[1:] + return req, true +} + +// drain replies false to every request still waiting, releasing the remote side. +func (q *remoteRequestQueue) drain() { + for { + req, ok := q.pop() + if !ok { + return + } + _ = req.Reply(false, nil) + } +} + +// proxyChannelRequest is one client-to-server channel request awaiting ordered +// forwarding to the master's SSH transport. +type proxyChannelRequest struct { + requestType string + wantReply bool + payload []byte +} + +// proxyGlobalRequest is one client-to-server global request awaiting ordered +// forwarding to the master's SSH transport. +type proxyGlobalRequest struct { + requestType string + wantReply bool + payload []byte +} + +// orderedQueue is an unbounded FIFO drained by a single worker. Push never +// blocks the proxy read loop, so data needed by the remote side of an in-flight +// request is still forwarded, while the worker keeps wire order for messages +// whose replies carry no request identifier. +type orderedQueue[T any] struct { + mu sync.Mutex + items []T + closed bool + wake chan struct{} +} + +func newOrderedQueue[T any]() *orderedQueue[T] { + return &orderedQueue[T]{wake: make(chan struct{}, 1)} +} + +func (q *orderedQueue[T]) push(item T) { + q.mu.Lock() + if q.closed { + q.mu.Unlock() + return + } + q.items = append(q.items, item) + q.mu.Unlock() + select { + case q.wake <- struct{}{}: + default: + } +} + +// close stops accepting new items; the worker drains queued items first. +func (q *orderedQueue[T]) close() { + q.mu.Lock() + q.closed = true + q.mu.Unlock() + select { + case q.wake <- struct{}{}: + default: + } +} + +func (q *orderedQueue[T]) pop() (T, bool) { + var zero T + for { + q.mu.Lock() + if len(q.items) > 0 { + item := q.items[0] + q.items[0] = zero + q.items = q.items[1:] + q.mu.Unlock() + return item, true + } + closed := q.closed + q.mu.Unlock() + if closed { + return zero, false + } + <-q.wake + } +} + +type proxySessionState struct { + server *MuxServer + conn net.Conn + writeMu sync.Mutex + channels sync.Map // serverChID (uint32) -> *proxyChan + // byClientCh indexes the same records by the proxy client's channel ID, which + // is what channel responses carry back. + byClientCh sync.Map // clientChID (uint32) -> *proxyChan + nextChID atomic.Uint32 + // global serializes client-to-server global requests. RFC 4254 global + // replies carry no request ID, so they can only be answered in wire order. + global *orderedQueue[proxyGlobalRequest] + closed atomic.Bool +} + +func (ps *proxySessionState) closeAll() { + if ps.closed.Swap(true) { + return + } + if ps.global != nil { + ps.global.close() + } + ps.channels.Range(func(key, val any) bool { + if pc, ok := val.(*proxyChan); ok { + ps.byClientCh.CompareAndDelete(pc.clientCh, pc) + pc.clientWindow.close() + pc.input.close() + pc.requests.close() + pc.pendingRemoteReqs.drain() + _ = pc.remoteCh.Close() + } + return true + }) +} + +// writeChannelPacket writes one channel-scoped packet unless the session ended or +// this channel's close was already written. The client's SSH mux fails the whole +// multiplexed connection on any packet for a channel it has closed, and RFC 4254 +// forbids sending anything after SSH_MSG_CHANNEL_CLOSE, so io.EOF reports +// "closed". +func (ps *proxySessionState) writeChannelPacket(pc *proxyChan, payload []byte) error { + ps.writeMu.Lock() + defer ps.writeMu.Unlock() + if ps.closed.Load() || pc == nil || pc.closeSent { + return io.EOF + } + return writeUnpaddedPacket(ps.conn, payload) +} + +// closeChannel writes the single SSH_MSG_CHANNEL_CLOSE for pc. RFC 4254 requires +// answering a close this side did not initiate, and the client only releases its +// channel (unblocking its readers) after receiving that answer. The closeSent +// gate also makes every later channel packet a no-op. +func (ps *proxySessionState) closeChannel(pc *proxyChan) { + ps.writeMu.Lock() + defer ps.writeMu.Unlock() + if ps.closed.Load() || pc == nil || pc.closeSent { + return + } + pc.closeSent = true + w := newMuxWriter() + w.buf = append(w.buf, msgChannelClose) + w.WriteUint32(pc.clientCh) + _ = writeUnpaddedPacket(ps.conn, w.Bytes()) +} + +func (ps *proxySessionState) writePacket(payload []byte) error { + ps.writeMu.Lock() + defer ps.writeMu.Unlock() + if ps.closed.Load() { + return net.ErrClosed + } + return writeUnpaddedPacket(ps.conn, payload) +} + +// serveProxyConn routes unpadded SSH transport packets between proxy client and sshClient. +func (s *MuxServer) serveProxyConn(conn net.Conn) error { + ps := &proxySessionState{ + server: s, + conn: conn, + global: newOrderedQueue[proxyGlobalRequest](), + } + defer ps.closeAll() + + // Send an initial want-reply=false SSH_MSG_GLOBAL_REQUEST ("hostkeys-00@openssh.com") + // on entering proxy mode, matching OpenSSH mux_InitialProxyPacket behavior so that + // `-O proxy` callers immediately observe the server's initial connection-protocol + // packet while golang.org/x/crypto/ssh's NewControlClientConn discards it cleanly. + initPkt := newMuxWriter() + initPkt.buf = append(initPkt.buf, msgGlobalRequest) + initPkt.WriteString("hostkeys-00@openssh.com") + initPkt.buf = append(initPkt.buf, 0) // want_reply = false + if err := ps.writePacket(initPkt.Bytes()); err != nil { + return err + } + + go ps.serveGlobalRequests() + + for { + packet, err := readUnpaddedPacket(conn) + if err != nil { + return err + } + if len(packet) == 0 { + continue + } + + ps.dispatch(packet) + } +} + +func (ps *proxySessionState) dispatch(packet []byte) { + switch packet[0] { + case msgChannelOpen: + ps.handleOpen(packet[1:]) + case msgChannelData: + ps.handleData(packet[1:]) + case msgChannelEOF: + ps.handleEOF(packet[1:]) + case msgChannelClose: + ps.handleClose(packet[1:]) + case msgChannelExtendedData: + ps.handleExtendedData(packet[1:]) + case msgChannelRequest: + ps.handleRequest(packet[1:]) + case msgGlobalRequest: + ps.handleGlobalRequest(packet[1:]) + case msgChannelWindowAdjust: + ps.handleWindowAdjust(packet[1:]) + case msgChannelSuccess: + ps.handleRemoteRequestReply(packet[1:], true) + case msgChannelFailure: + ps.handleRemoteRequestReply(packet[1:], false) + case msgPing: + pong := make([]byte, len(packet)) + pong[0] = msgPong + copy(pong[1:], packet[1:]) + _ = ps.writePacket(pong) + } +} + +// handleRemoteRequestReply relays the proxy client's answer to a remote-initiated +// channel request. Without it a server waiting on a want-reply request (for +// example exit-status handling or a forwarding confirmation) blocks forever. +func (ps *proxySessionState) handleRemoteRequestReply(payload []byte, accepted bool) { + r := newMuxReader(payload) + clientChID, err := r.ReadUint32() + if err != nil { + return + } + pcVal, ok := ps.byClientCh.Load(clientChID) + if !ok { + return + } + pc := pcVal.(*proxyChan) + req, ok := pc.pendingRemoteReqs.pop() + if !ok { + return + } + var replyPayload []byte + if accepted && r.Remaining() > 0 { + replyPayload = slices.Clone(r.data[r.off:]) + } + _ = req.Reply(accepted, replyPayload) +} + +func (ps *proxySessionState) handleOpen(payload []byte) { + r := newMuxReader(payload) + chType, err := r.ReadString() + if err != nil { + return + } + clientChID, err := r.ReadUint32() + if err != nil { + return + } + clientWindow, err := r.ReadUint32() + if err != nil { + return + } + clientMaxPacket, err := r.ReadUint32() + if err != nil { + return + } + var extra []byte + if r.Remaining() > 0 { + extra = r.data[r.off:] + } + + // The client's window and maximum packet size bound every packet this master + // sends it. Reject values SSH cannot represent instead of trusting them. + if clientMaxPacket < minProxyChannelPacket || int64(clientMaxPacket) > maxFlowWindow || int64(clientWindow) > maxFlowWindow { + w := newMuxWriter() + w.buf = append(w.buf, msgChannelOpenFailure) + w.WriteUint32(clientChID) + w.WriteUint32(2) // SSH_OPEN_CONNECT_FAILED + w.WriteString("invalid channel window or maximum packet size") + w.WriteString("en") + _ = ps.writePacket(w.Bytes()) + return + } + + pc := &proxyChan{ + clientCh: clientChID, + clientWindow: newClientFlowWindow(clientWindow), + clientMaxPacket: int(clientMaxPacket), + reqsDone: make(chan struct{}), + requests: newOrderedQueue[proxyChannelRequest](), + requestsDone: make(chan struct{}), + input: newOrderedQueue[proxyChannelInput](), + pendingRemoteReqs: newRemoteRequestQueue(), + } + + go ps.openRemoteChannel(pc, chType, extra) +} + +func (ps *proxySessionState) openRemoteChannel(pc *proxyChan, chType string, extra []byte) { + remoteCh, reqs, err := ps.server.sshClient.OpenChannel(chType, extra) + if err != nil { + pc.clientWindow.close() + reason := uint32(2) // SSH_OPEN_CONNECT_FAILED + msg := err.Error() + var openErr *cryptoSSH.OpenChannelError + if errors.As(err, &openErr) { + reason = uint32(openErr.Reason) + msg = openErr.Message + } + if chType == "session" && strings.TrimSpace(msg) == "open failed" { + msg = "open failed (remote sshd MaxSessions limit may be reached)" + } + w := newMuxWriter() + w.buf = append(w.buf, msgChannelOpenFailure) + w.WriteUint32(pc.clientCh) + w.WriteUint32(reason) + w.WriteString(msg) + w.WriteString("en") + _ = ps.writePacket(w.Bytes()) + return + } + + pc.remoteCh = remoteCh + pc.serverCh = ps.nextChID.Add(1) + ps.channels.Store(pc.serverCh, pc) + ps.byClientCh.Store(pc.clientCh, pc) + if ps.closed.Load() { + // Shutdown may have taken its snapshot before this channel was published. + ps.channels.CompareAndDelete(pc.serverCh, pc) + ps.byClientCh.CompareAndDelete(pc.clientCh, pc) + pc.clientWindow.close() + pc.input.close() + _ = remoteCh.Close() + return + } + + w := newMuxWriter() + w.buf = append(w.buf, msgChannelOpenConfirmation) + w.WriteUint32(pc.clientCh) + w.WriteUint32(pc.serverCh) + w.WriteUint32(proxyAdvertisedWindow) + w.WriteUint32(proxyAdvertisedMaxPacket) + _ = ps.writePacket(w.Bytes()) + + go ps.forwardRequests(pc, reqs) + go ps.serveChannelRequests(pc) + go ps.relayChannelInput(pc) + go ps.streamChannelData(pc) +} + +func (ps *proxySessionState) forwardRequests(pc *proxyChan, reqs <-chan *cryptoSSH.Request) { + defer close(pc.reqsDone) + writeFailed := false + for req := range reqs { + if writeFailed { + if req.WantReply { + _ = req.Reply(false, nil) + } + continue + } + if req.WantReply { + pc.pendingRemoteReqs.push(req) + } + rw := newMuxWriter() + rw.buf = append(rw.buf, msgChannelRequest) + rw.WriteUint32(pc.clientCh) + rw.WriteString(req.Type) + rw.WriteBool(req.WantReply) + rw.buf = append(rw.buf, req.Payload...) + if err := ps.writeChannelPacket(pc, rw.Bytes()); err != nil { + writeFailed = true + pc.pendingRemoteReqs.drain() + } + } + // The remote request stream ended (or the client closed the channel). Any + // request still awaiting an answer can no longer be satisfied, so release the + // remote side instead of leaving it blocked on a reply that will never arrive. + pc.pendingRemoteReqs.drain() +} + +// streamRemoteStdout forwards SSH channel data (stdout) to the proxy client. +func (ps *proxySessionState) streamRemoteStdout(pc *proxyChan) { + buf := make([]byte, proxyAdvertisedMaxPacket) + for { + n, err := pc.remoteCh.Read(buf) + if n > 0 { + if werr := ps.sendChannelData(pc, false, buf[:n]); werr != nil { + return + } + } + if err != nil { + return + } + } +} + +// streamRemoteStderr forwards SSH extended channel data (stderr) to the proxy +// client. Dropping it would lose all remote stderr and, once the remote process +// fills the channel window, block it permanently. +func (ps *proxySessionState) streamRemoteStderr(pc *proxyChan) { + stderr := pc.remoteCh.Stderr() + if stderr == nil { + return + } + buf := make([]byte, proxyAdvertisedMaxPacket) + for { + n, err := stderr.Read(buf) + if n > 0 { + if werr := ps.sendChannelData(pc, true, buf[:n]); werr != nil { + return + } + } + if err != nil { + return + } + } +} + +// sendChannelData writes data to the proxy client within the credit it granted. +// Every packet reserves its exact size from the client's window first and stays +// within the client's maximum packet size; a larger packet makes the client's +// SSH mux abort the whole multiplexed connection. +func (ps *proxySessionState) sendChannelData(pc *proxyChan, extended bool, data []byte) error { + for len(data) > 0 { + chunk := min(len(data), pc.clientMaxPacket) + if err := pc.clientWindow.reserve(chunk); err != nil { + return err + } + + w := newMuxWriter() + if extended { + w.buf = append(w.buf, msgChannelExtendedData) + w.WriteUint32(pc.clientCh) + w.WriteUint32(extendedDataStderr) + } else { + w.buf = append(w.buf, msgChannelData) + w.WriteUint32(pc.clientCh) + } + w.WriteBytes(data[:chunk]) + if err := ps.writeChannelPacket(pc, w.Bytes()); err != nil { + if errors.Is(err, io.EOF) { + // The channel closed while data was in flight; nothing may follow its + // close packet. + return err + } + // The client will not drain any more data; end the session so the read + // loop and every relay worker stop instead of waiting for a dead peer. + _ = ps.conn.Close() + return err + } + data = data[chunk:] + } + return nil +} + +// streamChannelData drains both remote streams, then reports EOF and close to +// the proxy client once pending replies have settled. +func (ps *proxySessionState) streamChannelData(pc *proxyChan) { + var streams sync.WaitGroup + streams.Add(2) + go func() { + defer streams.Done() + ps.streamRemoteStdout(pc) + }() + go func() { + defer streams.Done() + ps.streamRemoteStderr(pc) + }() + streams.Wait() + + if ps.closed.Load() { + // The session is gone: the peer will never answer and no packet may be + // written, so report nothing and skip the drain waits below. + return + } + + // Report EOF to the proxy client once remote stdout and stderr reach EOF. + // Do NOT close the channel on a 1s timer here: the remote process may have + // closed its stdout/stderr while still reading large stdin from the client + // (e.g. `cat > file 2>/dev/null < big`). Wait until the remote channel itself + // closes (which closes reqs and therefore pc.reqsDone) before sending + // SSH_MSG_CHANNEL_CLOSE. + ew := newMuxWriter() + ew.buf = append(ew.buf, msgChannelEOF) + ew.WriteUint32(pc.clientCh) + _ = ps.writeChannelPacket(pc, ew.Bytes()) + + // Wait for the remote channel to close (or the proxy session to tear down), + // which closes reqs and finishes forwarding exit-status / exit-signal. + <-pc.reqsDone + + // Drain every queued client-to-server request before reporting close. A + // close sent while a want-reply request (exec/shell) is still in flight + // makes the proxy client fail that request with io.EOF. + pc.requests.close() + select { + case <-pc.requestsDone: + case <-time.After(5 * time.Second): + } + + ps.closeChannel(pc) + ps.channels.CompareAndDelete(pc.serverCh, pc) + ps.byClientCh.CompareAndDelete(pc.clientCh, pc) + pc.clientWindow.close() + pc.input.close() + _ = pc.remoteCh.Close() +} + +func (ps *proxySessionState) handleData(payload []byte) { + r := newMuxReader(payload) + serverChID, err := r.ReadUint32() + if err != nil { + return + } + data, err := r.ReadBytes() + if err != nil || len(data) == 0 { + return + } + ps.enqueueInput(serverChID, proxyChannelInput{kind: msgChannelData, payload: data}) +} + +func (ps *proxySessionState) handleExtendedData(payload []byte) { + r := newMuxReader(payload) + serverChID, err := r.ReadUint32() + if err != nil { + return + } + extendedType, err := r.ReadUint32() + if err != nil || extendedType != extendedDataStderr { + return + } + data, err := r.ReadBytes() + if err != nil || len(data) == 0 { + return + } + ps.enqueueInput(serverChID, proxyChannelInput{kind: msgChannelExtendedData, extendedType: extendedType, payload: data}) +} + +// enqueueInput hands client data to the channel's ordered relay worker. The +// read loop must never wait for a remote channel's window: one channel nobody +// reads would otherwise stall every other channel on the shared session. +func (ps *proxySessionState) enqueueInput(serverChID uint32, item proxyChannelInput) { + pcVal, ok := ps.channels.Load(serverChID) + if !ok { + return + } + pc := pcVal.(*proxyChan) + if pc.input == nil { + return + } + if pc.queuedIn.Add(int64(len(item.payload))) > maxProxyQueuedInput { + // The peer ignored the window it was given; fail the session instead of + // buffering its input without bound. + _ = ps.conn.Close() + return + } + pc.input.push(item) +} + +// relayChannelInput forwards queued client data in wire order and credits the +// client's window only afterwards, so accepted-but-unforwarded input stays +// bounded by the window this master advertised. +func (ps *proxySessionState) relayChannelInput(pc *proxyChan) { + for { + item, ok := pc.input.pop() + if !ok { + return + } + pc.queuedIn.Add(-int64(len(item.payload))) + + switch item.kind { + case msgChannelData: + _, _ = pc.remoteCh.Write(item.payload) + case msgChannelExtendedData: + if stderr := pc.remoteCh.Stderr(); stderr != nil { + _, _ = stderr.Write(item.payload) + } + case msgChannelEOF: + _ = pc.remoteCh.CloseWrite() + continue + default: + continue + } + + if len(item.payload) == 0 { + continue + } + w := newMuxWriter() + w.buf = append(w.buf, msgChannelWindowAdjust) + w.WriteUint32(pc.clientCh) + w.WriteUint32(uint32(len(item.payload))) + _ = ps.writeChannelPacket(pc, w.Bytes()) + } +} + +// handleWindowAdjust credits data this master may send to the proxy client. +func (ps *proxySessionState) handleWindowAdjust(payload []byte) { + r := newMuxReader(payload) + serverChID, err := r.ReadUint32() + if err != nil { + return + } + delta, err := r.ReadUint32() + if err != nil { + return + } + pcVal, ok := ps.channels.Load(serverChID) + if !ok { + return + } + _ = pcVal.(*proxyChan).clientWindow.add(delta) +} + +func (ps *proxySessionState) handleEOF(payload []byte) { + r := newMuxReader(payload) + serverChID, err := r.ReadUint32() + if err != nil { + return + } + if pcVal, ok := ps.channels.Load(serverChID); ok { + // The relay worker preserves order, so EOF reaches the remote channel only + // after the data the client sent before it. + pc := pcVal.(*proxyChan) + if pc.input != nil { + pc.input.push(proxyChannelInput{kind: msgChannelEOF}) + } + } +} + +func (ps *proxySessionState) handleClose(payload []byte) { + r := newMuxReader(payload) + serverChID, err := r.ReadUint32() + if err != nil { + return + } + if pcVal, ok := ps.channels.LoadAndDelete(serverChID); ok { + pc := pcVal.(*proxyChan) + ps.byClientCh.CompareAndDelete(pc.clientCh, pc) + pc.clientWindow.close() + pc.input.close() + pc.requests.close() + pc.pendingRemoteReqs.drain() + _ = pc.remoteCh.Close() + // RFC 4254 requires answering a close this side did not initiate, and the + // client only frees its channel (and unblocks its readers) after receiving + // that answer. closeChannel also blocks every later packet for the channel. + ps.closeChannel(pc) + } +} + +// handleRequest queues a client-to-server channel request. The queue preserves +// wire order for both requests and their replies. +func (ps *proxySessionState) handleRequest(payload []byte) { + r := newMuxReader(payload) + serverChID, err := r.ReadUint32() + if err != nil { + return + } + reqType, err := r.ReadString() + if err != nil { + return + } + wantReply, err := r.ReadBool() + if err != nil { + return + } + var reqPayload []byte + if r.Remaining() > 0 { + reqPayload = r.data[r.off:] + } + + pcVal, ok := ps.channels.Load(serverChID) + if !ok { + return + } + pc := pcVal.(*proxyChan) + if pc.requests == nil { + return + } + pc.requests.push(proxyChannelRequest{requestType: reqType, wantReply: wantReply, payload: reqPayload}) +} + +// serveChannelRequests forwards queued channel requests in wire order and +// answers want-reply requests in the same order. +func (ps *proxySessionState) serveChannelRequests(pc *proxyChan) { + if pc.requests == nil { + return + } + defer close(pc.requestsDone) + for { + req, ok := pc.requests.pop() + if !ok { + return + } + accepted, err := pc.remoteCh.SendRequest(req.requestType, req.wantReply, req.payload) + if !req.wantReply { + continue + } + w := newMuxWriter() + if err == nil && accepted { + w.buf = append(w.buf, msgChannelSuccess) + } else { + w.buf = append(w.buf, msgChannelFailure) + } + w.WriteUint32(pc.clientCh) + if err := ps.writeChannelPacket(pc, w.Bytes()); err != nil { + return + } + } +} + +// handleGlobalRequest queues a client-to-server global request instead of +// answering it from a free-running goroutine. RFC 4254 global replies carry no +// request ID, so one ordered worker is required to keep request/reply pairing +// intact; concurrent goroutines would pair replies with the wrong request. +func (ps *proxySessionState) handleGlobalRequest(payload []byte) { + r := newMuxReader(payload) + reqType, err := r.ReadString() + if err != nil { + return + } + wantReply, err := r.ReadBool() + if err != nil { + return + } + var reqPayload []byte + if r.Remaining() > 0 { + reqPayload = slices.Clone(r.data[r.off:]) + } + + if ps.server.sshClient == nil || ps.global == nil { + return + } + ps.global.push(proxyGlobalRequest{requestType: reqType, wantReply: wantReply, payload: reqPayload}) +} + +// isAllowedProxyGlobalRequest restricts which global requests a proxy client +// may forward to the shared SSH transport. Session-mutating global requests +// such as no-more-sessions@openssh.com or cancel-tcpip-forward would affect +// other users of the master. +func isAllowedProxyGlobalRequest(reqType string) bool { + switch reqType { + case "keepalive@openssh.com", "hostkeys-prove-00@openssh.com": + return true + default: + return false + } +} + +// serveGlobalRequests forwards allowed queued global requests in wire order. +func (ps *proxySessionState) serveGlobalRequests() { + for { + req, ok := ps.global.pop() + if !ok { + return + } + var ( + accepted bool + replyPayload []byte + err error + ) + if isAllowedProxyGlobalRequest(req.requestType) { + accepted, replyPayload, err = ps.server.sshClient.SendRequest(req.requestType, req.wantReply, req.payload) + } + if !req.wantReply { + continue + } + w := newMuxWriter() + if err != nil || !accepted { + w.buf = append(w.buf, msgRequestFailure) + } else { + w.buf = append(w.buf, msgRequestSuccess) + if len(replyPayload) > 0 { + w.buf = append(w.buf, replyPayload...) + } + } + if werr := ps.writePacket(w.Bytes()); werr != nil { + return + } + } +} + +func readUnpaddedPacket(r io.Reader) ([]byte, error) { + var lenBuf [4]byte + if _, err := io.ReadFull(r, lenBuf[:]); err != nil { + return nil, err + } + pktLen := binary.BigEndian.Uint32(lenBuf[:]) + if pktLen == 0 || pktLen > MaxMuxPacketLength { + return nil, fmt.Errorf("invalid unpadded packet length: %d", pktLen) + } + + buf := make([]byte, pktLen) + if _, err := io.ReadFull(r, buf); err != nil { + return nil, err + } + // buf[0] is padding length (must be 0) + if buf[0] != 0 { + return nil, fmt.Errorf("unexpected padding length %d in unpadded packet", buf[0]) + } + return buf[1:], nil +} + +func writeUnpaddedPacket(w io.Writer, payload []byte) error { + totalLen := uint32(1 + len(payload)) + buf := make([]byte, 4+totalLen) + binary.BigEndian.PutUint32(buf[0:4], totalLen) + buf[4] = 0 // padding length = 0 + copy(buf[5:], payload) + _, err := w.Write(buf) + return err +} + +func (s *MuxServer) addActiveSession() bool { + s.idleMu.Lock() + defer s.idleMu.Unlock() + + if s.shuttingDown || s.closed.Load() { + return false + } + + s.activeSessions.Add(1) + s.idleGen++ + if s.idleTimer != nil { + s.idleTimer.Stop() + s.idleTimer = nil + } + return true +} + +func (s *MuxServer) addActiveForwardStream() bool { + s.idleMu.Lock() + defer s.idleMu.Unlock() + + if s.shuttingDown || s.closed.Load() { + return false + } + s.activeForwardStreams.Add(1) + s.idleGen++ + if s.idleTimer != nil { + s.idleTimer.Stop() + s.idleTimer = nil + } + return true +} + +func (s *MuxServer) removeActiveForwardStream() { + s.idleMu.Lock() + defer s.idleMu.Unlock() + + newStreams := s.activeForwardStreams.Add(-1) + s.notifyIdleLocked() + if newStreams <= 0 && s.activeSessions.Load() <= 0 { + s.scheduleIdleOrShutdownLocked() + } +} + +func (s *MuxServer) removeActiveSession() { + s.idleMu.Lock() + defer s.idleMu.Unlock() + + newCount := s.activeSessions.Add(-1) + s.notifyIdleLocked() + if newCount <= 0 && s.activeForwardStreams.Load() <= 0 { + s.scheduleIdleOrShutdownLocked() + } +} + +func (s *MuxServer) scheduleIdleOrShutdownLocked() { + if s.shuttingDown || s.closed.Load() { + return + } + + if s.stoppedListening.Load() { + s.shuttingDown = true + go func() { _ = s.Shutdown() }() + return + } + + if s.persistEnabled && !s.persistIndefinite && s.persistDuration > 0 { + if s.idleTimer != nil { + s.idleTimer.Stop() + } + s.idleGen++ + gen := s.idleGen + s.idleTimer = time.AfterFunc(s.persistDuration, func() { + s.onIdleTimeout(gen) + }) + } else if !s.persistEnabled { + s.shuttingDown = true + go func() { _ = s.Shutdown() }() + } +} + +func (s *MuxServer) onIdleTimeout(gen uint64) { + s.idleMu.Lock() + if s.idleGen != gen { + s.idleMu.Unlock() + return + } + + if s.activeSessions.Load() > 0 || s.activeForwardStreams.Load() > 0 { + s.idleMu.Unlock() + return + } + + if s.shuttingDown || s.closed.Load() { + s.idleMu.Unlock() + return + } + + s.shuttingDown = true + if s.idleTimer != nil { + s.idleTimer.Stop() + s.idleTimer = nil + } + s.idleMu.Unlock() + + _ = s.Shutdown() +} + +// Shutdown gracefully closes the server, listeners, active forwards, and SSH connections. +func (s *MuxServer) Shutdown() error { + s.shutdownOnce.Do(func() { + s.closed.Store(true) + + s.idleMu.Lock() + s.shuttingDown = true + s.idleGen++ + if s.idleTimer != nil { + s.idleTimer.Stop() + s.idleTimer = nil + } + s.idleMu.Unlock() + + if s.listener != nil { + _ = s.listener.Close() + } + s.unlinkControlSocketIfOurs() + + s.forwards.Range(func(key, val any) bool { + if closer, ok := val.(io.Closer); ok { + _ = closer.Close() + } + return true + }) + + s.liveStreams.Range(func(key, _ any) bool { + if conn, ok := key.(net.Conn); ok { + _ = conn.Close() + } + return true + }) + + if s.sshClient != nil && s.sshClient.Conn != nil { + _ = s.sshClient.Close() + } + if s.rootConn != nil { + _ = s.rootConn.Close() + } + + activeMuxServers.CompareAndDelete(s.controlPath, s) + if s.shutdownChan != nil { + close(s.shutdownChan) + } + }) + return nil +} + +// unlinkControlSocketIfOurs removes the control socket only when the path still +// refers to the socket this server created. After -O stop or a replacement, a +// newer master may own the same path and must not lose its listener entry. +func (s *MuxServer) unlinkControlSocketIfOurs() { + if s.stoppedListening.Load() { + return + } + s.unlinkControlSocketFileIfOurs() +} + +func (s *MuxServer) unlinkControlSocketFileIfOurs() { + if s.origFi != nil { + if current, err := os.Lstat(s.controlPath); err != nil || !os.SameFile(s.origFi, current) { + return + } + } + _ = os.Remove(s.controlPath) +} + +// AddForegroundSession adds an active in-process foreground session to prevent idle timeout. +func (s *MuxServer) AddForegroundSession() bool { + return s.addActiveSession() +} + +// RemoveForegroundSession removes an in-process foreground session and arms the idle timer if sessions reach 0. +func (s *MuxServer) RemoveForegroundSession() { + s.releaseForegroundOnStop.Store(false) + if s.foregroundReleasedOnStop.Swap(false) { + return + } + s.removeActiveSession() +} + +// ReleaseForegroundOnStop marks the master's initial foreground session +// reference (held by a -N process) to be dropped when -O stop arrives, so the +// master retires once any remaining client sessions drain. +func (s *MuxServer) ReleaseForegroundOnStop() { + if s == nil { + return + } + s.releaseForegroundOnStop.Store(true) +} + +// ActiveSessions returns the current number of active multiplex sessions. +func (s *MuxServer) ActiveSessions() int32 { + return s.activeSessions.Load() +} + +// Wait returns a channel that is closed when the server shuts down. +func (s *MuxServer) Wait() <-chan struct{} { + return s.shutdownChan +} + +// ControlPath returns the active control socket path. +func (s *MuxServer) ControlPath() string { + return s.controlPath +} + +// Helper functions for sending MUX server responses. + +func sendOk(conn io.Writer, reqID uint32) error { + return WriteMuxPacket(conn, MUX_S_OK, EncodeMuxClientRequestID(reqID)) +} + +func sendFailure(conn io.Writer, reqID uint32, reason string) error { + return WriteMuxPacket(conn, MUX_S_FAILURE, EncodeMuxError(reqID, reason)) +} + +func sendPermissionDenied(conn io.Writer, reqID uint32, reason string) error { + return WriteMuxPacket(conn, MUX_S_PERMISSION_DENIED, EncodeMuxError(reqID, reason)) +} + +func sendAlive(conn io.Writer, reqID uint32, pid uint32) error { + return WriteMuxPacket(conn, MUX_S_ALIVE, EncodeMuxAlive(reqID, pid)) +} + +func sendProxy(conn io.Writer, reqID uint32) error { + return WriteMuxPacket(conn, MUX_S_PROXY, EncodeMuxClientRequestID(reqID)) +} + +func sendRemotePort(conn io.Writer, reqID uint32, port uint32) error { + return WriteMuxPacket(conn, MUX_S_REMOTE_PORT, EncodeMuxRemotePort(reqID, port)) +} + +func sendSessionOpened(conn io.Writer, reqID uint32, sessionID uint32) error { + return WriteMuxPacket(conn, MUX_S_SESSION_OPENED, EncodeMuxSessionOpened(reqID, sessionID)) +} + +func sendExitMessage(conn io.Writer, sessionID uint32, exitValue uint32) error { + return WriteMuxPacket(conn, MUX_S_EXIT_MESSAGE, EncodeMuxExitMessage(sessionID, exitValue)) +} + +func sendTTYAllocFail(conn io.Writer, sessionID uint32) error { + return WriteMuxPacket(conn, MUX_S_TTY_ALLOC_FAIL, EncodeMuxTTYAllocFail(sessionID)) +} diff --git a/core/ssh/mux_server_test.go b/core/ssh/mux_server_test.go new file mode 100644 index 00000000..b4730f22 --- /dev/null +++ b/core/ssh/mux_server_test.go @@ -0,0 +1,620 @@ +package ssh + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + cryptoSSH "golang.org/x/crypto/ssh" +) + +type dummySSHConn struct { + chans chan cryptoSSH.NewChannel + reqs chan *cryptoSSH.Request + closed bool + mu sync.Mutex +} + +func (d *dummySSHConn) User() string { return "test" } +func (d *dummySSHConn) SessionID() []byte { return []byte("test") } +func (d *dummySSHConn) ClientVersion() []byte { return []byte("test") } +func (d *dummySSHConn) ServerVersion() []byte { return []byte("test") } +func (d *dummySSHConn) RemoteAddr() net.Addr { return &net.IPAddr{} } +func (d *dummySSHConn) LocalAddr() net.Addr { return &net.IPAddr{} } +func (d *dummySSHConn) SendRequest(string, bool, []byte) (bool, []byte, error) { return true, nil, nil } +func (d *dummySSHConn) OpenChannel(string, []byte) (cryptoSSH.Channel, <-chan *cryptoSSH.Request, error) { + return nil, nil, errors.New("open channel not implemented in dummy") +} +func (d *dummySSHConn) Close() error { + d.mu.Lock() + defer d.mu.Unlock() + if !d.closed { + d.closed = true + close(d.chans) + close(d.reqs) + } + return nil +} +func (d *dummySSHConn) Wait() error { return nil } + +func newDummySSHClient() *cryptoSSH.Client { + chans := make(chan cryptoSSH.NewChannel) + reqs := make(chan *cryptoSSH.Request) + conn := &dummySSHConn{chans: chans, reqs: reqs} + return cryptoSSH.NewClient(conn, chans, reqs) +} + +func TestMuxServer_SocketCreation_PermissionsAndLink(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "mux-server-test-*") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + sockPath := filepath.Join(tmpDir, "master.sock") + opts := MuxServerOptions{ + ControlPath: sockPath, + SSHClient: newDummySSHClient(), + } + + server, err := NewMuxServer(opts) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + defer func() { _ = server.Shutdown() }() + + // Check file exists + fi, err := os.Stat(sockPath) + if err != nil { + t.Fatalf("socket file does not exist: %v", err) + } + + if runtime.GOOS != "windows" { + // Verify permissions 0600 + perm := fi.Mode().Perm() + if perm != 0600 { + t.Fatalf("expected permissions 0600, got %o", perm) + } + } + + // Creating a second server on the same path must return ErrMasterAlreadyExists + _, err = NewMuxServer(opts) + if err == nil || !errors.Is(err, ErrMasterAlreadyExists) { + t.Fatalf("expected ErrMasterAlreadyExists, got: %v", err) + } +} + +func TestMuxServer_AliveCheck(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "mux-server-test-*") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + sockPath := filepath.Join(tmpDir, "alive.sock") + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: sockPath, + SSHClient: newDummySSHClient(), + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + defer func() { _ = server.Shutdown() }() + + go func() { _ = server.Serve() }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + client, err := DialMuxClient(ctx, sockPath) + if err != nil { + t.Fatalf("DialMuxClient failed: %v", err) + } + defer func() { _ = client.Close() }() + + pid, err := client.AliveCheck(ctx) + if err != nil { + t.Fatalf("AliveCheck failed: %v", err) + } + if int(pid) != os.Getpid() { + t.Fatalf("expected pid %d, got %d", os.Getpid(), pid) + } +} + +func TestMuxServer_PeerUID_Verification(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Peer UID verification not applicable on Windows") + } + + tmpDir, err := os.MkdirTemp("", "mux-server-test-*") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + sockPath := filepath.Join(tmpDir, "peeruid.sock") + + // 1. Matching UID + currentUID := uint32(os.Getuid()) + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: sockPath, + ExpectedUID: currentUID, + CheckPeerUID: true, + SSHClient: newDummySSHClient(), + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + go func() { _ = server.Serve() }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + client, err := DialMuxClient(ctx, sockPath) + if err != nil { + t.Fatalf("DialMuxClient failed with matching UID: %v", err) + } + if _, err := client.AliveCheck(ctx); err != nil { + t.Fatalf("AliveCheck failed with matching UID: %v", err) + } + _ = client.Close() + _ = server.Shutdown() + + // 2. Mismatched UID (currentUID + 1) + mismatchPath := filepath.Join(tmpDir, "mismatch.sock") + serverMismatch, err := NewMuxServer(MuxServerOptions{ + ControlPath: mismatchPath, + ExpectedUID: currentUID + 9999, + CheckPeerUID: true, + SSHClient: newDummySSHClient(), + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + defer func() { _ = serverMismatch.Shutdown() }() + go func() { _ = serverMismatch.Serve() }() + + _, err = DialMuxClient(ctx, mismatchPath) + if err == nil { + t.Fatal("expected connection to fail with mismatched UID, got nil error") + } +} + +func TestMuxServer_Terminate(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "mux-server-test-*") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + sockPath := filepath.Join(tmpDir, "term.sock") + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: sockPath, + SSHClient: newDummySSHClient(), + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + + go func() { _ = server.Serve() }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + client, err := DialMuxClient(ctx, sockPath) + if err != nil { + t.Fatalf("DialMuxClient failed: %v", err) + } + defer func() { _ = client.Close() }() + + if err := client.Terminate(ctx); err != nil { + t.Fatalf("Terminate failed: %v", err) + } + + // Server shutdown channel should close + select { + case <-server.Wait(): + // OK + case <-time.After(1 * time.Second): + t.Fatal("timeout waiting for server shutdown") + } + + // Socket file should be removed + if socketPathExists(sockPath) { + t.Fatalf("expected socket %q to be unlinked after termination", sockPath) + } +} + +func TestMuxServer_StopListening(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "mux-server-test-*") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + sockPath := filepath.Join(tmpDir, "stop.sock") + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: sockPath, + SSHClient: newDummySSHClient(), + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + defer func() { _ = server.Shutdown() }() + + go func() { _ = server.Serve() }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + client, err := DialMuxClient(ctx, sockPath) + if err != nil { + t.Fatalf("DialMuxClient failed: %v", err) + } + defer func() { _ = client.Close() }() + + if err := client.StopListening(ctx); err != nil { + t.Fatalf("StopListening failed: %v", err) + } + + // Socket file should be unlinked + time.Sleep(50 * time.Millisecond) + if socketPathExists(sockPath) { + t.Fatalf("expected socket %q to be unlinked after stop listening", sockPath) + } +} + +func TestMuxServer_ControlPersist_IdleCountdown(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "mux-server-test-*") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + sockPath := filepath.Join(tmpDir, "persist.sock") + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: sockPath, + PersistEnabled: true, + PersistDuration: 100 * time.Millisecond, + SSHClient: newDummySSHClient(), + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + + go func() { _ = server.Serve() }() + + // Server should shut down after 100ms idle + select { + case <-server.Wait(): + // OK + case <-time.After(1 * time.Second): + t.Fatal("server did not shut down on idle timeout") + } + + if socketPathExists(sockPath) { + t.Fatalf("expected socket %q to be unlinked after idle shutdown", sockPath) + } +} + +func TestMuxServer_LongSocketPaths(t *testing.T) { + maxLen := MaxControlPathLength() + tmpDir, err := os.MkdirTemp("", "m1-longpath-*") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + mockClient := newDummySSHClient() + + // Test boundary lengths up to maxLen - 1 + lengthsToTest := []int{50, 70, 75, 80, 85, 90, 95, 100, maxLen - 1} + + for _, targetLen := range lengthsToTest { + if targetLen >= maxLen { + continue + } + prefix := filepath.Join(tmpDir, "s_") + if len(prefix) >= targetLen { + continue + } + sockName := prefix + strings.Repeat("x", targetLen-len(prefix)) + if len(sockName) != targetLen { + continue + } + + if err := ValidateControlPath(sockName); err != nil { + t.Fatalf("ValidateControlPath rejected valid length %d: %v", targetLen, err) + } + + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: sockName, + SSHClient: mockClient, + }) + if err != nil { + t.Fatalf("NewMuxServer failed for valid length %d: %v", targetLen, err) + } + + fi, err := os.Stat(sockName) + if err != nil { + _ = server.Shutdown() + t.Fatalf("socket does not exist for length %d: %v", targetLen, err) + } + if runtime.GOOS != "windows" && fi.Mode().Perm() != 0600 { + _ = server.Shutdown() + t.Fatalf("expected perm 0600 for length %d, got %o", targetLen, fi.Mode().Perm()) + } + + _ = server.Shutdown() + } +} + +func TestMuxServer_UmaskIsolationConcurrent(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "m1-umask-iso-*") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + mockClient := newDummySSHClient() + stopChan := make(chan struct{}) + var wg sync.WaitGroup + + // Goroutine 1: Continuously create temporary directories and verify +x execute permission + var dirPermErr atomic.Pointer[error] + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stopChan: + return + default: + d, err := os.MkdirTemp(tmpDir, "check-perm-*") + if err != nil { + e := fmt.Errorf("MkdirTemp failed: %w", err) + dirPermErr.Store(&e) + return + } + fi, err := os.Stat(d) + if err != nil { + e := fmt.Errorf("stat failed: %w", err) + dirPermErr.Store(&e) + return + } + // Verify execute permission (0100) is never stripped + if fi.Mode().Perm()&0100 == 0 { + e := fmt.Errorf("CRITICAL: directory %s lost execute permission: %o", d, fi.Mode().Perm()) + dirPermErr.Store(&e) + return + } + _ = os.Remove(d) + } + } + }() + + // Goroutine 2: Repeatedly start and stop MuxServers + for i := 0; i < 50; i++ { + sockPath := filepath.Join(tmpDir, fmt.Sprintf("server_%d.sock", i)) + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: sockPath, + SSHClient: mockClient, + }) + if err != nil { + t.Fatalf("NewMuxServer iteration %d failed: %v", i, err) + } + _ = server.Shutdown() + } + + close(stopChan) + wg.Wait() + + if errPtr := dirPermErr.Load(); errPtr != nil { + t.Fatalf("Umask isolation failure: %v", *errPtr) + } +} + +func TestMuxServer_ExtInfo(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "mux-extinfo-*") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + sockPath := filepath.Join(tmpDir, "info.sock") + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: sockPath, + SSHClient: newDummySSHClient(), + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + defer func() { _ = server.Shutdown() }() + + go func() { _ = server.Serve() }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + client, err := DialMuxClient(ctx, sockPath) + if err != nil { + t.Fatalf("DialMuxClient failed: %v", err) + } + defer func() { _ = client.Close() }() + + connInfo, err := client.RequestInfo(ctx, "connection") + if err != nil { + t.Fatalf("RequestInfo(connection) failed: %v", err) + } + if !strings.Contains(connInfo, "OpenSSH multiplexing control connection") { + t.Fatalf("unexpected connection info: %s", connInfo) + } + + chanInfo, err := client.RequestInfo(ctx, "channels") + if err != nil { + t.Fatalf("RequestInfo(channels) failed: %v", err) + } + if !strings.Contains(chanInfo, "Active Forwardings:") { + t.Fatalf("unexpected channels info: %s", chanInfo) + } +} + +func TestMuxServer_ForegroundSession(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "mux-foreground-*") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + sockPath := filepath.Join(tmpDir, "fg.sock") + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: sockPath, + SSHClient: newDummySSHClient(), + PersistEnabled: true, + PersistDuration: 100 * time.Millisecond, + InitialActiveSessions: 1, + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + defer func() { _ = server.Shutdown() }() + + if server.ActiveSessions() != 1 { + t.Fatalf("expected 1 active session, got %d", server.ActiveSessions()) + } + + // Should not shut down because active session is 1 + time.Sleep(150 * time.Millisecond) + select { + case <-server.Wait(): + t.Fatal("server shut down prematurely while foreground session active") + default: + } + + // Remove foreground session + server.RemoveForegroundSession() + if server.ActiveSessions() != 0 { + t.Fatalf("expected 0 active sessions, got %d", server.ActiveSessions()) + } + + // Now it should shut down after persistDuration + select { + case <-server.Wait(): + // OK + case <-time.After(500 * time.Millisecond): + t.Fatal("server failed to shut down after foreground session removed") + } +} + +func TestMuxServer_MalformedRequestPreservesRequestID(t *testing.T) { + sockPath := filepath.Join(t.TempDir(), "malformed.sock") + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: sockPath, + SSHClient: newDummySSHClient(), + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + defer func() { _ = server.Shutdown() }() + go func() { _ = server.Serve() }() + + conn, err := net.Dial("unix", sockPath) + if err != nil { + t.Fatalf("Dial failed: %v", err) + } + defer func() { _ = conn.Close() }() + + if err := WriteMuxPacket(conn, MUX_MSG_HELLO, EncodeMuxHello(MuxProtoVersion, nil)); err != nil { + t.Fatalf("send hello failed: %v", err) + } + if _, _, err := ReadMuxPacket(conn); err != nil { + t.Fatalf("read hello failed: %v", err) + } + + // Send a truncated MUX_C_NEW_SESSION containing only request_id = 42. + const wantReqID uint32 = 42 + if err := WriteMuxPacket(conn, MUX_C_NEW_SESSION, EncodeMuxRequestID(wantReqID)); err != nil { + t.Fatalf("send truncated new session failed: %v", err) + } + msgType, payload, err := ReadMuxPacket(conn) + if err != nil { + t.Fatalf("read failure response failed: %v", err) + } + if msgType != MUX_S_FAILURE { + t.Fatalf("expected MUX_S_FAILURE (0x%08x), got 0x%08x", MUX_S_FAILURE, msgType) + } + gotReqID, reason, err := DecodeMuxError(payload) + if err != nil { + t.Fatalf("DecodeMuxError failed: %v", err) + } + if gotReqID != wantReqID { + t.Fatalf("expected failure request_id=%d, got %d (reason=%q)", wantReqID, gotReqID, reason) + } +} + +func TestMuxServer_ForwardKeyNormalizesEmptyListenHost(t *testing.T) { + sockPath := filepath.Join(t.TempDir(), "fwd-norm.sock") + server, err := NewMuxServer(MuxServerOptions{ + ControlPath: sockPath, + SSHClient: newDummySSHClient(), + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + defer func() { _ = server.Shutdown() }() + go func() { _ = server.Serve() }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + client, err := DialMuxClient(ctx, sockPath) + if err != nil { + t.Fatalf("DialMuxClient failed: %v", err) + } + defer func() { _ = client.Close() }() + + // Pick an ephemeral local port + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Listen failed: %v", err) + } + freePort := uint32(ln.Addr().(*net.TCPAddr).Port) + _ = ln.Close() + + // Open with empty ListenHost (simulating native `ssh -O forward -L port:host:port`) + fwdReq := ForwardRequest{ + Type: ForwardLocal, + ListenHost: "", + ListenPort: freePort, + ConnectHost: "127.0.0.1", + ConnectPort: 80, + } + if _, err := client.OpenForward(ctx, fwdReq); err != nil { + t.Fatalf("OpenForward failed: %v", err) + } + + channelsInfo, err := client.RequestInfo(ctx, "channels") + if err != nil { + t.Fatalf("RequestInfo(channels) failed: %v", err) + } + expectedEntry := fmt.Sprintf("1:127.0.0.1:%d:127.0.0.1:80", freePort) + if !strings.Contains(channelsInfo, expectedEntry) { + t.Fatalf("expected normalized forward entry %q in channels info, got:\n%s", expectedEntry, channelsInfo) + } + + // Cancel with explicit 127.0.0.1 ListenHost must match the normalized key + cancelReq := fwdReq + cancelReq.ListenHost = "127.0.0.1" + if err := client.CloseForward(ctx, cancelReq); err != nil { + t.Fatalf("CloseForward with normalized host failed: %v", err) + } +} diff --git a/core/ssh/plan.go b/core/ssh/plan.go index b29d88dc..7280c098 100644 --- a/core/ssh/plan.go +++ b/core/ssh/plan.go @@ -183,6 +183,12 @@ func (c *Connector) acquirePlan(key string, plan ConnectionPlan) (*planEntry, er for _, hop := range plan.Hops { provider.configs[hop.NodeID] = clientConfigFromPlan(hop) } + // Connection sharing is deliberately not inherited here: the capture is a + // frozen generation keyed by scope, hops and trust versions, while a + // control socket is local state addressed only by %l%h%p%r%j. Attaching a + // plan to an existing master would reuse a transport admitted under another + // scope, credential version or host-key decision, and it would bypass + // RetirePlan. Plans therefore always dial their own transport. connector := NewConnector(provider, WithEnvironment(c.environment), WithLogger(c.getLogger()), WithDialer(c.baseDialer), WithHandshakeTimeout(c.getHandshakeTimeout()), WithInteractionTimeout(c.interactionTimeout), diff --git a/core/ssh/sftp_stream.go b/core/ssh/sftp_stream.go index 21d07b6d..6913078a 100644 --- a/core/ssh/sftp_stream.go +++ b/core/ssh/sftp_stream.go @@ -210,7 +210,7 @@ func (c *Client) openSubsystemSFTPStream(ctx context.Context) (*SFTPStream, erro session, err := c.newSessionContext(setupCtx) if err != nil { - return nil, fmt.Errorf("create SSH session failed: %w", err) + return nil, err } stdin, err := session.StdinPipe() diff --git a/core/ssh/types_api.go b/core/ssh/types_api.go index 6b7fe899..416cb13f 100644 --- a/core/ssh/types_api.go +++ b/core/ssh/types_api.go @@ -255,9 +255,17 @@ func validateTargetCompatibility(cur ConnectionConfig, newCfg *ClientConfig) err return fmt.Errorf("%w: host address changed (expected %q, got %q)", ErrSnapshotMismatch, cur.Address, newCfg.Address) } - if newCfg.Port != cur.Port { + curPort := cur.Port + if curPort <= 0 { + curPort = 22 + } + newPort := newCfg.Port + if newPort <= 0 { + newPort = 22 + } + if newPort != curPort { return fmt.Errorf("%w: host port changed (expected %d, got %d)", - ErrSnapshotMismatch, cur.Port, newCfg.Port) + ErrSnapshotMismatch, curPort, newPort) } if newCfg.User != cur.User { return fmt.Errorf("%w: user changed (expected %q, got %q)", diff --git a/docs/development/governance/architecture-map.md b/docs/development/governance/architecture-map.md index 52de89e8..6b4fecc1 100644 --- a/docs/development/governance/architecture-map.md +++ b/docs/development/governance/architecture-map.md @@ -12,7 +12,10 @@ XOps 是 Go 编写的运维 CLI,覆盖主机/身份管理、SSH/SFTP/SCP、批 flowchart TD E[cmd/cli/main.go] --> R[cmd/root.go / Cobra] R --> C[cmd/* SSH/exec/SCP/play/host/MCP] - C --> A[pkg/adapter] + C --> CTL[cmd/ssh_control.go + cmd/ssh_option_flag.go] + CTL --> A[pkg/adapter] + CTL --> SSH[core/ssh] + C --> A C --> P[pkg/playbook + pkg/tui] C --> CFG[pkg/config Repository + Provider] C --> CM[cmd/mcp.go] @@ -24,28 +27,32 @@ flowchart TD MR --> G[core/mcp/guardrail + state + transfer + tunnel] G --> PORTS B --> PORTS - B --> SSH[core/ssh] + B --> SSH B --> SFTP[core/sftp] A --> SSH P --> SSH CFG --> CR[pkg/credential + internal/credentialfile] SFTP --> SSH + ENV[internal/sshenv] --> SSH ``` -**注意**:上图同时展示组合关系与包依赖方向(如 `core/mcp/runtime`、`core/mcp/sshexec`、`internal/mcphost` 均单向依赖契约包 `core/mcp/ports`;`core/sftp` 依赖 `core/ssh`)。要生成精确 Go import 图可使用 `go list -json ./...` 核对。 +**注意**:上图同时展示组合关系与包依赖方向(如 `core/mcp/runtime`、`core/mcp/sshexec`、`internal/mcphost` 均单向依赖契约包 `core/mcp/ports`;`core/sftp` 依赖 `core/ssh`;`core/ssh` 保持纯净单向解耦,由 `internal/sshenv` 注入宿主环境)。要生成精确 Go import 图可使用 `go list -json ./...` 核对。 ## 推荐按以下次序理解 -1. **程序启动 / 模块组装**:`cmd/cli/main.go:main` -> `cmd/root.go:Execute` -> `cmd/{ssh,exec,mcp,play,credential}.go`。 -2. **配置与凭据状态**:`pkg/config/{provider,repository,store,target,encrypted_runtime}.go`; `pkg/credential/service.go`; `internal/credentialfile`。 +1. **程序启动 / 模块组装**:`cmd/cli/main.go:main` -> `cmd/root.go:Execute` -> `cmd/{ssh,exec,mcp,play,credential,ssh_control,ssh_option_flag}.go`。 +2. **配置与凭据状态**:`pkg/config/{provider,repository,store,target,encrypted_runtime,schema_v2,validate,openssh}.go`; `pkg/credential/service.go`; `internal/credentialfile`。 3. **连接/命令语义**:`pkg/adapter/ssh_adapter.go` -> `core/ssh/{connector,plan,command_plan,command_execution,execution_config,probe}.go`。 -4. **MCP 分层**:`cmd/mcp.go:runMCPServer` -> `internal/mcphost/{config,host}.go` -> `core/mcp/runtime/{server,invocation}.go` -> `core/mcp/ports/types.go`, `core/mcp/sshexec/backend.go`, `core/mcp/guardrail/*`(注:CLI 模式下 `internal/mcphost.host` 直接实现 `StateSource` 与 `ExecutionGate`;动态发布宿主使用 `core/mcp/state.Coordinator`)。 -5. **批量执行**:`cmd/exec.go`、`cmd/exec_execution.go` 和 `pkg/playbook/{engine,runner}.go`。 -6. **交互**:`pkg/tui/*`、`cmd/sftpshell/*`、`internal/terminal/*`。 +4. **OpenSSH 连接多路复用**:`cmd/{ssh_control,ssh_option_flag}.go` -> `pkg/adapter/ssh_adapter.go:ResolveControl`(4 层解析继承体系:CLI > Node > Global > OpenSSH)-> `core/ssh/{control_config,mux_protocol,mux_client,mux_proxy_conn,mux_server,mux_rights_unix,connector}.go`(`PROTOCOL.mux` v4 二进制线缆协议、0600 套接字原子硬链接防御、对端 Peer UID 校验、`ControlPersist` 后台守护与优雅退出)。 +5. **MCP 分层**:`cmd/mcp.go:runMCPServer` -> `internal/mcphost/{config,host}.go` -> `core/mcp/runtime/{server,invocation}.go` -> `core/mcp/ports/types.go`, `core/mcp/sshexec/backend.go`, `core/mcp/guardrail/*`(注:CLI 模式下 `internal/mcphost.host` 直接实现 `StateSource` 与 `ExecutionGate`;动态发布宿主使用 `core/mcp/state.Coordinator`)。 +6. **批量执行与编排**:`cmd/exec.go`、`cmd/exec_execution.go` 和 `pkg/playbook/{engine,runner}.go`(支持跨任务步骤透明复用 ControlPath 套接字)。 +7. **交互**:`pkg/tui/*`、`cmd/sftpshell/*`、`internal/terminal/*`。 ## “先读索引,再查源码” - 源码边界最权威:`core/README.md`、`docs/development/shared-core-decoupling.md`。 +- OpenSSH 连接多路复用:`docs/development/openssh-multiplexing-design.md`。 +- SSH OTP 与多因素认证:`docs/development/ssh-otp-keyboard-interactive-design.md`。 - SSH 解释器演进:`docs/development/ssh-execution-compatibility.md`、`ssh-execution-implementation.md`、`ssh-execution-probe-{design,implementation}.md`。 - MCP 传输:`docs/development/mcp-http-transfer-design.md`、`docs/guide/mcp-http.md`。 - 离线库兼容性:`docs/development/compatibility.md`、`docs/development/credential-experience-design.md`。 diff --git a/docs/development/index.md b/docs/development/index.md index 10da2956..74e9e5f8 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -26,6 +26,7 @@ golangci-lint run ./... 实现状态与验证范围以各文档说明为准。 - [代码架构与治理地图](./governance/architecture-map.md) +- [OpenSSH 连接多路复用设计方案](./openssh-multiplexing-design.md) - [SSH 多解释器执行与兼容性设计](./ssh-execution-compatibility.md) - [SSH 执行兼容性实施计划](./ssh-execution-implementation.md) - [SSH 解释器与启动方言自动探测设计](./ssh-execution-probe-design.md) diff --git a/docs/development/openssh-multiplexing-design.md b/docs/development/openssh-multiplexing-design.md new file mode 100644 index 00000000..692ed336 --- /dev/null +++ b/docs/development/openssh-multiplexing-design.md @@ -0,0 +1,550 @@ +# OpenSSH 连接多路复用 (-M / -S / -O & ControlMaster / ControlPath / ControlPersist) 永久设计规范 + +**文档状态**:已实施并验证通过(M1–M4 全量完成) +**依据文档**:[代码架构与治理地图](./governance/architecture-map.md)、[公共代码与 MCP 接口解耦设计](./shared-core-decoupling.md)、[SSH 诊断设计](./ssh-diagnostics.md) +**语言版本**:中文开发规范(English version: [openssh-multiplexing-design.md](../en/development/openssh-multiplexing-design.md)) + +--- + +## 1. 背景与目标 + +### 1.1 背景与业务痛点 + +在分布式系统管理、批量执行、自动化运维与 Playbook 编排场景中,CLI 工具频繁对同一批目标节点发起远程连接。传统的 SSH 客户端每次建立连接都需要执行完整的网络握手、TCP 建立、SSH 版本交换、密钥交换(KEX)、服务端主机密钥验证、用户身份认证(公钥/密码/MFA/OTP 挑战)以及会话通道协商。在跨地域网络、跳板机(Bastion/Jump Host)或开启多因素认证的环境下,单个会话的握手耗时通常在数百毫秒至数秒之间。 + +OpenSSH 原生通过连接多路复用(Connection Multiplexing,基于 `-M`、`-S`、`-O` 及 `ControlMaster`、`ControlPath`、`ControlPersist` 配置指令)解决了这一痛点:首个会话作为 Master 建立主传输连接并在本地监听 Unix 域套接字(或 Windows 10+ 的 `AF_UNIX` 套接字);后续客户端通过该套接字与 Master 进程通信,直接复用已认证的 SSH 传输通道,实现“零额外网络握手、零二次认证、毫秒级会话建立”。 + +为了与现代开发运维工具链(包括 Ansible、系统原生 `/usr/bin/ssh`、内部运维平台)深度互操作,`xops-cli` 必须提供与 OpenSSH 完全兼容的连接复用能力,同时保证自身的架构分层与核心解耦规范不受破坏。 + +### 1.2 核心设计原则 + +1. **严格兼容 OpenSSH Wire 协议 (`PROTOCOL.mux` v4)**: + 通信协议层严格遵循 OpenSSH 官方规范,二进制帧格式、字段大端序编码、操作码定义(`MUX_MSG_HELLO`、`MUX_C_*`、`MUX_S_*`)完全一致,具备与系统原生 OpenSSH 共享控制套接字的能力。 +2. **上层功能零降级 (`MUX_C_PROXY`)**: + 复用连接通过 `MUX_C_PROXY` 协议将本地套接字流平滑切换为 SSH 传输模式,经由 `muxProxySSHConn` 包装为标准的 `golang.org/x/crypto/ssh.Conn` 并构造 `*cryptoSSH.Client`,使得交互式 PTY 终端、SFTP 传输、批量远程执行、sudo/su 探测缓存等所有既有能力 100% 保持原生行为,无任何功能降级或分支特化。 +3. **严格的 `core/ssh` 单向解耦不变量**: + 复用核心协议与引擎完全驻留在 `core/ssh` 中,绝不依赖上层 CLI、配置模型或内部辅助包(通过 `scripts/check_core.py --race` 验证)。宿主环境信息(用户名、UID、主机名、家目录)统一通过 `core.Environment` 显式注入。 +4. **纵深防御的安全模型**: + Unix 域套接字必须严格限制权限(0600)、防范硬链接竞争(link(2) 原子创建)、校验对端真实身份(Peer UID 校验,Linux `SO_PEERCRED` / Darwin `LOCAL_PEERCRED`),阻断多用户环境下的未授权劫持或提权攻击。 +5. **清晰的 4 层配置继承体系**: + 会话级命令行参数(CLI Flags) > 节点级配置(Node Config) > 全局默认配置(Global Defaults) > OpenSSH 系统配置(`~/.ssh/config`),支持细粒度覆盖与单次显式禁用(`-S none`)。 + +### 1.3 目标与非目标 + +| 纳入范围 (Goals) | 不包含的承诺 (Non-Goals) | +| :--- | :--- | +| OpenSSH `PROTOCOL.mux` 版本 4 二进制协议编解码 | 兼容历史已被废弃的 Version 1–3 废弃协议 | +| `xops ssh` 完整支持 `-M`(单 master)、`-M -M`(确认 master)、`-S`、`-o`、`-O` | 在未安装原生 `AF_UNIX` 的古董 Windows(Win10 1803 之前)上提供模拟套接字 | +| `-O` 控制指令支持:`check`、`exit`、`stop`、`forward`、`cancel`、`proxy`、`conninfo`、`channels` | 支持无需 Master 存在的无状态控制指令 | +| `ControlMaster` 模式支持:`no`、`yes`、`ask`、`auto`、`autoask` | 绕过本地系统文件权限体系创建全局可写的公开控制套接字 | +| `ControlPath` 令牌展开:`%%`, `%h`, `%p`, `%r`, `%n`, `%u`, `%l`, `%L`, `%i`, `%d`, `%j`, `%C`(SHA-1 哈希)及 `~`、`${ENV}` | 允许未经校验的任意超长路径(突破 OS `sockaddr_un` 108/104 字节上限) | +| `ControlPersist` 支持:`no`、`yes`/`0`(永久驻留)、时间格式(`10m`, `1h`, `600s`, `1d`, `1w` 等)及空闲超时自动退出 | 保证不受操作系统 OOM 杀进程或宿主意外断电影响的套接字存续 | +| `scp`、`sftp`、`exec`、`play` 自动感知并透明复用已激活的 `ControlPath` 套接字 | 修改远程服务端 SSH 协议本身(所有复用均在客户端与本地 Master 之间协商) | +| 跨平台支持:Linux、macOS、BSD、Windows 10+(通过 `MUX_C_PROXY` 原生免 fd 传递) | 在非 Unix 平台支持 Unix 特有的 `SCM_RIGHTS` 文件描述符传递 | + +--- + +## 2. 总体架构与数据流 + +### 2.1 模块边界与分层架构 + +整体架构严格遵守 `AGENTS.md` 规定的单向依赖关系与边界划分: + +```mermaid +flowchart TD + subgraph CLI["cmd/ (CLI Layer)"] + SSHCmd["cmd/ssh.go\n(-M, -S, -o, -O)"] + OtherCmds["cmd/{scp, sftp, exec}.go\n(-S, -o, Auto-Reuse)"] + Playbook["pkg/playbook/\n(Step Reuse)"] + SSHControl["cmd/ssh_control.go\n(ExecuteControlCommand, Exit Codes)"] + SSHFlag["cmd/ssh_option_flag.go\n(SSHControlFlags, -o Parsing)"] + end + + subgraph ConfigAdapter["pkg/adapter/ & pkg/config/ (Resolution & Configuration)"] + Adapter["pkg/adapter/ssh_adapter.go\n(ResolveControl: 4-Tier Precedence)"] + CfgRepo["pkg/config/repository.go\n(Configuration Snapshot)"] + OpenSSHParser["pkg/config/openssh.go\n(Parse ControlMaster/Path/Persist)"] + Models["pkg/models/types.go & pkg/config/schema_v2.go\n(Node.Control, Schema V2 CAS Hash)"] + end + + subgraph EnvDiscovery["internal/sshenv/ (Host Context)"] + SSHEIDiscover["internal/sshenv/environment.go\n(Discover HomeDir, LocalUser, LocalHost, LocalUID)"] + end + + subgraph CoreSSH["core/ssh/ (Pure Protocol & Socket Engine - Zero CLI Imports)"] + Connector["core/ssh/connector.go\n(tryConnectMultiplex, dialAndPublishProxy)"] + CtrlCfg["core/ssh/control_config.go\n(ExpandControlPath, ParseControlPersist, %C)"] + MuxProto["core/ssh/mux_protocol.go\n(PROTOCOL.mux v4 Framing & Codecs)"] + MuxClient["core/ssh/mux_client.go\n(DialMuxClient, AliveCheck, Proxy, Forward)"] + MuxProxy["core/ssh/mux_proxy_conn.go\n(muxProxySSHConn -> cryptoSSH.Conn)"] + MuxServer["core/ssh/mux_server.go\n(MuxServer, IdleTimer, PeerUID, Dispatcher)"] + MuxUnix["core/ssh/mux_rights_unix.go\n(bindAndLinkSocket, SCM_RIGHTS, SO_PEERCRED)"] + MuxOther["core/ssh/mux_rights_other.go\n(Windows AF_UNIX Direct Bind)"] + end + + SSHCmd --> SSHFlag + SSHCmd --> SSHControl + OtherCmds --> SSHFlag + Playbook --> Adapter + SSHControl --> Adapter + SSHControl --> MuxClient + SSHCmd --> Adapter + OtherCmds --> Adapter + Adapter --> CfgRepo + Adapter --> OpenSSHParser + Adapter --> CtrlCfg + CfgRepo --> Models + OpenSSHParser --> Models + EnvDiscovery --> CoreSSH + Adapter --> Connector + Connector --> CtrlCfg + Connector --> MuxClient + Connector --> MuxProxy + Connector --> MuxServer + MuxServer --> MuxProto + MuxServer --> MuxUnix + MuxServer --> MuxOther + MuxClient --> MuxProto + MuxClient --> MuxProxy +``` + +### 2.2 连接复用与握手时序 + +下图展示了首个客户端创建 Master 以及后续客户端复用连接的完整生命周期: + +```mermaid +sequenceDiagram + autonumber + participant C1 as Client 1 (Foreground / Master) + participant Socket as Local Unix Socket (ControlPath) + participant Srv as MuxServer (Background / Master Thread) + participant C2 as Client 2 (Passenger / Proxy Client) + participant Remote as Remote SSH Server + + Note over C1,Remote: 阶段一:首个连接启动 Master + C1->>Remote: TCP 握手 + 密钥交换 + 用户认证 (耗时 ~500ms) + Remote-->>C1: SSH 传输连接就绪 (*cryptoSSH.Client) + C1->>Socket: 绑定临时套接字并原子硬链接为 ControlPath (权限 0600) + C1->>Srv: 启动 MuxServer 监听套接字 (维护 ActiveSessions=1) + C1->>Remote: 打开 Interactive Shell / PTY 会话并交互运行 + + Note over C2,Remote: 阶段二:次级会话透明复用 (耗时 ~2ms) + C2->>Socket: 探测套接字并执行 DialContext("unix", ControlPath) + Socket->>Srv: 接收连接请求 + Srv->>Srv: 校验 Peer UID (SO_PEERCRED / LOCAL_PEERCRED) + C2->>Srv: MUX_MSG_HELLO (Version=4) + Srv-->>C2: MUX_MSG_HELLO (Version=4) + C2->>Srv: MUX_C_PROXY (RequestID=1) + Srv-->>C2: MUX_S_PROXY (RequestID=1) + Note over C2,Srv: 套接字协议无缝切换至 SSH Connection Framing + C2->>Srv: RFC 4254 SSH Channel Open (session / direct-tcpip) + Srv->>Remote: 经由既有 SSH 连接复用开辟 Remote Channel + Remote-->>Srv: Channel Confirmation + Srv-->>C2: Channel Confirmation + Note over C2,Remote: Client 2 拥有完全自治的 cryptoSSH.Client,零握手直达会话 + + Note over C1,Remote: 阶段三:前台退出与 ControlPersist 守护 + C1->>C1: 前台用户退出 Shell (Exit) + C1->>Srv: RemoveForegroundSession() (ActiveSessions 从 1 减至 0) + alt ControlPersist 未开启 ("no") + Srv->>Socket: Unlink 套接字并关闭监听 + Srv->>Remote: 关闭 SSH 物理连接并终止进程 + else ControlPersist 开启 (如 "10m") + Srv->>Srv: 启动空闲倒计时定时器 (10 分钟) + Note over Srv: Master 保持后台守护运行,持续接收后续 Client 连接 + opt 新客户端在 10 分钟内接入 + C2->>Srv: 接入连接 -> 停止空闲定时器,ActiveSessions 递增 + end + opt 10 分钟内无新连接接入 + Srv->>Socket: 定时器触发 -> 原子解除套接字监听并关闭 Remote 传输 + end + end +``` + +### 2.3 `core/ssh` 解耦不变量 + +遵循仓库最高架构契约(`AGENTS.md` 与 `docs/development/shared-core-decoupling.md`): +- `core/ssh/` 内的代码与测试**严禁** import `cmd/**`、`pkg/**` 或根目录 `internal/**`(`python3 scripts/check_core.py --race` 自动化静态检查为 0 违规)。 +- 本地系统环境的探测全部收敛于 `internal/sshenv/environment.go:Discover()`,并通过 `core.Environment` 结构体传入: + ```go + type Environment struct { + InitializationError error + KnownHostsFile string + AgentSocket string + DefaultKeyPaths []string + ResolveKeyPath func(string) string + InteractiveIO InteractiveIO + InputBridge InputBridge + HomeDir string // 用户主目录 (~ 展开) + LocalUser string // 当前登录用户名 (%u 展开) + LocalHost string // 本地 FQDN 主机名 (%l 展开) + LocalUID string // 当前本地用户 UID (%i 展开) + } + ``` + +--- + +## 3. OpenSSH 二进制线缆协议与协议帧 (`PROTOCOL.mux` v4) + +### 3.1 协议版本与消息类型定义 + +`core/ssh/mux_protocol.go` 完整实现了 OpenSSH `PROTOCOL.mux` 版本 4 的二进制线缆帧协议。所有的 MUX 数据包均采用网络字节序(Big-Endian)编码: + +``` ++-------------------------------+-------------------------------+ +| uint32 packet_length | uint32 packet_type | +| (4 + len(payload) 字节) | (消息操作码) | ++-------------------------------+-------------------------------+ +| payload 字节流 ... | ++---------------------------------------------------------------+ +``` + +协议常量严格对应 OpenSSH 原生定义: + +```go +const ( + MuxProtoVersion uint32 = 4 + MuxProtocolVersion uint32 = 4 +) + +// Client -> Master 请求消息类型 +const ( + MUX_MSG_HELLO uint32 = 0x00000001 + MUX_C_NEW_SESSION uint32 = 0x10000002 + MUX_C_ALIVE_CHECK uint32 = 0x10000004 + MUX_C_TERMINATE uint32 = 0x10000005 + MUX_C_OPEN_FWD uint32 = 0x10000006 + MUX_C_CLOSE_FWD uint32 = 0x10000007 + MUX_C_NEW_STDIO_FWD uint32 = 0x10000008 + MUX_C_STOP_LISTENING uint32 = 0x10000009 + MUX_C_PROXY uint32 = 0x1000000f + MUX_C_EXT_INFO uint32 = 0x20000001 +) + +// Master -> Client 响应消息类型 +const ( + MUX_S_OK uint32 = 0x80000001 + MUX_S_PERMISSION_DENIED uint32 = 0x80000002 + MUX_S_FAILURE uint32 = 0x80000003 + MUX_S_EXIT_MESSAGE uint32 = 0x80000004 + MUX_S_ALIVE uint32 = 0x80000005 + MUX_S_SESSION_OPENED uint32 = 0x80000006 + MUX_S_REMOTE_PORT uint32 = 0x80000007 + MUX_S_TTY_ALLOC_FAIL uint32 = 0x80000008 + MUX_S_PROXY uint32 = 0x8000000f + MUX_S_EXT_INFO uint32 = 0x90000001 +) + +// 端口转发类型 +const ( + MUX_FWD_LOCAL uint32 = 1 + MUX_FWD_REMOTE uint32 = 2 + MUX_FWD_DYNAMIC uint32 = 3 +) +``` + +### 3.2 代理模式切换 (`MUX_C_PROXY`) 与 `muxProxySSHConn` 包装 + +对于内部 CLI 会话(`ssh`、`scp`、`sftp`、`exec`、`play`),`xops-cli` 优先使用 `MUX_C_PROXY` 协议模式: +1. 客户端连接 Unix 套接字,完成 `MUX_MSG_HELLO` 握手(Master 在 Hello 扩展中通告 `target@xops` 规范目标身份,客户端据此校验静态 `ControlPath` 是否指向同一远端目标,不匹配时立即以 `ErrControlTargetMismatch` 失败关闭,防止跨主机串用)。 +2. 客户端发送 `MUX_C_PROXY (request_id=1)`。 +3. 服务端回复 `MUX_S_PROXY (request_id=1)`,随后主动下发一条无应答全局通知包(`hostkeys-00@openssh.com`,`WantReply: false`)并在代理读循环中响应 OpenSSH `SSH_MSG_PING (192)` -> `SSH_MSG_PONG (193)`。 +4. 从此时起,Unix 域套接字上的数据流直接作为 SSH Connection Protocol(RFC 4254)传输载体。 +5. 客户端调用 `cryptoSSH.NewControlClientConn(rawConn)` 进行信道握手,并使用自定义 `muxProxySSHConn` 包装: + ```go + type muxProxySSHConn struct { + cryptoSSH.Conn + rawConn net.Conn + target TargetContext + remoteAddr net.Addr + localAddr net.Addr + user string + } + ``` +6. 调用 `cryptoSSH.NewClient(proxyConn, chans, reqs)` 构筑完备的 `*cryptoSSH.Client`。此对象对上层逻辑(如 SFTP 客户端创建、Interactive PTY 请求、环境变量注入、Probe 方言探测缓存)完全透明,无任何行为差异。 +7. **流量控制与通道保序**(`core/ssh/mux_server.go:proxySessionState`): + - 下行方向严格遵循客户端在通道 open 中声明的接收窗口与最大包长。Master 在发送每个数据包前先 `clientFlowWindow.reserve` 精确预留额度,并在 `SSH_MSG_CHANNEL_WINDOW_ADJUST` 中归还额度;忽略该窗口会让客户端 `x/crypto` 在 2 MiB 后以 `ssh: remote side wrote too much` 终止整条复用连接(大文件下载/高吞吐命令才会暴露)。 + - 上行方向不阻塞读循环:客户端数据经每通道 `orderedQueue` 交给专属 relay goroutine 写入远端通道,仅在写入成功后才回窗口额度。否则一个远端不再读取 stdin 的通道会占住共享读循环,导致同一 Master 上所有其他通道(含 `-O` 查询与新会话)一起挂死。 + - 全局请求(`tcpip-forward`、keepalive 等)同样经有序列化 worker 转发:RFC 4254 的全局回复不携带请求 ID,并发回复会把结果错配到别的请求。 + - 客户端已关闭的通道不再接收任何迟到包:`x/crypto` 对未知通道 ID 的非请求包会让整条 mux 连接失败。 + - **通道关闭必须回应且只能回应一次**:客户端主动发送 `SSH_MSG_CHANNEL_CLOSE` 时,Master 必须回发一条 close(RFC 4254 §5.3;`x/crypto` 只有收到该回应才会释放客户端通道并唤醒被阻塞的读者)。`proxyChan.closeSent` 在 `writeMu` 保护下作为唯一关闭标记:`handleClose` 与远端流自然结束都调用同一个 `closeChannel`,保证只发一包;此后所有通道级包(data/extended-data/EOF/window-adjust/channel-request/成功失败回复)都被 `writeChannelPacket` 直接丢弃,避免“关闭后再发”把客户端 mux 打死。整个代理会话断连(`closeAll`)同样由 `writeChannelPacket`/`writePacket`/`closeChannel` 的 `closed` 检查拦截:被唤醒的 relay 协程不再向已关闭的传输写包,也不再等待不会到来的应答。 + +### 3.3 传统 Passenger 模式与 Unix `SCM_RIGHTS` fd 传递 + +为了兼容系统原生 `/usr/bin/ssh` 作为客户端连入 `xops-cli` Master 套接字的场景,在 Unix 平台(Linux/macOS/BSD)通过 `core/ssh/mux_rights_unix.go` 实现了标准 Passenger 模式: +- **`MUX_C_NEW_SESSION`**:按 OpenSSH `PROTOCOL.mux` v4 规范将 `want_tty`、`want_x11_forwarding`、`want_agent`、`subsystem_flag` 编码为 4 字节 `uint32`(`0` 或 `1`)。Master 接收 Unix 控制消息中的 3 个文件描述符(标准输入、标准输出、标准错误,通过 `recvFDs(unixConn, 3)` 使用 `net.UnixConn.ReadMsgUnix` 与 `unix.ParseUnixRights` 解析),在 Master 持有的远程 SSH 连接上直接打开底层 `"session"` 通道(`sshClient.OpenChannel("session", nil)`),支持 `exec`、`shell` 与 `subsystem`(如 `sftp`),在本地 `stdin` EOF 时向远端通道发送 `CloseWrite()`(`SSH_MSG_CHANNEL_EOF`),并在会话终止且释放继承的描述符后回传 `MUX_S_EXIT_MESSAGE`。 +- **描述符接收契约**:OpenSSH `mm_send_fd` 每个描述符单独发送一条控制消息,因此 `recvFDs` 必须循环累加直到收满,不能假设一次 `recvmsg` 携带全部 fd;接收上限为 5s(`passengerFDReceiveTimeout`),每个 fd 立即 `SetFD(FD_CLOEXEC)`,超出请求数量的 fd 直接关闭而不是交给调用方。 +- **单一所有权**:接收到的 fd 由 `recvFiles` 交给 `*os.File`,此后只能通过 `File.Close()` 释放。handler 不得再次 `unix.Close` 同一 fd 编号——第二次关闭可能命中并发 goroutine 刚复用的无关描述符;`MUX_C_NEW_STDIO_FWD` 在两个方向的复制都退出后才返回,避免 deferred 清理与在途写竞态。 +- **`MUX_C_NEW_STDIO_FWD`**:`connect_port` 遵循 `PROTOCOL.mux` v4 编码为 `uint32`;接收 2 个文件描述符(输入与输出),通过 Master 的 `Client.Dial("tcp", host:port)` 进行流式转发,并在本地输入 EOF 时调用半关闭 `CloseWrite()` 等待远端响应排空。 +- **跨平台降级**:在 Windows 平台(`core/ssh/mux_rights_other.go`),由于 Windows 的 Winsock `AF_UNIX` 不支持文件描述符传递机制(fd passing / `SCM_RIGHTS`),若接收到 Passenger 模式请求,服务安全拒绝并明确提示使用 `MUX_C_PROXY`。 + +### 3.4 资源限制与 DOS 防护 + +为防止恶意客户端或异常报文导致内存耗尽(OOM),协议层硬性限制: +- `MaxMuxPacketLength = 256 * 1024` (256 KB) +- `MaxMuxStringLength = 64 * 1024` (64 KB) +- 严禁分配未经校验长度的切片,读取时先验证长度范围再执行 `io.ReadFull`。 + +--- + +## 4. Unix 域套接字安全与生命周期 + +### 4.1 严格权限控制 + +套接字文件是多用户本地系统中的安全边界。未经授权的本地用户若能写入该套接字,即可通过 Master 劫持远程主机访问权限。 +- 控制套接字父目录创建时权限强制为 `0700` (`rwx------`)。 +- 控制套接字文件本身的权限强制为 `0600` (`rw-------`)。 +- 为避免修改进程级全局掩码(`unix.Umask`)导致与其他并发线程/文件写入操作竞争,`xops-cli` 严禁调用进程级 `Umask`,而是在套接字创建后通过明确的 `os.Chmod(targetPath, 0600)` 执行权限收敛。 + +### 4.2 临时硬链接原子创建与竞争防御 + +当多个客户端几乎同时决定担任 Master 时,如果简单地以 `net.Listen("unix", path)` 竞争,容易引发文件覆盖与 TOCTOU 竞争漏洞。 + +`core/ssh/mux_rights_unix.go` 实现了基于 POSIX `link(2)` 的原子创建算法(`bindAndLinkSocket`): +1. **三级路径预算策略**:受操作系统 `sockaddr_un.sun_path` 上限约束(Linux/Windows 108 字节,Darwin/BSD 104 字节): + - 若目标路径末尾剩余空间 $\ge 8$ 字节,追加小后缀生成临时路径(形如 `path.m%04x.t`); + - 否则若所在目录剩余空间 $\ge 8$ 字节,在目录下生成紧凑临时路径(形如 `dir/.m%04x.t`); + - 若剩余空间耗尽(极端长路径),平滑降级为目标路径直接绑定并配合权限收敛。 +2. **原子硬链接**:在临时路径上绑定并 `chmod 0600`,随后调用 `os.Link(tempPath, targetPath)`: + - 若硬链接成功:说明本进程赢得了 Master 资格,立即解除临时路径(`os.Remove(tempPath)`)。 + - 若返回 `EEXIST`:说明已有其他 Master 并发创建成功,安全关闭本进程监听并返回 `ErrMasterAlreadyExists`。 + +### 4.3 对端 Peer UID 校验(双向) + +即使文件权限限制为 `0600`,本地 root 用户或拥有特殊提权能力的用户仍可能连接套接字。为了满足防御深度: +- **Linux** (`mux_peer_linux.go`):通过 `unix.GetsockoptUcred(fd, unix.SOL_SOCKET, unix.SO_PEERCRED)` 获取连入客户端的进程凭据 `ucred.Uid`。 +- **macOS / FreeBSD** (`mux_peer_darwin.go` / `mux_peer_freebsd.go`):通过 `unix.GetsockoptXucred(fd, unix.SOL_LOCAL, unix.LOCAL_PEERCRED)` 获取 `xucred.Uid`。 +- **校验逻辑**:Master 校验连入对端的 UID 必须与期望 UID(当前执行用户 UID)完全一致;若不匹配立即切断连接并记录安全审计日志,返回 `ErrUnauthorizedPeer`。 +- **客户端侧同等校验**:`DialMuxClient` / `DialMuxProxyStream` / `DialControlProxy` 在握手前调用同一个 `checkPeerUID`,要求 Master 端 UID 等于本进程有效 UID(默认 `localPeerUID()`)或 `0`(root)。否则任何本地用户在 `/tmp` 等共享目录抢先创建同名套接字,即可冒充 Master 收取受害者的命令、提权口令与传输内容。`WithExpectedPeerUID` 只能改期望的 UID,不能关闭校验:`0` 表示“期望对端是 root”,因此以 root 运行的客户端同样会拒绝普通用户预先植入的套接字(先前把 `0` 当作“跳过校验”会让 root 会话被劫持)。仅缺少对端凭据能力的平台(Windows)由 `checkPeerUID` 自身 no-op。 +- **Windows** (`mux_rights_other.go`):由于 Windows AF_UNIX 依赖 NTFS 安全描述符(DACL)隔离用户安全边界且不支持 `SO_PEERCRED`,因此在此平台上 `localPeerUID()` 返回 `0` 并安全跳过 UID 检查。 +- **服务端默认值**:`StartMuxServer` 在 `Environment.LocalUID` 缺失或无法解析时回退到 `localPeerUID()`(Unix 下即有效 UID),绝不因宿主漏注入宿主上下文而静默关闭 Master 侧校验;显式 `LocalUID` 仍优先。`Connector` 用同一个 `expectedPeerUIDFromEnvironment` 派生客户端侧期望值,使容器等注入 UID 与有效 UID 不一致时两端仍然一致。 + +### 4.4 僵死套接字(Stale Socket)探测与清理 + +若先前的 Master 进程由于崩溃(`kill -9` 或断电)异常终止,磁盘上会遗留无效的套接字文件。 +- 客户端在连接套接字前,先通过 `os.Lstat` 与 `isControlSocketFileInfo` 确认目标路径确实为 Unix 域套接字(在 Unix 平台上绝不把普通文件或目录当作套接字清理),且仅在允许创建新 Master 的模式(`Master != no`)且连接明确返回 `ECONNREFUSED` 时才将其判定为僵死套接字(握手超时等瞬时阻塞不会误删正在运行的繁忙 Master)。 +- 清理竞争防护:为了防止清理时误删刚刚由并发进程启动的新 Master 套接字,清理逻辑通过 `os.SameFile(origFi, currentFi)` 校验:只有当前磁盘文件的 `Inode` / 设备号与先前探测失败时的文件元数据完全一致时,才执行 `os.Remove(targetPath)`,杜绝并发竞争删除。 + +--- + +## 5. 配置模型与 4 层解析继承体系 + +### 5.1 4 层继承优先级 + +`pkg/adapter/ssh_adapter.go:ResolveControl` 严格按以下优先级由高到低解析复用配置: + +$$\text{Tier 1 (CLI Session Overrides)} > \text{Tier 2 (Node Config)} > \text{Tier 3 (Global Defaults)} > \text{Tier 4 (OpenSSH Config)}$$ + +1. **Tier 1 (CLI 会话参数覆盖)**:命令行传入的 `-M`、`-M -M`、`-S `、`-S none`、`-o Key=Value`。 +2. **Tier 2 (Node 级配置)**:当前节点在 `config.yaml` 中配置的 `nodes[name].control`。 +3. **Tier 3 (Global 全局配置)**:全局配置中定义的默认 `control`。 +4. **Tier 4 (OpenSSH 系统配置)**:解析 `~/.ssh/config` 中针对该 Host 匹配的 `ControlMaster`、`ControlPath`、`ControlPersist`。在 Tier 4 内部,具体的 Host 声明(非通配符)优先于泛通配符(如 `Host *`)。 + +### 5.2 字段级合并语义 (Orthogonal Field-by-Field Merging) + +三项关键配置(`Master`、`Path`、`Persist`)是彼此正交的独立字段,逐层按字段级合并: +- 每一层若未显式指定某个字段,则向下继承更低层级的字段值。 +- **禁用语义 (`-S none` 或 `Path: "none"`)**:当任何高优先级层级显式指定了 `Path: "none"`,该指令具有全局最高禁用意图——强制将合并后的 `Master` 归一化为 `"no"`,清除 `Persist` 为空,并立即退出复用链路,回退至原生直接连接。 + +### 5.3 ControlPath 路径令牌展开机制 + +`core/ssh/control_config.go:ExpandControlPath` 完全兼容 OpenSSH `ssh_config(5)` 的令牌展开: + +| 令牌 | 展开含义 | 示例 | +| :--- | :--- | :--- | +| `%%` | 转义为单个 `%` 字符 | `%` | +| `%h` | 目标远程主机名或目标 IP | `192.168.1.10` / `prod-app01` | +| `%p` | 目标远程端口(默认 22) | `22` / `2222` | +| `%r` | 目标远程登录用户名 | `root` / `ubuntu` | +| `%n` | 命令行原始输入的目标主机别名 | `app-alias` | +| `%u` | 本地当前登录用户名 | `wuyue` | +| `%l` | 本地当前主机 FQDN 全称 | `devbox.localdomain` | +| `%L` | 本地当前主机名(首个点号前的前缀) | `devbox` | +| `%i` | 本地当前用户数字 UID | `1000` | +| `%d` | 本地当前用户主目录 | `/home/wuyue` | +| `%j` | ProxyJump 跳板机名称(若存在) | `bastion.corp` | +| `%C` | **连接特征哈希**(40 位十六进制 SHA-1) | `b4a2...3f1c` | +| `~` | 路径开头的波浪号展开为本地用户主目录 | `~/.ssh/sockets` -> `/home/wuyue/.ssh/sockets` | +| `${VAR}` | 环境变量展开 | `${XDG_RUNTIME_DIR}/ssh.sock` | + +### 5.4 `%C` 散列令牌与套接字路径长度限制 + +Unix domain socket 对地址长度有严格的内核上限: +- Linux / Windows: 108 字节 +- macOS (Darwin) / FreeBSD / OpenBSD: 104 字节 + +若使用类似 `~/.ssh/cm-%r@%h:%p` 的模板,在长域名、长用户名或深层嵌套路径下极易超出 104 字节引发 `bind: invalid argument` (EINVAL)。为此,OpenSSH 推出了 `%C` 令牌。 +`ComputeConnectionHash` 严格遵循 OpenSSH 算法: + +$$\text{Hash} = \text{SHA-1}(\%l + \%h + \%p + \%r + \%j)$$ + +输出固定为 40 个字符的十六进制小写字符串。推荐的生产标准路径模式为: +`~/.ssh/cm-%C`(全长约 54 字节,安全适配所有 Unix 内核限制)。 + +### 5.5 Schema V2 配置声明与 CAS 实体版本化 + +在 `pkg/config/types.go` 与 `schema_v2.go` 中,`ControlConfig` 深度嵌入模型: + +```yaml +# config.yaml (Schema V2) +control: + master: auto + path: ~/.ssh/cm-%C + persist: 10m + +nodes: + web01: + host: 10.0.0.1 + user: ops + control: + master: "yes" + path: /tmp/custom-%h-%p + persist: 0 +``` + +- **验证保护**:`ValidateControlConfig` 校验模式必须属于合法枚举,校验 `Persist` 格式,校验未展开路径的语法合法性。 +- **CAS 版本哈希**:`ControlConfig` 纳入 `nodeEntityVersion`(`NodeRef`)的 CAS 内容哈希计算(`pkg/config/repository.go:nodeEntityVersion`),配置变更自动使依赖该节点的上层 `NodeRef` 乐观并发校验失效;认证机密版本 `nodeAuthVersion` 仍只覆盖认证材料,不包含复用配置。 + +--- + +## 6. CLI 命令与控制子命令交互 + +### 6.1 CLI 选项规范 + +`cmd/ssh_option_flag.go` 为各个子命令注册与 OpenSSH 规范一致的参数: + +- **`xops ssh`**: + - `-M`:启用 Master 模式(相当于 `-o ControlMaster=yes`)。 + - `-M -M`(重复指定):启用确认 Master 模式(相当于 `-o ControlMaster=ask`)。 + - `-S `:指定控制套接字路径(或 `-S none` 显式禁用连接共享)。 + - `-o Key=Value`:传递通用 SSH 参数(支持 `ControlMaster`、`ControlPath`、`ControlPersist`、`Port`、`User`、`IdentityFile`、`ProxyJump`)。 + - `-O `:向正在运行的 Master 进程发送控制指令。 +- **`xops scp`、`xops sftp`、`xops exec`**: + - 注册 `-S` 与 `-o` 参数。 + - 默认自动执行 4 层解析:如果检测到对应目标已有激活的 Master 套接字,直接透明复用,无需用户添加额外参数。 +- **`xops play`**: + - 在同一 Playbook 任务流中,针对同一节点的连续 step 自动保持套接字连接复用,消除多步骤编排中的握手累积开销。 + +### 6.2 `-O` 控制指令实现与标准 OpenSSH 输出 + +`cmd/ssh_control.go` 实现了与 OpenSSH 完全对齐的控制指令: + +| 指令 (`-O`) | 功能描述 | 成功标准输出/错误输出 (stderr) | 正常退出码 | +| :--- | :--- | :--- | :--- | +| `check` | 查询 Master 进程是否存活并获取其 PID | `Master running (pid=)` | 0 | +| `exit` | 请求 Master 进程立即切断连接并退出 | `Exit request sent.` | 0 | +| `stop` | 请求 Master 停止监听新连接,但保留既有会话;没有会话需要排空时立即退出 | `Stop listening request sent.` | 0 | +| `forward` | 在活动 Master 上动态增加本地/远程/动态端口转发(配合 `-L`、`-R`、`-D`) | `Allocated port ...` / `Forwarding request sent.` | 0 | +| `cancel` | 取消活动 Master 上的指定端口转发(配合 `-L`、`-R`、`-D`) | `Forwarding cancel request sent.` | 0 | +| `proxy` | 对齐 OpenSSH `-O proxy` 语义:通过 `MUX_C_PROXY` 连接活动 Master 并在该复用连接上执行请求的远程命令或交互会话(需指定目标 `` 或 `--host `) | 远程命令或会话的标准输出/错误输出 | 远程命令退出码(连接或校验失败为 255) | +| `proxy-stream` | (扩展指令)将当前进程标准输入输出切换为与 Master 的原始 `MUX_C_PROXY` 二进制字节流管道(指定 `-S` 时可省略 ``) | 二进制标准输入输出流转发 | 0 | +| `conninfo` | (扩展查询)获取 Master 基础连接元数据 | 连接摘要文本 | 0 | +| `channels` | (扩展查询)获取 Master 当前活跃通道清单 | 活跃通道清单 | 0 | + +### 6.3 `-O` 查询的只读解析契约 + +`-O` 是"对既有 Master 的查询",不得产生任何配置副作用: + +- 配置通过只读 `config.Provider` 加载(`cmd/ssh_control.go:loadReadOnlyControlProvider`),绝不走 `Repository.PrepareNodeContext` 或任何持久化路径;`-O check` 不会因为 `remember=always` 之类策略改写 `xops_config.yaml`。 +- 目标解析使用 `ResolveSelector`,歧义(`AmbiguousNodeError`)与未找到一律返回退出码 `255`,不再静默降级为空节点。 +- `ControlPath` 令牌(`%h/%p/%r/%j/%C`)始终基于**解析后的连接目标**展开(`cmd/ssh_option_flag.go:controlSocketPath`),与 `Connector.buildTargetContext` 完全一致;即使显式传入 `-S`,也先解析节点再展开,避免用户用节点别名调用时算出与 Master 实际路径不同的套接字。 + +### 6.4 严格错误格式与退出码传递 + +OpenSSH 客户端对控制指令有明确的行业契约: +1. **退出码规范**:执行成功退出码为 `0`;任何失败(如套接字不存在、连接被拒、Master 内部错误、参数非法)退出码必须严格为 `255`。参数层失败同样如此:`-O restart`、`-O check -M`、`-O check -S none`、`-O` 搭配非法 `-o` 都在接触套接字前以 `ControlExitError{Code: 255}` 返回,并由主入口以纯文本输出一次(`Reported` 标记避免重复打印)。 +2. **错误消息格式**:当连接套接字失败时,标准错误必须输出与系统调用一致的格式: + `Control socket connect(/path/to/socket): No such file or directory` 或 + `Control socket connect(/path/to/socket): Connection refused` + 绝不在错误前追加额外噪音前缀(如 `Error: `)。 +3. **主入口支持**:在 `cmd/cli/main.go` 中,优先通过 `cmd.ExitCodeFromError(err)` 提取 `ControlExitError` 的 255 退出码,阻止 Cobra 默认的 Exit(1) 覆盖;`cmd.ErrorAlreadyReported(err)` 为假时由主入口打印消息(不经 `logger.PrintError` 着色)。 +4. **无需目标的 `-O`**:除开启远程会话的 `-O proxy` 仍要求指定目标主机外,其余管理与流管道指令(`check`/`exit`/`stop`/`forward`/`cancel`/`proxy-stream`/`conninfo`/`channels`)在显式指定 `-S /path/to/socket` 时可以省略 ``,因为请求直接寻址套接字本身;不带 `-S` 时仍必须能从节点、全局默认或 OpenSSH 配置解析出套接字,缺失目标依旧报错。 + +--- + +## 7. ControlPersist 后台守护与生命周期管理 + +### 7.1 Persist 语法支持 + +`core/ssh/control_config.go:ParseControlPersist` 支持丰富的 OpenSSH 时间格式: +- `"no"` / `"false"` / `""`:禁用持久化,前台会话退出后 Master 立即关闭。 +- `"yes"` / `"true"` / `"0"`:无限期保持,即使前台退出,Master 也常驻后台,直至被 `-O exit` 终止或操作系统关闭。 +- **时间量词序列**:兼容 `sshd_config(5)` 时间定义: + - `s` / `S`:秒(不带单位的纯数字也默认为秒,如 `"600"` -> 600s) + - `m` / `M`:分钟(如 `"10m"` -> 600s) + - `h` / `H`:小时(如 `"1h"` -> 3600s,`"1h30m"` -> 5400s) + - `d` / `D`:天(如 `"1d"` -> 86400s) + - `w` / `W`:周(如 `"1w"` -> 604800s) + +### 7.2 前台会话保护与空闲超时状态机 + +`core/ssh/mux_server.go` 内部维护了并发安全的状态机: +1. **活跃会话计数器 (`activeSessions atomic.Int32`)**: + - 启动前台 Master(如交互式终端)时,调用 `srv.AddForegroundSession()`,会话数设为 1。 + - 连入 Passenger 或 Proxy 客户端时,会话数相应自增。 + - 只要 `activeSessions > 0`,空闲定时器处于绝对停止状态,绝不会发生会话中途断开。 +2. **会话释放与倒计时触发**: + - 当客户端退出调用 `removeActiveSession()` 使 `activeSessions` 归零时: + - 若 `Persist` 未启用:Master 立即触发优雅停机(`s.Shutdown()`)。 + - 若 `Persist` 为无限期(`indefinite=true`):Master 保持运行,不设定时器。 + - 若 `Persist` 为指定时长:使用代际递增计数器(`idleGen`)启动 `time.Timer`。 +3. **竞态防范 (Generation-based TOCTOU Protection)**: + - 若在空闲倒计时期间有新客户端连接,`addActiveSession()` 会在互斥锁保护下递增 `idleGen` 并取消当前定时器。 + - 即使旧定时器的回调函数恰好并发执行,检查 `currentGen != timerGen` 会立即丢弃过期超时事件,彻底避免误杀活跃 Master。 + +### 7.3 后台守护进程转换与 `srv.Wait()` + +在运行 `xops ssh -f -N` 或前台会话结束转入后台时: +- 主线程通过 `srv.Wait()` 监听 Master 的停机通知通道。 +- 无论是收到系统的终止信号(SIGINT/SIGTERM)、接收到客户端的 `-O exit` 请求,还是空闲超时到期,Master 均通过统一的 `shutdownOnce` 执行资源清理:关闭监听套接字、解除磁盘套接字链接(Unlink)、优雅断开所有子通道、关闭底层的 SSH 物理传输连接。 + +### 7.4 Master 所有权与 `ControlPersist` 交接(实施契约) + +复用配置只有在 Master 真正存活时才有意义,因此所有权必须显式: + +1. **Master 归启动它的 `Connector` 所有**:`Connector.masters` 记录本实例启动的每个 `MuxServer`;`Connector.CloseAll()` 在关闭连接池前先 `Shutdown()` 这些 Master,解除控制套接字与 `activeMuxServers` 注册。否则 `xops exec/scp/sftp/play/ssh` 退出后磁盘上会残留指向已关闭传输的"僵死但可连"套接字,后续命令必须再做一次探测与清理。 + - **Connector 自身就是一个活跃会话**:`maybeStartMasterListener` 以 `MuxMasterOptions.InitialActiveSessions = 1` 创建 Master,直到 `ReleaseMaster` 或 `CloseAll` 才释放。没有这份引用时,`ControlPersist=30s` 的 Master 会在创建瞬间启动空闲定时器,`sftp`/`scp`/`exec`/`play`(以及跳板节点)运行超过该时长就会被自己的 Master 掐断;`ControlPersist=no` 时,另一个客户端断开也会把 `activeSessions` 降到 0 并触发同样的误杀。 + - **`ReleaseMaster` 只释放引用、不停机**:交接前用它把 Master 变为"可空闲",Master 仍留在 `Connector.masters` 中,交接失败时 `CloseAll` 依旧负责收尾,重复调用也不会把计数减成负数。 +2. **`-N` 进程持有 Master 直到停机**:`xops ssh -M -N`(含 `-f -N` 子进程)直接保留 `Connector` 创建 Master 时持有的唯一前台会话引用(`InitialActiveSessions = 1`),并在 `runConnectedSession` 中等待 `select { <-runCtx.Done(); <-srv.Wait() }`,既避免重复计数(`activeSessions == 2`),也防止空闲倒计时或首个客户端断开误杀 `-N` 进程。 +3. **一次性/交互会话结束后交接给后台守护**:当 `ControlPersist` 启用且前台会话(命令或交互 Shell)结束时,`SshOptions.handoffPersistentMaster` 先释放并关闭本 `Connector` 启动的全部 Master(含 `ProxyJump` 跳板节点 Master)以释放套接字,再以 `XOPS_CLI_SSH_MUX_DAEMON=1` + `XOPS_CLI_SSH_BG_CHILD=1` re-exec 一个只服务控制套接字的子进程(强制 `-N`、清空命令、跳过 `--*-stdin` 读取、标准流指向 `/dev/null`),最后在 `waitForControlSocket` 的有界等待(10s)内确认新 Master 可用。交接失败只写 stderr 警告,不改变已执行命令的退出状态。 + - **不交接的场景**:`-N` 进程本身持有 Master(无需二次 fork)、`-f` 后台子进程、守护子进程自身(`XOPS_CLI_SSH_MUX_DAEMON=1`)、`ControlMaster=ask/autoask`(脱离终端的后台守护进程无法弹出交互确认提示,强行交接会导致后续复用请求永远因无确认源而被拒)、**仅来自 Tier 4 (`~/.ssh/config`) 的隐式复用配置**(`ControlConfig.ImplicitTier4 == true`,当用户未在 CLI 参数或 `xops` Node/Global 配置中显式声明复用时,允许透明复用既有套接字,但在单次命令退出时不静默拉起长生命周期后台守护进程),以及会话被取消(`Ctrl-C`/context 取消)——后者若再拉起守护进程,等于复活用户刚刚停止的连接。 + - **安全与凭据校验隔离**:凭据有效性验证(`adapter.VerifyConnection`)与 MCP Host 运行时连接器显式调用 `adapter.WithControlMultiplexing(false)` 禁用外部控制套接字复用与创建,确保凭据校验始终对远端执行真实认证握手,且受控的 MCP 会话不会跨边界复用或暴露本地共享套接字。 + - **一次性凭据不交接**:使用 `--otp` 的会话不会拉起守护进程——该验证码已被父进程消费,子进程重放必然失败并可能触发服务端 MFA 失败锁定。 + - **守护进程早退不再阻塞**:父进程通过 `cmd.Wait()` 监听子进程退出,`waitForControlSocket` 一旦发现子进程先于套接字就绪退出就立刻返回并告警,而不是空等满 10 秒就绪超时。 + - **守护进程不占用前台会话计数**:`ControlPersist=10m` 等有界持久化必须让空闲定时器正常到期退出;守护进程启动连接后立即 `Connector.ReleaseMaster` 放弃自己那份引用,`activeSessions` 因此归零,空闲定时器正常布防。若守护进程像前台进程一样保留引用,`activeSessions` 恒大于 0,空闲定时器永不触发,进程将永久泄漏。`-N` 前台主进程则相反,必须计入前台会话,否则首个客户端断开就会误杀它。 + - **交接前先释放引用**:`handoffPersistentMaster` 在 `PrepareHandoff()` 之前调用 `Connector.ReleaseMaster`,否则 Master 永远不满足"无活跃会话",交接必定被拒。 + - **仅在 Master 排空外部用户后交接**:前台会话(`ssh`/`scp`/`sftp`/`exec`)退出时先通过 `drainOwnedMasters`(`MuxServer.WaitForDrain`)等待附属在该 Master 上的其他客户端会话与 `-O forward` 转发排空(期间保留 Connector 的前台引用防止空闲计时器早退),随后调用 `Connector.ReleaseMaster` 与 `MuxServer.PrepareHandoff()` 原子预占(此后 `addActiveSession` 与 `-O forward` 一律拒绝;多跳链路中任一跳预占失败则通过 `CancelHandoff` 回滚);若已收到 `-O stop`/`-O exit` 则排空后直接退出,绝不切断正在使用该套接字的其他进程,也不会把用户刚刚停止的 Master 重新拉起。 +4. **`-f` 父进程不得抢占套接字**:`runParentDaemon` 只做连通性与隧道预校验,构建 Connector 时以**节点级** `SessionControlOptions{Path: "none"}`(`disableSessionControlForParent`)强制禁用共享,由后台子进程独占 `ControlPath`。为什么必须是节点级:`buildAdapterOptions` 可能已为该节点注入会话级 `-M/-S/-o` 覆盖,而节点级覆盖优先于全局默认,用 `WithGlobalSessionControl` 会失效。否则父进程会先绑定套接字、随即 `client.Close()` 退出,子进程只能连入一个即将消失的 Master。 +5. **跳板链目标优先复用**:`Connector.connectPlan` 在构建 ProxyJump 链之前,先对最终目标尝试缓存复用与既有 Master 附着(`reuseJumpChainedTarget`)。命中时完全不再拨号、认证跳板机;否则每次复用都要对跳板机重新认证(含 OTP/MFA)。链上已缓存的中间跳仍会先做健康探测,避免“跳板已停止应答但其转发仍在工作”被目标自身探测掩盖。 +6. **`-M` 不降级为从属客户端**:`ControlMaster=yes/ask` 探测到已有存活 Master 时不再作为 Proxy 客户端复用,而是继续建立自己的独立连接并由后续绑定报告冲突(对齐 OpenSSH"已存在控制套接字则禁用复用"的行为)。 +7. **`ControlMaster=ask/autoask` 必须真正确认**:`MuxServerOptions.AskConfirmation` + `ConfirmSession` 由 `Connector.WithSessionConfirmer` 注入(CLI 使用 `cmd.InteractionHandler`)。无确认源、确认报错或用户拒绝一律返回 `MUX_S_PERMISSION_DENIED`,绝不静默放行。 + +--- + +## 8. 国际化与多语言 (Localization & i18n) + +CLI 帮助与参数提示在 `pkg/i18n/locales/` 中配置中英文词条映射(`active.zh.yaml` 与 `active.en.yaml`)。`-O` 的运行时输出不在此列:`check`/`exit`/`stop`/`forward`/`cancel` 的文本与错误前缀是对系统原生 `ssh -O` 的兼容契约(第 6.4 节),必须保持 OpenSSH 原文并由测试逐字断言,不能本地化。 + +| 词条 Key | 中文翻译 | 英文翻译 | +| :--- | :--- | :--- | +| `flag_ssh_master` | 启用连接共享 Master 模式;重复指定进入确认模式 | Places the ssh client into 'master' mode for connection sharing; repeated flags place ssh into confirmation mode | +| `flag_ssh_control_path` | 指定连接共享控制套接字路径,或使用 'none' 禁用 | Specify path to control socket for connection sharing, or 'none' to disable | +| `flag_ssh_option` | 以 Key=Value 格式传递配置选项 | Can be used to give options in the format used in the configuration file (Key=Value) | +| `flag_ssh_control_command` | 向运行中的主进程发送控制命令:check, exit, stop, forward, cancel, proxy, proxy-stream, conninfo, channels | Send control command to active master: check, exit, stop, forward, cancel, proxy, proxy-stream, conninfo, channels | + +--- + +## 9. 测试与质量保证矩阵 (Testing & Quality Assurance Matrix) + +为了防止未来迭代产生功能回退,项目建立了分层的多维度质量门禁: + +| 验证层级 | 测试目标与验证项 | 验证命令 | 验收门槛 | +| :--- | :--- | :--- | :--- | +| **架构解耦测试** | 验证 `core/**` 对 `cmd/**`、`pkg/**`、`internal/**` 的零依赖不变量 | `python3 -m unittest discover -s scripts -p test_check_core.py`
`python3 scripts/check_core.py --race` | 0 违规,纯净 core 单元测试全过 | +| **线缆与协议单元测试** | `core/ssh` 报文编解码、大端序、长度保护、哈希计算、令牌展开 | `go test -v -race ./core/ssh/` | 100% 通过,无数据竞争 | +| **套接字并发与压力测试** | Master 并发代理请求、通道争用、异步 WaitGroup 同步 | `go test -v -race -shuffle=on -count=1 ./core/ssh/` | 100% 通过,无死锁、无泄漏 | +| **配置与 4 层继承测试** | 4-tier 优先级覆盖、`-S none` 禁用、OpenSSH `~/.ssh/config` 解析 | `go test -v -race ./pkg/config/... ./pkg/adapter/...` | 100% 通过 | +| **CLI 控制与退出码测试** | `-O` 指令执行、255 退出码传播、stderr 格式、`-M`/`-S`/`-o` 解析 | `go test -v -race ./cmd/...` | 100% 通过 | +| **Playbook 连贯复用测试** | 多步骤任务与跨节点套接字共享、零二次握手验证 | `go test -v -race ./pkg/playbook/` | 100% 通过 | +| **代码静态质量与规范** | 全仓库代码风格、复杂度和潜在缺陷扫描 | `golangci-lint run ./...` | 0 告警、0 错误 | +| **双语文档同步性检查** | 中英文文档路径、章节与公开页面一致性验证 | `npm run docs:check` | 0 差异,Page paths match | diff --git a/docs/en/development/governance/architecture-map.md b/docs/en/development/governance/architecture-map.md index 96f74456..4dc8a980 100644 --- a/docs/en/development/governance/architecture-map.md +++ b/docs/en/development/governance/architecture-map.md @@ -12,7 +12,10 @@ XOps is an operations CLI written in Go covering host/identity management, SSH/S flowchart TD E[cmd/cli/main.go] --> R[cmd/root.go / Cobra] R --> C[cmd/* SSH/exec/SCP/play/host/MCP] - C --> A[pkg/adapter] + C --> CTL[cmd/ssh_control.go + cmd/ssh_option_flag.go] + CTL --> A[pkg/adapter] + CTL --> SSH[core/ssh] + C --> A C --> P[pkg/playbook + pkg/tui] C --> CFG[pkg/config Repository + Provider] C --> CM[cmd/mcp.go] @@ -24,28 +27,32 @@ flowchart TD MR --> G[core/mcp/guardrail + state + transfer + tunnel] G --> PORTS B --> PORTS - B --> SSH[core/ssh] + B --> SSH B --> SFTP[core/sftp] A --> SSH P --> SSH CFG --> CR[pkg/credential + internal/credentialfile] SFTP --> SSH + ENV[internal/sshenv] --> SSH ``` -**Note**: The diagram shows both composition and Go dependency directions (for example, `core/mcp/runtime`, `core/mcp/sshexec`, and `internal/mcphost` all depend one-way on `core/mcp/ports`; `core/sftp` depends on `core/ssh`). Use `go list -json ./...` to inspect the exact Go import graph. +**Note**: The diagram shows both composition and Go dependency directions (for example, `core/mcp/runtime`, `core/mcp/sshexec`, and `internal/mcphost` all depend one-way on `core/mcp/ports`; `core/sftp` depends on `core/ssh`; `core/ssh` preserves strict unidirectional decoupling, receiving host context from `internal/sshenv`). Use `go list -json ./...` to inspect the exact Go import graph. ## Recommended Reading Order -1. **Process startup / composition root**: `cmd/cli/main.go:main` -> `cmd/root.go:Execute` -> `cmd/{ssh,exec,mcp,play,credential}.go`. -2. **Configuration and credential state**: `pkg/config/{provider,repository,store,target,encrypted_runtime}.go`; `pkg/credential/service.go`; `internal/credentialfile`. +1. **Process startup / composition root**: `cmd/cli/main.go:main` -> `cmd/root.go:Execute` -> `cmd/{ssh,exec,mcp,play,credential,ssh_control,ssh_option_flag}.go`. +2. **Configuration and credential state**: `pkg/config/{provider,repository,store,target,encrypted_runtime,schema_v2,validate,openssh}.go`; `pkg/credential/service.go`; `internal/credentialfile`. 3. **Connection / execution semantics**: `pkg/adapter/ssh_adapter.go` -> `core/ssh/{connector,plan,command_plan,command_execution,execution_config,probe}.go`. -4. **MCP layering**: `cmd/mcp.go:runMCPServer` -> `internal/mcphost/{config,host}.go` -> `core/mcp/runtime/{server,invocation}.go` -> `core/mcp/ports/types.go`, `core/mcp/sshexec/backend.go`, `core/mcp/guardrail/*` (Note: in CLI mode, `internal/mcphost.host` directly implements `StateSource` and `ExecutionGate`; dynamic publication hosts use `core/mcp/state.Coordinator`). -5. **Batch orchestration**: `cmd/exec.go`, `cmd/exec_execution.go`, and `pkg/playbook/{engine,runner}.go`. -6. **Interactive UI & terminal**: `pkg/tui/*`, `cmd/sftpshell/*`, `internal/terminal/*`. +4. **OpenSSH Connection Multiplexing**: `cmd/{ssh_control,ssh_option_flag}.go` -> `pkg/adapter/ssh_adapter.go:ResolveControl` (4-tier resolution hierarchy: CLI > Node > Global > OpenSSH) -> `core/ssh/{control_config,mux_protocol,mux_client,mux_proxy_conn,mux_server,mux_rights_unix,connector}.go` (`PROTOCOL.mux` v4 binary wire protocol, 0600 socket atomic link defense, peer UID validation, `ControlPersist` background daemon and graceful shutdown). +5. **MCP layering**: `cmd/mcp.go:runMCPServer` -> `internal/mcphost/{config,host}.go` -> `core/mcp/runtime/{server,invocation}.go` -> `core/mcp/ports/types.go`, `core/mcp/sshexec/backend.go`, `core/mcp/guardrail/*` (Note: in CLI mode, `internal/mcphost.host` directly implements `StateSource` and `ExecutionGate`; dynamic publication hosts use `core/mcp/state.Coordinator`). +6. **Batch orchestration**: `cmd/exec.go`, `cmd/exec_execution.go`, and `pkg/playbook/{engine,runner}.go` (supporting transparent ControlPath socket reuse across task steps). +7. **Interactive UI & terminal**: `pkg/tui/*`, `cmd/sftpshell/*`, `internal/terminal/*`. ## "Read the Index First, Then Inspect Source" - Authoritative boundary specs: `core/README.md`, `docs/en/development/shared-core-decoupling.md`. +- OpenSSH Connection Multiplexing: `docs/en/development/openssh-multiplexing-design.md`. +- SSH OTP & Multi-Factor Authentication: `docs/en/development/ssh-otp-keyboard-interactive-design.md`. - SSH interpreter evolution: `docs/en/development/ssh-execution-compatibility.md`, `ssh-execution-implementation.md`, `ssh-execution-probe-{design,implementation}.md`. - MCP HTTP transfers: `docs/en/development/mcp-http-transfer-design.md`, `docs/en/guide/mcp-http.md`. - Offline store compatibility: `docs/en/development/compatibility.md`, `docs/en/development/credential-experience-design.md`. diff --git a/docs/en/development/index.md b/docs/en/development/index.md index 9dccfa65..6a650493 100644 --- a/docs/en/development/index.md +++ b/docs/en/development/index.md @@ -26,6 +26,7 @@ Historical implementation records describe validation at the time; they do not i Each document states its implementation status and validation scope. - [Architecture and governance map](./governance/architecture-map.md) +- [OpenSSH connection multiplexing design](./openssh-multiplexing-design.md) - [SSH execution and interpreter compatibility](./ssh-execution-compatibility.md) - [SSH execution compatibility implementation plan](./ssh-execution-implementation.md) - [SSH interpreter and launch dialect automated probing design](./ssh-execution-probe-design.md) diff --git a/docs/en/development/openssh-multiplexing-design.md b/docs/en/development/openssh-multiplexing-design.md new file mode 100644 index 00000000..306141fb --- /dev/null +++ b/docs/en/development/openssh-multiplexing-design.md @@ -0,0 +1,542 @@ +# OpenSSH Connection Multiplexing (-M / -S / -O & ControlMaster / ControlPath / ControlPersist) Permanent Design Specification + +**Document Status**: Implemented and Verified (M1–M4 fully complete) +**Governing Documents**: [Architecture & Governance Map](./governance/architecture-map.md), [Shared Core Decoupling Design](./shared-core-decoupling.md), [SSH Diagnostics Design](./ssh-diagnostics.md) +**Language Version**: English Development Specification (Chinese version: [openssh-multiplexing-design.md](../../development/openssh-multiplexing-design.md)) + +--- + +## 1. Background and Objectives + +### 1.1 Background and Problem Statement + +In distributed system administration, batch command execution, automated operations, and playbook orchestration, CLI tools frequently initiate multiple remote connections to the same target nodes. Traditional SSH clients perform a complete handshake for every single connection: TCP connection establishment, SSH protocol version exchange, key exchange (KEX), server host key verification, user authentication (publickey/password/MFA/OTP challenge-response), and session channel negotiation. In cross-region networks, over jump hosts (bastions), or with multi-factor authentication enabled, this handshake typically costs hundreds of milliseconds to multiple seconds per session. + +Native OpenSSH resolves this bottleneck using Connection Multiplexing (connection sharing via `-M`, `-S`, `-O`, and the configuration directives `ControlMaster`, `ControlPath`, and `ControlPersist`): the initial session acts as a Master by establishing the primary SSH transport and listening on a local Unix domain socket (or a Windows 10+ `AF_UNIX` socket). Subsequent client invocations connect to this socket, sharing the pre-authenticated SSH transport and achieving zero additional network handshakes, zero redundant authentication prompts, and millisecond-level session establishment. + +To ensure deep interoperability with modern DevOps ecosystems (including Ansible, system-native `/usr/bin/ssh`, and automated orchestration platforms), `xops-cli` must provide connection multiplexing strictly compatible with OpenSSH, while preserving its architectural layering and decoupling invariants. + +### 1.2 Core Design Principles + +1. **Strict Wire Protocol Compatibility (`PROTOCOL.mux` v4)**: + The communication wire layer strictly adheres to the OpenSSH specification, sharing identical binary framing, big-endian integer encodings, and request/response opcodes (`MUX_MSG_HELLO`, `MUX_C_*`, `MUX_S_*`), enabling seamless socket sharing with system-native OpenSSH. +2. **Zero Degradation of Upper-Layer Features (`MUX_C_PROXY`)**: + Multiplexed connections transition the local control socket into transparent SSH transport framing via `MUX_C_PROXY`. Wrapped as `muxProxySSHConn` (satisfying `golang.org/x/crypto/ssh.Conn`) and constructed into a standard `*cryptoSSH.Client`, all existing capabilities—interactive PTY sessions, SFTP file transfers, remote batch execution, and sudo/su dialect probe caches—function natively with zero behavioral degradation or feature branching. +3. **Strict Unidirectional `core/ssh` Decoupling Invariant**: + The multiplexing protocol and socket engine reside entirely within `core/ssh`, with zero imports from `cmd/**`, `pkg/**`, or root `internal/**` (enforced by `scripts/check_core.py --race`). Host environment discovery (username, UID, hostname, home directory) is explicitly injected via `core.Environment`. +4. **Defense-in-Depth Security Model**: + Unix domain sockets enforce strict permissions (`0600`), defense against hard-link races via atomic `link(2)` socket creation, and verification of peer credentials (Peer UID validation via `SO_PEERCRED` on Linux and `LOCAL_PEERCRED` on Darwin/BSD) to block unauthorized hijacking or privilege escalation in multi-user systems. +5. **Clear 4-Tier Resolution Hierarchy**: + Resolution priority flows from CLI Session Flags > Node Configuration > Global Defaults > OpenSSH Configuration (`~/.ssh/config`), supporting fine-grained property inheritance and one-shot explicit bypass (`-S none`). + +### 1.3 Goals and Non-Goals + +| In Scope (Goals) | Out of Scope (Non-Goals) | +| :--- | :--- | +| OpenSSH `PROTOCOL.mux` version 4 binary wire encoding/decoding | Supporting deprecated protocol versions 1–3 | +| Full support in `xops ssh` for `-M` (single master), `-M -M` (confirmation master), `-S`, `-o`, and `-O` | Providing simulated sockets on legacy Windows versions lacking `AF_UNIX` (prior to Windows 10 1803) | +| `-O` control commands: `check`, `exit`, `stop`, `forward`, `cancel`, `proxy`, `conninfo`, `channels` | Supporting stateless control commands without an active master process | +| `ControlMaster` modes: `no`, `yes`, `ask`, `auto`, `autoask` | Bypassing local OS file permissions to create world-writable sockets | +| `ControlPath` token expansion: `%%`, `%h`, `%p`, `%r`, `%n`, `%u`, `%l`, `%L`, `%i`, `%d`, `%j`, `%C` (SHA-1 hash), `~`, and `${ENV}` | Allowing unchecked paths that exceed OS `sockaddr_un` limits (108/104 bytes) | +| `ControlPersist` support: `no`, `yes`/`0` (indefinite), duration syntax (`10m`, `1h`, `600s`, `1d`, `1w`), and idle timeout termination | Guaranteeing socket survival across kernel OOM kills or host power loss | +| Automatic detection and transparent socket reuse across `scp`, `sftp`, `exec`, and `play` | Modifying the remote SSH server daemon (all multiplexing is negotiated between client and local master) | +| Cross-platform support: Linux, macOS, BSD, and Windows 10+ (via `MUX_C_PROXY` without fd passing) | Supporting Unix-specific `SCM_RIGHTS` fd passing on non-Unix platforms | + +--- + +## 2. System Architecture and Data Flow + +### 2.1 Component Boundaries and Layered Architecture + +The system architecture adheres strictly to the module boundaries mandated in `AGENTS.md`: + +```mermaid +flowchart TD + subgraph CLI["cmd/ (CLI Layer)"] + SSHCmd["cmd/ssh.go\n(-M, -S, -o, -O)"] + OtherCmds["cmd/{scp, sftp, exec}.go\n(-S, -o, Auto-Reuse)"] + Playbook["pkg/playbook/\n(Step Reuse)"] + SSHControl["cmd/ssh_control.go\n(ExecuteControlCommand, Exit Codes)"] + SSHFlag["cmd/ssh_option_flag.go\n(SSHControlFlags, -o Parsing)"] + end + + subgraph ConfigAdapter["pkg/adapter/ & pkg/config/ (Resolution & Configuration)"] + Adapter["pkg/adapter/ssh_adapter.go\n(ResolveControl: 4-Tier Precedence)"] + CfgRepo["pkg/config/repository.go\n(Configuration Snapshot)"] + OpenSSHParser["pkg/config/openssh.go\n(Parse ControlMaster/Path/Persist)"] + Models["pkg/models/types.go & pkg/config/schema_v2.go\n(Node.Control, Schema V2 CAS Hash)"] + end + + subgraph EnvDiscovery["internal/sshenv/ (Host Context)"] + SSHEIDiscover["internal/sshenv/environment.go\n(Discover HomeDir, LocalUser, LocalHost, LocalUID)"] + end + + subgraph CoreSSH["core/ssh/ (Pure Protocol & Socket Engine - Zero CLI Imports)"] + Connector["core/ssh/connector.go\n(tryConnectMultiplex, dialAndPublishProxy)"] + CtrlCfg["core/ssh/control_config.go\n(ExpandControlPath, ParseControlPersist, %C)"] + MuxProto["core/ssh/mux_protocol.go\n(PROTOCOL.mux v4 Framing & Codecs)"] + MuxClient["core/ssh/mux_client.go\n(DialMuxClient, AliveCheck, Proxy, Forward)"] + MuxProxy["core/ssh/mux_proxy_conn.go\n(muxProxySSHConn -> cryptoSSH.Conn)"] + MuxServer["core/ssh/mux_server.go\n(MuxServer, IdleTimer, PeerUID, Dispatcher)"] + MuxUnix["core/ssh/mux_rights_unix.go\n(bindAndLinkSocket, SCM_RIGHTS, SO_PEERCRED)"] + MuxOther["core/ssh/mux_rights_other.go\n(Windows AF_UNIX Direct Bind)"] + end + + SSHCmd --> SSHFlag + SSHCmd --> SSHControl + OtherCmds --> SSHFlag + Playbook --> Adapter + SSHControl --> Adapter + SSHControl --> MuxClient + SSHCmd --> Adapter + OtherCmds --> Adapter + Adapter --> CfgRepo + Adapter --> OpenSSHParser + Adapter --> CtrlCfg + CfgRepo --> Models + OpenSSHParser --> Models + EnvDiscovery --> CoreSSH + Adapter --> Connector + Connector --> CtrlCfg + Connector --> MuxClient + Connector --> MuxProxy + Connector --> MuxServer + MuxServer --> MuxProto + MuxServer --> MuxUnix + MuxServer --> MuxOther + MuxClient --> MuxProto + MuxClient --> MuxProxy +``` + +### 2.2 Connection Multiplexing and Handshake Sequence + +The sequence diagram below details master initialization, client socket reuse, and background daemon transition: + +```mermaid +sequenceDiagram + autonumber + participant C1 as Client 1 (Foreground / Master) + participant Socket as Local Unix Socket (ControlPath) + participant Srv as MuxServer (Background / Master Thread) + participant C2 as Client 2 (Passenger / Proxy Client) + participant Remote as Remote SSH Server + + Note over C1,Remote: Phase 1: First Connection Establishes Master + C1->>Remote: TCP Handshake + Key Exchange + User Authentication (~500ms) + Remote-->>C1: SSH Transport Ready (*cryptoSSH.Client) + C1->>Socket: Bind Temporary Socket & Atomically link(2) to ControlPath (0600) + C1->>Srv: Start MuxServer Listener (Maintains ActiveSessions=1) + C1->>Remote: Open Interactive Shell / PTY Session + + Note over C2,Remote: Phase 2: Secondary Session Transparently Reuses Connection (~2ms) + C2->>Socket: Probe Socket & DialContext("unix", ControlPath) + Socket->>Srv: Accept Connection + Srv->>Srv: Verify Peer UID (SO_PEERCRED / LOCAL_PEERCRED) + C2->>Srv: MUX_MSG_HELLO (Version=4) + Srv-->>C2: MUX_MSG_HELLO (Version=4) + C2->>Srv: MUX_C_PROXY (RequestID=1) + Srv-->>C2: MUX_S_PROXY (RequestID=1) + Note over C2,Srv: Socket protocol transitions seamlessly to SSH Connection Framing + C2->>Srv: RFC 4254 SSH Channel Open (session / direct-tcpip) + Srv->>Remote: Open Channel over Existing SSH Connection + Remote-->>Srv: Channel Confirmation + Srv-->>C2: Channel Confirmation + Note over C2,Remote: Client 2 possesses fully functional cryptoSSH.Client with zero handshake delay + + Note over C1,Remote: Phase 3: Foreground Exit & ControlPersist Daemonization + C1->>C1: Foreground User Exits Shell (Exit) + C1->>Srv: RemoveForegroundSession() (ActiveSessions decrements from 1 to 0) + alt ControlPersist Disabled ("no") + Srv->>Socket: Unlink Socket and Close Listener + Srv->>Remote: Close SSH Transport Connection and Terminate + else ControlPersist Enabled (e.g. "10m") + Srv->>Srv: Start Idle Countdown Timer (10 minutes) + Note over Srv: Master persists in background, continuing to accept new clients + opt New Client Connects Within 10 Minutes + C2->>Srv: Inbound Client -> Stop Idle Timer, Increment ActiveSessions + end + opt No Client Connects Within 10 Minutes + Srv->>Socket: Timer Fires -> Atomically Unlink Socket & Close Remote Transport + end + end +``` + +### 2.3 `core/ssh` Decoupling Invariants + +In strict adherence to repository architectural invariants (`AGENTS.md` and `docs/development/shared-core-decoupling.md`): +- Source and test code in `core/ssh/` must **never** import `cmd/**`, `pkg/**`, or root `internal/**` (verified with zero violations by `python3 scripts/check_core.py --race`). +- Host environment discovery is centralized in `internal/sshenv/environment.go:Discover()` and injected explicitly via `core.Environment`: + ```go + type Environment struct { + InitializationError error + KnownHostsFile string + AgentSocket string + DefaultKeyPaths []string + ResolveKeyPath func(string) string + InteractiveIO InteractiveIO + InputBridge InputBridge + HomeDir string // User home directory (~ expansion) + LocalUser string // Local logged-in username (%u expansion) + LocalHost string // Local host FQDN (%l expansion) + LocalUID string // Local user numeric UID (%i expansion) + } + ``` + +--- + +## 3. OpenSSH Binary Wire Protocol & Framing (`PROTOCOL.mux` v4) + +### 3.1 Protocol Version and Message Opcode Definitions + +`core/ssh/mux_protocol.go` implements the OpenSSH `PROTOCOL.mux` version 4 binary wire framing. All MUX packets follow network byte order (Big-Endian): + +``` ++-------------------------------+-------------------------------+ +| uint32 packet_length | uint32 packet_type | +| (4 + len(payload) bytes) | (Message Opcode) | ++-------------------------------+-------------------------------+ +| payload bytes ... | ++---------------------------------------------------------------+ +``` + +The protocol opcodes match the native OpenSSH specifications: + +```go +const ( + MuxProtoVersion uint32 = 4 + MuxProtocolVersion uint32 = 4 +) + +// Client -> Master request message types +const ( + MUX_MSG_HELLO uint32 = 0x00000001 + MUX_C_NEW_SESSION uint32 = 0x10000002 + MUX_C_ALIVE_CHECK uint32 = 0x10000004 + MUX_C_TERMINATE uint32 = 0x10000005 + MUX_C_OPEN_FWD uint32 = 0x10000006 + MUX_C_CLOSE_FWD uint32 = 0x10000007 + MUX_C_NEW_STDIO_FWD uint32 = 0x10000008 + MUX_C_STOP_LISTENING uint32 = 0x10000009 + MUX_C_PROXY uint32 = 0x1000000f + MUX_C_EXT_INFO uint32 = 0x20000001 +) + +// Master -> Client response message types +const ( + MUX_S_OK uint32 = 0x80000001 + MUX_S_PERMISSION_DENIED uint32 = 0x80000002 + MUX_S_FAILURE uint32 = 0x80000003 + MUX_S_EXIT_MESSAGE uint32 = 0x80000004 + MUX_S_ALIVE uint32 = 0x80000005 + MUX_S_SESSION_OPENED uint32 = 0x80000006 + MUX_S_REMOTE_PORT uint32 = 0x80000007 + MUX_S_TTY_ALLOC_FAIL uint32 = 0x80000008 + MUX_S_PROXY uint32 = 0x8000000f + MUX_S_EXT_INFO uint32 = 0x90000001 +) + +// Forwarding types +const ( + MUX_FWD_LOCAL uint32 = 1 + MUX_FWD_REMOTE uint32 = 2 + MUX_FWD_DYNAMIC uint32 = 3 +) +``` + +### 3.2 Proxy Mode Transition (`MUX_C_PROXY`) & `muxProxySSHConn` Wrapping + +For internal CLI sessions (`ssh`, `scp`, `sftp`, `exec`, `play`), `xops-cli` prioritizes the `MUX_C_PROXY` mode: +1. The client dials the Unix domain socket and completes the `MUX_MSG_HELLO` handshake (the Master advertises a `target@xops` canonical target identity extension so clients can verify static `ControlPath` sockets and fail closed with `ErrControlTargetMismatch` on cross-host mismatches). +2. The client transmits `MUX_C_PROXY (request_id=1)`. +3. The server replies with `MUX_S_PROXY (request_id=1)`, immediately emits an initial no-reply global notification packet (`hostkeys-00@openssh.com`, `WantReply: false`), and answers OpenSSH `SSH_MSG_PING (192)` with `SSH_MSG_PONG (193)` in the proxy loop. +4. From that byte forward, the Unix socket stream acts as the raw SSH Connection Protocol (RFC 4254) transport. +5. The client invokes `cryptoSSH.NewControlClientConn(rawConn)` to negotiate channel demultiplexing, and wraps the connection via `muxProxySSHConn`: + ```go + type muxProxySSHConn struct { + cryptoSSH.Conn + rawConn net.Conn + target TargetContext + remoteAddr net.Addr + localAddr net.Addr + user string + } + ``` +6. The client constructs `*cryptoSSH.Client` using `cryptoSSH.NewClient(proxyConn, chans, reqs)`. This client is completely transparent to upper-layer services (SFTP subsystem clients, interactive PTY requests, environment variable passing, probe dialect caches) with zero feature degradation. +7. **Flow control and channel ordering** (`core/ssh/mux_server.go:proxySessionState`): + - The master-to-client direction honours the receive window and maximum packet size the client advertised in its channel open. Every data packet reserves its exact size through `clientFlowWindow.reserve` first and the credit is refunded by `SSH_MSG_CHANNEL_WINDOW_ADJUST`. Ignoring the window makes `x/crypto` fail the whole multiplexed connection with `ssh: remote side wrote too much` once 2 MiB is outstanding, which large downloads and high-throughput commands would hit. + - The client-to-master direction never blocks the read loop: client data is queued per channel (`orderedQueue`) and written to the remote channel by a dedicated relay goroutine, which credits the client window only after the write succeeded. Relaying inline would let one channel whose remote end stopped reading stdin stall the shared read loop, and with it every other channel on the master (including `-O` queries and new sessions). + - Global requests (`tcpip-forward`, keepalive probes) also go through one ordered worker: RFC 4254 global replies carry no request ID, so concurrent replies would be paired with the wrong request. + - No late packet is written for a channel the client already closed: `x/crypto` fails the entire multiplexed connection on a non-request packet for an unknown channel ID. + - **A channel close is answered exactly once**: when the client sends `SSH_MSG_CHANNEL_CLOSE`, the master must answer with its own close (RFC 4254 §5.3; `x/crypto` only releases the client-side channel and wakes blocked readers after that answer). `proxyChan.closeSent`, written under `writeMu`, is the single close marker: both `handleClose` and the natural end of the remote streams call the same `closeChannel`, so exactly one packet is sent, and `writeChannelPacket` drops every later channel packet (data, extended data, EOF, window adjust, channel request, success/failure reply) instead of tearing down the client's mux with a post-close packet. Once the whole proxy session ends (`closeAll`), `writeChannelPacket`, `writePacket` and `closeChannel` suppress every write through the same `closed` flag, so woken relay workers neither write into the dead transport nor wait for an answer that cannot arrive. + +### 3.3 Classic Passenger Mode & Unix `SCM_RIGHTS` fd Passing + +To maintain full compatibility with system-native `/usr/bin/ssh` connecting to a `xops-cli` master socket, standard passenger mode is implemented on Unix platforms (Linux/macOS/BSD) in `core/ssh/mux_rights_unix.go`: +- **`MUX_C_NEW_SESSION`**: Encodes `want_tty`, `want_x11_forwarding`, `want_agent`, and `subsystem_flag` as 4-byte `uint32` (`0` or `1`) per OpenSSH `PROTOCOL.mux` v4. The master receives 3 file descriptors (stdin, stdout, stderr) transmitted via Unix control messages (`recvFDs(unixConn, 3)` using `net.UnixConn.ReadMsgUnix` and `unix.ParseUnixRights`), opens a raw `"session"` channel (`sshClient.OpenChannel("session", nil)`) supporting `exec`, `shell`, and `subsystem` (e.g. `sftp`), half-closes the remote channel via `CloseWrite()` (`SSH_MSG_CHANNEL_EOF`) on local stdin EOF, and transmits `MUX_S_EXIT_MESSAGE` after closing inherited descriptors upon session completion. +- **Descriptor receive contract**: OpenSSH's `mm_send_fd` transmits one descriptor per control message, so `recvFDs` accumulates until the request is satisfied instead of assuming one `recvmsg` carries them all. Receiving is bounded (5s, `passengerFDReceiveTimeout`), every descriptor is marked `FD_CLOEXEC` immediately, and descriptors beyond the requested count are closed rather than handed to the caller. +- **Single ownership**: `recvFiles` hands each descriptor to an `*os.File` and only `File.Close()` may release it afterwards. A handler must never `unix.Close` the same descriptor number again: the second close can hit an unrelated descriptor a concurrent goroutine has since reused. `MUX_C_NEW_STDIO_FWD` returns only after both copy directions exit, so deferred cleanup cannot race an in-flight write. +- **`MUX_C_NEW_STDIO_FWD`**: Encodes `connect_port` as `uint32` per `PROTOCOL.mux` v4; receives 2 file descriptors (stdin and stdout), establishing bidirectional streaming over `Client.Dial("tcp", host:port)` and calling `CloseWrite()` on local stdin EOF while draining remote output. +- **Cross-Platform Fallback**: On Windows (`core/ssh/mux_rights_other.go`), because Winsock `AF_UNIX` does not support Unix file descriptor passing (`SCM_RIGHTS`), passenger requests are rejected with a clear diagnostic directing clients to use `MUX_C_PROXY`. + +### 3.4 Resource Limits & DOS Defense + +To defend against memory exhaustion (OOM) from malicious or corrupted clients: +- `MaxMuxPacketLength = 256 * 1024` (256 KB) +- `MaxMuxStringLength = 64 * 1024` (64 KB) +- Length fields are strictly validated before allocating buffers for `io.ReadFull`. + +--- + +## 4. Unix Domain Socket Security & Lifecycle + +### 4.1 Strict Permission Enforcement + +Socket files represent a critical local trust boundary. If an unauthorized local user could write to the control socket, they could hijack sessions to the remote server via the master. +- Parent directory permissions are enforced as `0700` (`rwx------`). +- The control socket file itself is enforced as `0600` (`rw-------`). +- To avoid mutating process-wide umask (`unix.Umask`), which causes race conditions with concurrent goroutines and file writes, `xops-cli` never modifies process umask, instead enforcing permissions explicitly via `os.Chmod(targetPath, 0600)`. + +### 4.2 Atomic Hard-Link Creation & Race Defense + +When multiple concurrent processes independently attempt to start a master, binding directly via `net.Listen("unix", path)` introduces file overwrite and TOCTOU vulnerabilities. + +`core/ssh/mux_rights_unix.go` implements an atomic creation algorithm based on POSIX `link(2)` (`bindAndLinkSocket`): +1. **Three-Tier Path Budget Strategy**: Given OS buffer limits for `sockaddr_un.sun_path` (108 bytes on Linux/Windows, 104 bytes on Darwin/BSD): + - If target path budget has $\ge 8$ bytes remaining, append a compact suffix (`path.m%04x.t`). + - Else if parent directory budget has $\ge 8$ bytes remaining, create a compact temporary path inside the directory (`dir/.m%04x.t`). + - If path budget is completely exhausted (extreme path lengths), fallback to direct binding on the target path with immediate chmod. +2. **Atomic Hard Link**: The process binds and chmods the temporary path, then invokes `os.Link(tempPath, targetPath)`: + - If link succeeds: this process won the master election and immediately unlinks the temporary path (`os.Remove(tempPath)`). + - If `EEXIST` is returned: another master was concurrently established; this process closes its temporary listener and returns `ErrMasterAlreadyExists`. + +### 4.3 Peer UID Verification (Both Directions) + +Even with `0600` file permissions, local root or users with elevated capabilities could connect to the socket. For defense-in-depth: +- **Linux** (`mux_peer_linux.go`): Queries peer credentials via `unix.GetsockoptUcred(fd, unix.SOL_SOCKET, unix.SO_PEERCRED)`. +- **macOS / FreeBSD** (`mux_peer_darwin.go` / `mux_peer_freebsd.go`): Queries peer credentials via `unix.GetsockoptXucred(fd, unix.SOL_LOCAL, unix.LOCAL_PEERCRED)`. +- **Validation**: The master verifies that the peer UID matches the expected local user UID. Any mismatch causes immediate connection termination with an audit entry and `ErrUnauthorizedPeer`. +- **Client-side parity**: `DialMuxClient` / `DialMuxProxyStream` / `DialControlProxy` run the same `checkPeerUID` before the handshake and require the master endpoint to belong to the effective local UID (default `localPeerUID()`) or `0` (root). Without it, any local user could win the race for a socket path in a shared directory such as `/tmp` and impersonate the master to collect commands, privilege passwords, and transferred data. `WithExpectedPeerUID` only changes the expectation and can never disable the check: `0` means "expect root", so a client running as root also rejects a socket planted by another user (treating `0` as "skip the check" allowed exactly that hijack). Only platforms without peer credentials (Windows) make `checkPeerUID` a no-op. +- **Windows** (`mux_rights_other.go`): Because Windows AF_UNIX relies on NTFS security descriptors (DACLs) and lacks `SO_PEERCRED`, `localPeerUID()` returns `0` and peer UID checks are safely bypassed on this platform. +- **Master-side default**: `StartMuxServer` falls back to `localPeerUID()` (the effective UID on Unix) when `Environment.LocalUID` is missing or unparseable, so a host that forgets to inject its context can never silently disable the master-side credential check; an explicit `LocalUID` still wins. `Connector` derives its client-side expectation from the same `expectedPeerUIDFromEnvironment`, so both ends agree even when a host injects a UID that differs from the effective one (containers). + +### 4.4 Stale Socket Detection and Safe Unlinking + +If a prior master crashed abnormally (`kill -9` or power failure), an inactive socket file remains on disk. +- Before connecting, the client verifies via `os.Lstat` and `isControlSocketFileInfo` that the path is an actual Unix domain socket (never unlinking regular files or directories on Unix), and only treats the socket as stale when the mode permits master creation (`Master != no`) and dialing explicitly returns `ECONNREFUSED` (transient handshake timeouts never unlink a live busy master). +- **Race-Free Unlink**: To prevent unlinking a newly started master created by a concurrent process, unlinking verifies `os.SameFile(origFi, currentFi)`. The file is only removed if the on-disk inode and device match the initial failed probe. + +--- + +## 5. Configuration Model & 4-Tier Resolution Hierarchy + +### 5.1 4-Tier Resolution Precedence + +`pkg/adapter/ssh_adapter.go:ResolveControl` evaluates multiplexing configurations strictly according to the following precedence: + +$$\text{Tier 1 (CLI Session Overrides)} > \text{Tier 2 (Node Config)} > \text{Tier 3 (Global Defaults)} > \text{Tier 4 (OpenSSH Config)}$$ + +1. **Tier 1 (CLI Session Overrides)**: Command-line flags `-M`, `-M -M`, `-S `, `-S none`, and `-o Key=Value`. +2. **Tier 2 (Node Configuration)**: Target node definition in `config.yaml` (`nodes[name].control`). +3. **Tier 3 (Global Defaults)**: Root-level default configuration in `config.yaml` (`control`). +4. **Tier 4 (OpenSSH Config Fallback)**: Parsed from `~/.ssh/config` matching the target host. Within Tier 4, concrete host entries take precedence over wildcard patterns (`Host *`). + +### 5.2 Orthogonal Field-by-Field Merging + +The three primary configuration fields (`Master`, `Path`, `Persist`) are orthogonal and merged independently across tiers: +- A higher tier leaves unset fields to be inherited from lower tiers. +- **Explicit Disablement (`-S none` or `Path: "none"`)**: If any higher tier explicitly specifies `Path: "none"`, multiplexing is disabled globally for that session: `Master` is normalized to `"no"`, `Persist` is cleared, and the client falls back to direct connection. + +### 5.3 ControlPath Token Expansion + +`core/ssh/control_config.go:ExpandControlPath` supports standard OpenSSH `ssh_config(5)` tokens: + +| Token | Expansion Meaning | Example | +| :--- | :--- | :--- | +| `%%` | Literal percent sign | `%` | +| `%h` | Remote target hostname or IP address | `192.168.1.10` / `prod-app01` | +| `%p` | Remote target port (defaults to 22) | `22` / `2222` | +| `%r` | Remote target username | `root` / `ubuntu` | +| `%n` | Original target host argument as supplied on CLI | `app-alias` | +| `%u` | Local logged-in username | `wuyue` | +| `%l` | Local host FQDN hostname | `devbox.localdomain` | +| `%L` | Local hostname up to the first dot | `devbox` | +| `%i` | Local user numeric UID | `1000` | +| `%d` | Local user home directory | `/home/wuyue` | +| `%j` | ProxyJump bastion host (if configured) | `bastion.corp` | +| `%C` | **Connection Feature Hash** (40-char SHA-1 hex) | `b4a2...3f1c` | +| `~` | Tilde prefix expanded to local home directory | `~/.ssh/sockets` -> `/home/wuyue/.ssh/sockets` | +| `${VAR}` | Environment variable expansion | `${XDG_RUNTIME_DIR}/ssh.sock` | + +### 5.4 `%C` Hash Token & Socket Length Constraints + +Unix domain sockets are constrained by kernel buffer sizes: +- Linux / Windows: 108 bytes +- macOS (Darwin) / FreeBSD / OpenBSD: 104 bytes + +Socket templates such as `~/.ssh/cm-%r@%h:%p` easily exceed 104 bytes when using long usernames, FQDNs, or nested paths, triggering `bind: invalid argument` (EINVAL). OpenSSH addresses this via `%C`. +`ComputeConnectionHash` strictly mirrors the OpenSSH algorithm: + +$$\text{Hash} = \text{SHA-1}(\%l + \%h + \%p + \%r + \%j)$$ + +The result is a fixed 40-character lowercase hexadecimal string. The recommended standard socket path is: +`~/.ssh/cm-%C` (approximately 54 bytes, comfortably within all OS limits). + +### 5.5 Schema V2 Declarations & CAS Entity Versioning + +In `pkg/config/types.go` and `schema_v2.go`, `ControlConfig` is integrated into the configuration schema: + +```yaml +# config.yaml (Schema V2) +control: + master: auto + path: ~/.ssh/cm-%C + persist: 10m + +nodes: + web01: + host: 10.0.0.1 + user: ops + control: + master: "yes" + path: /tmp/custom-%h-%p + persist: 0 +``` + +- **Validation**: `ValidateControlConfig` verifies enum values, persist duration syntax, and unexpanded path token syntax. +- **CAS Versioning**: `ControlConfig` participates in the `NodeRef` CAS content hash (`pkg/config/repository.go:nodeEntityVersion`), so a change invalidates dependent optimistic-concurrency preconditions for that node. `nodeAuthVersion` stays scoped to authentication material and does not include multiplexing configuration. + +--- + +## 6. CLI Commands & Control Subcommands + +### 6.1 CLI Flag Specification + +`cmd/ssh_option_flag.go` provides standard flags across commands: + +- **`xops ssh`**: + - `-M`: Enable master mode (equivalent to `-o ControlMaster=yes`). + - `-M -M` (repeated): Enable confirmation master mode (equivalent to `-o ControlMaster=ask`). + - `-S `: Specify control socket path (or `-S none` to disable connection sharing). + - `-o Key=Value`: Pass OpenSSH options (`ControlMaster`, `ControlPath`, `ControlPersist`, `Port`, `User`, `IdentityFile`, `ProxyJump`). + - `-O `: Send control command to active master. +- **`xops scp`, `xops sftp`, `xops exec`**: + - Register `-S` and `-o` flags. + - Automatically evaluate 4-tier resolution: if an active master socket exists for the target node, reuse it transparently without requiring extra flags. +- **`xops play`**: + - Automatically reuses active control sockets across consecutive steps for the same node, eliminating repeated handshake latency. + +### 6.2 `-O` Control Commands & OpenSSH Diagnostic Output + +`cmd/ssh_control.go` implements OpenSSH-aligned control actions: + +| Command (`-O`) | Function | Standard Output / Diagnostic (stderr) | Exit Code | +| :--- | :--- | :--- | :--- | +| `check` | Verify if master is alive and return PID | `Master running (pid=)` | 0 | +| `exit` | Request master to disconnect and exit | `Exit request sent.` | 0 | +| `stop` | Request master to stop listening on socket while retaining existing sessions; a master with nothing to drain retires immediately | `Stop listening request sent.` | 0 | +| `forward` | Request dynamic/local/remote port forwarding on master (`-L`, `-R`, `-D`) | `Allocated port ...` / `Forwarding request sent.` | 0 | +| `cancel` | Cancel an existing port forwarding on master (`-L`, `-R`, `-D`) | `Forwarding cancel request sent.` | 0 | +| `proxy` | Align with OpenSSH `-O proxy` semantics: connect to the active master via `MUX_C_PROXY` and execute the requested remote command or session over the multiplexed connection (requires a target `` or `--host `) | Remote command/session stdout & stderr | Remote exit status (255 on socket/proxy/validation failure) | +| `proxy-stream` | (Extension) Transition current stdio to a raw `MUX_C_PROXY` bidirectional byte-stream pipe with the master (`` may be omitted when `-S` is specified) | Binary stdio stream | 0 | +| `conninfo` | (Extension) Query master connection metadata | Connection summary text | 0 | +| `channels` | (Extension) Query active open channels on master | Channel list text | 0 | + +### 6.3 Strict Error Formatting and Exit Code Propagation + +OpenSSH control commands observe strict conventions: +1. **Exit Code Specification**: Returns `0` on success, and strictly `255` on any failure (missing socket, connection refused, master error, invalid arguments). Flag-level failures behave the same: `-O restart`, `-O check -M`, `-O check -S none`, and `-O` with an invalid `-o` all return `ControlExitError{Code: 255}` before any socket is contacted, and the top-level handler prints the message once in plain text (the `Reported` marker prevents double printing). +2. **Error Message Formatting**: Socket connection failures format syscall errors identically to OpenSSH: + `Control socket connect(/path/to/socket): No such file or directory` or + `Control socket connect(/path/to/socket): Connection refused` + without extra logging prefixes like `Error: `. +3. **CLI Root Support**: In `cmd/cli/main.go`, `cmd.ExitCodeFromError(err)` extracts the exit code from `ControlExitError`, preserving code 255; when `cmd.ErrorAlreadyReported(err)` is false the handler prints the message itself (without `logger.PrintError` coloring). +4. **`-O` without a destination**: Except for `-O proxy` (which opens a remote session and requires a target host), management and raw-stream commands (`check`/`exit`/`stop`/`forward`/`cancel`/`proxy-stream`/`conninfo`/`channels`) with `-S /path/to/socket` may omit ``, because the request addresses the socket itself. Without `-S` the socket must still resolve from the node, a global default, or the OpenSSH configuration, so a missing target remains an error. + +--- + +## 7. ControlPersist Background Daemon Lifecycle + +### 7.1 Persist Syntax Parsing + +`core/ssh/control_config.go:ParseControlPersist` parses OpenSSH persist values: +- `"no"` / `"false"` / `""`: Disabled; master terminates immediately upon foreground exit. +- `"yes"` / `"true"` / `"0"`: Indefinite persistence; master persists in background indefinitely until stopped via `-O exit` or OS shutdown. +- **Duration Units**: Conforms to `sshd_config(5)` time formats: + - `s` / `S`: Seconds (bare integers default to seconds, e.g. `"600"` -> 600s) + - `m` / `M`: Minutes (e.g. `"10m"` -> 600s) + - `h` / `H`: Hours (e.g. `"1h"` -> 3600s, `"1h30m"` -> 5400s) + - `d` / `D`: Days (e.g. `"1d"` -> 86400s) + - `w` / `W`: Weeks (e.g. `"1w"` -> 604800s) + +### 7.2 Foreground Session Protection & Idle State Machine + +`core/ssh/mux_server.go` maintains thread-safe state management: +1. **Active Session Counter (`activeSessions atomic.Int32`)**: + - Starting a foreground master invokes `srv.AddForegroundSession()`, setting count to 1. + - Incoming passenger or proxy clients increment the counter. + - While `activeSessions > 0`, the idle timer is held inactive. +2. **Session Release & Idle Trigger**: + - When a client disconnects and `removeActiveSession()` drops `activeSessions` to 0: + - If `Persist` is disabled: master immediately executes graceful shutdown (`s.Shutdown()`). + - If `Persist` is indefinite: master remains running with no timer scheduled. + - If `Persist` specifies a duration: starts `time.Timer` tracked by generation counter (`idleGen`). +3. **Generation Counter Race Protection (TOCTOU Defense)**: + - If a new client connects while the idle timer is pending, `addActiveSession()` increments `idleGen` under mutex protection and stops the timer. + - If a stale timer fires concurrently, `currentGen != timerGen` ensures the timeout is discarded, preventing premature termination of active masters. + +### 7.3 Background Daemon Transition & `srv.Wait()` + +When running `xops ssh -f -N` or when the foreground session exits: +- The command loop waits on `srv.Wait()` for master shutdown. +- Upon receiving termination signals (SIGINT/SIGTERM), `-O exit`, or idle timeout expiration, `shutdownOnce` ensures unified cleanup: unlinking the socket, closing the listener, terminating channels, and closing the underlying SSH transport. + +### 7.4 Master Ownership & `ControlPersist` Handoff (Implementation Contract) + +Multiplexing configuration only matters while a master is alive, so ownership must be explicit: + +1. **The starting `Connector` owns its masters**: `Connector.masters` records every `MuxServer` this instance started, and `Connector.CloseAll()` shuts them down before closing the pool, releasing the control socket and the `activeMuxServers` entry. Otherwise `xops exec/scp/sftp/play/ssh` leaves a socket that dials successfully but fronts a closed transport, forcing every later command to probe and clean it up. + - **The connector is itself an active session**: `maybeStartMasterListener` creates the master with `MuxMasterOptions.InitialActiveSessions = 1` and keeps that reference until `ReleaseMaster` or `CloseAll`. Without it, a `ControlPersist=30s` master arms its idle timer at creation and cuts off `sftp`/`scp`/`exec`/`play` (and jump hosts) that run longer than that window; with `ControlPersist=no`, another client disconnecting drops `activeSessions` to 0 and kills them the same way. + - **`ReleaseMaster` drops the reference without stopping the master**: the handoff uses it to make the master idle-eligible. The master stays in `Connector.masters`, so `CloseAll` still cleans up when the handoff fails, and a repeated release cannot drive the counter negative. +2. **A `-N` process holds its master until shutdown**: `xops ssh -M -N` (including the `-f -N` child) retains the single foreground-session reference acquired by `Connector` (`InitialActiveSessions = 1`) and waits on `select { <-runCtx.Done(); <-srv.Wait() }` in `runConnectedSession`, avoiding double-counting (`activeSessions == 2`) while ensuring the first client disconnect never kills the `-N` process. +3. **One-shot and interactive sessions hand the master to a background daemon**: when `ControlPersist` is enabled and the foreground session ends, `SshOptions.handoffPersistentMaster` releases and shuts down every master started by this `Connector` (including intermediate `ProxyJump` masters) to free the sockets, re-execs a child marked `XOPS_CLI_SSH_MUX_DAEMON=1` and `XOPS_CLI_SSH_BG_CHILD=1` that only serves the control socket (forced `-N`, empty command, `--*-stdin` reads skipped, stdio on `/dev/null`), then confirms the new master within a bounded `waitForControlSocket` (10s). A failed handoff writes a stderr warning and never changes the exit status of the command that already ran. + - **No handoff** for a `-N` process (it already owns the master, so a second fork would duplicate it), for the `-f` background child, for the daemon itself (`XOPS_CLI_SSH_MUX_DAEMON=1`), for `ControlMaster=ask/autoask` (a headless daemon detached from the terminal cannot display confirmation prompts, so handing off would cause all subsequent sessions to fail closed), for **implicit Tier 4 (`~/.ssh/config`) configurations** (`ControlConfig.ImplicitTier4 == true`: when multiplexing comes solely from `~/.ssh/config` without explicit CLI flags or `xops` Node/Global configuration, existing sockets are reused transparently during the command, but no detached background daemon is spawned on command exit), or when the session was cancelled (Ctrl-C / context cancellation): reviving the master there would resurrect what the operator just stopped. + - **Isolation for credential verification and MCP**: Credential verification (`adapter.VerifyConnection`) and MCP Host runtime connectors explicitly pass `adapter.WithControlMultiplexing(false)` so they never attach to or create external control sockets on disk, guaranteeing that credential checks always perform a real authentication handshake and MCP sessions remain isolated. + - **One-time credentials are never handed off**: a session that used `--otp` does not start a daemon. The code was already consumed by the parent; replaying it in the child cannot succeed and can trip server-side MFA lockouts. + - **An early daemon exit no longer stalls the shell**: the parent watches the child with `cmd.Wait()`, so `waitForControlSocket` returns and warns as soon as the child dies instead of waiting out the 10s readiness timeout. + - **The daemon must not hold a foreground session**: a bounded `ControlPersist=10m` relies on the idle timer expiring. The daemon calls `Connector.ReleaseMaster` right after connecting, so `activeSessions` returns to 0 and the idle timer arms normally; keeping the reference like a foreground process would leave `activeSessions > 0` forever, so the timer never fires and the process leaks. A `-N` foreground master is the opposite case and must be counted, otherwise the first client disconnect kills it. + - **The handoff releases the connector reference first**: `handoffPersistentMaster` calls `Connector.ReleaseMaster` before `PrepareHandoff()`; otherwise the master never reports "no active sessions" and the transfer is always refused. + - **Handoff drains external users before reserving the master**: when a foreground command (`ssh`/`scp`/`sftp`/`exec`) finishes, `drainOwnedMasters` (`MuxServer.WaitForDrain`) first waits for any attached secondary client sessions or `-O forward` listeners to finish while the connector still holds its foreground reference (preventing premature idle timeouts), then calls `Connector.ReleaseMaster` and `MuxServer.PrepareHandoff()` to atomically reserve the master (rejecting new `addActiveSession` and `-O forward` requests, with `CancelHandoff` rolling back partial reservations across multi-hop jump chains). If `-O stop`/`-O exit` was received, the master drains and exits without spawning a daemon, never cutting off active users or resurrecting a stopped master. +4. **The `-f` parent must not claim the socket**: `runParentDaemon` only validates connectivity and tunnels and builds its connector with a **node-scoped** `SessionControlOptions{Path: "none"}` (`disableSessionControlForParent`), leaving `ControlPath` to the background child. It must be node-scoped because `buildAdapterOptions` may already have registered a session `-M/-S/-o` override for that node, and a node override outranks the global default, so `WithGlobalSessionControl` would be ignored. Otherwise the parent binds the socket, closes its client, and exits while the child attaches to a master that is about to disappear. +5. **A jump-chained target is reused before its chain is built**: `Connector.connectPlan` first tries the cached transport and any active control master for the final target (`reuseJumpChainedTarget`). On a hit the bastion is never dialed or re-authenticated (including its OTP/MFA factors). Cached intermediate hops are still probed first so a bastion that stopped answering while it keeps forwarding cannot be hidden by the target's own probe. +6. **`-M` never degrades into a passenger**: with `ControlMaster=yes/ask`, a live master on the path is not reused as a proxy; the command dials its own connection and the later bind reports the conflict (matching OpenSSH's "socket already exists, disabling multiplexing"). +7. **`ControlMaster=ask/autoask` must really ask**: `MuxServerOptions.AskConfirmation` plus `ConfirmSession`, injected through `Connector.WithSessionConfirmer` (the CLI uses its `InteractionHandler`). A missing confirmer, a failing prompt, or a refusal always yields `MUX_S_PERMISSION_DENIED`; there is no silent admission. + +--- + +## 8. Localization & i18n + +CLI help and flag usage hints are localized in `pkg/i18n/locales/` (`active.zh.yaml` and `active.en.yaml`). `-O` runtime output is deliberately excluded: the `check`/`exit`/`stop`/`forward`/`cancel` text and error prefixes are a compatibility contract with the system `ssh -O` command (section 6.4), so they must stay byte-identical to OpenSSH and are asserted verbatim by tests instead of being translated. + +| Localization Key | Chinese Translation | English Translation | +| :--- | :--- | :--- | +| `flag_ssh_master` | 启用连接共享 Master 模式;重复指定进入确认模式 | Places the ssh client into 'master' mode for connection sharing; repeated flags place ssh into confirmation mode | +| `flag_ssh_control_path` | 指定连接共享控制套接字路径,或使用 'none' 禁用 | Specify path to control socket for connection sharing, or 'none' to disable | +| `flag_ssh_option` | 以 Key=Value 格式传递配置选项 | Can be used to give options in the format used in the configuration file (Key=Value) | +| `flag_ssh_control_command` | 向运行中的主进程发送控制命令:check, exit, stop, forward, cancel, proxy, proxy-stream, conninfo, channels | Send control command to active master: check, exit, stop, forward, cancel, proxy, proxy-stream, conninfo, channels | + +--- + +## 9. Testing & Quality Assurance Matrix + +To guarantee robustness against regressions, the project maintains a layered quality gate: + +| Verification Level | Test Scope | Verification Command | Acceptance Criteria | +| :--- | :--- | :--- | :--- | +| **Architecture Decoupling** | Verify `core/**` zero imports from `cmd/**`, `pkg/**`, or `internal/**` | `python3 -m unittest discover -s scripts -p test_check_core.py`
`python3 scripts/check_core.py --race` | 0 violations, isolated tests pass | +| **Wire Protocol Unit Tests** | `core/ssh` packet serialization, big-endian encoding, length checks, token expansion | `go test -v -race ./core/ssh/` | 100% pass, zero data races | +| **Socket Concurrency & Stress** | Master concurrent proxy requests, channel contention, async WaitGroup sync | `go test -v -race -shuffle=on -count=1 ./core/ssh/` | 100% pass, zero deadlocks | +| **Configuration & 4-Tier Hierarchy** | 4-tier precedence, `-S none` disablement, OpenSSH `~/.ssh/config` parsing | `go test -v -race ./pkg/config/... ./pkg/adapter/...` | 100% pass | +| **CLI Control & Exit Codes** | `-O` command execution, exit code 255 propagation, stderr format, flag parsing | `go test -v -race ./cmd/...` | 100% pass | +| **Playbook Connection Reuse** | Multi-step tasks and cross-node socket sharing with zero re-authentication | `go test -v -race ./pkg/playbook/` | 100% pass | +| **Static Code Quality** | Repository-wide linting, complexity checks, and bug analysis | `golangci-lint run ./...` | 0 warnings, 0 errors | +| **Bilingual Documentation Sync** | Consistency of Chinese and English page paths, sections, and public pages | `npm run docs:check` | 0 discrepancies, Page paths match | diff --git a/internal/mcphost/connector.go b/internal/mcphost/connector.go index a85599ec..b4057503 100644 --- a/internal/mcphost/connector.go +++ b/internal/mcphost/connector.go @@ -25,7 +25,7 @@ func newNonInteractiveMCPConnector(provider config.ConfigProvider, l corelog.Deb if cfg := provider.Snapshot(); cfg != nil && cfg.PasswordPromptPattern != "" { sshOpts = append(sshOpts, ssh.WithPasswordPromptPattern(cfg.PasswordPromptPattern)) } - adpOpts := []adapter.Option{adapter.WithNonInteractive(true)} + adpOpts := []adapter.Option{adapter.WithNonInteractive(true), adapter.WithControlMultiplexing(false)} if credentialRegistry != nil { adpOpts = append(adpOpts, adapter.WithCredentialSource(credentialRegistry)) } diff --git a/internal/mcphost/host.go b/internal/mcphost/host.go index 1201c30f..a0843e19 100644 --- a/internal/mcphost/host.go +++ b/internal/mcphost/host.go @@ -17,6 +17,7 @@ import ( "github.com/wentf9/xops-cli/core/mcp/tunnel" "github.com/wentf9/xops-cli/core/sftp" "github.com/wentf9/xops-cli/core/ssh" + "github.com/wentf9/xops-cli/internal/sshenv" "github.com/wentf9/xops-cli/pkg/adapter" "github.com/wentf9/xops-cli/pkg/config" cryptoSSH "golang.org/x/crypto/ssh" @@ -90,7 +91,15 @@ func nodeInfo(provider config.ConfigProvider, id string) (ports.NodeInfo, error) if err != nil { return ports.NodeInfo{}, err } - return ports.NodeInfo{ID: id, Alias: slices.Clone(node.Alias), Tags: slices.Clone(node.Tags), Address: fmt.Sprintf("%s:%d", host.Address, host.Port), User: identity.User, AuthType: identity.AuthType, ProxyJump: node.ProxyJump}, nil + port := host.Port + if port == 0 { + port = 22 + } + user := strings.TrimSpace(identity.User) + if user == "" { + user = sshenv.Discover().LocalUser + } + return ports.NodeInfo{ID: id, Alias: slices.Clone(node.Alias), Tags: slices.Clone(node.Tags), Address: fmt.Sprintf("%s:%d", host.Address, port), User: user, AuthType: identity.AuthType, ProxyJump: node.ProxyJump}, nil } func (h *host) Resolve(ctx context.Context, request ports.ResolveRequest) (ports.OperationSnapshot, error) { @@ -105,7 +114,7 @@ func (h *host) resolveSnapshot(ctx context.Context, request ports.ResolveRequest return ports.OperationSnapshot{}, err } view := ports.OperationSnapshot{DomainID: h.domain, Policy: h.policy, Targets: make(map[string]ports.Target), Selectors: make(map[string]string)} - adp := adapter.NewSSHAdapter(p, adapter.WithNonInteractive(true), adapter.WithCredentialSource(h.config.Registry), adapter.WithCredentialRecording(false)) + adp := adapter.NewSSHAdapter(p, adapter.WithNonInteractive(true), adapter.WithControlMultiplexing(false), adapter.WithCredentialSource(h.config.Registry), adapter.WithCredentialRecording(false)) for _, selector := range request.Selectors { if selector == "" { continue diff --git a/internal/sshenv/environment.go b/internal/sshenv/environment.go index b0065af4..950129f1 100644 --- a/internal/sshenv/environment.go +++ b/internal/sshenv/environment.go @@ -2,6 +2,7 @@ package sshenv import ( "os" + "os/user" "path/filepath" "strings" @@ -12,11 +13,34 @@ import ( // Callers explicitly inject the result into core/ssh with WithEnvironment. func Discover() core.Environment { userDirectory, err := os.UserHomeDir() + currentUser, _ := user.Current() + hostname, _ := os.Hostname() + + localUser := "" + localUID := "" + if currentUser != nil { + localUser = currentUser.Username + localUID = currentUser.Uid + if userDirectory == "" { + userDirectory = currentUser.HomeDir + } + } else { + localUser = os.Getenv("USER") + if localUser == "" { + localUser = os.Getenv("USERNAME") + } + } + environment := core.Environment{ InitializationError: err, AgentSocket: os.Getenv("SSH_AUTH_SOCK"), InteractiveIO: core.InteractiveIO{Stdin: os.Stdin, Stdout: os.Stdout, Stderr: os.Stderr}, InputBridge: platformInputBridge(), + HomeDir: userDirectory, + LocalUser: localUser, + LocalHost: hostname, + LocalUID: localUID, + LookupEnv: os.LookupEnv, } if userDirectory != "" { environment.KnownHostsFile = filepath.Join(userDirectory, ".ssh", "known_hosts") diff --git a/pkg/adapter/ssh_adapter.go b/pkg/adapter/ssh_adapter.go index cbfdfd7c..60c6349d 100644 --- a/pkg/adapter/ssh_adapter.go +++ b/pkg/adapter/ssh_adapter.go @@ -3,7 +3,9 @@ package adapter import ( "context" "fmt" + "net" "regexp" + "strconv" "strings" "time" @@ -16,16 +18,95 @@ import ( "github.com/wentf9/xops-cli/pkg/models" ) -// SessionAuth 描述单个连接会话的临时认证机密覆盖 +// SessionAuth 描述单个连接会话的临时认证机密与跳板覆盖 type SessionAuth struct { Password string Passphrase string KeyPath string SuPwd string OTP string + ProxyJump string Remember bool } +// SessionControlOptions specifies session-level multiplexing overrides (Tier 1). +// Pointers indicate whether a flag was explicitly specified on the CLI. +type SessionControlOptions struct { + Master *ssh.ControlMasterMode + Path *string + Persist *string +} + +// IsEmpty returns true if no session control flags were provided. +func (s SessionControlOptions) IsEmpty() bool { + return s.Master == nil && s.Path == nil && s.Persist == nil +} + +// SetMaster sets the ControlMaster override. +func (s *SessionControlOptions) SetMaster(m ssh.ControlMasterMode) { + s.Master = &m +} + +// SetPath sets the ControlPath override. +func (s *SessionControlOptions) SetPath(p string) { + s.Path = &p +} + +// SetPersist sets the ControlPersist override. +func (s *SessionControlOptions) SetPersist(p string) { + s.Persist = &p +} + +// supportedSSHOptions lists the -o keys this CLI understands. Unknown keys are +// rejected instead of ignored so a typo cannot silently disable the requested +// behavior. +var supportedSSHOptions = []string{ + "controlmaster", "controlpath", "controlpersist", "port", "user", "identityfile", "proxyjump", +} + +// ParseOptionStrings parses OpenSSH -o Key=Value strings into SessionControlOptions. +func ParseOptionStrings(options []string) (SessionControlOptions, error) { + var opts SessionControlOptions + for _, opt := range options { + opt = strings.TrimSpace(opt) + if opt == "" { + continue + } + key, val, ok := strings.Cut(opt, "=") + if !ok { + return opts, fmt.Errorf("invalid -o option format (expected Key=Value): %q", opt) + } + key = strings.TrimSpace(key) + val = strings.TrimSpace(val) + + switch strings.ToLower(key) { + case "controlmaster": + m, err := ssh.ParseControlMasterMode(val) + if err != nil { + return opts, fmt.Errorf("invalid ControlMaster in -o option: %w", err) + } + opts.SetMaster(m) + case "controlpath": + opts.SetPath(val) + case "controlpersist": + if _, _, _, err := ssh.ParseControlPersist(val); err != nil { + return opts, fmt.Errorf("invalid ControlPersist in -o option: %w", err) + } + opts.SetPersist(val) + case "port": + p, err := strconv.ParseUint(val, 10, 16) + if err != nil || p == 0 { + return opts, fmt.Errorf("invalid Port in -o option: %q", val) + } + case "user", "identityfile", "proxyjump": + // Applied to the connection target by the CLI, see ApplyGeneralOptions. + default: + return opts, fmt.Errorf("unsupported -o option %q (supported: %s)", key, strings.Join(supportedSSHOptions, ", ")) + } + } + return opts, nil +} + // CredentialResolver 描述能够根据 Ref 解析 Secret 的契约(如 *credential.Registry) type CredentialResolver interface { Resolve(ctx context.Context, ref credential.Ref) (credential.Secret, error) @@ -58,6 +139,30 @@ func WithGlobalSessionAuth(auth SessionAuth) Option { } } +// WithSessionControlOverride registers session multiplexing overrides for a specific node. +func WithSessionControlOverride(nodeID string, opts SessionControlOptions) Option { + return func(a *SSHAdapter) { + if a.sessionControlOverrides == nil { + a.sessionControlOverrides = make(map[string]SessionControlOptions) + } + a.sessionControlOverrides[nodeID] = opts + } +} + +// WithGlobalSessionControl registers session multiplexing overrides for all nodes in the session. +func WithGlobalSessionControl(opts SessionControlOptions) Option { + return func(a *SSHAdapter) { + a.globalSessionControl = &opts + } +} + +// WithOpenSSHParser injects an explicit OpenSSHParser for Tier 4 resolution (e.g. in tests). +func WithOpenSSHParser(parser *config.OpenSSHParser) Option { + return func(a *SSHAdapter) { + a.openSSHParser = parser + } +} + // WithNonInteractive 标记该适配器为非交互式环境(如批处理、MCP、Playbook) func WithNonInteractive(nonInteractive bool) Option { return func(a *SSHAdapter) { @@ -80,6 +185,16 @@ func WithRememberConfirmation(confirm func(context.Context, string) (bool, error return func(a *SSHAdapter) { a.rememberConfirmation = confirm } } +// WithControlMultiplexing controls whether this adapter exposes a +// ControlProvider to the underlying ssh.Connector. Credential verification and +// MCP hosts disable it so they never attach to or create external control +// sockets on disk. +func WithControlMultiplexing(enabled bool) Option { + return func(a *SSHAdapter) { + a.disableControl = !enabled + } +} + // SSHAdapter 实现 ssh.ConnectionProvider, ssh.SecretResolver, ssh.CredentialRecorder 接口,作为业务模型与底层 SSH 的防腐层 type SSHAdapter struct { cfgProvider config.ConfigProvider @@ -87,7 +202,11 @@ type SSHAdapter struct { credentialService *credential.Service sessionOverrides map[string]SessionAuth globalSessionAuth *SessionAuth + sessionControlOverrides map[string]SessionControlOptions + globalSessionControl *SessionControlOptions + openSSHParser *config.OpenSSHParser nonInteractive bool + disableControl bool credentialRecording bool rememberConfirmation func(context.Context, string) (bool, error) enrollmentProbe bool @@ -110,6 +229,7 @@ var ( _ ssh.SecretResolver = (*SSHAdapter)(nil) _ ssh.CredentialRecorder = (*SSHAdapter)(nil) _ ssh.ConfigStore = (*SSHAdapter)(nil) + _ ssh.ControlProvider = (*SSHAdapter)(nil) ) // NewSSHAdapter 创建 SSH 适配器 @@ -161,6 +281,9 @@ func newConnector(adp *SSHAdapter, opts ...ssh.Option) *ssh.Connector { if cfg := adp.cfgProvider.Snapshot(); cfg != nil && cfg.PasswordPromptPattern != "" { finalOpts = append(finalOpts, ssh.WithPasswordPromptPattern(cfg.PasswordPromptPattern)) } + if !adp.disableControl { + finalOpts = append(finalOpts, ssh.WithControlProvider(adp)) + } finalOpts = append(finalOpts, opts...) return ssh.NewConnector(adp, finalOpts...) } @@ -183,6 +306,349 @@ func (a *SSHAdapter) getSessionOverride(nodeID string) (SessionAuth, bool) { return SessionAuth{}, false } +func (a *SSHAdapter) getSessionControlOverride(nodeID string) (SessionControlOptions, bool) { + if a.sessionControlOverrides != nil { + if override, ok := a.sessionControlOverrides[nodeID]; ok { + return override, true + } + } + if a.globalSessionControl != nil { + return *a.globalSessionControl, true + } + return SessionControlOptions{}, false +} + +// GetControlConfig implements ssh.ControlProvider. +func (a *SSHAdapter) GetControlConfig(nodeID string) (*ssh.ControlConfig, error) { + if a.disableControl { + return nil, nil + } + sessionOpts, _ := a.getSessionControlOverride(nodeID) + return a.ResolveControl(nodeID, sessionOpts) +} + +// ResolveControl resolves the effective ControlConfig for nodeID given session options, +// strictly enforcing the 4-tier precedence hierarchy and field-by-field merging semantics. +func (a *SSHAdapter) ResolveControl(nodeName string, sessionOpts SessionControlOptions) (*ssh.ControlConfig, error) { + if a.disableControl { + return nil, nil + } + if a.cfgProvider == nil { + return nil, fmt.Errorf("configuration provider is nil") + } + + // 1. Resolve Tier 2 (Node Config) and target context. OpenSSH virtual nodes + // carry their ~/.ssh/config values in Node.Control, but they remain Tier 4: + // they must not outrank the persisted global defaults. + var tier2NodeCtrl *ssh.ControlConfig + snapshot, snapErr := a.cfgProvider.ResolveConnection(nodeName) + virtualOpenSSHNode := strings.HasPrefix(nodeName, config.OpenSSHNodePrefix) + if snapErr == nil { + if virtualOpenSSHNode { + tier2NodeCtrl = nil + } else { + tier2NodeCtrl = snapshot.Node.Control + } + } + + // 2. Resolve Tier 3 (Global Defaults) + var tier3GlobalCtrl *ssh.ControlConfig + if globalCfg := a.cfgProvider.Snapshot(); globalCfg != nil { + tier3GlobalCtrl = globalCfg.Control + } + + // 3. Resolve Tier 4 (OpenSSH Config Fallback) + var tier4OpenSSHCtrl *ssh.ControlConfig + if virtualOpenSSHNode { + if snapErr == nil { + tier4OpenSSHCtrl = snapshot.Node.Control + } + } else { + tier4OpenSSHCtrl = a.resolveTier4OpenSSH(nodeName, snapErr, snapshot) + } + + // 4. Field-by-field merging: Tier 1 > Tier 2 > Tier 3 > Tier 4 + merged := mergeControlFields(sessionOpts, tier2NodeCtrl, tier3GlobalCtrl, tier4OpenSSHCtrl) + if merged == nil { + return nil, nil // No multiplexing configured + } + + // 5. Validation + if err := validateMergedControl(merged); err != nil { + return nil, err + } + + return merged, nil +} + +func (a *SSHAdapter) resolveTier4OpenSSH(nodeName string, snapErr error, snapshot config.ConnectionSnapshot) *ssh.ControlConfig { + if strings.HasPrefix(nodeName, config.OpenSSHNodePrefix) { + return nil + } + parser := a.getOpenSSHParser() + if parser == nil { + return nil + } + lookupKeys := a.buildTier4OpenSSHLookupKeys(parser, nodeName, snapErr, snapshot) + + // 1. First pass: prioritize concrete host matches (defined without wildcards like Host *) + // across OpenSSHAlias, nodeName, Host.Address, and Alias so specific definitions take precedence. + for _, key := range lookupKeys { + if parser.HasHost(key) { + if ctrl, err := parser.GetControlConfig(key); err == nil && ctrl != nil { + return ctrl + } + } + } + + // 2. Second pass: fallback to generic wildcard / pattern matches (e.g. Host *). + for _, key := range lookupKeys { + if ctrl, err := parser.GetControlConfig(key); err == nil && ctrl != nil { + return ctrl + } + } + return nil +} + +func (a *SSHAdapter) buildTier4OpenSSHLookupKeys(parser *config.OpenSSHParser, nodeName string, snapErr error, snapshot config.ConnectionSnapshot) []string { + var lookupKeys []string + appendUnique := func(keys ...string) { + for _, k := range keys { + k = strings.TrimSpace(k) + if k != "" && !slicesContain(lookupKeys, k) { + lookupKeys = append(lookupKeys, k) + } + } + } + + openSSHAlias := snapshot.Node.OpenSSHAlias + if openSSHAlias == "" { + openSSHAlias = a.lookupNodeOpenSSHAlias(nodeName) + } + if openSSHAlias != "" { + appendUnique(openSSHAlias) + } + if snapErr != nil { + if !strings.Contains(nodeName, "@") && !strings.Contains(nodeName, ":") { + appendUnique(nodeName) + } + if addr, port, ok := parseFallbackNodeIDAddressPort(nodeName); ok { + appendUnique(parser.FindConcreteHostsByAddress(addr, port)...) + if addr != nodeName { + appendUnique(addr) + } + } else { + appendUnique(nodeName) + } + return lookupKeys + } + + appendUnique(snapshot.Node.Alias...) + appendUnique(snapshot.Host.Alias...) + if !strings.Contains(nodeName, "@") && !strings.Contains(nodeName, ":") { + appendUnique(nodeName) + } + if snapshot.Host.Address != "" { + appendUnique(parser.FindConcreteHostsByAddress(snapshot.Host.Address, uint16(effectiveSnapshotPort(snapshot.Host.Port)))...) + if snapshot.Host.Address != nodeName { + appendUnique(snapshot.Host.Address) + } + } + appendUnique(nodeName) + return lookupKeys +} + +func (a *SSHAdapter) lookupNodeOpenSSHAlias(nodeName string) string { + if a == nil || a.cfgProvider == nil || nodeName == "" { + return "" + } + if p, ok := a.cfgProvider.(interface{ NodeOpenSSHAlias(string) string }); ok { + return p.NodeOpenSSHAlias(nodeName) + } + return "" +} + +func parseFallbackNodeIDAddressPort(nodeName string) (string, uint16, bool) { + spec := strings.TrimSpace(nodeName) + if at := strings.LastIndex(spec, "@"); at >= 0 { + spec = strings.TrimSpace(spec[at+1:]) + } + host, portText, err := net.SplitHostPort(spec) + if err != nil { + return "", 0, false + } + host = strings.TrimPrefix(strings.TrimSuffix(strings.TrimSpace(host), "]"), "[") + parsedPort, parseErr := strconv.ParseUint(strings.TrimSpace(portText), 10, 16) + if host == "" || parseErr != nil || parsedPort == 0 { + return "", 0, false + } + return host, uint16(parsedPort), true +} + +func slicesContain(items []string, target string) bool { + for _, item := range items { + if item == target { + return true + } + } + return false +} + +func validateMergedControl(merged *ssh.ControlConfig) error { + if merged.Master != "" { + m, err := ssh.ParseControlMasterMode(string(merged.Master)) + if err != nil { + return err + } + merged.Master = m + } + if merged.Persist != "" { + if _, _, _, err := ssh.ParseControlPersist(merged.Persist); err != nil { + return err + } + } + // Path == "none" disables multiplexing: normalize Master to "no" and clear Persist + if merged.Path == "none" { + merged.Master = ssh.ControlMasterNo + merged.Persist = "" + return nil + } + if merged.Master != ssh.ControlMasterNo && merged.Master != "" { + if merged.Path == "" { + return fmt.Errorf("ControlPath is required when ControlMaster is %s", merged.Master) + } + } + if merged.Persist != "" && merged.Path == "" { + return fmt.Errorf("ControlPath is required when ControlPersist is configured") + } + return nil +} + +func resolveMergedMaster(t1 SessionControlOptions, t2, t3, t4 *ssh.ControlConfig) ssh.ControlMasterMode { + if t1.Master != nil && *t1.Master != "" { + return *t1.Master + } + if t2 != nil && t2.Master != "" { + return t2.Master + } + if t3 != nil && t3.Master != "" { + return t3.Master + } + if t4 != nil && t4.Master != "" { + return t4.Master + } + return ssh.ControlMasterNo +} + +func resolveMergedPath(t1 SessionControlOptions, t2, t3, t4 *ssh.ControlConfig) string { + if t1.Path != nil { + return *t1.Path + } + if t2 != nil && t2.Path != "" { + return t2.Path + } + if t3 != nil && t3.Path != "" { + return t3.Path + } + if t4 != nil && t4.Path != "" { + return t4.Path + } + return "" +} + +func resolveMergedPersist(t1 SessionControlOptions, t2, t3, t4 *ssh.ControlConfig) string { + if t1.Persist != nil { + return *t1.Persist + } + if t2 != nil && t2.Persist != "" { + return t2.Persist + } + if t3 != nil && t3.Persist != "" { + return t3.Persist + } + if t4 != nil && t4.Persist != "" { + return t4.Persist + } + return "" +} + +func mergeControlFields( + t1 SessionControlOptions, + t2 *ssh.ControlConfig, + t3 *ssh.ControlConfig, + t4 *ssh.ControlConfig, +) *ssh.ControlConfig { + master := resolveMergedMaster(t1, t2, t3, t4) + path := resolveMergedPath(t1, t2, t3, t4) + persist := resolveMergedPersist(t1, t2, t3, t4) + + // Handle "-S none" or Path == "none": disables connection sharing + if path == "none" { + master = ssh.ControlMasterNo + persist = "" + } + + // Unconfigured check: if nothing was configured across all 4 tiers, return nil + if master == ssh.ControlMasterNo && path == "" && persist == "" { + return nil + } + + hasExplicitPersist := (t1.Persist != nil && *t1.Persist != "") || + (t2 != nil && t2.Persist != "") || + (t3 != nil && t3.Persist != "") + + return &ssh.ControlConfig{ + Master: master, + Path: path, + Persist: persist, + ImplicitTier4: !hasExplicitPersist && t4 != nil, + } +} + +func (a *SSHAdapter) getOpenSSHParser() *config.OpenSSHParser { + if a.openSSHParser != nil { + return a.openSSHParser + } + if p, ok := a.cfgProvider.(interface{ GetOpenSSHParser() *config.OpenSSHParser }); ok { + return p.GetOpenSSHParser() + } + return nil +} + +func effectiveSnapshotPort(port uint16) int { + if port == 0 { + return 22 + } + return int(port) +} + +func effectiveSnapshotUser(user string) string { + if u := strings.TrimSpace(user); u != "" { + return u + } + return sshenv.Discover().LocalUser +} + +// ExpandControlPath expands tokens in rawPath for nodeID using node snapshot context. +// Useful for CLI control commands (-O check/exit/stop) that directly connect to sockets. +func (a *SSHAdapter) ExpandControlPath(nodeID string, rawPath string) (string, error) { + if rawPath == "" || rawPath == "none" { + return rawPath, nil + } + snapshot, err := a.cfgProvider.ResolveConnection(nodeID) + if err != nil { + return "", fmt.Errorf("resolve node %q for control path expansion: %w", nodeID, err) + } + target := ssh.TargetContext{ + Host: snapshot.Host.Address, + Port: effectiveSnapshotPort(snapshot.Host.Port), + User: effectiveSnapshotUser(snapshot.Identity.User), + OriginalHost: nodeID, + JumpHost: snapshot.Node.ProxyJump, + } + return ssh.ExpandControlPath(rawPath, sshenv.Discover(), target) +} + // ConfirmConnection publishes a prepared node before credentials are recorded. // Providers without pending connections require no additional mutation. func (a *SSHAdapter) ConfirmConnection(ctx context.Context, nodeID string) error { @@ -221,8 +687,8 @@ func (a *SSHAdapter) GetConfig(nodeID string) (*ssh.ClientConfig, error) { cfg := &ssh.ClientConfig{ NodeID: nodeID, Address: snapshot.Host.Address, - Port: int(snapshot.Host.Port), - User: snapshot.Identity.User, + Port: effectiveSnapshotPort(snapshot.Host.Port), + User: effectiveSnapshotUser(snapshot.Identity.User), AuthType: snapshot.Identity.AuthType, Password: snapshot.Identity.Password, KeyPath: snapshot.Identity.KeyPath, @@ -239,35 +705,51 @@ func (a *SSHAdapter) GetConfig(nodeID string) (*ssh.ClientConfig, error) { } if override, ok := a.getSessionOverride(nodeID); ok { - if override.Password != "" { - cfg.Password = override.Password - } - if override.Passphrase != "" { - cfg.Passphrase = override.Passphrase - } - if override.KeyPath != "" { - cfg.KeyPath = override.KeyPath - } - // Route explicit session material through auto authentication unless the - // identity already uses keyboard-interactive or otp. This lets the - // connector record discovered credentials after a successful handshake - // and enables stage-0 keyboard-interactive when --otp is supplied on a - // default password node. - if cfg.AuthType != "keyboard-interactive" && cfg.AuthType != "otp" { - if override.Password != "" || override.Passphrase != "" || override.KeyPath != "" { - cfg.AuthType = "auto" - } else if override.OTP != "" && (cfg.AuthType == "" || cfg.AuthType == "password") { - cfg.AuthType = "auto" - } - } - if override.SuPwd != "" { - cfg.SuPwd = override.SuPwd + if err := a.applySessionOverrideToConfig(nodeID, cfg, override); err != nil { + return nil, err } } return cfg, nil } +func (a *SSHAdapter) applySessionOverrideToConfig(nodeID string, cfg *ssh.ClientConfig, override SessionAuth) error { + if override.Password != "" { + cfg.Password = override.Password + } + if override.Passphrase != "" { + cfg.Passphrase = override.Passphrase + } + if override.KeyPath != "" { + cfg.KeyPath = override.KeyPath + } + // Route explicit session material through auto authentication unless the + // identity already uses keyboard-interactive or otp. This lets the + // connector record discovered credentials after a successful handshake + // and enables stage-0 keyboard-interactive when --otp is supplied on a + // default password node. + if cfg.AuthType != "keyboard-interactive" && cfg.AuthType != "otp" { + if override.Password != "" || override.Passphrase != "" || override.KeyPath != "" { + cfg.AuthType = "auto" + } else if override.OTP != "" && (cfg.AuthType == "" || cfg.AuthType == "password") { + cfg.AuthType = "auto" + } + } + if override.SuPwd != "" { + cfg.SuPwd = override.SuPwd + } + if override.ProxyJump != "" { + resolvedJump, err := a.cfgProvider.ResolveProxyJumpChain(override.ProxyJump) + if err != nil { + return fmt.Errorf("resolve proxy jump %q for node %q failed: %w", override.ProxyJump, nodeID, err) + } + cfg.ProxyJump = resolvedJump + cfg.OriginalProxyJump = resolvedJump + cfg.HasOriginalProxyJump = true + } + return nil +} + // UpdateAuth 处理密码或私钥密码的回写并返回本次提交后的新认证令牌 func (a *SSHAdapter) UpdateAuth(ctx context.Context, nodeID, authUpdateToken, password, keyPath, passphrase string) (string, error) { if a.nonInteractive { @@ -527,9 +1009,10 @@ func (a *SSHAdapter) resolveOTP( if promptText == "" { promptText = "OTP" } + effectiveUser := effectiveSnapshotUser(snapshot.Identity.User) identityName := snapshot.Node.IdentityRef if identityName == "" { - identityName = snapshot.Identity.User + identityName = effectiveUser } opts := credentialhelper.AnswerCommandOptions{ Command: otpCfg.Command, @@ -537,7 +1020,7 @@ func (a *SSHAdapter) resolveOTP( Timeout: timeout, Node: req.NodeID, Host: snapshot.Host.Address, - User: snapshot.Identity.User, + User: effectiveUser, Identity: identityName, Prompt: promptText, Instruction: req.Instruction, @@ -651,9 +1134,10 @@ func (a *SSHAdapter) resolveChallengeRuleAnswer( if timeout <= 0 { timeout = credentialhelper.DefaultAnswerCommandTimeout } + effectiveUser := effectiveSnapshotUser(snapshot.Identity.User) identityName := snapshot.Node.IdentityRef if identityName == "" { - identityName = snapshot.Identity.User + identityName = effectiveUser } opts := credentialhelper.AnswerCommandOptions{ Command: rule.Command, @@ -661,7 +1145,7 @@ func (a *SSHAdapter) resolveChallengeRuleAnswer( Timeout: timeout, Node: req.NodeID, Host: snapshot.Host.Address, - User: snapshot.Identity.User, + User: effectiveUser, Identity: identityName, Prompt: req.Prompt, Instruction: req.Instruction, @@ -779,13 +1263,15 @@ func validateSecretRequestSnapshot(snapshot config.ConnectionSnapshot, req ssh.S return fmt.Errorf("%w: host address changed for node %q: expected %q, got %q", ssh.ErrSnapshotMismatch, req.NodeID, req.Host, snapshot.Host.Address) } - if req.Port != 0 && int(snapshot.Host.Port) != req.Port { + effectivePort := effectiveSnapshotPort(snapshot.Host.Port) + if req.Port != 0 && effectivePort != req.Port { return fmt.Errorf("%w: host port changed for node %q: expected %d, got %d", - ssh.ErrSnapshotMismatch, req.NodeID, req.Port, snapshot.Host.Port) + ssh.ErrSnapshotMismatch, req.NodeID, req.Port, effectivePort) } - if req.User != "" && snapshot.Identity.User != req.User { + effectiveUser := effectiveSnapshotUser(snapshot.Identity.User) + if req.User != "" && effectiveUser != req.User { return fmt.Errorf("%w: user changed for node %q: expected %q, got %q", - ssh.ErrSnapshotMismatch, req.NodeID, req.User, snapshot.Identity.User) + ssh.ErrSnapshotMismatch, req.NodeID, req.User, effectiveUser) } return nil } diff --git a/pkg/adapter/ssh_adapter_control_test.go b/pkg/adapter/ssh_adapter_control_test.go new file mode 100644 index 00000000..e4ff037c --- /dev/null +++ b/pkg/adapter/ssh_adapter_control_test.go @@ -0,0 +1,779 @@ +package adapter + +import ( + "strings" + "testing" + + "github.com/wentf9/xops-cli/core/concurrent" + "github.com/wentf9/xops-cli/core/ssh" + "github.com/wentf9/xops-cli/pkg/config" + "github.com/wentf9/xops-cli/pkg/models" +) + +func setupTestProvider( + t *testing.T, + nodeCtrl *ssh.ControlConfig, + globalCtrl *ssh.ControlConfig, + openSSHContent string, +) config.ConfigProvider { + t.Helper() + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Control: globalCtrl, + } + + cfg.Hosts.Set("host1", models.Host{Address: "192.168.1.100", Port: 2222}) + cfg.Identities.Set("id1", models.Identity{User: "alice", AuthType: "auto"}) + cfg.Nodes.Set("testnode", models.Node{ + HostRef: "host1", + IdentityRef: "id1", + Alias: []string{"node-alias"}, + Control: nodeCtrl, + }) + + var parser *config.OpenSSHParser + if openSSHContent != "" { + p, err := config.NewOpenSSHParserFromReader(strings.NewReader(openSSHContent)) + if err != nil { + t.Fatalf("failed to parse openssh config: %v", err) + } + parser = p + } + + return config.NewProviderWithOpenSSHParser(cfg, parser) +} + +func TestResolveControl_Tier1OverridesAll(t *testing.T) { + // T2: Master: auto, Path: ~/.ssh/node.sock, Persist: 5m + t2 := &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "~/.ssh/node.sock", Persist: "5m"} + // T3: Master: auto, Path: ~/.ssh/global.sock, Persist: 10m + t3 := &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "~/.ssh/global.sock", Persist: "10m"} + // T4: Master: yes + t4OpenSSH := ` +Host testnode + ControlMaster yes +` + provider := setupTestProvider(t, t2, t3, t4OpenSSH) + adp := NewSSHAdapter(provider) + + // T1: Master: yes, Path: /tmp/custom.sock (Persist not set on T1, should retain T2's Persist: 5m) + var t1 SessionControlOptions + t1.SetMaster(ssh.ControlMasterYes) + t1.SetPath("/tmp/custom.sock") + + res, err := adp.ResolveControl("testnode", t1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res == nil { + t.Fatal("expected non-nil ControlConfig") + } + + if res.Master != ssh.ControlMasterYes { + t.Errorf("expected Master=yes, got: %v", res.Master) + } + if res.Path != "/tmp/custom.sock" { + t.Errorf("expected Path=/tmp/custom.sock, got: %v", res.Path) + } + if res.Persist != "5m" { + t.Errorf("expected Persist=5m (retained from Tier 2), got: %v", res.Persist) + } +} + +func TestResolveControl_Tier2OverridesTier3And4(t *testing.T) { + // T2: Master: ask, Path: ~/.ssh/node.sock (Persist empty) + t2 := &ssh.ControlConfig{Master: ssh.ControlMasterAsk, Path: "~/.ssh/node.sock"} + // T3: Master: auto, Path: ~/.ssh/global.sock, Persist: 15m + t3 := &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "~/.ssh/global.sock", Persist: "15m"} + // T4: Master: yes + t4OpenSSH := ` +Host testnode + ControlMaster yes +` + provider := setupTestProvider(t, t2, t3, t4OpenSSH) + adp := NewSSHAdapter(provider) + + // T1: empty + var t1 SessionControlOptions + + res, err := adp.ResolveControl("testnode", t1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res == nil { + t.Fatal("expected non-nil ControlConfig") + } + + if res.Master != ssh.ControlMasterAsk { + t.Errorf("expected Master=ask (from T2), got: %v", res.Master) + } + if res.Path != "~/.ssh/node.sock" { + t.Errorf("expected Path=~/.ssh/node.sock (from T2), got: %v", res.Path) + } + if res.Persist != "15m" { + t.Errorf("expected Persist=15m (fallback to T3), got: %v", res.Persist) + } +} + +func TestResolveControl_Tier3OverridesTier4(t *testing.T) { + // T2: nil + // T3: Master: auto, Path: ~/.ssh/global-%r@%h:%p + t3 := &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "~/.ssh/global-%r@%h:%p"} + // T4: Master: no, Path: ~/.ssh/ssh-%h.sock + t4OpenSSH := ` +Host testnode + ControlMaster no + ControlPath ~/.ssh/ssh-%h.sock +` + provider := setupTestProvider(t, nil, t3, t4OpenSSH) + adp := NewSSHAdapter(provider) + + var t1 SessionControlOptions + res, err := adp.ResolveControl("testnode", t1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res == nil { + t.Fatal("expected non-nil ControlConfig") + } + + if res.Master != ssh.ControlMasterAuto { + t.Errorf("expected Master=auto (from T3), got: %v", res.Master) + } + if res.Path != "~/.ssh/global-%r@%h:%p" { + t.Errorf("expected Path from T3, got: %v", res.Path) + } +} + +func TestResolveControl_Tier4Fallback_NodeMatch(t *testing.T) { + // T1, T2, T3: nil/empty + t4OpenSSH := ` +Host testnode + ControlMaster auto + ControlPath ~/.ssh/mux-%r@%h:%p + ControlPersist 10m +` + provider := setupTestProvider(t, nil, nil, t4OpenSSH) + adp := NewSSHAdapter(provider) + + var t1 SessionControlOptions + res, err := adp.ResolveControl("testnode", t1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res == nil { + t.Fatal("expected non-nil ControlConfig") + } + + if res.Master != ssh.ControlMasterAuto || + res.Path != "~/.ssh/mux-%r@%h:%p" || + res.Persist != "10m" { + t.Fatalf("Tier 4 fallback mismatch: %+v", res) + } +} + +func TestResolveControl_Tier4Fallback_Wildcard(t *testing.T) { + // T1, T2, T3: nil/empty + t4OpenSSH := ` +Host * + ControlMaster auto + ControlPath ~/.ssh/mux-%h +` + provider := setupTestProvider(t, nil, nil, t4OpenSSH) + adp := NewSSHAdapter(provider) + + var t1 SessionControlOptions + res, err := adp.ResolveControl("testnode", t1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res == nil { + t.Fatal("expected non-nil ControlConfig") + } + + if res.Master != ssh.ControlMasterAuto || res.Path != "~/.ssh/mux-%h" { + t.Fatalf("wildcard fallback mismatch: %+v", res) + } +} + +func TestResolveControl_FieldByFieldMerging_Orthogonal(t *testing.T) { + // T3 provides Master: auto + t3 := &ssh.ControlConfig{Master: ssh.ControlMasterAuto} + // T2 provides Path: ~/.ssh/node-%h.sock + t2 := &ssh.ControlConfig{Path: "~/.ssh/node-%h.sock"} + // T1 provides Persist: 20m + provider := setupTestProvider(t, t2, t3, "") + adp := NewSSHAdapter(provider) + + var t1 SessionControlOptions + t1.SetPersist("20m") + + res, err := adp.ResolveControl("testnode", t1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res == nil { + t.Fatal("expected non-nil ControlConfig") + } + + if res.Master != ssh.ControlMasterAuto { + t.Errorf("expected Master=auto from T3, got: %v", res.Master) + } + if res.Path != "~/.ssh/node-%h.sock" { + t.Errorf("expected Path from T2, got: %v", res.Path) + } + if res.Persist != "20m" { + t.Errorf("expected Persist=20m from T1, got: %v", res.Persist) + } +} + +func assertDisabledControl(t *testing.T, res *ssh.ControlConfig, err error) { + t.Helper() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res == nil { + t.Fatal("expected non-nil ControlConfig") + } + if res.Path != "none" { + t.Errorf("expected Path='none', got: %v", res.Path) + } + if res.Master != ssh.ControlMasterNo { + t.Errorf("expected Master=no when Path=none, got: %v", res.Master) + } + if res.Persist != "" { + t.Errorf("expected Persist='' when Path=none, got: %v", res.Persist) + } + if res.IsEnabled() { + t.Error("expected IsEnabled() == false") + } +} + +func TestResolveControl_DisableSharing_SNone(t *testing.T) { + // T2 has active multiplexing + t2 := &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "~/.ssh/sock"} + provider := setupTestProvider(t, t2, nil, "") + adp := NewSSHAdapter(provider) + + // T1 specifies Path: "none" + var t1 SessionControlOptions + t1.SetPath("none") + + res, err := adp.ResolveControl("testnode", t1) + assertDisabledControl(t, res, err) +} + +func TestResolveControl_DisableSharing_SNone_WithLowerTierPersist(t *testing.T) { + t.Run("Tier2_Node_Configured_Persist", func(t *testing.T) { + t2 := &ssh.ControlConfig{ + Master: ssh.ControlMasterAuto, + Path: "~/.ssh/node.sock", + Persist: "10m", + } + provider := setupTestProvider(t, t2, nil, "") + adp := NewSSHAdapter(provider) + + var t1 SessionControlOptions + t1.SetPath("none") + + res, err := adp.ResolveControl("testnode", t1) + assertDisabledControl(t, res, err) + }) + + t.Run("Tier3_Global_Configured_Persist", func(t *testing.T) { + t3 := &ssh.ControlConfig{ + Master: ssh.ControlMasterAuto, + Path: "~/.ssh/global.sock", + Persist: "15m", + } + provider := setupTestProvider(t, nil, t3, "") + adp := NewSSHAdapter(provider) + + var t1 SessionControlOptions + t1.SetPath("none") + + res, err := adp.ResolveControl("testnode", t1) + assertDisabledControl(t, res, err) + }) + + t.Run("Tier4_OpenSSH_Configured_Persist", func(t *testing.T) { + openSSH := ` +Host testnode + ControlMaster auto + ControlPath ~/.ssh/mux-%r@%h:%p + ControlPersist 10m +` + provider := setupTestProvider(t, nil, nil, openSSH) + adp := NewSSHAdapter(provider) + + var t1 SessionControlOptions + t1.SetPath("none") + + res, err := adp.ResolveControl("testnode", t1) + assertDisabledControl(t, res, err) + }) + + t.Run("Tier4_OpenSSH_Host_Explicit_PathNone_With_Wildcard_Persist", func(t *testing.T) { + openSSH := ` +Host testnode + ControlPath none + +Host * + ControlMaster auto + ControlPath ~/.ssh/mux-%h + ControlPersist 10m +` + provider := setupTestProvider(t, nil, nil, openSSH) + adp := NewSSHAdapter(provider) + + var t1 SessionControlOptions + + res, err := adp.ResolveControl("testnode", t1) + assertDisabledControl(t, res, err) + }) + + t.Run("Tier2_Node_Explicit_PathNone_With_Tier3_Global_Persist", func(t *testing.T) { + t2 := &ssh.ControlConfig{Path: "none"} + t3 := &ssh.ControlConfig{ + Master: ssh.ControlMasterAuto, + Path: "~/.ssh/global.sock", + Persist: "10m", + } + provider := setupTestProvider(t, t2, t3, "") + adp := NewSSHAdapter(provider) + + var t1 SessionControlOptions + + res, err := adp.ResolveControl("testnode", t1) + assertDisabledControl(t, res, err) + }) +} + +func TestResolveControl_ExplicitMasterNo(t *testing.T) { + // T3 has active multiplexing + t3 := &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "~/.ssh/sock"} + provider := setupTestProvider(t, nil, t3, "") + adp := NewSSHAdapter(provider) + + // T1 specifies Master: "no" + var t1 SessionControlOptions + t1.SetMaster(ssh.ControlMasterNo) + + res, err := adp.ResolveControl("testnode", t1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res == nil { + t.Fatal("expected non-nil ControlConfig") + } + + if res.Master != ssh.ControlMasterNo { + t.Errorf("expected Master=no, got: %v", res.Master) + } + if res.Path != "~/.ssh/sock" { + t.Errorf("expected Path preserved from T3, got: %v", res.Path) + } +} + +func TestResolveControl_Error_MasterWithoutPath(t *testing.T) { + provider := setupTestProvider(t, nil, nil, "") + adp := NewSSHAdapter(provider) + + // T1 specifies Master: yes, but no path in any tier + var t1 SessionControlOptions + t1.SetMaster(ssh.ControlMasterYes) + + _, err := adp.ResolveControl("testnode", t1) + if err == nil || !strings.Contains(err.Error(), "ControlPath is required when ControlMaster is yes") { + t.Fatalf("expected ControlPath is required error, got: %v", err) + } +} + +func TestResolveControl_Error_PersistWithoutPath(t *testing.T) { + provider := setupTestProvider(t, nil, nil, "") + adp := NewSSHAdapter(provider) + + // T1 specifies Persist: 10m, but no path in any tier + var t1 SessionControlOptions + t1.SetPersist("10m") + + _, err := adp.ResolveControl("testnode", t1) + if err == nil || !strings.Contains(err.Error(), "ControlPath is required when ControlPersist is configured") { + t.Fatalf("expected ControlPath is required error, got: %v", err) + } +} + +func TestResolveControl_Error_InvalidMasterMode(t *testing.T) { + provider := setupTestProvider(t, nil, nil, "") + adp := NewSSHAdapter(provider) + + opts, err := ParseOptionStrings([]string{"ControlMaster=invalid"}) + if err == nil || !strings.Contains(err.Error(), "unsupported ControlMaster option") { + t.Fatalf("expected unsupported ControlMaster error, got: %v", err) + } + _ = opts + + // Also test direct struct with invalid mode passed to ResolveControl + invalidMode := ssh.ControlMasterMode("invalid") + t1 := SessionControlOptions{Master: &invalidMode, Path: new(string)} + *t1.Path = "/tmp/sock" + _, err = adp.ResolveControl("testnode", t1) + if err == nil || !strings.Contains(err.Error(), "unsupported ControlMaster option") { + t.Fatalf("expected unsupported ControlMaster error, got: %v", err) + } +} + +func TestResolveControl_Error_InvalidPersistDuration(t *testing.T) { + provider := setupTestProvider(t, nil, nil, "") + adp := NewSSHAdapter(provider) + + opts, err := ParseOptionStrings([]string{"ControlPersist=badval"}) + if err == nil || !strings.Contains(err.Error(), "invalid ControlPersist") { + t.Fatalf("expected invalid ControlPersist error, got: %v", err) + } + _ = opts + + // Also test direct struct with invalid persist duration passed to ResolveControl + badPersist := "10xyz" + t1 := SessionControlOptions{Persist: &badPersist, Path: new(string)} + *t1.Path = "/tmp/sock" + _, err = adp.ResolveControl("testnode", t1) + if err == nil || (!strings.Contains(err.Error(), "bad ControlPersist argument") && !strings.Contains(err.Error(), "invalid")) { + t.Fatalf("expected invalid persist duration error, got: %v", err) + } +} + +func TestResolveControl_VirtualOpenSSHNode(t *testing.T) { + openSSHContent := ` +Host app1 + HostName 10.0.0.50 + User deploy + ControlMaster auto + ControlPath ~/.ssh/sockets/%r@%h-%p + ControlPersist 10m +` + parser, err := config.NewOpenSSHParserFromReader(strings.NewReader(openSSHContent)) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + provider := config.NewProviderWithOpenSSHParser(&config.Configuration{}, parser) + adp := NewSSHAdapter(provider) + + // T1 specifies CLI override for Path + var t1 SessionControlOptions + t1.SetPath("/tmp/override.sock") + + res, err := adp.ResolveControl(config.OpenSSHNodePrefix+"app1", t1) + if err != nil { + t.Fatalf("ResolveControl failed: %v", err) + } + if res == nil { + t.Fatal("expected non-nil ControlConfig") + } + + if res.Path != "/tmp/override.sock" { + t.Errorf("expected overridden Path, got: %v", res.Path) + } + if res.Master != ssh.ControlMasterAuto { + t.Errorf("expected Master=auto from virtual node, got: %v", res.Master) + } + if res.Persist != "10m" { + t.Errorf("expected Persist=10m from virtual node, got: %v", res.Persist) + } +} + +func TestAdapter_GetControlConfig_ControlProviderContract(t *testing.T) { + var _ ssh.ControlProvider = (*SSHAdapter)(nil) + + t2 := &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "~/.ssh/cm-%h", Persist: "10m"} + provider := setupTestProvider(t, t2, nil, "") + + var sessionOpts SessionControlOptions + sessionOpts.SetMaster(ssh.ControlMasterYes) + + adp := NewSSHAdapter(provider, WithSessionControlOverride("testnode", sessionOpts)) + + ctrl, err := adp.GetControlConfig("testnode") + if err != nil { + t.Fatalf("GetControlConfig failed: %v", err) + } + if ctrl == nil { + t.Fatal("expected non-nil ControlConfig") + } + + if ctrl.Master != ssh.ControlMasterYes { + t.Errorf("expected Master=yes from session override, got: %v", ctrl.Master) + } + if ctrl.Path != "~/.ssh/cm-%h" { + t.Errorf("expected Path preserved from node config, got: %v", ctrl.Path) + } + if ctrl.Persist != "10m" { + t.Errorf("expected Persist preserved from node config, got: %v", ctrl.Persist) + } +} + +func TestAdapter_ExpandControlPath_Helper(t *testing.T) { + provider := setupTestProvider(t, nil, nil, "") + adp := NewSSHAdapter(provider) + + raw := "~/.ssh/mux-%r@%h:%p" + expanded, err := adp.ExpandControlPath("testnode", raw) + if err != nil { + t.Fatalf("ExpandControlPath failed: %v", err) + } + + if !strings.Contains(expanded, "alice@192.168.1.100:2222") { + t.Fatalf("expected expanded path to contain target info, got: %q", expanded) + } + + // raw path "none" or empty should be returned as-is + noneExp, err := adp.ExpandControlPath("testnode", "none") + if err != nil || noneExp != "none" { + t.Fatalf("expected none returned as-is, got: %q, %v", noneExp, err) + } +} + +func TestSessionControlOptions_ParseOptionStrings(t *testing.T) { + options := []string{ + "ControlMaster=auto", + "ControlPath=~/.ssh/sockets/%r@%h:%p", + "ControlPersist=15m", + } + + opts, err := ParseOptionStrings(options) + if err != nil { + t.Fatalf("ParseOptionStrings failed: %v", err) + } + + if opts.Master == nil || *opts.Master != ssh.ControlMasterAuto { + t.Errorf("expected Master=auto, got: %v", opts.Master) + } + if opts.Path == nil || *opts.Path != "~/.ssh/sockets/%r@%h:%p" { + t.Errorf("expected Path=~/.ssh/sockets/%%r@%%h:%%p, got: %v", opts.Path) + } + if opts.Persist == nil || *opts.Persist != "15m" { + t.Errorf("expected Persist=15m, got: %v", opts.Persist) + } + + // Invalid format without '=' + _, err = ParseOptionStrings([]string{"ControlMaster"}) + if err == nil || !strings.Contains(err.Error(), "invalid -o option format") { + t.Fatalf("expected invalid format error, got: %v", err) + } +} + +func TestResolveControl_Tier4_ConcreteHostOverridesWildcard(t *testing.T) { + // Verifies that a concrete host IP match (Host 192.168.1.100) takes precedence + // over a generic wildcard fallback (Host *), even though nodeName is "testnode". + t4OpenSSH := ` +Host 192.168.1.100 + ControlMaster yes + ControlPath ~/.ssh/specific-ip.sock + +Host * + ControlMaster auto + ControlPath ~/.ssh/fallback-wildcard.sock +` + provider := setupTestProvider(t, nil, nil, t4OpenSSH) + adp := NewSSHAdapter(provider) + + var t1 SessionControlOptions + res, err := adp.ResolveControl("testnode", t1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res == nil { + t.Fatal("expected non-nil ControlConfig") + } + + if res.Path != "~/.ssh/specific-ip.sock" { + t.Errorf("expected Path=~/.ssh/specific-ip.sock (concrete IP match), got: %v", res.Path) + } + if res.Master != ssh.ControlMasterYes { + t.Errorf("expected Master=yes (concrete IP match), got: %v", res.Master) + } +} + +func TestResolveControl_Tier4_ConcreteAliasOverridesWildcard(t *testing.T) { + // Verifies that a concrete alias match (Host node-alias) takes precedence + // over a generic wildcard fallback (Host *). + t4OpenSSH := ` +Host node-alias + ControlMaster ask + ControlPath ~/.ssh/specific-alias.sock + +Host * + ControlMaster auto + ControlPath ~/.ssh/fallback-wildcard.sock +` + provider := setupTestProvider(t, nil, nil, t4OpenSSH) + adp := NewSSHAdapter(provider) + + var t1 SessionControlOptions + res, err := adp.ResolveControl("testnode", t1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res == nil { + t.Fatal("expected non-nil ControlConfig") + } + + if res.Path != "~/.ssh/specific-alias.sock" { + t.Errorf("expected Path=~/.ssh/specific-alias.sock (concrete alias match), got: %v", res.Path) + } + if res.Master != ssh.ControlMasterAsk { + t.Errorf("expected Master=ask (concrete alias match), got: %v", res.Master) + } +} + +func TestResolveControl_Tier4_ConcreteNodePrecedenceOverIP(t *testing.T) { + // Verifies that if both nodeName and Host.Address have concrete definitions, + // the more specific nodeName definition takes precedence. + t4OpenSSH := ` +Host testnode + ControlMaster ask + ControlPath ~/.ssh/specific-node.sock + +Host 192.168.1.100 + ControlMaster yes + ControlPath ~/.ssh/specific-ip.sock + +Host * + ControlMaster auto + ControlPath ~/.ssh/fallback-wildcard.sock +` + provider := setupTestProvider(t, nil, nil, t4OpenSSH) + adp := NewSSHAdapter(provider) + + var t1 SessionControlOptions + res, err := adp.ResolveControl("testnode", t1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res == nil { + t.Fatal("expected non-nil ControlConfig") + } + + if res.Path != "~/.ssh/specific-node.sock" { + t.Errorf("expected Path=~/.ssh/specific-node.sock (concrete node match), got: %v", res.Path) + } + if res.Master != ssh.ControlMasterAsk { + t.Errorf("expected Master=ask (concrete node match), got: %v", res.Master) + } +} + +// TestResolveControl_VirtualNodeStaysTier4 verifies that ~/.ssh/config values +// imported into an OpenSSH virtual node never outrank the persisted global +// default (Tier 3). The virtual node is a Tier 4 source by definition. +func TestResolveControl_VirtualNodeStaysTier4(t *testing.T) { + openSSHContent := ` +Host app1 + HostName 10.0.0.50 + ControlMaster yes + ControlPath ~/.ssh/specific.sock +` + parser, err := config.NewOpenSSHParserFromReader(strings.NewReader(openSSHContent)) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Control: &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "/tmp/global.sock", Persist: "10m"}, + } + provider := config.NewProviderWithOpenSSHParser(cfg, parser) + adp := NewSSHAdapter(provider) + + res, err := adp.ResolveControl(config.OpenSSHNodePrefix+"app1", SessionControlOptions{}) + if err != nil { + t.Fatalf("ResolveControl failed: %v", err) + } + if res == nil { + t.Fatal("expected non-nil ControlConfig") + } + if res.Path != "/tmp/global.sock" || res.Master != ssh.ControlMasterAuto { + t.Fatalf("global default must outrank the OpenSSH virtual node, got %+v", res) + } + + // A session override (Tier 1) still wins over everything. + var session SessionControlOptions + session.SetPath("/tmp/session.sock") + res, err = adp.ResolveControl(config.OpenSSHNodePrefix+"app1", session) + if err != nil { + t.Fatalf("ResolveControl with session override failed: %v", err) + } + if res.Path != "/tmp/session.sock" { + t.Fatalf("expected session override to win, got %+v", res) + } +} + +// TestResolveControl_VirtualNodeInheritsMissingMaster verifies that an OpenSSH +// host declaring only ControlPath/ControlPersist inherits the master mode from a +// lower tier instead of forcing "no". +func TestResolveControl_VirtualNodeInheritsMissingMaster(t *testing.T) { + openSSHContent := ` +Host app2 + HostName 10.0.0.60 + ControlPath ~/.ssh/only-path.sock + ControlPersist 5m +` + parser, err := config.NewOpenSSHParserFromReader(strings.NewReader(openSSHContent)) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Control: &ssh.ControlConfig{Master: ssh.ControlMasterAuto}, + } + provider := config.NewProviderWithOpenSSHParser(cfg, parser) + adp := NewSSHAdapter(provider) + + node, _, _, err := provider.Resolve(config.OpenSSHNodePrefix + "app2") + if err != nil { + t.Fatalf("Resolve failed: %v", err) + } + if node.Control == nil || node.Control.Master != "" { + t.Fatalf("expected an unset ControlMaster for a host that omits it, got %+v", node.Control) + } + + res, err := adp.ResolveControl(config.OpenSSHNodePrefix+"app2", SessionControlOptions{}) + if err != nil { + t.Fatalf("ResolveControl failed: %v", err) + } + if res.Master != ssh.ControlMasterAuto { + t.Fatalf("expected inherited ControlMaster=auto, got %q", res.Master) + } + if res.Path != "~/.ssh/only-path.sock" || res.Persist != "5m" { + t.Fatalf("expected OpenSSH path and persist, got %+v", res) + } +} + +// TestParseOptionStrings_RejectsUnknownKey verifies that a mistyped -o key fails +// loudly. Silently ignoring it would disable the behavior the user asked for. +func TestParseOptionStrings_RejectsUnknownKey(t *testing.T) { + for _, option := range []string{ + "ControlMaste=yes", + "EnableMultiplexing=true", + "Compression=yes", + } { + if _, err := ParseOptionStrings([]string{option}); err == nil { + t.Fatalf("expected %q to be rejected", option) + } else if !strings.Contains(err.Error(), "unsupported -o option") { + t.Fatalf("unexpected error for %q: %v", option, err) + } + } + + // Supported keys still parse, including the ones applied to the target by the CLI. + opts, err := ParseOptionStrings([]string{"ControlMaster=auto", "ControlPath=/tmp/x.sock", "Port=2222", "User=deploy", "IdentityFile=~/.ssh/id", "ProxyJump=bastion"}) + if err != nil { + t.Fatalf("expected supported options to parse: %v", err) + } + if opts.Master == nil || *opts.Master != ssh.ControlMasterAuto { + t.Fatalf("expected ControlMaster=auto, got %v", opts.Master) + } +} diff --git a/pkg/adapter/ssh_adapter_test.go b/pkg/adapter/ssh_adapter_test.go index 6fc34620..dfd61d0c 100644 --- a/pkg/adapter/ssh_adapter_test.go +++ b/pkg/adapter/ssh_adapter_test.go @@ -2256,3 +2256,92 @@ func newTestAdapterConfig(nodeID string, identity models.Identity) *config.Confi Hosts: hostMap, } } + +func TestSSHAdapter_ZeroPortAndEmptyUserSnapshotNormalization(t *testing.T) { + ctx := context.Background() + localUser := sshenv.Discover().LocalUser + if localUser == "" { + t.Skip("local user is empty in test environment") + } + + nodeMap := concurrent.NewMap[string, models.Node](concurrent.HashString) + hostMap := concurrent.NewMap[string, models.Host](concurrent.HashString) + identityMap := concurrent.NewMap[string, models.Identity](concurrent.HashString) + + hostMap.Set("host-zero", models.Host{Address: "10.99.88.77", Port: 0}) + identityMap.Set("id-empty-user", models.Identity{ + User: "", + AuthType: "password", + Password: "zero-port-secret", + }) + nodeMap.Set("node-zero", models.Node{ + HostRef: "host-zero", + IdentityRef: "id-empty-user", + }) + cfg := &config.Configuration{ + Nodes: nodeMap, + Identities: identityMap, + Hosts: hostMap, + } + + provider := config.NewProviderWithoutOpenSSH(cfg) + adp := NewSSHAdapter(provider) + + // 1. GetConfig normalizes Port=0 -> 22 and User="" -> localUser + clientCfg, err := adp.GetConfig("node-zero") + if err != nil { + t.Fatalf("GetConfig(node-zero) failed: %v", err) + } + if clientCfg.Port != 22 { + t.Fatalf("expected Port=22 from GetConfig, got %d", clientCfg.Port) + } + if clientCfg.User != localUser { + t.Fatalf("expected User=%q from GetConfig, got %q", localUser, clientCfg.User) + } + + // 2. ExpandControlPath normalizes %p -> 22 and %r -> localUser + expanded, err := adp.ExpandControlPath("node-zero", "/tmp/mux-%r@%h:%p") + if err != nil { + t.Fatalf("ExpandControlPath failed: %v", err) + } + wantExpanded := fmt.Sprintf("/tmp/mux-%s@10.99.88.77:22", localUser) + if expanded != wantExpanded { + t.Fatalf("expected ExpandControlPath=%q, got %q", wantExpanded, expanded) + } + + // 3. ResolveSecret accepts normalized Port=22 and User=localUser without false ErrSnapshotMismatch + secret, err := adp.ResolveSecret(ctx, ssh.SecretRequest{ + Kind: ssh.SecretKindLoginPassword, + NodeID: "node-zero", + Host: "10.99.88.77", + Port: 22, + User: localUser, + VersionToken: clientCfg.AuthUpdateToken, + }) + if err != nil { + t.Fatalf("ResolveSecret with normalized Port=22 and User=%q failed: %v", localUser, err) + } + if string(secret) != "zero-port-secret" { + t.Fatalf("expected secret 'zero-port-secret', got %q", string(secret)) + } + + // 4. ResolveSecret still fails closed with ErrSnapshotMismatch on mismatched port or user + if _, err := adp.ResolveSecret(ctx, ssh.SecretRequest{ + Kind: ssh.SecretKindLoginPassword, + NodeID: "node-zero", + Host: "10.99.88.77", + Port: 2222, + User: localUser, + }); !errors.Is(err, ssh.ErrSnapshotMismatch) { + t.Fatalf("expected ErrSnapshotMismatch for Port=2222, got %v", err) + } + if _, err := adp.ResolveSecret(ctx, ssh.SecretRequest{ + Kind: ssh.SecretKindLoginPassword, + NodeID: "node-zero", + Host: "10.99.88.77", + Port: 22, + User: localUser + "-other", + }); !errors.Is(err, ssh.ErrSnapshotMismatch) { + t.Fatalf("expected ErrSnapshotMismatch for mismatched user, got %v", err) + } +} diff --git a/pkg/adapter/verification.go b/pkg/adapter/verification.go index 7409ecb0..c5ffeddc 100644 --- a/pkg/adapter/verification.go +++ b/pkg/adapter/verification.go @@ -62,7 +62,7 @@ func verifyConnection(ctx context.Context, preview *config.Provider, nodeID stri connectionProvider = probeConnectionProvider{ConfigProvider: preview.Frozen()} } connector := NewConnectorWithAdapterOptions(connectionProvider, []Option{ - WithNonInteractive(true), WithCredentialRecording(false), WithCredentialSource(resolver), + WithNonInteractive(true), WithCredentialRecording(false), WithCredentialSource(resolver), WithControlMultiplexing(false), }, ssh.WithHandshakeTimeout(VerificationTimeout)) connector.AcceptNewHostKey.Store(true) defer func() { diff --git a/pkg/adapter/verification_test.go b/pkg/adapter/verification_test.go index dbb994aa..64f580c0 100644 --- a/pkg/adapter/verification_test.go +++ b/pkg/adapter/verification_test.go @@ -3,10 +3,14 @@ package adapter import ( "context" "os" + "path/filepath" "testing" "time" + "github.com/wentf9/xops-cli/core/ssh" + "github.com/wentf9/xops-cli/core/testutil/sshfixture" "github.com/wentf9/xops-cli/pkg/models" + cryptoSSH "golang.org/x/crypto/ssh" ) func TestInventoryVerificationNeverPersistsDraft(t *testing.T) { @@ -36,3 +40,81 @@ func TestInventoryVerificationNeverPersistsDraft(t *testing.T) { }) } } + +// TestVerifyConnection_IgnoresActiveControlSocket verifies that VerifyConnection +// always dials a fresh SSH connection to validate credentials instead of +// attaching to an active control master socket on disk (which would report the +// wrong password as valid). +func TestVerifyConnection_IgnoresActiveControlSocket(t *testing.T) { + setTestHome(t) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + + host, port, stop := startAdapterPrivilegeSSHServer(t, "verified-secret", "") + t.Cleanup(stop) + + // Start a live MuxServer on a known control socket for the same (host, port, user). + addr := host + fixtureClient, err := cryptoSSH.Dial("tcp", addr+":"+itoa(port), &cryptoSSH.ClientConfig{ + User: "root", + Auth: []cryptoSSH.AuthMethod{cryptoSSH.Password("verified-secret")}, + HostKeyCallback: cryptoSSH.InsecureIgnoreHostKey(), + Timeout: 5 * time.Second, + }) + if err != nil { + t.Fatalf("dial test server for master socket failed: %v", err) + } + t.Cleanup(func() { _ = fixtureClient.Close() }) + _ = sshfixture.Password + + sockPath := filepath.Join(t.TempDir(), "verify-bypass.sock") + muxServer, err := ssh.NewMuxServer(ssh.MuxServerOptions{ + ControlPath: sockPath, + SSHClient: fixtureClient, + Target: ssh.TargetContext{Host: host, Port: port, User: "root"}, + PersistEnabled: true, + PersistIndefinite: true, + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + go func() { _ = muxServer.Serve() }() + t.Cleanup(func() { _ = muxServer.Shutdown() }) + + repo, _, _, _ := pendingAdapterRepository(t) + preview, err := repo.PreviewConnection( + "draft", + models.Node{ + Control: &ssh.ControlConfig{ + Master: ssh.ControlMasterAuto, + Path: sockPath, + Persist: "10m", + }, + }, + models.Host{Address: host, Port: uint16(port)}, + models.Identity{User: "root", AuthType: "password", Password: "wrong-password"}, + ) + if err != nil { + t.Fatal(err) + } + + // VerifyConnection MUST fail because the password is wrong, even though an + // active control master socket exists for root@host:port at sockPath! + if err := VerifyConnection(ctx, preview, "draft", nil); err == nil { + t.Fatal("VerifyConnection succeeded with wrong password by reusing active control master socket") + } +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + return string(buf[i:]) +} diff --git a/pkg/config/openssh.go b/pkg/config/openssh.go index 07ca34d4..8cb1b115 100644 --- a/pkg/config/openssh.go +++ b/pkg/config/openssh.go @@ -12,6 +12,7 @@ import ( "strings" "github.com/kevinburke/ssh_config" + "github.com/wentf9/xops-cli/core/ssh" "github.com/wentf9/xops-cli/pkg/models" ) @@ -109,6 +110,53 @@ func (p *OpenSSHParser) HasHost(alias string) bool { return false } +// FindConcreteHostsByAddress returns concrete Host pattern names in ssh_config +// whose resolved HostName matches address and (when port > 0) whose resolved +// Port matches port. +func (p *OpenSSHParser) FindConcreteHostsByAddress(address string, port uint16) []string { + if p == nil || p.cfg == nil { + return nil + } + cleanAddr := strings.TrimPrefix(strings.TrimSuffix(strings.TrimSpace(address), "]"), "[") + if cleanAddr == "" { + return nil + } + var matches []string + seen := make(map[string]struct{}) + for _, block := range p.cfg.Hosts { + for _, pattern := range block.Patterns { + name := pattern.String() + if !isConcreteSSHHost(name) { + continue + } + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + hostName, err := p.getVal(name, "HostName", name) + if err != nil { + continue + } + cleanHost := strings.TrimPrefix(strings.TrimSuffix(strings.TrimSpace(hostName), "]"), "[") + if !strings.EqualFold(cleanHost, cleanAddr) { + continue + } + if port > 0 { + portStr, err := p.getVal(name, "Port", "22") + if err != nil { + continue + } + parsedPort, parseErr := strconv.ParseUint(strings.TrimSpace(portStr), 10, 16) + if parseErr != nil || uint16(parsedPort) != port { + continue + } + } + matches = append(matches, name) + } + } + return matches +} + // GetVirtualNode 根据 alias 从 ssh_config 生成运行时的内存 Node / Host / Identity func (p *OpenSSHParser) GetVirtualNode(alias string) (models.Node, models.Host, models.Identity, error) { if p == nil { @@ -170,6 +218,11 @@ func (p *OpenSSHParser) GetVirtualNode(alias string) (models.Node, models.Host, } proxyJump = parseOpenSSHProxyJump(proxyJump) + ctrl, err := parseOpenSSHControl(p.cfg, lookupAlias) + if err != nil { + return models.Node{}, models.Host{}, models.Identity{}, err + } + hostRef := fmt.Sprintf("%s:%d", hostName, port) identityRef := fmt.Sprintf("%s@%s", user, hostName) @@ -180,6 +233,7 @@ func (p *OpenSSHParser) GetVirtualNode(alias string) (models.Node, models.Host, SudoMode: models.SudoModeAuto, // 对于导入节点,依然支持 auto Tags: []string{"openssh"}, // 打一个虚拟 tag Alias: []string{alias}, + Control: ctrl, } host := models.Host{ @@ -196,6 +250,69 @@ func (p *OpenSSHParser) GetVirtualNode(alias string) (models.Node, models.Host, return node, host, identity, nil } +// GetControlConfig parses multiplexing options for a given alias/host in ssh_config. +func (p *OpenSSHParser) GetControlConfig(alias string) (*ssh.ControlConfig, error) { + if p == nil || p.cfg == nil || alias == "" { + return nil, nil + } + cleanAlias := strings.TrimPrefix(alias, OpenSSHNodePrefix) + lookupAlias, _, _, err := parseOpenSSHHostSpec(cleanAlias) + if err != nil { + lookupAlias = cleanAlias + } + return parseOpenSSHControl(p.cfg, lookupAlias) +} + +func parseOpenSSHControl(cfg *ssh_config.Config, host string) (*ssh.ControlConfig, error) { + if cfg == nil || host == "" { + return nil, nil + } + masterVal, err := cfg.Get(host, "ControlMaster") + if err != nil { + return nil, fmt.Errorf("read openssh ControlMaster for host %q: %w", host, err) + } + pathVal, err := cfg.Get(host, "ControlPath") + if err != nil { + return nil, fmt.Errorf("read openssh ControlPath for host %q: %w", host, err) + } + persistVal, err := cfg.Get(host, "ControlPersist") + if err != nil { + return nil, fmt.Errorf("read openssh ControlPersist for host %q: %w", host, err) + } + + masterVal = strings.TrimSpace(masterVal) + pathVal = strings.TrimSpace(pathVal) + persistVal = strings.TrimSpace(persistVal) + + if masterVal == "" && pathVal == "" && persistVal == "" { + return nil, nil + } + + // An absent ControlMaster stays empty so lower-priority layers keep the + // ability to inherit a value orthogonally. Only an explicit directive is + // normalized into a mode. + var masterMode ssh.ControlMasterMode + if masterVal != "" { + mode, err := ssh.ParseControlMasterMode(masterVal) + if err != nil { + return nil, fmt.Errorf("invalid ControlMaster %q for %q in ssh config: %w", masterVal, host, err) + } + masterMode = mode + } + + if persistVal != "" { + if _, _, _, err := ssh.ParseControlPersist(persistVal); err != nil { + return nil, fmt.Errorf("invalid ControlPersist %q for %q in ssh config: %w", persistVal, host, err) + } + } + + return &ssh.ControlConfig{ + Master: masterMode, + Path: pathVal, + Persist: persistVal, + }, nil +} + func parseOpenSSHHostSpec(spec string) (host, userName string, port uint16, err error) { spec = strings.TrimSpace(spec) if spec == "" { diff --git a/pkg/config/openssh_import.go b/pkg/config/openssh_import.go index 9542b498..dab4cb03 100644 --- a/pkg/config/openssh_import.go +++ b/pkg/config/openssh_import.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/kevinburke/ssh_config" + "github.com/wentf9/xops-cli/core/ssh" "github.com/wentf9/xops-cli/pkg/models" ) @@ -92,6 +93,12 @@ func openSSHHostFromConfig(cfg *ssh_config.Config, name, defaultUser string) (Op return OpenSSHHost{}, err } + ctrl, err := parseOpenSSHControl(cfg, name) + if err != nil { + return OpenSSHHost{}, err + } + ctrl = normalizeImportedControl(ctrl) + return OpenSSHHost{ Name: name, Host: models.Host{ @@ -108,10 +115,43 @@ func openSSHHostFromConfig(cfg *ssh_config.Config, name, defaultUser string) (Op Tags: []string{"openssh"}, ProxyJump: proxyJump, SudoMode: models.SudoModeAuto, + Control: ctrl, }, }, nil } +// normalizeImportedControl makes an imported ControlConfig valid under the +// persisted schema rules without weakening them. OpenSSH inherits ControlPersist +// independently of ControlMaster, so a host that resolved to ControlMaster=no +// can carry a ControlPersist it never asked for; that combination is contradictory +// once materialized, and the master mode wins. +func normalizeImportedControl(ctrl *ssh.ControlConfig) *ssh.ControlConfig { + if ctrl == nil { + return nil + } + if ctrl.Path == "" { + // An OpenSSH host with ControlMaster or ControlPersist but no ControlPath + // cannot form a standalone node ControlConfig without failing schema + // validation when no global ControlPath is set. + return nil + } + if ctrl.Master == ssh.ControlMasterNo && ctrl.Persist != "" { + if _, _, _, err := ssh.ParseControlPersist(ctrl.Persist); err != nil { + ctrl.Persist = "" + return ctrl + } + enabled, _, _, _ := ssh.ParseControlPersist(ctrl.Persist) + if enabled { + ctrl.Persist = "" + } + } + if ctrl.Path == "none" { + ctrl.Master = ssh.ControlMasterNo + ctrl.Persist = "" + } + return ctrl +} + func openSSHValue(cfg *ssh_config.Config, host, key, fallback string) (string, error) { value, err := cfg.Get(host, key) if err != nil { diff --git a/pkg/config/openssh_import_test.go b/pkg/config/openssh_import_test.go index d368ef20..b1f3b546 100644 --- a/pkg/config/openssh_import_test.go +++ b/pkg/config/openssh_import_test.go @@ -66,3 +66,95 @@ func TestParseOpenSSHHosts_InvalidPort(t *testing.T) { t.Fatal("ParseOpenSSHHosts() error = nil, want invalid port error") } } + +func TestParseOpenSSHHosts_ControlConfig(t *testing.T) { + t.Parallel() + + const input = ` +Host app + HostName 10.0.0.10 + User deploy + +Host custom + HostName 10.0.0.20 + ControlMaster yes + ControlPath /tmp/custom.sock + ControlPersist 0 + +Host disabled + HostName 10.0.0.30 + ControlPath none + +Host * + ControlMaster auto + ControlPath ~/.ssh/sockets/%r@%h-%p + ControlPersist 10m +` + + hosts, err := ParseOpenSSHHosts(strings.NewReader(input), "local-user") + if err != nil { + t.Fatalf("ParseOpenSSHHosts() error = %v", err) + } + if len(hosts) != 3 { + t.Fatalf("expected 3 hosts, got %d", len(hosts)) + } + + // 1. app inherits wildcard control options + app := hosts[0] + if app.Name != "app" { + t.Fatalf("expected first host to be 'app', got %s", app.Name) + } + if app.Node.Control == nil { + t.Fatal("expected app.Node.Control to be non-nil") + } + if app.Node.Control.Master != "auto" || + app.Node.Control.Path != "~/.ssh/sockets/%r@%h-%p" || + app.Node.Control.Persist != "10m" { + t.Fatalf("app control mismatch: %+v", app.Node.Control) + } + + // 2. custom has explicit control options + custom := hosts[1] + if custom.Name != "custom" { + t.Fatalf("expected second host to be 'custom', got %s", custom.Name) + } + if custom.Node.Control == nil { + t.Fatal("expected custom.Node.Control to be non-nil") + } + if custom.Node.Control.Master != "yes" || + custom.Node.Control.Path != "/tmp/custom.sock" || + custom.Node.Control.Persist != "0" { + t.Fatalf("custom control mismatch: %+v", custom.Node.Control) + } + + // 3. disabled overrides path to none + disabled := hosts[2] + if disabled.Name != "disabled" { + t.Fatalf("expected third host to be 'disabled', got %s", disabled.Name) + } + if disabled.Node.Control == nil { + t.Fatal("expected disabled.Node.Control to be non-nil") + } + if disabled.Node.Control.Path != "none" { + t.Fatalf("disabled control mismatch: %+v", disabled.Node.Control) + } + if disabled.Node.Control.IsEnabled() { + t.Fatal("disabled control should have IsEnabled() == false") + } +} + +func TestParseOpenSSHHosts_InvalidControl(t *testing.T) { + t.Parallel() + + // 1. Invalid ControlMaster + _, err := ParseOpenSSHHosts(strings.NewReader("Host bad\n ControlMaster unknown_mode\n"), "user") + if err == nil || !strings.Contains(err.Error(), "invalid ControlMaster") { + t.Fatalf("expected invalid ControlMaster error, got: %v", err) + } + + // 2. Invalid ControlPersist + _, err = ParseOpenSSHHosts(strings.NewReader("Host bad\n ControlPersist 10invalid\n"), "user") + if err == nil || !strings.Contains(err.Error(), "invalid ControlPersist") { + t.Fatalf("expected invalid ControlPersist error, got: %v", err) + } +} diff --git a/pkg/config/openssh_test.go b/pkg/config/openssh_test.go index 91edb123..149cee11 100644 --- a/pkg/config/openssh_test.go +++ b/pkg/config/openssh_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/wentf9/xops-cli/core/ssh" ) // TestOpenSSHParser_NonExistentFile 验证当配置文件不存在时,返回空 Parser 且错误为 nil @@ -353,3 +355,257 @@ func TestParseOpenSSHHostSpec(t *testing.T) { }) } } + +func TestOpenSSHParser_ControlConfig_Modes(t *testing.T) { + configContent := ` +Host srv-auto + HostName 10.0.0.1 + ControlMaster auto + ControlPath ~/.ssh/sockets/%r@%h-%p + ControlPersist 10m + +Host srv-yes + HostName 10.0.0.2 + ControlMaster yes + ControlPath /tmp/mux-%C.sock + ControlPersist 0 + +Host srv-ask + HostName 10.0.0.3 + ControlMaster ask + ControlPath ~/.ssh/cm-%h + ControlPersist 1h +` + + parser, err := NewOpenSSHParserFromReader(strings.NewReader(configContent)) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + + node1, _, _, err := parser.GetVirtualNode("srv-auto") + if err != nil { + t.Fatalf("GetVirtualNode(srv-auto) failed: %v", err) + } + if node1.Control == nil || node1.Control.Master != ssh.ControlMasterAuto || + node1.Control.Path != "~/.ssh/sockets/%r@%h-%p" || + node1.Control.Persist != "10m" { + t.Fatalf("srv-auto control mismatch: %+v", node1.Control) + } + ctrl1, err := parser.GetControlConfig("srv-auto") + if err != nil || !ctrl1.Equal(node1.Control) { + t.Fatalf("GetControlConfig(srv-auto) mismatch: %v, err=%v", ctrl1, err) + } + + node2, _, _, err := parser.GetVirtualNode("srv-yes") + if err != nil { + t.Fatalf("GetVirtualNode(srv-yes) failed: %v", err) + } + if node2.Control == nil || node2.Control.Master != ssh.ControlMasterYes || + node2.Control.Path != "/tmp/mux-%C.sock" || + node2.Control.Persist != "0" { + t.Fatalf("srv-yes control mismatch: %+v", node2.Control) + } + + node3, _, _, err := parser.GetVirtualNode("srv-ask") + if err != nil { + t.Fatalf("GetVirtualNode(srv-ask) failed: %v", err) + } + if node3.Control == nil || node3.Control.Master != ssh.ControlMasterAsk || + node3.Control.Persist != "1h" { + t.Fatalf("srv-ask control mismatch: %+v", node3.Control) + } +} + +func TestOpenSSHParser_ControlConfig_NoneAndNoControl(t *testing.T) { + configContent := ` +Host srv-none + HostName 10.0.0.4 + ControlMaster no + ControlPath none + ControlPersist no + +Host srv-nocontrol + HostName 10.0.0.5 +` + + parser, err := NewOpenSSHParserFromReader(strings.NewReader(configContent)) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + + node4, _, _, err := parser.GetVirtualNode("srv-none") + if err != nil { + t.Fatalf("GetVirtualNode(srv-none) failed: %v", err) + } + if node4.Control == nil || node4.Control.Path != "none" || node4.Control.Master != ssh.ControlMasterNo { + t.Fatalf("srv-none control mismatch: %+v", node4.Control) + } + if node4.Control.IsEnabled() { + t.Fatalf("srv-none should have IsEnabled() == false") + } + + node5, _, _, err := parser.GetVirtualNode("srv-nocontrol") + if err != nil { + t.Fatalf("GetVirtualNode(srv-nocontrol) failed: %v", err) + } + if node5.Control != nil { + t.Fatalf("expected srv-nocontrol Control to be nil, got: %+v", node5.Control) + } +} + +func TestOpenSSHParser_ControlConfig_WildcardInheritance(t *testing.T) { + configContent := ` +Host concrete-override + HostName 10.0.1.1 + ControlMaster no + +Host concrete-disabled + HostName 10.0.1.2 + ControlPath none + +Host concrete-normal + HostName 10.0.1.3 + +Host * + ControlMaster auto + ControlPath ~/.ssh/sockets/%r@%h-%p + ControlPersist 10m +` + + parser, err := NewOpenSSHParserFromReader(strings.NewReader(configContent)) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + + // 1. concrete-normal inherits all from Host * + ctrlNormal, err := parser.GetControlConfig("concrete-normal") + if err != nil { + t.Fatalf("GetControlConfig(concrete-normal) failed: %v", err) + } + if ctrlNormal == nil { + t.Fatal("expected concrete-normal to inherit control options") + } + if ctrlNormal.Master != ssh.ControlMasterAuto || + ctrlNormal.Path != "~/.ssh/sockets/%r@%h-%p" || + ctrlNormal.Persist != "10m" { + t.Fatalf("inherited control mismatch: %+v", ctrlNormal) + } + + // 2. concrete-override overrides Master to 'no', but inherits Path and Persist + ctrlOverride, err := parser.GetControlConfig("concrete-override") + if err != nil { + t.Fatalf("GetControlConfig(concrete-override) failed: %v", err) + } + if ctrlOverride.Master != ssh.ControlMasterNo || + ctrlOverride.Path != "~/.ssh/sockets/%r@%h-%p" || + ctrlOverride.Persist != "10m" { + t.Fatalf("overridden master mismatch: %+v", ctrlOverride) + } + + // 3. concrete-disabled overrides Path to 'none' + ctrlDisabled, err := parser.GetControlConfig("concrete-disabled") + if err != nil { + t.Fatalf("GetControlConfig(concrete-disabled) failed: %v", err) + } + if ctrlDisabled.Path != "none" { + t.Fatalf("expected Path='none', got: %q", ctrlDisabled.Path) + } + if ctrlDisabled.IsEnabled() { + t.Fatal("expected IsEnabled() == false for Path='none'") + } +} + +func TestOpenSSHParser_ControlConfig_Invalid(t *testing.T) { + // 1. Invalid ControlMaster + cfg1 := ` +Host bad-master + ControlMaster invalid_val +` + parser1, _ := NewOpenSSHParserFromReader(strings.NewReader(cfg1)) + _, err1 := parser1.GetControlConfig("bad-master") + if err1 == nil || !strings.Contains(err1.Error(), "invalid ControlMaster") { + t.Fatalf("expected invalid ControlMaster error, got: %v", err1) + } + + // 2. Invalid ControlPersist + cfg2 := ` +Host bad-persist + ControlPersist 10x +` + parser2, _ := NewOpenSSHParserFromReader(strings.NewReader(cfg2)) + _, err2 := parser2.GetControlConfig("bad-persist") + if err2 == nil || !strings.Contains(err2.Error(), "invalid ControlPersist") { + t.Fatalf("expected invalid ControlPersist error, got: %v", err2) + } +} + +// TestOpenSSHImport_NormalizesContradictoryControl verifies that a host which +// resolved to ControlMaster=no while inheriting ControlPersist from a wildcard +// still imports: the persisted schema rejects that combination outright. +func TestOpenSSHImport_NormalizesContradictoryControl(t *testing.T) { + configContent := ` +Host no-master-host + HostName 10.0.0.7 + ControlMaster no + +Host * + ControlMaster auto + ControlPath ~/.ssh/sockets/%r@%h-%p + ControlPersist 10m +` + hosts, err := ParseOpenSSHHosts(strings.NewReader(configContent), "deploy") + if err != nil { + t.Fatalf("ParseOpenSSHHosts failed: %v", err) + } + var found *OpenSSHHost + for i := range hosts { + if hosts[i].Name == "no-master-host" { + found = &hosts[i] + break + } + } + if found == nil { + t.Fatal("no-master-host was not imported") + } + if found.Node.Control == nil { + t.Fatal("expected imported control config") + } + if found.Node.Control.Master != ssh.ControlMasterNo { + t.Fatalf("expected ControlMaster=no, got %q", found.Node.Control.Master) + } + if found.Node.Control.Persist != "" { + t.Fatalf("expected inherited ControlPersist to be dropped for ControlMaster=no, got %q", found.Node.Control.Persist) + } + if err := ValidateControlConfig(found.Node.Control); err != nil { + t.Fatalf("imported control config is invalid: %v", err) + } +} + +// TestOpenSSHParser_ControlMasterAbsentStaysUnset verifies that an omitted +// ControlMaster is not materialized as "no"; doing so would block orthogonal +// inheritance from a lower-priority layer. +func TestOpenSSHParser_ControlMasterAbsentStaysUnset(t *testing.T) { + configContent := ` +Host path-only + HostName 10.0.0.8 + ControlPath ~/.ssh/path-only.sock + ControlPersist 5m +` + parser, err := NewOpenSSHParserFromReader(strings.NewReader(configContent)) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + ctrl, err := parser.GetControlConfig("path-only") + if err != nil { + t.Fatalf("GetControlConfig failed: %v", err) + } + if ctrl == nil { + t.Fatal("expected control config") + } + if ctrl.Master != "" { + t.Fatalf("expected an unset ControlMaster, got %q", ctrl.Master) + } + if ctrl.Path != "~/.ssh/path-only.sock" || ctrl.Persist != "5m" { + t.Fatalf("unexpected control config %+v", ctrl) + } +} diff --git a/pkg/config/pending_connection.go b/pkg/config/pending_connection.go index de831930..6af8e13b 100644 --- a/pkg/config/pending_connection.go +++ b/pkg/config/pending_connection.go @@ -7,8 +7,15 @@ import ( "strings" "github.com/wentf9/xops-cli/core/ssh" + "github.com/wentf9/xops-cli/pkg/models" ) +func equalPersistedNode(a, b models.Node) bool { + a.OpenSSHAlias = "" + b.OpenSSHAlias = "" + return reflect.DeepEqual(a, b) +} + type pendingConnection struct { provider *Provider // immutable connection bundle gate contextGate @@ -40,49 +47,57 @@ func (r *Repository) PrepareNodeContext(ctx context.Context, opts EnsureNodeOpti var nodeID string var created bool cfg, _, _, err := prepareConfiguration(r.provider.Snapshot(), func(cfg *Configuration) error { - lookup, aliases, err := buildIndexes(cfg, false) - if err != nil { - return fmt.Errorf("build connection indexes: %w", err) - } - if !opts.Target.HasUser && !opts.Target.HasPort { - return r.ensureBareTargetInTransaction(cfg, opts, lookup, aliases, selector, defaultUser, &nodeID, &created) - } - return r.ensureExplicitTargetInTransaction(cfg, opts, lookup, aliases, selector, defaultUser, &nodeID, &created) + return r.ensureTargetInTransaction(cfg, opts, defaultUser, &nodeID, &created) }) if err != nil { return EnsureNodeResult{}, err } if created { - // Retain only this connection's entities, never a stale copy of the - // whole configuration that could overwrite concurrent changes. - node, _ := cfg.Nodes.Get(nodeID) - host, _ := cfg.Hosts.Get(node.HostRef) - identity, _ := cfg.Identities.Get(node.IdentityRef) - // Concurrent first connections on different ports must not reserve the - // same identity and later share (or conflict over) remembered secrets. - node.IdentityRef = privateIdentityReference(cfg, nodeID) - pending := cloneConfiguration(nil) - pending.Nodes.Set(nodeID, node) - pending.Hosts.Set(node.HostRef, host) - pending.Identities.Set(node.IdentityRef, identity) - if r.pending == nil { - r.pending = make(map[string]*pendingConnection) - } - if previous := r.pending[nodeID]; previous != nil { - oldNode, oldHost, oldIdentity, err := previous.provider.Resolve(nodeID) - if err != nil { - return EnsureNodeResult{}, fmt.Errorf("resolve pending connection %q: %w", nodeID, err) - } - if !reflect.DeepEqual(oldNode, node) || !reflect.DeepEqual(oldHost, host) || !reflect.DeepEqual(oldIdentity, identity) { - return EnsureNodeResult{}, fmt.Errorf("pending connection %q changed: %w", nodeID, ErrConfigConflict) - } - return EnsureNodeResult{NodeID: nodeID, Created: true}, nil + if err := r.registerPendingConnection(cfg, nodeID); err != nil { + return EnsureNodeResult{}, err } - r.pending[nodeID] = &pendingConnection{provider: NewProviderWithoutOpenSSH(pending)} } return EnsureNodeResult{NodeID: nodeID, Created: created}, nil } +func (r *Repository) registerPendingConnection(cfg *Configuration, nodeID string) error { + // Retain only this connection's entities, never a stale copy of the + // whole configuration that could overwrite concurrent changes. + node, _ := cfg.Nodes.Get(nodeID) + host, _ := cfg.Hosts.Get(node.HostRef) + identity, _ := cfg.Identities.Get(node.IdentityRef) + // Concurrent first connections on different ports must not reserve the + // same identity and later share (or conflict over) remembered secrets. + node.IdentityRef = privateIdentityReference(cfg, nodeID) + pending := cloneConfiguration(nil) + pending.Nodes.Set(nodeID, node) + pending.Hosts.Set(node.HostRef, host) + pending.Identities.Set(node.IdentityRef, identity) + if r.pending == nil { + r.pending = make(map[string]*pendingConnection) + } + if previous := r.pending[nodeID]; previous != nil { + oldNode, oldHost, oldIdentity, err := previous.provider.Resolve(nodeID) + if err != nil { + return fmt.Errorf("resolve pending connection %q: %w", nodeID, err) + } + if !equalPersistedNode(oldNode, node) || !reflect.DeepEqual(oldHost, host) || !reflect.DeepEqual(oldIdentity, identity) { + return fmt.Errorf("pending connection %q changed: %w", nodeID, ErrConfigConflict) + } + if oldNode.OpenSSHAlias == "" && node.OpenSSHAlias != "" { + previous.provider.SetNodeOpenSSHAlias(nodeID, node.OpenSSHAlias) + r.SetNodeOpenSSHAlias(nodeID, node.OpenSSHAlias) + } + return nil + } + pendingProvider := NewProviderWithoutOpenSSH(pending) + if node.OpenSSHAlias != "" { + pendingProvider.SetNodeOpenSSHAlias(nodeID, node.OpenSSHAlias) + } + r.pending[nodeID] = &pendingConnection{provider: pendingProvider} + return nil +} + func (r *Repository) connectionProvider(nodeID string) *Provider { r.pendingMu.RLock() defer r.pendingMu.RUnlock() @@ -92,6 +107,23 @@ func (r *Repository) connectionProvider(nodeID string) *Provider { return r.provider } +func (r *Repository) resolvePendingNode(nodeID string) (models.Node, models.Host, models.Identity, bool) { + if r == nil || nodeID == "" { + return models.Node{}, models.Host{}, models.Identity{}, false + } + r.pendingMu.RLock() + pending := r.pending[nodeID] + r.pendingMu.RUnlock() + if pending == nil || pending.provider == nil { + return models.Node{}, models.Host{}, models.Identity{}, false + } + node, host, ident, err := pending.provider.Resolve(nodeID) + if err != nil { + return models.Node{}, models.Host{}, models.Identity{}, false + } + return node, host, ident, true +} + // ConfirmNodeContext saves exactly the prepared connection after SSH // authentication succeeds. Conflicting concurrent edits are never overwritten. // A failed save leaves the pending connection available for an explicit retry; @@ -156,7 +188,7 @@ func (r *Repository) confirmNodeContext(ctx context.Context, nodeID string, exec node.Execution = execution.Clone() } result, err := r.commitResultContext(ctx, anyRevision, func(cfg *Configuration) error { - if existing, ok := cfg.Nodes.Get(nodeID); ok && !reflect.DeepEqual(existing, node) { + if existing, ok := cfg.Nodes.Get(nodeID); ok && !equalPersistedNode(existing, node) { return fmt.Errorf("save connected node %q: %w", nodeID, ErrConfigConflict) } if existing, ok := cfg.Hosts.Get(node.HostRef); ok && !reflect.DeepEqual(existing, host) { @@ -171,6 +203,9 @@ func (r *Repository) confirmNodeContext(ctx context.Context, nodeID string, exec return nil }) if result.Applied { + if node.OpenSSHAlias != "" { + r.SetNodeOpenSSHAlias(nodeID, node.OpenSSHAlias) + } r.pendingMu.Lock() delete(r.pending, nodeID) r.pendingMu.Unlock() diff --git a/pkg/config/provider.go b/pkg/config/provider.go index fdc9eac6..3a52a596 100644 --- a/pkg/config/provider.go +++ b/pkg/config/provider.go @@ -9,17 +9,19 @@ import ( "github.com/wentf9/xops-cli/core/concurrent" corepolicy "github.com/wentf9/xops-cli/core/mcp/policy" + "github.com/wentf9/xops-cli/core/ssh" "github.com/wentf9/xops-cli/pkg/models" ) // Provider owns one coherent in-memory configuration snapshot. All reads see // one revision. Repository owns durable mutations and publishes new snapshots. type Provider struct { - mu sync.RWMutex - cfg *Configuration - lookupIndex map[string][]string - aliasIndex map[string]string - openSSH *OpenSSHParser + mu sync.RWMutex + cfg *Configuration + lookupIndex map[string][]string + aliasIndex map[string]string + openSSH *OpenSSHParser + openSSHAliases map[string]string } var _ ConfigProvider = (*Provider)(nil) @@ -63,7 +65,54 @@ func newProvider(cfg *Configuration, parser *OpenSSHParser) *Provider { func (p *Provider) Frozen() ConfigProvider { p.mu.RLock() defer p.mu.RUnlock() - return newProvider(p.cfg, p.openSSH) + frozen := newProvider(p.cfg, p.openSSH) + if len(p.openSSHAliases) > 0 { + frozen.openSSHAliases = make(map[string]string, len(p.openSSHAliases)) + for k, v := range p.openSSHAliases { + frozen.openSSHAliases[k] = v + } + } + return frozen +} + +// GetOpenSSHControl returns multiplexing configuration from OpenSSH config for an alias. +func (p *Provider) GetOpenSSHControl(alias string) (*ssh.ControlConfig, error) { + if p == nil || p.openSSH == nil { + return nil, nil + } + return p.openSSH.GetControlConfig(alias) +} + +// GetOpenSSHParser returns the underlying OpenSSH parser. +func (p *Provider) GetOpenSSHParser() *OpenSSHParser { + if p == nil { + return nil + } + return p.openSSH +} + +// SetNodeOpenSSHAlias records the concrete OpenSSH Host alias from which nodeID +// was derived during the current session without modifying persisted state. +func (p *Provider) SetNodeOpenSSHAlias(nodeID, alias string) { + if p == nil || nodeID == "" || alias == "" { + return + } + p.mu.Lock() + defer p.mu.Unlock() + if p.openSSHAliases == nil { + p.openSSHAliases = make(map[string]string) + } + p.openSSHAliases[nodeID] = alias +} + +// NodeOpenSSHAlias returns the session-recorded OpenSSH Host alias for nodeID. +func (p *Provider) NodeOpenSSHAlias(nodeID string) string { + if p == nil || nodeID == "" { + return "" + } + p.mu.RLock() + defer p.mu.RUnlock() + return p.openSSHAliases[nodeID] } // Snapshot returns a deep copy that callers may retain and mutate freely. @@ -140,6 +189,7 @@ func (p *Provider) Resolve(nodeID string) (models.Node, models.Host, models.Iden if node, ok := p.cfg.Nodes.Get(nodeID); ok { host, hostOK := p.cfg.Hosts.Get(node.HostRef) identity, identityOK := p.cfg.Identities.Get(node.IdentityRef) + openSSHAlias := p.openSSHAliases[nodeID] p.mu.RUnlock() if !hostOK { return models.Node{}, models.Host{}, models.Identity{}, fmt.Errorf("host ref %q for node %q: %w", node.HostRef, nodeID, ErrHostNotFound) @@ -147,7 +197,11 @@ func (p *Provider) Resolve(nodeID string) (models.Node, models.Host, models.Iden if !identityOK { return models.Node{}, models.Host{}, models.Identity{}, fmt.Errorf("identity ref %q for node %q: %w", node.IdentityRef, nodeID, ErrIdentityNotFound) } - return cloneNode(node), cloneHost(host), cloneIdentity(identity), nil + clonedNode := cloneNode(node) + if clonedNode.OpenSSHAlias == "" { + clonedNode.OpenSSHAlias = openSSHAlias + } + return clonedNode, cloneHost(host), cloneIdentity(identity), nil } p.mu.RUnlock() @@ -178,7 +232,11 @@ func (p *Provider) GetNode(nodeID string) (models.Node, bool) { p.mu.RLock() defer p.mu.RUnlock() node, ok := p.cfg.Nodes.Get(nodeID) - return cloneNode(node), ok + cloned := cloneNode(node) + if ok && cloned.OpenSSHAlias == "" { + cloned.OpenSSHAlias = p.openSSHAliases[nodeID] + } + return cloned, ok } func (p *Provider) GetHost(nodeID string) (models.Host, bool) { @@ -318,22 +376,24 @@ func buildIndexes(cfg *Configuration, rejectAliasConflicts bool) (map[string][]s return nil, nil, err } } - if identity.User == "" { + user := normalizedIdentityUser(identity.User) + if user == "" { continue } + port := normalizedHostPort(host.Port) for _, address := range append(slices.Clone(host.Alias), host.Address) { if address == "" { continue } - addLookup(lookup, fmt.Sprintf("%s@%s", identity.User, address), nodeID) - addLookup(lookup, fmt.Sprintf("%s@%s:%d", identity.User, address, host.Port), nodeID) + addLookup(lookup, fmt.Sprintf("%s@%s", user, address), nodeID) + addLookup(lookup, fmt.Sprintf("%s@%s:%d", user, address, port), nodeID) } for _, alias := range node.Alias { if alias == "" { continue } - addLookup(lookup, fmt.Sprintf("%s@%s", identity.User, alias), nodeID) - addLookup(lookup, fmt.Sprintf("%s@%s:%d", identity.User, alias, host.Port), nodeID) + addLookup(lookup, fmt.Sprintf("%s@%s", user, alias), nodeID) + addLookup(lookup, fmt.Sprintf("%s@%s:%d", user, alias, port), nodeID) } } for key := range lookup { @@ -346,11 +406,15 @@ func addAlias(lookup map[string][]string, aliases map[string]string, alias, node if alias == "" { return nil } - if existing, ok := aliases[alias]; ok && existing != nodeID { + if existing, ok := aliases[alias]; ok { + if existing == nodeID { + addLookup(lookup, alias, nodeID) + return nil + } if rejectConflict { return fmt.Errorf("alias %q is already assigned to node %q", alias, existing) } - delete(aliases, alias) + aliases[alias] = "" addLookup(lookup, alias, nodeID) return nil } @@ -388,6 +452,7 @@ func cloneConfiguration(cfg *Configuration) *Configuration { cloned.Guardrail = cloneGuardrail(cfg.Guardrail) cloned.MCP = cfg.MCP.Clone() cloned.Execution = cfg.Execution.Clone() + cloned.Control = cfg.Control.Clone() if cfg.Nodes != nil { for _, key := range cfg.Nodes.Keys() { if node, ok := cfg.Nodes.Get(key); ok { @@ -421,6 +486,9 @@ func cloneNode(node models.Node) models.Node { if node.Execution != nil { node.Execution = node.Execution.Clone() } + if node.Control != nil { + node.Control = node.Control.Clone() + } return node } diff --git a/pkg/config/repository.go b/pkg/config/repository.go index df5feeb9..663da3cc 100644 --- a/pkg/config/repository.go +++ b/pkg/config/repository.go @@ -13,6 +13,7 @@ import ( "time" coreauth "github.com/wentf9/xops-cli/core/auth" + "github.com/wentf9/xops-cli/core/ssh" "github.com/wentf9/xops-cli/pkg/credential" "github.com/wentf9/xops-cli/pkg/models" "gopkg.in/yaml.v3" @@ -349,8 +350,12 @@ func (r *Repository) ResolveConnection(nodeID string) (ConnectionSnapshot, error if err != nil { return ConnectionSnapshot{}, fmt.Errorf("resolve sudo version for node %q: %w", nodeID, err) } + clonedNode := cloneNode(node) + if clonedNode.OpenSSHAlias == "" && r.provider != nil { + clonedNode.OpenSSHAlias = r.provider.NodeOpenSSHAlias(nodeID) + } return ConnectionSnapshot{ - Node: cloneNode(node), + Node: clonedNode, Host: cloneHost(host), Identity: cloneIdentity(identity), UpdateRef: &ConnectionUpdateRef{ @@ -364,9 +369,29 @@ func (r *Repository) ResolveConnection(nodeID string) (ConnectionSnapshot, error if err != nil { return ConnectionSnapshot{}, err } + if node.OpenSSHAlias == "" && r.provider != nil { + node.OpenSSHAlias = r.provider.NodeOpenSSHAlias(nodeID) + } return ConnectionSnapshot{Node: node, Host: host, Identity: identity}, nil } +// SetNodeOpenSSHAlias records the concrete OpenSSH Host alias from which nodeID +// was derived during the current session without modifying persisted state. +func (r *Repository) SetNodeOpenSSHAlias(nodeID, alias string) { + if r == nil || r.provider == nil { + return + } + r.provider.SetNodeOpenSSHAlias(nodeID, alias) +} + +// NodeOpenSSHAlias returns the session-recorded OpenSSH Host alias for nodeID. +func (r *Repository) NodeOpenSSHAlias(nodeID string) string { + if r == nil || r.provider == nil { + return "" + } + return r.provider.NodeOpenSSHAlias(nodeID) +} + func versionFromString(value string) (Version, error) { var version Version if len(value) != len(version) { @@ -1012,10 +1037,10 @@ func findImportedProxyByConnection(cfg *Configuration, hop string) (string, bool if !hostExists || !identityExists || host.Address != hostName { continue } - if port != 0 && host.Port != port { + if port != 0 && normalizedHostPort(host.Port) != normalizedHostPort(port) { continue } - if userName != "" && identity.User != userName { + if userName != "" && normalizedIdentityUser(identity.User) != userName { continue } candidates = append(candidates, nodeID) @@ -1184,6 +1209,20 @@ func (r *Repository) Snapshot() *Configuration { return r.provider.Snapshot() } +func (r *Repository) GetOpenSSHControl(alias string) (*ssh.ControlConfig, error) { + if r == nil || r.provider == nil { + return nil, nil + } + return r.provider.GetOpenSSHControl(alias) +} + +func (r *Repository) GetOpenSSHParser() *OpenSSHParser { + if r == nil || r.provider == nil { + return nil + } + return r.provider.GetOpenSSHParser() +} + // Frozen exposes an immutable read-only view, retaining OpenSSH diagnostics // without exposing Repository's durable mutation interfaces to the caller. func (r *Repository) Frozen() ConfigProvider { diff --git a/pkg/config/schema_v2.go b/pkg/config/schema_v2.go index 467bcdc1..cb86477d 100644 --- a/pkg/config/schema_v2.go +++ b/pkg/config/schema_v2.go @@ -172,6 +172,7 @@ type NodeV2 struct { PrivilegePasswordRef *credential.Ref `yaml:"privilege_password_ref,omitempty"` PasswordPromptPattern string `yaml:"password_prompt_pattern,omitempty"` Execution *ssh.ExecutionConfig `yaml:"execution,omitempty"` + Control *ssh.ControlConfig `yaml:"control,omitempty"` } // ConfigurationV2 是 Schema v2 的顶级规范配置 DTO。 @@ -186,6 +187,7 @@ type ConfigurationV2 struct { MCP *MCPConfig `yaml:"mcp,omitempty"` PasswordPromptPattern string `yaml:"password_prompt_pattern,omitempty"` Execution *ssh.ExecutionConfig `yaml:"execution,omitempty"` + Control *ssh.ControlConfig `yaml:"control,omitempty"` } // ValidateV2 对 ConfigurationV2 进行全面且严格的合法性校验。 @@ -203,6 +205,12 @@ func ValidateV2(cfg *ConfigurationV2) error { } } + if cfg.Control != nil { + if err := ValidateControlConfig(cfg.Control); err != nil { + return fmt.Errorf("%w: global control invalid: %w", ErrSchemaValidation, err) + } + } + if err := validateCredentialConfig(&cfg.Credential); err != nil { return err } @@ -488,6 +496,19 @@ func validateNodeRefs(nodeName string, node NodeV2, cfg *ConfigurationV2) error if _, err := ssh.ResolveExecution(node.Execution, cfg.Execution); err != nil { return fmt.Errorf("%w: node %q effective execution invalid: %w", ErrSchemaValidation, nodeName, err) } + + if node.Control != nil { + if err := ValidateControlConfig(node.Control); err != nil { + return fmt.Errorf("%w: node %q control invalid: %w", ErrSchemaValidation, nodeName, err) + } + } + if effectiveControl, err := ssh.ResolveControl(node.Control, cfg.Control); err != nil { + return fmt.Errorf("%w: node %q effective control invalid: %w", ErrSchemaValidation, nodeName, err) + } else if effectiveControl != nil { + if err := ValidateEffectiveControl(effectiveControl); err != nil { + return fmt.Errorf("%w: node %q effective control invalid: %w", ErrSchemaValidation, nodeName, err) + } + } return nil } @@ -631,6 +652,7 @@ func (c *Configuration) ToV2() (*ConfigurationV2, error) { PasswordPromptPattern: c.PasswordPromptPattern, MCP: c.MCP.Clone(), Execution: c.Execution.Clone(), + Control: c.Control.Clone(), } if c.Credential != nil { @@ -691,6 +713,7 @@ func (c *Configuration) ToV2() (*ConfigurationV2, error) { PrivilegePasswordRef: n.PrivilegePasswordRef.Clone(), PasswordPromptPattern: n.PasswordPromptPattern, Execution: n.Execution.Clone(), + Control: n.Control.Clone(), } } } @@ -719,6 +742,7 @@ func FromV2(v2 *ConfigurationV2) (*Configuration, error) { MCP: v2.MCP.Clone(), PasswordPromptPattern: v2.PasswordPromptPattern, Execution: v2.Execution.Clone(), + Control: v2.Control.Clone(), } for k, h := range v2.Hosts { @@ -749,6 +773,7 @@ func FromV2(v2 *ConfigurationV2) (*Configuration, error) { PrivilegePasswordRef: n.PrivilegePasswordRef.Clone(), PasswordPromptPattern: n.PasswordPromptPattern, Execution: n.Execution.Clone(), + Control: n.Control.Clone(), }) } diff --git a/pkg/config/schema_v2_test.go b/pkg/config/schema_v2_test.go index 1b3ab18f..d13c5b89 100644 --- a/pkg/config/schema_v2_test.go +++ b/pkg/config/schema_v2_test.go @@ -1310,3 +1310,285 @@ func TestToV2AndFromV2_OTPAndKeyboardInteractive(t *testing.T) { t.Fatalf("reloaded KeyboardInteractive mismatch") } } + +func TestSchemaV2_ControlConfig_Unmarshal(t *testing.T) { + yamlData := `schema_version: 2 +credential: + default_store: pass + stores: + pass: {type: pass} +hosts: + h1: {address: 192.168.1.10, port: 22} +identities: + dev: {user: dev, auth_type: auto} +control: + master: auto + path: ~/.ssh/cm-%r@%h:%p + persist: 10m +nodes: + n1: + host_ref: h1 + identity_ref: dev + control: + master: yes + path: /tmp/custom-%h.sock + persist: 5m + n2: + host_ref: h1 + identity_ref: dev +` + + cfg, err := UnmarshalV2([]byte(yamlData)) + if err != nil { + t.Fatalf("UnmarshalV2 failed: %v", err) + } + + // Verify global control + if cfg.Control == nil { + t.Fatal("expected global control to be non-nil") + } + if cfg.Control.Master != ssh.ControlMasterAuto || + cfg.Control.Path != "~/.ssh/cm-%r@%h:%p" || + cfg.Control.Persist != "10m" { + t.Fatalf("global control mismatch: %+v", cfg.Control) + } + + // Verify node n1 control + n1 := cfg.Nodes["n1"] + if n1.Control == nil { + t.Fatal("expected node n1 control to be non-nil") + } + if n1.Control.Master != ssh.ControlMasterYes || + n1.Control.Path != "/tmp/custom-%h.sock" || + n1.Control.Persist != "5m" { + t.Fatalf("node n1 control mismatch: %+v", n1.Control) + } + + // Verify node n2 control is nil + n2 := cfg.Nodes["n2"] + if n2.Control != nil { + t.Fatalf("expected node n2 control to be nil, got: %+v", n2.Control) + } +} + +func TestSchemaV2_ControlConfig_FromV2AndRoundtrip(t *testing.T) { + yamlData := `schema_version: 2 +credential: + default_store: pass + stores: + pass: {type: pass} +hosts: + h1: {address: 192.168.1.10, port: 22} +identities: + dev: {user: dev, auth_type: auto} +control: + master: auto + path: ~/.ssh/cm-%r@%h:%p + persist: 10m +nodes: + n1: + host_ref: h1 + identity_ref: dev + control: + master: yes + path: /tmp/custom-%h.sock + persist: 5m +` + + cfg, err := UnmarshalV2([]byte(yamlData)) + if err != nil { + t.Fatalf("UnmarshalV2 failed: %v", err) + } + + n1 := cfg.Nodes["n1"] + // Convert FromV2 + memCfg, err := FromV2(cfg) + if err != nil { + t.Fatalf("FromV2 failed: %v", err) + } + if memCfg.Control == nil || !memCfg.Control.Equal(cfg.Control) { + t.Fatalf("FromV2 global control mismatch: %+v", memCfg.Control) + } + + memN1, ok := memCfg.Nodes.Get("n1") + if !ok || memN1.Control == nil || !memN1.Control.Equal(n1.Control) { + t.Fatalf("FromV2 node n1 control mismatch") + } + + // Verify snapshot isolation + snap := memCfg.Snapshot() + snap.Control.Master = ssh.ControlMasterAsk + if memCfg.Control.Master != ssh.ControlMasterAuto { + t.Fatal("snapshot mutation affected original config") + } + + // Convert back ToV2 + v2Back, err := memCfg.ToV2() + if err != nil { + t.Fatalf("ToV2 failed: %v", err) + } + if v2Back.Control == nil || !v2Back.Control.Equal(cfg.Control) { + t.Fatalf("ToV2 global control mismatch") + } +} + +func TestSchemaV2_ControlConfig_ValidationErrors(t *testing.T) { + baseYAML := `schema_version: 2 +credential: + default_store: pass + stores: + pass: {type: pass} +hosts: + h1: {address: 192.168.1.10, port: 22} +identities: + dev: {user: dev, auth_type: auto} +` + + // 1. Invalid global ControlMaster + t.Run("invalid global master", func(t *testing.T) { + y := baseYAML + `control: + master: invalid_mode +nodes: + n1: {host_ref: h1, identity_ref: dev} +` + _, err := UnmarshalV2([]byte(y)) + if err == nil || !errors.Is(err, ErrSchemaValidation) || !strings.Contains(err.Error(), "unsupported ControlMaster option") { + t.Fatalf("expected unsupported ControlMaster error, got: %v", err) + } + }) + + // 2. Invalid node ControlPersist + t.Run("invalid node persist", func(t *testing.T) { + y := baseYAML + `nodes: + n1: + host_ref: h1 + identity_ref: dev + control: + master: auto + path: /tmp/sock + persist: 10xyz +` + _, err := UnmarshalV2([]byte(y)) + if err == nil || !errors.Is(err, ErrSchemaValidation) || !strings.Contains(err.Error(), "invalid ControlPersist") { + t.Fatalf("expected invalid ControlPersist error, got: %v", err) + } + }) + + // 3. Cross-field: Master is 'no' with active persist + t.Run("master no with persist", func(t *testing.T) { + y := baseYAML + `nodes: + n1: + host_ref: h1 + identity_ref: dev + control: + master: "no" + persist: 10m +` + _, err := UnmarshalV2([]byte(y)) + if err == nil || !errors.Is(err, ErrSchemaValidation) || !strings.Contains(err.Error(), "cannot be specified when ControlMaster is 'no'") { + t.Fatalf("expected cross-field master no with persist error, got: %v", err) + } + }) + + // 4. Effective control: Active Master with no Path anywhere + t.Run("active master without path", func(t *testing.T) { + y := baseYAML + `nodes: + n1: + host_ref: h1 + identity_ref: dev + control: + master: auto +` + _, err := UnmarshalV2([]byte(y)) + if err == nil || !errors.Is(err, ErrSchemaValidation) || !strings.Contains(err.Error(), "requires non-empty ControlPath") { + t.Fatalf("expected requires non-empty ControlPath error, got: %v", err) + } + }) + + // 5. Effective control: Node sets Path="none" with Master="yes" + t.Run("active master with path none", func(t *testing.T) { + y := baseYAML + `nodes: + n1: + host_ref: h1 + identity_ref: dev + control: + master: yes + path: none +` + _, err := UnmarshalV2([]byte(y)) + if err == nil || !errors.Is(err, ErrSchemaValidation) || !strings.Contains(err.Error(), "ControlPath cannot be 'none'") { + t.Fatalf("expected ControlPath cannot be none error, got: %v", err) + } + }) +} + +func TestSchemaV2_ControlConfig_NodeDisableMultiplexing(t *testing.T) { + yamlData := `schema_version: 2 +credential: + default_store: pass + stores: + pass: {type: pass} +hosts: + h1: {address: 192.168.1.10, port: 22} +identities: + dev: {user: dev, auth_type: auto} +control: + master: auto + path: ~/.ssh/cm-%r@%h:%p + persist: 10m +nodes: + n1: + host_ref: h1 + identity_ref: dev + control: + path: none + n2: + host_ref: h1 + identity_ref: dev +` + + cfg, err := UnmarshalV2([]byte(yamlData)) + if err != nil { + t.Fatalf("UnmarshalV2 failed on valid node control path: none: %v", err) + } + + // Verify node n1 has raw control path none + n1 := cfg.Nodes["n1"] + if n1.Control == nil { + t.Fatal("expected node n1 control to be non-nil") + } + if n1.Control.Path != "none" { + t.Fatalf("expected node n1 control path to be %q, got %q", "none", n1.Control.Path) + } + + // Verify effective control for node n1 disables multiplexing + eff, err := ssh.ResolveControl(n1.Control, cfg.Control) + if err != nil { + t.Fatalf("unexpected ResolveControl error: %v", err) + } + if eff.Master != ssh.ControlMasterNo { + t.Fatalf("expected effective master %q, got %q", ssh.ControlMasterNo, eff.Master) + } + if eff.Path != "none" { + t.Fatalf("expected effective path %q, got %q", "none", eff.Path) + } + if eff.Persist != "" { + t.Fatalf("expected effective persist to be empty, got %q", eff.Persist) + } + if eff.IsEnabled() { + t.Fatal("expected effective control to be disabled for node n1") + } + + // Verify node n2 inherits global control + n2 := cfg.Nodes["n2"] + effN2, err := ssh.ResolveControl(n2.Control, cfg.Control) + if err != nil { + t.Fatalf("unexpected ResolveControl error for n2: %v", err) + } + if effN2.Master != ssh.ControlMasterAuto || effN2.Path != "~/.ssh/cm-%r@%h:%p" || effN2.Persist != "10m" { + t.Fatalf("node n2 unexpected effective control: %+v", effN2) + } + if !effN2.IsEnabled() { + t.Fatal("expected effective control for n2 to be enabled") + } +} diff --git a/pkg/config/target.go b/pkg/config/target.go index 6b60127d..a2c07da2 100644 --- a/pkg/config/target.go +++ b/pkg/config/target.go @@ -18,6 +18,7 @@ import ( // user, port, and jump host were explicitly specified. type ConnectionTarget struct { Selector string + RawSelector string User string HasUser bool Port uint16 @@ -46,12 +47,35 @@ type EnsureNodeResult struct { Mutation NodeMutation } +// ResolvedTarget describes the effective connection parameters resolved from +// a ConnectionTarget without mutating configuration or creating pending nodes. +type ResolvedTarget struct { + // NodeID is the matched saved/virtual node ID, or the derived canonical + // FormatNodeID(User, Host, Port) when no existing node matches the effective target. + NodeID string + // MatchedNodeID is non-empty only when the effective (User, Host, Port) + // matched an existing saved node or unmodified OpenSSH virtual node. + MatchedNodeID string + // BaseNodeID is the saved or OpenSSH virtual node ID matched by Selector + // before applying user/port overrides, if any. + BaseNodeID string + Host string + Port uint16 + User string + ProxyJump string + KeyPath string + OpenSSHAlias string + Created bool +} + type canonicalTargetInfo struct { + BaseNodeID string Address string Port uint16 IdentityFile string ProxyJump string DefaultUser string + OpenSSHAlias string AmbiguousDefaultUser bool AmbiguousCandidates []string } @@ -70,6 +94,464 @@ func FormatNodeID(user, address string, port uint16) string { return fmt.Sprintf("%s@%s", user, FormatHostPort(address, port)) } +type openSSHParserSource interface { + GetOpenSSHParser() *OpenSSHParser +} + +type openSSHAliasStore interface { + SetNodeOpenSSHAlias(nodeID, alias string) + NodeOpenSSHAlias(nodeID string) string +} + +func resolveTargetHelper(provider ConfigProvider) (*Repository, *Configuration) { + if provider == nil { + return nil, nil + } + if repo, ok := provider.(*Repository); ok && repo != nil { + return repo, repo.Snapshot() + } + if p, ok := provider.(*Provider); ok && p != nil { + return &Repository{provider: p}, p.Snapshot() + } + if fr, ok := provider.(*frozenRepository); ok && fr != nil { + return resolveTargetHelper(fr.ConfigProvider) + } + snap := provider.Snapshot() + lookup, aliases, _ := buildIndexes(snap, false) + var parser *OpenSSHParser + if src, ok := provider.(openSSHParserSource); ok { + parser = src.GetOpenSSHParser() + } + if parser == nil { + parser = &OpenSSHParser{cfg: nil} + } + p := &Provider{ + cfg: snap, + lookupIndex: lookup, + aliasIndex: aliases, + openSSH: parser, + } + return &Repository{provider: p}, snap +} + +func recordResolvedOpenSSHAlias(r *Repository, provider ConfigProvider, nodeID, alias string) { + if nodeID == "" || alias == "" { + return + } + if r != nil { + r.SetNodeOpenSSHAlias(nodeID, alias) + } + if store, ok := provider.(openSSHAliasStore); ok { + store.SetNodeOpenSSHAlias(nodeID, alias) + } +} + +func normalizeTargetSelector(cfg *Configuration, lookup map[string][]string, aliases map[string]string, target ConnectionTarget) (ConnectionTarget, string) { + selector := strings.TrimSpace(target.Selector) + if isDirectOrIndexedSelector(cfg, lookup, aliases, selector) { + return target, selector + } + parsedHost, parsedUser, parsedPort, err := parseOpenSSHHostSpec(selector) + if err != nil || parsedHost == "" { + return target, selector + } + cleanHost := strings.TrimPrefix(strings.TrimSuffix(strings.TrimSpace(parsedHost), "]"), "[") + if cleanHost == "" || isAlreadySplitCompositeTarget(target, selector, parsedUser, parsedPort) { + return target, selector + } + if cleanHost == selector && parsedUser == "" && parsedPort == 0 { + return target, selector + } + if strings.TrimSpace(target.RawSelector) == "" { + target.RawSelector = selector + } + target.Selector = cleanHost + if !target.HasUser && parsedUser != "" { + target.User = parsedUser + target.HasUser = true + } + if !target.HasPort && parsedPort != 0 { + target.Port = parsedPort + target.HasPort = true + } + return target, cleanHost +} + +func isDirectOrIndexedSelector(cfg *Configuration, lookup map[string][]string, aliases map[string]string, selector string) bool { + if selector == "" || strings.HasPrefix(selector, OpenSSHNodePrefix) { + return true + } + if cfg != nil && cfg.Nodes != nil { + if _, exists := cfg.Nodes.Get(selector); exists { + return true + } + } + if nid, ok := aliases[selector]; ok && nid != "" { + return true + } + return len(lookup[selector]) > 0 +} + +func isAlreadySplitCompositeTarget(target ConnectionTarget, selector, parsedUser string, parsedPort uint16) bool { + if strings.TrimSpace(target.RawSelector) != selector { + return false + } + if parsedUser == "" && parsedPort == 0 { + return false + } + return (parsedUser == "" || target.HasUser) && (parsedPort == 0 || target.HasPort) +} + +type targetDecision struct { + NormalizedTarget ConnectionTarget + Selector string + MatchedNodeID string + BaseNodeID string + OpenSSHAlias string + Canonical canonicalTargetInfo + EffectiveUser string + EffectivePort uint16 + NeedsCreation bool +} + +// ResolveTarget resolves a ConnectionTarget against a read-only ConfigProvider +// using the exact same resolution rules as PrepareNodeContext / EnsureNodeContext, +// without mutating persisted state or creating pending nodes. +func ResolveTarget(provider ConfigProvider, target ConnectionTarget) (ResolvedTarget, error) { + if provider == nil { + return ResolvedTarget{}, fmt.Errorf("configuration provider is nil") + } + r, cfg := resolveTargetHelper(provider) + if r == nil || cfg == nil { + return ResolvedTarget{}, fmt.Errorf("configuration snapshot is nil") + } + lookup, aliases, err := buildIndexes(cfg, false) + if err != nil { + return ResolvedTarget{}, fmt.Errorf("build configuration indexes failed: %w", err) + } + decision, err := r.evaluateTargetDecision(cfg, lookup, aliases, target, "") + if err != nil { + return ResolvedTarget{}, err + } + if !decision.NeedsCreation { + return r.buildMatchedResolvedTarget(provider, decision.MatchedNodeID, decision.BaseNodeID, decision.OpenSSHAlias, decision.NormalizedTarget) + } + return r.buildCreatedResolvedTargetWithUserPort(provider, cfg, decision.Canonical, decision.EffectiveUser, decision.EffectivePort, decision.NormalizedTarget) +} + +// ResolveTarget resolves target against the repository's current view. +func (r *Repository) ResolveTarget(target ConnectionTarget) (ResolvedTarget, error) { + if r == nil { + return ResolvedTarget{}, fmt.Errorf("configuration repository is nil") + } + return ResolveTarget(r, target) +} + +// ResolveTarget resolves target against the provider's configuration snapshot. +func (p *Provider) ResolveTarget(target ConnectionTarget) (ResolvedTarget, error) { + if p == nil { + return ResolvedTarget{}, fmt.Errorf("configuration provider is nil") + } + return ResolveTarget(p, target) +} + +func (r *Repository) evaluateTargetDecision( + cfg *Configuration, + lookup map[string][]string, + aliases map[string]string, + target ConnectionTarget, + explicitDefaultUser string, +) (targetDecision, error) { + normTarget, selector := normalizeTargetSelector(cfg, lookup, aliases, target) + if selector == "" { + return targetDecision{}, fmt.Errorf("target selector is empty") + } + var decision targetDecision + var err error + if !normTarget.HasUser && !normTarget.HasPort { + decision, err = r.evaluateBareTargetDecision(cfg, lookup, aliases, selector, normTarget, explicitDefaultUser) + } else { + decision, err = r.evaluateExplicitTargetDecision(cfg, lookup, aliases, selector, normTarget, explicitDefaultUser) + } + if err != nil { + return targetDecision{}, err + } + if decision.NeedsCreation { + targetNodeID := FormatNodeID(decision.EffectiveUser, decision.Canonical.Address, decision.EffectivePort) + if _, exists := cfg.Nodes.Get(targetNodeID); exists { + if decision.BaseNodeID == "" { + decision.BaseNodeID = targetNodeID + } + decision.MatchedNodeID = targetNodeID + decision.NeedsCreation = false + } + } + return decision, nil +} + +func (r *Repository) evaluateBareTargetDecision( + cfg *Configuration, + lookup map[string][]string, + aliases map[string]string, + selector string, + target ConnectionTarget, + explicitDefaultUser string, +) (targetDecision, error) { + if _, exists := cfg.Nodes.Get(selector); exists { + return targetDecision{ + NormalizedTarget: target, + Selector: selector, + MatchedNodeID: selector, + BaseNodeID: selector, + }, nil + } + if nid, ok := aliases[selector]; ok && nid != "" { + return targetDecision{ + NormalizedTarget: target, + Selector: selector, + MatchedNodeID: nid, + BaseNodeID: nid, + }, nil + } + candidates := lookup[selector] + switch len(candidates) { + case 1: + return targetDecision{ + NormalizedTarget: target, + Selector: selector, + MatchedNodeID: candidates[0], + BaseNodeID: candidates[0], + }, nil + case 0: + if nid, alias, ok := r.matchBareOpenSSHSelector(selector); ok { + return targetDecision{ + NormalizedTarget: target, + Selector: selector, + MatchedNodeID: nid, + BaseNodeID: nid, + OpenSSHAlias: alias, + }, nil + } + canonicalInfo, err := r.resolveCanonicalTargetInfo(cfg, lookup, aliases, selector, target) + if err != nil { + return targetDecision{}, err + } + effectiveUser, err := resolveEffectiveUser(target, explicitDefaultUser, canonicalInfo.DefaultUser) + if err != nil { + return targetDecision{}, err + } + effectivePort := canonicalInfo.Port + if effectivePort == 0 { + effectivePort = 22 + } + return targetDecision{ + NormalizedTarget: target, + Selector: selector, + BaseNodeID: canonicalInfo.BaseNodeID, + OpenSSHAlias: canonicalInfo.OpenSSHAlias, + Canonical: canonicalInfo, + EffectiveUser: effectiveUser, + EffectivePort: effectivePort, + NeedsCreation: true, + }, nil + default: + return targetDecision{}, &AmbiguousNodeError{Selector: selector, Candidates: slices.Clone(candidates)} + } +} + +func (r *Repository) evaluateExplicitTargetDecision( + cfg *Configuration, + lookup map[string][]string, + aliases map[string]string, + selector string, + target ConnectionTarget, + explicitDefaultUser string, +) (targetDecision, error) { + canonicalInfo, err := r.resolveCanonicalTargetInfo(cfg, lookup, aliases, selector, target) + if err != nil { + return targetDecision{}, err + } + if !target.HasUser && canonicalInfo.AmbiguousDefaultUser { + return targetDecision{}, &AmbiguousNodeError{Selector: selector, Candidates: canonicalInfo.AmbiguousCandidates} + } + + effectivePort := canonicalInfo.Port + if target.HasPort { + effectivePort = target.Port + } else if effectivePort == 0 { + effectivePort = 22 + } + + effectiveUser, userErr := resolveEffectiveUser(target, explicitDefaultUser, canonicalInfo.DefaultUser) + if userErr != nil { + return targetDecision{}, userErr + } + + matches := narrowMatchingNodesBySelector( + findMatchingNodes(cfg, effectiveUser, canonicalInfo.Address, effectivePort), + aliases, + selector, + strings.TrimSpace(target.RawSelector), + ) + switch len(matches) { + case 1: + baseID := canonicalInfo.BaseNodeID + if baseID == "" { + baseID = matches[0] + } + return targetDecision{ + NormalizedTarget: target, + Selector: selector, + MatchedNodeID: matches[0], + BaseNodeID: baseID, + OpenSSHAlias: canonicalInfo.OpenSSHAlias, + Canonical: canonicalInfo, + EffectiveUser: effectiveUser, + EffectivePort: effectivePort, + }, nil + case 0: + if nid, alias := r.matchOpenSSHVirtualNode(selector, effectiveUser, effectivePort); nid != "" { + return targetDecision{ + NormalizedTarget: target, + Selector: selector, + MatchedNodeID: nid, + BaseNodeID: nid, + OpenSSHAlias: alias, + Canonical: canonicalInfo, + EffectiveUser: effectiveUser, + EffectivePort: effectivePort, + }, nil + } + return targetDecision{ + NormalizedTarget: target, + Selector: selector, + BaseNodeID: canonicalInfo.BaseNodeID, + OpenSSHAlias: canonicalInfo.OpenSSHAlias, + Canonical: canonicalInfo, + EffectiveUser: effectiveUser, + EffectivePort: effectivePort, + NeedsCreation: true, + }, nil + default: + return targetDecision{}, &AmbiguousNodeError{Selector: selector, Candidates: matches} + } +} + +func (r *Repository) buildMatchedResolvedTarget( + provider ConfigProvider, + matchedID, baseID, openSSHAlias string, + target ConnectionTarget, +) (ResolvedTarget, error) { + if openSSHAlias != "" { + recordResolvedOpenSSHAlias(r, provider, matchedID, openSSHAlias) + } + node, host, ident, err := provider.Resolve(matchedID) + if err != nil { + return ResolvedTarget{}, err + } + cleanAddr := strings.TrimPrefix(strings.TrimSuffix(strings.TrimSpace(host.Address), "]"), "[") + port := host.Port + if port == 0 { + port = 22 + } + user := ident.User + if user == "" { + if defUser, uErr := resolveEffectiveUser(target, "", ""); uErr == nil { + user = defUser + } + } + jump := node.ProxyJump + if target.HasProxyJump { + jump = strings.TrimSpace(target.ProxyJump) + if jump != "" { + if resolvedJump, jErr := provider.ResolveProxyJumpChain(jump); jErr == nil { + jump = resolvedJump + } + } + } + alias := openSSHAlias + if alias == "" { + alias = r.effectiveNodeOpenSSHAlias(matchedID, node) + } + if alias == "" && strings.HasPrefix(matchedID, OpenSSHNodePrefix) { + if r.provider != nil && r.provider.openSSH != nil { + alias = resolveOpenSSHAliasName(r.provider.openSSH, matchedID) + } + } + return ResolvedTarget{ + NodeID: matchedID, + MatchedNodeID: matchedID, + BaseNodeID: baseID, + Host: cleanAddr, + Port: port, + User: user, + ProxyJump: jump, + KeyPath: ident.KeyPath, + OpenSSHAlias: alias, + Created: false, + }, nil +} + +func (r *Repository) buildCreatedResolvedTargetWithUserPort( + provider ConfigProvider, + cfg *Configuration, + canonicalInfo canonicalTargetInfo, + effectiveUser string, + effectivePort uint16, + target ConnectionTarget, +) (ResolvedTarget, error) { + targetNodeID := FormatNodeID(effectiveUser, canonicalInfo.Address, effectivePort) + if _, exists := cfg.Nodes.Get(targetNodeID); exists { + baseID := canonicalInfo.BaseNodeID + if baseID == "" { + baseID = targetNodeID + } + return r.buildMatchedResolvedTarget(provider, targetNodeID, baseID, canonicalInfo.OpenSSHAlias, target) + } + jump, err := r.resolveJumpForNode(cfg, EnsureNodeOptions{Target: target}, canonicalInfo, canonicalInfo.Address, effectivePort) + if err != nil { + if target.HasProxyJump { + jump = strings.TrimSpace(target.ProxyJump) + if strings.EqualFold(jump, "none") { + jump = "" + } + } else { + jump = "" + } + } + openSSHAlias := canonicalInfo.OpenSSHAlias + keyPath := "" + if canonicalInfo.IdentityFile != "" { + keyPath = fileutils.ToAbsolutePath(canonicalInfo.IdentityFile) + } + if pNode, _, pIdent, ok := r.resolvePendingNode(targetNodeID); ok { + if !target.HasProxyJump && jump == "" && pNode.ProxyJump != "" { + jump = pNode.ProxyJump + } + if keyPath == "" && pIdent.KeyPath != "" { + keyPath = pIdent.KeyPath + } + if openSSHAlias == "" && pNode.OpenSSHAlias != "" { + openSSHAlias = pNode.OpenSSHAlias + } + } + if openSSHAlias != "" { + recordResolvedOpenSSHAlias(r, provider, targetNodeID, openSSHAlias) + } + return ResolvedTarget{ + NodeID: targetNodeID, + MatchedNodeID: "", + BaseNodeID: canonicalInfo.BaseNodeID, + Host: canonicalInfo.Address, + Port: effectivePort, + User: effectiveUser, + ProxyJump: jump, + KeyPath: keyPath, + OpenSSHAlias: openSSHAlias, + Created: true, + }, nil +} + // EnsureNodeContext ensures a node matching the target intent exists. If not, it // atomically creates the required Host, Identity, and Node within a single // configuration transaction. @@ -101,16 +583,7 @@ func (r *Repository) EnsureNodeContext(ctx context.Context, opts EnsureNodeOptio var createdNode bool result, commitErr := r.commitResultContext(ctx, anyRevision, func(cfg *Configuration) error { - lookup, aliases, err := buildIndexes(cfg, false) - if err != nil { - return fmt.Errorf("build configuration indexes failed: %w", err) - } - - if !target.HasUser && !target.HasPort { - return r.ensureBareTargetInTransaction(cfg, opts, lookup, aliases, selector, explicitDefaultUser, &finalNodeID, &createdNode) - } - - return r.ensureExplicitTargetInTransaction(cfg, opts, lookup, aliases, selector, explicitDefaultUser, &finalNodeID, &createdNode) + return r.ensureTargetInTransaction(cfg, opts, explicitDefaultUser, &finalNodeID, &createdNode) }) mutation := NodeMutation{ @@ -139,94 +612,138 @@ func (r *Repository) EnsureNodeContext(ctx context.Context, opts EnsureNodeOptio }, commitErr } -func (r *Repository) ensureBareTargetInTransaction( +func (r *Repository) ensureTargetInTransaction( cfg *Configuration, opts EnsureNodeOptions, - lookup map[string][]string, - aliases map[string]string, - selector, explicitDefaultUser string, + explicitDefaultUser string, finalNodeID *string, createdNode *bool, ) error { - if _, exists := cfg.Nodes.Get(selector); exists { - *finalNodeID = selector - *createdNode = false - return nil + lookup, aliases, err := buildIndexes(cfg, false) + if err != nil { + return fmt.Errorf("build configuration indexes failed: %w", err) } - if nid, ok := aliases[selector]; ok && nid != "" { - *finalNodeID = nid - *createdNode = false - return nil + decision, err := r.evaluateTargetDecision(cfg, lookup, aliases, opts.Target, explicitDefaultUser) + if err != nil { + return err } - candidates := lookup[selector] - switch len(candidates) { - case 1: - *finalNodeID = candidates[0] + if !decision.NeedsCreation { + if decision.OpenSSHAlias != "" { + r.SetNodeOpenSSHAlias(decision.MatchedNodeID, decision.OpenSSHAlias) + } + *finalNodeID = decision.MatchedNodeID *createdNode = false return nil - case 0: - if r.provider != nil && r.provider.openSSH != nil && r.provider.openSSH.cfg != nil { - if nid, ok := r.provider.openSSH.Find(selector); ok { - *finalNodeID = nid - *createdNode = false - return nil - } + } + txOpts := opts + txOpts.Target = decision.NormalizedTarget + return r.createNodeInTransaction(cfg, txOpts, decision.Canonical, decision.EffectiveUser, decision.EffectivePort, finalNodeID, createdNode) +} + +func narrowMatchingNodesBySelector(matches []string, aliases map[string]string, selectors ...string) []string { + if len(matches) <= 1 { + return matches + } + for _, selector := range selectors { + if selector == "" { + continue } - canonicalInfo, resolveErr := r.resolveCanonicalTargetInfo(cfg, lookup, aliases, selector, opts.Target) - if resolveErr != nil { - return resolveErr + if slices.Contains(matches, selector) { + return []string{selector} } - effectiveUser, userErr := resolveEffectiveUser(opts.Target, explicitDefaultUser, canonicalInfo.DefaultUser) - if userErr != nil { - return userErr + if nid, ok := aliases[selector]; ok && nid != "" && slices.Contains(matches, nid) { + return []string{nid} } - return r.createNodeInTransaction(cfg, opts, canonicalInfo, effectiveUser, 22, finalNodeID, createdNode) - default: - return &AmbiguousNodeError{Selector: selector, Candidates: slices.Clone(candidates)} } + return matches } -func (r *Repository) ensureExplicitTargetInTransaction( - cfg *Configuration, - opts EnsureNodeOptions, - lookup map[string][]string, - aliases map[string]string, - selector, explicitDefaultUser string, - finalNodeID *string, - createdNode *bool, -) error { - target := opts.Target - canonicalInfo, resolveErr := r.resolveCanonicalTargetInfo(cfg, lookup, aliases, selector, target) - if resolveErr != nil { - return resolveErr - } - if !target.HasUser && canonicalInfo.AmbiguousDefaultUser { - return &AmbiguousNodeError{Selector: selector, Candidates: canonicalInfo.AmbiguousCandidates} +func (r *Repository) openSSHParserOrEmpty() *OpenSSHParser { + if r != nil && r.provider != nil && r.provider.openSSH != nil { + return r.provider.openSSH } + return &OpenSSHParser{cfg: nil} +} - effectivePort := canonicalInfo.Port - if target.HasPort { - effectivePort = target.Port - } else if effectivePort == 0 { - effectivePort = 22 +func resolveOpenSSHAliasName(parser *OpenSSHParser, spec string) string { + if parser == nil || parser.cfg == nil { + return "" } + clean := strings.TrimSpace(strings.TrimPrefix(spec, OpenSSHNodePrefix)) + if clean == "" { + return "" + } + lookupHost, _, _, err := parseOpenSSHHostSpec(clean) + if err != nil || lookupHost == "" { + return "" + } + if parser.HasHost(lookupHost) { + return lookupHost + } + return "" +} - effectiveUser, userErr := resolveEffectiveUser(target, explicitDefaultUser, canonicalInfo.DefaultUser) - if userErr != nil { - return userErr +func (r *Repository) matchBareOpenSSHSelector(selector string) (string, string, bool) { + if strings.HasPrefix(selector, OpenSSHNodePrefix) { + clean := strings.TrimSpace(strings.TrimPrefix(selector, OpenSSHNodePrefix)) + if clean == "" { + return "", "", false + } + parser := r.openSSHParserOrEmpty() + if _, _, _, err := parser.GetVirtualNode(clean); err != nil { + return "", "", false + } + return OpenSSHNodePrefix + clean, resolveOpenSSHAliasName(parser, clean), true + } + if r != nil && r.provider != nil && r.provider.openSSH != nil && r.provider.openSSH.cfg != nil { + if nid, ok := r.provider.openSSH.Find(selector); ok { + return nid, resolveOpenSSHAliasName(r.provider.openSSH, nid), true + } } + return "", "", false +} - matches := findMatchingNodes(cfg, effectiveUser, canonicalInfo.Address, effectivePort) - switch len(matches) { - case 1: - *finalNodeID = matches[0] - *createdNode = false - return nil - case 0: - return r.createNodeInTransaction(cfg, opts, canonicalInfo, effectiveUser, effectivePort, finalNodeID, createdNode) - default: - return &AmbiguousNodeError{Selector: selector, Candidates: matches} +func (r *Repository) matchOpenSSHVirtualNode(selector, effectiveUser string, effectivePort uint16) (string, string) { + if strings.HasPrefix(selector, OpenSSHNodePrefix) { + clean := strings.TrimSpace(strings.TrimPrefix(selector, OpenSSHNodePrefix)) + if clean == "" { + return "", "" + } + parser := r.openSSHParserOrEmpty() + _, vhost, vident, err := parser.GetVirtualNode(clean) + if err != nil { + return "", "" + } + vport := vhost.Port + if vport == 0 { + vport = 22 + } + if vident.User == effectiveUser && vport == effectivePort { + return OpenSSHNodePrefix + clean, resolveOpenSSHAliasName(parser, clean) + } + return "", "" + } + if r == nil || r.provider == nil || r.provider.openSSH == nil || r.provider.openSSH.cfg == nil { + return "", "" + } + if selector == "" || strings.Contains(selector, "@") || strings.Contains(selector, ":") { + return "", "" + } + if !r.provider.openSSH.HasHost(selector) { + return "", "" } + _, vhost, vident, err := r.provider.openSSH.GetVirtualNode(selector) + if err != nil { + return "", "" + } + vport := vhost.Port + if vport == 0 { + vport = 22 + } + if vident.User == effectiveUser && vport == effectivePort { + return OpenSSHNodePrefix + selector, selector + } + return "", "" } func resolveEffectiveUser(target ConnectionTarget, explicitDefaultUser, canonicalDefaultUser string) (string, error) { @@ -248,73 +765,42 @@ func resolveEffectiveUser(target ConnectionTarget, explicitDefaultUser, canonica // findExistingNodeFast checks in the current snapshot if the node already exists. func (r *Repository) findExistingNodeFast(target ConnectionTarget, explicitDefaultUser string) (string, bool, error) { - selector := strings.TrimSpace(target.Selector) snapshot := r.provider.Snapshot() lookup, aliases, err := buildIndexes(snapshot, false) if err != nil { return "", false, err } - - if !target.HasUser && !target.HasPort { - return r.findBareNodeFast(snapshot, lookup, aliases, selector) - } - - canonicalInfo, err := r.resolveCanonicalTargetInfo(snapshot, lookup, aliases, selector, target) + decision, err := r.evaluateTargetDecision(snapshot, lookup, aliases, target, explicitDefaultUser) if err != nil { return "", true, err } - if !target.HasUser && canonicalInfo.AmbiguousDefaultUser { - return "", true, &AmbiguousNodeError{Selector: selector, Candidates: canonicalInfo.AmbiguousCandidates} - } - - effectivePort := canonicalInfo.Port - if target.HasPort { - effectivePort = target.Port - } else if effectivePort == 0 { - effectivePort = 22 - } - - effectiveUser, userErr := resolveEffectiveUser(target, explicitDefaultUser, canonicalInfo.DefaultUser) - if userErr != nil { - return "", true, userErr + if !decision.NeedsCreation { + if decision.OpenSSHAlias != "" { + r.SetNodeOpenSSHAlias(decision.MatchedNodeID, decision.OpenSSHAlias) + } + return decision.MatchedNodeID, false, nil } + return "", false, nil +} - matches := findMatchingNodes(snapshot, effectiveUser, canonicalInfo.Address, effectivePort) - switch len(matches) { - case 1: - return matches[0], false, nil - case 0: - return "", false, nil - default: - return "", true, &AmbiguousNodeError{Selector: selector, Candidates: matches} +func normalizedHostPort(port uint16) uint16 { + if port == 0 { + return 22 } + return port } -func (r *Repository) findBareNodeFast(snapshot *Configuration, lookup map[string][]string, aliases map[string]string, selector string) (string, bool, error) { - if _, exists := snapshot.Nodes.Get(selector); exists { - return selector, false, nil - } - if nid, ok := aliases[selector]; ok && nid != "" { - return nid, false, nil - } - candidates := lookup[selector] - switch len(candidates) { - case 1: - return candidates[0], false, nil - case 0: - if r.provider != nil && r.provider.openSSH != nil && r.provider.openSSH.cfg != nil { - if nid, ok := r.provider.openSSH.Find(selector); ok { - return nid, false, nil - } - } - return "", false, nil - default: - return "", true, &AmbiguousNodeError{Selector: selector, Candidates: slices.Clone(candidates)} +func normalizedIdentityUser(rawUser string) string { + if u := strings.TrimSpace(rawUser); u != "" { + return u } + fallbackUser, _ := resolveEffectiveUser(ConnectionTarget{}, "", "") + return fallbackUser } func findMatchingNodes(snapshot *Configuration, user, addr string, port uint16) []string { var matches []string + targetPort := normalizedHostPort(port) for _, nid := range snapshot.Nodes.Keys() { n, ok := snapshot.Nodes.Get(nid) if !ok { @@ -324,7 +810,8 @@ func findMatchingNodes(snapshot *Configuration, user, addr string, port uint16) ident, iok := snapshot.Identities.Get(n.IdentityRef) if hok && iok { cleanHostAddr := strings.TrimPrefix(strings.TrimSuffix(strings.TrimSpace(h.Address), "]"), "[") - if cleanHostAddr == addr && h.Port == port && ident.User == user { + nodeUser := normalizedIdentityUser(ident.User) + if cleanHostAddr == addr && normalizedHostPort(h.Port) == targetPort && nodeUser == user { matches = append(matches, nid) } } @@ -334,61 +821,93 @@ func findMatchingNodes(snapshot *Configuration, user, addr string, port uint16) } // resolveCanonicalTargetInfo extracts canonical address, port, identity and proxy details. +// +//nolint:gocyclo // canonical target resolution sequentially evaluates direct, alias, lookup, OpenSSH, and raw fallback tiers func (r *Repository) resolveCanonicalTargetInfo(cfg *Configuration, lookup map[string][]string, aliases map[string]string, selector string, target ConnectionTarget) (canonicalTargetInfo, error) { - // 1. Direct Node ID match - if node, exists := cfg.Nodes.Get(selector); exists { - if host, hostExists := cfg.Hosts.Get(node.HostRef); hostExists { - clean := strings.TrimPrefix(strings.TrimSuffix(strings.TrimSpace(host.Address), "]"), "[") - var defUser string - if ident, identExists := cfg.Identities.Get(node.IdentityRef); identExists { - defUser = ident.User - } - return canonicalTargetInfo{Address: clean, Port: host.Port, DefaultUser: defUser}, nil - } - } - - // 2. Alias match - if nid, exists := aliases[selector]; exists && nid != "" { - if node, ok := cfg.Nodes.Get(nid); ok { - if host, hostExists := cfg.Hosts.Get(node.HostRef); hostExists { - clean := strings.TrimPrefix(strings.TrimSuffix(strings.TrimSpace(host.Address), "]"), "[") - var defUser string - if ident, identExists := cfg.Identities.Get(node.IdentityRef); identExists { - defUser = ident.User - } - return canonicalTargetInfo{Address: clean, Port: host.Port, DefaultUser: defUser}, nil - } - } + // 1 & 2. Direct Node ID or Alias match (checking RawSelector first when split from user@host:port) + if info, ok := r.resolveDirectOrAliasTargetInfo(cfg, aliases, strings.TrimSpace(target.RawSelector), selector); ok { + return info, nil } // 3. Lookup match if candidates := lookup[selector]; len(candidates) > 0 { - return resolveLookupCandidates(cfg, selector, candidates, target) + return r.resolveLookupCandidates(cfg, selector, candidates, target) + } + if raw := strings.TrimSpace(target.RawSelector); raw != "" && raw != selector { + if candidates := lookup[raw]; len(candidates) > 0 { + return r.resolveLookupCandidates(cfg, raw, candidates, target) + } + } + if strings.TrimSpace(target.RawSelector) == "" && target.HasUser && target.HasPort && strings.TrimSpace(target.User) != "" && target.Port > 0 { + composite := FormatNodeID(strings.TrimSpace(target.User), selector, target.Port) + if info, ok := r.resolveDirectOrAliasTargetInfo(cfg, aliases, composite); ok { + return info, nil + } } // 4. OpenSSH configuration match if r != nil && r.provider != nil && r.provider.openSSH != nil && r.provider.openSSH.cfg != nil { - clean := strings.TrimPrefix(selector, OpenSSHNodePrefix) - if r.provider.openSSH.HasHost(clean) { + clean := strings.TrimSpace(strings.TrimPrefix(selector, OpenSSHNodePrefix)) + allowSpec := strings.HasPrefix(selector, OpenSSHNodePrefix) || (!strings.Contains(clean, "@") && !strings.Contains(clean, ":")) + if clean != "" && allowSpec && r.provider.openSSH.HasHost(clean) { vnode, vhost, vident, err := r.provider.openSSH.GetVirtualNode(clean) if err != nil { return canonicalTargetInfo{}, fmt.Errorf("resolve openssh host %q: %w", clean, err) } return canonicalTargetInfo{ + BaseNodeID: OpenSSHNodePrefix + clean, Address: strings.TrimPrefix(strings.TrimSuffix(strings.TrimSpace(vhost.Address), "]"), "["), Port: vhost.Port, IdentityFile: vident.KeyPath, ProxyJump: vnode.ProxyJump, DefaultUser: vident.User, + OpenSSHAlias: resolveOpenSSHAliasName(r.provider.openSSH, clean), }, nil } } - // 5. Raw address/hostname fallback - clean := strings.TrimPrefix(strings.TrimSuffix(strings.TrimSpace(selector), "]"), "[") + // 5. Raw address/hostname fallback (including openssh: virtual selectors or + // unconfirmed user@host:port node IDs passed across process boundaries). + clean := strings.TrimPrefix(strings.TrimSpace(selector), OpenSSHNodePrefix) + if parsedHost, parsedUser, parsedPort, err := parseOpenSSHHostSpec(clean); err == nil && parsedHost != "" { + port := parsedPort + if port == 0 { + port = 22 + } + return canonicalTargetInfo{ + Address: strings.TrimPrefix(strings.TrimSuffix(strings.TrimSpace(parsedHost), "]"), "["), + Port: port, + DefaultUser: parsedUser, + }, nil + } + clean = strings.TrimPrefix(strings.TrimSuffix(clean, "]"), "[") return canonicalTargetInfo{Address: clean, Port: 22}, nil } +func (r *Repository) resolveDirectOrAliasTargetInfo(cfg *Configuration, aliases map[string]string, keys ...string) (canonicalTargetInfo, bool) { + for _, key := range keys { + if key == "" { + continue + } + if info, ok := r.resolveCandidateNodeInfo(cfg, key); ok { + return info, true + } + if nid, exists := aliases[key]; exists && nid != "" { + if info, ok := r.resolveCandidateNodeInfo(cfg, nid); ok { + return info, true + } + } + } + return canonicalTargetInfo{}, false +} + +func (r *Repository) effectiveNodeOpenSSHAlias(nodeID string, node models.Node) string { + if node.OpenSSHAlias != "" { + return node.OpenSSHAlias + } + return r.NodeOpenSSHAlias(nodeID) +} + func filterCandidatesByExplicitTarget(cfg *Configuration, candidates []string, target ConnectionTarget) []string { if !target.HasPort && !target.HasUser { return slices.Clone(candidates) @@ -405,9 +924,9 @@ func filterCandidatesByExplicitTarget(cfg *Configuration, candidates []string, t } var user string if ident, identExists := cfg.Identities.Get(node.IdentityRef); identExists { - user = ident.User + user = normalizedIdentityUser(ident.User) } - if target.HasPort && host.Port != target.Port { + if target.HasPort && normalizedHostPort(host.Port) != normalizedHostPort(target.Port) { continue } if target.HasUser && user != target.User { @@ -418,7 +937,7 @@ func filterCandidatesByExplicitTarget(cfg *Configuration, candidates []string, t return filtered } -func resolveCandidateNodeInfo(cfg *Configuration, candidateID string) (canonicalTargetInfo, bool) { +func (r *Repository) resolveCandidateNodeInfo(cfg *Configuration, candidateID string) (canonicalTargetInfo, bool) { node, ok := cfg.Nodes.Get(candidateID) if !ok { return canonicalTargetInfo{}, false @@ -429,13 +948,42 @@ func resolveCandidateNodeInfo(cfg *Configuration, candidateID string) (canonical } clean := strings.TrimPrefix(strings.TrimSuffix(strings.TrimSpace(host.Address), "]"), "[") var defUser string + var keyPath string if ident, identExists := cfg.Identities.Get(node.IdentityRef); identExists { - defUser = ident.User + defUser = normalizedIdentityUser(ident.User) + keyPath = ident.KeyPath + } + return canonicalTargetInfo{ + BaseNodeID: candidateID, + Address: clean, + Port: normalizedHostPort(host.Port), + IdentityFile: keyPath, + DefaultUser: defUser, + OpenSSHAlias: r.effectiveNodeOpenSSHAlias(candidateID, node), + }, true +} + +func (r *Repository) consistentCandidateOpenSSHAlias(cfg *Configuration, candidates []string) string { + var alias string + for _, cid := range candidates { + node, ok := cfg.Nodes.Get(cid) + if !ok { + continue + } + nodeAlias := r.effectiveNodeOpenSSHAlias(cid, node) + if nodeAlias == "" { + continue + } + if alias == "" { + alias = nodeAlias + } else if alias != nodeAlias { + return "" + } } - return canonicalTargetInfo{Address: clean, Port: host.Port, DefaultUser: defUser}, true + return alias } -func resolveConsistentCandidates(cfg *Configuration, selector string, candidates []string) (canonicalTargetInfo, error) { +func (r *Repository) resolveConsistentCandidates(cfg *Configuration, selector string, candidates []string) (canonicalTargetInfo, error) { var firstAddr string var firstPort uint16 var firstUser string @@ -454,16 +1002,17 @@ func resolveConsistentCandidates(cfg *Configuration, selector string, candidates } var user string if ident, identExists := cfg.Identities.Get(node.IdentityRef); identExists { - user = ident.User + user = normalizedIdentityUser(ident.User) } clean := strings.TrimPrefix(strings.TrimSuffix(strings.TrimSpace(host.Address), "]"), "[") + port := normalizedHostPort(host.Port) if !initialized { firstAddr = clean - firstPort = host.Port + firstPort = port firstUser = user initialized = true } else { - if firstAddr != clean || firstPort != host.Port { + if firstAddr != clean || firstPort != port { addrPortConsistent = false break } @@ -481,6 +1030,7 @@ func resolveConsistentCandidates(cfg *Configuration, selector string, candidates Address: firstAddr, Port: firstPort, DefaultUser: defUser, + OpenSSHAlias: r.consistentCandidateOpenSSHAlias(cfg, candidates), AmbiguousDefaultUser: !userConsistent, AmbiguousCandidates: slices.Clone(candidates), }, nil @@ -488,7 +1038,7 @@ func resolveConsistentCandidates(cfg *Configuration, selector string, candidates return canonicalTargetInfo{}, &AmbiguousNodeError{Selector: selector, Candidates: slices.Clone(candidates)} } -func resolveCandidateAddressFallback(cfg *Configuration, selector string, candidates []string, target ConnectionTarget) (canonicalTargetInfo, error) { +func (r *Repository) resolveCandidateAddressFallback(cfg *Configuration, selector string, candidates []string, target ConnectionTarget) (canonicalTargetInfo, error) { var firstAddr string var firstPort uint16 var firstUser string @@ -508,12 +1058,13 @@ func resolveCandidateAddressFallback(cfg *Configuration, selector string, candid } var user string if ident, identExists := cfg.Identities.Get(node.IdentityRef); identExists { - user = ident.User + user = normalizedIdentityUser(ident.User) } clean := strings.TrimPrefix(strings.TrimSuffix(strings.TrimSpace(host.Address), "]"), "[") + port := normalizedHostPort(host.Port) if !initialized { firstAddr = clean - firstPort = host.Port + firstPort = port firstUser = user initialized = true } else { @@ -521,7 +1072,7 @@ func resolveCandidateAddressFallback(cfg *Configuration, selector string, candid addrConsistent = false break } - if firstPort != host.Port { + if firstPort != port { portConsistent = false } if firstUser != user { @@ -556,22 +1107,23 @@ func resolveCandidateAddressFallback(cfg *Configuration, selector string, candid Address: firstAddr, Port: effectivePort, DefaultUser: defUser, + OpenSSHAlias: r.consistentCandidateOpenSSHAlias(cfg, candidates), AmbiguousDefaultUser: !target.HasUser && !userConsistent, AmbiguousCandidates: slices.Clone(candidates), }, nil } -func resolveLookupCandidates(cfg *Configuration, selector string, candidates []string, target ConnectionTarget) (canonicalTargetInfo, error) { +func (r *Repository) resolveLookupCandidates(cfg *Configuration, selector string, candidates []string, target ConnectionTarget) (canonicalTargetInfo, error) { filtered := filterCandidatesByExplicitTarget(cfg, candidates, target) if len(filtered) == 1 { - if info, ok := resolveCandidateNodeInfo(cfg, filtered[0]); ok { + if info, ok := r.resolveCandidateNodeInfo(cfg, filtered[0]); ok { return info, nil } } if len(filtered) > 1 { - return resolveConsistentCandidates(cfg, selector, filtered) + return r.resolveConsistentCandidates(cfg, selector, filtered) } - return resolveCandidateAddressFallback(cfg, selector, candidates, target) + return r.resolveCandidateAddressFallback(cfg, selector, candidates, target) } // createNodeInTransaction performs the atomic creation of Host, Identity, and Node. @@ -587,6 +1139,9 @@ func (r *Repository) createNodeInTransaction( canonicalAddr := canonicalInfo.Address targetNodeID := FormatNodeID(effectiveUser, canonicalAddr, effectivePort) if _, exists := cfg.Nodes.Get(targetNodeID); exists { + if canonicalInfo.OpenSSHAlias != "" { + r.SetNodeOpenSSHAlias(targetNodeID, canonicalInfo.OpenSSHAlias) + } *finalNodeID = targetNodeID *createdNode = false return nil @@ -613,11 +1168,12 @@ func (r *Repository) createNodeInTransaction( sudoMode = opts.SudoMode } newNode := models.Node{ - HostRef: hostRef, - IdentityRef: identityRef, - ProxyJump: finalJump, - SudoMode: sudoMode, - SuPwd: opts.SuPwd, + HostRef: hostRef, + IdentityRef: identityRef, + ProxyJump: finalJump, + SudoMode: sudoMode, + SuPwd: opts.SuPwd, + OpenSSHAlias: canonicalInfo.OpenSSHAlias, } if opts.Alias != "" { trimmedAlias := strings.TrimSpace(opts.Alias) @@ -631,6 +1187,9 @@ func (r *Repository) createNodeInTransaction( } cfg.Nodes.Set(targetNodeID, newNode) + if canonicalInfo.OpenSSHAlias != "" { + r.SetNodeOpenSSHAlias(targetNodeID, canonicalInfo.OpenSSHAlias) + } *finalNodeID = targetNodeID *createdNode = true return nil @@ -638,37 +1197,56 @@ func (r *Repository) createNodeInTransaction( func ensureMatchingHost(cfg *Configuration, canonicalAddr string, port uint16, targetNodeID string) (string, error) { var matchingHostRefs []string + var unaliasedHostRef string + targetPort := normalizedHostPort(port) for _, href := range cfg.Hosts.Keys() { h, ok := cfg.Hosts.Get(href) if !ok { continue } clean := strings.TrimPrefix(strings.TrimSuffix(strings.TrimSpace(h.Address), "]"), "[") - if clean == canonicalAddr && h.Port == port { + if clean == canonicalAddr && normalizedHostPort(h.Port) == targetPort { matchingHostRefs = append(matchingHostRefs, href) + if len(h.Alias) == 0 && unaliasedHostRef == "" { + unaliasedHostRef = href + } } } - switch len(matchingHostRefs) { - case 0: - candidateRef := FormatHostPort(canonicalAddr, port) + createHost := func() string { + candidateRef := FormatHostPort(canonicalAddr, targetPort) hostRef := candidateRef if existing, exists := cfg.Hosts.Get(candidateRef); exists { clean := strings.TrimPrefix(strings.TrimSuffix(strings.TrimSpace(existing.Address), "]"), "[") - if clean != canonicalAddr || existing.Port != port { + if clean != canonicalAddr || normalizedHostPort(existing.Port) != targetPort || len(existing.Alias) > 0 { hostRef = privateHostReference(cfg, targetNodeID) } } cfg.Hosts.Set(hostRef, models.Host{ Address: canonicalAddr, - Port: port, + Port: targetPort, }) - return hostRef, nil + return hostRef + } + + switch len(matchingHostRefs) { + case 0: + return createHost(), nil case 1: + if existing, ok := cfg.Hosts.Get(matchingHostRefs[0]); ok && len(existing.Alias) > 0 { + if countNodeReferences(cfg, func(candidate models.Node) bool { + return candidate.HostRef == matchingHostRefs[0] && candidate.HostRef != "" + }) > 0 { + return createHost(), nil + } + } return matchingHostRefs[0], nil default: + if unaliasedHostRef != "" { + return unaliasedHostRef, nil + } return "", fmt.Errorf("ambiguous host configuration: multiple host references found for %s: %v", - FormatHostPort(canonicalAddr, port), matchingHostRefs) + FormatHostPort(canonicalAddr, targetPort), matchingHostRefs) } } @@ -687,6 +1265,7 @@ func (r *Repository) resolveJumpForNode( return "", nil } + targetPort := normalizedHostPort(port) jumpSet := make(map[string]struct{}) for _, nid := range cfg.Nodes.Keys() { n, ok := cfg.Nodes.Get(nid) @@ -698,7 +1277,7 @@ func (r *Repository) resolveJumpForNode( continue } clean := strings.TrimPrefix(strings.TrimSuffix(strings.TrimSpace(h.Address), "]"), "[") - if clean == canonicalAddr && h.Port == port { + if clean == canonicalAddr && normalizedHostPort(h.Port) == targetPort { trimmedJump := strings.TrimSpace(n.ProxyJump) jumpSet[trimmedJump] = struct{}{} } @@ -717,7 +1296,7 @@ func (r *Repository) resolveJumpForNode( return "", nil default: return "", fmt.Errorf("ambiguous proxy jump: multiple nodes for host %s have conflicting proxy jumps", - FormatHostPort(canonicalAddr, port)) + FormatHostPort(canonicalAddr, targetPort)) } } @@ -777,14 +1356,14 @@ func (r *Repository) resolveProxyJumpChain(cfg *Configuration, jumpChain string) // ResolveProxyJumpChainWithConfig validates and normalizes a comma-separated jump host chain. func ResolveProxyJumpChainWithConfig(cfg *Configuration, openSSH *OpenSSHParser, jumpChain string) (string, error) { trimmed := strings.TrimSpace(jumpChain) - if trimmed == "" { + if trimmed == "" || strings.EqualFold(trimmed, "none") { return "", nil } rawHops := strings.Split(trimmed, ",") resolvedHops := make([]string, 0, len(rawHops)) for _, rawHop := range rawHops { hop := strings.TrimSpace(rawHop) - if hop == "" { + if hop == "" || strings.EqualFold(hop, "none") { continue } resolved, err := resolveSingleJumpHopWithConfig(cfg, openSSH, hop) @@ -837,10 +1416,10 @@ func filterHostCandidates(cfg *Configuration, candidates []string, hopUser strin if !hok || !iok { continue } - if hopPort != 0 && h.Port != hopPort { + if hopPort != 0 && normalizedHostPort(h.Port) != normalizedHostPort(hopPort) { continue } - if hopUser != "" && ident.User != hopUser { + if hopUser != "" && normalizedIdentityUser(ident.User) != hopUser { continue } matched = append(matched, cid) @@ -880,9 +1459,10 @@ func resolveHopPortFromCandidates(cfg *Configuration, hostCandidates []string) ( if !hok { continue } + port := normalizedHostPort(h.Port) if firstPort == 0 { - firstPort = h.Port - } else if firstPort != h.Port { + firstPort = port + } else if firstPort != port { return 0, false } } @@ -919,8 +1499,8 @@ func resolveHopAddressFallback(cfg *Configuration, hop, hopUser string, hopPort ident, iok := cfg.Identities.Get(n.IdentityRef) if hok && iok { cleanHostAddr := strings.TrimPrefix(strings.TrimSuffix(strings.TrimSpace(h.Address), "]"), "[") - if cleanHostAddr == firstAddr && h.Port == effectivePort { - if hopUser == "" || ident.User == hopUser { + if cleanHostAddr == firstAddr && normalizedHostPort(h.Port) == normalizedHostPort(effectivePort) { + if hopUser == "" || normalizedIdentityUser(ident.User) == hopUser { matchingNodes = append(matchingNodes, nid) } } diff --git a/pkg/config/target_test.go b/pkg/config/target_test.go index ca03ffce..1520053f 100644 --- a/pkg/config/target_test.go +++ b/pkg/config/target_test.go @@ -3,6 +3,7 @@ package config import ( "context" "errors" + "fmt" "strings" "sync" "testing" @@ -1945,3 +1946,169 @@ func TestEnsureNodeContext_OpenSSHInheritedJumpNotOverriddenByLocalAlias(t *test t.Errorf("expected inherited ProxyJump %q, got %q (must not be local node root@10.0.0.99:22)", expectedJump, node.ProxyJump) } } + +func TestOpenSSHPrefixedCompositeSelector_PreservesOpenSSHConfigAndAlias(t *testing.T) { + ctx := context.Background() + sshConfigContent := ` +Host jump-box + HostName 10.20.30.40 + User jumpdefault + Port 2200 + IdentityFile ~/.ssh/id_jump_box + ProxyJump gw-box + +Host gw-box + HostName 10.20.30.1 + User gwuser + Port 22 +` + parser, err := NewOpenSSHParserFromReader(strings.NewReader(sshConfigContent)) + if err != nil { + t.Fatalf("failed to create openssh parser: %v", err) + } + cfg := cloneConfiguration(nil) + store := &repositoryTestStore{result: PersistResult{Applied: true, Durable: true}} + repo, err := NewRepositoryWithOpenSSHParser(cfg, store, parser) + if err != nil { + t.Fatalf("failed to create repo: %v", err) + } + + // 1. Bare openssh:user@alias:port selector matches virtual node and records canonical alias "jump-box". + bareTarget := ConnectionTarget{ + Selector: "openssh:jumpuser@jump-box:2201", + } + bareResolved, err := ResolveTarget(repo, bareTarget) + if err != nil { + t.Fatalf("ResolveTarget(bare openssh composite) failed: %v", err) + } + if bareResolved.OpenSSHAlias != "jump-box" { + t.Fatalf("expected OpenSSHAlias='jump-box', got %q", bareResolved.OpenSSHAlias) + } + if bareResolved.Host != "10.20.30.40" || bareResolved.User != "jumpuser" || bareResolved.Port != 2201 { + t.Fatalf("unexpected resolved target for bare openssh composite: %+v", bareResolved) + } + + // 2. Explicit port override on openssh:jumpuser@jump-box must still resolve via OpenSSHParser Step 4. + overrideTarget := ConnectionTarget{ + Selector: "openssh:jumpuser@jump-box", + Port: 2222, + HasPort: true, + } + overrideResolved, err := ResolveTarget(repo, overrideTarget) + if err != nil { + t.Fatalf("ResolveTarget(openssh composite + port override) failed: %v", err) + } + if overrideResolved.Host != "10.20.30.40" || overrideResolved.Port != 2222 || overrideResolved.OpenSSHAlias != "jump-box" { + t.Fatalf("expected Host=10.20.30.40 Port=2222 OpenSSHAlias=jump-box, got %+v", overrideResolved) + } + if overrideResolved.ProxyJump != "openssh:gw-box" { + t.Fatalf("expected inherited ProxyJump 'openssh:gw-box', got %q", overrideResolved.ProxyJump) + } + + ensured, err := repo.EnsureNodeContext(ctx, EnsureNodeOptions{Target: overrideTarget}) + if err != nil { + t.Fatalf("EnsureNodeContext(openssh composite + port override) failed: %v", err) + } + ensuredNode, ok := repo.Snapshot().Nodes.Get(ensured.NodeID) + if !ok { + t.Fatalf("expected node %q to exist in snapshot", ensured.NodeID) + } + if gotAlias := repo.effectiveNodeOpenSSHAlias(ensured.NodeID, ensuredNode); gotAlias != "jump-box" { + t.Fatalf("expected effectiveNodeOpenSSHAlias(%q) = 'jump-box', got %q", ensured.NodeID, gotAlias) + } +} + +func TestTargetResolution_ZeroPortAndEmptyUserInvariants(t *testing.T) { + cfg := cloneConfiguration(nil) + store := &repositoryTestStore{result: PersistResult{Applied: true, Durable: true}} + + // 1. Verify Host.Port == 0 and empty Identity.User match explicit Port=22 and default OS user. + defaultUser, err := resolveEffectiveUser(ConnectionTarget{}, "", "") + if err != nil { + t.Fatalf("resolveEffectiveUser failed: %v", err) + } + cfg.Hosts.Set("10.30.40.50:0", models.Host{Address: "10.30.40.50", Port: 0}) + cfg.Identities.Set("empty-user-id", models.Identity{User: "", AuthType: "auto"}) + cfg.Nodes.Set("zero-port-node", models.Node{ + HostRef: "10.30.40.50:0", + IdentityRef: "empty-user-id", + Alias: []string{"zero-user-alias"}, + }) + repoZero, err := NewRepositoryWithoutOpenSSH(cfg, store) + if err != nil { + t.Fatalf("NewRepositoryWithoutOpenSSH failed: %v", err) + } + if got, err := repoZero.ResolveSelector(defaultUser + "@zero-user-alias"); err != nil || got != "zero-port-node" { + t.Fatalf("expected ResolveSelector(%q) = 'zero-port-node', got %q (err=%v)", defaultUser+"@zero-user-alias", got, err) + } + if got, err := repoZero.ResolveSelector(defaultUser + "@zero-user-alias:22"); err != nil || got != "zero-port-node" { + t.Fatalf("expected ResolveSelector(%q) = 'zero-port-node', got %q (err=%v)", defaultUser+"@zero-user-alias:22", got, err) + } + resolvedZero, err := ResolveTarget(repoZero, ConnectionTarget{ + Selector: "zero-port-node", + User: defaultUser, + HasUser: true, + Port: 22, + HasPort: true, + }) + if err != nil { + t.Fatalf("ResolveTarget(zero-port-node with explicit -p 22 and default user) failed: %v", err) + } + if resolvedZero.MatchedNodeID != "zero-port-node" { + t.Fatalf("expected MatchedNodeID='zero-port-node', got %q", resolvedZero.MatchedNodeID) + } + + // 2. Multi-candidate filtering, index lookup, and jump resolution with Port==0 / User=="" + cfg.Hosts.Set("10.30.40.50:2222", models.Host{Address: "10.30.40.50", Port: 2222}) + cfg.Identities.Set("other-user-id", models.Identity{User: "otheruser", AuthType: "auto"}) + cfg.Nodes.Set("other-port-node", models.Node{ + HostRef: "10.30.40.50:2222", + IdentityRef: "other-user-id", + }) + repoMulti, err := NewRepositoryWithoutOpenSSH(cfg, store) + if err != nil { + t.Fatalf("NewRepositoryWithoutOpenSSH failed: %v", err) + } + // Multi-candidate lookup by address "10.30.40.50" with explicit defaultUser and Port 22 must filter to zero-port-node + resolvedMulti, err := ResolveTarget(repoMulti, ConnectionTarget{ + Selector: "10.30.40.50", + User: defaultUser, + HasUser: true, + Port: 22, + HasPort: true, + }) + if err != nil { + t.Fatalf("ResolveTarget(multi-candidate 10.30.40.50 with defaultUser and Port 22) failed: %v", err) + } + if resolvedMulti.MatchedNodeID != "zero-port-node" { + t.Fatalf("expected MatchedNodeID='zero-port-node' from multi-candidate filter, got %q", resolvedMulti.MatchedNodeID) + } + + // Jump hop resolution against zero-port-node with explicit defaultUser@10.30.40.50:22 + jumpHopSpec := fmt.Sprintf("%s@10.30.40.50:22", defaultUser) + resolvedJump, err := repoMulti.ResolveProxyJumpChain(jumpHopSpec) + if err != nil { + t.Fatalf("ResolveProxyJumpChain(%q) failed: %v", jumpHopSpec, err) + } + if resolvedJump != "zero-port-node" { + t.Fatalf("expected jump chain to resolve to 'zero-port-node', got %q", resolvedJump) + } + + // Index lookup for Port==0 host with non-empty user must index :22 + cfg.Identities.Set("indexed-user-id", models.Identity{User: "idxuser", AuthType: "auto"}) + cfg.Nodes.Set("indexed-zero-port", models.Node{ + HostRef: "10.30.40.50:0", + IdentityRef: "indexed-user-id", + Alias: []string{"zero-alias"}, + }) + repoIdx, err := NewRepositoryWithoutOpenSSH(cfg, store) + if err != nil { + t.Fatalf("NewRepositoryWithoutOpenSSH failed: %v", err) + } + if got, err := repoIdx.ResolveSelector("idxuser@10.30.40.50:22"); err != nil || got != "indexed-zero-port" { + t.Fatalf("expected ResolveSelector('idxuser@10.30.40.50:22') = 'indexed-zero-port', got %q (err=%v)", got, err) + } + if got, err := repoIdx.ResolveSelector("idxuser@zero-alias:22"); err != nil || got != "indexed-zero-port" { + t.Fatalf("expected ResolveSelector('idxuser@zero-alias:22') = 'indexed-zero-port', got %q (err=%v)", got, err) + } +} diff --git a/pkg/config/types.go b/pkg/config/types.go index 10ff3a38..6d14da2d 100644 --- a/pkg/config/types.go +++ b/pkg/config/types.go @@ -18,6 +18,7 @@ type Configuration struct { MCP *MCPConfig `yaml:"mcp,omitempty"` PasswordPromptPattern string `yaml:"password_prompt_pattern,omitempty"` // 全局级自定义密码提示正则 Execution *ssh.ExecutionConfig `yaml:"execution,omitempty"` + Control *ssh.ControlConfig `yaml:"control,omitempty"` } // CanRememberCredentials checks configured write capability without accessing a @@ -50,6 +51,7 @@ type ConfigProvider interface { FindAlias(alias string) string ResolveProxyJumpChain(jumpChain string) (string, error) Snapshot() *Configuration + GetOpenSSHControl(alias string) (*ssh.ControlConfig, error) } // ConnectionSnapshot is one internally consistent connection configuration. diff --git a/pkg/config/validate.go b/pkg/config/validate.go new file mode 100644 index 00000000..05f7ec5d --- /dev/null +++ b/pkg/config/validate.go @@ -0,0 +1,142 @@ +package config + +import ( + "fmt" + "strings" + + "github.com/wentf9/xops-cli/core/ssh" +) + +// DefaultControlPath specifies the standard OpenSSH multiplexing socket pattern. +const DefaultControlPath = "~/.ssh/cm-%r@%h:%p" + +// ValidateControlConfig validates standalone ControlConfig field syntax and cross-field consistency. +func ValidateControlConfig(ctrl *ssh.ControlConfig) error { + if ctrl == nil { + return nil + } + + // 1. Validate Master mode + normMaster := strings.ToLower(strings.TrimSpace(string(ctrl.Master))) + switch normMaster { + case "", string(ssh.ControlMasterNo), string(ssh.ControlMasterYes), + string(ssh.ControlMasterAsk), string(ssh.ControlMasterAuto), string(ssh.ControlMasterAutoAsk): + // valid + default: + return fmt.Errorf("%w: unsupported ControlMaster option %q", ErrSchemaValidation, ctrl.Master) + } + + // 2. Validate Persist duration format + if ctrl.Persist != "" { + if _, _, _, err := ssh.ParseControlPersist(ctrl.Persist); err != nil { + return fmt.Errorf("%w: invalid ControlPersist %q: %w", ErrSchemaValidation, ctrl.Persist, err) + } + } + + // 3. Validate Path syntax & tokens + if ctrl.Path != "" && ctrl.Path != "none" { + if err := ValidateControlPathSyntax(ctrl.Path); err != nil { + return fmt.Errorf("%w: invalid ControlPath %q: %w", ErrSchemaValidation, ctrl.Path, err) + } + } + + // 4. Cross-field rules + // Persist specified while Master is explicitly 'no' + if normMaster == string(ssh.ControlMasterNo) && ctrl.Persist != "" && + !strings.EqualFold(ctrl.Persist, "no") && !strings.EqualFold(ctrl.Persist, "false") { + return fmt.Errorf("%w: ControlPersist %q cannot be specified when ControlMaster is 'no'", ErrSchemaValidation, ctrl.Persist) + } + + // Path is 'none' while Master is explicitly active + if ctrl.Path == "none" && isActiveMasterMode(normMaster) { + return fmt.Errorf("%w: ControlPath cannot be 'none' when ControlMaster is %q", ErrSchemaValidation, ctrl.Master) + } + + return nil +} + +// ValidateControlPathSyntax verifies raw ControlPath string token syntax without environment dependencies. +func ValidateControlPathSyntax(rawPath string) error { + if rawPath == "" || rawPath == "none" { + return nil + } + + // Check environment variable format ${VAR} + if strings.Contains(rawPath, "${") { + idx := 0 + for { + start := strings.Index(rawPath[idx:], "${") + if start == -1 { + break + } + start += idx + end := strings.Index(rawPath[start:], "}") + if end == -1 { + return fmt.Errorf("unclosed environment variable in ControlPath: %q", rawPath[start:]) + } + end += start + varName := rawPath[start+2 : end] + if varName == "" || !isValidEnvVarIdentifier(varName) { + return fmt.Errorf("invalid environment variable name ${%s} in ControlPath", varName) + } + idx = end + 1 + } + } + + // Check % tokens + for i := 0; i < len(rawPath); i++ { + if rawPath[i] == '%' { + if i+1 >= len(rawPath) { + return fmt.Errorf("trailing '%%' in ControlPath") + } + i++ + switch rawPath[i] { + case '%', 'h', 'p', 'r', 'n', 'u', 'l', 'L', 'i', 'd', 'j', 'C': + // Valid OpenSSH tokens + default: + return fmt.Errorf("unknown ControlPath token %%%c", rawPath[i]) + } + } + } + + return nil +} + +// ValidateEffectiveControl verifies the resolved effective multiplexing configuration for a node. +func ValidateEffectiveControl(ctrl *ssh.ControlConfig) error { + if ctrl == nil { + return nil + } + normMaster := strings.ToLower(strings.TrimSpace(string(ctrl.Master))) + if isActiveMasterMode(normMaster) { + if ctrl.Path == "" || ctrl.Path == "none" { + return fmt.Errorf("%w: ControlMaster %q requires non-empty ControlPath", ErrSchemaValidation, ctrl.Master) + } + } + return nil +} + +func isActiveMasterMode(mode string) bool { + return mode == string(ssh.ControlMasterYes) || + mode == string(ssh.ControlMasterAsk) || + mode == string(ssh.ControlMasterAuto) || + mode == string(ssh.ControlMasterAutoAsk) +} + +func isValidEnvVarIdentifier(s string) bool { + if len(s) == 0 { + return false + } + for i, r := range s { + if i == 0 { + if (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') && r != '_' { + return false + } + } else { + if (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '_' { + return false + } + } + } + return true +} diff --git a/pkg/config/validate_test.go b/pkg/config/validate_test.go new file mode 100644 index 00000000..ee2d707f --- /dev/null +++ b/pkg/config/validate_test.go @@ -0,0 +1,166 @@ +package config + +import ( + "errors" + "strings" + "testing" + + "github.com/wentf9/xops-cli/core/ssh" +) + +func TestValidateControlConfig_Valid(t *testing.T) { + tests := []struct { + name string + ctrl *ssh.ControlConfig + }{ + {"nil", nil}, + {"empty", &ssh.ControlConfig{}}, + {"master_no", &ssh.ControlConfig{Master: ssh.ControlMasterNo}}, + {"master_yes_with_path", &ssh.ControlConfig{Master: ssh.ControlMasterYes, Path: "~/.ssh/cm-%h"}}, + {"master_auto_with_persist", &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "/tmp/sock", Persist: "10m"}}, + {"master_ask", &ssh.ControlConfig{Master: ssh.ControlMasterAsk, Path: "/tmp/sock", Persist: "1h30m"}}, + {"master_autoask", &ssh.ControlConfig{Master: ssh.ControlMasterAutoAsk, Path: "/tmp/sock", Persist: "0"}}, + {"path_none_master_no", &ssh.ControlConfig{Master: ssh.ControlMasterNo, Path: "none"}}, + {"path_with_env_var", &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "${XDG_RUNTIME_DIR}/ssh-%C.sock"}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if err := ValidateControlConfig(tc.ctrl); err != nil { + t.Fatalf("expected valid, got: %v", err) + } + }) + } +} + +func TestValidateControlConfig_InvalidMaster(t *testing.T) { + invalids := []string{"invalid", "maybe", "123", "none"} + for _, inv := range invalids { + ctrl := &ssh.ControlConfig{Master: ssh.ControlMasterMode(inv)} + err := ValidateControlConfig(ctrl) + if err == nil { + t.Fatalf("expected error for master %q, got nil", inv) + } + if !errors.Is(err, ErrSchemaValidation) { + t.Fatalf("expected error wrapping ErrSchemaValidation, got: %v", err) + } + } +} + +func TestValidateControlConfig_InvalidPersist(t *testing.T) { + invalids := []string{"10x", "-5m", "invalid", "0m"} + for _, inv := range invalids { + ctrl := &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "/tmp/sock", Persist: inv} + err := ValidateControlConfig(ctrl) + if err == nil { + t.Fatalf("expected error for persist %q, got nil", inv) + } + if !errors.Is(err, ErrSchemaValidation) { + t.Fatalf("expected error wrapping ErrSchemaValidation, got: %v", err) + } + } +} + +func TestValidateControlPathSyntax(t *testing.T) { + // Valid cases + validPaths := []string{ + "", + "none", + "~/.ssh/cm-%r@%h:%p", + "/tmp/ssh-%u-%h-%p-%n-%l-%L-%i-%d-%j-%C-%%.sock", + "${HOME}/.ssh/cm-%h", + "${XDG_RUNTIME_DIR}/ssh-${USER}/sock", + } + for _, p := range validPaths { + if err := ValidateControlPathSyntax(p); err != nil { + t.Fatalf("path %q should be valid, got: %v", p, err) + } + } + + // Invalid trailing percent + if err := ValidateControlPathSyntax("/tmp/cm-%"); err == nil || !strings.Contains(err.Error(), "trailing '%'") { + t.Fatalf("expected trailing percent error, got: %v", err) + } + + // Unknown token + if err := ValidateControlPathSyntax("/tmp/cm-%z"); err == nil || !strings.Contains(err.Error(), "unknown ControlPath token %z") { + t.Fatalf("expected unknown token error, got: %v", err) + } + + // Unclosed env var + if err := ValidateControlPathSyntax("/tmp/${UNCLOSED/sock"); err == nil || !strings.Contains(err.Error(), "unclosed environment variable") { + t.Fatalf("expected unclosed env var error, got: %v", err) + } + + // Invalid env var identifier + if err := ValidateControlPathSyntax("/tmp/${123BAD}/sock"); err == nil || !strings.Contains(err.Error(), "invalid environment variable name") { + t.Fatalf("expected invalid env var name error, got: %v", err) + } +} + +func TestValidateControlConfig_CrossFieldRules(t *testing.T) { + // Rule A: Persist specified when Master is 'no' + ctrlA := &ssh.ControlConfig{ + Master: ssh.ControlMasterNo, + Path: "/tmp/sock", + Persist: "10m", + } + errA := ValidateControlConfig(ctrlA) + if errA == nil || !strings.Contains(errA.Error(), "cannot be specified when ControlMaster is 'no'") { + t.Fatalf("expected persist with master no error, got: %v", errA) + } + + // Rule B: Path is 'none' when Master is active + activeModes := []ssh.ControlMasterMode{ + ssh.ControlMasterYes, + ssh.ControlMasterAsk, + ssh.ControlMasterAuto, + ssh.ControlMasterAutoAsk, + } + for _, mode := range activeModes { + ctrlB := &ssh.ControlConfig{ + Master: mode, + Path: "none", + } + errB := ValidateControlConfig(ctrlB) + if errB == nil || !strings.Contains(errB.Error(), "ControlPath cannot be 'none' when ControlMaster is") { + t.Fatalf("expected path none with master %q error, got: %v", mode, errB) + } + } +} + +func TestValidateEffectiveControl(t *testing.T) { + // nil is valid + if err := ValidateEffectiveControl(nil); err != nil { + t.Fatalf("nil should be valid, got: %v", err) + } + + // Master no with empty path is valid + if err := ValidateEffectiveControl(&ssh.ControlConfig{Master: ssh.ControlMasterNo}); err != nil { + t.Fatalf("master no with empty path should be valid, got: %v", err) + } + + // Active master with empty path is invalid + activeModes := []ssh.ControlMasterMode{ + ssh.ControlMasterYes, + ssh.ControlMasterAsk, + ssh.ControlMasterAuto, + ssh.ControlMasterAutoAsk, + } + for _, mode := range activeModes { + ctrlEmpty := &ssh.ControlConfig{Master: mode, Path: ""} + if err := ValidateEffectiveControl(ctrlEmpty); err == nil || !strings.Contains(err.Error(), "requires non-empty ControlPath") { + t.Fatalf("mode %q with empty path should fail, got: %v", mode, err) + } + + ctrlNone := &ssh.ControlConfig{Master: mode, Path: "none"} + if err := ValidateEffectiveControl(ctrlNone); err == nil || !strings.Contains(err.Error(), "requires non-empty ControlPath") { + t.Fatalf("mode %q with path none should fail, got: %v", mode, err) + } + + ctrlValid := &ssh.ControlConfig{Master: mode, Path: "/tmp/valid.sock"} + if err := ValidateEffectiveControl(ctrlValid); err != nil { + t.Fatalf("mode %q with valid path should succeed, got: %v", mode, err) + } + } +} diff --git a/pkg/i18n/locales/active.en.yaml b/pkg/i18n/locales/active.en.yaml index 36c997d9..84f78350 100644 --- a/pkg/i18n/locales/active.en.yaml +++ b/pkg/i18n/locales/active.en.yaml @@ -69,6 +69,14 @@ flag_background: other: "Requests ssh to go to background just before command execution (implies -n)" flag_stdin_redirect: other: "Redirects stdin from /dev/null (prevents reading from stdin)" +flag_ssh_master: + other: "Places the ssh client into 'master' mode for connection sharing; repeated flags place ssh into confirmation mode" +flag_ssh_control_path: + other: "Specify path to control socket for connection sharing, or 'none' to disable" +flag_ssh_option: + other: "Can be used to give options in the format used in the configuration file (Key=Value)" +flag_ssh_control_command: + other: "Send control command to active master: check, exit, stop, forward, cancel, proxy, proxy-stream, conninfo, channels" ssh_err_background_requires_nocmd: other: "Background execution (-f) requires -N (no command) option" prompt_enter_passphrase: diff --git a/pkg/i18n/locales/active.zh.yaml b/pkg/i18n/locales/active.zh.yaml index dc994046..7cc58e76 100644 --- a/pkg/i18n/locales/active.zh.yaml +++ b/pkg/i18n/locales/active.zh.yaml @@ -69,6 +69,14 @@ flag_background: other: "在身份验证和端口转发建立后转入后台运行 (隐含 -n)" flag_stdin_redirect: other: "将标准输入重定向到 /dev/null (防止后台进程读取终端输入)" +flag_ssh_master: + other: "将 ssh 客户端置于多路复用“主模式”;多次指定进入确认模式" +flag_ssh_control_path: + other: "指定用于连接共享的控制套接字路径,指定 'none' 禁用共享" +flag_ssh_option: + other: "以配置文件格式传递配置选项 (Key=Value)" +flag_ssh_control_command: + other: "向运行中的主进程发送控制命令:check, exit, stop, forward, cancel, proxy, proxy-stream, conninfo, channels" ssh_err_background_requires_nocmd: other: "后台执行 (-f) 必须与 -N (不执行远程命令) 选项配合使用" prompt_enter_passphrase: diff --git a/pkg/logger/interface.go b/pkg/logger/interface.go index 0c9c4fc1..8f3977fc 100644 --- a/pkg/logger/interface.go +++ b/pkg/logger/interface.go @@ -13,6 +13,10 @@ func (defaultAdapter) Debugf(format string, args ...any) { Debugf(format, args...) } +func (defaultAdapter) Warnf(format string, args ...any) { + PrintWarnf(format, args...) +} + // DefaultLogger 返回基于当前全局配置的 DebugLogger 适配器 func DefaultLogger() corelog.DebugLogger { return defaultAdapter{} diff --git a/pkg/models/types.go b/pkg/models/types.go index 523a0a5c..1d7e2dbc 100644 --- a/pkg/models/types.go +++ b/pkg/models/types.go @@ -230,6 +230,12 @@ type Node struct { // 执行环境配置 Execution *ssh.ExecutionConfig `yaml:"execution,omitempty" json:"execution,omitempty"` + + // 连接复用配置 + Control *ssh.ControlConfig `yaml:"control,omitempty" json:"control,omitempty"` + + // OpenSSHAlias 记录运行时从 ~/.ssh/config 具体 Host 块派生该节点时的源别名(不序列化落盘) + OpenSSHAlias string `yaml:"-" json:"-"` } // NodeFilter 用于批量操作时筛选节点 diff --git a/pkg/models/types_test.go b/pkg/models/types_test.go index d533d48e..04d8ec5f 100644 --- a/pkg/models/types_test.go +++ b/pkg/models/types_test.go @@ -1,10 +1,13 @@ package models import ( + "strings" "testing" "time" + "github.com/wentf9/xops-cli/core/ssh" "github.com/wentf9/xops-cli/pkg/credential" + "gopkg.in/yaml.v3" ) func TestOTPConfig_CloneAndEqual(t *testing.T) { @@ -185,3 +188,47 @@ func TestIdentity_CloneAndEqual(t *testing.T) { t.Fatal("mutating cloned KeyboardInteractive affected original") } } + +func TestNode_ControlYAMLSerialization(t *testing.T) { + // 1. Without Control, field should be omitted in YAML + nodeWithoutControl := Node{ + HostRef: "srv1", + IdentityRef: "admin", + } + out, err := yaml.Marshal(nodeWithoutControl) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + if strings.Contains(string(out), "control:") { + t.Fatalf("expected control to be omitted, got: %s", string(out)) + } + + // 2. With Control, field should be marshaled and unmarshaled correctly + nodeWithControl := Node{ + HostRef: "srv1", + IdentityRef: "admin", + Control: &ssh.ControlConfig{ + Master: ssh.ControlMasterAuto, + Path: "~/.ssh/cm-%r@%h:%p", + Persist: "10m", + }, + } + data, err := yaml.Marshal(nodeWithControl) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + if !strings.Contains(string(data), "control:") { + t.Fatalf("expected control in YAML, got: %s", string(data)) + } + + var unmarshaled Node + if err := yaml.Unmarshal(data, &unmarshaled); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if unmarshaled.Control == nil { + t.Fatal("expected unmarshaled Control to be non-nil") + } + if !unmarshaled.Control.Equal(nodeWithControl.Control) { + t.Fatalf("expected %v, got %v", nodeWithControl.Control, unmarshaled.Control) + } +} diff --git a/pkg/playbook/adversarial_concurrency_test.go b/pkg/playbook/adversarial_concurrency_test.go new file mode 100644 index 00000000..be1f59b3 --- /dev/null +++ b/pkg/playbook/adversarial_concurrency_test.go @@ -0,0 +1,389 @@ +package playbook_test + +import ( + "context" + "fmt" + "net" + "path/filepath" + "strconv" + "syscall" + "testing" + "time" + + "github.com/wentf9/xops-cli/core/concurrent" + corelog "github.com/wentf9/xops-cli/core/log" + "github.com/wentf9/xops-cli/core/ssh" + "github.com/wentf9/xops-cli/core/testutil/sshfixture" + "github.com/wentf9/xops-cli/pkg/adapter" + "github.com/wentf9/xops-cli/pkg/config" + "github.com/wentf9/xops-cli/pkg/models" + "github.com/wentf9/xops-cli/pkg/playbook" +) + +// TestAdversarial_Playbook_HighConcurrencySocketSharing stress tests multiple nodes +// executing concurrently in parallel over a single active master control socket. +// It verifies that all steps succeed concurrently and that 0 additional SSH authentications occur. +func TestAdversarial_Playbook_HighConcurrencySocketSharing(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + fixture, sockPath, _, host, port := setupPlaybookFixture(t, ctx) + + const numNodes = 8 + const stepsPerNode = 3 + + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + } + + cfg.Hosts.Set("shared-host", models.Host{Address: host, Port: port}) + cfg.Identities.Set("shared-id", models.Identity{User: "fixture", AuthType: "password", Password: "synthetic-ssh-fixture-password"}) + + var targetNodes []string + for i := 1; i <= numNodes; i++ { + nodeID := fmt.Sprintf("web-%02d", i) + targetNodes = append(targetNodes, nodeID) + cfg.Nodes.Set(nodeID, models.Node{ + HostRef: "shared-host", + IdentityRef: "shared-id", + Tags: []string{"web"}, + Control: &ssh.ControlConfig{ + Master: ssh.ControlMasterAuto, + Path: sockPath, + }, + }) + } + + provider := config.NewProviderWithoutOpenSSH(cfg) + recorder := &diagnosticRecorder{} + + connector := adapter.NewConnector(provider, ssh.WithDiagnostics(recorder), ssh.WithHostKeyVerifier(fixtureTrust{key: fixture.HostKey})) + connector.AcceptNewHostKey.Store(true) + defer func() { _ = connector.CloseAll() }() + + var steps []playbook.Step + for s := 1; s <= stepsPerNode; s++ { + steps = append(steps, playbook.Step{ + Name: fmt.Sprintf("step-%d", s), + Shell: fmt.Sprintf("echo step-%d", s), + }) + } + + pb := &playbook.Playbook{ + Name: "adversarial-concurrency-mux", + Settings: playbook.Settings{ + Concurrency: numNodes, // Maximum parallel concurrency across all nodes + }, + Targets: playbook.Targets{ + Nodes: targetNodes, + }, + Steps: steps, + } + + engine := playbook.NewEngine(pb, provider, connector) + report, err := engine.Run(ctx) + if err != nil { + t.Fatalf("engine.Run failed under concurrency: %v", err) + } + + if len(report.Hosts) != numNodes { + t.Fatalf("expected %d host reports, got %d", numNodes, len(report.Hosts)) + } + + for _, hr := range report.Hosts { + if hr.Status != playbook.HostStatusSuccess { + t.Errorf("host %s failed with status %v", hr.NodeID, hr.Status) + } + if len(hr.Steps) != stepsPerNode { + t.Errorf("host %s expected %d steps, got %d", hr.NodeID, stepsPerNode, len(hr.Steps)) + } + for sIdx, sReport := range hr.Steps { + if sReport.Status != playbook.StatusChanged { + t.Errorf("host %s step %d status = %v, expected StatusChanged", hr.NodeID, sIdx, sReport.Status) + } + } + + // Verify ConnectionReused diagnostic was recorded for each node + if !recorder.hasEvent(corelog.ConnectionReused, hr.NodeID) { + t.Errorf("expected ConnectionReused diagnostic for %s", hr.NodeID) + } + } + + // VERIFY ZERO NEW SSH AUTHENTICATIONS: + // Only 1 initial authentication from setupPlaybookFixture's master dial. + // All 8 nodes and 24 steps ran concurrently without performing any additional SSH authentications! + authed := fixture.Authenticated.Load() + if authed != 1 { + t.Fatalf("CRITICAL: expected exactly 1 SSH authentication on remote peer, got %d (connection was not reused!)", authed) + } +} + +// TestAdversarial_Playbook_RapidSequentialMultiStep verifies that a single node +// executing a long sequence of steps over the multiplexed connection operates cleanly +// with 0 extra authentications. +func TestAdversarial_Playbook_RapidSequentialMultiStep(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + fixture, sockPath, _, host, port := setupPlaybookFixture(t, ctx) + + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + } + + cfg.Hosts.Set("host-seq", models.Host{Address: host, Port: port}) + cfg.Identities.Set("id-seq", models.Identity{User: "fixture", AuthType: "password", Password: "synthetic-ssh-fixture-password"}) + cfg.Nodes.Set("node-seq", models.Node{ + HostRef: "host-seq", + IdentityRef: "id-seq", + Control: &ssh.ControlConfig{ + Master: ssh.ControlMasterAuto, + Path: sockPath, + }, + }) + + provider := config.NewProviderWithoutOpenSSH(cfg) + recorder := &diagnosticRecorder{} + + connector := adapter.NewConnector(provider, ssh.WithDiagnostics(recorder), ssh.WithHostKeyVerifier(fixtureTrust{key: fixture.HostKey})) + connector.AcceptNewHostKey.Store(true) + defer func() { _ = connector.CloseAll() }() + + const numSteps = 10 + var steps []playbook.Step + for i := 1; i <= numSteps; i++ { + steps = append(steps, playbook.Step{ + Name: fmt.Sprintf("rapid-step-%02d", i), + Shell: fmt.Sprintf("echo rapid-%02d", i), + }) + } + + pb := &playbook.Playbook{ + Name: "rapid-sequential-playbook", + Targets: playbook.Targets{ + Nodes: []string{"node-seq"}, + }, + Steps: steps, + } + + engine := playbook.NewEngine(pb, provider, connector) + report, err := engine.Run(ctx) + if err != nil { + t.Fatalf("engine.Run failed: %v", err) + } + + if len(report.Hosts) != 1 || report.Hosts[0].Status != playbook.HostStatusSuccess { + t.Fatalf("expected host success, got %+v", report.Hosts) + } + if len(report.Hosts[0].Steps) != numSteps { + t.Fatalf("expected %d steps, got %d", numSteps, len(report.Hosts[0].Steps)) + } + + // Authentications must remain exactly 1 + if authed := fixture.Authenticated.Load(); authed != 1 { + t.Fatalf("expected 1 authentication, got %d", authed) + } +} + +// TestAdversarial_Playbook_ConcurrentSharedAndDirectNodes tests a mixed execution where +// some nodes reuse a master socket while others bypass it via ControlPath: "none". +func TestAdversarial_Playbook_ConcurrentSharedAndDirectNodes(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + fixture, sockPath, _, host, port := setupPlaybookFixture(t, ctx) + + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + } + + cfg.Hosts.Set("mixed-host", models.Host{Address: host, Port: port}) + cfg.Identities.Set("mixed-id", models.Identity{User: "fixture", AuthType: "password", Password: "synthetic-ssh-fixture-password"}) + + // 4 shared nodes + for i := 1; i <= 4; i++ { + cfg.Nodes.Set(fmt.Sprintf("shared-%02d", i), models.Node{ + HostRef: "mixed-host", + IdentityRef: "mixed-id", + Control: &ssh.ControlConfig{ + Master: ssh.ControlMasterAuto, + Path: sockPath, + }, + }) + } + + // 2 direct nodes (bypass mux) + for i := 1; i <= 2; i++ { + cfg.Nodes.Set(fmt.Sprintf("direct-%02d", i), models.Node{ + HostRef: "mixed-host", + IdentityRef: "mixed-id", + Control: &ssh.ControlConfig{ + Master: ssh.ControlMasterNo, + Path: "none", + }, + }) + } + + provider := config.NewProviderWithoutOpenSSH(cfg) + recorder := &diagnosticRecorder{} + + connector := adapter.NewConnector(provider, ssh.WithDiagnostics(recorder), ssh.WithHostKeyVerifier(fixtureTrust{key: fixture.HostKey})) + connector.AcceptNewHostKey.Store(true) + defer func() { _ = connector.CloseAll() }() + + pb := &playbook.Playbook{ + Name: "mixed-shared-direct-playbook", + Settings: playbook.Settings{ + Concurrency: 6, + }, + Targets: playbook.Targets{ + Nodes: []string{"shared-01", "shared-02", "shared-03", "shared-04", "direct-01", "direct-02"}, + }, + Steps: []playbook.Step{ + {Name: "step-mixed", Shell: "echo mixed"}, + }, + } + + engine := playbook.NewEngine(pb, provider, connector) + report, err := engine.Run(ctx) + if err != nil { + t.Fatalf("engine.Run failed: %v", err) + } + + if len(report.Hosts) != 6 { + t.Fatalf("expected 6 host reports, got %d", len(report.Hosts)) + } + for _, hr := range report.Hosts { + if hr.Status != playbook.HostStatusSuccess { + t.Errorf("host %s failed: status %v", hr.NodeID, hr.Status) + } + } + + // Verify shared nodes reused connection + for i := 1; i <= 4; i++ { + sharedID := fmt.Sprintf("shared-%02d", i) + if !recorder.hasEvent(corelog.ConnectionReused, sharedID) { + t.Errorf("expected ConnectionReused for %s", sharedID) + } + } + + // Verify direct nodes did NOT reuse connection + for i := 1; i <= 2; i++ { + directID := fmt.Sprintf("direct-%02d", i) + if recorder.hasEvent(corelog.ConnectionReused, directID) { + t.Errorf("direct node %s should NOT have emitted ConnectionReused", directID) + } + } + + // Authentications: 1 (master dial) + 2 (direct-01 & direct-02) = 3 total. + // The 4 shared nodes contributed ZERO additional authentications! + if authed := fixture.Authenticated.Load(); authed != 3 { + t.Fatalf("expected 3 total authentications (1 master + 2 direct), got %d", authed) + } +} + +// TestAdversarial_Playbook_StaleSocketSimulatedCrashRecovery tests that if a previous +// master crashes and leaves a dead socket on disk, a playbook execution unblocks, +// unlinks the stale socket, starts a fresh master, and executes the steps successfully. +func TestAdversarial_Playbook_StaleSocketSimulatedCrashRecovery(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + fixture, err := sshfixture.New(ctx) + if err != nil { + t.Fatalf("sshfixture.New failed: %v", err) + } + defer func() { _ = fixture.Close() }() + + tmpDir := t.TempDir() + staleSockPath := filepath.Join(tmpDir, "crashed_playbook.sock") + + // Create a dead socket on disk + fd, err := syscall.Socket(syscall.AF_UNIX, syscall.SOCK_STREAM, 0) + if err != nil { + t.Fatalf("create socket failed: %v", err) + } + sa := &syscall.SockaddrUnix{Name: staleSockPath} + if err := syscall.Bind(fd, sa); err != nil { + _ = syscall.Close(fd) + t.Fatalf("bind failed: %v", err) + } + _ = syscall.Close(fd) // Dead socket on disk + + host, portStr, err := net.SplitHostPort(fixture.Address) + if err != nil { + t.Fatal(err) + } + portInt, err := strconv.Atoi(portStr) + if err != nil { + t.Fatal(err) + } + port := uint16(portInt) + + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + } + + cfg.Hosts.Set("host-crash", models.Host{Address: host, Port: port}) + cfg.Identities.Set("id-crash", models.Identity{User: "fixture", AuthType: "password", Password: sshfixture.Password}) + cfg.Nodes.Set("node-crash", models.Node{ + HostRef: "host-crash", + IdentityRef: "id-crash", + Control: &ssh.ControlConfig{ + Master: ssh.ControlMasterAuto, + Path: staleSockPath, + }, + }) + + provider := config.NewProviderWithoutOpenSSH(cfg) + recorder := &diagnosticRecorder{} + + connector := adapter.NewConnector(provider, ssh.WithDiagnostics(recorder), ssh.WithHostKeyVerifier(fixtureTrust{key: fixture.HostKey})) + connector.AcceptNewHostKey.Store(true) + defer func() { _ = connector.CloseAll() }() + + pb := &playbook.Playbook{ + Name: "crash-recovery-playbook", + Targets: playbook.Targets{ + Nodes: []string{"node-crash"}, + }, + Steps: []playbook.Step{ + {Name: "step-after-crash-1", Shell: "echo recovered-1"}, + {Name: "step-after-crash-2", Shell: "echo recovered-2"}, + }, + } + + engine := playbook.NewEngine(pb, provider, connector) + report, err := engine.Run(ctx) + if err != nil { + t.Fatalf("engine.Run failed after master crash: %v", err) + } + + if len(report.Hosts) != 1 || report.Hosts[0].Status != playbook.HostStatusSuccess { + t.Fatalf("expected host success after crash recovery, got %+v", report.Hosts) + } + if len(report.Hosts[0].Steps) != 2 { + t.Fatalf("expected 2 steps, got %d", len(report.Hosts[0].Steps)) + } + + // Verify master socket on staleSockPath was recreated and is alive! + probeCtx, pCancel := context.WithTimeout(ctx, 2*time.Second) + defer pCancel() + mCli, probeErr := ssh.DialMuxClient(probeCtx, staleSockPath) + if probeErr != nil { + t.Fatalf("expected newly created master on %s: %v", staleSockPath, probeErr) + } + defer func() { _ = mCli.Close() }() + pid, checkErr := mCli.AliveCheck(probeCtx) + if checkErr != nil || pid == 0 { + t.Fatalf("AliveCheck failed on recovered master: %v", checkErr) + } +} diff --git a/pkg/playbook/playbook_control_test.go b/pkg/playbook/playbook_control_test.go new file mode 100644 index 00000000..cf60e129 --- /dev/null +++ b/pkg/playbook/playbook_control_test.go @@ -0,0 +1,418 @@ +package playbook_test + +import ( + "context" + "net" + "path/filepath" + "strconv" + "sync" + "syscall" + "testing" + "time" + + cryptoSSH "golang.org/x/crypto/ssh" + + "github.com/wentf9/xops-cli/core/concurrent" + corelog "github.com/wentf9/xops-cli/core/log" + "github.com/wentf9/xops-cli/core/ssh" + "github.com/wentf9/xops-cli/core/testutil/sshfixture" + "github.com/wentf9/xops-cli/pkg/adapter" + "github.com/wentf9/xops-cli/pkg/config" + "github.com/wentf9/xops-cli/pkg/models" + "github.com/wentf9/xops-cli/pkg/playbook" +) + +type diagnosticRecorder struct { + mu sync.Mutex + events []corelog.Diagnostic +} + +func (r *diagnosticRecorder) ObserveDiagnostic(d corelog.Diagnostic) { + r.mu.Lock() + defer r.mu.Unlock() + r.events = append(r.events, d) +} + +func (r *diagnosticRecorder) hasEvent(kind corelog.DiagnosticKind, nodeID string) bool { + r.mu.Lock() + defer r.mu.Unlock() + for _, e := range r.events { + if e.Kind == kind && (nodeID == "" || e.NodeID == nodeID) { + return true + } + } + return false +} + +// setupPlaybookFixture creates an sshfixture and a master connection with MuxServer listening on a socket. +func setupPlaybookFixture(t *testing.T, ctx context.Context) (*sshfixture.Server, string, *ssh.MuxServer, string, uint16) { + t.Helper() + + fixture, err := sshfixture.New(ctx) + if err != nil { + t.Fatalf("sshfixture.New failed: %v", err) + } + + host, portStr, err := net.SplitHostPort(fixture.Address) + if err != nil { + t.Fatalf("SplitHostPort failed: %v", err) + } + portInt, err := strconv.Atoi(portStr) + if err != nil { + t.Fatalf("Atoi failed: %v", err) + } + port := uint16(portInt) + + // Establish direct SSH master client connection to fixture + clientCfg := &cryptoSSH.ClientConfig{ + User: "fixture", + Auth: []cryptoSSH.AuthMethod{ + cryptoSSH.Password(sshfixture.Password), + }, + HostKeyCallback: cryptoSSH.FixedHostKey(fixture.HostKey), + Timeout: 5 * time.Second, + } + sshClient, err := cryptoSSH.Dial("tcp", fixture.Address, clientCfg) + if err != nil { + t.Fatalf("cryptoSSH.Dial master failed: %v", err) + } + + tmpDir := t.TempDir() + sockPath := filepath.Join(tmpDir, "playbook_master.sock") + + muxServer, err := ssh.NewMuxServer(ssh.MuxServerOptions{ + ControlPath: sockPath, + SSHClient: sshClient, + }) + if err != nil { + t.Fatalf("NewMuxServer failed: %v", err) + } + go func() { _ = muxServer.Serve() }() + + t.Cleanup(func() { + _ = muxServer.Shutdown() + _ = sshClient.Close() + _ = fixture.Close() + }) + + return fixture, sockPath, muxServer, host, port +} + +func TestPlaybook_ActiveControlSocketReuse(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + fixture, sockPath, _, host, port := setupPlaybookFixture(t, ctx) + + // Configure playbook inventory with ControlPath pointing to active socket + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + } + + cfg.Hosts.Set("host-1", models.Host{Address: host, Port: port}) + cfg.Identities.Set("id-1", models.Identity{User: "fixture", AuthType: "password", Password: sshfixture.Password}) + cfg.Nodes.Set("web-01", models.Node{ + HostRef: "host-1", + IdentityRef: "id-1", + Tags: []string{"web"}, + Control: &ssh.ControlConfig{ + Master: ssh.ControlMasterAuto, + Path: sockPath, + }, + }) + + provider := config.NewProviderWithoutOpenSSH(cfg) + recorder := &diagnosticRecorder{} + + connector := adapter.NewConnector(provider, ssh.WithDiagnostics(recorder), ssh.WithHostKeyVerifier(fixtureTrust{key: fixture.HostKey})) + connector.AcceptNewHostKey.Store(true) + defer func() { _ = connector.CloseAll() }() + + pb := &playbook.Playbook{ + Name: "test-playbook-mux", + Targets: playbook.Targets{ + Nodes: []string{"web-01"}, + }, + Steps: []playbook.Step{ + {Name: "step-1", Shell: "echo step1"}, + {Name: "step-2", Shell: "echo step2"}, + }, + } + + engine := playbook.NewEngine(pb, provider, connector) + report, err := engine.Run(ctx) + if err != nil { + t.Fatalf("engine.Run failed: %v", err) + } + + if len(report.Hosts) != 1 { + t.Fatalf("expected 1 host report, got %d", len(report.Hosts)) + } + hr := report.Hosts[0] + if hr.Status != playbook.HostStatusSuccess { + t.Fatalf("expected host status success, got %v", hr.Status) + } + if len(hr.Steps) != 2 { + t.Fatalf("expected 2 steps, got %d", len(hr.Steps)) + } + for i, step := range hr.Steps { + if step.Status != playbook.StatusChanged { + t.Errorf("step %d (%s) status = %v, expected StatusChanged (err: %v)", i, step.StepName, step.Status, step.Err) + } + } + + // Verify ConnectionReused diagnostic was recorded for web-01 + if !recorder.hasEvent(corelog.ConnectionReused, "web-01") { + t.Errorf("expected ConnectionReused diagnostic event for web-01") + } + + // Verify no new SSH handshakes / authentications were made to the fixture + // The master dial was authentication #1; playbook reused the socket via MUX_C_PROXY without new authentication + if authed := fixture.Authenticated.Load(); authed != 1 { + t.Errorf("expected exactly 1 SSH authentication on fixture, got %d", authed) + } +} + +func TestPlaybook_CrossNodeSocketSharing(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + fixture, sockPath, _, host, port := setupPlaybookFixture(t, ctx) + + // Configure two nodes sharing the same physical host and same master socket + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + } + + cfg.Hosts.Set("host-1", models.Host{Address: host, Port: port}) + cfg.Identities.Set("id-1", models.Identity{User: "fixture", AuthType: "password", Password: sshfixture.Password}) + + cfg.Nodes.Set("web-01", models.Node{ + HostRef: "host-1", + IdentityRef: "id-1", + Tags: []string{"web"}, + Control: &ssh.ControlConfig{ + Master: ssh.ControlMasterAuto, + Path: sockPath, + }, + }) + cfg.Nodes.Set("web-02", models.Node{ + HostRef: "host-1", + IdentityRef: "id-1", + Tags: []string{"web"}, + Control: &ssh.ControlConfig{ + Master: ssh.ControlMasterAuto, + Path: sockPath, + }, + }) + + provider := config.NewProviderWithoutOpenSSH(cfg) + recorder := &diagnosticRecorder{} + + connector := adapter.NewConnector(provider, ssh.WithDiagnostics(recorder), ssh.WithHostKeyVerifier(fixtureTrust{key: fixture.HostKey})) + connector.AcceptNewHostKey.Store(true) + defer func() { _ = connector.CloseAll() }() + + pb := &playbook.Playbook{ + Name: "test-playbook-cross-node", + Targets: playbook.Targets{ + Nodes: []string{"web-01", "web-02"}, + }, + Steps: []playbook.Step{ + {Name: "step-shared", Shell: "echo shared"}, + }, + } + + engine := playbook.NewEngine(pb, provider, connector) + report, err := engine.Run(ctx) + if err != nil { + t.Fatalf("engine.Run failed: %v", err) + } + + if len(report.Hosts) != 2 { + t.Fatalf("expected 2 host reports, got %d", len(report.Hosts)) + } + for _, hr := range report.Hosts { + if hr.Status != playbook.HostStatusSuccess { + t.Errorf("host %s status = %v, expected HostStatusSuccess", hr.NodeID, hr.Status) + } + } + + // Both web-01 and web-02 should have reused the active socket + if !recorder.hasEvent(corelog.ConnectionReused, "web-01") { + t.Errorf("expected ConnectionReused diagnostic for web-01") + } + if !recorder.hasEvent(corelog.ConnectionReused, "web-02") { + t.Errorf("expected ConnectionReused diagnostic for web-02") + } + + // Verify only 1 initial SSH authentication was performed on fixture + if authed := fixture.Authenticated.Load(); authed != 1 { + t.Errorf("expected exactly 1 SSH authentication on fixture, got %d", authed) + } +} + +func TestPlaybook_ControlPathNone_BypassesReuse(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + fixture, sockPath, _, host, port := setupPlaybookFixture(t, ctx) + + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + } + + cfg.Hosts.Set("host-1", models.Host{Address: host, Port: port}) + cfg.Identities.Set("id-1", models.Identity{User: "fixture", AuthType: "password", Password: sshfixture.Password}) + + // Node configured with Path: "none" (explicit disablement) even though sockPath exists + cfg.Nodes.Set("web-direct", models.Node{ + HostRef: "host-1", + IdentityRef: "id-1", + Control: &ssh.ControlConfig{ + Master: ssh.ControlMasterNo, + Path: "none", + }, + }) + + _ = sockPath // active socket exists, but node explicitly set "none" + + provider := config.NewProviderWithoutOpenSSH(cfg) + recorder := &diagnosticRecorder{} + + connector := adapter.NewConnector(provider, ssh.WithDiagnostics(recorder), ssh.WithHostKeyVerifier(fixtureTrust{key: fixture.HostKey})) + connector.AcceptNewHostKey.Store(true) + defer func() { _ = connector.CloseAll() }() + + pb := &playbook.Playbook{ + Name: "test-playbook-no-mux", + Targets: playbook.Targets{ + Nodes: []string{"web-direct"}, + }, + Steps: []playbook.Step{ + {Name: "direct-step", Shell: "echo direct"}, + }, + } + + engine := playbook.NewEngine(pb, provider, connector) + report, err := engine.Run(ctx) + if err != nil { + t.Fatalf("engine.Run failed: %v", err) + } + + if len(report.Hosts) != 1 || report.Hosts[0].Status != playbook.HostStatusSuccess { + t.Fatalf("expected 1 successful host report, got %+v", report.Hosts) + } + + // ConnectionReused should NOT have been emitted because Path is "none" + if recorder.hasEvent(corelog.ConnectionReused, "web-direct") { + t.Errorf("ConnectionReused should NOT be emitted when ControlPath is 'none'") + } + + // Directly dialed fixture: total authentications should now be 2 (master + web-direct) + if authed := fixture.Authenticated.Load(); authed != 2 { + t.Errorf("expected 2 SSH authentications on fixture, got %d", authed) + } +} + +func TestPlaybook_StaleSocketAutoUnlinkAndFallback(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + fixture, err := sshfixture.New(ctx) + if err != nil { + t.Fatalf("sshfixture.New failed: %v", err) + } + defer func() { _ = fixture.Close() }() + + host, portStr, _ := net.SplitHostPort(fixture.Address) + portInt, _ := strconv.Atoi(portStr) + port := uint16(portInt) + + // Create a stale socket on disk without active listener + tmpDir := t.TempDir() + staleSockPath := filepath.Join(tmpDir, "stale.sock") + fd, err := syscall.Socket(syscall.AF_UNIX, syscall.SOCK_STREAM, 0) + if err != nil { + t.Fatalf("create socket failed: %v", err) + } + defer func() { _ = syscall.Close(fd) }() + sa := &syscall.SockaddrUnix{Name: staleSockPath} + if err := syscall.Bind(fd, sa); err != nil { + t.Fatalf("bind failed: %v", err) + } + _ = syscall.Close(fd) // Close without listening, leaving stale socket file + + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + } + + cfg.Hosts.Set("host-1", models.Host{Address: host, Port: port}) + cfg.Identities.Set("id-1", models.Identity{User: "fixture", AuthType: "password", Password: sshfixture.Password}) + cfg.Nodes.Set("web-stale", models.Node{ + HostRef: "host-1", + IdentityRef: "id-1", + Control: &ssh.ControlConfig{ + Master: ssh.ControlMasterAuto, + Path: staleSockPath, + }, + }) + + provider := config.NewProviderWithoutOpenSSH(cfg) + recorder := &diagnosticRecorder{} + + connector := adapter.NewConnector(provider, ssh.WithDiagnostics(recorder), ssh.WithHostKeyVerifier(fixtureTrust{key: fixture.HostKey})) + connector.AcceptNewHostKey.Store(true) + defer func() { _ = connector.CloseAll() }() + + pb := &playbook.Playbook{ + Name: "test-stale-socket-fallback", + Targets: playbook.Targets{ + Nodes: []string{"web-stale"}, + }, + Steps: []playbook.Step{ + {Name: "fallback-step", Shell: "echo fallback"}, + }, + } + + engine := playbook.NewEngine(pb, provider, connector) + report, err := engine.Run(ctx) + if err != nil { + t.Fatalf("engine.Run failed: %v", err) + } + + if len(report.Hosts) != 1 || report.Hosts[0].Status != playbook.HostStatusSuccess { + t.Fatalf("expected 1 successful host report after stale fallback, got %+v", report.Hosts) + } + + // Verify the step succeeded + if report.Hosts[0].Steps[0].Status != playbook.StatusChanged { + t.Errorf("expected step status changed, got %v", report.Hosts[0].Steps[0].Status) + } + + // Verify stale socket was unlinked and a new master started on staleSockPath + aliveProbeCtx, probeCancel := context.WithTimeout(ctx, 2*time.Second) + defer probeCancel() + client, probeErr := ssh.DialMuxClient(aliveProbeCtx, staleSockPath) + if probeErr != nil { + t.Fatalf("expected new master to be listening on staleSockPath: %v", probeErr) + } + defer func() { _ = client.Close() }() + pid, aliveErr := client.AliveCheck(aliveProbeCtx) + if aliveErr != nil || pid == 0 { + t.Errorf("alive check on recreated master failed: %v, pid=%d", aliveErr, pid) + } + + // Direct dial was used for the initial connection so ConnectionReused was not emitted + if recorder.hasEvent(corelog.ConnectionReused, "web-stale") { + t.Errorf("ConnectionReused should not be emitted on initial stale socket fallback") + } +} From d1e764ffe6ee3578c0e23424cb5743bc4f4191e3 Mon Sep 17 00:00:00 2001 From: wuyue Date: Fri, 6 Nov 2026 12:43:42 +0800 Subject: [PATCH 2/7] feat(sftpshell): support -l/-a/-h/-1 flags in ls and lls --- cmd/sftpshell/shell.go | 45 ++++++++++++++++++ cmd/sftpshell/shell_test.go | 93 +++++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) diff --git a/cmd/sftpshell/shell.go b/cmd/sftpshell/shell.go index 19256782..8438c889 100644 --- a/cmd/sftpshell/shell.go +++ b/cmd/sftpshell/shell.go @@ -1172,6 +1172,7 @@ func (s *Shell) handleLocalCd(args []string) error { } func (s *Shell) handleLs(ctx context.Context, args []string, long bool) error { + args, long = parseLsFlags(args, long) if len(args) > 0 && hasWildcard(args[0]) { matches, err := s.expandRemote(ctx, args[0]) if err != nil { @@ -1261,6 +1262,7 @@ func (s *Shell) listRemoteOne(ctx context.Context, path string, long bool) error } func (s *Shell) handleLocalLs(args []string, long bool) error { + args, long = parseLsFlags(args, long) if len(args) > 0 && hasWildcard(args[0]) { matches, err := s.expandLocal(args[0]) if err != nil { @@ -2353,6 +2355,49 @@ func parseForceFlag(args []string) ([]string, bool) { return filtered, force } +func parseLsFlags(args []string, defaultLong bool) ([]string, bool) { + long := defaultLong + filtered := make([]string, 0, len(args)) + parsingFlags := true + for _, arg := range args { + if !parsingFlags { + filtered = append(filtered, arg) + continue + } + if arg == "--" { + parsingFlags = false + continue + } + if len(arg) > 1 && strings.HasPrefix(arg, "-") && isLsFlagGroup(arg[1:]) { + for _, ch := range arg[1:] { + switch ch { + case 'l': + long = true + case '1': + long = false + } + } + continue + } + filtered = append(filtered, arg) + } + return filtered, long +} + +func isLsFlagGroup(s string) bool { + if s == "" { + return false + } + for _, ch := range s { + switch ch { + case 'l', 'a', 'h', '1': + default: + return false + } + } + return true +} + func (s *Shell) printHelp() { s.fprintlnStdout(i18n.T("sftp_shell_help")) } diff --git a/cmd/sftpshell/shell_test.go b/cmd/sftpshell/shell_test.go index 42e4d627..606d87b3 100644 --- a/cmd/sftpshell/shell_test.go +++ b/cmd/sftpshell/shell_test.go @@ -1885,3 +1885,96 @@ func TestShellRun_PassthroughTrailingBackslash(t *testing.T) { t.Errorf("expected stderr to contain 'unclosed quote', got: %q", stderrOutput) } } + +func TestParseLsFlags(t *testing.T) { + tests := []struct { + name string + args []string + defaultLong bool + wantArgs []string + wantLong bool + }{ + { + name: "ls -l path", + args: []string{"-l", "/tmp/file.txt"}, + defaultLong: false, + wantArgs: []string{"/tmp/file.txt"}, + wantLong: true, + }, + { + name: "ls -la path", + args: []string{"-la", "/tmp"}, + defaultLong: false, + wantArgs: []string{"/tmp"}, + wantLong: true, + }, + { + name: "ll -1 overrides default long", + args: []string{"-1", "/tmp"}, + defaultLong: true, + wantArgs: []string{"/tmp"}, + wantLong: false, + }, + { + name: "double dash preserves literal -l filename", + args: []string{"-l", "--", "-l"}, + defaultLong: false, + wantArgs: []string{"-l"}, + wantLong: true, + }, + { + name: "non-ls flag preserved as path", + args: []string{"-foo"}, + defaultLong: false, + wantArgs: []string{"-foo"}, + wantLong: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotArgs, gotLong := parseLsFlags(tt.args, tt.defaultLong) + if gotLong != tt.wantLong { + t.Fatalf("parseLsFlags(%v, %v) long = %v, want %v", tt.args, tt.defaultLong, gotLong, tt.wantLong) + } + if len(gotArgs) != len(tt.wantArgs) { + t.Fatalf("parseLsFlags(%v, %v) args = %v, want %v", tt.args, tt.defaultLong, gotArgs, tt.wantArgs) + } + for i := range gotArgs { + if gotArgs[i] != tt.wantArgs[i] { + t.Fatalf("parseLsFlags(%v, %v) args[%d] = %q, want %q", tt.args, tt.defaultLong, i, gotArgs[i], tt.wantArgs[i]) + } + } + }) + } +} + +func TestLocalLsWithLongFlag(t *testing.T) { + initTestI18n(t) + + var stdout bytes.Buffer + var stderr bytes.Buffer + + dir := t.TempDir() + filePath := filepath.Join(dir, "sample.txt") + writeTestFile(t, filePath, []byte("hello world")) + + s := &Shell{ + cwd: "/test/remote/dir", + localCwd: dir, + stdout: &stdout, + stderr: &stderr, + } + + exit, err := s.dispatchCommand(context.Background(), "lls", []string{"-l", "sample.txt"}) + if exit { + t.Error("expected exit to be false") + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "sample.txt") || !strings.Contains(out, "-rw-") { + t.Fatalf("expected long listing output containing mode and sample.txt, got %q", out) + } +} From 3acdb37be2aac4d1bca3e4f7673aaa5295c21634 Mon Sep 17 00:00:00 2001 From: wuyue Date: Fri, 6 Nov 2026 12:43:42 +0800 Subject: [PATCH 3/7] fix(cmd): defer explicit interpreter command planning when node dialect is needed --- cmd/exec.go | 85 ++++++++++++++----------- cmd/exec_execution.go | 124 ++++++++++++++++++++++++++++++++++++- cmd/exec_execution_test.go | 102 +++++++++++++++++++++++++++++- cmd/ssh.go | 11 ++++ cmd/ssh_control.go | 46 +++++++++----- cmd/ssh_control_test.go | 11 ++++ cmd/ssh_execution.go | 29 +++++++++ cmd/ssh_execution_test.go | 34 ++++++++++ 8 files changed, 385 insertions(+), 57 deletions(-) diff --git a/cmd/exec.go b/cmd/exec.go index e0f9a545..17f5831e 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -541,13 +541,21 @@ func (o *ExecOptions) runInteractive( var execErr error if o.Sudo && o.execution.enabled() { - execution, optErr := o.cliExecutionConfig() + cliExec, optErr := o.cliExecutionConfig() + if optErr != nil { + return optErr + } + execution, optErr := ssh.ResolveExecution(cliExec, client.Execution()) if optErr != nil { return optErr } execErr = client.RunInteractiveWithSudoExecution(ctx, cmd, execution) - } else if o.commandPlan != nil { - execErr = client.RunInteractivePlan(ctx, *o.commandPlan) + } else if o.commandPlan != nil || o.execution.enabled() { + plan, planErr := o.resolveCommandPlan(cmd, client.Execution()) + if planErr != nil { + return fmt.Errorf("[%s] %w", task.host, planErr) + } + execErr = client.RunInteractivePlan(ctx, plan) } else if o.Sudo { execErr = client.RunInteractiveWithSudo(ctx, cmd) } else { @@ -667,9 +675,15 @@ func (o *ExecOptions) getOrCreateNode(ctx context.Context, repository *config.Re return res.NodeID, updated, nil } +func (o *ExecOptions) isPlannedCommandMismatch(execCmd string, isScript bool) bool { + return isScript || + (o.commandPlan != nil && o.commandPlan.Command() != execCmd) || + (o.commandPlan == nil && o.Command != execCmd) +} + func (o *ExecOptions) executeTask(ctx context.Context, connector *ssh.Connector, t execHostTask, execCmd string, isScript bool, totalTasks int, stdoutMu *sync.Mutex) (retErr error) { - if o.commandPlan != nil { - if isScript || o.commandPlan.Command() != execCmd { + if o.commandPlan != nil || (!isScript && o.execution.enabled()) { + if o.isPlannedCommandMismatch(execCmd, isScript) { return fmt.Errorf("[%s] command changed after execution planning", t.host) } if !o.Sudo && !o.Stream && o.OutDir == "" { @@ -726,35 +740,19 @@ func (o *ExecOptions) executeTask(ctx context.Context, connector *ssh.Connector, } func (o *ExecOptions) cliExecutionConfig() (*ssh.ExecutionConfig, error) { - if !o.execution.enabled() && !o.execution.noLoginSet { - return nil, nil - } - if o.execution.noLoginSet { - // --no-login keeps its legacy meaning of explicitly selecting Bash. - opts, err := o.execution.commandOptions(o.NoLoginShell) - if err != nil { - return nil, err - } - login := opts.Login == ssh.LoginEnabled - return &ssh.ExecutionConfig{Interpreter: opts.Interpreter, LaunchDialect: opts.LaunchDialect, Login: &login}, nil - } - if (o.execution.interpreterSet && o.execution.interpreter == "") || (o.execution.dialectSet && o.execution.dialect == "") { - return nil, fmt.Errorf("explicit interpreter and launch dialect must not be empty") - } - // Missing fields must stay missing: script shebangs and node configuration - // resolve them later. Selecting a dialect is not selecting server. - cliExec := &ssh.ExecutionConfig{} - if o.execution.interpreterSet { - cliExec.Interpreter = ssh.Interpreter(o.execution.interpreter) - } - if o.execution.dialectSet { - cliExec.LaunchDialect = ssh.LaunchDialect(o.execution.dialect) + return o.execution.toExecutionConfig(o.NoLoginShell) +} + +func usesWindowsCommandPlanAdapter(execCfg *ssh.ExecutionConfig) bool { + if execCfg == nil { + return false } - if o.execution.loginSet { - login := o.execution.login - cliExec.Login = &login + switch execCfg.Interpreter { + case ssh.InterpreterPowerShell, ssh.InterpreterPwsh, ssh.InterpreterCmd: + return true + default: + return false } - return cliExec, cliExec.Validate() } func (o *ExecOptions) dispatchTaskExecution(ctx context.Context, client *ssh.Client, execCmd string, isScript bool, outputOpts []ssh.RunOption) (string, error) { @@ -777,19 +775,36 @@ func (o *ExecOptions) dispatchTaskExecution(ctx context.Context, client *ssh.Cli return client.RunScriptWithExecution(ctx, execCmd, resolvedExec, scriptOpts...) } - if o.commandPlan != nil { + if o.commandPlan != nil || o.execution.enabled() { if o.Sudo { - execution, err := o.cliExecutionConfig() + cliExec, err := o.cliExecutionConfig() + if err != nil { + return "", err + } + execution, err := ssh.ResolveExecution(cliExec, client.Execution()) if err != nil { return "", err } return client.RunWithSudoExecution(ctx, execCmd, execution, outputOpts...) } + plan, err := o.resolveCommandPlan(execCmd, client.Execution()) + if err != nil { + return "", err + } // The interpreter has already been applied by PlanCommand. Send its // frozen payload unchanged through the existing output-mode adapters. opts := append([]ssh.RunOption{}, outputOpts...) opts = append(opts, ssh.WithInterpreter(ssh.InterpreterServer), ssh.WithLaunchDialect(ssh.LaunchUnknown)) - return client.Run(ctx, o.commandPlan.Payload(), opts...) + return client.Run(ctx, plan.Payload(), opts...) + } + if !o.Sudo && !o.execution.noLoginSet && !o.NoLoginShell && usesWindowsCommandPlanAdapter(client.Execution()) { + plan, err := ssh.PlanCommand(execCmd, client.Execution().CommandOptions()) + if err != nil { + return "", fmt.Errorf("plan command: %w", err) + } + opts := append([]ssh.RunOption{}, outputOpts...) + opts = append(opts, ssh.WithInterpreter(ssh.InterpreterServer), ssh.WithLaunchDialect(ssh.LaunchUnknown)) + return client.Run(ctx, plan.Payload(), opts...) } cmdOpts := o.commandRunOptions(client.Execution(), outputOpts) if o.Sudo { diff --git a/cmd/exec_execution.go b/cmd/exec_execution.go index 2c162082..cfa397bc 100644 --- a/cmd/exec_execution.go +++ b/cmd/exec_execution.go @@ -37,6 +37,70 @@ func (o *execExecutionOptions) capturePresence(cmd *cobra.Command) { func (o execExecutionOptions) enabled() bool { return o.interpreterSet || o.dialectSet || o.loginSet } +func (o execExecutionOptions) needsNodeResolution() bool { + return o.interpreterSet && !o.dialectSet && o.interpreter != "" && o.interpreter != string(ssh.InterpreterServer) && !o.noLoginSet +} + +func (o execExecutionOptions) toExecutionConfig(noLogin bool) (*ssh.ExecutionConfig, error) { + if !o.enabled() && !o.noLoginSet { + return nil, nil + } + if o.noLoginSet { + opts, err := o.commandOptions(noLogin) + if err != nil { + return nil, err + } + login := opts.Login == ssh.LoginEnabled + return &ssh.ExecutionConfig{Interpreter: opts.Interpreter, LaunchDialect: opts.LaunchDialect, Login: &login}, nil + } + if (o.interpreterSet && o.interpreter == "") || (o.dialectSet && o.dialect == "") { + return nil, fmt.Errorf("explicit interpreter and launch dialect must not be empty") + } + cliExec := &ssh.ExecutionConfig{} + if o.interpreterSet { + cliExec.Interpreter = ssh.Interpreter(o.interpreter) + } + if o.dialectSet { + cliExec.LaunchDialect = ssh.LaunchDialect(o.dialect) + } + if o.loginSet { + login := o.login + cliExec.Login = &login + } + return cliExec, cliExec.Validate() +} + +func probeDialectForInterpreter(interp ssh.Interpreter) ssh.LaunchDialect { + switch interp { + case ssh.InterpreterBash, ssh.InterpreterSh, ssh.InterpreterZsh: + return ssh.LaunchPOSIX + case ssh.InterpreterFish: + return ssh.LaunchFish + case ssh.InterpreterCsh, ssh.InterpreterTcsh: + return ssh.LaunchCsh + case ssh.InterpreterPowerShell, ssh.InterpreterPwsh, ssh.InterpreterCmd: + return ssh.LaunchCmd + default: + return "" + } +} + +func validateDeferredCommandOptions(command string, options ssh.CommandOptions, sudo bool, cliExec *ssh.ExecutionConfig) error { + probeOpts := options + if probeDialect := probeDialectForInterpreter(options.Interpreter); probeDialect != "" { + probeOpts.LaunchDialect = probeDialect + } + if sudo && cliExec != nil { + probeExec := cliExec.Clone() + probeExec.LaunchDialect = probeOpts.LaunchDialect + if _, err := probeExec.SudoRunOptions(command); err != nil { + return err + } + } + _, err := ssh.PlanCommand(command, probeOpts) + return err +} + func isSupportedScriptInterpreter(interp ssh.Interpreter) bool { switch interp { case "", ssh.InterpreterBash, ssh.InterpreterSh, ssh.InterpreterZsh, ssh.InterpreterFish, ssh.InterpreterCsh, ssh.InterpreterTcsh: @@ -46,7 +110,7 @@ func isSupportedScriptInterpreter(interp ssh.Interpreter) bool { } } -func (o *ExecOptions) prepareCommandPlan() error { +func (o *ExecOptions) validateScriptArgs() error { if len(o.ScriptArgs) > 0 && o.ShellFile == "" { return fmt.Errorf("script arguments can only be specified when running a script with --shell") } @@ -55,6 +119,31 @@ func (o *ExecOptions) prepareCommandPlan() error { return fmt.Errorf("script arguments must not contain NUL bytes") } } + return nil +} + +func (o *ExecOptions) prepareDeferredCommandPlan(command string, options ssh.CommandOptions) error { + var cliExec *ssh.ExecutionConfig + if o.Sudo { + var err error + cliExec, err = o.cliExecutionConfig() + if err != nil { + return err + } + } + if err := validateDeferredCommandOptions(command, options, o.Sudo, cliExec); err != nil { + if o.Command == "" || o.Sudo { + return err + } + return fmt.Errorf("plan explicit command: %w", err) + } + return nil +} + +func (o *ExecOptions) prepareCommandPlan() error { + if err := o.validateScriptArgs(); err != nil { + return err + } if !o.execution.enabled() { return nil } @@ -80,6 +169,9 @@ func (o *ExecOptions) prepareCommandPlan() error { if command == "" { command = ":" // Validate options before consuming a possible stdin script. } + if o.execution.needsNodeResolution() { + return o.prepareDeferredCommandPlan(command, options) + } if o.Sudo { execution, err := o.cliExecutionConfig() if err != nil { @@ -101,6 +193,28 @@ func (o *ExecOptions) prepareCommandPlan() error { return nil } +func (o *ExecOptions) resolveCommandPlan(command string, nodeExec *ssh.ExecutionConfig) (ssh.CommandPlan, error) { + if o.commandPlan != nil { + if o.commandPlan.Command() != command { + return ssh.CommandPlan{}, fmt.Errorf("command changed after execution planning") + } + return *o.commandPlan, nil + } + cliExec, err := o.cliExecutionConfig() + if err != nil { + return ssh.CommandPlan{}, err + } + resolved, err := ssh.ResolveExecution(cliExec, nodeExec) + if err != nil { + return ssh.CommandPlan{}, fmt.Errorf("plan explicit command: %w", err) + } + plan, err := ssh.PlanCommand(command, resolved.CommandOptions()) + if err != nil { + return ssh.CommandPlan{}, fmt.Errorf("plan explicit command: %w", err) + } + return plan, nil +} + func (o execExecutionOptions) commandOptions(noLogin bool) (ssh.CommandOptions, error) { if (o.interpreterSet && o.interpreter == "") || (o.dialectSet && o.dialect == "") { return ssh.CommandOptions{}, fmt.Errorf("explicit interpreter and launch dialect must not be empty") @@ -140,14 +254,18 @@ func (o execExecutionOptions) commandOptions(noLogin bool) (ssh.CommandOptions, } func (o *ExecOptions) executePlannedTask(ctx context.Context, connector *ssh.Connector, task execHostTask, command string, isScript bool, stdoutMu *sync.Mutex) error { - if isScript || o.commandPlan.Command() != command { + if isScript || (o.commandPlan != nil && o.commandPlan.Command() != command) || (o.commandPlan == nil && o.Command != command) { return fmt.Errorf("[%s] command changed after execution planning", task.host) } client, err := connector.Connect(ctx, task.nodeID) if err != nil { return fmt.Errorf("[%s] connect failed: %w", task.host, err) } - result := client.ExecuteCommand(ctx, *o.commandPlan) + plan, err := o.resolveCommandPlan(command, client.Execution()) + if err != nil { + return fmt.Errorf("[%s] %w", task.host, err) + } + result := client.ExecuteCommand(ctx, plan) execErr := result.Err() printErr := o.printTaskResult(ctx, task, result.Output, execErr, stdoutMu) if execErr != nil { diff --git a/cmd/exec_execution_test.go b/cmd/exec_execution_test.go index 39fc5e38..037688ed 100644 --- a/cmd/exec_execution_test.go +++ b/cmd/exec_execution_test.go @@ -59,6 +59,12 @@ func TestExecExplicitExecutionOptions(t *testing.T) { t.Fatal(err) } err := o.Complete(command, command.Flags().Args()) + if err == nil { + err = o.Validate() + } + if err == nil && o.commandPlan == nil { + _, err = o.resolveCommandPlan(o.Command, nil) + } if test.wantErr != "" { if err == nil || !strings.Contains(err.Error(), test.wantErr) { t.Fatalf("error %v, want %q", err, test.wantErr) @@ -68,9 +74,6 @@ func TestExecExplicitExecutionOptions(t *testing.T) { if err != nil { t.Fatal(err) } - if err := o.Validate(); err != nil { - t.Fatal(err) - } if o.commandPlan == nil || o.commandPlan.Payload() != test.payload { t.Fatalf("unexpected plan: %+v", o.commandPlan) } @@ -360,3 +363,96 @@ func TestExecTaskLockedStreamCancellation(t *testing.T) { t.Fatalf("command executed %d times", server.Executed.Load()) } } + +func TestExecExplicitInterpreterInheritsNodeLaunchDialect(t *testing.T) { + if err := i18n.Init("en"); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + t.Cleanup(cancel) + + var gotPayload string + server, err := sshfixture.NewWithOptions(ctx, sshfixture.Options{ + ExecHandler: func(cmd string, ch cryptoSSH.Channel) { + gotPayload = cmd + _, _ = ch.Write([]byte("P2_EXEC_WIN_OK\n")) + _, _ = ch.SendRequest("exit-status", false, cryptoSSH.Marshal(struct{ Status uint32 }{0})) + }, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = server.Close() }) + + address, portText, err := net.SplitHostPort(server.Address) + if err != nil { + t.Fatal(err) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatal(err) + } + + // Node configured with cmd interpreter and cmd launch_dialect (like win-test-2225) + connector := ssh.NewConnector(tunnelTestProvider{cfg: &ssh.ClientConfig{ + NodeID: "win-test-2225", + Address: address, + Port: port, + User: "test", + AuthType: "password", + Password: sshfixture.Password, + Execution: &ssh.ExecutionConfig{ + Interpreter: ssh.InterpreterCmd, + LaunchDialect: ssh.LaunchCmd, + }, + }}, ssh.WithHostKeyVerifier(execFixtureVerifier{server.HostKey})) + t.Cleanup(func() { _ = connector.CloseAll() }) + + // 1. Explicit --interpreter powershell without --launch-dialect inherits LaunchCmd from node + o := NewExecOptions() + command := newCmdExecWithOptions(o) + if err := command.Flags().Parse([]string{"--host", "win-test-2225", "--interpreter", "powershell", "-c", "Write-Output P2_EXEC_WIN_OK"}); err != nil { + t.Fatal(err) + } + if err := o.Complete(command, nil); err != nil { + t.Fatalf("Complete failed: %v", err) + } + if err := o.Validate(); err != nil { + t.Fatalf("Validate failed: %v", err) + } + + var output bytes.Buffer + o.stdout, o.stderr = &output, &output + var mu sync.Mutex + task := execHostTask{nodeID: "win-test-2225", host: "win-test-2225"} + if err := o.executeTask(ctx, connector, task, o.Command, false, 1, &mu); err != nil { + t.Fatalf("executeTask with inherited launch_dialect failed: %v", err) + } + if !strings.Contains(gotPayload, "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ") { + t.Fatalf("expected encoded powershell payload, got %q", gotPayload) + } + if !strings.Contains(output.String(), "P2_EXEC_WIN_OK") { + t.Fatalf("unexpected output: %q", output.String()) + } + + // 2. Default node execution (cmd + LaunchCmd, no CLI flags) also executes via planned adapter + oDefault := NewExecOptions() + cmdDefault := newCmdExecWithOptions(oDefault) + if err := cmdDefault.Flags().Parse([]string{"--host", "win-test-2225", "-c", "echo P2_DEFAULT_CMD_OK"}); err != nil { + t.Fatal(err) + } + if err := oDefault.Complete(cmdDefault, nil); err != nil { + t.Fatal(err) + } + if err := oDefault.Validate(); err != nil { + t.Fatal(err) + } + output.Reset() + oDefault.stdout, oDefault.stderr = &output, &output + if err := oDefault.executeTask(ctx, connector, task, oDefault.Command, false, 1, &mu); err != nil { + t.Fatalf("executeTask with node default cmd interpreter failed: %v", err) + } + if !strings.Contains(gotPayload, "cmd.exe /d /s /c ") { + t.Fatalf("expected cmd.exe payload, got %q", gotPayload) + } +} diff --git a/cmd/ssh.go b/cmd/ssh.go index bce51084..3ca74b6f 100644 --- a/cmd/ssh.go +++ b/cmd/ssh.go @@ -1732,6 +1732,17 @@ func (o *SshOptions) runCommandWithStreams(ctx context.Context, client *ssh.Clie if o.commandPlan != nil { return client.RunCommandPlanWithIO(ctx, *o.commandPlan, stdin, stdout, stderr) } + if (o.execution.enabled() || o.execution.noLoginSet) && !o.Sudo && command != "" { + var execCfg *ssh.ExecutionConfig + if client != nil { + execCfg = client.Execution() + } + plan, err := o.resolveCommandPlan(command, execCfg) + if err != nil { + return err + } + return client.RunCommandPlanWithIO(ctx, plan, stdin, stdout, stderr) + } if !o.Sudo && o.Command == "" && o.stdinScript { if o.effectiveInterpreter(client) == ssh.InterpreterServer { return o.runPipedShellWithStreams(ctx, client, stdin, stdout, stderr) diff --git a/cmd/ssh_control.go b/cmd/ssh_control.go index 04630618..154b8df0 100644 --- a/cmd/ssh_control.go +++ b/cmd/ssh_control.go @@ -400,6 +400,9 @@ func executeProxy( } cfg, muxOpts := buildControlProxyClientConfig(target, provider, nodeID, o) + if err := o.prepareControlProxyCommandPlan(cfg.Execution); err != nil { + return controlError(streams.Stderr, err.Error()) + } client, err := ssh.DialControlProxyClient(ctx, socketPath, target, sshenv.Discover(), cfg, logger.DefaultLogger(), muxOpts...) if err != nil { @@ -419,24 +422,35 @@ func executeProxy( return nil } - var sessionErr error - if o.Command != "" || o.stdinScript { - sessionErr = o.runCommandWithStreams(ctx, client, o.Command, streams.Stdin, streams.Stdout, streams.Stderr) - } else { - stdinFile, _ := streams.Stdin.(*os.File) - ioStreams := ssh.InteractiveIO{ - Stdin: stdinFile, - Stdout: streams.Stdout, - Stderr: streams.Stderr, - } - if o.Sudo { - sessionErr = client.ShellWithSudoIO(ctx, ioStreams) - } else { - sessionErr = client.ShellWithIO(ctx, ioStreams) - } + return mapControlProxySessionError(streams.Stderr, o.runControlProxySession(ctx, client, streams)) +} + +func (o *SshOptions) prepareControlProxyCommandPlan(nodeExec *ssh.ExecutionConfig) error { + if o.NoCmd || o.Sudo || o.Command == "" || o.commandPlan != nil || (!o.execution.enabled() && !o.execution.noLoginSet) { + return nil } + plan, err := o.resolveCommandPlan(o.Command, nodeExec) + if err != nil { + return err + } + o.commandPlan = &plan + return nil +} - return mapControlProxySessionError(streams.Stderr, sessionErr) +func (o *SshOptions) runControlProxySession(ctx context.Context, client *ssh.Client, streams controlStreams) error { + if o.Command != "" || o.stdinScript { + return o.runCommandWithStreams(ctx, client, o.Command, streams.Stdin, streams.Stdout, streams.Stderr) + } + stdinFile, _ := streams.Stdin.(*os.File) + ioStreams := ssh.InteractiveIO{ + Stdin: stdinFile, + Stdout: streams.Stdout, + Stderr: streams.Stderr, + } + if o.Sudo { + return client.ShellWithSudoIO(ctx, ioStreams) + } + return client.ShellWithIO(ctx, ioStreams) } func buildControlProxyClientConfig( diff --git a/cmd/ssh_control_test.go b/cmd/ssh_control_test.go index 00fb0b77..28b166a8 100644 --- a/cmd/ssh_control_test.go +++ b/cmd/ssh_control_test.go @@ -826,6 +826,17 @@ func TestControl_Proxy_ExecutesSessionAndPropagatesExitCode(t *testing.T) { } } +func TestControl_Proxy_DeferredInterpreterValidationBeforeDial(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + missingSock := filepath.Join(t.TempDir(), "never_dialed.sock") + _, stderr, err := runControlProxyTestCommand(ctx, t, []string{"-O", "proxy", "-S", missingSock, "--interpreter", "powershell", "fixture@127.0.0.1:22", "--", "Get-Date"}) + if code, ok := ExitCodeFromError(err); !ok || code != 255 || !strings.Contains(stderr, `powershell interpreter requires cmd or powershell launch dialect`) { + t.Fatalf("expected deferred interpreter error before socket dial (255), got code=%d ok=%v err=%v stderr=%q", code, ok, err, stderr) + } +} + func TestProgrammaticGeneralOptions_AppliedWithoutComplete(t *testing.T) { // 1. Programmatic SshOptions (without Complete) applies -o Port/User/IdentityFile/ProxyJump, // while explicit struct fields take precedence over -o options. diff --git a/cmd/ssh_execution.go b/cmd/ssh_execution.go index f0fac4d7..5027855d 100644 --- a/cmd/ssh_execution.go +++ b/cmd/ssh_execution.go @@ -23,6 +23,13 @@ func (o *SshOptions) prepareCommandPlan() error { if err != nil { return err } + if o.execution.needsNodeResolution() { + if err := validateDeferredCommandOptions(o.Command, options, false, nil); err != nil { + return fmt.Errorf("plan explicit command: %w", err) + } + o.commandPlan = nil + return nil + } plan, err := ssh.PlanCommand(o.Command, options) if err != nil { return fmt.Errorf("plan explicit command: %w", err) @@ -31,6 +38,28 @@ func (o *SshOptions) prepareCommandPlan() error { return nil } +func (o *SshOptions) resolveCommandPlan(command string, nodeExec *ssh.ExecutionConfig) (ssh.CommandPlan, error) { + if o.commandPlan != nil { + if o.commandPlan.Command() != command { + return ssh.CommandPlan{}, fmt.Errorf("command changed after execution planning") + } + return *o.commandPlan, nil + } + cliExec, err := o.execution.toExecutionConfig(o.NoLoginShell) + if err != nil { + return ssh.CommandPlan{}, err + } + resolved, err := ssh.ResolveExecution(cliExec, nodeExec) + if err != nil { + return ssh.CommandPlan{}, fmt.Errorf("plan explicit command: %w", err) + } + plan, err := ssh.PlanCommand(command, resolved.CommandOptions()) + if err != nil { + return ssh.CommandPlan{}, fmt.Errorf("plan explicit command: %w", err) + } + return plan, nil +} + func (o *SshOptions) validateEmptyCommandExecution() error { if (o.execution.interpreterSet && o.execution.interpreter == "") || (o.execution.dialectSet && o.execution.dialect == "") { return fmt.Errorf("explicit interpreter and launch dialect must not be empty") diff --git a/cmd/ssh_execution_test.go b/cmd/ssh_execution_test.go index cd6cf398..e9b43d5a 100644 --- a/cmd/ssh_execution_test.go +++ b/cmd/ssh_execution_test.go @@ -360,3 +360,37 @@ func TestSSHRunCommandPipedStdinPreservesLoginMode(t *testing.T) { t.Fatalf("expected LoginEnabled, got %v", clientBashLogin.Execution().LoginMode()) } } + +func TestSSHExplicitInterpreterInheritsNodeLaunchDialect(t *testing.T) { + o := NewSshOptions() + cmd := newCmdSshWithOptions(o) + if err := cmd.Flags().Parse([]string{"--interpreter", "powershell", "win-test-2225", "Write-Output", "P2_SSH_WIN_OK"}); err != nil { + t.Fatal(err) + } + if err := o.Complete(cmd, cmd.Flags().Args()); err != nil { + t.Fatalf("Complete failed: %v", err) + } + if err := o.Validate(); err != nil { + t.Fatalf("Validate failed: %v", err) + } + + // 1. Without node LaunchDialect, resolveCommandPlan fails with "requires cmd or powershell launch dialect" + if _, err := o.resolveCommandPlan(o.Command, nil); err == nil || !strings.Contains(err.Error(), "requires cmd or powershell launch dialect") { + t.Fatalf("expected missing launch dialect error for unconfigured node, got: %v", err) + } + + // 2. With node LaunchDialect=cmd, resolveCommandPlan inherits LaunchCmd and plans PowerShell payload + plan, err := o.resolveCommandPlan(o.Command, &ssh.ExecutionConfig{ + Interpreter: ssh.InterpreterCmd, + LaunchDialect: ssh.LaunchCmd, + }) + if err != nil { + t.Fatalf("resolveCommandPlan with node LaunchCmd failed: %v", err) + } + if plan.Interpreter() != ssh.InterpreterPowerShell { + t.Fatalf("expected InterpreterPowerShell, got %v", plan.Interpreter()) + } + if !strings.Contains(plan.Payload(), "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ") { + t.Fatalf("unexpected payload: %q", plan.Payload()) + } +} From 63357fe83d86f8fe69ec6433a7a4c41d06f1f65f Mon Sep 17 00:00:00 2001 From: wuyue Date: Sat, 7 Nov 2026 10:41:14 +0800 Subject: [PATCH 4/7] fix(ssh): close stdin pipe on file EOF and unwrap exit error status --- cmd/ssh_control.go | 15 +++++++- core/ssh/execute.go | 26 ++++++++------ core/ssh/execute_pipe_test.go | 66 +++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 11 deletions(-) diff --git a/cmd/ssh_control.go b/cmd/ssh_control.go index 154b8df0..18a39888 100644 --- a/cmd/ssh_control.go +++ b/cmd/ssh_control.go @@ -44,12 +44,25 @@ func (e *ControlExitError) ExitCode() int { return e.Code } -// ExitCodeFromError extracts the exit code if err implements ExitCoder. +// ExitCodeFromError extracts the exit code if err implements ExitCoder, +// or unwraps cryptoSSH.ExitError / ssh.CommandExitError. func ExitCodeFromError(err error) (int, bool) { var coder ExitCoder if errors.As(err, &coder) { return coder.ExitCode(), true } + var exitErr *cryptoSSH.ExitError + if errors.As(err, &exitErr) { + code := exitErr.ExitStatus() + if code == 0 { + code = 255 + } + return code, true + } + var cmdExitErr *ssh.CommandExitError + if errors.As(err, &cmdExitErr) && cmdExitErr.ExitCode != nil { + return int(*cmdExitErr.ExitCode), true + } return 0, false } diff --git a/core/ssh/execute.go b/core/ssh/execute.go index 4cd0e871..c2aa9bc7 100644 --- a/core/ssh/execute.go +++ b/core/ssh/execute.go @@ -932,20 +932,26 @@ func pipeCommandStdin(stdin io.Reader, dst io.WriteCloser, options ...inputPipel if err != nil { return nil, err } - return sync.OnceValue(func() error { - cancelErr := cancel() - // The session owner closes/interrupts its channel before finishing - // input, unblocking any SSH window write. Join that writer before - // CloseWrite: x/crypto's stdin Write and Close are not concurrent-safe. + closeDst := sync.OnceValue(func() error { + if closeErr := dst.Close(); closeErr != nil && !errors.Is(closeErr, os.ErrClosed) && !errors.Is(closeErr, io.EOF) { + return closeErr + } + return nil + }) + doneCh := make(chan error, 1) + go func() { copyErr := <-done if errors.Is(copyErr, io.EOF) || errors.Is(copyErr, os.ErrClosed) { copyErr = nil } - closeErr := dst.Close() - if errors.Is(closeErr, io.EOF) || errors.Is(closeErr, os.ErrClosed) { - closeErr = nil - } - return errors.Join(cancelErr, copyErr, closeErr) + // Once the input copy goroutine joins, it will not call dst.Write again. + // Close dst to send EOF to the remote session so stdin-reading processes can exit. + doneCh <- errors.Join(copyErr, closeDst()) + }() + return sync.OnceValue(func() error { + cancelErr := cancel() + copyErr := <-doneCh + return errors.Join(cancelErr, copyErr) }), nil } diff --git a/core/ssh/execute_pipe_test.go b/core/ssh/execute_pipe_test.go index a4b89301..beeca446 100644 --- a/core/ssh/execute_pipe_test.go +++ b/core/ssh/execute_pipe_test.go @@ -103,6 +103,72 @@ func TestPipeCommandStdin_CancelIdempotency(t *testing.T) { } } +type mockBufferingWriteCloser struct { + mu sync.Mutex + buf bytes.Buffer + closedCount atomic.Int32 +} + +func (m *mockBufferingWriteCloser) Write(p []byte) (n int, err error) { + m.mu.Lock() + defer m.mu.Unlock() + return m.buf.Write(p) +} + +func (m *mockBufferingWriteCloser) Close() error { + m.closedCount.Add(1) + return nil +} + +func (m *mockBufferingWriteCloser) String() string { + m.mu.Lock() + defer m.mu.Unlock() + return m.buf.String() +} + +// TestPipeCommandStdin_FileEOFClosesDestinationBeforeStop verifies that when an os.File +// input reaches EOF, the destination is closed immediately so streaming commands can +// finish before stopAndWait is called. +func TestPipeCommandStdin_FileEOFClosesDestinationBeforeStop(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe() failed: %v", err) + } + defer func() { _ = r.Close() }() + + payload := "streaming file content\n" + if _, err := w.WriteString(payload); err != nil { + _ = w.Close() + t.Fatalf("write pipe failed: %v", err) + } + _ = w.Close() // send EOF on input file + + writer := &mockBufferingWriteCloser{} + stopAndWait, err := pipeCommandStdin(r, writer) + if err != nil { + t.Fatalf("pipeCommandStdin failed: %v", err) + } + + // The copying goroutine should observe EOF and close writer promptly + deadline := time.Now().Add(2 * time.Second) + for writer.closedCount.Load() == 0 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if writer.closedCount.Load() != 1 { + t.Fatalf("expected writer to be closed on EOF before stopAndWait, got closedCount=%d", writer.closedCount.Load()) + } + if got := writer.String(); got != payload { + t.Fatalf("expected payload %q, got %q", payload, got) + } + + if err := stopAndWait(); err != nil { + t.Fatalf("stopAndWait failed: %v", err) + } + if writer.closedCount.Load() != 1 { + t.Fatalf("expected writer closed count to remain 1 after stopAndWait, got %d", writer.closedCount.Load()) + } +} + // TestRunCommandWithIO_RejectsUncancelableStdin verifies that an arbitrary // blocking reader cannot start a command and leak a copy goroutine. func TestRunCommandWithIO_RejectsUncancelableStdin(t *testing.T) { From d6122948c1021a611ac0aa1fb63062be2b7e525c Mon Sep 17 00:00:00 2001 From: wuyue Date: Sat, 7 Nov 2026 15:52:42 +0800 Subject: [PATCH 5/7] feat(ssh): support extensible ssh_options and 4-tier precedence hierarchy - Introduce core/ssh.Options registry supporting 19 OpenSSH client options across mux, transport, auth, and trust classes. - Replace narrow control schema with extensible ssh_options across global and node configurations, serializing as canonical snake_case with PascalCase input acceptance. - Enforce 4-tier precedence hierarchy (CLI -o > Node ssh_options > Global ssh_options > ~/.ssh/config) with field-by-field merging semantics. - Guard host trust security: never inherit trust-class options (StrictHostKeyChecking, UserKnownHostsFile) from ~/.ssh/config; disable mux and host trust overrides in MCP and verification contexts. - Support runtime authentication policies, IdentitiesOnly, PreferredAuthentications, node-scoped keepalives, ConnectTimeout, and BatchMode. - Isolate jump-hop multiplexing daemons from target-scoped session options. - Update documentation and comprehensive test suites across core, pkg, and cmd. --- cmd/ssh.go | 18 +- cmd/ssh_control.go | 70 +- cmd/ssh_control_adversarial_test.go | 8 +- cmd/ssh_control_guard_test.go | 77 +- cmd/ssh_control_resolution_test.go | 30 +- cmd/ssh_control_test.go | 83 ++ cmd/ssh_option_flag.go | 5 + cmd/ssh_option_flag_test.go | 9 + core/ssh/auth.go | 204 ++- core/ssh/client.go | 14 +- core/ssh/connector.go | 541 +++++-- core/ssh/connector_test.go | 2 +- core/ssh/credential_recovery.go | 8 +- core/ssh/host_algorithms.go | 31 +- core/ssh/key_source.go | 5 +- core/ssh/plan.go | 14 +- core/ssh/ssh_options.go | 1264 +++++++++++++++++ core/ssh/ssh_options_test.go | 1059 ++++++++++++++ core/ssh/types_api.go | 15 + .../openssh-multiplexing-design.md | 57 +- .../openssh-multiplexing-design.md | 57 +- docs/en/guide/hosts.md | 24 + docs/guide/hosts.md | 24 + internal/mcphost/connector.go | 6 +- internal/mcphost/host.go | 2 +- pkg/adapter/enrollment_probe.go | 2 +- pkg/adapter/ssh_adapter.go | 324 ++++- pkg/adapter/ssh_adapter_control_test.go | 172 ++- pkg/adapter/ssh_adapter_test.go | 119 ++ pkg/adapter/verification.go | 2 +- pkg/adapter/verification_test.go | 4 +- pkg/config/openssh.go | 120 +- pkg/config/openssh_import.go | 61 +- pkg/config/openssh_import_test.go | 73 +- pkg/config/openssh_test.go | 102 +- pkg/config/provider.go | 14 +- pkg/config/repository.go | 7 + pkg/config/schema_v2.go | 34 +- pkg/config/schema_v2_test.go | 163 +-- pkg/config/types.go | 3 +- pkg/config/validate.go | 84 +- pkg/models/types.go | 4 +- pkg/models/types_test.go | 63 +- pkg/playbook/adversarial_concurrency_test.go | 20 +- pkg/playbook/playbook_control_test.go | 20 +- 45 files changed, 4327 insertions(+), 691 deletions(-) create mode 100644 core/ssh/ssh_options.go create mode 100644 core/ssh/ssh_options_test.go diff --git a/cmd/ssh.go b/cmd/ssh.go index 3ca74b6f..b52d7bf5 100644 --- a/cmd/ssh.go +++ b/cmd/ssh.go @@ -567,13 +567,11 @@ func (o *SshOptions) buildAdapterOptions(nodeID string, cfg *config.Configuratio // disableSessionControlForParent stops a -f parent from claiming the control // socket that the detached child owns, including any ProxyJump hops. It sets // both global and node-level overrides so neither a jump hop nor a node-scoped -// -M/-S/-o override on the target can bind or attach to a control socket. +// -M/-S/-o override on the target can bind or attach to a control socket, while +// preserving non-multiplexing CLI -o options. func disableSessionControlForParent(nodeID string) adapter.Option { - disabledSharing := "none" - ctrl := adapter.SessionControlOptions{Path: &disabledSharing} return func(a *adapter.SSHAdapter) { - adapter.WithGlobalSessionControl(ctrl)(a) - adapter.WithSessionControlOverride(nodeID, ctrl)(a) + a.DisableSessionControlSharing(nodeID) } } @@ -866,11 +864,13 @@ func (o *SshOptions) muxDaemonArgs(nodeID string, expandedPath string, master *s args = append(args, "-J", jumpHost) } args = append(args, "--remember", "never") - for _, opt := range o.control.Options { - if master != nil && isTargetSpecificOption(opt) { - continue + if o.shouldPassIdentityFile(nodeID, master) { + for _, opt := range o.control.Options { + if master != nil && isTargetSpecificOption(opt) { + continue + } + args = append(args, "-o", opt) } - args = append(args, "-o", opt) } if master != nil { if ctrl := master.Target().Control; ctrl != nil && ctrl.Persist != "" { diff --git a/cmd/ssh_control.go b/cmd/ssh_control.go index 18a39888..b2ec69bd 100644 --- a/cmd/ssh_control.go +++ b/cmd/ssh_control.go @@ -475,29 +475,12 @@ func buildControlProxyClientConfig( var ( cfg *ssh.ClientConfig snapshot *config.Configuration + adp *adapter.SSHAdapter muxOpts []ssh.MuxClientOption ) if provider != nil { snapshot = provider.Snapshot() - sessionAuth := adapter.SessionAuth{ - Password: o.Password, - Passphrase: o.Passphrase, - KeyPath: utils.ToAbsolutePath(o.IdentityFile), - Remember: false, - OTP: o.OTP, - ProxyJump: o.JumpHost, - } - adpOpts := []adapter.Option{ - adapter.WithCredentialRecording(false), - adapter.WithGlobalSessionAuth(sessionAuth), - } - if nodeID != "" { - adpOpts = append(adpOpts, adapter.WithSessionAuthOverride(nodeID, sessionAuth)) - } - if reg, regErr := utils.GetCredentialRegistry(snapshot); regErr == nil && reg != nil { - adpOpts = append(adpOpts, adapter.WithCredentialSource(reg)) - } - adp := adapter.NewSSHAdapter(provider, adpOpts...) + adp = adapter.NewSSHAdapter(provider, buildControlProxyAdapterOptions(snapshot, nodeID, o)...) if nodeID != "" { if resolvedCfg, err := adp.GetConfig(nodeID); err == nil && resolvedCfg != nil { normalizeControlProxyResolvedConfig(resolvedCfg, target) @@ -527,6 +510,7 @@ func buildControlProxyClientConfig( SudoMode: ssh.SudoModeAuto, ProxyJump: target.JumpHost, Execution: resolvedExec, + Options: resolveControlProxyFallbackOptions(adp, target.Host, o), PasswordPromptPattern: globalPattern, } } @@ -546,6 +530,54 @@ func buildControlProxyClientConfig( return cfg, muxOpts } +func resolveControlProxyFallbackOptions(adp *adapter.SSHAdapter, host string, o *SshOptions) *ssh.Options { + var sessionCtrlOpts adapter.SessionControlOptions + if o != nil { + if parsed, err := o.control.ToSessionControlOptions(); err == nil { + sessionCtrlOpts = parsed + } + } + if adp != nil { + if opts, err := adp.ResolveSSHOptions(host, sessionCtrlOpts); err == nil { + return opts + } + } + if opts, err := ssh.EffectiveOptions(sessionCtrlOpts.ToSSHOptions()); err == nil && !opts.IsEmpty() { + return opts + } + return nil +} + +func buildControlProxyAdapterOptions(snapshot *config.Configuration, nodeID string, o *SshOptions) []adapter.Option { + sessionAuth := adapter.SessionAuth{ + Password: o.Password, + Passphrase: o.Passphrase, + KeyPath: utils.ToAbsolutePath(o.IdentityFile), + Remember: false, + OTP: o.OTP, + ProxyJump: o.JumpHost, + } + adpOpts := []adapter.Option{ + adapter.WithCredentialRecording(false), + adapter.WithGlobalSessionAuth(sessionAuth), + } + if nodeID != "" { + adpOpts = append(adpOpts, adapter.WithSessionAuthOverride(nodeID, sessionAuth)) + } + if o != nil { + if sessionCtrlOpts, ctrlErr := o.control.ToSessionControlOptions(); ctrlErr == nil && !sessionCtrlOpts.IsEmpty() { + adpOpts = append(adpOpts, adapter.WithGlobalSessionControl(sessionCtrlOpts)) + if nodeID != "" { + adpOpts = append(adpOpts, adapter.WithSessionControlOverride(nodeID, sessionCtrlOpts)) + } + } + } + if reg, regErr := utils.GetCredentialRegistry(snapshot); regErr == nil && reg != nil { + adpOpts = append(adpOpts, adapter.WithCredentialSource(reg)) + } + return adpOpts +} + func normalizeControlProxyResolvedConfig(resolvedCfg *ssh.ClientConfig, target ssh.TargetContext) { if resolvedCfg.Port <= 0 { resolvedCfg.Port = target.Port diff --git a/cmd/ssh_control_adversarial_test.go b/cmd/ssh_control_adversarial_test.go index 2153a136..73c6f8ff 100644 --- a/cmd/ssh_control_adversarial_test.go +++ b/cmd/ssh_control_adversarial_test.go @@ -202,22 +202,22 @@ func TestAdversarial_ControlPathNone_DisablesSharing(t *testing.T) { Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), - Control: &ssh.ControlConfig{ + SSHOptions: ssh.OptionsFromControl(&ssh.ControlConfig{ Master: ssh.ControlMasterAuto, Path: "/tmp/global.sock", Persist: "1h", - }, + }), } cfg.Hosts.Set("host1", models.Host{Address: "192.168.1.100"}) cfg.Identities.Set("id1", models.Identity{User: "alice", AuthType: "auto"}) cfg.Nodes.Set("persistent-node", models.Node{ HostRef: "host1", IdentityRef: "id1", - Control: &ssh.ControlConfig{ + SSHOptions: ssh.OptionsFromControl(&ssh.ControlConfig{ Master: ssh.ControlMasterAuto, Path: "/tmp/lower-tier.sock", Persist: "10m", - }, + }), }) provider, err := config.NewProvider(cfg) diff --git a/cmd/ssh_control_guard_test.go b/cmd/ssh_control_guard_test.go index a058ae60..8906d313 100644 --- a/cmd/ssh_control_guard_test.go +++ b/cmd/ssh_control_guard_test.go @@ -61,14 +61,14 @@ func TestParentDaemonDisablesNodeControlSharing(t *testing.T) { Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), - Control: &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "/tmp/global.sock", Persist: "1h"}, + SSHOptions: ssh.OptionsFromControl(&ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "/tmp/global.sock", Persist: "1h"}), } cfg.Hosts.Set("host-1", models.Host{Address: "192.0.2.1", Port: 22}) cfg.Identities.Set("id-1", models.Identity{User: "ops", AuthType: "auto"}) cfg.Nodes.Set("web-01", models.Node{ HostRef: "host-1", IdentityRef: "id-1", - Control: &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "/tmp/node.sock", Persist: "10m"}, + SSHOptions: ssh.OptionsFromControl(&ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "/tmp/node.sock", Persist: "10m"}), }) provider := config.NewProviderWithoutOpenSSH(cfg) @@ -104,7 +104,7 @@ func TestParentDaemonDisablesNodeControlSharing(t *testing.T) { cfg.Nodes.Set("jump-01", models.Node{ HostRef: "host-1", IdentityRef: "id-1", - Control: &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "/tmp/jump.sock", Persist: "10m"}, + SSHOptions: ssh.OptionsFromControl(&ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "/tmp/jump.sock", Persist: "10m"}), }) jumpDisabled, err := adapter.NewSSHAdapter(provider, adpOpts...).GetControlConfig("jump-01") if err != nil { @@ -211,11 +211,11 @@ func TestDrainOwnedMastersWaitsForSecondarySession(t *testing.T) { cfg.Nodes.Set("web-01", models.Node{ HostRef: "h1", IdentityRef: "id1", - Control: &ssh.ControlConfig{ + SSHOptions: ssh.OptionsFromControl(&ssh.ControlConfig{ Master: ssh.ControlMasterAuto, Path: sockPath, Persist: "10m", - }, + }), }) provider := config.NewProviderWithoutOpenSSH(cfg) connector := adapter.NewConnector(provider, ssh.WithHostKeyVerifier(staticHostKeyTrust{})) @@ -314,11 +314,11 @@ func TestReleaseMastersForHandoffRestoresSessionOnFailure(t *testing.T) { cfg.Nodes.Set("web-01", models.Node{ HostRef: "h1", IdentityRef: "id1", - Control: &ssh.ControlConfig{ + SSHOptions: ssh.OptionsFromControl(&ssh.ControlConfig{ Master: ssh.ControlMasterAuto, Path: sockPath, Persist: "1s", - }, + }), }) provider := config.NewProviderWithoutOpenSSH(cfg) connector := adapter.NewConnector(provider, ssh.WithHostKeyVerifier(staticHostKeyTrust{})) @@ -497,12 +497,12 @@ func TestMultiHostHandoff_PreservesAllTargetMasters(t *testing.T) { cfg.Nodes.Set("nodeA", models.Node{ HostRef: "hA", IdentityRef: "id1", - Control: &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: sockA, Persist: "10m"}, + SSHOptions: ssh.OptionsFromControl(&ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: sockA, Persist: "10m"}), }) cfg.Nodes.Set("nodeB", models.Node{ HostRef: "hB", IdentityRef: "id1", - Control: &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: sockB, Persist: "10m"}, + SSHOptions: ssh.OptionsFromControl(&ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: sockB, Persist: "10m"}), }) provider := config.NewProviderWithoutOpenSSH(cfg) @@ -786,7 +786,16 @@ func TestMuxDaemonArgsIsolationAndRememberSuppression(t *testing.T) { opts.User = "targetuser" opts.IdentityFile = "/tmp/target-only-id" opts.JumpHost = "bastion" - opts.control.Options = []string{"ProxyJump=bastion", "Port=2222", "User=targetuser", "IdentityFile=/tmp/target-only-id", "ControlMaster=auto"} + opts.control.Options = []string{ + "ProxyJump=bastion", + "Port=2222", + "User=targetuser", + "IdentityFile=/tmp/target-only-id", + "ControlMaster=auto", + "StrictHostKeyChecking=no", + "PubkeyAuthentication=no", + "UserKnownHostsFile=/tmp/target_kh", + } args := opts.muxDaemonArgs("bastion", sockPath, jumpMaster) joined := strings.Join(args, " ") @@ -796,8 +805,24 @@ func TestMuxDaemonArgsIsolationAndRememberSuppression(t *testing.T) { if !strings.Contains(joined, "-p "+strconv.Itoa(port)) || !strings.Contains(joined, "-l jumpuser") { t.Fatalf("expected jumpMaster's port %d and user jumpuser, got %q", port, joined) } - if strings.Contains(joined, "-J ") || strings.Contains(joined, "ProxyJump=") || strings.Contains(joined, "2222") || strings.Contains(joined, "targetuser") || strings.Contains(joined, "target-only-id") || strings.Contains(joined, "-i ") { - t.Fatalf("expected target-specific JumpHost/Port/User/IdentityFile options to be stripped for jumpMaster, got %q", joined) + for _, forbidden := range []string{ + "-J ", + "ProxyJump=", + "2222", + "targetuser", + "target-only-id", + "-i ", + "StrictHostKeyChecking=no", + "PubkeyAuthentication=no", + "UserKnownHostsFile=", + "ControlMaster=auto", + } { + if strings.Contains(joined, forbidden) { + t.Fatalf("expected target-scoped option %q to be stripped for jumpMaster, got %q", forbidden, joined) + } + } + if !strings.Contains(joined, "-o ControlPersist=5m") || !strings.Contains(joined, "-o ControlMaster=yes") { + t.Fatalf("expected jumpMaster's own ControlPersist=5m and ControlMaster=yes, got %q", joined) } // Verify resolveNode in mux daemon child mode never persists an unconfirmed target. @@ -1014,21 +1039,21 @@ func TestHandoffConnectorMastersDoesNotSkipPersistentJumpForNonPersistentTarget( cfg.Nodes.Set("bastion", models.Node{ HostRef: "h-jump", IdentityRef: "id1", - Control: &ssh.ControlConfig{ + SSHOptions: ssh.OptionsFromControl(&ssh.ControlConfig{ Master: ssh.ControlMasterAuto, Path: jumpSock, Persist: "5m", - }, + }), }) cfg.Nodes.Set("app", models.Node{ HostRef: "h-target", IdentityRef: "id1", ProxyJump: "bastion", - Control: &ssh.ControlConfig{ + SSHOptions: ssh.OptionsFromControl(&ssh.ControlConfig{ Master: ssh.ControlMasterAuto, Path: targetSock, Persist: "no", - }, + }), }) provider := config.NewProviderWithoutOpenSSH(cfg) @@ -1096,21 +1121,21 @@ func TestMuxDaemonKeepsJumpMasterReferenceUntilTargetExits(t *testing.T) { cfg.Nodes.Set("bastion", models.Node{ HostRef: "h-jump", IdentityRef: "id1", - Control: &ssh.ControlConfig{ + SSHOptions: ssh.OptionsFromControl(&ssh.ControlConfig{ Master: ssh.ControlMasterAuto, Path: jumpSock, Persist: "1s", - }, + }), }) cfg.Nodes.Set("app", models.Node{ HostRef: "h-target", IdentityRef: "id1", ProxyJump: "bastion", - Control: &ssh.ControlConfig{ + SSHOptions: ssh.OptionsFromControl(&ssh.ControlConfig{ Master: ssh.ControlMasterAutoAsk, Path: targetSock, Persist: "5m", - }, + }), }) provider := config.NewProviderWithoutOpenSSH(cfg) @@ -2046,13 +2071,13 @@ Host app t.Fatalf("parse ssh_config: %v", err) } cfg := newEmptyTestConfig() - cfg.Control = &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "/tmp/global-%n", Persist: "30s"} + cfg.SSHOptions = ssh.OptionsFromControl(&ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "/tmp/global-%n", Persist: "30s"}) cfg.Hosts.Set("10.0.0.10:22", models.Host{Address: "10.0.0.10", Port: 22}) cfg.Identities.Set("ubuntu@10.0.0.10", models.Identity{User: "ubuntu", AuthType: "auto"}) cfg.Nodes.Set("prod", models.Node{ HostRef: "10.0.0.10:22", IdentityRef: "ubuntu@10.0.0.10", - Control: &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "/tmp/prod-tier2-%n", Persist: "30s"}, + SSHOptions: ssh.OptionsFromControl(&ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "/tmp/prod-tier2-%n", Persist: "30s"}), }) // Saved node from a previous `xops ssh -p 2222 app` where OpenSSHAlias was not serialized to YAML. cfg.Hosts.Set("10.0.0.20:2222", models.Host{Address: "10.0.0.20", Port: 2222}) @@ -2086,7 +2111,7 @@ Host app // R10-2 (Part 2): saved node ubuntu@10.0.0.20:2222 retains OpenSSHAlias="app" when resolved via selector "app". cfgNoGlobal := repo.Snapshot() - cfgNoGlobal.Control = nil + cfgNoGlobal.SSHOptions = nil store2 := config.NewDefaultStore(filepath.Join(t.TempDir(), "config2.yaml"), filepath.Join(t.TempDir(), "key2")) repoTier4, err := config.NewRepositoryWithOpenSSHParser(cfgNoGlobal, store2, parser) if err != nil { @@ -2359,7 +2384,7 @@ Host jump-alias t.Fatalf("parse ssh_config: %v", err) } baseCfg := newEmptyTestConfig() - baseCfg.Control = &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "/tmp/cm-global-%n-%r@%h:%p-%j", Persist: "30s"} + baseCfg.SSHOptions = ssh.OptionsFromControl(&ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "/tmp/cm-global-%n-%r@%h:%p-%j", Persist: "30s"}) baseCfg.Hosts.Set("10.0.0.10:22", models.Host{Address: "10.0.0.10", Port: 22, Alias: []string{"host-alias"}}) baseCfg.Identities.Set("root@10.0.0.10", models.Identity{User: "root", AuthType: "auto", KeyPath: "/tmp/root_key"}) baseCfg.Nodes.Set("web-node", models.Node{ @@ -2367,7 +2392,7 @@ Host jump-alias IdentityRef: "root@10.0.0.10", Alias: []string{"web"}, ProxyJump: "openssh:jump-alias", - Control: &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "/tmp/cm-node-%n-%r@%h:%p-%j", Persist: "30s"}, + SSHOptions: ssh.OptionsFromControl(&ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "/tmp/cm-node-%n-%r@%h:%p-%j", Persist: "30s"}), }) targets := []config.ConnectionTarget{ @@ -2474,7 +2499,7 @@ Host gw cfg.Nodes.Set("gw-2222", models.Node{ HostRef: "10.0.0.10:2222", IdentityRef: "root@10.0.0.10", - Control: &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "/tmp/tier2-gw2222-%n", Persist: "30s"}, + SSHOptions: ssh.OptionsFromControl(&ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "/tmp/tier2-gw2222-%n", Persist: "30s"}), }) provider := config.NewProviderWithOpenSSHParser(cfg, parser) diff --git a/cmd/ssh_control_resolution_test.go b/cmd/ssh_control_resolution_test.go index 664e70f7..8a12e8f6 100644 --- a/cmd/ssh_control_resolution_test.go +++ b/cmd/ssh_control_resolution_test.go @@ -15,9 +15,9 @@ import ( ) const controlResolutionConfig = `schema_version: 2 -control: - master: auto - path: "%s" +ssh_options: + control_master: auto + control_path: "%s" nodes: web-01: host_ref: host-web-01 @@ -257,9 +257,9 @@ func TestControlCommandGlobalDefaultHonorsCLIOverrides(t *testing.T) { directory := t.TempDir() t.Setenv("XOPS_CONFIG_DIR", directory) content := fmt.Sprintf(`schema_version: 2 -control: - master: auto - path: "%s" +ssh_options: + control_master: auto + control_path: "%s" nodes: web-01: host_ref: host-web-01 @@ -415,9 +415,9 @@ func TestControlCommandDisambiguatesMultiPortHostAndDefaultLocalUser(t *testing. directory := t.TempDir() t.Setenv("XOPS_CONFIG_DIR", directory) content := `schema_version: 2 -control: - master: auto - path: "/tmp/cm-%r@%h-%p.sock" +ssh_options: + control_master: auto + control_path: "/tmp/cm-%r@%h-%p.sock" nodes: root@10.0.0.1:22: host_ref: h-22 @@ -493,16 +493,16 @@ nodes: host_ref: h-base identity_ref: id-root sudo_mode: none - control: - master: auto - path: "/tmp/base-%n.sock" + ssh_options: + control_master: auto + control_path: "/tmp/base-%n.sock" "test@10.0.0.5:2222": host_ref: h-base identity_ref: id-test sudo_mode: none - control: - master: auto - path: "/tmp/derived-%r@%h-%p.sock" + ssh_options: + control_master: auto + control_path: "/tmp/derived-%r@%h-%p.sock" hosts: h-base: address: 10.0.0.5 diff --git a/cmd/ssh_control_test.go b/cmd/ssh_control_test.go index 28b166a8..b3a2b558 100644 --- a/cmd/ssh_control_test.go +++ b/cmd/ssh_control_test.go @@ -18,6 +18,7 @@ import ( "github.com/wentf9/xops-cli/core/ssh" "github.com/wentf9/xops-cli/core/testutil/sshfixture" + "github.com/wentf9/xops-cli/pkg/adapter" "github.com/wentf9/xops-cli/pkg/config" "github.com/wentf9/xops-cli/pkg/models" cryptoSSH "golang.org/x/crypto/ssh" @@ -948,3 +949,85 @@ func TestBuildControlProxyClientConfig_NormalizesAndPropagatesJump(t *testing.T) t.Fatalf("expected expanded socket path '/tmp/mux-directopt@10.55.66.77:2288', got %q", sockPath) } } + +func TestDisableSessionControlForParentAndProxySSHOptions(t *testing.T) { + cfg := config.NewProviderWithoutOpenSSH(nil).Snapshot() + cfg.Hosts.Set("h-zero", models.Host{Address: "10.55.66.77", Port: 0}) + cfg.Identities.Set("id-empty", models.Identity{User: "", AuthType: "password", Password: "pw"}) + cfg.Nodes.Set("node-zero", models.Node{HostRef: "h-zero", IdentityRef: "id-empty"}) + provider := config.NewProviderWithoutOpenSSH(cfg) + + targetCtx := ssh.TargetContext{ + Host: "10.55.66.77", + Port: 22, + User: "fallbackuser", + JumpHost: "openssh:10.55.66.1:22", + } + + // Verify buildControlProxyClientConfig forwards Tier 1 -o SSHOptions to adp.GetConfig(nodeID) + proxyWithOpts := &SshOptions{ + JumpHost: "10.55.66.1:22", + control: SSHControlFlags{ + Options: []string{"ConnectTimeout=17", "StrictHostKeyChecking=no"}, + }, + } + clientCfgWithOpts, _ := buildControlProxyClientConfig(targetCtx, provider, "node-zero", proxyWithOpts) + if clientCfgWithOpts == nil || clientCfgWithOpts.Options == nil { + t.Fatalf("expected non-nil clientCfg.Options from buildControlProxyClientConfig, got %+v", clientCfgWithOpts) + } + if clientCfgWithOpts.Options.ConnectTimeout() != 17*time.Second || + clientCfgWithOpts.Options.StrictHostKeyChecking() != ssh.StrictHostKeyCheckingNo { + t.Fatalf("expected Tier 1 -o options in proxy clientCfg, got %+v", clientCfgWithOpts.Options) + } + + // Verify buildControlProxyClientConfig also resolves Options on the ad-hoc (nodeID == "") fallback branch + clientCfgAdHoc, _ := buildControlProxyClientConfig(targetCtx, provider, "", proxyWithOpts) + if clientCfgAdHoc == nil || clientCfgAdHoc.Options == nil || + clientCfgAdHoc.Options.ConnectTimeout() != 17*time.Second || + clientCfgAdHoc.Options.StrictHostKeyChecking() != ssh.StrictHostKeyCheckingNo { + t.Fatalf("expected Tier 1 -o options in ad-hoc proxy clientCfg, got %+v", clientCfgAdHoc) + } +} + +func TestDisableSessionControlForParentPreservesNonMuxOptions(t *testing.T) { + cfg := config.NewProviderWithoutOpenSSH(nil).Snapshot() + cfg.Hosts.Set("h-zero", models.Host{Address: "10.55.66.77", Port: 0}) + cfg.Identities.Set("id-empty", models.Identity{User: "", AuthType: "password", Password: "pw"}) + cfg.Nodes.Set("node-zero", models.Node{HostRef: "h-zero", IdentityRef: "id-empty"}) + + // Verify disableSessionControlForParent disables multiplexing while preserving Tier 1 -o SSHOptions + tmpCfgDir := t.TempDir() + store := config.NewDefaultStore(filepath.Join(tmpCfgDir, "config.yaml"), filepath.Join(tmpCfgDir, "key")) + repo, err := config.NewRepositoryWithoutOpenSSH(cfg, store) + if err != nil { + t.Fatalf("NewRepositoryWithoutOpenSSH failed: %v", err) + } + parentOpts := NewSshOptions() + parentOpts.Remember = "never" + parentOpts.control = SSHControlFlags{ + MasterCount: 1, + ControlPath: "/tmp/parent-should-not-bind.sock", + Options: []string{"ConnectTimeout=19", "StrictHostKeyChecking=no"}, + } + adpOpts, err := parentOpts.buildAdapterOptions("node-zero", cfg, repo) + if err != nil { + t.Fatalf("buildAdapterOptions failed: %v", err) + } + adpOpts = append(adpOpts, disableSessionControlForParent("node-zero")) + adp := adapter.NewSSHAdapter(repo, adpOpts...) + if ctrl, ctrlErr := adp.GetControlConfig("node-zero"); ctrlErr != nil || ctrl == nil || ctrl.Path != "none" || ctrl.Master != ssh.ControlMasterNo { + t.Fatalf("expected disabled control config (Path=none, Master=no) for -f parent, got ctrl=%+v err=%v", ctrl, ctrlErr) + } + parentClientCfg, err := adp.GetConfig("node-zero") + if err != nil { + t.Fatalf("adp.GetConfig(node-zero) failed: %v", err) + } + if parentClientCfg.Options == nil || + parentClientCfg.Options.ConnectTimeout() != 19*time.Second || + parentClientCfg.Options.StrictHostKeyChecking() != ssh.StrictHostKeyCheckingNo { + t.Fatalf("expected non-mux -o options preserved for -f parent, got %+v", parentClientCfg.Options) + } + if parentClientCfg.Options.ControlPath() != "none" || parentClientCfg.Options.ControlMaster() != ssh.ControlMasterNo { + t.Fatalf("expected ControlPath=none and ControlMaster=no in -f parent Options, got %+v", parentClientCfg.Options.ControlConfig()) + } +} diff --git a/cmd/ssh_option_flag.go b/cmd/ssh_option_flag.go index c97de45b..5e2c958b 100644 --- a/cmd/ssh_option_flag.go +++ b/cmd/ssh_option_flag.go @@ -236,6 +236,11 @@ func resolveControlConfig(provider config.ConfigProvider, nodeID, selector strin // mergeSessionControl converts session overrides into one ControlConfig layer. func mergeSessionControl(sessionOpts adapter.SessionControlOptions) *ssh.ControlConfig { + if opts := sessionOpts.ToSSHOptions(); opts != nil { + if ctrl := opts.ControlConfig(); ctrl != nil { + return ctrl + } + } ctrl := &ssh.ControlConfig{} if sessionOpts.Master != nil { ctrl.Master = *sessionOpts.Master diff --git a/cmd/ssh_option_flag_test.go b/cmd/ssh_option_flag_test.go index 22f31224..d22f7283 100644 --- a/cmd/ssh_option_flag_test.go +++ b/cmd/ssh_option_flag_test.go @@ -73,6 +73,10 @@ func TestOptionFlagParsing(t *testing.T) { "ControlMaster=auto", "ControlPath=/tmp/opt.sock", "ControlPersist=10m", + "ConnectTimeout=15", + "StrictHostKeyChecking=no", + "ServerAliveInterval=20s", + "TCPKeepAlive=no", }, } sessionOpts, err := flags.ToSessionControlOptions() @@ -88,6 +92,11 @@ func TestOptionFlagParsing(t *testing.T) { if sessionOpts.Persist == nil || *sessionOpts.Persist != "10m" { t.Fatalf("expected Persist 10m, got %v", sessionOpts.Persist) } + eff := sessionOpts.ToSSHOptions() + tcpKA, tcpKASet := eff.TCPKeepAlive() + if eff.ConnectTimeout() != 15*time.Second || eff.StrictHostKeyChecking() != ssh.StrictHostKeyCheckingNo || eff.ServerAliveInterval() != 20*time.Second || !tcpKASet || tcpKA { + t.Fatalf("unexpected effective options from -o flags: %+v", eff) + } // Invalid format flagsBadFormat := SSHControlFlags{Options: []string{"NoEqualsSign"}} diff --git a/core/ssh/auth.go b/core/ssh/auth.go index beed819b..64c5f215 100644 --- a/core/ssh/auth.go +++ b/core/ssh/auth.go @@ -11,6 +11,7 @@ import ( "os" "slices" "sync" + "sync/atomic" "time" logger "github.com/wentf9/xops-cli/core/log" @@ -434,6 +435,8 @@ type AutoAuthOptions struct { PasswordCallback func(string) PassphraseCallback func(keyPath, passphrase string) CandidateRejectedCallback func(protocol string) + SSHOptions *Options + HostKeyChanged *atomic.Bool Logger logger.DebugLogger Diagnostics logger.DiagnosticObserver } @@ -488,6 +491,14 @@ func authenticationDiagnostic(ctx *ssh.ClientAuthContext) logger.Diagnostic { // ClientConfig.Auth 只会尝试同一 RFC 4252 method 的第一个实例,因此 auto 模式必须通过 // AuthCallback 显式推进多个 publickey candidate。 func newSequentialAuthCallback(candidates []autoAuthCandidate, secProvider *autoSecretProvider, l logger.DebugLogger, observers ...logger.DiagnosticObserver) ssh.ClientAuthCallback { + return newSequentialAuthCallbackWithPolicy(candidates, authPolicy{ + pubkeyAllowed: true, + passwordAllowed: true, + kbAllowed: true, + }, secProvider, l, observers...) +} + +func newSequentialAuthCallbackWithPolicy(candidates []autoAuthCandidate, policy authPolicy, secProvider *autoSecretProvider, l logger.DebugLogger, observers ...logger.DiagnosticObserver) ssh.ClientAuthCallback { if l == nil { l = logger.NopLogger } @@ -496,6 +507,7 @@ func newSequentialAuthCallback(candidates []autoAuthCandidate, secProvider *auto lastStage := 0 selected := false negotiated := false + var blockedErr error var activeCandidate *autoAuthCandidate return func(ctx *ssh.ClientAuthContext) (ssh.AuthMethod, error) { var observer logger.DiagnosticObserver @@ -519,6 +531,7 @@ func newSequentialAuthCallback(candidates []autoAuthCandidate, secProvider *auto lastStage = stage next = 0 selected = false + blockedErr = nil activeCandidate = nil } else if activeCandidate != nil { if activeCandidate.onRejected != nil { @@ -535,6 +548,13 @@ func newSequentialAuthCallback(candidates []autoAuthCandidate, secProvider *auto l.Debugf("Skipping SSH auth candidate %q: server allows %v", candidate.label, ctx.AllowedMethods) continue } + if err := policy.checkProtocolRuntime(candidate.protocol); err != nil { + l.Debugf("Skipping SSH auth candidate %q due to runtime policy: %v", candidate.label, err) + if blockedErr == nil { + blockedErr = err + } + continue + } l.Debugf("Trying SSH auth candidate: %s", candidate.label) selected = true @@ -545,6 +565,9 @@ func newSequentialAuthCallback(candidates []autoAuthCandidate, secProvider *auto } if !selected { + if blockedErr != nil { + return nil, blockedErr + } return nil, fmt.Errorf("server permits SSH authentication methods %v, but no matching local key, agent, password or keyboard-interactive method is available", ctx.AllowedMethods) } return nil, nil @@ -588,11 +611,52 @@ func buildAutoAuthPlan(ctx context.Context, opts AutoAuthOptions, first ...ssh.A } } - var candidates []autoAuthCandidate - for _, method := range first { - candidates = append(candidates, autoAuthCandidate{protocol: autoAuthProtocolPublicKey, label: "explicit signer", method: method, onRejected: rejectPublicKey}) + policy := newAuthPolicy(opts.SSHOptions, opts.HostKeyChanged) + + var pubkeyCandidates []autoAuthCandidate + var cleanup func() + if policy.pubkeyAllowed { + pubkeyCandidates, cleanup = buildAutoPubkeyCandidates(ctx, opts, first, secProvider, rejectPublicKey, policy.identitiesOnly, l) + } + + var passwordCandidates []autoAuthCandidate + if policy.passwordAllowed { + passwordCandidates = buildAutoPasswordCandidates(opts, secProvider) } + + var kbCandidates []autoAuthCandidate + if policy.kbAllowed { + kbMethod := buildKeyboardInteractiveAuthMethod(secProvider) + kbCandidates = append(kbCandidates, autoAuthCandidate{ + protocol: autoAuthProtocolKeyboardInteractive, + label: "keyboard-interactive", + method: kbMethod, + }) + } + + candidates := orderAutoAuthCandidates(policy.preferred, pubkeyCandidates, passwordCandidates, kbCandidates) + + return autoAuthPlan{ + candidates: candidates, + authCallback: newSequentialAuthCallbackWithPolicy(candidates, policy, secProvider, l, opts.Diagnostics), + cleanup: cleanup, + } +} + +func buildAutoPubkeyCandidates( + ctx context.Context, + opts AutoAuthOptions, + first []ssh.AuthMethod, + secProvider *autoSecretProvider, + rejectPublicKey func(), + identitiesOnly bool, + l logger.DebugLogger, +) ([]autoAuthCandidate, func()) { + var pubkeyCandidates []autoAuthCandidate var cleanup func() + for _, method := range first { + pubkeyCandidates = append(pubkeyCandidates, autoAuthCandidate{protocol: autoAuthProtocolPublicKey, label: "explicit signer", method: method, onRejected: rejectPublicKey}) + } seenKeyPaths := make(map[string]struct{}) addKey := func(path, label string) { @@ -621,7 +685,7 @@ func buildAutoAuthPlan(ctx context.Context, opts AutoAuthOptions, first ...ssh.A return } - candidates = append(candidates, autoAuthCandidate{ + pubkeyCandidates = append(pubkeyCandidates, autoAuthCandidate{ protocol: autoAuthProtocolPublicKey, label: label + ": " + keyPath, method: method, @@ -629,30 +693,38 @@ func buildAutoAuthPlan(ctx context.Context, opts AutoAuthOptions, first ...ssh.A }) } + hasExplicitIdentity := len(first) > 0 || opts.KeyPath != "" // 显式身份必须优先于任何自动发现来源。 if opts.KeyPath != "" { addKey(opts.KeyPath, "explicit key") } - if socket := opts.AgentSocket; socket != "" { - conn, err := dialSSHAgent(ctx, socket) - if err == nil { - agentClient := agent.NewClient(conn) - candidates = append(candidates, autoAuthCandidate{ - protocol: autoAuthProtocolPublicKey, - label: "ssh-agent", - method: ssh.PublicKeysCallback(agentClient.Signers), - }) - cleanup = func() { debugCloseResource(l, conn, "ssh agent connection") } - } else { - l.Debugf("connect to ssh-agent failed: %v", err) + if !identitiesOnly { + if socket := opts.AgentSocket; socket != "" { + conn, err := dialSSHAgent(ctx, socket) + if err == nil { + agentClient := agent.NewClient(conn) + pubkeyCandidates = append(pubkeyCandidates, autoAuthCandidate{ + protocol: autoAuthProtocolPublicKey, + label: "ssh-agent", + method: ssh.PublicKeysCallback(agentClient.Signers), + }) + cleanup = func() { debugCloseResource(l, conn, "ssh agent connection") } + } else { + l.Debugf("connect to ssh-agent failed: %v", err) + } } } - for _, path := range opts.DefaultKeyPaths { - addKey(path, "default key") + if !identitiesOnly || !hasExplicitIdentity { + for _, path := range opts.DefaultKeyPaths { + addKey(path, "default key") + } } + return pubkeyCandidates, cleanup +} +func buildAutoPasswordCandidates(opts AutoAuthOptions, secProvider *autoSecretProvider) []autoAuthCandidate { passwordAttempts := 0 passwordMethod := ssh.RetryableAuthMethod(ssh.PasswordCallback(func() (string, error) { retry := passwordAttempts > 0 && opts.RecoveryPrompter != nil @@ -669,7 +741,7 @@ func buildAutoAuthPlan(ctx context.Context, opts AutoAuthOptions, first ...ssh.A return password, nil }), 3) - candidates = append(candidates, autoAuthCandidate{ + return []autoAuthCandidate{{ protocol: autoAuthProtocolPassword, label: "password", method: passwordMethod, @@ -678,20 +750,92 @@ func buildAutoAuthPlan(ctx context.Context, opts AutoAuthOptions, first ...ssh.A opts.CandidateRejectedCallback(autoAuthProtocolPassword) } }, - }) + }} +} - kbMethod := buildKeyboardInteractiveAuthMethod(secProvider) - candidates = append(candidates, autoAuthCandidate{ - protocol: autoAuthProtocolKeyboardInteractive, - label: "keyboard-interactive", - method: kbMethod, - }) +type authPolicy struct { + pubkeyAllowed bool + passwordAllowed bool + kbAllowed bool + identitiesOnly bool + preferred []string + hostKeyChanged *atomic.Bool +} - return autoAuthPlan{ - candidates: candidates, - authCallback: newSequentialAuthCallback(candidates, secProvider, l, opts.Diagnostics), - cleanup: cleanup, +func newAuthPolicy(sshOpts *Options, hostKeyChanged *atomic.Bool) authPolicy { + pubkeyAllowed, passwordAllowed, kbAllowed, identitiesOnly, preferred := resolveAutoAuthPolicy(sshOpts) + return authPolicy{ + pubkeyAllowed: pubkeyAllowed, + passwordAllowed: passwordAllowed, + kbAllowed: kbAllowed, + identitiesOnly: identitiesOnly, + preferred: preferred, + hostKeyChanged: hostKeyChanged, + } +} + +func (p authPolicy) checkProtocolRuntime(protocol string) error { + switch protocol { + case autoAuthProtocolPassword, autoAuthProtocolKeyboardInteractive: + if p.hostKeyChanged != nil && p.hostKeyChanged.Load() { + return fmt.Errorf("%w: %s authentication disabled because remote host key has changed", ErrHostKeyMismatch, protocol) + } + } + return nil +} + +func resolveAutoAuthPolicy(sshOpts *Options) (pubkeyAllowed, passwordAllowed, kbAllowed, identitiesOnly bool, preferred []string) { + pubkeyAllowed = true + passwordAllowed = true + kbAllowed = true + if sshOpts == nil { + return + } + if v, ok := sshOpts.PubkeyAuthentication(); ok { + pubkeyAllowed = v + } + if v, ok := sshOpts.PasswordAuthentication(); ok { + passwordAllowed = v + } + if v, ok := sshOpts.KbdInteractiveAuthentication(); ok { + kbAllowed = v + } + identitiesOnly = sshOpts.IdentitiesOnly() + preferred = sshOpts.PreferredAuthentications() + if len(preferred) > 0 { + if !slices.Contains(preferred, autoAuthProtocolPublicKey) { + pubkeyAllowed = false + } + if !slices.Contains(preferred, autoAuthProtocolPassword) { + passwordAllowed = false + } + if !slices.Contains(preferred, autoAuthProtocolKeyboardInteractive) { + kbAllowed = false + } + } + return +} + +func orderAutoAuthCandidates(preferred []string, pubkey, password, kb []autoAuthCandidate) []autoAuthCandidate { + if len(preferred) == 0 { + var all []autoAuthCandidate + all = append(all, pubkey...) + all = append(all, password...) + all = append(all, kb...) + return all + } + var ordered []autoAuthCandidate + for _, proto := range preferred { + switch proto { + case autoAuthProtocolPublicKey: + ordered = append(ordered, pubkey...) + case autoAuthProtocolPassword: + ordered = append(ordered, password...) + case autoAuthProtocolKeyboardInteractive: + ordered = append(ordered, kb...) + } } + return ordered } func (p *autoSecretProvider) decryptPrivateKey(data []byte, path string) (ssh.Signer, string, error) { diff --git a/core/ssh/client.go b/core/ssh/client.go index b3342dee..809ba66b 100644 --- a/core/ssh/client.go +++ b/core/ssh/client.go @@ -121,6 +121,14 @@ func newClientWithComponents( if cfg != nil { connCfg = cfg.ToConnectionConfig() } + if connCfg.Options != nil { + if connCfg.Options.BatchMode() { + prompter = rejectInteraction{} + } + if ct := connCfg.Options.ConnectTimeout(); ct > 0 { + handshakeTimeout = ct + } + } if resolver == nil && cfg != nil && (cfg.Password != "" || cfg.SuPwd != "") { resolver = &compatSecretResolver{ password: cfg.Password, @@ -231,7 +239,11 @@ func (c *Client) configSnapshot() (ClientConfig, bool) { SudoMode: c.connCfg.SudoMode, SudoUpdateToken: c.connCfg.SudoUpdateToken, ProxyJump: c.connCfg.ProxyJump, + OriginalProxyJump: c.connCfg.OriginalProxyJump, + HasOriginalProxyJump: c.connCfg.HasOriginalProxyJump, PasswordPromptPattern: c.connCfg.PasswordPromptPattern, + Execution: c.connCfg.Execution.Clone(), + Options: c.connCfg.Options.Clone(), }, true } @@ -242,7 +254,7 @@ func (c *Client) ConnectionConfig() ConnectionConfig { } c.cfgMu.RLock() defer c.cfgMu.RUnlock() - return c.connCfg + return c.connCfg.Clone() } // AuthMaterial returns authentication material held by the client. diff --git a/core/ssh/connector.go b/core/ssh/connector.go index 76b4ae2c..c9f64bd2 100644 --- a/core/ssh/connector.go +++ b/core/ssh/connector.go @@ -85,9 +85,10 @@ type keepAliveConfig struct { // keepAliveEntry 单个连接的心跳注册条目 type keepAliveEntry struct { - cancel context.CancelFunc // per-node ctx 的取消函数 - client *PooledClient // 心跳目标,用于驱逐时指针比对防止误删新连接 - done <-chan struct{} // 心跳 goroutine 的退出通知 + cancel context.CancelFunc // per-node ctx 的取消函数 + client *PooledClient // 心跳目标,用于驱逐时指针比对防止误删新连接 + done <-chan struct{} // 心跳 goroutine 的退出通知 + nodeScoped bool // 是否由节点自身的 ServerAliveInterval 驱动(独立于全局 keepAliveCfg 生命周期) } var knownHostsFileMutex sync.Mutex @@ -344,7 +345,7 @@ func (c *Connector) connectPlan(ctx context.Context, plan []connectionPlanNode) } dialer := c.baseDialer if dialer == nil { - dialer = &net.Dialer{Timeout: c.getHandshakeTimeout()} + dialer = c.directDialerFor(planNode.cfg) } if planNode.cfg.ProxyJump != "" { if connected == nil { @@ -362,13 +363,35 @@ func (c *Connector) connectPlan(ctx context.Context, plan []connectionPlanNode) return connected, nil } -func (c *Connector) getHandshakeTimeout() time.Duration { +func (c *Connector) getNodeHandshakeTimeout(cfg *ClientConfig) time.Duration { + if cfg != nil && cfg.Options != nil { + if ct := cfg.Options.ConnectTimeout(); ct > 0 { + return ct + } + } if c.handshakeTimeout > 0 { return c.handshakeTimeout } return defaultSSHHandshakeTimeout } +func (c *Connector) getNodeSecretPrompter(cfg *ClientConfig) SecretPrompter { + if cfg != nil && cfg.Options != nil && cfg.Options.BatchMode() { + return rejectInteraction{} + } + return c.secretPrompter +} + +func (c *Connector) directDialerFor(cfg *ClientConfig) Dialer { + d := &net.Dialer{Timeout: c.getNodeHandshakeTimeout(cfg)} + if cfg != nil && cfg.Options != nil { + if tcpKA, ok := cfg.Options.TCPKeepAlive(); ok && !tcpKA { + d.KeepAlive = -1 + } + } + return d +} + func (c *Connector) getInteractionTimeout() time.Duration { if c.interactionTimeout > 0 { return c.interactionTimeout @@ -418,7 +441,7 @@ func (c *Connector) resolveConnectionPlan(ctx context.Context, nodeName string) nodeCfg.HasOriginalProxyJump = true } positions[current] = len(plan) - plan = append(plan, connectionPlanNode{name: current, cfg: &nodeCfg, control: c.resolveControl(current)}) + plan = append(plan, connectionPlanNode{name: current, cfg: &nodeCfg, control: c.resolveControl(current, &nodeCfg)}) jumps := splitProxyJumpChain(nodeCfg.ProxyJump) if len(jumps) > 1 { @@ -451,7 +474,7 @@ func (c *Connector) resolveConnectionPlan(ctx context.Context, nodeName string) cloned.ProxyJump = "" } positions[jumpName] = len(plan) - plan = append(plan, connectionPlanNode{name: jumpName, cfg: &cloned, control: c.resolveControl(jumpName)}) + plan = append(plan, connectionPlanNode{name: jumpName, cfg: &cloned, control: c.resolveControl(jumpName, &cloned)}) } return plan, nil } @@ -467,16 +490,25 @@ func (c *Connector) expectedPeerUID() uint32 { return expectedPeerUIDFromEnvironment(c.environment) } -func (c *Connector) resolveControl(nodeName string) *ControlConfig { +func (c *Connector) resolveControl(nodeName string, cfg *ClientConfig) *ControlConfig { if c.controlProvider != nil { if ctrl, err := c.controlProvider.GetControlConfig(nodeName); err == nil && ctrl != nil { return ctrl } + return nil } if cp, ok := c.provider.(ControlProvider); ok { if ctrl, err := cp.GetControlConfig(nodeName); err == nil && ctrl != nil { return ctrl } + return nil + } + if cfg != nil && cfg.Options != nil { + if err := cfg.Options.ValidateEffective(); err == nil { + return cfg.Options.ControlConfig() + } else { + c.getLogger().Debugf("ssh: ignoring invalid effective control options for %q: %v", nodeName, err) + } } return nil } @@ -604,10 +636,10 @@ func (c *Connector) wrapCachedClient(cfg *ClientConfig, cachedClient *PooledClie cfg, c.provider, c.secretResolver, - c.secretPrompter, + c.getNodeSecretPrompter(cfg), c.credentialRecorder, c.PasswordPromptPattern, - c.getHandshakeTimeout(), + c.getNodeHandshakeTimeout(cfg), c.getInteractionTimeout(), c.getLogger(), )) @@ -645,11 +677,11 @@ func (c *Connector) evictCachedClient(nodeName string, stale *PooledClient) { c.removeKeepAliveFor(nodeName, stale) } -func (c *Connector) resolveSecret(req SecretRequest) ([]byte, error) { +func (c *Connector) resolveSecret(cfg *ClientConfig, req SecretRequest) ([]byte, error) { if c.secretResolver == nil { return nil, ErrInteractionRequired } - timeout := c.getHandshakeTimeout() + timeout := c.getNodeHandshakeTimeout(cfg) if timeout <= 0 { timeout = defaultSSHHandshakeTimeout } @@ -665,7 +697,7 @@ func (c *Connector) recordAuthUpdate(ctx context.Context, nodeName string, cfg * } committedToken, err := c.credentialRecorder.UpdateAuth(ctx, nodeName, cfg.AuthUpdateToken, cfg.Password, cfg.KeyPath, cfg.Passphrase) if err != nil { - if reportCredentialFailure(ctx, c.secretPrompter, "save", err) { + if reportCredentialFailure(ctx, c.getNodeSecretPrompter(cfg), "save", err) { return "", errCredentialNotSaved } if closeErr := rootConn.Close(); closeErr != nil { @@ -688,7 +720,7 @@ func (c *Connector) initializeConnection(ctx context.Context, planNode connectio cfg := planNode.cfg originalKeyPath := cfg.KeyPath - coordinator := newHandshakeCoordinator(ctx, c.getHandshakeTimeout()) + coordinator := newHandshakeCoordinator(ctx, c.getNodeHandshakeTimeout(cfg)) defer coordinator.Close() var discoveredAuth bool @@ -772,7 +804,7 @@ func (c *Connector) initializeConnection(ctx context.Context, planNode connectio } return nil, err } - c.startKeepAliveFor(nodeName, pooled) + c.startKeepAliveFor(nodeName, pooled, cfg) // Start multiplexing master if configured. Pass rootConn (this hop's own // connection, not the bastion's transportRoot) so shutting down a jump-target // master never closes the underlying bastion TCP connection. @@ -883,7 +915,7 @@ func (c *Connector) tryConnectMultiplex(ctx context.Context, planNode connection } func (c *Connector) probeActiveMuxMaster(ctx context.Context, planNode connectionPlanNode, expandedPath string, target TargetContext, origFi os.FileInfo, explicitMaster bool, masterMode ControlMasterMode) (*Client, bool, error) { - probeTimeout := c.getHandshakeTimeout() + probeTimeout := c.getNodeHandshakeTimeout(planNode.cfg) if probeTimeout > 2*time.Second { probeTimeout = 2 * time.Second } @@ -956,7 +988,7 @@ func (c *Connector) dialAndPublishProxy(ctx context.Context, planNode connection } func (c *Connector) tryDialAndPublishProxy(ctx context.Context, planNode connectionPlanNode, expandedPath string, target TargetContext) (*Client, error) { - dialCtx, cancel := context.WithTimeout(ctx, c.getHandshakeTimeout()) + dialCtx, cancel := context.WithTimeout(ctx, c.getNodeHandshakeTimeout(planNode.cfg)) defer cancel() rawClient, proxyConn, proxyErr := DialControlProxy(dialCtx, expandedPath, target, WithExpectedPeerUID(c.expectedPeerUID())) if proxyErr != nil { @@ -972,7 +1004,7 @@ func (c *Connector) tryDialAndPublishProxy(ctx context.Context, planNode connect _ = proxyConn.Close() return nil, err } - c.startKeepAliveFor(planNode.name, pooled) + c.startKeepAliveFor(planNode.name, pooled, planNode.cfg) logger.EmitDiagnostic(c.diagnostics, logger.Diagnostic{ Kind: logger.ConnectionReused, NodeID: planNode.name, @@ -1105,7 +1137,7 @@ func (c *Connector) maybeStartMasterListener(planNode connectionPlanNode, rawCli Environment: c.environment, RootConn: rootConn, InitialActiveSessions: 1, - ConfirmSession: c.confirmSessionCallback(planNode.name), + ConfirmSession: c.confirmSessionCallback(planNode.name, planNode.cfg), }) if err != nil { // A concurrent master already owns this path (or the bind was rejected). @@ -1250,8 +1282,8 @@ func (c *Connector) Masters() []*MuxServer { // confirmSessionCallback adapts the injected SessionConfirmer into the bounded, // fail-closed callback a master uses for ask/autoask mode. -func (c *Connector) confirmSessionCallback(nodeName string) func(string) bool { - if c.sessionConfirmer == nil { +func (c *Connector) confirmSessionCallback(nodeName string, cfg *ClientConfig) func(string) bool { + if c.sessionConfirmer == nil || (cfg != nil && cfg.Options != nil && cfg.Options.BatchMode()) { return nil } confirmer := c.sessionConfirmer @@ -1394,11 +1426,17 @@ func (c *Connector) watchKeepAliveConfig(cfg *keepAliveConfig) { return } c.keepAliveCfg = nil - c.keepAlives.IterCb(func(_ string, entry *keepAliveEntry) bool { - entry.cancel() + var globalNodes []string + c.keepAlives.IterCb(func(nodeName string, entry *keepAliveEntry) bool { + if !entry.nodeScoped { + entry.cancel() + globalNodes = append(globalNodes, nodeName) + } return true }) - c.keepAlives.Clear() + for _, nodeName := range globalNodes { + c.keepAlives.Remove(nodeName) + } } // startKeepAliveFor 为入池连接挂载心跳;未启用心跳时为 no-op。 @@ -1406,11 +1444,38 @@ func (c *Connector) watchKeepAliveConfig(cfg *keepAliveConfig) { // 杜绝"读到 cfg 后、写入注册表前"窗口内 CloseAll 先行清空导致的孤儿注册条目。 // 同节点重连时先取消旧心跳,避免同一节点存在多个心跳 goroutine。 // 锁序恒为 kaMu -> keepAlives 分片锁,evictDeadClient 不取 kaMu,无死锁风险 -func (c *Connector) startKeepAliveFor(nodeName string, client *PooledClient) { +func (c *Connector) startKeepAliveFor(nodeName string, client *PooledClient, clientCfgs ...*ClientConfig) { c.kaMu.Lock() defer c.kaMu.Unlock() - cfg := c.keepAliveCfg - if cfg == nil || cfg.ctx.Err() != nil { + + var clientCfg *ClientConfig + if len(clientCfgs) > 0 { + clientCfg = clientCfgs[0] + } + + var parentCtx context.Context + var interval, timeout time.Duration + var nodeScoped bool + if cfg := c.keepAliveCfg; cfg != nil && cfg.ctx.Err() == nil { + parentCtx = cfg.ctx + interval = cfg.interval + timeout = cfg.timeout + } + if clientCfg != nil && clientCfg.Options != nil { + countMax := clientCfg.Options.ServerAliveCountMax() + if nodeInterval := clientCfg.Options.ServerAliveInterval(); nodeInterval > 0 { + nodeScoped = true + parentCtx = c.lifecycleCtx + interval = nodeInterval + if countMax <= 0 { + countMax = 3 + } + timeout = nodeInterval * time.Duration(countMax) + } else if interval > 0 && countMax > 0 { + timeout = interval * time.Duration(countMax) + } + } + if parentCtx == nil || parentCtx.Err() != nil || interval <= 0 { return } @@ -1419,14 +1484,15 @@ func (c *Connector) startKeepAliveFor(nodeName string, client *PooledClient) { old.cancel() } - nodeCtx, cancel := context.WithCancel(cfg.ctx) + nodeCtx, cancel := context.WithCancel(parentCtx) entry := &keepAliveEntry{ - cancel: cancel, - client: client, + cancel: cancel, + client: client, + nodeScoped: nodeScoped, } c.keepAlives.Set(nodeName, entry) c.keepAliveWG.Add(1) - entry.done = startKeepAlive(nodeCtx, client.SSHClient, cfg.interval, cfg.timeout, func(error) { + entry.done = startKeepAlive(nodeCtx, client.SSHClient, interval, timeout, func(error) { c.evictDeadClient(nodeName, client, entry) }, client.interrupt) go func() { @@ -1456,7 +1522,7 @@ func (c *Connector) evictDeadClient(nodeName string, dead *PooledClient, entry * entry.cancel() } -func (c *Connector) initHandshakeDeadline(ctx context.Context, conn net.Conn, nodeName string, coordinator *handshakeCoordinator) error { +func (c *Connector) initHandshakeDeadline(ctx context.Context, conn net.Conn, nodeName string, timeout time.Duration, coordinator *handshakeCoordinator) error { if coordinator != nil { if err := coordinator.SetConn(conn); err != nil { if closeErr := conn.Close(); closeErr != nil { @@ -1466,7 +1532,7 @@ func (c *Connector) initHandshakeDeadline(ctx context.Context, conn net.Conn, no } return nil } - return setInitialHandshakeDeadline(ctx, conn, nodeName, c.getHandshakeTimeout()) + return setInitialHandshakeDeadline(ctx, conn, nodeName, timeout) } func handshakeCancelError(ctx context.Context, coordinator *handshakeCoordinator, nodeName string) error { @@ -1528,7 +1594,7 @@ func (c *Connector) dialAndHandshake(ctx context.Context, nodeName string, cfg * dialer = c.baseDialer } if dialer == nil { - dialer = &net.Dialer{Timeout: c.getHandshakeTimeout()} + dialer = c.directDialerFor(cfg) } if cfg.Port == 0 { cfg.Port = 22 @@ -1563,7 +1629,7 @@ func (c *Connector) dialAndHandshake(ctx context.Context, nodeName string, cfg * if _, proxied := dialer.(*SSHProxyDialer); !proxied { conn = &physicalSSHConn{Conn: conn} } - if err := c.initHandshakeDeadline(ctx, conn, nodeName, coordinator); err != nil { + if err := c.initHandshakeDeadline(ctx, conn, nodeName, c.getNodeHandshakeTimeout(cfg), coordinator); err != nil { return nil, nil, err } stopCancelClose := context.AfterFunc(ctx, func() { @@ -1749,7 +1815,7 @@ func (c *Connector) CloseAll() error { func (c *Connector) resolvePasswordAuth(cfg *ClientConfig) (ssh.AuthMethod, error) { if cfg.Password == "" { - secret, err := c.resolveSecret(SecretRequest{ + secret, err := c.resolveSecret(cfg, SecretRequest{ Kind: SecretKindLoginPassword, NodeID: cfg.NodeID, User: cfg.User, @@ -1776,7 +1842,7 @@ func (c *Connector) resolveKeyAuth(cfg *ClientConfig) (ssh.AuthMethod, error) { var secret []byte if cfg.Passphrase == "" { var err error - secret, err = c.resolveSecret(SecretRequest{ + secret, err = c.resolveSecret(cfg, SecretRequest{ Kind: SecretKindPrivateKeyPassphrase, NodeID: cfg.NodeID, User: cfg.User, @@ -1821,16 +1887,21 @@ func (c *Connector) buildSSHConfig(ctx context.Context, cfg *ClientConfig, coord var authCallback ssh.ClientAuthCallback authMethods := []ssh.AuthMethod{} - prompter := c.secretPrompter + basePrompter := c.getNodeSecretPrompter(cfg) + prompter := basePrompter authCtx := c.lifecycleCtx if coordinator != nil { authCtx = coordinator.Context() prompter = &coordinatingPrompter{ - prompter: c.secretPrompter, + prompter: basePrompter, coordinator: coordinator, } } + hostKeyChanged := &atomic.Bool{} + policy := newAuthPolicy(cfg.Options, hostKeyChanged) + pubkeyAllowed, passwordAllowed, kbAllowed := policy.pubkeyAllowed, policy.passwordAllowed, policy.kbAllowed + // 根据 AuthType 处理不同的认证方式 switch cfg.AuthType { case "auto": @@ -1852,11 +1923,13 @@ func (c *Connector) buildSSHConfig(ctx context.Context, cfg *ClientConfig, coord VersionToken: cfg.AuthUpdateToken, Resolver: c.secretResolver, Prompter: prompter, - HandshakeTimeout: c.getHandshakeTimeout(), + HandshakeTimeout: c.getNodeHandshakeTimeout(cfg), InteractionTimeout: c.interactionTimeout, FailClosed: failClosed, - RecoveryPrompter: credentialRecoveryPrompter(c.secretPrompter), + RecoveryPrompter: credentialRecoveryPrompter(basePrompter), KeyPath: cfg.KeyPath, + SSHOptions: cfg.Options, + HostKeyChanged: hostKeyChanged, PasswordCallback: func(s string) { if s != "" { // Rejected earlier key candidates are cleared by @@ -1910,10 +1983,16 @@ func (c *Connector) buildSSHConfig(ctx context.Context, cfg *ClientConfig, coord cleanup = release case "keyboard-interactive", "otp": - authCallback = c.buildKeyboardInteractiveAuth(authCtx, cfg, prompter, coordinator, observer) + if !kbAllowed { + return nil, nil, errors.New("keyboard-interactive authentication disabled by SSH options") + } + authCallback = c.buildKeyboardInteractiveAuth(authCtx, cfg, policy, prompter, coordinator, observer) case "password": - if credentialRecoveryPrompter(c.secretPrompter) != nil { + if !passwordAllowed { + return nil, nil, errors.New("password authentication disabled by SSH options") + } + if credentialRecoveryPrompter(basePrompter) != nil { authMethods = append(authMethods, c.recoverablePasswordAuth(authCtx, cfg, prompter, onAuthDiscovered)) break } @@ -1924,6 +2003,9 @@ func (c *Connector) buildSSHConfig(ctx context.Context, cfg *ClientConfig, coord authMethods = append(authMethods, method) case "key": + if !pubkeyAllowed { + return nil, nil, errors.New("publickey authentication disabled by SSH options") + } method, release, err := c.keyAuth(authCtx, cfg, prompter, onAuthDiscovered) if err != nil { return nil, nil, err @@ -1932,6 +2014,12 @@ func (c *Connector) buildSSHConfig(ctx context.Context, cfg *ClientConfig, coord cleanup = release case "agent": + if !pubkeyAllowed { + return nil, nil, errors.New("publickey authentication disabled by SSH options") + } + if policy.identitiesOnly { + return nil, nil, errors.New("ssh-agent authentication disabled by IdentitiesOnly=yes") + } method, agentCleanup, err := c.resolveAgentAuth(ctx) if err != nil { return nil, nil, err @@ -1944,10 +2032,18 @@ func (c *Connector) buildSSHConfig(ctx context.Context, cfg *ClientConfig, coord } if authCallback == nil && len(authMethods) == 1 { - authCallback = c.buildSingleMethodTransitionCallback(authCtx, cfg, prompter, coordinator, observer) + authCallback = c.buildSingleMethodTransitionCallback(authCtx, cfg, policy, prompter, coordinator, observer) } - hostKeyCallback, hostKeyAlgorithms, err := c.getHostKeyOptions(ctx, coordinator, cfg) + hostKeyCallback, hostKeyAlgorithms, err := c.getHostKeyOptions(ctx, coordinator, cfg, hostKeyChanged) + if err != nil { + if cleanup != nil { + err = errors.Join(err, cleanup()) + } + return nil, nil, err + } + + cryptoCfg, err := buildTransportCryptoConfig(cfg.Options) if err != nil { if cleanup != nil { err = errors.Join(err, cleanup()) @@ -1961,6 +2057,7 @@ func (c *Connector) buildSSHConfig(ctx context.Context, cfg *ClientConfig, coord } clientConfig := &ssh.ClientConfig{ + Config: cryptoCfg, User: cfg.User, Auth: authMethods, AuthCallback: authCallback, @@ -1973,6 +2070,35 @@ func (c *Connector) buildSSHConfig(ctx context.Context, cfg *ClientConfig, coord return clientConfig, cleanup, nil } +func buildTransportCryptoConfig(opts *Options) (ssh.Config, error) { + var cryptoCfg ssh.Config + if opts == nil { + return cryptoCfg, nil + } + if ciphers := opts.Ciphers(); ciphers != "" { + list, err := ResolveAlgorithmList(ciphers, ssh.SupportedAlgorithms().Ciphers) + if err != nil { + return cryptoCfg, fmt.Errorf("resolve Ciphers option: %w", err) + } + cryptoCfg.Ciphers = list + } + if macs := opts.MACs(); macs != "" { + list, err := ResolveAlgorithmList(macs, ssh.SupportedAlgorithms().MACs) + if err != nil { + return cryptoCfg, fmt.Errorf("resolve MACs option: %w", err) + } + cryptoCfg.MACs = list + } + if kex := opts.KexAlgorithms(); kex != "" { + list, err := ResolveAlgorithmList(kex, ssh.SupportedAlgorithms().KeyExchanges) + if err != nil { + return cryptoCfg, fmt.Errorf("resolve KexAlgorithms option: %w", err) + } + cryptoCfg.KeyExchanges = list + } + return cryptoCfg, nil +} + // observeSSHConfig adds diagnostics without changing fallback selection or trust results. func (c *Connector) observeSSHConfig(cfg *ClientConfig, clientConfig *ssh.ClientConfig, observer logger.DiagnosticObserver) { if observer == nil { @@ -1991,6 +2117,7 @@ func (c *Connector) newAutoSecretProvider(authCtx context.Context, cfg *ClientCo if coordinator != nil { failClosed = coordinator.FailClosed } + basePrompter := c.getNodeSecretPrompter(cfg) return &autoSecretProvider{ lifecycleCtx: authCtx, nodeID: cfg.NodeID, @@ -2000,14 +2127,14 @@ func (c *Connector) newAutoSecretProvider(authCtx context.Context, cfg *ClientCo versionToken: cfg.AuthUpdateToken, resolver: c.secretResolver, prompter: prompter, - handshakeTimeout: c.getHandshakeTimeout(), + handshakeTimeout: c.getNodeHandshakeTimeout(cfg), interactionTimeout: c.interactionTimeout, failClosed: failClosed, - recoveryPrompter: credentialRecoveryPrompter(c.secretPrompter), + recoveryPrompter: credentialRecoveryPrompter(basePrompter), } } -func (c *Connector) buildKeyboardInteractiveAuth(authCtx context.Context, cfg *ClientConfig, prompter SecretPrompter, coordinator *handshakeCoordinator, observer logger.DiagnosticObserver) ssh.ClientAuthCallback { +func (c *Connector) buildKeyboardInteractiveAuth(authCtx context.Context, cfg *ClientConfig, policy authPolicy, prompter SecretPrompter, coordinator *handshakeCoordinator, observer logger.DiagnosticObserver) ssh.ClientAuthCallback { secProvider := c.newAutoSecretProvider(authCtx, cfg, prompter, coordinator) kbMethod := buildKeyboardInteractiveAuthMethod(secProvider) candidates := []autoAuthCandidate{ @@ -2017,44 +2144,49 @@ func (c *Connector) buildKeyboardInteractiveAuth(authCtx context.Context, cfg *C method: kbMethod, }, } - passwordAttempts := 0 - initialPassword := cfg.Password - passwordMethod := ssh.RetryableAuthMethod(ssh.PasswordCallback(func() (string, error) { - retry := passwordAttempts > 0 - if retry && secProvider.recoveryPrompter == nil { - return "", ErrInteractionRequired - } - passwordAttempts++ - if !retry && initialPassword != "" { - pwd := initialPassword - initialPassword = "" - return pwd, nil - } - password, err := secProvider.resolveOrPromptAttempt(SecretRequest{ - Kind: SecretKindLoginPassword, - }, retry) - if err != nil { - return "", fmt.Errorf("failed to read password: %w", err) - } - return password, nil - }), 3) - candidates = append(candidates, autoAuthCandidate{ - protocol: autoAuthProtocolPassword, - label: "password", - method: passwordMethod, - allow: func(ctx *ssh.ClientAuthContext) bool { - // Only coordinate with password in explicit keyboard-interactive/otp mode - // when multi-stage MFA is in progress or when the initial stage did not - // offer keyboard-interactive (e.g., password -> keyboard-interactive MFA). - return len(ctx.PartialSuccessMethods) > 0 || !slices.Contains(ctx.TriedMethods, autoAuthProtocolKeyboardInteractive) - }, - }) - return newSequentialAuthCallback(candidates, secProvider, c.getLogger(), observer) + if policy.passwordAllowed { + passwordAttempts := 0 + initialPassword := cfg.Password + passwordMethod := ssh.RetryableAuthMethod(ssh.PasswordCallback(func() (string, error) { + retry := passwordAttempts > 0 + if retry && secProvider.recoveryPrompter == nil { + return "", ErrInteractionRequired + } + passwordAttempts++ + if !retry && initialPassword != "" { + pwd := initialPassword + initialPassword = "" + return pwd, nil + } + password, err := secProvider.resolveOrPromptAttempt(SecretRequest{ + Kind: SecretKindLoginPassword, + }, retry) + if err != nil { + return "", fmt.Errorf("failed to read password: %w", err) + } + return password, nil + }), 3) + candidates = append(candidates, autoAuthCandidate{ + protocol: autoAuthProtocolPassword, + label: "password", + method: passwordMethod, + allow: func(ctx *ssh.ClientAuthContext) bool { + // Only coordinate with password in explicit keyboard-interactive/otp mode + // when multi-stage MFA is in progress or when the initial stage did not + // offer keyboard-interactive (e.g., password -> keyboard-interactive MFA). + return len(ctx.PartialSuccessMethods) > 0 || !slices.Contains(ctx.TriedMethods, autoAuthProtocolKeyboardInteractive) + }, + }) + } + return newSequentialAuthCallbackWithPolicy(candidates, policy, secProvider, c.getLogger(), observer) } -func (c *Connector) buildSingleMethodTransitionCallback(authCtx context.Context, cfg *ClientConfig, prompter SecretPrompter, coordinator *handshakeCoordinator, observer logger.DiagnosticObserver) ssh.ClientAuthCallback { +func (c *Connector) buildSingleMethodTransitionCallback(authCtx context.Context, cfg *ClientConfig, policy authPolicy, prompter SecretPrompter, coordinator *handshakeCoordinator, observer logger.DiagnosticObserver) ssh.ClientAuthCallback { secProvider := c.newAutoSecretProvider(authCtx, cfg, prompter, coordinator) - kbMethod := buildKeyboardInteractiveAuthMethod(secProvider) + var kbMethod ssh.AuthMethod + if policy.kbAllowed { + kbMethod = buildKeyboardInteractiveAuthMethod(secProvider) + } protocol := "publickey" if cfg.AuthType == "password" { protocol = "password" @@ -2078,8 +2210,11 @@ func (c *Connector) buildSingleMethodTransitionCallback(authCtx context.Context, lastStage = stage kbTriedInStage = false } - // When single-method auth achieves partial success, transition to keyboard-interactive once per stage if allowed by server - if stage > 0 && !kbTriedInStage && slices.Contains(ctx.AllowedMethods, autoAuthProtocolKeyboardInteractive) { + // When single-method auth achieves partial success, transition to keyboard-interactive once per stage if allowed by server and policy + if stage > 0 && policy.kbAllowed && !kbTriedInStage && slices.Contains(ctx.AllowedMethods, autoAuthProtocolKeyboardInteractive) { + if err := policy.checkProtocolRuntime(autoAuthProtocolKeyboardInteractive); err != nil { + return nil, err + } kbTriedInStage = true if observer != nil { event := authenticationDiagnostic(ctx) @@ -2089,9 +2224,11 @@ func (c *Connector) buildSingleMethodTransitionCallback(authCtx context.Context, c.getLogger().Debugf("Transitioning to keyboard-interactive after partial success: %v", ctx.PartialSuccessMethods) return kbMethod, nil } - // Diagnostic logging for the primary method attempt - if stage == 0 && observer != nil { - if slices.Contains(ctx.AllowedMethods, protocol) && !slices.Contains(ctx.TriedMethods, protocol) { + if stage == 0 { + if err := policy.checkProtocolRuntime(protocol); err != nil { + return nil, err + } + if observer != nil && slices.Contains(ctx.AllowedMethods, protocol) && !slices.Contains(ctx.TriedMethods, protocol) { event := authenticationDiagnostic(ctx) event.Kind, event.Method, event.Methods = logger.AuthenticationAttempt, protocol, nil logger.EmitDiagnostic(observer, event) @@ -2138,73 +2275,209 @@ func buildKeyAuthMethod(cfg *ClientConfig, extraPassphrase []byte, resolver ...f return ssh.PublicKeys(signer), nil } -func (c *Connector) getHostKeyCallback(ctx context.Context, coordinator *handshakeCoordinator, cfg *ClientConfig) (ssh.HostKeyCallback, error) { +func (c *Connector) getHostKeyCallback(ctx context.Context, coordinator *handshakeCoordinator, cfg *ClientConfig, hostKeyChangedFlags ...*atomic.Bool) (ssh.HostKeyCallback, error) { if c.hostKeyVerifier != nil { - verificationParent := ctx - if coordinator != nil { - verificationParent = coordinator.Context() - } - request := HostKeyRequest{NodeID: cfg.NodeID, Host: cfg.Address, Port: cfg.Port, User: cfg.User, VersionToken: cfg.TrustVersion} - return func(hostname string, remote net.Addr, key ssh.PublicKey) error { - verification, cancel := context.WithTimeout(verificationParent, c.getHandshakeTimeout()) - defer cancel() - if err := verification.Err(); err != nil { - return err - } - requestCopy := request - requestCopy.Hostname, requestCopy.Remote = hostname, remote - return c.hostKeyVerifier.Verify(verification, requestCopy, key) - }, nil + return c.verifierHostKeyCallback(ctx, coordinator, cfg), nil } - if err := validateEnvironment(c.environment); err != nil { + + var hostKeyChanged *atomic.Bool + if len(hostKeyChangedFlags) > 0 { + hostKeyChanged = hostKeyChangedFlags[0] + } + + strictMode, userKH, batchMode := extractHostKeyPolicy(cfg) + knownHostsFile, noKnownHostsFile, hostKeyCallback, err := c.openKnownHostsCallback(userKH, cfg) + if err != nil { return nil, err } - knownHostsFile := c.environment.KnownHostsFile + return func(hostname string, remote net.Addr, key ssh.PublicKey) (retErr error) { + err := hostKeyCallback(hostname, remote, key) + if err == nil { + return nil + } + + if keyErr, ok := errors.AsType[*knownhosts.KeyError](err); ok { + return c.handleKnownHostsKeyError(ctx, coordinator, keyErr, strictMode, batchMode, noKnownHostsFile, knownHostsFile, hostname, remote, key, hostKeyChanged) + } + return err + }, nil +} + +func (c *Connector) verifierHostKeyCallback(ctx context.Context, coordinator *handshakeCoordinator, cfg *ClientConfig) ssh.HostKeyCallback { + verificationParent := ctx + if coordinator != nil { + verificationParent = coordinator.Context() + } + request := HostKeyRequest{NodeID: cfg.NodeID, Host: cfg.Address, Port: cfg.Port, User: cfg.User, VersionToken: cfg.TrustVersion} + return func(hostname string, remote net.Addr, key ssh.PublicKey) error { + verification, cancel := context.WithTimeout(verificationParent, c.getNodeHandshakeTimeout(cfg)) + defer cancel() + if err := verification.Err(); err != nil { + return err + } + requestCopy := request + requestCopy.Hostname, requestCopy.Remote = hostname, remote + return c.hostKeyVerifier.Verify(verification, requestCopy, key) + } +} + +func extractHostKeyPolicy(cfg *ClientConfig) (strictMode string, userKH string, batchMode bool) { + strictMode = StrictHostKeyCheckingAsk + if cfg != nil && cfg.Options != nil { + if m := cfg.Options.StrictHostKeyChecking(); m != "" { + strictMode = m + } + userKH = strings.TrimSpace(cfg.Options.UserKnownHostsFile()) + batchMode = cfg.Options.BatchMode() + } + return strictMode, userKH, batchMode +} + +func (c *Connector) openKnownHostsCallback(userKH string, cfg *ClientConfig) (string, bool, ssh.HostKeyCallback, error) { + emptyCallback := func(string, net.Addr, ssh.PublicKey) error { + return &knownhosts.KeyError{} + } + if strings.EqualFold(userKH, "none") || userKH == "/dev/null" { + return "", true, emptyCallback, nil + } + + if userKH != "" { + return c.openCustomKnownHostsFiles(userKH, cfg, emptyCallback) + } + + if err := validateEnvironment(c.environment); err != nil { + return "", false, nil, err + } + knownHostsFile := c.environment.KnownHostsFile if _, err := os.Stat(knownHostsFile); os.IsNotExist(err) { if mkdirErr := os.MkdirAll(filepath.Dir(knownHostsFile), 0700); mkdirErr != nil { - return nil, fmt.Errorf("create .ssh directory failed: %w", mkdirErr) + return "", false, nil, fmt.Errorf("create .ssh directory failed: %w", mkdirErr) } if writeErr := os.WriteFile(knownHostsFile, []byte(""), 0600); writeErr != nil { - return nil, fmt.Errorf("create known_hosts file failed: %w", writeErr) + return "", false, nil, fmt.Errorf("create known_hosts file failed: %w", writeErr) } } hostKeyCallback, err := knownhosts.New(knownHostsFile) if err != nil { - return nil, fmt.Errorf("parse known_hosts file failed: %w", err) + return "", false, nil, fmt.Errorf("parse known_hosts file failed: %w", err) } + return knownHostsFile, false, hostKeyCallback, nil +} - return func(hostname string, remote net.Addr, key ssh.PublicKey) (retErr error) { - err := hostKeyCallback(hostname, remote, key) - if err == nil { - return nil +func (c *Connector) openCustomKnownHostsFiles(userKH string, cfg *ClientConfig, emptyCallback ssh.HostKeyCallback) (string, bool, ssh.HostKeyCallback, error) { + fields := strings.Fields(userKH) + var existingFiles []string + var primaryWriteFile string + target := TargetContext{} + if cfg != nil { + target = TargetContext{ + Host: cfg.Address, + Port: cfg.Port, + User: cfg.User, + OriginalHost: cfg.NodeID, + JumpHost: cfg.ProxyJump, } - - if keyErr, ok := errors.AsType[*knownhosts.KeyError](err); ok { - if len(keyErr.Want) > 0 { - return fmt.Errorf("%w: remote host identification has changed for %s: %w", ErrHostKeyMismatch, hostname, err) + } + for _, tokenPath := range fields { + if strings.EqualFold(tokenPath, "none") || tokenPath == "/dev/null" { + continue + } + expanded, expErr := expandKnownHostsPath(tokenPath, c.environment, target) + if expErr != nil { + return "", false, nil, fmt.Errorf("expand UserKnownHostsFile %q failed: %w", tokenPath, expErr) + } + if primaryWriteFile == "" { + primaryWriteFile = expanded + } + st, err := os.Stat(expanded) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + return "", false, nil, fmt.Errorf("stat UserKnownHostsFile %q failed: %w", expanded, err) } + continue + } + if st.IsDir() { + return "", false, nil, fmt.Errorf("UserKnownHostsFile %q is a directory", expanded) + } + existingFiles = append(existingFiles, expanded) + } + if len(existingFiles) == 0 { + return primaryWriteFile, primaryWriteFile == "", emptyCallback, nil + } + hostKeyCallback, err := knownhosts.New(existingFiles...) + if err != nil { + return "", false, nil, fmt.Errorf("parse known_hosts file failed: %w", err) + } + return primaryWriteFile, primaryWriteFile == "", hostKeyCallback, nil +} - if coordinator != nil { - if pauseErr := coordinator.Pause(); pauseErr != nil { - return pauseErr - } - defer func() { - if resumeErr := coordinator.Resume(); resumeErr != nil { - retErr = errors.Join(retErr, resumeErr) - } - }() +func expandKnownHostsPath(pattern string, env Environment, target TargetContext) (string, error) { + return ExpandControlPath(pattern, env, target) +} + +func (c *Connector) handleKnownHostsKeyError( + ctx context.Context, + coordinator *handshakeCoordinator, + keyErr *knownhosts.KeyError, + strictMode string, + batchMode bool, + noKnownHostsFile bool, + knownHostsFile string, + hostname string, + remote net.Addr, + key ssh.PublicKey, + hostKeyChanged *atomic.Bool, +) (retErr error) { + if len(keyErr.Want) > 0 { + if strictMode == StrictHostKeyCheckingNo { + if hostKeyChanged != nil { + hostKeyChanged.Store(true) } + c.getLogger().Debugf("host key verification: remote host identification changed for %s; continuing due to StrictHostKeyChecking=no with password and keyboard-interactive authentication disabled", hostname) + return nil + } + return fmt.Errorf("%w: remote host identification has changed for %s: %w", ErrHostKeyMismatch, hostname, keyErr) + } - if promptErr := c.promptHostKeyVerification(ctx, hostname, remote, key); promptErr != nil { - return promptErr + switch strictMode { + case StrictHostKeyCheckingYes: + return fmt.Errorf("%w: no host key is known for %s and StrictHostKeyChecking=yes", ErrHostKeyMismatch, hostname) + case StrictHostKeyCheckingNo: + if !noKnownHostsFile && knownHostsFile != "" { + _ = appendKnownHost(knownHostsFile, hostname, key) + } + return nil + case StrictHostKeyCheckingAcceptNew: + if noKnownHostsFile || knownHostsFile == "" { + return nil + } + return appendKnownHost(knownHostsFile, hostname, key) + default: + if batchMode && !c.AcceptNewHostKey.Load() { + return ErrInteractionRequired + } + if coordinator != nil { + if pauseErr := coordinator.Pause(); pauseErr != nil { + return pauseErr } + defer func() { + if resumeErr := coordinator.Resume(); resumeErr != nil { + retErr = errors.Join(retErr, resumeErr) + } + }() + } - return appendKnownHost(knownHostsFile, hostname, key) + if promptErr := c.promptHostKeyVerification(ctx, hostname, remote, key); promptErr != nil { + return promptErr } - return err - }, nil + + if noKnownHostsFile || knownHostsFile == "" { + return nil + } + return appendKnownHost(knownHostsFile, hostname, key) + } } func (c *Connector) promptHostKeyVerification(ctx context.Context, hostname string, remote net.Addr, key ssh.PublicKey) error { @@ -2241,7 +2514,11 @@ func appendKnownHost(knownHostsFile, hostname string, key ssh.PublicKey) (err er knownHostsFileMutex.Lock() defer knownHostsFileMutex.Unlock() - f, openErr := os.OpenFile(knownHostsFile, os.O_APPEND|os.O_WRONLY, 0600) + if mkdirErr := os.MkdirAll(filepath.Dir(knownHostsFile), 0700); mkdirErr != nil { + return fmt.Errorf("create known_hosts directory failed: %w", mkdirErr) + } + + f, openErr := os.OpenFile(knownHostsFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) if openErr != nil { return fmt.Errorf("open known_hosts file failed: %w", openErr) } diff --git a/core/ssh/connector_test.go b/core/ssh/connector_test.go index 279818a7..89e6de98 100644 --- a/core/ssh/connector_test.go +++ b/core/ssh/connector_test.go @@ -738,7 +738,7 @@ func TestConnector_DefaultFailClosed_RejectsInteraction(t *testing.T) { } // 验证 Client 在默认无交互 handler 下提权同样失败关闭 - client := newClientWithComponents(nil, nil, store.cfg, connector.provider, connector.secretResolver, connector.secretPrompter, connector.credentialRecorder, "", connector.getHandshakeTimeout(), connector.getInteractionTimeout(), nil) + client := newClientWithComponents(nil, nil, store.cfg, connector.provider, connector.secretResolver, connector.secretPrompter, connector.credentialRecorder, "", connector.getNodeHandshakeTimeout(store.cfg), connector.getInteractionTimeout(), nil) _, privErr := client.resolvePrivilegeMaterial(context.Background(), SecretKindSuPassword) if privErr == nil { t.Fatal("expected error on privilege resolution without handler, got nil") diff --git a/core/ssh/credential_recovery.go b/core/ssh/credential_recovery.go index d24853d5..4b115fb2 100644 --- a/core/ssh/credential_recovery.go +++ b/core/ssh/credential_recovery.go @@ -41,8 +41,8 @@ func (c *Connector) recoverablePasswordAuth(ctx context.Context, cfg *ClientConf provider := &autoSecretProvider{ lifecycleCtx: ctx, nodeID: cfg.NodeID, user: cfg.User, host: cfg.Address, port: cfg.Port, versionToken: cfg.AuthUpdateToken, - resolver: c.secretResolver, prompter: prompter, recoveryPrompter: c.secretPrompter, - handshakeTimeout: c.getHandshakeTimeout(), interactionTimeout: c.interactionTimeout, + resolver: c.secretResolver, prompter: prompter, recoveryPrompter: credentialRecoveryPrompter(c.getNodeSecretPrompter(cfg)), + handshakeTimeout: c.getNodeHandshakeTimeout(cfg), interactionTimeout: c.interactionTimeout, } attempts := 0 initial := cfg.Password @@ -73,8 +73,8 @@ func (c *Connector) recoverableKeyAuth(ctx context.Context, cfg *ClientConfig, p provider := &autoSecretProvider{ lifecycleCtx: ctx, nodeID: cfg.NodeID, user: cfg.User, host: cfg.Address, port: cfg.Port, versionToken: cfg.AuthUpdateToken, - resolver: resolver, prompter: prompter, recoveryPrompter: c.secretPrompter, - handshakeTimeout: c.getHandshakeTimeout(), interactionTimeout: c.interactionTimeout, + resolver: resolver, prompter: prompter, recoveryPrompter: credentialRecoveryPrompter(c.getNodeSecretPrompter(cfg)), + handshakeTimeout: c.getNodeHandshakeTimeout(cfg), interactionTimeout: c.interactionTimeout, } method, err := resolveKeyAuthMethod(resolveKeyPath(cfg.KeyPath, c.environment.ResolveKeyPath), provider, func(_ string, passphrase string) { cfg.Passphrase, cfg.Password = passphrase, "" diff --git a/core/ssh/host_algorithms.go b/core/ssh/host_algorithms.go index 3f53056f..2d75d155 100644 --- a/core/ssh/host_algorithms.go +++ b/core/ssh/host_algorithms.go @@ -5,12 +5,13 @@ import ( "errors" "fmt" "slices" + "sync/atomic" cryptoSSH "golang.org/x/crypto/ssh" ) -func (c *Connector) getHostKeyOptions(ctx context.Context, coordinator *handshakeCoordinator, cfg *ClientConfig) (cryptoSSH.HostKeyCallback, []string, error) { - callback, err := c.getHostKeyCallback(ctx, coordinator, cfg) +func (c *Connector) getHostKeyOptions(ctx context.Context, coordinator *handshakeCoordinator, cfg *ClientConfig, hostKeyChanged ...*atomic.Bool) (cryptoSSH.HostKeyCallback, []string, error) { + callback, err := c.getHostKeyCallback(ctx, coordinator, cfg, hostKeyChanged...) if err != nil { return nil, nil, fmt.Errorf("get host key callback failed: %w", err) } @@ -22,14 +23,22 @@ func (c *Connector) getHostKeyOptions(ctx context.Context, coordinator *handshak } func (c *Connector) getHostKeyAlgorithms(ctx context.Context, coordinator *handshakeCoordinator, cfg *ClientConfig) ([]string, error) { + var optionAlgos []string + if cfg != nil && cfg.Options != nil && cfg.Options.HostKeyAlgorithms() != "" { + algos, err := ResolveAlgorithmList(cfg.Options.HostKeyAlgorithms(), cryptoSSH.SupportedAlgorithms().HostKeys) + if err != nil { + return nil, fmt.Errorf("resolve HostKeyAlgorithms option: %w", err) + } + optionAlgos = algos + } source, ok := c.hostKeyVerifier.(HostKeyAlgorithmSource) if !ok { - return nil, nil - } // Existing verifiers retain the SSH library defaults. + return optionAlgos, nil + } // Existing verifiers retain the SSH library defaults (or optionAlgos when specified). if coordinator != nil { ctx = coordinator.Context() } - selection, cancel := context.WithTimeout(ctx, c.getHandshakeTimeout()) + selection, cancel := context.WithTimeout(ctx, c.getNodeHandshakeTimeout(cfg)) defer cancel() if err := selection.Err(); err != nil { return nil, err @@ -50,5 +59,17 @@ func (c *Connector) getHostKeyAlgorithms(ctx context.Context, coordinator *hands return nil, fmt.Errorf("host key algorithm source returned unsupported algorithm %q", algorithm) } } + if len(optionAlgos) > 0 { + var intersected []string + for _, alg := range optionAlgos { + if slices.Contains(algorithms, alg) { + intersected = append(intersected, alg) + } + } + if len(intersected) == 0 { + return nil, errors.New("no host key algorithms in common between HostKeyAlgorithms option and pinned host key verifier") + } + return intersected, nil + } return slices.Clone(algorithms), nil } diff --git a/core/ssh/key_source.go b/core/ssh/key_source.go index 2db2cc03..f6fc5bdc 100644 --- a/core/ssh/key_source.go +++ b/core/ssh/key_source.go @@ -79,7 +79,7 @@ func (c *Connector) keyAuth(ctx context.Context, cfg *ClientConfig, prompter Sec if c.keySource != nil { return c.sourcedKeyAuth(ctx, cfg) } - if credentialRecoveryPrompter(c.secretPrompter) != nil { + if credentialRecoveryPrompter(c.getNodeSecretPrompter(cfg)) != nil { method, release, err := c.recoverableKeyAuth(ctx, cfg, prompter, discovered) return method, legacyAuthCleanup(release), err } @@ -90,7 +90,8 @@ func (c *Connector) keyAuth(ctx context.Context, cfg *ClientConfig, prompter Sec // Explicit referenced signers precede agent/file/password candidates. A source // lookup failure cannot silently fall back to a different authentication source. func (c *Connector) buildOwnedAutoAuth(ctx context.Context, cfg *ClientConfig, options AutoAuthOptions) (autoAuthPlan, func() error, error) { - if cfg.KeyRef == "" && (c.keySource == nil || cfg.KeyPath == "") { + policy := newAuthPolicy(options.SSHOptions, options.HostKeyChanged) + if !policy.pubkeyAllowed || (cfg.KeyRef == "" && (c.keySource == nil || cfg.KeyPath == "")) { plan := buildAutoAuthPlan(ctx, options) return plan, legacyAuthCleanup(plan.cleanup), nil } diff --git a/core/ssh/plan.go b/core/ssh/plan.go index 7280c098..c5b31e9f 100644 --- a/core/ssh/plan.go +++ b/core/ssh/plan.go @@ -128,6 +128,12 @@ func normalizePlan(plan ConnectionPlan) (ConnectionPlan, error) { if !hop.HasOriginalProxyJump { hop.OriginalProxyJump, hop.HasOriginalProxyJump = hop.ProxyJump, true } + hop.Execution = hop.Execution.Clone() + hop.Options = hop.Options.Clone() + hop.Options.StripClasses(OptionClassMux) + if hop.Options.IsEmpty() { + hop.Options = nil + } seen[hop.NodeID] = true plan.Hops[index], previous = hop, hop.NodeID } @@ -191,7 +197,7 @@ func (c *Connector) acquirePlan(key string, plan ConnectionPlan) (*planEntry, er // RetirePlan. Plans therefore always dial their own transport. connector := NewConnector(provider, WithEnvironment(c.environment), WithLogger(c.getLogger()), WithDialer(c.baseDialer), - WithHandshakeTimeout(c.getHandshakeTimeout()), WithInteractionTimeout(c.interactionTimeout), + WithHandshakeTimeout(c.getNodeHandshakeTimeout(nil)), WithInteractionTimeout(c.interactionTimeout), WithSecretResolver(c.secretResolver), WithKeySource(c.keySource), WithHostKeyVerifier(c.hostKeyVerifier), WithPasswordPromptPattern(c.PasswordPromptPattern), WithDiagnostics(c.diagnostics), @@ -215,6 +221,11 @@ func (c *Connector) acquirePlan(key string, plan ConnectionPlan) (*planEntry, er } func clientConfigFromPlan(cfg ConnectionConfig) ClientConfig { + opts := cfg.Options.Clone() + opts.StripClasses(OptionClassMux) + if opts.IsEmpty() { + opts = nil + } return ClientConfig{ NodeID: cfg.NodeID, Address: cfg.Address, Port: cfg.Port, User: cfg.User, AuthType: cfg.AuthType, KeyPath: cfg.KeyPath, KeyRef: cfg.KeyRef, TrustVersion: cfg.TrustVersion, @@ -222,6 +233,7 @@ func clientConfigFromPlan(cfg ConnectionConfig) ClientConfig { ProxyJump: cfg.ProxyJump, OriginalProxyJump: cfg.OriginalProxyJump, HasOriginalProxyJump: cfg.HasOriginalProxyJump, PasswordPromptPattern: cfg.PasswordPromptPattern, Execution: cfg.Execution.Clone(), + Options: opts, } } diff --git a/core/ssh/ssh_options.go b/core/ssh/ssh_options.go new file mode 100644 index 00000000..a5b94bdb --- /dev/null +++ b/core/ssh/ssh_options.go @@ -0,0 +1,1264 @@ +package ssh + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "slices" + "strconv" + "strings" + "time" + + cryptoSSH "golang.org/x/crypto/ssh" + "gopkg.in/yaml.v3" +) + +// ErrSSHOptionsValidation indicates an invalid SSH option key or value. +var ErrSSHOptionsValidation = errors.New("ssh options validation error") + +// Standard canonical snake_case option keys used for persistence in ssh_options. +const ( + OptionControlMaster = "control_master" + OptionControlPath = "control_path" + OptionControlPersist = "control_persist" + OptionConnectTimeout = "connect_timeout" + OptionServerAliveInterval = "server_alive_interval" + OptionServerAliveCountMax = "server_alive_count_max" + OptionTCPKeepAlive = "tcp_keep_alive" + OptionStrictHostKeyChecking = "strict_host_key_checking" + OptionUserKnownHostsFile = "user_known_hosts_file" + OptionPubkeyAuthentication = "pubkey_authentication" + OptionPasswordAuthentication = "password_authentication" + OptionKbdInteractiveAuthentication = "kbd_interactive_authentication" + OptionIdentitiesOnly = "identities_only" + OptionPreferredAuthentications = "preferred_authentications" + OptionBatchMode = "batch_mode" + OptionHostKeyAlgorithms = "host_key_algorithms" + OptionCiphers = "ciphers" + OptionMACs = "macs" + OptionKexAlgorithms = "kex_algorithms" +) + +// OptionClass categorizes OpenSSH options by security and functional domain. +type OptionClass string + +const ( + OptionClassMux OptionClass = "mux" + OptionClassTransport OptionClass = "transport" + OptionClassAuth OptionClass = "auth" + OptionClassTrust OptionClass = "trust" +) + +// StrictHostKeyCheckingMode defines OpenSSH StrictHostKeyChecking values. +const ( + StrictHostKeyCheckingYes = "yes" + StrictHostKeyCheckingNo = "no" + StrictHostKeyCheckingAsk = "ask" + StrictHostKeyCheckingAcceptNew = "accept-new" +) + +// OptionSpec describes a supported OpenSSH client option in the registry. +type OptionSpec struct { + CanonicalKey string // Persistent snake_case key (e.g. "control_master") + OpenSSHName string // Canonical OpenSSH name (e.g. "ControlMaster") + Class OptionClass // Functional/security category + Aliases []string // Accepted aliases (e.g. "ChallengeResponseAuthentication") + AllowList bool // Whether YAML sequence values are accepted and joined with "," + Normalize func(raw string) (string, error) +} + +var optionRegistry = []OptionSpec{ + { + CanonicalKey: OptionControlMaster, + OpenSSHName: "ControlMaster", + Class: OptionClassMux, + Normalize: func(raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", fmt.Errorf("unsupported ControlMaster option %q", raw) + } + m, err := ParseControlMasterMode(trimmed) + if err != nil { + return "", err + } + return string(m), nil + }, + }, + { + CanonicalKey: OptionControlPath, + OpenSSHName: "ControlPath", + Class: OptionClassMux, + Normalize: func(raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", errors.New("invalid ControlPath: value cannot be empty") + } + if strings.EqualFold(trimmed, "none") { + return "none", nil + } + if err := ValidateControlPathSyntax(trimmed); err != nil { + return "", fmt.Errorf("invalid ControlPath %q: %w", raw, err) + } + return trimmed, nil + }, + }, + { + CanonicalKey: OptionControlPersist, + OpenSSHName: "ControlPersist", + Class: OptionClassMux, + Normalize: func(raw string) (string, error) { + trimmed := strings.ToLower(strings.TrimSpace(raw)) + if trimmed == "" { + return "", errors.New("invalid ControlPersist: value cannot be empty") + } + if _, _, _, err := ParseControlPersist(trimmed); err != nil { + return "", fmt.Errorf("invalid ControlPersist %q: %w", raw, err) + } + return trimmed, nil + }, + }, + { + CanonicalKey: OptionConnectTimeout, + OpenSSHName: "ConnectTimeout", + Class: OptionClassTransport, + Normalize: func(raw string) (string, error) { + return normalizeOptionalDurationOption("ConnectTimeout", raw) + }, + }, + { + CanonicalKey: OptionServerAliveInterval, + OpenSSHName: "ServerAliveInterval", + Class: OptionClassTransport, + Normalize: func(raw string) (string, error) { + return normalizeOptionalDurationOption("ServerAliveInterval", raw) + }, + }, + { + CanonicalKey: OptionServerAliveCountMax, + OpenSSHName: "ServerAliveCountMax", + Class: OptionClassTransport, + Normalize: func(raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + val, err := strconv.Atoi(trimmed) + if err != nil || val < 0 { + return "", fmt.Errorf("invalid ServerAliveCountMax %q: must be a non-negative integer", raw) + } + return strconv.Itoa(val), nil + }, + }, + { + CanonicalKey: OptionTCPKeepAlive, + OpenSSHName: "TCPKeepAlive", + Class: OptionClassTransport, + Normalize: func(raw string) (string, error) { + return normalizeYesNoOption("TCPKeepAlive", raw) + }, + }, + { + CanonicalKey: OptionStrictHostKeyChecking, + OpenSSHName: "StrictHostKeyChecking", + Class: OptionClassTrust, + Normalize: ParseStrictHostKeyChecking, + }, + { + CanonicalKey: OptionUserKnownHostsFile, + OpenSSHName: "UserKnownHostsFile", + Class: OptionClassTrust, + Normalize: func(raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", errors.New("invalid UserKnownHostsFile: value cannot be empty") + } + return trimmed, nil + }, + }, + { + CanonicalKey: OptionPubkeyAuthentication, + OpenSSHName: "PubkeyAuthentication", + Class: OptionClassAuth, + Normalize: func(raw string) (string, error) { + return normalizeYesNoOption("PubkeyAuthentication", raw) + }, + }, + { + CanonicalKey: OptionPasswordAuthentication, + OpenSSHName: "PasswordAuthentication", + Class: OptionClassAuth, + Normalize: func(raw string) (string, error) { + return normalizeYesNoOption("PasswordAuthentication", raw) + }, + }, + { + CanonicalKey: OptionKbdInteractiveAuthentication, + OpenSSHName: "KbdInteractiveAuthentication", + Class: OptionClassAuth, + Aliases: []string{"ChallengeResponseAuthentication", "challenge_response_authentication"}, + Normalize: func(raw string) (string, error) { + return normalizeYesNoOption("KbdInteractiveAuthentication", raw) + }, + }, + { + CanonicalKey: OptionIdentitiesOnly, + OpenSSHName: "IdentitiesOnly", + Class: OptionClassAuth, + Normalize: func(raw string) (string, error) { + return normalizeYesNoOption("IdentitiesOnly", raw) + }, + }, + { + CanonicalKey: OptionPreferredAuthentications, + OpenSSHName: "PreferredAuthentications", + Class: OptionClassAuth, + AllowList: true, + Normalize: normalizePreferredAuthentications, + }, + { + CanonicalKey: OptionBatchMode, + OpenSSHName: "BatchMode", + Class: OptionClassAuth, + Normalize: func(raw string) (string, error) { + return normalizeYesNoOption("BatchMode", raw) + }, + }, + { + CanonicalKey: OptionHostKeyAlgorithms, + OpenSSHName: "HostKeyAlgorithms", + Class: OptionClassTransport, + AllowList: true, + Normalize: func(raw string) (string, error) { + defaults := cryptoSSH.SupportedAlgorithms().HostKeys + return normalizeAlgorithmSpec("HostKeyAlgorithms", raw, defaults, allSupportedAlgorithms( + defaults, + cryptoSSH.InsecureAlgorithms().HostKeys, + )) + }, + }, + { + CanonicalKey: OptionCiphers, + OpenSSHName: "Ciphers", + Class: OptionClassTransport, + AllowList: true, + Normalize: func(raw string) (string, error) { + defaults := cryptoSSH.SupportedAlgorithms().Ciphers + return normalizeAlgorithmSpec("Ciphers", raw, defaults, allSupportedAlgorithms( + defaults, + cryptoSSH.InsecureAlgorithms().Ciphers, + )) + }, + }, + { + CanonicalKey: OptionMACs, + OpenSSHName: "MACs", + Class: OptionClassTransport, + AllowList: true, + Normalize: func(raw string) (string, error) { + defaults := cryptoSSH.SupportedAlgorithms().MACs + return normalizeAlgorithmSpec("MACs", raw, defaults, allSupportedAlgorithms( + defaults, + cryptoSSH.InsecureAlgorithms().MACs, + )) + }, + }, + { + CanonicalKey: OptionKexAlgorithms, + OpenSSHName: "KexAlgorithms", + Class: OptionClassTransport, + AllowList: true, + Normalize: func(raw string) (string, error) { + defaults := cryptoSSH.SupportedAlgorithms().KeyExchanges + return normalizeAlgorithmSpec("KexAlgorithms", raw, defaults, allSupportedAlgorithms( + defaults, + cryptoSSH.InsecureAlgorithms().KeyExchanges, + )) + }, + }, +} + +var optionLookupMap = buildOptionLookupMap() + +func buildOptionLookupMap() map[string]OptionSpec { + m := make(map[string]OptionSpec, len(optionRegistry)*3) + for _, spec := range optionRegistry { + m[strings.ToLower(spec.CanonicalKey)] = spec + m[strings.ToLower(spec.OpenSSHName)] = spec + for _, alias := range spec.Aliases { + m[strings.ToLower(alias)] = spec + } + } + return m +} + +// RegisteredOptionSpecs returns a copy of all registered OpenSSH option specs. +func RegisteredOptionSpecs() []OptionSpec { + return slices.Clone(optionRegistry) +} + +// LookupOptionSpec resolves a key (in either snake_case or OpenSSH case-insensitive format) +// to its OptionSpec. +func LookupOptionSpec(key string) (OptionSpec, bool) { + trimmed := strings.ToLower(strings.TrimSpace(key)) + if trimmed == "" { + return OptionSpec{}, false + } + spec, ok := optionLookupMap[trimmed] + return spec, ok +} + +// ParseStrictHostKeyChecking validates and normalizes StrictHostKeyChecking values. +func ParseStrictHostKeyChecking(raw string) (string, error) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "yes", "true": + return StrictHostKeyCheckingYes, nil + case "no", "off", "false": + return StrictHostKeyCheckingNo, nil + case "ask": + return StrictHostKeyCheckingAsk, nil + case "accept-new": + return StrictHostKeyCheckingAcceptNew, nil + default: + return "", fmt.Errorf("unsupported StrictHostKeyChecking option %q (expected yes, no, ask, accept-new, or off)", raw) + } +} + +func normalizeYesNoOption(name, raw string) (string, error) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "yes", "true": + return "yes", nil + case "no", "false": + return "no", nil + default: + return "", fmt.Errorf("invalid %s option %q (expected yes or no)", name, raw) + } +} + +func normalizeOptionalDurationOption(name, raw string) (string, error) { + trimmed := strings.ToLower(strings.TrimSpace(raw)) + switch trimmed { + case "none", "0", "0s": + return "0", nil + } + if _, err := parseOpenSSHDuration(trimmed); err != nil { + return "", fmt.Errorf("invalid %s %q: %w", name, raw, err) + } + return strings.TrimSpace(raw), nil +} + +func normalizePreferredAuthentications(raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", errors.New("invalid PreferredAuthentications: value cannot be empty") + } + parts := strings.Split(trimmed, ",") + var normalized []string + for _, p := range parts { + token := strings.ToLower(strings.TrimSpace(p)) + switch token { + case "publickey", "keyboard-interactive", "password", "gssapi-with-mic", "hostbased": + if !slices.Contains(normalized, token) { + normalized = append(normalized, token) + } + default: + return "", fmt.Errorf("invalid PreferredAuthentications method %q", p) + } + } + if len(normalized) == 0 { + return "", errors.New("invalid PreferredAuthentications: no methods specified") + } + return strings.Join(normalized, ","), nil +} + +func allSupportedAlgorithms(secure, insecure []string) []string { + out := make([]string, 0, len(secure)+len(insecure)) + out = append(out, secure...) + for _, a := range insecure { + if !slices.Contains(out, a) { + out = append(out, a) + } + } + return out +} + +func normalizeAlgorithmSpec(name, raw string, defaults, supported []string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", fmt.Errorf("invalid %s: value cannot be empty", name) + } + checkSpec := trimmed + var modifier byte + if checkSpec[0] == '+' || checkSpec[0] == '-' || checkSpec[0] == '^' { + modifier = checkSpec[0] + checkSpec = strings.TrimSpace(checkSpec[1:]) + } + if checkSpec == "" { + return "", fmt.Errorf("invalid %s %q: empty algorithm list", name, raw) + } + for part := range strings.SplitSeq(checkSpec, ",") { + algo := strings.TrimSpace(part) + if algo == "" { + return "", fmt.Errorf("invalid %s %q: empty algorithm name in list", name, raw) + } + if modifier != '-' && !slices.Contains(supported, algo) { + return "", fmt.Errorf("invalid %s %q: unsupported algorithm %q", name, raw, algo) + } + } + if _, err := ResolveAlgorithmList(trimmed, defaults); err != nil { + return "", fmt.Errorf("invalid %s %q: %w", name, raw, err) + } + return trimmed, nil +} + +// FilterSupportedListValue filters a comma-separated option value (for algorithm lists +// or PreferredAuthentications) down to tokens supported by this implementation. +// Used when leniently parsing ~/.ssh/config so OpenSSH-only algorithms (such as +// sntrup761x25519-sha512@openssh.com or umac-128-etm@openssh.com) do not reject the +// remaining supported algorithms. +func FilterSupportedListValue(canonicalKey, raw string) string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "" + } + var supported []string + switch canonicalKey { + case OptionPreferredAuthentications: + supported = []string{"publickey", "keyboard-interactive", "password"} + case OptionHostKeyAlgorithms: + supported = allSupportedAlgorithms(cryptoSSH.SupportedAlgorithms().HostKeys, cryptoSSH.InsecureAlgorithms().HostKeys) + case OptionCiphers: + supported = allSupportedAlgorithms(cryptoSSH.SupportedAlgorithms().Ciphers, cryptoSSH.InsecureAlgorithms().Ciphers) + case OptionMACs: + supported = allSupportedAlgorithms(cryptoSSH.SupportedAlgorithms().MACs, cryptoSSH.InsecureAlgorithms().MACs) + case OptionKexAlgorithms: + supported = allSupportedAlgorithms(cryptoSSH.SupportedAlgorithms().KeyExchanges, cryptoSSH.InsecureAlgorithms().KeyExchanges) + default: + return raw + } + + var prefix string + body := trimmed + if canonicalKey != OptionPreferredAuthentications && (body[0] == '+' || body[0] == '-' || body[0] == '^') { + prefix = body[:1] + body = strings.TrimSpace(body[1:]) + } + var kept []string + for part := range strings.SplitSeq(body, ",") { + tok := strings.TrimSpace(part) + if canonicalKey == OptionPreferredAuthentications { + tok = strings.ToLower(tok) + } + if tok != "" && (prefix == "-" || slices.Contains(supported, tok)) { + kept = append(kept, tok) + } + } + if len(kept) == 0 { + return "" + } + return prefix + strings.Join(kept, ",") +} + +// ResolveAlgorithmList resolves an OpenSSH algorithm specification (explicit list, +// +append, -remove, or ^prepend) against the provided default list. +func ResolveAlgorithmList(spec string, defaults []string) ([]string, error) { + trimmed := strings.TrimSpace(spec) + if trimmed == "" { + return slices.Clone(defaults), nil + } + + var modifier byte + if trimmed[0] == '+' || trimmed[0] == '-' || trimmed[0] == '^' { + modifier = trimmed[0] + trimmed = strings.TrimSpace(trimmed[1:]) + } + if trimmed == "" { + return nil, errors.New("empty algorithm list") + } + + var items []string + for part := range strings.SplitSeq(trimmed, ",") { + algo := strings.TrimSpace(part) + if algo == "" { + return nil, errors.New("empty algorithm name in list") + } + if !slices.Contains(items, algo) { + items = append(items, algo) + } + } + + var result []string + switch modifier { + case 0: + result = items + case '+': + result = slices.Clone(defaults) + for _, algo := range items { + if !slices.Contains(result, algo) { + result = append(result, algo) + } + } + case '^': + result = slices.Clone(items) + for _, algo := range defaults { + if !slices.Contains(result, algo) { + result = append(result, algo) + } + } + case '-': + for _, algo := range defaults { + if !slices.Contains(items, algo) { + result = append(result, algo) + } + } + } + + if len(result) == 0 { + return nil, errors.New("resolved algorithm list is empty") + } + return result, nil +} + +// ValidateControlPathSyntax verifies raw ControlPath string token syntax without environment dependencies. +func ValidateControlPathSyntax(rawPath string) error { + if rawPath == "" || rawPath == "none" { + return nil + } + + if strings.Contains(rawPath, "${") { + idx := 0 + for { + start := strings.Index(rawPath[idx:], "${") + if start == -1 { + break + } + start += idx + end := strings.Index(rawPath[start:], "}") + if end == -1 { + return fmt.Errorf("unclosed environment variable in ControlPath: %q", rawPath[start:]) + } + end += start + varName := rawPath[start+2 : end] + if varName == "" || !isValidEnvIdentifier(varName) { + return fmt.Errorf("invalid environment variable name ${%s} in ControlPath", varName) + } + idx = end + 1 + } + } + + for i := 0; i < len(rawPath); i++ { + if rawPath[i] == '%' { + if i+1 >= len(rawPath) { + return errors.New("trailing '%' in ControlPath") + } + i++ + switch rawPath[i] { + case '%', 'h', 'p', 'r', 'n', 'u', 'l', 'L', 'i', 'd', 'j', 'C': + default: + return fmt.Errorf("unknown ControlPath token %%%c", rawPath[i]) + } + } + } + return nil +} + +func isValidEnvIdentifier(s string) bool { + if len(s) == 0 { + return false + } + for i, r := range s { + if i == 0 { + if (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') && r != '_' { + return false + } + } else { + if (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '_' { + return false + } + } + } + return true +} + +// Options represents an extensible set of OpenSSH client options. +// On disk (YAML/JSON) it serializes using canonical snake_case keys, while +// accepting both snake_case and OpenSSH standard names on input. +type Options struct { + values map[string]string + // ImplicitTier4 is true when multiplexing was resolved solely from ~/.ssh/config + // without any CLI flag or xops node/global configuration. + ImplicitTier4 bool `json:"-" yaml:"-"` +} + +// NewOptions creates an Options instance from alternating key, value pairs. +func NewOptions(kv ...string) (*Options, error) { + if len(kv)%2 != 0 { + return nil, fmt.Errorf("%w: odd number of key-value arguments", ErrSSHOptionsValidation) + } + opts := &Options{} + for i := 0; i < len(kv); i += 2 { + if err := opts.Set(kv[i], kv[i+1]); err != nil { + return nil, err + } + } + return opts, nil +} + +// MustOptions creates an Options instance from alternating key, value pairs and panics on error. +func MustOptions(kv ...string) *Options { + opts, err := NewOptions(kv...) + if err != nil { + panic(err) + } + return opts +} + +// OptionsFromControl constructs an Options instance from a ControlConfig. +func OptionsFromControl(ctrl *ControlConfig) *Options { + if ctrl == nil { + return nil + } + if ctrl.Master == "" && ctrl.Path == "" && ctrl.Persist == "" { + return nil + } + opts := &Options{ImplicitTier4: ctrl.ImplicitTier4} + if ctrl.Master != "" { + opts.setRaw(OptionControlMaster, string(ctrl.Master)) + } + if ctrl.Path != "" { + opts.setRaw(OptionControlPath, ctrl.Path) + } + if ctrl.Persist != "" { + opts.setRaw(OptionControlPersist, ctrl.Persist) + } + return opts +} + +// IsEmpty reports whether no options are configured. +func (o *Options) IsEmpty() bool { + return o == nil || len(o.values) == 0 +} + +// IsZero implements yaml.IsZeroer so empty non-nil Options are omitted with omitempty. +func (o Options) IsZero() bool { + return len(o.values) == 0 +} + +// Clone returns a deep copy of Options. +func (o *Options) Clone() *Options { + if o == nil { + return nil + } + cloned := &Options{ + ImplicitTier4: o.ImplicitTier4, + } + if len(o.values) > 0 { + cloned.values = make(map[string]string, len(o.values)) + for k, v := range o.values { + cloned.values[k] = v + } + } + return cloned +} + +// Equal reports whether two Options contain identical key-value pairs. +func (o *Options) Equal(other *Options) bool { + if o.IsEmpty() && other.IsEmpty() { + return true + } + if o.IsEmpty() || other.IsEmpty() { + return false + } + if len(o.values) != len(other.values) { + return false + } + for k, v := range o.values { + if other.values[k] != v { + return false + } + } + return true +} + +// Set validates and sets an option by either snake_case or OpenSSH key name. +// Passing an empty value removes the option. +func (o *Options) Set(key, rawValue string) error { + if o == nil { + return fmt.Errorf("%w: options receiver is nil", ErrSSHOptionsValidation) + } + spec, ok := LookupOptionSpec(key) + if !ok { + return fmt.Errorf("%w: unknown ssh option %q", ErrSSHOptionsValidation, key) + } + if strings.TrimSpace(rawValue) == "" { + o.Delete(spec.CanonicalKey) + return nil + } + normalized, err := spec.Normalize(rawValue) + if err != nil { + return fmt.Errorf("%w: %w", ErrSSHOptionsValidation, err) + } + o.setRaw(spec.CanonicalKey, normalized) + return nil +} + +// setRaw stores a value directly under its canonical key without running Normalize. +func (o *Options) setRaw(canonicalKey, val string) { + if val == "" { + o.Delete(canonicalKey) + return + } + if o.values == nil { + o.values = make(map[string]string) + } + o.values[canonicalKey] = val +} + +// Delete removes an option by key. +func (o *Options) Delete(key string) { + if o == nil || len(o.values) == 0 { + return + } + if spec, ok := LookupOptionSpec(key); ok { + delete(o.values, spec.CanonicalKey) + } else { + delete(o.values, key) + } + if len(o.values) == 0 { + o.values = nil + } +} + +// Get returns the normalized string value for key, or "" if unset. +func (o *Options) Get(key string) string { + if o == nil || len(o.values) == 0 { + return "" + } + if spec, ok := LookupOptionSpec(key); ok { + return o.values[spec.CanonicalKey] + } + return o.values[key] +} + +// Has reports whether key is explicitly configured in o. +func (o *Options) Has(key string) bool { + if o == nil || len(o.values) == 0 { + return false + } + if spec, ok := LookupOptionSpec(key); ok { + _, exists := o.values[spec.CanonicalKey] + return exists + } + _, exists := o.values[key] + return exists +} + +// Map returns a defensive copy of the canonical snake_case key-value map. +func (o *Options) Map() map[string]string { + if o.IsEmpty() { + return nil + } + cp := make(map[string]string, len(o.values)) + for k, v := range o.values { + cp[k] = v + } + return cp +} + +// ControlMaster returns the configured ControlMasterMode, or "" if unset. +func (o *Options) ControlMaster() ControlMasterMode { + return ControlMasterMode(o.Get(OptionControlMaster)) +} + +// SetControlMaster sets the ControlMasterMode directly. +func (o *Options) SetControlMaster(m ControlMasterMode) { + o.setRaw(OptionControlMaster, string(m)) +} + +// ControlPath returns the configured ControlPath, or "" if unset. +func (o *Options) ControlPath() string { + return o.Get(OptionControlPath) +} + +// SetControlPath sets the ControlPath directly. +func (o *Options) SetControlPath(path string) { + o.setRaw(OptionControlPath, path) +} + +// ControlPersist returns the configured ControlPersist, or "" if unset. +func (o *Options) ControlPersist() string { + return o.Get(OptionControlPersist) +} + +// SetControlPersist sets the ControlPersist directly. +func (o *Options) SetControlPersist(persist string) { + o.setRaw(OptionControlPersist, persist) +} + +// ControlConfig extracts the multiplexing ControlConfig view from o, or returns +// nil if no multiplexing options are present. +func (o *Options) ControlConfig() *ControlConfig { + if o.IsEmpty() { + return nil + } + master := o.ControlMaster() + path := o.ControlPath() + persist := o.ControlPersist() + if master == "" && path == "" && persist == "" { + return nil + } + return &ControlConfig{ + Master: master, + Path: path, + Persist: persist, + ImplicitTier4: o.ImplicitTier4, + } +} + +// ConnectTimeout returns the configured ConnectTimeout duration, or 0 if unset/disabled. +func (o *Options) ConnectTimeout() time.Duration { + val := o.Get(OptionConnectTimeout) + if val == "" || val == "0" || strings.EqualFold(val, "none") { + return 0 + } + d, err := parseOpenSSHDuration(strings.ToLower(val)) + if err != nil { + return 0 + } + return d +} + +// ServerAliveInterval returns the configured ServerAliveInterval, or 0 if unset/disabled. +func (o *Options) ServerAliveInterval() time.Duration { + val := o.Get(OptionServerAliveInterval) + if val == "" || val == "0" || strings.EqualFold(val, "none") { + return 0 + } + d, err := parseOpenSSHDuration(strings.ToLower(val)) + if err != nil { + return 0 + } + return d +} + +// ServerAliveCountMax returns the configured ServerAliveCountMax (defaulting to 3 if unset). +func (o *Options) ServerAliveCountMax() int { + val := o.Get(OptionServerAliveCountMax) + if val == "" { + return 3 + } + n, err := strconv.Atoi(val) + if err != nil || n < 0 { + return 3 + } + return n +} + +// TCPKeepAlive returns (enabled, true) if TCPKeepAlive is explicitly set, or (false, false) if unset. +func (o *Options) TCPKeepAlive() (bool, bool) { + return o.boolOption(OptionTCPKeepAlive) +} + +// StrictHostKeyChecking returns the normalized StrictHostKeyChecking mode ("yes", "no", "ask", "accept-new"), or "" if unset. +func (o *Options) StrictHostKeyChecking() string { + return o.Get(OptionStrictHostKeyChecking) +} + +// UserKnownHostsFile returns the configured UserKnownHostsFile path, or "" if unset. +func (o *Options) UserKnownHostsFile() string { + return o.Get(OptionUserKnownHostsFile) +} + +// PubkeyAuthentication returns (enabled, true) if PubkeyAuthentication is set, or (true, false) if unset. +func (o *Options) PubkeyAuthentication() (bool, bool) { + return o.boolOption(OptionPubkeyAuthentication) +} + +// PasswordAuthentication returns (enabled, true) if PasswordAuthentication is set, or (true, false) if unset. +func (o *Options) PasswordAuthentication() (bool, bool) { + return o.boolOption(OptionPasswordAuthentication) +} + +// KbdInteractiveAuthentication returns (enabled, true) if KbdInteractiveAuthentication is set, or (true, false) if unset. +func (o *Options) KbdInteractiveAuthentication() (bool, bool) { + return o.boolOption(OptionKbdInteractiveAuthentication) +} + +// IdentitiesOnly reports whether IdentitiesOnly is set to "yes". +func (o *Options) IdentitiesOnly() bool { + v, ok := o.boolOption(OptionIdentitiesOnly) + return ok && v +} + +// PreferredAuthentications returns the configured list of preferred authentication methods, or nil if unset. +func (o *Options) PreferredAuthentications() []string { + val := o.Get(OptionPreferredAuthentications) + if val == "" { + return nil + } + return strings.Split(val, ",") +} + +// BatchMode reports whether BatchMode is set to "yes". +func (o *Options) BatchMode() bool { + v, ok := o.boolOption(OptionBatchMode) + return ok && v +} + +// HostKeyAlgorithms returns the raw HostKeyAlgorithms specification, or "" if unset. +func (o *Options) HostKeyAlgorithms() string { + return o.Get(OptionHostKeyAlgorithms) +} + +// Ciphers returns the raw Ciphers specification, or "" if unset. +func (o *Options) Ciphers() string { + return o.Get(OptionCiphers) +} + +// MACs returns the raw MACs specification, or "" if unset. +func (o *Options) MACs() string { + return o.Get(OptionMACs) +} + +// KexAlgorithms returns the raw KexAlgorithms specification, or "" if unset. +func (o *Options) KexAlgorithms() string { + return o.Get(OptionKexAlgorithms) +} + +// FilterByClasses returns a new Options containing only keys matching the allowed OptionClasses. +// Returns nil if the filtered result is empty. +func (o *Options) FilterByClasses(allowed ...OptionClass) *Options { + if o.IsEmpty() || len(allowed) == 0 { + return nil + } + res := &Options{ImplicitTier4: o.ImplicitTier4} + for k, v := range o.values { + spec, ok := LookupOptionSpec(k) + if !ok { + continue + } + if slices.Contains(allowed, spec.Class) { + res.setRaw(k, v) + } + } + if res.IsEmpty() { + return nil + } + return res +} + +// StripClasses removes all options belonging to any of the specified OptionClasses in place. +func (o *Options) StripClasses(classes ...OptionClass) { + if o.IsEmpty() || len(classes) == 0 { + return + } + for k := range o.values { + spec, ok := LookupOptionSpec(k) + if ok && slices.Contains(classes, spec.Class) { + delete(o.values, k) + } + } +} + +func (o *Options) boolOption(canonicalKey string) (bool, bool) { + val := o.Get(canonicalKey) + switch strings.ToLower(val) { + case "yes", "true": + return true, true + case "no", "false": + return false, true + default: + return false, false + } +} + +// Validate validates all individual option values and standalone cross-field rules. +func (o *Options) Validate() error { + if o.IsEmpty() { + return nil + } + for k, v := range o.values { + spec, ok := LookupOptionSpec(k) + if !ok { + return fmt.Errorf("%w: unknown ssh option %q", ErrSSHOptionsValidation, k) + } + if _, err := spec.Normalize(v); err != nil { + return fmt.Errorf("%w: %w", ErrSSHOptionsValidation, err) + } + } + + normMaster := strings.ToLower(strings.TrimSpace(string(o.ControlMaster()))) + persist := o.ControlPersist() + path := o.ControlPath() + + if normMaster == string(ControlMasterNo) && persist != "" && + !strings.EqualFold(persist, "no") && !strings.EqualFold(persist, "false") { + return fmt.Errorf("%w: ControlPersist %q cannot be specified when ControlMaster is 'no'", ErrSSHOptionsValidation, persist) + } + + if path == "none" && isActiveControlMaster(normMaster) { + return fmt.Errorf("%w: ControlPath cannot be 'none' when ControlMaster is %q", ErrSSHOptionsValidation, o.ControlMaster()) + } + + return nil +} + +// ValidateEffective validates cross-field constraints on a resolved effective Options set. +func (o *Options) ValidateEffective() error { + if o.IsEmpty() { + return nil + } + if err := o.Validate(); err != nil { + return err + } + normMaster := strings.ToLower(strings.TrimSpace(string(o.ControlMaster()))) + path := o.ControlPath() + persist := o.ControlPersist() + if isActiveControlMaster(normMaster) { + if path == "" || path == "none" { + return fmt.Errorf("%w: ControlMaster %q requires non-empty ControlPath", ErrSSHOptionsValidation, o.ControlMaster()) + } + } + if persist != "" && !strings.EqualFold(persist, "no") && !strings.EqualFold(persist, "false") && path == "" { + return fmt.Errorf("%w: ControlPath is required when ControlPersist is configured", ErrSSHOptionsValidation) + } + return nil +} + +func isActiveControlMaster(mode string) bool { + return mode == string(ControlMasterYes) || + mode == string(ControlMasterAsk) || + mode == string(ControlMasterAuto) || + mode == string(ControlMasterAutoAsk) +} + +// MergeOptionLayers merges a slice of Options from highest priority to lowest priority +// using first-match-wins per option key, without applying cross-field effective finalization. +func MergeOptionLayers(layers ...*Options) *Options { + res := &Options{} + hasExplicitPersist := false + hasImplicitTier4 := false + for _, layer := range layers { + if layer.IsEmpty() { + continue + } + if !layer.ImplicitTier4 && layer.ControlPersist() != "" { + hasExplicitPersist = true + } + if layer.ImplicitTier4 { + hasImplicitTier4 = true + } + for k, v := range layer.values { + if _, exists := res.values[k]; !exists { + res.setRaw(k, v) + } + } + } + if res.IsEmpty() { + return nil + } + res.ImplicitTier4 = !hasExplicitPersist && hasImplicitTier4 + return res +} + +func finalizeEffectiveOptions(opts *Options) *Options { + if opts.IsEmpty() { + return nil + } + if opts.ControlPath() == "none" { + opts.SetControlMaster(ControlMasterNo) + opts.Delete(OptionControlPersist) + } + return opts +} + +// ResolveOptions merges a higher-priority child Options with a lower-priority parent Options. +// Every option is inherited orthogonally unless overridden by child. +// Special multiplexing rule: if the effective control_path is "none", multiplexing is disabled +// (control_master="no", control_persist=""). +func ResolveOptions(child, parent *Options) (*Options, error) { + return finalizeEffectiveOptions(MergeOptionLayers(child, parent)), nil +} + +// EffectiveOptions resolves a hierarchy of Options from highest priority to lowest priority. +func EffectiveOptions(layers ...*Options) (*Options, error) { + return finalizeEffectiveOptions(MergeOptionLayers(layers...)), nil +} + +// MarshalYAML serializes Options as a YAML mapping using canonical snake_case keys in registry order. +func (o Options) MarshalYAML() (any, error) { + node := &yaml.Node{ + Kind: yaml.MappingNode, + Tag: "!!map", + } + if len(o.values) == 0 { + return node, nil + } + for _, spec := range optionRegistry { + val, ok := o.values[spec.CanonicalKey] + if !ok || val == "" { + continue + } + node.Content = append(node.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: spec.CanonicalKey}, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: val}, + ) + } + return node, nil +} + +// UnmarshalYAML decodes a YAML mapping into Options, accepting both canonical snake_case +// and OpenSSH standard option names while rejecting unknown or duplicate options. +func (o *Options) UnmarshalYAML(value *yaml.Node) error { + if value == nil || value.Tag == "!!null" { + return nil + } + if value.Kind != yaml.MappingNode { + return fmt.Errorf("%w: expected mapping for ssh_options", ErrSSHOptionsValidation) + } + + seen := make(map[string]string, len(value.Content)/2) + parsed := &Options{} + for i := 0; i < len(value.Content); i += 2 { + keyNode := value.Content[i] + valNode := value.Content[i+1] + rawKey := keyNode.Value + + spec, ok := LookupOptionSpec(rawKey) + if !ok { + return fmt.Errorf("%w: unknown ssh option %q in ssh_options", ErrSSHOptionsValidation, rawKey) + } + if prevKey, dup := seen[spec.CanonicalKey]; dup { + return fmt.Errorf("%w: duplicate ssh option %q (conflicts with %q)", ErrSSHOptionsValidation, rawKey, prevKey) + } + seen[spec.CanonicalKey] = rawKey + + rawVal, err := extractYAMLOptionValue(spec, valNode) + if err != nil { + return fmt.Errorf("%w: option %q: %w", ErrSSHOptionsValidation, rawKey, err) + } + normVal, err := spec.Normalize(rawVal) + if err != nil { + return fmt.Errorf("%w: %w", ErrSSHOptionsValidation, err) + } + parsed.setRaw(spec.CanonicalKey, normVal) + } + + if err := parsed.Validate(); err != nil { + return err + } + *o = *parsed + return nil +} + +func extractYAMLOptionValue(spec OptionSpec, valNode *yaml.Node) (string, error) { + if valNode == nil || valNode.Tag == "!!null" { + return "", errors.New("value cannot be null") + } + switch valNode.Kind { + case yaml.ScalarNode: + return valNode.Value, nil + case yaml.SequenceNode: + if !spec.AllowList { + return "", fmt.Errorf("%s does not accept a list value", spec.OpenSSHName) + } + var items []string + for _, elem := range valNode.Content { + if elem.Kind != yaml.ScalarNode || elem.Tag == "!!null" { + return "", fmt.Errorf("%s list elements must be scalar strings", spec.OpenSSHName) + } + items = append(items, elem.Value) + } + return strings.Join(items, ","), nil + default: + return "", fmt.Errorf("unexpected YAML value kind for %s", spec.OpenSSHName) + } +} + +// MarshalJSON serializes Options as a JSON object with canonical snake_case keys in deterministic order. +func (o Options) MarshalJSON() ([]byte, error) { + if len(o.values) == 0 { + return []byte("{}"), nil + } + var buf bytes.Buffer + buf.WriteByte('{') + first := true + for _, spec := range optionRegistry { + val, ok := o.values[spec.CanonicalKey] + if !ok || val == "" { + continue + } + if !first { + buf.WriteByte(',') + } + first = false + keyBytes, _ := json.Marshal(spec.CanonicalKey) + valBytes, _ := json.Marshal(val) + buf.Write(keyBytes) + buf.WriteByte(':') + buf.Write(valBytes) + } + buf.WriteByte('}') + return buf.Bytes(), nil +} + +// UnmarshalJSON decodes a JSON object into Options, accepting both snake_case and OpenSSH option names. +func (o *Options) UnmarshalJSON(data []byte) error { + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return nil + } + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(trimmed, &rawMap); err != nil { + return fmt.Errorf("%w: decode ssh_options json: %w", ErrSSHOptionsValidation, err) + } + seen := make(map[string]string, len(rawMap)) + parsed := &Options{} + for rawKey, rawMsg := range rawMap { + spec, ok := LookupOptionSpec(rawKey) + if !ok { + return fmt.Errorf("%w: unknown ssh option %q", ErrSSHOptionsValidation, rawKey) + } + if prevKey, dup := seen[spec.CanonicalKey]; dup { + return fmt.Errorf("%w: duplicate ssh option %q (conflicts with %q)", ErrSSHOptionsValidation, rawKey, prevKey) + } + seen[spec.CanonicalKey] = rawKey + + rawVal, err := extractJSONOptionValue(spec, rawMsg) + if err != nil { + return fmt.Errorf("%w: option %q: %w", ErrSSHOptionsValidation, rawKey, err) + } + normVal, err := spec.Normalize(rawVal) + if err != nil { + return fmt.Errorf("%w: %w", ErrSSHOptionsValidation, err) + } + parsed.setRaw(spec.CanonicalKey, normVal) + } + if err := parsed.Validate(); err != nil { + return err + } + *o = *parsed + return nil +} + +func extractJSONOptionValue(spec OptionSpec, rawMsg json.RawMessage) (string, error) { + var strVal string + if err := json.Unmarshal(rawMsg, &strVal); err == nil { + return strVal, nil + } + var boolVal bool + if err := json.Unmarshal(rawMsg, &boolVal); err == nil { + if boolVal { + return "yes", nil + } + return "no", nil + } + var intVal int64 + if err := json.Unmarshal(rawMsg, &intVal); err == nil { + return strconv.FormatInt(intVal, 10), nil + } + if spec.AllowList { + var listVal []string + if err := json.Unmarshal(rawMsg, &listVal); err == nil { + return strings.Join(listVal, ","), nil + } + } + return "", fmt.Errorf("invalid value format for %s", spec.OpenSSHName) +} diff --git a/core/ssh/ssh_options_test.go b/core/ssh/ssh_options_test.go new file mode 100644 index 00000000..8cc634bf --- /dev/null +++ b/core/ssh/ssh_options_test.go @@ -0,0 +1,1059 @@ +package ssh + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "encoding/pem" + "errors" + "net" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + cryptoSSH "golang.org/x/crypto/ssh" + "gopkg.in/yaml.v3" +) + +func TestOptionRegistry_LookupAndNormalization(t *testing.T) { + cases := []struct { + key string + rawValue string + wantKey string + wantVal string + }{ + {"control_master", "Auto", OptionControlMaster, "auto"}, + {"ControlMaster", "YES", OptionControlMaster, "yes"}, + {"control_path", "~/.ssh/cm-%C", OptionControlPath, "~/.ssh/cm-%C"}, + {"ControlPath", "NONE", OptionControlPath, "none"}, + {"control_persist", "10m", OptionControlPersist, "10m"}, + {"ControlPersist", "Yes", OptionControlPersist, "yes"}, + {"connect_timeout", "15", OptionConnectTimeout, "15"}, + {"ConnectTimeout", "10s", OptionConnectTimeout, "10s"}, + {"server_alive_interval", "30", OptionServerAliveInterval, "30"}, + {"ServerAliveInterval", "1m", OptionServerAliveInterval, "1m"}, + {"server_alive_count_max", "3", OptionServerAliveCountMax, "3"}, + {"ServerAliveCountMax", "5", OptionServerAliveCountMax, "5"}, + {"tcp_keep_alive", "yes", OptionTCPKeepAlive, "yes"}, + {"TCPKeepAlive", "false", OptionTCPKeepAlive, "no"}, + {"strict_host_key_checking", "accept-new", OptionStrictHostKeyChecking, "accept-new"}, + {"StrictHostKeyChecking", "off", OptionStrictHostKeyChecking, "no"}, + {"user_known_hosts_file", "/dev/null", OptionUserKnownHostsFile, "/dev/null"}, + {"UserKnownHostsFile", "none", OptionUserKnownHostsFile, "none"}, + {"pubkey_authentication", "no", OptionPubkeyAuthentication, "no"}, + {"PubkeyAuthentication", "true", OptionPubkeyAuthentication, "yes"}, + {"password_authentication", "no", OptionPasswordAuthentication, "no"}, + {"PasswordAuthentication", "yes", OptionPasswordAuthentication, "yes"}, + {"kbd_interactive_authentication", "no", OptionKbdInteractiveAuthentication, "no"}, + {"KbdInteractiveAuthentication", "yes", OptionKbdInteractiveAuthentication, "yes"}, + {"ChallengeResponseAuthentication", "no", OptionKbdInteractiveAuthentication, "no"}, + {"identities_only", "yes", OptionIdentitiesOnly, "yes"}, + {"IdentitiesOnly", "no", OptionIdentitiesOnly, "no"}, + {"preferred_authentications", "PublicKey, Password", OptionPreferredAuthentications, "publickey,password"}, + {"PreferredAuthentications", "keyboard-interactive", OptionPreferredAuthentications, "keyboard-interactive"}, + {"batch_mode", "yes", OptionBatchMode, "yes"}, + {"BatchMode", "no", OptionBatchMode, "no"}, + {"host_key_algorithms", "+ssh-rsa", OptionHostKeyAlgorithms, "+ssh-rsa"}, + {"HostKeyAlgorithms", "ssh-ed25519,ecdsa-sha2-nistp256", OptionHostKeyAlgorithms, "ssh-ed25519,ecdsa-sha2-nistp256"}, + {"ciphers", "^aes128-gcm@openssh.com", OptionCiphers, "^aes128-gcm@openssh.com"}, + {"Ciphers", "chacha20-poly1305@openssh.com", OptionCiphers, "chacha20-poly1305@openssh.com"}, + {"macs", "-hmac-sha1", OptionMACs, "-hmac-sha1"}, + {"MACs", "hmac-sha2-256", OptionMACs, "hmac-sha2-256"}, + {"kex_algorithms", "curve25519-sha256", OptionKexAlgorithms, "curve25519-sha256"}, + {"KexAlgorithms", "+diffie-hellman-group14-sha256", OptionKexAlgorithms, "+diffie-hellman-group14-sha256"}, + } + + opts := &Options{} + for _, tc := range cases { + spec, ok := LookupOptionSpec(tc.key) + if !ok { + t.Fatalf("expected LookupOptionSpec(%q) to succeed", tc.key) + } + if spec.CanonicalKey != tc.wantKey { + t.Fatalf("LookupOptionSpec(%q).CanonicalKey = %q, want %q", tc.key, spec.CanonicalKey, tc.wantKey) + } + if err := opts.Set(tc.key, tc.rawValue); err != nil { + t.Fatalf("opts.Set(%q, %q) unexpected error: %v", tc.key, tc.rawValue, err) + } + if got := opts.Get(tc.wantKey); got != tc.wantVal { + t.Fatalf("opts.Get(%q) after Set(%q, %q) = %q, want %q", tc.wantKey, tc.key, tc.rawValue, got, tc.wantVal) + } + } + + for _, removed := range []string{"compression", "Compression", "forward_agent", "ForwardAgent"} { + if _, ok := LookupOptionSpec(removed); ok { + t.Fatalf("expected %q to be removed from optionRegistry", removed) + } + } +} + +func TestOptions_YAMLAndJSONSerialization(t *testing.T) { + // Input YAML uses mixed snake_case and OpenSSH PascalCase + rawYAML := ` +ControlMaster: auto +control_path: ~/.ssh/cm-%r@%h:%p +ControlPersist: 10m +StrictHostKeyChecking: accept-new +ConnectTimeout: 15s +PreferredAuthentications: + - publickey + - password +` + var opts Options + if err := yaml.Unmarshal([]byte(rawYAML), &opts); err != nil { + t.Fatalf("Unmarshal YAML failed: %v", err) + } + if opts.ControlMaster() != ControlMasterAuto { + t.Fatalf("ControlMaster = %q, want auto", opts.ControlMaster()) + } + if opts.ControlPath() != "~/.ssh/cm-%r@%h:%p" { + t.Fatalf("ControlPath = %q", opts.ControlPath()) + } + if opts.ControlPersist() != "10m" { + t.Fatalf("ControlPersist = %q", opts.ControlPersist()) + } + if opts.StrictHostKeyChecking() != StrictHostKeyCheckingAcceptNew { + t.Fatalf("StrictHostKeyChecking = %q", opts.StrictHostKeyChecking()) + } + if opts.ConnectTimeout() != 15*time.Second { + t.Fatalf("ConnectTimeout = %v, want 15s", opts.ConnectTimeout()) + } + if got := opts.PreferredAuthentications(); !reflect.DeepEqual(got, []string{"publickey", "password"}) { + t.Fatalf("PreferredAuthentications = %v", got) + } + + // Marshal back to YAML must always emit canonical snake_case keys + marshaledYAML, err := yaml.Marshal(&opts) + if err != nil { + t.Fatalf("Marshal YAML failed: %v", err) + } + yamlStr := string(marshaledYAML) + for _, wantKey := range []string{ + "control_master: auto", + "control_path: ~/.ssh/cm-%r@%h:%p", + "control_persist: 10m", + "connect_timeout: 15s", + "strict_host_key_checking: accept-new", + "preferred_authentications: publickey,password", + } { + if !strings.Contains(yamlStr, wantKey) { + t.Fatalf("expected marshaled YAML to contain %q, got:\n%s", wantKey, yamlStr) + } + } + if strings.Contains(yamlStr, "ControlMaster") || strings.Contains(yamlStr, "StrictHostKeyChecking") { + t.Fatalf("expected marshaled YAML to use snake_case only, got:\n%s", yamlStr) + } + + // JSON round-trip with OpenSSH keys in input and snake_case in output + rawJSON := `{"ControlMaster":"auto","ControlPath":"~/.ssh/cm-%C","BatchMode":true,"ConnectTimeout":20}` + var jsonOpts Options + if err := json.Unmarshal([]byte(rawJSON), &jsonOpts); err != nil { + t.Fatalf("Unmarshal JSON failed: %v", err) + } + if !jsonOpts.BatchMode() || jsonOpts.ConnectTimeout() != 20*time.Second { + t.Fatalf("unexpected jsonOpts: %+v", jsonOpts) + } + outJSON, err := json.Marshal(&jsonOpts) + if err != nil { + t.Fatalf("Marshal JSON failed: %v", err) + } + if !strings.Contains(string(outJSON), `"control_master":"auto"`) || !strings.Contains(string(outJSON), `"batch_mode":"yes"`) { + t.Fatalf("unexpected marshaled JSON: %s", string(outJSON)) + } +} + +func TestOptions_RejectsUnknownAndDuplicateKeys(t *testing.T) { + var opts Options + err := yaml.Unmarshal([]byte("unknown_ssh_option: foo\n"), &opts) + if err == nil || !strings.Contains(err.Error(), "unknown ssh option") { + t.Fatalf("expected unknown ssh option error, got: %v", err) + } + + // Duplicate canonical key via snake_case + PascalCase in same YAML block + dupYAML := "control_master: auto\nControlMaster: no\n" + err = yaml.Unmarshal([]byte(dupYAML), &opts) + if err == nil || !strings.Contains(err.Error(), "duplicate ssh option") { + t.Fatalf("expected duplicate ssh option error, got: %v", err) + } +} + +func TestOptions_InheritanceAndControlBridge(t *testing.T) { + global := MustOptions( + OptionControlMaster, "auto", + OptionControlPath, "~/.ssh/cm-%C", + OptionControlPersist, "10m", + OptionConnectTimeout, "20s", + OptionStrictHostKeyChecking, "accept-new", + ) + node := MustOptions( + OptionControlPersist, "30m", + OptionBatchMode, "yes", + ) + cli := MustOptions( + OptionConnectTimeout, "5s", + ) + + eff, err := EffectiveOptions(cli, node, global) + if err != nil { + t.Fatalf("EffectiveOptions failed: %v", err) + } + if err := eff.ValidateEffective(); err != nil { + t.Fatalf("ValidateEffective failed: %v", err) + } + if eff.ControlMaster() != ControlMasterAuto { + t.Fatalf("ControlMaster = %q", eff.ControlMaster()) + } + if eff.ControlPersist() != "30m" { + t.Fatalf("ControlPersist = %q, want 30m", eff.ControlPersist()) + } + if eff.ConnectTimeout() != 5*time.Second { + t.Fatalf("ConnectTimeout = %v, want 5s", eff.ConnectTimeout()) + } + if !eff.BatchMode() { + t.Fatal("expected BatchMode = true") + } + + ctrl := eff.ControlConfig() + if ctrl == nil || ctrl.Master != ControlMasterAuto || ctrl.Path != "~/.ssh/cm-%C" || ctrl.Persist != "30m" { + t.Fatalf("unexpected ControlConfig: %+v", ctrl) + } +} + +func TestOptions_ControlPathNoneNormalization(t *testing.T) { + global := MustOptions( + OptionControlMaster, "auto", + OptionControlPath, "~/.ssh/cm-%C", + OptionControlPersist, "10m", + ) + // ControlPath=none disables multiplexing even when parent has ControlMaster=auto + disableMux := MustOptions( + OptionControlPath, "none", + ) + effDisabled, err := ResolveOptions(disableMux, global) + if err != nil { + t.Fatalf("ResolveOptions failed: %v", err) + } + if err := effDisabled.ValidateEffective(); err != nil { + t.Fatalf("ValidateEffective with ControlPath=none failed: %v", err) + } + if effDisabled.ControlConfig().IsEnabled() { + t.Fatalf("expected ControlPath=none to disable multiplexing, got: %+v", effDisabled.ControlConfig()) + } + + // M3: Child setting ControlMaster=yes over inherited ControlPath=none must normalize to disabled + childMasterOnly := MustOptions( + OptionControlMaster, "yes", + ) + effInheritedNone, err := ResolveOptions(childMasterOnly, disableMux) + if err != nil { + t.Fatalf("ResolveOptions with inherited ControlPath=none failed: %v", err) + } + if err := effInheritedNone.ValidateEffective(); err != nil { + t.Fatalf("ValidateEffective with inherited ControlPath=none failed: %v", err) + } + if effInheritedNone.ControlConfig().IsEnabled() { + t.Fatalf("expected inherited ControlPath=none to disable multiplexing even with child ControlMaster=yes, got: %+v", effInheritedNone.ControlConfig()) + } + + // M4 (Root Cause 2): Higher tier overriding ControlPath over a middle tier's ControlPath=none + // must preserve the lower tier's ControlMaster=auto instead of prematurely zeroing it out. + tier1PathOverride := MustOptions(OptionControlPath, "/tmp/override.sock") + effMultiLayer, err := EffectiveOptions(tier1PathOverride, disableMux, global) + if err != nil { + t.Fatalf("EffectiveOptions multi-layer failed: %v", err) + } + if effMultiLayer.ControlMaster() != ControlMasterAuto || effMultiLayer.ControlPath() != "/tmp/override.sock" || effMultiLayer.ControlPersist() != "10m" { + t.Fatalf("expected Tier 1 path override to preserve Tier 3 ControlMaster=auto and ControlPersist=10m, got %+v", effMultiLayer.ControlConfig()) + } +} + +func TestResolveAlgorithmList(t *testing.T) { + defaults := []string{"alg-a", "alg-b", "alg-c"} + + // Explicit list + got, err := ResolveAlgorithmList("alg-c,alg-a", defaults) + if err != nil || !reflect.DeepEqual(got, []string{"alg-c", "alg-a"}) { + t.Fatalf("explicit list got %v, err %v", got, err) + } + + // +append + got, err = ResolveAlgorithmList("+alg-d,alg-b", defaults) + if err != nil || !reflect.DeepEqual(got, []string{"alg-a", "alg-b", "alg-c", "alg-d"}) { + t.Fatalf("+append got %v, err %v", got, err) + } + + // ^prepend + got, err = ResolveAlgorithmList("^alg-d,alg-c", defaults) + if err != nil || !reflect.DeepEqual(got, []string{"alg-d", "alg-c", "alg-a", "alg-b"}) { + t.Fatalf("^prepend got %v, err %v", got, err) + } + + // -remove + got, err = ResolveAlgorithmList("-alg-b", defaults) + if err != nil || !reflect.DeepEqual(got, []string{"alg-a", "alg-c"}) { + t.Fatalf("-remove got %v, err %v", got, err) + } + + // -remove all fails + if _, err := ResolveAlgorithmList("-alg-a,alg-b,alg-c", defaults); err == nil { + t.Fatal("expected error when removing all algorithms") + } +} + +func TestConnector_RuntimeOptions_HostKeyAndAlgorithmsAndAuth(t *testing.T) { + ctx := context.Background() + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + signer, err := cryptoSSH.NewSignerFromKey(priv) + if err != nil { + t.Fatal(err) + } + pubKey := signer.PublicKey() + + tmpDir := t.TempDir() + khFile := filepath.Join(tmpDir, "known_hosts") + remoteAddr := &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 22} + + connector := NewConnector(nil, WithEnvironment(Environment{ + HomeDir: tmpDir, + KnownHostsFile: khFile, + })) + + // 1. StrictHostKeyChecking=no + UserKnownHostsFile=none bypasses verification without creating known_hosts + cfgNo := &ClientConfig{ + NodeID: "node-no", + Address: "10.0.0.1", + Port: 22, + User: "root", + Options: MustOptions( + OptionStrictHostKeyChecking, "no", + OptionUserKnownHostsFile, "none", + OptionBatchMode, "yes", + ), + } + cbNo, err := connector.getHostKeyCallback(ctx, nil, cfgNo) + if err != nil { + t.Fatalf("getHostKeyCallback(StrictHostKeyChecking=no) error: %v", err) + } + if err := cbNo("10.0.0.1:22", remoteAddr, pubKey); err != nil { + t.Fatalf("expected StrictHostKeyChecking=no to accept unknown host key, got: %v", err) + } + if _, err := os.Stat(khFile); !os.IsNotExist(err) { + t.Fatalf("expected UserKnownHostsFile=none not to create %s", khFile) + } + + // 2. StrictHostKeyChecking=yes rejects unknown host key + cfgYes := &ClientConfig{ + NodeID: "node-yes", + Address: "10.0.0.1", + Port: 22, + User: "root", + Options: MustOptions( + OptionStrictHostKeyChecking, "yes", + OptionUserKnownHostsFile, khFile, + ), + } + cbYes, err := connector.getHostKeyCallback(ctx, nil, cfgYes) + if err != nil { + t.Fatalf("getHostKeyCallback(StrictHostKeyChecking=yes) error: %v", err) + } + if err := cbYes("10.0.0.1:22", remoteAddr, pubKey); err == nil { + t.Fatal("expected StrictHostKeyChecking=yes to reject unknown host key") + } + + // 3. StrictHostKeyChecking=ask with BatchMode=yes rejects unknown host key without prompting + cfgBatchAsk := &ClientConfig{ + NodeID: "node-batch-ask", + Address: "10.0.0.1", + Port: 22, + User: "root", + Options: MustOptions( + OptionStrictHostKeyChecking, "ask", + OptionBatchMode, "yes", + OptionUserKnownHostsFile, khFile, + ), + } + cbBatchAsk, err := connector.getHostKeyCallback(ctx, nil, cfgBatchAsk) + if err != nil { + t.Fatalf("getHostKeyCallback(ask+BatchMode) error: %v", err) + } + if err := cbBatchAsk("10.0.0.1:22", remoteAddr, pubKey); err == nil { + t.Fatal("expected StrictHostKeyChecking=ask with BatchMode=yes to reject unknown host key") + } + + // 4. StrictHostKeyChecking=accept-new accepts and persists unknown host key to UserKnownHostsFile, + // after which StrictHostKeyChecking=yes succeeds! + cfgAcceptNew := &ClientConfig{ + NodeID: "node-accept-new", + Address: "10.0.0.1", + Port: 22, + User: "root", + Options: MustOptions( + OptionStrictHostKeyChecking, "accept-new", + OptionUserKnownHostsFile, khFile, + ), + } + cbAcceptNew, err := connector.getHostKeyCallback(ctx, nil, cfgAcceptNew) + if err != nil { + t.Fatalf("getHostKeyCallback(accept-new) error: %v", err) + } + if err := cbAcceptNew("10.0.0.1:22", remoteAddr, pubKey); err != nil { + t.Fatalf("expected StrictHostKeyChecking=accept-new to accept new host key, got: %v", err) + } + cbYesAfter, err := connector.getHostKeyCallback(ctx, nil, cfgYes) + if err != nil { + t.Fatalf("getHostKeyCallback(yes after accept-new) error: %v", err) + } + if err := cbYesAfter("10.0.0.1:22", remoteAddr, pubKey); err != nil { + t.Fatalf("expected StrictHostKeyChecking=yes to accept persisted host key, got: %v", err) + } + + verifyRuntimeSSHConfigAlgorithmsAndAuth(t, ctx, connector) +} + +func verifyRuntimeSSHConfigAlgorithmsAndAuth(t *testing.T, ctx context.Context, connector *Connector) { + t.Helper() + // 5. buildSSHConfig applies Ciphers, MACs, KexAlgorithms, HostKeyAlgorithms, and auth filtering + cfgAlgs := &ClientConfig{ + NodeID: "node-algs", + Address: "10.0.0.1", + Port: 22, + User: "root", + AuthType: "password", + Password: "secret", + Options: MustOptions( + OptionStrictHostKeyChecking, "no", + OptionCiphers, "aes128-gcm@openssh.com,chacha20-poly1305@openssh.com", + OptionMACs, "hmac-sha2-256-etm@openssh.com", + OptionKexAlgorithms, "curve25519-sha256", + OptionHostKeyAlgorithms, "ssh-ed25519", + ), + } + sshCfg, cleanup, err := connector.buildSSHConfig(ctx, cfgAlgs, nil, func() {}, nil) + if err != nil { + t.Fatalf("buildSSHConfig failed: %v", err) + } + if cleanup != nil { + defer func() { _ = cleanup() }() + } + if !reflect.DeepEqual(sshCfg.Ciphers, []string{"aes128-gcm@openssh.com", "chacha20-poly1305@openssh.com"}) { + t.Fatalf("unexpected Ciphers: %v", sshCfg.Ciphers) + } + if !reflect.DeepEqual(sshCfg.MACs, []string{"hmac-sha2-256-etm@openssh.com"}) { + t.Fatalf("unexpected MACs: %v", sshCfg.MACs) + } + if !reflect.DeepEqual(sshCfg.KeyExchanges, []string{"curve25519-sha256"}) { + t.Fatalf("unexpected KeyExchanges: %v", sshCfg.KeyExchanges) + } + if !reflect.DeepEqual(sshCfg.HostKeyAlgorithms, []string{"ssh-ed25519"}) { + t.Fatalf("unexpected HostKeyAlgorithms: %v", sshCfg.HostKeyAlgorithms) + } + + // 6. PasswordAuthentication=no blocks password auth + cfgNoPass := &ClientConfig{ + NodeID: "node-nopass", + Address: "10.0.0.1", + Port: 22, + User: "root", + AuthType: "password", + Password: "secret", + Options: MustOptions( + OptionStrictHostKeyChecking, "no", + OptionPasswordAuthentication, "no", + ), + } + if _, _, err := connector.buildSSHConfig(ctx, cfgNoPass, nil, func() {}, nil); err == nil { + t.Fatal("expected PasswordAuthentication=no to reject password authentication") + } +} + +type stubAlgorithmVerifier struct { + HostKeyVerifier + algs []string +} + +func (s stubAlgorithmVerifier) HostKeyAlgorithms(context.Context, HostKeyRequest) ([]string, error) { + return s.algs, nil +} + +func TestConnector_HostKeyMismatchWithStrictNo_DisablesPasswordAndKbdInteractive(t *testing.T) { + ctx := context.Background() + _, priv1, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + signer1, err := cryptoSSH.NewSignerFromKey(priv1) + if err != nil { + t.Fatal(err) + } + _, priv2, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + signer2, err := cryptoSSH.NewSignerFromKey(priv2) + if err != nil { + t.Fatal(err) + } + + tmpDir := t.TempDir() + kh1 := filepath.Join(tmpDir, "kh1") + kh2 := filepath.Join(tmpDir, "kh-%h") + remoteAddr := &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 22} + + connector := NewConnector(nil, WithEnvironment(Environment{ + HomeDir: tmpDir, + KnownHostsFile: kh1, + })) + + // Persist signer1 under kh-10.0.0.1 via space-separated UserKnownHostsFile with %h token + cfgAccept := &ClientConfig{ + NodeID: "node-1", + Address: "10.0.0.1", + Port: 22, + User: "root", + Options: MustOptions( + OptionStrictHostKeyChecking, "accept-new", + OptionUserKnownHostsFile, kh2+" "+kh1, + ), + } + cbAccept, err := connector.getHostKeyCallback(ctx, nil, cfgAccept) + if err != nil { + t.Fatalf("getHostKeyCallback(accept-new) error: %v", err) + } + if err := cbAccept("10.0.0.1:22", remoteAddr, signer1.PublicKey()); err != nil { + t.Fatalf("cbAccept failed: %v", err) + } + expandedKH2 := filepath.Join(tmpDir, "kh-10.0.0.1") + if _, err := os.Stat(expandedKH2); err != nil { + t.Fatalf("expected %s to be created on write: %v", expandedKH2, err) + } + + // Now present mismatched signer2 with StrictHostKeyChecking=no: + // handshake callback accepts it, sets hostKeyChanged=true, and disables password/kbd-interactive auth. + cfgMismatchNo := &ClientConfig{ + NodeID: "node-1", + Address: "10.0.0.1", + Port: 22, + User: "root", + AuthType: "password", + Password: "secret", + Options: MustOptions( + OptionStrictHostKeyChecking, "no", + OptionUserKnownHostsFile, kh2+" "+kh1, + ), + } + sshCfg, cleanup, err := connector.buildSSHConfig(ctx, cfgMismatchNo, nil, func() {}, nil) + if err != nil { + t.Fatalf("buildSSHConfig failed: %v", err) + } + if cleanup != nil { + defer func() { _ = cleanup() }() + } + if err := sshCfg.HostKeyCallback("10.0.0.1:22", remoteAddr, signer2.PublicKey()); err != nil { + t.Fatalf("expected StrictHostKeyChecking=no to allow mismatched key at callback stage, got: %v", err) + } + // 1. Explicit password mode: AuthCallback must fail closed with ErrHostKeyMismatch + if _, authErr := sshCfg.AuthCallback(&cryptoSSH.ClientAuthContext{AllowedMethods: []string{"password"}}); !errors.Is(authErr, ErrHostKeyMismatch) { + t.Fatalf("expected password AuthCallback after host key mismatch to return ErrHostKeyMismatch, got: %v", authErr) + } + + // 2. Explicit keyboard-interactive mode: AuthCallback must fail closed with ErrHostKeyMismatch + cfgKbdMismatch := &ClientConfig{ + NodeID: "node-1", + Address: "10.0.0.1", + Port: 22, + User: "root", + AuthType: "keyboard-interactive", + Password: "secret", + Options: MustOptions( + OptionStrictHostKeyChecking, "no", + OptionUserKnownHostsFile, kh2+" "+kh1, + ), + } + sshCfgKbd, cleanupKbd, err := connector.buildSSHConfig(ctx, cfgKbdMismatch, nil, func() {}, nil) + if err != nil { + t.Fatalf("buildSSHConfig(keyboard-interactive) failed: %v", err) + } + if cleanupKbd != nil { + defer func() { _ = cleanupKbd() }() + } + if err := sshCfgKbd.HostKeyCallback("10.0.0.1:22", remoteAddr, signer2.PublicKey()); err != nil { + t.Fatalf("HostKeyCallback(kbd) failed: %v", err) + } + if _, authErr := sshCfgKbd.AuthCallback(&cryptoSSH.ClientAuthContext{AllowedMethods: []string{"keyboard-interactive", "password"}}); !errors.Is(authErr, ErrHostKeyMismatch) { + t.Fatalf("expected keyboard-interactive AuthCallback after host key mismatch to return ErrHostKeyMismatch, got: %v", authErr) + } + + // 3. Auto mode: AuthCallback must skip password/kbd-interactive and return ErrHostKeyMismatch + cfgAutoMismatch := &ClientConfig{ + NodeID: "node-1", + Address: "10.0.0.1", + Port: 22, + User: "root", + AuthType: "auto", + Password: "secret", + Options: MustOptions( + OptionStrictHostKeyChecking, "no", + OptionUserKnownHostsFile, kh2+" "+kh1, + ), + } + sshCfgAuto, cleanupAuto, err := connector.buildSSHConfig(ctx, cfgAutoMismatch, nil, func() {}, nil) + if err != nil { + t.Fatalf("buildSSHConfig(auto) failed: %v", err) + } + if cleanupAuto != nil { + defer func() { _ = cleanupAuto() }() + } + if err := sshCfgAuto.HostKeyCallback("10.0.0.1:22", remoteAddr, signer2.PublicKey()); err != nil { + t.Fatalf("HostKeyCallback(auto) failed: %v", err) + } + if _, authErr := sshCfgAuto.AuthCallback(&cryptoSSH.ClientAuthContext{AllowedMethods: []string{"password", "keyboard-interactive"}}); !errors.Is(authErr, ErrHostKeyMismatch) { + t.Fatalf("expected auto AuthCallback after host key mismatch to return ErrHostKeyMismatch, got: %v", authErr) + } + + // 4. Key mode with PartialSuccess MFA transition: stage 0 allows publickey, stage 1 blocks keyboard-interactive with ErrHostKeyMismatch + verifyKeyPartialSuccessMismatch(t, ctx, connector, tmpDir, priv1, signer2.PublicKey(), remoteAddr, kh2+" "+kh1) +} + +func verifyKeyPartialSuccessMismatch( + t *testing.T, + ctx context.Context, + connector *Connector, + tmpDir string, + privKey ed25519.PrivateKey, + mismatchedServerKey cryptoSSH.PublicKey, + remoteAddr net.Addr, + userKH string, +) { + t.Helper() + pemBlock, err := cryptoSSH.MarshalPrivateKey(privKey, "") + if err != nil { + t.Fatal(err) + } + keyFile := filepath.Join(tmpDir, "id_ed25519") + if err := os.WriteFile(keyFile, pem.EncodeToMemory(pemBlock), 0600); err != nil { + t.Fatal(err) + } + cfgKeyMismatch := &ClientConfig{ + NodeID: "node-1", + Address: "10.0.0.1", + Port: 22, + User: "root", + AuthType: "key", + KeyPath: keyFile, + Options: MustOptions( + OptionStrictHostKeyChecking, "no", + OptionUserKnownHostsFile, userKH, + ), + } + sshCfgKey, cleanupKey, err := connector.buildSSHConfig(ctx, cfgKeyMismatch, nil, func() {}, nil) + if err != nil { + t.Fatalf("buildSSHConfig(key) failed: %v", err) + } + if cleanupKey != nil { + defer func() { _ = cleanupKey() }() + } + if err := sshCfgKey.HostKeyCallback("10.0.0.1:22", remoteAddr, mismatchedServerKey); err != nil { + t.Fatalf("HostKeyCallback(key) failed: %v", err) + } + // Stage 0 (publickey): allowed (returns nil, nil so ClientConfig.Auth[0] runs) + if m, err := sshCfgKey.AuthCallback(&cryptoSSH.ClientAuthContext{AllowedMethods: []string{"publickey"}}); err != nil || m != nil { + t.Fatalf("expected stage 0 publickey to proceed, got method=%v err=%v", m, err) + } + // Stage 1 (after publickey PartialSuccess, server asks for keyboard-interactive): must fail closed with ErrHostKeyMismatch + if _, err := sshCfgKey.AuthCallback(&cryptoSSH.ClientAuthContext{ + PartialSuccessMethods: []string{"publickey"}, + AllowedMethods: []string{"keyboard-interactive"}, + }); !errors.Is(err, ErrHostKeyMismatch) { + t.Fatalf("expected stage 1 MFA keyboard-interactive after host key mismatch to return ErrHostKeyMismatch, got: %v", err) + } +} + +func TestConnector_HostKeyAlgorithmsIntersectionWithVerifier(t *testing.T) { + ctx := context.Background() + connector := NewConnector(nil, WithHostKeyVerifier(stubAlgorithmVerifier{ + algs: []string{"ssh-ed25519", "ecdsa-sha2-nistp256"}, + })) + + cfgOverlap := &ClientConfig{ + Address: "10.0.0.1", + Port: 22, + Options: MustOptions( + OptionHostKeyAlgorithms, "rsa-sha2-512,ssh-ed25519", + ), + } + algs, err := connector.getHostKeyAlgorithms(ctx, nil, cfgOverlap) + if err != nil { + t.Fatalf("getHostKeyAlgorithms with overlap failed: %v", err) + } + if !reflect.DeepEqual(algs, []string{"ssh-ed25519"}) { + t.Fatalf("expected intersection [ssh-ed25519], got %v", algs) + } + + cfgDisjoint := &ClientConfig{ + Address: "10.0.0.1", + Port: 22, + Options: MustOptions( + OptionHostKeyAlgorithms, "rsa-sha2-512", + ), + } + if _, err := connector.getHostKeyAlgorithms(ctx, nil, cfgDisjoint); err == nil { + t.Fatal("expected error when HostKeyAlgorithms has no overlap with verifier algorithms") + } +} + +func TestPlanAndIdentitiesOnlyAndKnownHostsExpansion(t *testing.T) { + // 1. Root Cause 3: clientConfigFromPlan strips OptionClassMux while retaining transport/auth options + planCfg := ConnectionConfig{ + NodeID: "node-plan", + Address: "10.0.0.1", + Port: 22, + User: "root", + Options: MustOptions( + OptionControlMaster, "auto", + OptionControlPath, "~/.ssh/cm-%C", + OptionControlPersist, "10m", + OptionConnectTimeout, "12s", + ), + } + converted := clientConfigFromPlan(planCfg) + if converted.Options == nil || converted.Options.ConnectTimeout() != 12*time.Second { + t.Fatalf("expected non-mux option ConnectTimeout=12s to be preserved, got %+v", converted.Options) + } + if converted.Options.ControlConfig() != nil { + t.Fatalf("expected clientConfigFromPlan to strip OptionClassMux, got %+v", converted.Options.ControlConfig()) + } + + // 2. Root Cause 1 (M2): IdentitiesOnly=yes keeps DefaultKeyPaths when no explicit KeyPath is set, + // and skips DefaultKeyPaths when an explicit KeyPath is set. + tmpDir := t.TempDir() + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + pemBlock, err := cryptoSSH.MarshalPrivateKey(priv, "") + if err != nil { + t.Fatal(err) + } + defaultKey := filepath.Join(tmpDir, "default_id") + explicitKey := filepath.Join(tmpDir, "explicit_id") + if err := os.WriteFile(defaultKey, pem.EncodeToMemory(pemBlock), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(explicitKey, pem.EncodeToMemory(pemBlock), 0600); err != nil { + t.Fatal(err) + } + + planDefault := buildAutoAuthPlan(context.Background(), AutoAuthOptions{ + DefaultKeyPaths: []string{defaultKey}, + SSHOptions: MustOptions( + OptionIdentitiesOnly, "yes", + OptionPasswordAuthentication, "no", + OptionKbdInteractiveAuthentication, "no", + ), + }) + if len(planDefault.candidates) != 1 || !strings.Contains(planDefault.candidates[0].label, "default key") { + t.Fatalf("expected IdentitiesOnly=yes without explicit key to keep DefaultKeyPaths, got %+v", planDefault.candidates) + } + + planExplicit := buildAutoAuthPlan(context.Background(), AutoAuthOptions{ + KeyPath: explicitKey, + DefaultKeyPaths: []string{defaultKey}, + SSHOptions: MustOptions( + OptionIdentitiesOnly, "yes", + OptionPasswordAuthentication, "no", + OptionKbdInteractiveAuthentication, "no", + ), + }) + if len(planExplicit.candidates) != 1 || !strings.Contains(planExplicit.candidates[0].label, "explicit key") { + t.Fatalf("expected IdentitiesOnly=yes with explicit key to skip DefaultKeyPaths, got %+v", planExplicit.candidates) + } + + // 3. Root Cause 5: expandKnownHostsPath expands ${VAR} without % and fails closed on invalid token %k + envWithLookup := Environment{ + HomeDir: tmpDir, + LookupEnv: func(key string) (string, bool) { + if key == "XOPS_TEST_KH_DIR" { + return tmpDir, true + } + return "", false + }, + } + expandedEnv, err := expandKnownHostsPath("${XOPS_TEST_KH_DIR}/known_hosts", envWithLookup, TargetContext{}) + if err != nil || expandedEnv != filepath.Join(tmpDir, "known_hosts") { + t.Fatalf("expected ${XOPS_TEST_KH_DIR} to expand, got %q, err=%v", expandedEnv, err) + } + if _, err := expandKnownHostsPath(filepath.Join(tmpDir, "kh-%k"), envWithLookup, TargetContext{}); err == nil { + t.Fatal("expected unsupported token %k in expandKnownHostsPath to return an error") + } +} + +func TestEdgeCases_KnownHostsDir_AgentIdentitiesOnly_AlgoDefaults_PlanNormalization(t *testing.T) { + ctx := context.Background() + tmpDir := t.TempDir() + connector := NewConnector(nil, WithEnvironment(Environment{ + HomeDir: tmpDir, + KnownHostsFile: filepath.Join(tmpDir, "known_hosts"), + })) + + // 1. UserKnownHostsFile pointing to a directory must fail closed + dirCfg := &ClientConfig{ + Address: "10.0.0.1", + Port: 22, + User: "root", + Options: MustOptions(OptionUserKnownHostsFile, tmpDir), + } + if _, err := connector.getHostKeyCallback(ctx, nil, dirCfg); err == nil || !strings.Contains(err.Error(), "is a directory") { + t.Fatalf("expected UserKnownHostsFile directory error, got: %v", err) + } + + // 2. AuthType == "agent" must respect IdentitiesOnly=yes + agentCfg := &ClientConfig{ + Address: "10.0.0.1", + Port: 22, + User: "root", + AuthType: "agent", + Options: MustOptions(OptionIdentitiesOnly, "yes"), + } + if _, _, err := connector.buildSSHConfig(ctx, agentCfg, nil, func() {}, nil); err == nil || !strings.Contains(err.Error(), "IdentitiesOnly=yes") { + t.Fatalf("expected AuthType=agent with IdentitiesOnly=yes to fail, got: %v", err) + } + + // 3. Removing all default secure Ciphers via "-" must fail during option normalization + allDefaultCiphers := strings.Join(cryptoSSH.SupportedAlgorithms().Ciphers, ",") + opts := &Options{} + if err := opts.Set(OptionCiphers, "-"+allDefaultCiphers); err == nil { + t.Fatal("expected removing all default secure ciphers to fail normalization") + } + + // 4. MergeOptionLayers keeps ImplicitTier4=false when a higher layer sets ControlPersist + t2 := MustOptions(OptionControlPersist, "10m") + t4 := MustOptions(OptionControlMaster, "auto", OptionControlPath, "/tmp/cm-%C") + t4.ImplicitTier4 = true + merged := MergeOptionLayers(t2, t4) + if merged == nil || merged.ImplicitTier4 { + t.Fatalf("expected ImplicitTier4=false when Tier 2 explicitly sets ControlPersist, got %+v", merged) + } + t2NoPersist := MustOptions(OptionConnectTimeout, "5s") + mergedImplicit := MergeOptionLayers(t2NoPersist, t4) + if mergedImplicit == nil || !mergedImplicit.ImplicitTier4 { + t.Fatalf("expected ImplicitTier4=true when only Tier 4 provides mux options, got %+v", mergedImplicit) + } + + // 5. normalizePlan clones Options and strips OptionClassMux before ConnectionKey hashing + origOpts := MustOptions( + OptionControlMaster, "auto", + OptionControlPath, "/tmp/cm-%C", + OptionConnectTimeout, "15s", + ) + planWithMux := ConnectionPlan{ + Scope: "scope-1", + Hops: []ConnectionConfig{{ + NodeID: "n1", + Address: "10.0.0.1", + Port: 22, + User: "root", + Options: origOpts, + }}, + } + planWithoutMux := ConnectionPlan{ + Scope: "scope-1", + Hops: []ConnectionConfig{{ + NodeID: "n1", + Address: "10.0.0.1", + Port: 22, + User: "root", + Options: MustOptions(OptionConnectTimeout, "15s"), + }}, + } + key1, err := ConnectionKey(planWithMux) + if err != nil { + t.Fatalf("ConnectionKey(planWithMux) failed: %v", err) + } + key2, err := ConnectionKey(planWithoutMux) + if err != nil { + t.Fatalf("ConnectionKey(planWithoutMux) failed: %v", err) + } + if key1 != key2 { + t.Fatalf("expected ConnectionKey to ignore stripped OptionClassMux options, got %q != %q", key1, key2) + } +} + +type testOptionsPrompter struct { + promptCalled bool + confirmCalled bool + reportCalled bool +} + +func (p *testOptionsPrompter) PromptSecret(context.Context, SecretRequest) (string, error) { + p.promptCalled = true + return "should-not-be-used", nil +} + +func (p *testOptionsPrompter) ConfirmSession(context.Context, string) (bool, error) { + p.confirmCalled = true + return true, nil +} + +func (p *testOptionsPrompter) CredentialRecoveryAllowed() bool { + return true +} + +func (p *testOptionsPrompter) ReportCredentialFailure(context.Context, string) error { + p.reportCalled = true + return nil +} + +type failingCredentialRecorder struct{} + +func (failingCredentialRecorder) UpdateAuth(context.Context, string, string, string, string, string) (string, error) { + return "", errors.New("save failed") +} + +func (failingCredentialRecorder) UpdateSudo(context.Context, string, string, SudoMode, string) (string, error) { + return "", errors.New("save failed") +} + +type timeoutCheckingSecretResolver struct { + observedRemaining time.Duration +} + +func (r *timeoutCheckingSecretResolver) ResolveSecret(ctx context.Context, _ SecretRequest) ([]byte, error) { + if dl, ok := ctx.Deadline(); ok { + r.observedRemaining = time.Until(dl) + } + return []byte("secret-pwd"), nil +} + +func TestBatchModeBlocksCredentialRecoveryAndResolveSecretHonorsConnectTimeout(t *testing.T) { + ctx := context.Background() + prompter := &testOptionsPrompter{} + resolver := &timeoutCheckingSecretResolver{} + + conn := NewConnector( + nil, + WithSecretPrompter(prompter), + WithSecretResolver(resolver), + WithCredentialRecorder(failingCredentialRecorder{}), + ) + defer func() { _ = conn.CloseAll() }() + + // 1. Explicit AuthType: "key" with encrypted key and BatchMode=yes must not enter recoverableKeyAuth or prompt + pemData, _ := generateTestEncryptedKey(t, "correct-passphrase") + encKeyPath := filepath.Join(t.TempDir(), "id_enc") + if err := os.WriteFile(encKeyPath, pemData, 0600); err != nil { + t.Fatalf("write encrypted key failed: %v", err) + } + keyCfg := &ClientConfig{ + NodeID: "n-key-batch", + Address: "127.0.0.1", + Port: 22, + User: "root", + AuthType: "key", + KeyPath: encKeyPath, + Options: MustOptions(OptionBatchMode, "yes"), + } + // Clear resolver temporarily so resolveKeyAuth gets ErrInteractionRequired + conn.secretResolver = rejectSecretResolver{} + if _, _, err := conn.keyAuth(ctx, keyCfg, conn.getNodeSecretPrompter(keyCfg), nil); err == nil { + t.Fatal("expected keyAuth with encrypted key and BatchMode=yes to fail without passphrase") + } + if prompter.promptCalled || prompter.reportCalled { + t.Fatalf("expected BatchMode=yes to block prompt and recovery report in keyAuth, got prompt=%v report=%v", prompter.promptCalled, prompter.reportCalled) + } + + // 2. recordAuthUpdate with BatchMode=yes must not invoke ReportCredentialFailure + c1, c2 := net.Pipe() + defer func() { _ = c1.Close(); _ = c2.Close() }() + saveCfg := &ClientConfig{ + NodeID: "n-save-batch", + Password: "discovered", + AuthUpdateToken: "tok-1", + Options: MustOptions(OptionBatchMode, "yes"), + } + if _, err := conn.recordAuthUpdate(ctx, "n-save-batch", saveCfg, c1); err == nil || errors.Is(err, errCredentialNotSaved) { + t.Fatalf("expected hard save error without interactive recovery under BatchMode=yes, got %v", err) + } + if prompter.reportCalled { + t.Fatal("expected ReportCredentialFailure not to be called under BatchMode=yes") + } + + // 3. resolvePasswordAuth honors node ConnectTimeout via resolveSecret + conn.secretResolver = resolver + pwdCfg := &ClientConfig{ + NodeID: "n-pwd-timeout", + Address: "127.0.0.1", + Port: 22, + User: "root", + AuthType: "password", + Options: MustOptions(OptionConnectTimeout, "3s"), + } + if _, err := conn.resolvePasswordAuth(pwdCfg); err != nil { + t.Fatalf("resolvePasswordAuth failed: %v", err) + } + if resolver.observedRemaining <= 0 || resolver.observedRemaining > 3*time.Second { + t.Fatalf("expected resolveSecret deadline bounded by 3s ConnectTimeout, got %v", resolver.observedRemaining) + } +} + +func TestClientBatchModeAndSnapshotCloningAndTargetTrustValidation(t *testing.T) { + ctx := context.Background() + prompter := &testOptionsPrompter{} + + cfg := &ClientConfig{ + NodeID: "n1", + Address: "10.0.0.1", + Port: 22, + User: "root", + SudoMode: SudoModeSudo, + Options: MustOptions( + OptionBatchMode, "yes", + OptionConnectTimeout, "9s", + OptionStrictHostKeyChecking, "yes", + ), + } + client := newClientWithComponents(nil, nil, cfg, nil, nil, prompter, nil, "", 0, 0, nil) + if client.handshakeTimeout != 9*time.Second { + t.Fatalf("expected client.handshakeTimeout=9s from ConnectTimeout option, got %v", client.handshakeTimeout) + } + if _, err := client.resolvePrivilegeMaterial(ctx, SecretKindSudoPassword); !errors.Is(err, ErrInteractionRequired) { + t.Fatalf("expected ErrInteractionRequired in BatchMode=yes privilege resolution, got %v", err) + } + if prompter.promptCalled { + t.Fatal("expected SecretPrompter not to be called when BatchMode=yes") + } + + // Verify confirmSessionCallback returns nil when BatchMode=yes + conn := NewConnector(nil, WithSessionConfirmer(prompter)) + if cb := conn.confirmSessionCallback("n1", cfg); cb != nil { + t.Fatal("expected confirmSessionCallback to be nil when BatchMode=yes") + } + + // Verify Client.Config() and Client.ConnectionConfig() deep-clone Options + snapCfg := client.Config() + if snapCfg == nil || snapCfg.Options == nil || !snapCfg.Options.BatchMode() { + t.Fatalf("expected Client.Config() to include cloned Options, got %+v", snapCfg) + } + _ = snapCfg.Options.Set(OptionBatchMode, "no") + connCfg := client.ConnectionConfig() + if connCfg.Options == nil || !connCfg.Options.BatchMode() { + t.Fatalf("expected mutating Client.Config().Options not to affect client, got %+v", connCfg.Options) + } + _ = connCfg.Options.Set(OptionBatchMode, "no") + if !client.ConnectionConfig().Options.BatchMode() { + t.Fatal("expected mutating Client.ConnectionConfig().Options not to affect live client") + } + + // Verify validateTargetCompatibility detects trust option drift + newCfg := *cfg + newCfg.Options = MustOptions(OptionStrictHostKeyChecking, "no") + if err := validateTargetCompatibility(client.ConnectionConfig(), &newCfg); !errors.Is(err, ErrSnapshotMismatch) { + t.Fatalf("expected ErrSnapshotMismatch when StrictHostKeyChecking changed, got %v", err) + } +} diff --git a/core/ssh/types_api.go b/core/ssh/types_api.go index 416cb13f..a4d4363e 100644 --- a/core/ssh/types_api.go +++ b/core/ssh/types_api.go @@ -45,6 +45,7 @@ type ClientConfig struct { // 为空时回落到 Connector 的全局配置,再为空则使用内置的多语言默认模式。 PasswordPromptPattern string Execution *ExecutionConfig + Options *Options } // ToConnectionConfig 将 ClientConfig 转换为仅含网络与连接参数的 ConnectionConfig,剥离所有明文密码。 @@ -74,6 +75,7 @@ func (c *ClientConfig) ToConnectionConfig() ConnectionConfig { HasOriginalProxyJump: hasOriginal, PasswordPromptPattern: c.PasswordPromptPattern, Execution: c.Execution.Clone(), + Options: c.Options.Clone(), } } @@ -95,6 +97,15 @@ type ConnectionConfig struct { HasOriginalProxyJump bool PasswordPromptPattern string Execution *ExecutionConfig + Options *Options +} + +// Clone returns a deep copy of ConnectionConfig, isolating mutable Execution and Options pointers. +func (c ConnectionConfig) Clone() ConnectionConfig { + out := c + out.Execution = c.Execution.Clone() + out.Options = c.Options.Clone() + return out } // AuthMaterial 包含单次 SSH 握手所需的敏感认证材料。 @@ -274,6 +285,10 @@ func validateTargetCompatibility(cur ConnectionConfig, newCfg *ClientConfig) err if newCfg.KeyRef != cur.KeyRef || newCfg.TrustVersion != cur.TrustVersion { return fmt.Errorf("%w: key reference or host trust version changed", ErrSnapshotMismatch) } + if cur.Options.StrictHostKeyChecking() != newCfg.Options.StrictHostKeyChecking() || + cur.Options.UserKnownHostsFile() != newCfg.Options.UserKnownHostsFile() { + return fmt.Errorf("%w: host trust options changed", ErrSnapshotMismatch) + } if newCfg.KeyPath != cur.KeyPath { return fmt.Errorf("%w: key path changed (expected %q, got %q)", ErrSnapshotMismatch, cur.KeyPath, newCfg.KeyPath) diff --git a/docs/development/openssh-multiplexing-design.md b/docs/development/openssh-multiplexing-design.md index 692ed336..78acfe00 100644 --- a/docs/development/openssh-multiplexing-design.md +++ b/docs/development/openssh-multiplexing-design.md @@ -331,20 +331,20 @@ const ( ### 5.1 4 层继承优先级 -`pkg/adapter/ssh_adapter.go:ResolveControl` 严格按以下优先级由高到低解析复用配置: +`pkg/adapter/ssh_adapter.go:ResolveSSHOptions`(以及基于其派生的 `ResolveControl`)严格按以下优先级由高到低解析 OpenSSH 客户端选项与连接共享配置: $$\text{Tier 1 (CLI Session Overrides)} > \text{Tier 2 (Node Config)} > \text{Tier 3 (Global Defaults)} > \text{Tier 4 (OpenSSH Config)}$$ 1. **Tier 1 (CLI 会话参数覆盖)**:命令行传入的 `-M`、`-M -M`、`-S `、`-S none`、`-o Key=Value`。 -2. **Tier 2 (Node 级配置)**:当前节点在 `config.yaml` 中配置的 `nodes[name].control`。 -3. **Tier 3 (Global 全局配置)**:全局配置中定义的默认 `control`。 -4. **Tier 4 (OpenSSH 系统配置)**:解析 `~/.ssh/config` 中针对该 Host 匹配的 `ControlMaster`、`ControlPath`、`ControlPersist`。在 Tier 4 内部,具体的 Host 声明(非通配符)优先于泛通配符(如 `Host *`)。 +2. **Tier 2 (Node 级配置)**:当前节点在 `config.yaml` 中配置的 `nodes[name].ssh_options`。 +3. **Tier 3 (Global 全局配置)**:全局配置中定义的默认 `ssh_options`。 +4. **Tier 4 (OpenSSH 系统配置)**:解析 `~/.ssh/config` 中针对该 Host 匹配的 OpenSSH 客户端选项(包含 `ControlMaster`、`ControlPath`、`ControlPersist` 及连接/认证/算法选项)。在 Tier 4 内部,具体的 Host 声明(非通配符)优先于泛通配符(如 `Host *`)。 ### 5.2 字段级合并语义 (Orthogonal Field-by-Field Merging) -三项关键配置(`Master`、`Path`、`Persist`)是彼此正交的独立字段,逐层按字段级合并: -- 每一层若未显式指定某个字段,则向下继承更低层级的字段值。 -- **禁用语义 (`-S none` 或 `Path: "none"`)**:当任何高优先级层级显式指定了 `Path: "none"`,该指令具有全局最高禁用意图——强制将合并后的 `Master` 归一化为 `"no"`,清除 `Persist` 为空,并立即退出复用链路,回退至原生直接连接。 +`core/ssh/ssh_options.go:Options` 采用注册表驱动(`optionRegistry`)的规范化键值模型,所有受支持的 OpenSSH 选项在 4 个层级之间彼此正交、按项独立合并: +- 每一层若未显式指定某个选项,则向下继承更低层级的选项值。 +- **禁用语义 (`-S none` 或 `control_path: "none"`)**:当任何高优先级层级显式指定了 `control_path: "none"`(或 `ControlPath=none`),该指令具有全局最高禁用意图——强制将合并后的 `control_master` 归一化为 `"no"`,清除 `control_persist`,并立即退出复用链路,回退至原生直接连接。 ### 5.3 ControlPath 路径令牌展开机制 @@ -381,29 +381,42 @@ $$\text{Hash} = \text{SHA-1}(\%l + \%h + \%p + \%r + \%j)$$ 输出固定为 40 个字符的十六进制小写字符串。推荐的生产标准路径模式为: `~/.ssh/cm-%C`(全长约 54 字节,安全适配所有 Unix 内核限制)。 -### 5.5 Schema V2 配置声明与 CAS 实体版本化 +### 5.5 Schema V2 `ssh_options` 可扩展配置与 CAS 实体版本化 -在 `pkg/config/types.go` 与 `schema_v2.go` 中,`ControlConfig` 深度嵌入模型: +在 `pkg/config/types.go` 与 `schema_v2.go` 中,全局与节点级统一使用可扩展的 `ssh_options` 映射(`*ssh.Options`): +- **落盘规范与手写兼容**:序列化落盘时统一输出规范 `snake_case` 键名(与 `xops_config.yaml` 其余字段风格一致);反序列化加载时同时接受规范 `snake_case` 与 OpenSSH 标准 `PascalCase`/大小写不敏感键名(如 `ControlMaster`、`StrictHostKeyChecking`),允许用户直接按 OpenSSH 习惯手动编辑配置文件。未知键或同一选项的重复键名会在加载/校验阶段直接报错拒绝。 ```yaml # config.yaml (Schema V2) -control: - master: auto - path: ~/.ssh/cm-%C - persist: 10m +ssh_options: + control_master: auto + control_path: ~/.ssh/cm-%C + control_persist: 10m + connect_timeout: 15s + server_alive_interval: 30s + strict_host_key_checking: accept-new nodes: web01: - host: 10.0.0.1 - user: ops - control: - master: "yes" - path: /tmp/custom-%h-%p - persist: 0 + host_ref: host-web01 + identity_ref: id-ops + ssh_options: + control_master: "yes" + control_path: /tmp/custom-%h-%p + control_persist: "0" + tcp_keep_alive: "yes" ``` -- **验证保护**:`ValidateControlConfig` 校验模式必须属于合法枚举,校验 `Persist` 格式,校验未展开路径的语法合法性。 -- **CAS 版本哈希**:`ControlConfig` 纳入 `nodeEntityVersion`(`NodeRef`)的 CAS 内容哈希计算(`pkg/config/repository.go:nodeEntityVersion`),配置变更自动使依赖该节点的上层 `NodeRef` 乐观并发校验失效;认证机密版本 `nodeAuthVersion` 仍只覆盖认证材料,不包含复用配置。 +- **受支持的 OpenSSH 选项注册表(`core/ssh/ssh_options.go`,共 19 项,按 `OptionClass` 分类隔离)**: + - **多路复用 (`OptionClassMux`)**:`control_master` (`ControlMaster`), `control_path` (`ControlPath`), `control_persist` (`ControlPersist`) + - **传输与算法 (`OptionClassTransport`)**:`connect_timeout` (`ConnectTimeout`), `server_alive_interval` (`ServerAliveInterval`), `server_alive_count_max` (`ServerAliveCountMax`), `tcp_keep_alive` (`TCPKeepAlive`), `host_key_algorithms` (`HostKeyAlgorithms`), `ciphers` (`Ciphers`), `macs` (`MACs`), `kex_algorithms` (`KexAlgorithms`;均支持完整列表、`+` 追加、`^` 前置、`-` 移除语法) + - **认证控制 (`OptionClassAuth`)**:`pubkey_authentication` (`PubkeyAuthentication`), `password_authentication` (`PasswordAuthentication`), `kbd_interactive_authentication` (`KbdInteractiveAuthentication` / `ChallengeResponseAuthentication`), `identities_only` (`IdentitiesOnly`), `preferred_authentications` (`PreferredAuthentications`), `batch_mode` (`BatchMode`) + - **主机密钥信任 (`OptionClassTrust`)**:`strict_host_key_checking` (`StrictHostKeyChecking`: `yes`/`ask`/`accept-new`/`no`/`off`), `user_known_hosts_file` (`UserKnownHostsFile`) +- **信任与继承边界**: + - `OptionClassTrust` 选项**不从 `~/.ssh/config`(Tier 4)继承**,仅接受来自 CLI `-o`(Tier 1)与 `xops` 显式配置(Tier 2 / Tier 3);当主机指纹发生变更(`known_hosts` 不匹配)且显式指定 `StrictHostKeyChecking=no` 时,遵循 OpenSSH 原生语义自动禁用密码与键盘交互认证(仅允许公钥认证),防止凭据泄露给中间人。 + - 已纳管的 `xops` 节点在回退查询 Tier 4 (`~/.ssh/config`) 时仅继承 `OptionClassMux` 与 `OptionClassTransport`;`ParseOpenSSHHosts` 导入节点时同样仅持久化这两类选项。 + - MCP Host 与凭据校验路径显式启用 `WithHostTrustOverrides(false)` 与 `WithControlMultiplexing(false)`,剥离 `OptionClassTrust` 与 `OptionClassMux`,保持 fail-closed 主机密钥校验。 +- **CAS 版本哈希**:`SSHOptions` 纳入 `nodeEntityVersion`(`NodeRef`)的 CAS 内容哈希计算(`pkg/config/repository.go:nodeEntityVersion`),配置变更自动使依赖该节点的上层 `NodeRef` 乐观并发校验失效;认证机密版本 `nodeAuthVersion` 仍只覆盖认证材料。 --- @@ -417,7 +430,7 @@ nodes: - `-M`:启用 Master 模式(相当于 `-o ControlMaster=yes`)。 - `-M -M`(重复指定):启用确认 Master 模式(相当于 `-o ControlMaster=ask`)。 - `-S `:指定控制套接字路径(或 `-S none` 显式禁用连接共享)。 - - `-o Key=Value`:传递通用 SSH 参数(支持 `ControlMaster`、`ControlPath`、`ControlPersist`、`Port`、`User`、`IdentityFile`、`ProxyJump`)。 + - `-o Key=Value`:传递 OpenSSH 客户端选项(支持 `Port`、`User`、`IdentityFile`、`ProxyJump` 以及第 5.5 节列出的全部 19 项 `ssh_options` 选项,大小写不敏感并同时支持 `PascalCase` 与 `snake_case`)。 - `-O `:向正在运行的 Master 进程发送控制指令。 - **`xops scp`、`xops sftp`、`xops exec`**: - 注册 `-S` 与 `-o` 参数。 diff --git a/docs/en/development/openssh-multiplexing-design.md b/docs/en/development/openssh-multiplexing-design.md index 306141fb..85cdfc76 100644 --- a/docs/en/development/openssh-multiplexing-design.md +++ b/docs/en/development/openssh-multiplexing-design.md @@ -331,20 +331,20 @@ If a prior master crashed abnormally (`kill -9` or power failure), an inactive s ### 5.1 4-Tier Resolution Precedence -`pkg/adapter/ssh_adapter.go:ResolveControl` evaluates multiplexing configurations strictly according to the following precedence: +`pkg/adapter/ssh_adapter.go:ResolveSSHOptions` (and `ResolveControl` derived from it) evaluates OpenSSH client options and multiplexing configurations strictly according to the following precedence: $$\text{Tier 1 (CLI Session Overrides)} > \text{Tier 2 (Node Config)} > \text{Tier 3 (Global Defaults)} > \text{Tier 4 (OpenSSH Config)}$$ 1. **Tier 1 (CLI Session Overrides)**: Command-line flags `-M`, `-M -M`, `-S `, `-S none`, and `-o Key=Value`. -2. **Tier 2 (Node Configuration)**: Target node definition in `config.yaml` (`nodes[name].control`). -3. **Tier 3 (Global Defaults)**: Root-level default configuration in `config.yaml` (`control`). -4. **Tier 4 (OpenSSH Config Fallback)**: Parsed from `~/.ssh/config` matching the target host. Within Tier 4, concrete host entries take precedence over wildcard patterns (`Host *`). +2. **Tier 2 (Node Configuration)**: Target node definition in `config.yaml` (`nodes[name].ssh_options`). +3. **Tier 3 (Global Defaults)**: Root-level default configuration in `config.yaml` (`ssh_options`). +4. **Tier 4 (OpenSSH Config Fallback)**: Parsed from `~/.ssh/config` matching the target host (including `ControlMaster`, `ControlPath`, `ControlPersist`, and connection/authentication/algorithm options). Within Tier 4, concrete host entries take precedence over wildcard patterns (`Host *`). ### 5.2 Orthogonal Field-by-Field Merging -The three primary configuration fields (`Master`, `Path`, `Persist`) are orthogonal and merged independently across tiers: -- A higher tier leaves unset fields to be inherited from lower tiers. -- **Explicit Disablement (`-S none` or `Path: "none"`)**: If any higher tier explicitly specifies `Path: "none"`, multiplexing is disabled globally for that session: `Master` is normalized to `"no"`, `Persist` is cleared, and the client falls back to direct connection. +`core/ssh/ssh_options.go:Options` uses a registry-driven (`optionRegistry`) normalized key-value model where all supported OpenSSH options are orthogonal and merged independently across the 4 tiers: +- A higher tier leaves unset options to be inherited from lower tiers. +- **Explicit Disablement (`-S none` or `control_path: "none"`)**: If any higher tier explicitly specifies `control_path: "none"` (or `ControlPath=none`), multiplexing is disabled globally for that session: `control_master` is normalized to `"no"`, `control_persist` is cleared, and the client falls back to direct connection. ### 5.3 ControlPath Token Expansion @@ -381,29 +381,42 @@ $$\text{Hash} = \text{SHA-1}(\%l + \%h + \%p + \%r + \%j)$$ The result is a fixed 40-character lowercase hexadecimal string. The recommended standard socket path is: `~/.ssh/cm-%C` (approximately 54 bytes, comfortably within all OS limits). -### 5.5 Schema V2 Declarations & CAS Entity Versioning +### 5.5 Schema V2 `ssh_options` Extensible Configuration & CAS Entity Versioning -In `pkg/config/types.go` and `schema_v2.go`, `ControlConfig` is integrated into the configuration schema: +In `pkg/config/types.go` and `schema_v2.go`, both global and per-node configurations use the extensible `ssh_options` mapping (`*ssh.Options`): +- **Canonical `snake_case` Persistence & OpenSSH Input Compatibility**: When marshaled to disk, `ssh_options` always writes canonical `snake_case` keys (consistent with the rest of `xops_config.yaml`). When unmarshaling manually edited files, both canonical `snake_case` and OpenSSH standard `PascalCase` / case-insensitive option names (e.g., `ControlMaster`, `StrictHostKeyChecking`) are accepted. Unknown keys or duplicate representations of the same option are rejected during validation. ```yaml # config.yaml (Schema V2) -control: - master: auto - path: ~/.ssh/cm-%C - persist: 10m +ssh_options: + control_master: auto + control_path: ~/.ssh/cm-%C + control_persist: 10m + connect_timeout: 15s + server_alive_interval: 30s + strict_host_key_checking: accept-new nodes: web01: - host: 10.0.0.1 - user: ops - control: - master: "yes" - path: /tmp/custom-%h-%p - persist: 0 + host_ref: host-web01 + identity_ref: id-ops + ssh_options: + control_master: "yes" + control_path: /tmp/custom-%h-%p + control_persist: "0" + tcp_keep_alive: "yes" ``` -- **Validation**: `ValidateControlConfig` verifies enum values, persist duration syntax, and unexpanded path token syntax. -- **CAS Versioning**: `ControlConfig` participates in the `NodeRef` CAS content hash (`pkg/config/repository.go:nodeEntityVersion`), so a change invalidates dependent optimistic-concurrency preconditions for that node. `nodeAuthVersion` stays scoped to authentication material and does not include multiplexing configuration. +- **Supported OpenSSH Options Registry (`core/ssh/ssh_options.go`, 19 options isolated by `OptionClass`)**: + - **Multiplexing (`OptionClassMux`)**: `control_master` (`ControlMaster`), `control_path` (`ControlPath`), `control_persist` (`ControlPersist`) + - **Transport & Algorithms (`OptionClassTransport`)**: `connect_timeout` (`ConnectTimeout`), `server_alive_interval` (`ServerAliveInterval`), `server_alive_count_max` (`ServerAliveCountMax`), `tcp_keep_alive` (`TCPKeepAlive`), `host_key_algorithms` (`HostKeyAlgorithms`), `ciphers` (`Ciphers`), `macs` (`MACs`), `kex_algorithms` (`KexAlgorithms`; all supporting full list, `+` append, `^` prepend, and `-` remove modifiers) + - **Authentication Control (`OptionClassAuth`)**: `pubkey_authentication` (`PubkeyAuthentication`), `password_authentication` (`PasswordAuthentication`), `kbd_interactive_authentication` (`KbdInteractiveAuthentication` / `ChallengeResponseAuthentication`), `identities_only` (`IdentitiesOnly`), `preferred_authentications` (`PreferredAuthentications`), `batch_mode` (`BatchMode`) + - **Host Key Trust (`OptionClassTrust`)**: `strict_host_key_checking` (`StrictHostKeyChecking`: `yes`/`ask`/`accept-new`/`no`/`off`), `user_known_hosts_file` (`UserKnownHostsFile`) +- **Trust & Inheritance Boundaries**: + - `OptionClassTrust` options are **never inherited from `~/.ssh/config` (Tier 4)**; they are accepted only from CLI `-o` (Tier 1) and explicit `xops` configuration (Tier 2 / Tier 3). When a remote host key has changed (`known_hosts` mismatch) and `StrictHostKeyChecking=no` is explicitly set, XOps follows OpenSSH native semantics by disabling password and keyboard-interactive authentication (allowing only public-key authentication) to prevent credential disclosure. + - Managed `xops` nodes only inherit `OptionClassMux` and `OptionClassTransport` from Tier 4 (`~/.ssh/config`), and `ParseOpenSSHHosts` persists only those two classes when importing nodes. + - MCP Host and credential-verification paths pass `WithHostTrustOverrides(false)` and `WithControlMultiplexing(false)`, stripping `OptionClassTrust` and `OptionClassMux` to preserve fail-closed host key verification. +- **CAS Versioning**: `SSHOptions` participates in the `NodeRef` CAS content hash (`pkg/config/repository.go:nodeEntityVersion`), so a change invalidates dependent optimistic-concurrency preconditions for that node. `nodeAuthVersion` stays scoped to authentication material. --- @@ -417,7 +430,7 @@ nodes: - `-M`: Enable master mode (equivalent to `-o ControlMaster=yes`). - `-M -M` (repeated): Enable confirmation master mode (equivalent to `-o ControlMaster=ask`). - `-S `: Specify control socket path (or `-S none` to disable connection sharing). - - `-o Key=Value`: Pass OpenSSH options (`ControlMaster`, `ControlPath`, `ControlPersist`, `Port`, `User`, `IdentityFile`, `ProxyJump`). + - `-o Key=Value`: Pass OpenSSH client options (supports `Port`, `User`, `IdentityFile`, `ProxyJump`, and all 19 `ssh_options` listed in section 5.5, case-insensitively in either `PascalCase` or `snake_case`). - `-O `: Send control command to active master. - **`xops scp`, `xops sftp`, `xops exec`**: - Register `-S` and `-o` flags. diff --git a/docs/en/guide/hosts.md b/docs/en/guide/hosts.md index 887a0059..cdf4e499 100644 --- a/docs/en/guide/hosts.md +++ b/docs/en/guide/hosts.md @@ -135,6 +135,30 @@ nodes: **Configuration precedence**: CLI flags > Playbook step `execution` > Playbook `settings.execution` > Node configuration (`node.execution`) > Global configuration (`cfg.execution`) > System default (`server`). +## OpenSSH client options and multiplexing (`ssh_options`) + +You can configure `ssh_options` globally or per node in `~/.xops/xops_config.yaml`, or override them per session on `xops ssh|scp|sftp|exec` via `-o Key=Value`. Persisted files emit canonical `snake_case` keys while also accepting OpenSSH `PascalCase` option names (such as `ControlMaster` and `ConnectTimeout`) when edited by hand: + +```yaml +schema_version: 2 +ssh_options: + control_master: auto + control_path: ~/.ssh/cm-%C + control_persist: 10m + connect_timeout: 15s + server_alive_interval: 30s + +nodes: + web-prod-01: + host_ref: h_web + identity_ref: id_deploy + ssh_options: + control_persist: 30m + strict_host_key_checking: accept-new +``` + +**Configuration precedence**: CLI `-M`/`-S`/`-o` > Node configuration (`node.ssh_options`) > Global configuration (`cfg.ssh_options`) > `~/.ssh/config` (host-trust options such as `StrictHostKeyChecking` and `UserKnownHostsFile` are read only from explicit CLI `-o` flags or `xops_config.yaml`, never implicitly inherited from `~/.ssh/config`). + ## Removed credential arguments Plaintext flags `--password` and `--key-pass` (and their short forms) have been removed from `host add|edit` and `identity add|edit` to keep secrets out of command arguments and shell history; the legacy `loadHost` command has also been removed (use `xops host import`). diff --git a/docs/guide/hosts.md b/docs/guide/hosts.md index dd729e74..da4206bc 100644 --- a/docs/guide/hosts.md +++ b/docs/guide/hosts.md @@ -135,6 +135,30 @@ nodes: **生效优先级**:CLI 命令行参数 > Playbook 步骤 `execution` > Playbook 全局 `settings.execution` > 节点配置 (`node.execution`) > 全局配置 (`cfg.execution`) > 系统默认 (`server`)。 +## OpenSSH 客户端选项与连接共享(`ssh_options`) + +你可以在 `~/.xops/xops_config.yaml` 的全局或节点层级配置 `ssh_options`,或在 `xops ssh|scp|sftp|exec` 中通过 `-o Key=Value` 临时覆盖。落盘配置统一使用 `snake_case`,同时也支持手动编辑时直接书写 OpenSSH 标准 `PascalCase` 键名(如 `ControlMaster`、`ConnectTimeout`): + +```yaml +schema_version: 2 +ssh_options: + control_master: auto + control_path: ~/.ssh/cm-%C + control_persist: 10m + connect_timeout: 15s + server_alive_interval: 30s + +nodes: + web-prod-01: + host_ref: h_web + identity_ref: id_deploy + ssh_options: + control_persist: 30m + strict_host_key_checking: accept-new +``` + +**生效优先级**:CLI `-M`/`-S`/`-o` > 节点配置 (`node.ssh_options`) > 全局配置 (`cfg.ssh_options`) > `~/.ssh/config`(其中 `StrictHostKeyChecking` 与 `UserKnownHostsFile` 等主机信任选项仅从 CLI `-o` 或 `xops_config.yaml` 显式配置中读取,不从 `~/.ssh/config` 隐式继承)。 + ## 已删除的凭据参数 `host add|edit` 和 `identity add|edit` 已移除明文参数 `--password`、`--key-pass` 及其短参数,避免密码泄露在命令行参数和 Shell 历史中;旧命令 `loadHost` 也已移除(请改用 `xops host import`)。 diff --git a/internal/mcphost/connector.go b/internal/mcphost/connector.go index b4057503..17a34812 100644 --- a/internal/mcphost/connector.go +++ b/internal/mcphost/connector.go @@ -25,7 +25,11 @@ func newNonInteractiveMCPConnector(provider config.ConfigProvider, l corelog.Deb if cfg := provider.Snapshot(); cfg != nil && cfg.PasswordPromptPattern != "" { sshOpts = append(sshOpts, ssh.WithPasswordPromptPattern(cfg.PasswordPromptPattern)) } - adpOpts := []adapter.Option{adapter.WithNonInteractive(true), adapter.WithControlMultiplexing(false)} + adpOpts := []adapter.Option{ + adapter.WithNonInteractive(true), + adapter.WithControlMultiplexing(false), + adapter.WithHostTrustOverrides(false), + } if credentialRegistry != nil { adpOpts = append(adpOpts, adapter.WithCredentialSource(credentialRegistry)) } diff --git a/internal/mcphost/host.go b/internal/mcphost/host.go index a0843e19..41435fc4 100644 --- a/internal/mcphost/host.go +++ b/internal/mcphost/host.go @@ -114,7 +114,7 @@ func (h *host) resolveSnapshot(ctx context.Context, request ports.ResolveRequest return ports.OperationSnapshot{}, err } view := ports.OperationSnapshot{DomainID: h.domain, Policy: h.policy, Targets: make(map[string]ports.Target), Selectors: make(map[string]string)} - adp := adapter.NewSSHAdapter(p, adapter.WithNonInteractive(true), adapter.WithControlMultiplexing(false), adapter.WithCredentialSource(h.config.Registry), adapter.WithCredentialRecording(false)) + adp := adapter.NewSSHAdapter(p, adapter.WithNonInteractive(true), adapter.WithControlMultiplexing(false), adapter.WithHostTrustOverrides(false), adapter.WithCredentialSource(h.config.Registry), adapter.WithCredentialRecording(false)) for _, selector := range request.Selectors { if selector == "" { continue diff --git a/pkg/adapter/enrollment_probe.go b/pkg/adapter/enrollment_probe.go index c787fa44..5d79d1bd 100644 --- a/pkg/adapter/enrollment_probe.go +++ b/pkg/adapter/enrollment_probe.go @@ -42,7 +42,7 @@ func (a *SSHAdapter) ConfirmAuthenticatedConnection(ctx context.Context, nodeID repo, ok := a.cfgProvider.(enrollmentProbeRepository) auth, remembering := a.getSessionOverride(nodeID) _, excluded := a.enrollmentProbeExcluded[nodeID] - if !ok || !a.enrollmentProbe || excluded || a.nonInteractive || !a.credentialRecording || !remembering || !auth.Remember { + if !ok || !a.enrollmentProbe || excluded || a.isNonInteractiveForNode(nodeID) || !a.credentialRecording || !remembering || !auth.Remember { return nil, a.ConfirmConnection(ctx, nodeID) } nodeExecution, pending := repo.PendingNodeExecution(nodeID) diff --git a/pkg/adapter/ssh_adapter.go b/pkg/adapter/ssh_adapter.go index 60c6349d..3fc51265 100644 --- a/pkg/adapter/ssh_adapter.go +++ b/pkg/adapter/ssh_adapter.go @@ -29,42 +29,96 @@ type SessionAuth struct { Remember bool } -// SessionControlOptions specifies session-level multiplexing overrides (Tier 1). -// Pointers indicate whether a flag was explicitly specified on the CLI. +// SessionControlOptions specifies session-level OpenSSH option overrides (Tier 1). +// Pointers indicate whether a multiplexing flag was explicitly specified on the CLI. type SessionControlOptions struct { Master *ssh.ControlMasterMode Path *string Persist *string + Options *ssh.Options } -// IsEmpty returns true if no session control flags were provided. +// IsEmpty returns true if no session control or SSH option flags were provided. func (s SessionControlOptions) IsEmpty() bool { - return s.Master == nil && s.Path == nil && s.Persist == nil + return s.Master == nil && s.Path == nil && s.Persist == nil && s.Options.IsEmpty() +} + +func (s *SessionControlOptions) ensureOptions() *ssh.Options { + if s.Options == nil { + s.Options = &ssh.Options{} + } + return s.Options } // SetMaster sets the ControlMaster override. func (s *SessionControlOptions) SetMaster(m ssh.ControlMasterMode) { + if m == "" { + s.Master = nil + if s.Options != nil { + s.Options.Delete(ssh.OptionControlMaster) + } + return + } s.Master = &m + s.ensureOptions().SetControlMaster(m) } // SetPath sets the ControlPath override. func (s *SessionControlOptions) SetPath(p string) { + if p == "" { + s.Path = nil + if s.Options != nil { + s.Options.Delete(ssh.OptionControlPath) + } + return + } s.Path = &p + s.ensureOptions().SetControlPath(p) } // SetPersist sets the ControlPersist override. func (s *SessionControlOptions) SetPersist(p string) { + if p == "" { + s.Persist = nil + if s.Options != nil { + s.Options.Delete(ssh.OptionControlPersist) + } + return + } s.Persist = &p + s.ensureOptions().SetControlPersist(p) } -// supportedSSHOptions lists the -o keys this CLI understands. Unknown keys are -// rejected instead of ignored so a typo cannot silently disable the requested -// behavior. -var supportedSSHOptions = []string{ - "controlmaster", "controlpath", "controlpersist", "port", "user", "identityfile", "proxyjump", +// ToSSHOptions returns the session-level ssh.Options representation (including Master/Path/Persist fields). +func (s SessionControlOptions) ToSSHOptions() *ssh.Options { + opts := s.Options.Clone() + if s.Master != nil && *s.Master != "" { + if opts == nil { + opts = &ssh.Options{} + } + opts.SetControlMaster(*s.Master) + } + if s.Path != nil && *s.Path != "" { + if opts == nil { + opts = &ssh.Options{} + } + opts.SetControlPath(*s.Path) + } + if s.Persist != nil && *s.Persist != "" { + if opts == nil { + opts = &ssh.Options{} + } + opts.SetControlPersist(*s.Persist) + } + if opts.IsEmpty() { + return nil + } + return opts } // ParseOptionStrings parses OpenSSH -o Key=Value strings into SessionControlOptions. +// Unknown keys are rejected instead of ignored so a typo cannot silently disable +// the requested behavior. func ParseOptionStrings(options []string) (SessionControlOptions, error) { var opts SessionControlOptions for _, opt := range options { @@ -78,35 +132,64 @@ func ParseOptionStrings(options []string) (SessionControlOptions, error) { } key = strings.TrimSpace(key) val = strings.TrimSpace(val) - - switch strings.ToLower(key) { - case "controlmaster": - m, err := ssh.ParseControlMasterMode(val) - if err != nil { - return opts, fmt.Errorf("invalid ControlMaster in -o option: %w", err) - } - opts.SetMaster(m) - case "controlpath": - opts.SetPath(val) - case "controlpersist": - if _, _, _, err := ssh.ParseControlPersist(val); err != nil { - return opts, fmt.Errorf("invalid ControlPersist in -o option: %w", err) - } - opts.SetPersist(val) - case "port": - p, err := strconv.ParseUint(val, 10, 16) - if err != nil || p == 0 { - return opts, fmt.Errorf("invalid Port in -o option: %q", val) - } - case "user", "identityfile", "proxyjump": - // Applied to the connection target by the CLI, see ApplyGeneralOptions. - default: - return opts, fmt.Errorf("unsupported -o option %q (supported: %s)", key, strings.Join(supportedSSHOptions, ", ")) + if key == "" { + return opts, fmt.Errorf("invalid -o option format (expected Key=Value): %q", opt) + } + if err := applyParsedOption(&opts, key, val); err != nil { + return opts, err } } return opts, nil } +func applyParsedOption(opts *SessionControlOptions, key, val string) error { + switch strings.ToLower(key) { + case "controlmaster", "control_master": + m, err := ssh.ParseControlMasterMode(val) + if err != nil { + return fmt.Errorf("invalid ControlMaster in -o option: %w", err) + } + opts.SetMaster(m) + case "controlpath", "control_path": + if val == "" { + return fmt.Errorf("invalid ControlPath in -o option: value cannot be empty") + } + if strings.EqualFold(val, "none") { + val = "none" + } else if err := ssh.ValidateControlPathSyntax(val); err != nil { + return fmt.Errorf("invalid ControlPath in -o option: %w", err) + } + opts.SetPath(val) + case "controlpersist", "control_persist": + if _, _, _, err := ssh.ParseControlPersist(val); err != nil { + return fmt.Errorf("invalid ControlPersist in -o option: %w", err) + } + opts.SetPersist(strings.ToLower(val)) + case "port": + p, err := strconv.ParseUint(val, 10, 16) + if err != nil || p == 0 { + return fmt.Errorf("invalid Port in -o option: %q", val) + } + case "user", "identityfile", "proxyjump": + if val == "" { + return fmt.Errorf("invalid %s in -o option: value cannot be empty", key) + } + // Applied to the connection target by the CLI, see ApplyGeneralOptions. + default: + spec, supported := ssh.LookupOptionSpec(key) + if !supported { + return fmt.Errorf("unsupported -o option %q", key) + } + if val == "" { + return fmt.Errorf("invalid %s in -o option: value cannot be empty", spec.OpenSSHName) + } + if err := opts.ensureOptions().Set(spec.CanonicalKey, val); err != nil { + return fmt.Errorf("invalid %s in -o option: %w", spec.OpenSSHName, err) + } + } + return nil +} + // CredentialResolver 描述能够根据 Ref 解析 Secret 的契约(如 *credential.Registry) type CredentialResolver interface { Resolve(ctx context.Context, ref credential.Ref) (credential.Secret, error) @@ -156,6 +239,32 @@ func WithGlobalSessionControl(opts SessionControlOptions) Option { } } +// DisableSessionControlSharing forces ControlPath=none for the session (both +// globally and on nodeID) while preserving any non-multiplexing session SSH +// options already registered for nodeID or globally. +func (a *SSHAdapter) DisableSessionControlSharing(nodeID string) { + disable := func(s SessionControlOptions) SessionControlOptions { + var out SessionControlOptions + if s.Options != nil { + out.Options = s.Options.Clone() + out.Options.StripClasses(ssh.OptionClassMux) + } + out.SetPath("none") + return out + } + if a.globalSessionControl != nil { + updated := disable(*a.globalSessionControl) + a.globalSessionControl = &updated + } else { + updated := disable(SessionControlOptions{}) + a.globalSessionControl = &updated + } + if a.sessionControlOverrides == nil { + a.sessionControlOverrides = make(map[string]SessionControlOptions) + } + a.sessionControlOverrides[nodeID] = disable(a.sessionControlOverrides[nodeID]) +} + // WithOpenSSHParser injects an explicit OpenSSHParser for Tier 4 resolution (e.g. in tests). func WithOpenSSHParser(parser *config.OpenSSHParser) Option { return func(a *SSHAdapter) { @@ -195,6 +304,16 @@ func WithControlMultiplexing(enabled bool) Option { } } +// WithHostTrustOverrides controls whether this adapter forwards trust-class +// SSH options (StrictHostKeyChecking, UserKnownHostsFile) from configuration +// or session overrides to the underlying ssh.Connector. MCP and verification +// paths disable it to enforce fail-closed host key verification. +func WithHostTrustOverrides(enabled bool) Option { + return func(a *SSHAdapter) { + a.disableHostTrust = !enabled + } +} + // SSHAdapter 实现 ssh.ConnectionProvider, ssh.SecretResolver, ssh.CredentialRecorder 接口,作为业务模型与底层 SSH 的防腐层 type SSHAdapter struct { cfgProvider config.ConfigProvider @@ -207,6 +326,7 @@ type SSHAdapter struct { openSSHParser *config.OpenSSHParser nonInteractive bool disableControl bool + disableHostTrust bool credentialRecording bool rememberConfirmation func(context.Context, string) (bool, error) enrollmentProbe bool @@ -327,45 +447,78 @@ func (a *SSHAdapter) GetControlConfig(nodeID string) (*ssh.ControlConfig, error) return a.ResolveControl(nodeID, sessionOpts) } -// ResolveControl resolves the effective ControlConfig for nodeID given session options, -// strictly enforcing the 4-tier precedence hierarchy and field-by-field merging semantics. -func (a *SSHAdapter) ResolveControl(nodeName string, sessionOpts SessionControlOptions) (*ssh.ControlConfig, error) { - if a.disableControl { - return nil, nil - } +// ResolveSSHOptions resolves the effective ssh.Options for nodeName given session options, +// enforcing the 4-tier precedence hierarchy (Tier 1 CLI > Tier 2 Node > Tier 3 Global > Tier 4 ~/.ssh/config). +func (a *SSHAdapter) ResolveSSHOptions(nodeName string, sessionOpts SessionControlOptions) (*ssh.Options, error) { if a.cfgProvider == nil { return nil, fmt.Errorf("configuration provider is nil") } - // 1. Resolve Tier 2 (Node Config) and target context. OpenSSH virtual nodes - // carry their ~/.ssh/config values in Node.Control, but they remain Tier 4: - // they must not outrank the persisted global defaults. - var tier2NodeCtrl *ssh.ControlConfig + t2Opts, t3Opts, t4Opts := a.resolveOptionTiers(nodeName) + merged, err := ssh.EffectiveOptions(sessionOpts.ToSSHOptions(), t2Opts, t3Opts, t4Opts) + if err != nil { + return nil, err + } + if merged.IsEmpty() { + return nil, nil + } + // Multiplexing options are governed and validated by ResolveControl / GetControlConfig; + // strip control fields when control multiplexing is disabled on this adapter. + if a.disableControl { + merged.StripClasses(ssh.OptionClassMux) + } + if a.disableHostTrust { + merged.StripClasses(ssh.OptionClassTrust) + } + if merged.IsEmpty() { + return nil, nil + } + return merged, nil +} + +func (a *SSHAdapter) resolveOptionTiers(nodeName string) (tier2Node, tier3Global, tier4OpenSSH *ssh.Options) { snapshot, snapErr := a.cfgProvider.ResolveConnection(nodeName) virtualOpenSSHNode := strings.HasPrefix(nodeName, config.OpenSSHNodePrefix) if snapErr == nil { - if virtualOpenSSHNode { - tier2NodeCtrl = nil - } else { - tier2NodeCtrl = snapshot.Node.Control + if !virtualOpenSSHNode { + tier2Node = snapshot.Node.SSHOptions } } - // 2. Resolve Tier 3 (Global Defaults) - var tier3GlobalCtrl *ssh.ControlConfig if globalCfg := a.cfgProvider.Snapshot(); globalCfg != nil { - tier3GlobalCtrl = globalCfg.Control + tier3Global = globalCfg.SSHOptions } - // 3. Resolve Tier 4 (OpenSSH Config Fallback) - var tier4OpenSSHCtrl *ssh.ControlConfig if virtualOpenSSHNode { if snapErr == nil { - tier4OpenSSHCtrl = snapshot.Node.Control + tier4OpenSSH = snapshot.Node.SSHOptions.Clone() } } else { - tier4OpenSSHCtrl = a.resolveTier4OpenSSH(nodeName, snapErr, snapshot) + tier4OpenSSH = a.resolveTier4OpenSSHOptions(nodeName, snapErr, snapshot).FilterByClasses( + ssh.OptionClassMux, + ssh.OptionClassTransport, + ) } + if tier4OpenSSH != nil && tier4OpenSSH.ControlConfig() != nil { + tier4OpenSSH.ImplicitTier4 = true + } + return tier2Node, tier3Global, tier4OpenSSH +} + +// ResolveControl resolves the effective ControlConfig for nodeID given session options, +// strictly enforcing the 4-tier precedence hierarchy and field-by-field merging semantics. +func (a *SSHAdapter) ResolveControl(nodeName string, sessionOpts SessionControlOptions) (*ssh.ControlConfig, error) { + if a.disableControl { + return nil, nil + } + if a.cfgProvider == nil { + return nil, fmt.Errorf("configuration provider is nil") + } + + t2Opts, t3Opts, t4Opts := a.resolveOptionTiers(nodeName) + tier2NodeCtrl := t2Opts.ControlConfig() + tier3GlobalCtrl := t3Opts.ControlConfig() + tier4OpenSSHCtrl := t4Opts.ControlConfig() // 4. Field-by-field merging: Tier 1 > Tier 2 > Tier 3 > Tier 4 merged := mergeControlFields(sessionOpts, tier2NodeCtrl, tier3GlobalCtrl, tier4OpenSSHCtrl) @@ -381,7 +534,7 @@ func (a *SSHAdapter) ResolveControl(nodeName string, sessionOpts SessionControlO return merged, nil } -func (a *SSHAdapter) resolveTier4OpenSSH(nodeName string, snapErr error, snapshot config.ConnectionSnapshot) *ssh.ControlConfig { +func (a *SSHAdapter) resolveTier4OpenSSHOptions(nodeName string, snapErr error, snapshot config.ConnectionSnapshot) *ssh.Options { if strings.HasPrefix(nodeName, config.OpenSSHNodePrefix) { return nil } @@ -393,21 +546,23 @@ func (a *SSHAdapter) resolveTier4OpenSSH(nodeName string, snapErr error, snapsho // 1. First pass: prioritize concrete host matches (defined without wildcards like Host *) // across OpenSSHAlias, nodeName, Host.Address, and Alias so specific definitions take precedence. + var layers []*ssh.Options for _, key := range lookupKeys { if parser.HasHost(key) { - if ctrl, err := parser.GetControlConfig(key); err == nil && ctrl != nil { - return ctrl + if opts, err := parser.GetSSHOptions(key); err == nil && !opts.IsEmpty() { + layers = append(layers, opts) } } } - - // 2. Second pass: fallback to generic wildcard / pattern matches (e.g. Host *). - for _, key := range lookupKeys { - if ctrl, err := parser.GetControlConfig(key); err == nil && ctrl != nil { - return ctrl + if len(layers) == 0 { + // 2. Second pass: fallback to generic wildcard / pattern matches (e.g. Host *). + for _, key := range lookupKeys { + if opts, err := parser.GetSSHOptions(key); err == nil && !opts.IsEmpty() { + layers = append(layers, opts) + } } } - return nil + return ssh.MergeOptionLayers(layers...) } func (a *SSHAdapter) buildTier4OpenSSHLookupKeys(parser *config.OpenSSHParser, nodeName string, snapErr error, snapshot config.ConnectionSnapshot) []string { @@ -528,6 +683,9 @@ func resolveMergedMaster(t1 SessionControlOptions, t2, t3, t4 *ssh.ControlConfig if t1.Master != nil && *t1.Master != "" { return *t1.Master } + if t1.Options != nil && t1.Options.ControlMaster() != "" { + return t1.Options.ControlMaster() + } if t2 != nil && t2.Master != "" { return t2.Master } @@ -544,6 +702,9 @@ func resolveMergedPath(t1 SessionControlOptions, t2, t3, t4 *ssh.ControlConfig) if t1.Path != nil { return *t1.Path } + if t1.Options != nil && t1.Options.ControlPath() != "" { + return t1.Options.ControlPath() + } if t2 != nil && t2.Path != "" { return t2.Path } @@ -560,6 +721,9 @@ func resolveMergedPersist(t1 SessionControlOptions, t2, t3, t4 *ssh.ControlConfi if t1.Persist != nil { return *t1.Persist } + if t1.Options != nil && t1.Options.ControlPersist() != "" { + return t1.Options.ControlPersist() + } if t2 != nil && t2.Persist != "" { return t2.Persist } @@ -594,6 +758,7 @@ func mergeControlFields( } hasExplicitPersist := (t1.Persist != nil && *t1.Persist != "") || + (t1.Options != nil && t1.Options.ControlPersist() != "") || (t2 != nil && t2.Persist != "") || (t3 != nil && t3.Persist != "") @@ -684,6 +849,12 @@ func (a *SSHAdapter) GetConfig(nodeID string) (*ssh.ClientConfig, error) { return nil, fmt.Errorf("resolve execution for node %q failed: %w", nodeID, err) } + sessionCtrlOpts, _ := a.getSessionControlOverride(nodeID) + sshOpts, err := a.ResolveSSHOptions(nodeID, sessionCtrlOpts) + if err != nil { + return nil, fmt.Errorf("resolve ssh options for node %q failed: %w", nodeID, err) + } + cfg := &ssh.ClientConfig{ NodeID: nodeID, Address: snapshot.Host.Address, @@ -702,6 +873,7 @@ func (a *SSHAdapter) GetConfig(nodeID string) (*ssh.ClientConfig, error) { HasOriginalProxyJump: true, PasswordPromptPattern: snapshot.Node.PasswordPromptPattern, Execution: execCfg, + Options: sshOpts, } if override, ok := a.getSessionOverride(nodeID); ok { @@ -750,10 +922,22 @@ func (a *SSHAdapter) applySessionOverrideToConfig(nodeID string, cfg *ssh.Client return nil } +func (a *SSHAdapter) isNonInteractiveForNode(nodeID string) bool { + if a.nonInteractive { + return true + } + if a.cfgProvider == nil { + return false + } + sessionCtrlOpts, _ := a.getSessionControlOverride(nodeID) + opts, err := a.ResolveSSHOptions(nodeID, sessionCtrlOpts) + return err == nil && opts != nil && opts.BatchMode() +} + // UpdateAuth 处理密码或私钥密码的回写并返回本次提交后的新认证令牌 func (a *SSHAdapter) UpdateAuth(ctx context.Context, nodeID, authUpdateToken, password, keyPath, passphrase string) (string, error) { - if a.nonInteractive { - // 非交互模式禁止写回自动发现的秘密 + if a.isNonInteractiveForNode(nodeID) { + // 非交互模式或 BatchMode=yes 禁止写回自动发现的秘密 return authUpdateToken, nil } if !a.credentialRecording { @@ -844,8 +1028,8 @@ func (a *SSHAdapter) defaultStoreID() string { // UpdateSudo 处理提权密码和模式的回写并返回本次提交后的新提权令牌 func (a *SSHAdapter) UpdateSudo(ctx context.Context, nodeID, sudoUpdateToken string, mode ssh.SudoMode, suPwd string) (string, error) { - if a.nonInteractive { - // 非交互模式禁止写回自动发现的秘密 + if a.isNonInteractiveForNode(nodeID) { + // 非交互模式或 BatchMode=yes 禁止写回自动发现的秘密 return sudoUpdateToken, nil } if !a.credentialRecording { @@ -922,7 +1106,7 @@ func (a *SSHAdapter) ResolveSecret(ctx context.Context, req ssh.SecretRequest) ( if ctx == nil { return nil, fmt.Errorf("secret resolution context is nil") } - if a.nonInteractive { + if a.isNonInteractiveForNode(req.NodeID) { ctx = credential.WithoutInteraction(ctx) } if ctx != nil { @@ -1025,7 +1209,7 @@ func (a *SSHAdapter) resolveOTP( Prompt: promptText, Instruction: req.Instruction, Echo: req.Echo, - NonInteractive: a.nonInteractive || credential.InteractionDisabled(ctx), + NonInteractive: a.isNonInteractiveForNode(req.NodeID) || credential.InteractionDisabled(ctx), } ans, err := credentialhelper.RunAnswerCommand(ctx, opts) if err != nil { @@ -1150,7 +1334,7 @@ func (a *SSHAdapter) resolveChallengeRuleAnswer( Prompt: req.Prompt, Instruction: req.Instruction, Echo: req.Echo, - NonInteractive: a.nonInteractive || credential.InteractionDisabled(ctx), + NonInteractive: a.isNonInteractiveForNode(req.NodeID) || credential.InteractionDisabled(ctx), } ans, err := credentialhelper.RunAnswerCommand(ctx, opts) if err != nil { diff --git a/pkg/adapter/ssh_adapter_control_test.go b/pkg/adapter/ssh_adapter_control_test.go index e4ff037c..a5dbcae8 100644 --- a/pkg/adapter/ssh_adapter_control_test.go +++ b/pkg/adapter/ssh_adapter_control_test.go @@ -21,7 +21,7 @@ func setupTestProvider( Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), - Control: globalCtrl, + SSHOptions: ssh.OptionsFromControl(globalCtrl), } cfg.Hosts.Set("host1", models.Host{Address: "192.168.1.100", Port: 2222}) @@ -30,7 +30,7 @@ func setupTestProvider( HostRef: "host1", IdentityRef: "id1", Alias: []string{"node-alias"}, - Control: nodeCtrl, + SSHOptions: ssh.OptionsFromControl(nodeCtrl), }) var parser *config.OpenSSHParser @@ -682,7 +682,7 @@ Host app1 Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), - Control: &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "/tmp/global.sock", Persist: "10m"}, + SSHOptions: ssh.OptionsFromControl(&ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: "/tmp/global.sock", Persist: "10m"}), } provider := config.NewProviderWithOpenSSHParser(cfg, parser) adp := NewSSHAdapter(provider) @@ -728,7 +728,7 @@ Host app2 Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), - Control: &ssh.ControlConfig{Master: ssh.ControlMasterAuto}, + SSHOptions: ssh.OptionsFromControl(&ssh.ControlConfig{Master: ssh.ControlMasterAuto}), } provider := config.NewProviderWithOpenSSHParser(cfg, parser) adp := NewSSHAdapter(provider) @@ -737,8 +737,8 @@ Host app2 if err != nil { t.Fatalf("Resolve failed: %v", err) } - if node.Control == nil || node.Control.Master != "" { - t.Fatalf("expected an unset ControlMaster for a host that omits it, got %+v", node.Control) + if node.SSHOptions == nil || node.SSHOptions.ControlMaster() != "" { + t.Fatalf("expected an unset ControlMaster for a host that omits it, got %+v", node.SSHOptions) } res, err := adp.ResolveControl(config.OpenSSHNodePrefix+"app2", SessionControlOptions{}) @@ -759,7 +759,7 @@ func TestParseOptionStrings_RejectsUnknownKey(t *testing.T) { for _, option := range []string{ "ControlMaste=yes", "EnableMultiplexing=true", - "Compression=yes", + "UnknownSSHDirective=yes", } { if _, err := ParseOptionStrings([]string{option}); err == nil { t.Fatalf("expected %q to be rejected", option) @@ -768,12 +768,166 @@ func TestParseOptionStrings_RejectsUnknownKey(t *testing.T) { } } - // Supported keys still parse, including the ones applied to the target by the CLI. - opts, err := ParseOptionStrings([]string{"ControlMaster=auto", "ControlPath=/tmp/x.sock", "Port=2222", "User=deploy", "IdentityFile=~/.ssh/id", "ProxyJump=bastion"}) + // Supported keys parse cleanly, including target overrides and extended OpenSSH options. + opts, err := ParseOptionStrings([]string{ + "ControlMaster=auto", + "ControlPath=/tmp/x.sock", + "Port=2222", + "User=deploy", + "IdentityFile=~/.ssh/id", + "ProxyJump=bastion", + "ConnectTimeout=12", + "StrictHostKeyChecking=accept-new", + "BatchMode=yes", + "TCPKeepAlive=no", + "PreferredAuthentications=publickey,password", + }) if err != nil { t.Fatalf("expected supported options to parse: %v", err) } if opts.Master == nil || *opts.Master != ssh.ControlMasterAuto { t.Fatalf("expected ControlMaster=auto, got %v", opts.Master) } + sshOpts := opts.ToSSHOptions() + tcpKeepAlive, tcpKeepAliveSet := sshOpts.TCPKeepAlive() + if sshOpts == nil || sshOpts.StrictHostKeyChecking() != ssh.StrictHostKeyCheckingAcceptNew || !sshOpts.BatchMode() || !tcpKeepAliveSet || tcpKeepAlive { + t.Fatalf("unexpected parsed ssh.Options: %+v", sshOpts) + } +} + +func TestResolveSSHOptions_FourTierPrecedenceAndGetConfig(t *testing.T) { + openSSHContent := ` +Host testnode + ConnectTimeout 30 + StrictHostKeyChecking no + PasswordAuthentication no + ServerAliveInterval 60 +` + parser, err := config.NewOpenSSHParserFromReader(strings.NewReader(openSSHContent)) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + SSHOptions: ssh.MustOptions( + ssh.OptionConnectTimeout, "20s", + ssh.OptionStrictHostKeyChecking, "accept-new", + ), + } + cfg.Hosts.Set("host1", models.Host{Address: "192.168.1.100", Port: 2222}) + cfg.Identities.Set("id1", models.Identity{User: "alice", AuthType: "auto"}) + cfg.Nodes.Set("testnode", models.Node{ + HostRef: "host1", + IdentityRef: "id1", + SSHOptions: ssh.MustOptions( + ssh.OptionConnectTimeout, "10s", + ssh.OptionIdentitiesOnly, "yes", + ), + }) + + provider := config.NewProviderWithOpenSSHParser(cfg, parser) + cliOpts, err := ParseOptionStrings([]string{"ConnectTimeout=5", "BatchMode=yes"}) + if err != nil { + t.Fatalf("ParseOptionStrings failed: %v", err) + } + adp := NewSSHAdapter(provider, WithSessionControlOverride("testnode", cliOpts)) + + clientCfg, err := adp.GetConfig("testnode") + if err != nil { + t.Fatalf("GetConfig failed: %v", err) + } + if clientCfg.Options == nil { + t.Fatal("expected clientCfg.Options to be populated") + } + // Tier 1 wins for ConnectTimeout and BatchMode + if clientCfg.Options.Get(ssh.OptionConnectTimeout) != "5" || !clientCfg.Options.BatchMode() { + t.Fatalf("expected Tier 1 ConnectTimeout=5 and BatchMode=yes, got %+v", clientCfg.Options) + } + // Tier 2 wins for IdentitiesOnly + if !clientCfg.Options.IdentitiesOnly() { + t.Fatalf("expected Tier 2 IdentitiesOnly=yes, got %+v", clientCfg.Options) + } + // Tier 3 provides StrictHostKeyChecking (Tier 4 trust options are never inherited) + if clientCfg.Options.StrictHostKeyChecking() != ssh.StrictHostKeyCheckingAcceptNew { + t.Fatalf("expected Tier 3 StrictHostKeyChecking=accept-new, got %q", clientCfg.Options.StrictHostKeyChecking()) + } + // Tier 4 provides ServerAliveInterval (transport class), but NOT PasswordAuthentication=no (auth class) on managed nodes + if clientCfg.Options.Get(ssh.OptionServerAliveInterval) != "60" { + t.Fatalf("expected Tier 4 ServerAliveInterval=60, got %q", clientCfg.Options.Get(ssh.OptionServerAliveInterval)) + } + if _, set := clientCfg.Options.PasswordAuthentication(); set { + t.Fatalf("expected managed node not to inherit Tier 4 PasswordAuthentication=no") + } + + // H1 (MCP isolation): WithHostTrustOverrides(false) strips StrictHostKeyChecking and UserKnownHostsFile + mcpAdp := NewSSHAdapter(provider, WithHostTrustOverrides(false), WithControlMultiplexing(false)) + mcpCfg, err := mcpAdp.GetConfig("testnode") + if err != nil { + t.Fatalf("mcpAdp.GetConfig failed: %v", err) + } + if mcpCfg.Options != nil && mcpCfg.Options.StrictHostKeyChecking() != "" { + t.Fatalf("expected WithHostTrustOverrides(false) to strip StrictHostKeyChecking, got %q", mcpCfg.Options.StrictHostKeyChecking()) + } +} + +func TestResolveTier4OpenSSHOptions_MergesAcrossConcreteLookupKeys(t *testing.T) { + openSSHContent := ` +Host testnode + ConnectTimeout 15 + +Host 192.168.1.100 + ControlMaster auto + ControlPath ~/.ssh/cm-%C +` + parser, err := config.NewOpenSSHParserFromReader(strings.NewReader(openSSHContent)) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + cfg := &config.Configuration{ + Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), + Identities: concurrent.NewMap[string, models.Identity](concurrent.HashString), + Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), + } + cfg.Hosts.Set("host1", models.Host{Address: "192.168.1.100", Port: 22}) + cfg.Identities.Set("id1", models.Identity{User: "alice", AuthType: "auto"}) + cfg.Nodes.Set("testnode", models.Node{HostRef: "host1", IdentityRef: "id1"}) + + provider := config.NewProviderWithOpenSSHParser(cfg, parser) + adp := NewSSHAdapter(provider) + + ctrl, err := adp.ResolveControl("testnode", SessionControlOptions{}) + if err != nil { + t.Fatalf("ResolveControl failed: %v", err) + } + if ctrl == nil || ctrl.Master != ssh.ControlMasterAuto || ctrl.Path != "~/.ssh/cm-%C" { + t.Fatalf("expected ControlConfig from second concrete key 192.168.1.100, got %+v", ctrl) + } + opts, err := adp.ResolveSSHOptions("testnode", SessionControlOptions{}) + if err != nil { + t.Fatalf("ResolveSSHOptions failed: %v", err) + } + if opts == nil || opts.Get(ssh.OptionConnectTimeout) != "15" { + t.Fatalf("expected ConnectTimeout=15 from first concrete key testnode, got %+v", opts) + } + if !opts.ImplicitTier4 || !ctrl.ImplicitTier4 { + t.Fatalf("expected ImplicitTier4=true on both ResolveSSHOptions and ResolveControl for pure Tier 4 config, got opts=%v ctrl=%v", opts.ImplicitTier4, ctrl.ImplicitTier4) + } + + // Direct SessionControlOptions.Options population is respected by ResolveControl + directSession := SessionControlOptions{ + Options: ssh.MustOptions( + ssh.OptionControlMaster, "yes", + ssh.OptionControlPath, "/tmp/direct-%C", + ssh.OptionControlPersist, "5m", + ), + } + ctrlDirect, err := adp.ResolveControl("testnode", directSession) + if err != nil { + t.Fatalf("ResolveControl(directSession) failed: %v", err) + } + if ctrlDirect == nil || ctrlDirect.Master != ssh.ControlMasterYes || ctrlDirect.Path != "/tmp/direct-%C" || ctrlDirect.Persist != "5m" || ctrlDirect.ImplicitTier4 { + t.Fatalf("unexpected ctrlDirect: %+v", ctrlDirect) + } } diff --git a/pkg/adapter/ssh_adapter_test.go b/pkg/adapter/ssh_adapter_test.go index dfd61d0c..b3f9c0bf 100644 --- a/pkg/adapter/ssh_adapter_test.go +++ b/pkg/adapter/ssh_adapter_test.go @@ -2345,3 +2345,122 @@ func TestSSHAdapter_ZeroPortAndEmptyUserSnapshotNormalization(t *testing.T) { t.Fatalf("expected ErrSnapshotMismatch for mismatched user, got %v", err) } } + +type batchModeSpyResolver struct { + interactionDisabled bool +} + +func (r *batchModeSpyResolver) Resolve(ctx context.Context, _ credential.Ref) (credential.Secret, error) { + r.interactionDisabled = credential.InteractionDisabled(ctx) + return credential.Secret{Value: []byte("resolved-secret")}, nil +} + +func TestSSHAdapter_BatchModeDisablesInteractionAndWriteback(t *testing.T) { + ctx := context.Background() + cfg := config.NewProviderWithoutOpenSSH(nil).Snapshot() + cfg.Hosts.Set("h-batch", models.Host{Address: "10.88.77.66", Port: 22}) + cfg.Identities.Set("id-batch", models.Identity{ + User: "root", + AuthType: "password", + LoginPasswordRef: &credential.Ref{StoreID: "encrypted_file", ItemID: "item-1"}, + }) + cfg.Nodes.Set("node-batch", models.Node{ + HostRef: "h-batch", + IdentityRef: "id-batch", + SSHOptions: ssh.MustOptions(ssh.OptionBatchMode, "yes"), + }) + provider := config.NewProviderWithoutOpenSSH(cfg) + + spy := &batchModeSpyResolver{} + confirmCalled := false + adp := NewSSHAdapter( + provider, + WithCredentialSource(spy), + WithRememberConfirmation(func(context.Context, string) (bool, error) { + confirmCalled = true + return true, nil + }), + ) + + secret, err := adp.ResolveSecret(ctx, ssh.SecretRequest{ + Kind: ssh.SecretKindLoginPassword, + NodeID: "node-batch", + Host: "10.88.77.66", + Port: 22, + User: "root", + }) + if err != nil || string(secret) != "resolved-secret" { + t.Fatalf("ResolveSecret failed: secret=%q err=%v", string(secret), err) + } + if !spy.interactionDisabled { + t.Fatal("expected credential.InteractionDisabled(ctx) to be true when BatchMode=yes") + } + + if tok, err := adp.UpdateAuth(ctx, "node-batch", "tok-1", "new-pass", "", ""); err != nil || tok != "tok-1" { + t.Fatalf("expected UpdateAuth no-op under BatchMode=yes, got tok=%q err=%v", tok, err) + } + if tok, err := adp.UpdateSudo(ctx, "node-batch", "sudo-1", ssh.SudoModeSu, "new-su"); err != nil || tok != "sudo-1" { + t.Fatalf("expected UpdateSudo no-op under BatchMode=yes, got tok=%q err=%v", tok, err) + } + if confirmCalled { + t.Fatal("expected rememberConfirmation not to be called when BatchMode=yes") + } + + // Verify ConfirmAuthenticatedConnection skips enrollment probe when BatchMode=yes + repo, err := config.NewRepositoryWithoutOpenSSH(cfg, adapterTestStore{}) + if err != nil { + t.Fatalf("NewRepositoryWithoutOpenSSH failed: %v", err) + } + prepared, err := repo.PrepareNodeContext(ctx, config.EnsureNodeOptions{ + Target: config.ConnectionTarget{Selector: "10.88.77.66", User: "root", HasUser: true, Port: 22, HasPort: true}, + }) + if err != nil { + t.Fatalf("PrepareNodeContext failed: %v", err) + } + probeAdp := NewSSHAdapter( + repo, + WithEnrollmentProbing(nil), + WithSessionAuthOverride(prepared.NodeID, SessionAuth{Password: "pw", Remember: true}), + WithSessionControlOverride(prepared.NodeID, SessionControlOptions{ + Options: ssh.MustOptions(ssh.OptionBatchMode, "yes"), + }), + ) + // Passing a nil *ssh.Client would panic if ConfirmAuthenticatedConnection attempted client.ProbeExecutionConfig(ctx) + execCfg, err := probeAdp.ConfirmAuthenticatedConnection(ctx, prepared.NodeID, nil) + if err != nil { + t.Fatalf("ConfirmAuthenticatedConnection failed under BatchMode=yes: %v", err) + } + if execCfg != nil { + t.Fatalf("expected nil execution config when enrollment probe is skipped under BatchMode=yes, got %+v", execCfg) + } +} + +func TestParseOptionStringsRejectsEmptyValuesAndSessionControlSync(t *testing.T) { + for _, invalid := range []string{ + "=val", + "ControlPath=", + "control_path= ", + "User=", + "IdentityFile=", + "ProxyJump=", + "Port=", + "ConnectTimeout=", + } { + if _, err := ParseOptionStrings([]string{invalid}); err == nil { + t.Fatalf("expected ParseOptionStrings(%q) to fail on empty key/value, got nil", invalid) + } + } + + var ctrl SessionControlOptions + ctrl.SetMaster(ssh.ControlMasterAuto) + ctrl.SetPath("/tmp/cm-%h.sock") + ctrl.SetPersist("10m") + + // Clearing via empty string must clear both the pointer field and the underlying Options map. + ctrl.SetMaster("") + ctrl.SetPath("") + ctrl.SetPersist("") + if !ctrl.IsEmpty() || ctrl.ToSSHOptions() != nil { + t.Fatalf("expected SessionControlOptions to be empty after clearing Master/Path/Persist, got %+v (ToSSHOptions=%+v)", ctrl, ctrl.ToSSHOptions()) + } +} diff --git a/pkg/adapter/verification.go b/pkg/adapter/verification.go index c5ffeddc..ae55747b 100644 --- a/pkg/adapter/verification.go +++ b/pkg/adapter/verification.go @@ -62,7 +62,7 @@ func verifyConnection(ctx context.Context, preview *config.Provider, nodeID stri connectionProvider = probeConnectionProvider{ConfigProvider: preview.Frozen()} } connector := NewConnectorWithAdapterOptions(connectionProvider, []Option{ - WithNonInteractive(true), WithCredentialRecording(false), WithCredentialSource(resolver), WithControlMultiplexing(false), + WithNonInteractive(true), WithCredentialRecording(false), WithCredentialSource(resolver), WithControlMultiplexing(false), WithHostTrustOverrides(false), }, ssh.WithHandshakeTimeout(VerificationTimeout)) connector.AcceptNewHostKey.Store(true) defer func() { diff --git a/pkg/adapter/verification_test.go b/pkg/adapter/verification_test.go index 64f580c0..0b99aae4 100644 --- a/pkg/adapter/verification_test.go +++ b/pkg/adapter/verification_test.go @@ -85,11 +85,11 @@ func TestVerifyConnection_IgnoresActiveControlSocket(t *testing.T) { preview, err := repo.PreviewConnection( "draft", models.Node{ - Control: &ssh.ControlConfig{ + SSHOptions: ssh.OptionsFromControl(&ssh.ControlConfig{ Master: ssh.ControlMasterAuto, Path: sockPath, Persist: "10m", - }, + }), }, models.Host{Address: host, Port: uint16(port)}, models.Identity{User: "root", AuthType: "password", Password: "wrong-password"}, diff --git a/pkg/config/openssh.go b/pkg/config/openssh.go index 8cb1b115..ebbc868a 100644 --- a/pkg/config/openssh.go +++ b/pkg/config/openssh.go @@ -218,7 +218,7 @@ func (p *OpenSSHParser) GetVirtualNode(alias string) (models.Node, models.Host, } proxyJump = parseOpenSSHProxyJump(proxyJump) - ctrl, err := parseOpenSSHControl(p.cfg, lookupAlias) + sshOpts, err := parseOpenSSHOptions(p.cfg, lookupAlias) if err != nil { return models.Node{}, models.Host{}, models.Identity{}, err } @@ -233,7 +233,7 @@ func (p *OpenSSHParser) GetVirtualNode(alias string) (models.Node, models.Host, SudoMode: models.SudoModeAuto, // 对于导入节点,依然支持 auto Tags: []string{"openssh"}, // 打一个虚拟 tag Alias: []string{alias}, - Control: ctrl, + SSHOptions: sshOpts, } host := models.Host{ @@ -250,8 +250,8 @@ func (p *OpenSSHParser) GetVirtualNode(alias string) (models.Node, models.Host, return node, host, identity, nil } -// GetControlConfig parses multiplexing options for a given alias/host in ssh_config. -func (p *OpenSSHParser) GetControlConfig(alias string) (*ssh.ControlConfig, error) { +// GetSSHOptions parses supported OpenSSH client options for a given alias/host in ssh_config. +func (p *OpenSSHParser) GetSSHOptions(alias string) (*ssh.Options, error) { if p == nil || p.cfg == nil || alias == "" { return nil, nil } @@ -260,57 +260,95 @@ func (p *OpenSSHParser) GetControlConfig(alias string) (*ssh.ControlConfig, erro if err != nil { lookupAlias = cleanAlias } - return parseOpenSSHControl(p.cfg, lookupAlias) + return parseOpenSSHOptions(p.cfg, lookupAlias) } -func parseOpenSSHControl(cfg *ssh_config.Config, host string) (*ssh.ControlConfig, error) { +// GetControlConfig parses multiplexing options for a given alias/host in ssh_config. +func (p *OpenSSHParser) GetControlConfig(alias string) (*ssh.ControlConfig, error) { + opts, err := p.GetSSHOptions(alias) + if err != nil || opts == nil { + return nil, err + } + return opts.ControlConfig(), nil +} + +func parseOpenSSHOptions(cfg *ssh_config.Config, host string) (*ssh.Options, error) { if cfg == nil || host == "" { return nil, nil } - masterVal, err := cfg.Get(host, "ControlMaster") - if err != nil { - return nil, fmt.Errorf("read openssh ControlMaster for host %q: %w", host, err) - } - pathVal, err := cfg.Get(host, "ControlPath") - if err != nil { - return nil, fmt.Errorf("read openssh ControlPath for host %q: %w", host, err) - } - persistVal, err := cfg.Get(host, "ControlPersist") - if err != nil { - return nil, fmt.Errorf("read openssh ControlPersist for host %q: %w", host, err) + opts := &ssh.Options{} + for _, spec := range ssh.RegisteredOptionSpecs() { + // Trust-class options (StrictHostKeyChecking, UserKnownHostsFile) are never + // inherited from ~/.ssh/config so wildcard host blocks cannot implicitly + // weaken host key verification. + if spec.Class == ssh.OptionClassTrust { + continue + } + val, err := firstMatchingOpenSSHOption(cfg, host, spec) + if err != nil { + return nil, err + } + if val == "" { + continue + } + if spec.AllowList { + filtered := ssh.FilterSupportedListValue(spec.CanonicalKey, val) + if filtered == "" { + continue + } + val = filtered + } + if err := opts.Set(spec.CanonicalKey, val); err != nil { + // Preserve strict error reporting on ControlMaster and ControlPersist, + // while leniently skipping unsupported ~/.ssh/config values (e.g. + // ControlPath tokens like %k or PubkeyAuthentication=unbound). + if spec.CanonicalKey == ssh.OptionControlMaster || spec.CanonicalKey == ssh.OptionControlPersist { + return nil, fmt.Errorf("invalid %s %q for %q in ssh config: %w", spec.OpenSSHName, val, host, err) + } + continue + } } - - masterVal = strings.TrimSpace(masterVal) - pathVal = strings.TrimSpace(pathVal) - persistVal = strings.TrimSpace(persistVal) - - if masterVal == "" && pathVal == "" && persistVal == "" { + if opts.IsEmpty() { return nil, nil } + return opts, nil +} - // An absent ControlMaster stays empty so lower-priority layers keep the - // ability to inherit a value orthogonally. Only an explicit directive is - // normalized into a mode. - var masterMode ssh.ControlMasterMode - if masterVal != "" { - mode, err := ssh.ParseControlMasterMode(masterVal) +// firstMatchingOpenSSHOption resolves an option across ssh_config Host blocks in +// OpenSSH first-match-wins order across both the primary OpenSSHName and its aliases. +func firstMatchingOpenSSHOption(cfg *ssh_config.Config, host string, spec ssh.OptionSpec) (string, error) { + if len(spec.Aliases) == 0 { + val, err := cfg.Get(host, spec.OpenSSHName) if err != nil { - return nil, fmt.Errorf("invalid ControlMaster %q for %q in ssh config: %w", masterVal, host, err) + return "", fmt.Errorf("read openssh %s for host %q: %w", spec.OpenSSHName, host, err) } - masterMode = mode + return strings.TrimSpace(val), nil } - - if persistVal != "" { - if _, _, _, err := ssh.ParseControlPersist(persistVal); err != nil { - return nil, fmt.Errorf("invalid ControlPersist %q for %q in ssh config: %w", persistVal, host, err) + keys := make([]string, 0, 1+len(spec.Aliases)) + keys = append(keys, strings.ToLower(spec.OpenSSHName)) + for _, alias := range spec.Aliases { + keys = append(keys, strings.ToLower(alias)) + } + for _, h := range cfg.Hosts { + if !h.Matches(host) { + continue + } + for _, node := range h.Nodes { + kv, ok := node.(*ssh_config.KV) + if !ok || kv == nil { + continue + } + lkey := strings.ToLower(strings.TrimSpace(kv.Key)) + for _, candidate := range keys { + if lkey == candidate { + if v := strings.TrimSpace(kv.Value); v != "" { + return v, nil + } + } + } } } - - return &ssh.ControlConfig{ - Master: masterMode, - Path: pathVal, - Persist: persistVal, - }, nil + return "", nil } func parseOpenSSHHostSpec(spec string) (host, userName string, port uint16, err error) { diff --git a/pkg/config/openssh_import.go b/pkg/config/openssh_import.go index dab4cb03..4974fab4 100644 --- a/pkg/config/openssh_import.go +++ b/pkg/config/openssh_import.go @@ -93,11 +93,11 @@ func openSSHHostFromConfig(cfg *ssh_config.Config, name, defaultUser string) (Op return OpenSSHHost{}, err } - ctrl, err := parseOpenSSHControl(cfg, name) + sshOpts, err := parseOpenSSHOptions(cfg, name) if err != nil { return OpenSSHHost{}, err } - ctrl = normalizeImportedControl(ctrl) + sshOpts = normalizeImportedSSHOptions(sshOpts) return OpenSSHHost{ Name: name, @@ -111,45 +111,56 @@ func openSSHHostFromConfig(cfg *ssh_config.Config, name, defaultUser string) (Op KeyPath: expandedKeyPath, }, Node: models.Node{ - Alias: []string{name}, - Tags: []string{"openssh"}, - ProxyJump: proxyJump, - SudoMode: models.SudoModeAuto, - Control: ctrl, + Alias: []string{name}, + Tags: []string{"openssh"}, + ProxyJump: proxyJump, + SudoMode: models.SudoModeAuto, + SSHOptions: sshOpts, }, }, nil } -// normalizeImportedControl makes an imported ControlConfig valid under the +// normalizeImportedSSHOptions makes imported ssh.Options valid under the // persisted schema rules without weakening them. OpenSSH inherits ControlPersist // independently of ControlMaster, so a host that resolved to ControlMaster=no // can carry a ControlPersist it never asked for; that combination is contradictory // once materialized, and the master mode wins. -func normalizeImportedControl(ctrl *ssh.ControlConfig) *ssh.ControlConfig { - if ctrl == nil { +func normalizeImportedSSHOptions(opts *ssh.Options) *ssh.Options { + if opts == nil || opts.IsEmpty() { return nil } - if ctrl.Path == "" { - // An OpenSSH host with ControlMaster or ControlPersist but no ControlPath - // cannot form a standalone node ControlConfig without failing schema - // validation when no global ControlPath is set. + // Only import mux and transport options into persisted node configurations; + // never persist trust or auth-policy options from ~/.ssh/config into nodes. + cloned := opts.FilterByClasses(ssh.OptionClassMux, ssh.OptionClassTransport) + if cloned == nil || cloned.IsEmpty() { return nil } - if ctrl.Master == ssh.ControlMasterNo && ctrl.Persist != "" { - if _, _, _, err := ssh.ParseControlPersist(ctrl.Persist); err != nil { - ctrl.Persist = "" - return ctrl + pathVal := cloned.ControlPath() + masterMode := cloned.ControlMaster() + persistVal := cloned.ControlPersist() + + if pathVal == "" { + // An OpenSSH host with ControlMaster or ControlPersist but no ControlPath + // cannot form a standalone node multiplexing config without failing schema + // validation when no global ControlPath is set. + cloned.Delete(ssh.OptionControlMaster) + cloned.Delete(ssh.OptionControlPersist) + } else { + if masterMode == ssh.ControlMasterNo && persistVal != "" { + enabled, _, _, err := ssh.ParseControlPersist(persistVal) + if err != nil || enabled { + cloned.Delete(ssh.OptionControlPersist) + } } - enabled, _, _, _ := ssh.ParseControlPersist(ctrl.Persist) - if enabled { - ctrl.Persist = "" + if pathVal == "none" { + _ = cloned.Set(ssh.OptionControlMaster, string(ssh.ControlMasterNo)) + cloned.Delete(ssh.OptionControlPersist) } } - if ctrl.Path == "none" { - ctrl.Master = ssh.ControlMasterNo - ctrl.Persist = "" + if cloned.IsEmpty() { + return nil } - return ctrl + return cloned } func openSSHValue(cfg *ssh_config.Config, host, key, fallback string) (string, error) { diff --git a/pkg/config/openssh_import_test.go b/pkg/config/openssh_import_test.go index b1f3b546..d8d96738 100644 --- a/pkg/config/openssh_import_test.go +++ b/pkg/config/openssh_import_test.go @@ -104,13 +104,13 @@ Host * if app.Name != "app" { t.Fatalf("expected first host to be 'app', got %s", app.Name) } - if app.Node.Control == nil { - t.Fatal("expected app.Node.Control to be non-nil") + if app.Node.SSHOptions == nil { + t.Fatal("expected app.Node.SSHOptions to be non-nil") } - if app.Node.Control.Master != "auto" || - app.Node.Control.Path != "~/.ssh/sockets/%r@%h-%p" || - app.Node.Control.Persist != "10m" { - t.Fatalf("app control mismatch: %+v", app.Node.Control) + if app.Node.SSHOptions.ControlMaster() != "auto" || + app.Node.SSHOptions.ControlPath() != "~/.ssh/sockets/%r@%h-%p" || + app.Node.SSHOptions.ControlPersist() != "10m" { + t.Fatalf("app control mismatch: %+v", app.Node.SSHOptions) } // 2. custom has explicit control options @@ -118,13 +118,13 @@ Host * if custom.Name != "custom" { t.Fatalf("expected second host to be 'custom', got %s", custom.Name) } - if custom.Node.Control == nil { - t.Fatal("expected custom.Node.Control to be non-nil") + if custom.Node.SSHOptions == nil { + t.Fatal("expected custom.Node.SSHOptions to be non-nil") } - if custom.Node.Control.Master != "yes" || - custom.Node.Control.Path != "/tmp/custom.sock" || - custom.Node.Control.Persist != "0" { - t.Fatalf("custom control mismatch: %+v", custom.Node.Control) + if custom.Node.SSHOptions.ControlMaster() != "yes" || + custom.Node.SSHOptions.ControlPath() != "/tmp/custom.sock" || + custom.Node.SSHOptions.ControlPersist() != "0" { + t.Fatalf("custom control mismatch: %+v", custom.Node.SSHOptions) } // 3. disabled overrides path to none @@ -132,13 +132,13 @@ Host * if disabled.Name != "disabled" { t.Fatalf("expected third host to be 'disabled', got %s", disabled.Name) } - if disabled.Node.Control == nil { - t.Fatal("expected disabled.Node.Control to be non-nil") + if disabled.Node.SSHOptions == nil { + t.Fatal("expected disabled.Node.SSHOptions to be non-nil") } - if disabled.Node.Control.Path != "none" { - t.Fatalf("disabled control mismatch: %+v", disabled.Node.Control) + if disabled.Node.SSHOptions.ControlPath() != "none" { + t.Fatalf("disabled control mismatch: %+v", disabled.Node.SSHOptions) } - if disabled.Node.Control.IsEnabled() { + if disabled.Node.SSHOptions.ControlConfig().IsEnabled() { t.Fatal("disabled control should have IsEnabled() == false") } } @@ -158,3 +158,42 @@ func TestParseOpenSSHHosts_InvalidControl(t *testing.T) { t.Fatalf("expected invalid ControlPersist error, got: %v", err) } } + +func TestParseOpenSSHHosts_FiltersOutAuthAndTrustOptions(t *testing.T) { + t.Parallel() + + const input = ` +Host imported-node + HostName 10.0.0.50 + User deploy + ConnectTimeout 15 + ControlMaster auto + ControlPath ~/.ssh/cm-%C + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + PasswordAuthentication no + BatchMode yes +` + hosts, err := ParseOpenSSHHosts(strings.NewReader(input), "local-user") + if err != nil { + t.Fatalf("ParseOpenSSHHosts() error = %v", err) + } + if len(hosts) != 1 { + t.Fatalf("expected 1 host, got %d", len(hosts)) + } + opts := hosts[0].Node.SSHOptions + if opts == nil { + t.Fatal("expected imported SSHOptions to be non-nil") + } + // Mux and Transport options are preserved + if opts.ControlMaster() != "auto" || opts.Get("connect_timeout") != "15" { + t.Fatalf("expected mux and transport options to be imported, got %+v", opts) + } + // Trust and Auth options must not be persisted into imported nodes + if opts.StrictHostKeyChecking() != "" || opts.UserKnownHostsFile() != "" { + t.Fatalf("expected trust options to be stripped on import, got %+v", opts) + } + if opts.Get("password_authentication") != "" || opts.Get("batch_mode") != "" { + t.Fatalf("expected auth options to be stripped on import, got %+v", opts) + } +} diff --git a/pkg/config/openssh_test.go b/pkg/config/openssh_test.go index 149cee11..df2e6e57 100644 --- a/pkg/config/openssh_test.go +++ b/pkg/config/openssh_test.go @@ -386,13 +386,13 @@ Host srv-ask if err != nil { t.Fatalf("GetVirtualNode(srv-auto) failed: %v", err) } - if node1.Control == nil || node1.Control.Master != ssh.ControlMasterAuto || - node1.Control.Path != "~/.ssh/sockets/%r@%h-%p" || - node1.Control.Persist != "10m" { - t.Fatalf("srv-auto control mismatch: %+v", node1.Control) + if node1.SSHOptions == nil || node1.SSHOptions.ControlMaster() != ssh.ControlMasterAuto || + node1.SSHOptions.ControlPath() != "~/.ssh/sockets/%r@%h-%p" || + node1.SSHOptions.ControlPersist() != "10m" { + t.Fatalf("srv-auto control mismatch: %+v", node1.SSHOptions) } ctrl1, err := parser.GetControlConfig("srv-auto") - if err != nil || !ctrl1.Equal(node1.Control) { + if err != nil || !ctrl1.Equal(node1.SSHOptions.ControlConfig()) { t.Fatalf("GetControlConfig(srv-auto) mismatch: %v, err=%v", ctrl1, err) } @@ -400,19 +400,19 @@ Host srv-ask if err != nil { t.Fatalf("GetVirtualNode(srv-yes) failed: %v", err) } - if node2.Control == nil || node2.Control.Master != ssh.ControlMasterYes || - node2.Control.Path != "/tmp/mux-%C.sock" || - node2.Control.Persist != "0" { - t.Fatalf("srv-yes control mismatch: %+v", node2.Control) + if node2.SSHOptions == nil || node2.SSHOptions.ControlMaster() != ssh.ControlMasterYes || + node2.SSHOptions.ControlPath() != "/tmp/mux-%C.sock" || + node2.SSHOptions.ControlPersist() != "0" { + t.Fatalf("srv-yes control mismatch: %+v", node2.SSHOptions) } node3, _, _, err := parser.GetVirtualNode("srv-ask") if err != nil { t.Fatalf("GetVirtualNode(srv-ask) failed: %v", err) } - if node3.Control == nil || node3.Control.Master != ssh.ControlMasterAsk || - node3.Control.Persist != "1h" { - t.Fatalf("srv-ask control mismatch: %+v", node3.Control) + if node3.SSHOptions == nil || node3.SSHOptions.ControlMaster() != ssh.ControlMasterAsk || + node3.SSHOptions.ControlPersist() != "1h" { + t.Fatalf("srv-ask control mismatch: %+v", node3.SSHOptions) } } @@ -437,10 +437,10 @@ Host srv-nocontrol if err != nil { t.Fatalf("GetVirtualNode(srv-none) failed: %v", err) } - if node4.Control == nil || node4.Control.Path != "none" || node4.Control.Master != ssh.ControlMasterNo { - t.Fatalf("srv-none control mismatch: %+v", node4.Control) + if node4.SSHOptions == nil || node4.SSHOptions.ControlPath() != "none" || node4.SSHOptions.ControlMaster() != ssh.ControlMasterNo { + t.Fatalf("srv-none control mismatch: %+v", node4.SSHOptions) } - if node4.Control.IsEnabled() { + if node4.SSHOptions.ControlConfig().IsEnabled() { t.Fatalf("srv-none should have IsEnabled() == false") } @@ -448,8 +448,8 @@ Host srv-nocontrol if err != nil { t.Fatalf("GetVirtualNode(srv-nocontrol) failed: %v", err) } - if node5.Control != nil { - t.Fatalf("expected srv-nocontrol Control to be nil, got: %+v", node5.Control) + if node5.SSHOptions != nil { + t.Fatalf("expected srv-nocontrol SSHOptions to be nil, got: %+v", node5.SSHOptions) } } @@ -567,17 +567,17 @@ Host * if found == nil { t.Fatal("no-master-host was not imported") } - if found.Node.Control == nil { - t.Fatal("expected imported control config") + if found.Node.SSHOptions == nil { + t.Fatal("expected imported ssh_options config") } - if found.Node.Control.Master != ssh.ControlMasterNo { - t.Fatalf("expected ControlMaster=no, got %q", found.Node.Control.Master) + if found.Node.SSHOptions.ControlMaster() != ssh.ControlMasterNo { + t.Fatalf("expected ControlMaster=no, got %q", found.Node.SSHOptions.ControlMaster()) } - if found.Node.Control.Persist != "" { - t.Fatalf("expected inherited ControlPersist to be dropped for ControlMaster=no, got %q", found.Node.Control.Persist) + if found.Node.SSHOptions.ControlPersist() != "" { + t.Fatalf("expected inherited ControlPersist to be dropped for ControlMaster=no, got %q", found.Node.SSHOptions.ControlPersist()) } - if err := ValidateControlConfig(found.Node.Control); err != nil { - t.Fatalf("imported control config is invalid: %v", err) + if err := ValidateSSHOptions(found.Node.SSHOptions); err != nil { + t.Fatalf("imported ssh_options config is invalid: %v", err) } } @@ -609,3 +609,55 @@ Host path-only t.Fatalf("unexpected control config %+v", ctrl) } } + +func TestOpenSSHParser_LenientOptionsAndNeverInheritsTrust(t *testing.T) { + configContent := ` +Host * + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + KexAlgorithms sntrup761x25519-sha512@openssh.com,curve25519-sha256 + MACs umac-128-etm@openssh.com,hmac-sha2-256 + PreferredAuthentications gssapi-with-mic,publickey,password + PubkeyAuthentication unbound + ChallengeResponseAuthentication yes + +Host modern-host + HostName 10.0.0.99 + User ubuntu + KbdInteractiveAuthentication no + ControlPath ~/.ssh/cm-%k +` + parser, err := NewOpenSSHParserFromReader(strings.NewReader(configContent)) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + node, _, _, err := parser.GetVirtualNode("modern-host") + if err != nil { + t.Fatalf("GetVirtualNode(modern-host) failed on modern OpenSSH options: %v", err) + } + if node.SSHOptions == nil { + t.Fatal("expected non-nil SSHOptions") + } + // H1: Trust options in ~/.ssh/config must never be inherited + if node.SSHOptions.StrictHostKeyChecking() != "" || node.SSHOptions.UserKnownHostsFile() != "" { + t.Fatalf("expected trust options from ~/.ssh/config to be ignored, got %+v", node.SSHOptions) + } + // H3: Unsupported algorithm/auth items filtered out, supported items kept + if got := node.SSHOptions.Get(ssh.OptionKexAlgorithms); got != "curve25519-sha256" { + t.Fatalf("expected KexAlgorithms=curve25519-sha256, got %q", got) + } + if got := node.SSHOptions.Get(ssh.OptionMACs); got != "hmac-sha2-256" { + t.Fatalf("expected MACs=hmac-sha2-256, got %q", got) + } + if got := node.SSHOptions.Get(ssh.OptionPreferredAuthentications); got != "publickey,password" { + t.Fatalf("expected PreferredAuthentications=publickey,password, got %q", got) + } + // Unsupported ControlPath token %k and PubkeyAuthentication=unbound are skipped leniently + if node.SSHOptions.ControlPath() != "" { + t.Fatalf("expected unsupported ControlPath %%k to be skipped, got %q", node.SSHOptions.ControlPath()) + } + // L4: ChallengeResponseAuthentication in Host * appeared before KbdInteractiveAuthentication in Host modern-host + if kbdEnabled, kbdSet := node.SSHOptions.KbdInteractiveAuthentication(); !kbdSet || !kbdEnabled { + t.Fatalf("expected first-match-wins ChallengeResponseAuthentication=yes in Host * to win over later KbdInteractiveAuthentication=no") + } +} diff --git a/pkg/config/provider.go b/pkg/config/provider.go index 3a52a596..b36bec71 100644 --- a/pkg/config/provider.go +++ b/pkg/config/provider.go @@ -75,6 +75,14 @@ func (p *Provider) Frozen() ConfigProvider { return frozen } +// GetOpenSSHOptions returns supported OpenSSH options from OpenSSH config for an alias. +func (p *Provider) GetOpenSSHOptions(alias string) (*ssh.Options, error) { + if p == nil || p.openSSH == nil { + return nil, nil + } + return p.openSSH.GetSSHOptions(alias) +} + // GetOpenSSHControl returns multiplexing configuration from OpenSSH config for an alias. func (p *Provider) GetOpenSSHControl(alias string) (*ssh.ControlConfig, error) { if p == nil || p.openSSH == nil { @@ -452,7 +460,7 @@ func cloneConfiguration(cfg *Configuration) *Configuration { cloned.Guardrail = cloneGuardrail(cfg.Guardrail) cloned.MCP = cfg.MCP.Clone() cloned.Execution = cfg.Execution.Clone() - cloned.Control = cfg.Control.Clone() + cloned.SSHOptions = cfg.SSHOptions.Clone() if cfg.Nodes != nil { for _, key := range cfg.Nodes.Keys() { if node, ok := cfg.Nodes.Get(key); ok { @@ -486,8 +494,8 @@ func cloneNode(node models.Node) models.Node { if node.Execution != nil { node.Execution = node.Execution.Clone() } - if node.Control != nil { - node.Control = node.Control.Clone() + if node.SSHOptions != nil { + node.SSHOptions = node.SSHOptions.Clone() } return node } diff --git a/pkg/config/repository.go b/pkg/config/repository.go index 663da3cc..3a426101 100644 --- a/pkg/config/repository.go +++ b/pkg/config/repository.go @@ -1209,6 +1209,13 @@ func (r *Repository) Snapshot() *Configuration { return r.provider.Snapshot() } +func (r *Repository) GetOpenSSHOptions(alias string) (*ssh.Options, error) { + if r == nil || r.provider == nil { + return nil, nil + } + return r.provider.GetOpenSSHOptions(alias) +} + func (r *Repository) GetOpenSSHControl(alias string) (*ssh.ControlConfig, error) { if r == nil || r.provider == nil { return nil, nil diff --git a/pkg/config/schema_v2.go b/pkg/config/schema_v2.go index cb86477d..b6f692fa 100644 --- a/pkg/config/schema_v2.go +++ b/pkg/config/schema_v2.go @@ -172,7 +172,7 @@ type NodeV2 struct { PrivilegePasswordRef *credential.Ref `yaml:"privilege_password_ref,omitempty"` PasswordPromptPattern string `yaml:"password_prompt_pattern,omitempty"` Execution *ssh.ExecutionConfig `yaml:"execution,omitempty"` - Control *ssh.ControlConfig `yaml:"control,omitempty"` + SSHOptions *ssh.Options `yaml:"ssh_options,omitempty"` } // ConfigurationV2 是 Schema v2 的顶级规范配置 DTO。 @@ -187,7 +187,7 @@ type ConfigurationV2 struct { MCP *MCPConfig `yaml:"mcp,omitempty"` PasswordPromptPattern string `yaml:"password_prompt_pattern,omitempty"` Execution *ssh.ExecutionConfig `yaml:"execution,omitempty"` - Control *ssh.ControlConfig `yaml:"control,omitempty"` + SSHOptions *ssh.Options `yaml:"ssh_options,omitempty"` } // ValidateV2 对 ConfigurationV2 进行全面且严格的合法性校验。 @@ -205,9 +205,9 @@ func ValidateV2(cfg *ConfigurationV2) error { } } - if cfg.Control != nil { - if err := ValidateControlConfig(cfg.Control); err != nil { - return fmt.Errorf("%w: global control invalid: %w", ErrSchemaValidation, err) + if cfg.SSHOptions != nil { + if err := ValidateSSHOptions(cfg.SSHOptions); err != nil { + return fmt.Errorf("%w: global ssh_options invalid: %w", ErrSchemaValidation, err) } } @@ -497,16 +497,16 @@ func validateNodeRefs(nodeName string, node NodeV2, cfg *ConfigurationV2) error return fmt.Errorf("%w: node %q effective execution invalid: %w", ErrSchemaValidation, nodeName, err) } - if node.Control != nil { - if err := ValidateControlConfig(node.Control); err != nil { - return fmt.Errorf("%w: node %q control invalid: %w", ErrSchemaValidation, nodeName, err) + if node.SSHOptions != nil { + if err := ValidateSSHOptions(node.SSHOptions); err != nil { + return fmt.Errorf("%w: node %q ssh_options invalid: %w", ErrSchemaValidation, nodeName, err) } } - if effectiveControl, err := ssh.ResolveControl(node.Control, cfg.Control); err != nil { - return fmt.Errorf("%w: node %q effective control invalid: %w", ErrSchemaValidation, nodeName, err) - } else if effectiveControl != nil { - if err := ValidateEffectiveControl(effectiveControl); err != nil { - return fmt.Errorf("%w: node %q effective control invalid: %w", ErrSchemaValidation, nodeName, err) + if effectiveOpts, err := ssh.ResolveOptions(node.SSHOptions, cfg.SSHOptions); err != nil { + return fmt.Errorf("%w: node %q effective ssh_options invalid: %w", ErrSchemaValidation, nodeName, err) + } else if effectiveOpts != nil { + if err := ValidateEffectiveSSHOptions(effectiveOpts); err != nil { + return fmt.Errorf("%w: node %q effective ssh_options invalid: %w", ErrSchemaValidation, nodeName, err) } } return nil @@ -652,7 +652,7 @@ func (c *Configuration) ToV2() (*ConfigurationV2, error) { PasswordPromptPattern: c.PasswordPromptPattern, MCP: c.MCP.Clone(), Execution: c.Execution.Clone(), - Control: c.Control.Clone(), + SSHOptions: c.SSHOptions.Clone(), } if c.Credential != nil { @@ -713,7 +713,7 @@ func (c *Configuration) ToV2() (*ConfigurationV2, error) { PrivilegePasswordRef: n.PrivilegePasswordRef.Clone(), PasswordPromptPattern: n.PasswordPromptPattern, Execution: n.Execution.Clone(), - Control: n.Control.Clone(), + SSHOptions: n.SSHOptions.Clone(), } } } @@ -742,7 +742,7 @@ func FromV2(v2 *ConfigurationV2) (*Configuration, error) { MCP: v2.MCP.Clone(), PasswordPromptPattern: v2.PasswordPromptPattern, Execution: v2.Execution.Clone(), - Control: v2.Control.Clone(), + SSHOptions: v2.SSHOptions.Clone(), } for k, h := range v2.Hosts { @@ -773,7 +773,7 @@ func FromV2(v2 *ConfigurationV2) (*Configuration, error) { PrivilegePasswordRef: n.PrivilegePasswordRef.Clone(), PasswordPromptPattern: n.PasswordPromptPattern, Execution: n.Execution.Clone(), - Control: n.Control.Clone(), + SSHOptions: n.SSHOptions.Clone(), }) } diff --git a/pkg/config/schema_v2_test.go b/pkg/config/schema_v2_test.go index d13c5b89..4d097973 100644 --- a/pkg/config/schema_v2_test.go +++ b/pkg/config/schema_v2_test.go @@ -1311,7 +1311,7 @@ func TestToV2AndFromV2_OTPAndKeyboardInteractive(t *testing.T) { } } -func TestSchemaV2_ControlConfig_Unmarshal(t *testing.T) { +func TestSchemaV2_SSHOptions_Unmarshal(t *testing.T) { yamlData := `schema_version: 2 credential: default_store: pass @@ -1321,18 +1321,20 @@ hosts: h1: {address: 192.168.1.10, port: 22} identities: dev: {user: dev, auth_type: auto} -control: - master: auto - path: ~/.ssh/cm-%r@%h:%p - persist: 10m +ssh_options: + control_master: auto + control_path: ~/.ssh/cm-%r@%h:%p + control_persist: 10m + strict_host_key_checking: accept-new nodes: n1: host_ref: h1 identity_ref: dev - control: - master: yes - path: /tmp/custom-%h.sock - persist: 5m + ssh_options: + ControlMaster: yes + ControlPath: /tmp/custom-%h.sock + ControlPersist: 5m + ConnectTimeout: 15s n2: host_ref: h1 identity_ref: dev @@ -1343,35 +1345,36 @@ nodes: t.Fatalf("UnmarshalV2 failed: %v", err) } - // Verify global control - if cfg.Control == nil { - t.Fatal("expected global control to be non-nil") + // Verify global ssh_options + if cfg.SSHOptions == nil { + t.Fatal("expected global ssh_options to be non-nil") } - if cfg.Control.Master != ssh.ControlMasterAuto || - cfg.Control.Path != "~/.ssh/cm-%r@%h:%p" || - cfg.Control.Persist != "10m" { - t.Fatalf("global control mismatch: %+v", cfg.Control) + if cfg.SSHOptions.ControlMaster() != ssh.ControlMasterAuto || + cfg.SSHOptions.ControlPath() != "~/.ssh/cm-%r@%h:%p" || + cfg.SSHOptions.ControlPersist() != "10m" || + cfg.SSHOptions.StrictHostKeyChecking() != ssh.StrictHostKeyCheckingAcceptNew { + t.Fatalf("global ssh_options mismatch: %+v", cfg.SSHOptions) } - // Verify node n1 control + // Verify node n1 ssh_options (parsed from PascalCase OpenSSH keys) n1 := cfg.Nodes["n1"] - if n1.Control == nil { - t.Fatal("expected node n1 control to be non-nil") + if n1.SSHOptions == nil { + t.Fatal("expected node n1 ssh_options to be non-nil") } - if n1.Control.Master != ssh.ControlMasterYes || - n1.Control.Path != "/tmp/custom-%h.sock" || - n1.Control.Persist != "5m" { - t.Fatalf("node n1 control mismatch: %+v", n1.Control) + if n1.SSHOptions.ControlMaster() != ssh.ControlMasterYes || + n1.SSHOptions.ControlPath() != "/tmp/custom-%h.sock" || + n1.SSHOptions.ControlPersist() != "5m" { + t.Fatalf("node n1 ssh_options mismatch: %+v", n1.SSHOptions) } - // Verify node n2 control is nil + // Verify node n2 ssh_options is nil n2 := cfg.Nodes["n2"] - if n2.Control != nil { - t.Fatalf("expected node n2 control to be nil, got: %+v", n2.Control) + if n2.SSHOptions != nil { + t.Fatalf("expected node n2 ssh_options to be nil, got: %+v", n2.SSHOptions) } } -func TestSchemaV2_ControlConfig_FromV2AndRoundtrip(t *testing.T) { +func TestSchemaV2_SSHOptions_FromV2AndRoundtrip(t *testing.T) { yamlData := `schema_version: 2 credential: default_store: pass @@ -1381,18 +1384,18 @@ hosts: h1: {address: 192.168.1.10, port: 22} identities: dev: {user: dev, auth_type: auto} -control: - master: auto - path: ~/.ssh/cm-%r@%h:%p - persist: 10m +ssh_options: + control_master: auto + control_path: ~/.ssh/cm-%r@%h:%p + control_persist: 10m nodes: n1: host_ref: h1 identity_ref: dev - control: - master: yes - path: /tmp/custom-%h.sock - persist: 5m + ssh_options: + control_master: yes + control_path: /tmp/custom-%h.sock + control_persist: 5m ` cfg, err := UnmarshalV2([]byte(yamlData)) @@ -1406,19 +1409,19 @@ nodes: if err != nil { t.Fatalf("FromV2 failed: %v", err) } - if memCfg.Control == nil || !memCfg.Control.Equal(cfg.Control) { - t.Fatalf("FromV2 global control mismatch: %+v", memCfg.Control) + if memCfg.SSHOptions == nil || !memCfg.SSHOptions.Equal(cfg.SSHOptions) { + t.Fatalf("FromV2 global ssh_options mismatch: %+v", memCfg.SSHOptions) } memN1, ok := memCfg.Nodes.Get("n1") - if !ok || memN1.Control == nil || !memN1.Control.Equal(n1.Control) { - t.Fatalf("FromV2 node n1 control mismatch") + if !ok || memN1.SSHOptions == nil || !memN1.SSHOptions.Equal(n1.SSHOptions) { + t.Fatalf("FromV2 node n1 ssh_options mismatch") } // Verify snapshot isolation snap := memCfg.Snapshot() - snap.Control.Master = ssh.ControlMasterAsk - if memCfg.Control.Master != ssh.ControlMasterAuto { + snap.SSHOptions.SetControlMaster(ssh.ControlMasterAsk) + if memCfg.SSHOptions.ControlMaster() != ssh.ControlMasterAuto { t.Fatal("snapshot mutation affected original config") } @@ -1427,12 +1430,12 @@ nodes: if err != nil { t.Fatalf("ToV2 failed: %v", err) } - if v2Back.Control == nil || !v2Back.Control.Equal(cfg.Control) { - t.Fatalf("ToV2 global control mismatch") + if v2Back.SSHOptions == nil || !v2Back.SSHOptions.Equal(cfg.SSHOptions) { + t.Fatalf("ToV2 global ssh_options mismatch") } } -func TestSchemaV2_ControlConfig_ValidationErrors(t *testing.T) { +func TestSchemaV2_SSHOptions_ValidationErrors(t *testing.T) { baseYAML := `schema_version: 2 credential: default_store: pass @@ -1446,8 +1449,8 @@ identities: // 1. Invalid global ControlMaster t.Run("invalid global master", func(t *testing.T) { - y := baseYAML + `control: - master: invalid_mode + y := baseYAML + `ssh_options: + control_master: invalid_mode nodes: n1: {host_ref: h1, identity_ref: dev} ` @@ -1463,10 +1466,10 @@ nodes: n1: host_ref: h1 identity_ref: dev - control: - master: auto - path: /tmp/sock - persist: 10xyz + ssh_options: + control_master: auto + control_path: /tmp/sock + control_persist: 10xyz ` _, err := UnmarshalV2([]byte(y)) if err == nil || !errors.Is(err, ErrSchemaValidation) || !strings.Contains(err.Error(), "invalid ControlPersist") { @@ -1480,9 +1483,9 @@ nodes: n1: host_ref: h1 identity_ref: dev - control: - master: "no" - persist: 10m + ssh_options: + control_master: "no" + control_persist: 10m ` _, err := UnmarshalV2([]byte(y)) if err == nil || !errors.Is(err, ErrSchemaValidation) || !strings.Contains(err.Error(), "cannot be specified when ControlMaster is 'no'") { @@ -1490,14 +1493,14 @@ nodes: } }) - // 4. Effective control: Active Master with no Path anywhere + // 4. Effective ssh_options: Active Master with no Path anywhere t.Run("active master without path", func(t *testing.T) { y := baseYAML + `nodes: n1: host_ref: h1 identity_ref: dev - control: - master: auto + ssh_options: + control_master: auto ` _, err := UnmarshalV2([]byte(y)) if err == nil || !errors.Is(err, ErrSchemaValidation) || !strings.Contains(err.Error(), "requires non-empty ControlPath") { @@ -1505,15 +1508,15 @@ nodes: } }) - // 5. Effective control: Node sets Path="none" with Master="yes" + // 5. Effective ssh_options: Node sets Path="none" with Master="yes" t.Run("active master with path none", func(t *testing.T) { y := baseYAML + `nodes: n1: host_ref: h1 identity_ref: dev - control: - master: yes - path: none + ssh_options: + control_master: yes + control_path: none ` _, err := UnmarshalV2([]byte(y)) if err == nil || !errors.Is(err, ErrSchemaValidation) || !strings.Contains(err.Error(), "ControlPath cannot be 'none'") { @@ -1522,7 +1525,7 @@ nodes: }) } -func TestSchemaV2_ControlConfig_NodeDisableMultiplexing(t *testing.T) { +func TestSchemaV2_SSHOptions_NodeDisableMultiplexing(t *testing.T) { yamlData := `schema_version: 2 credential: default_store: pass @@ -1532,16 +1535,16 @@ hosts: h1: {address: 192.168.1.10, port: 22} identities: dev: {user: dev, auth_type: auto} -control: - master: auto - path: ~/.ssh/cm-%r@%h:%p - persist: 10m +ssh_options: + control_master: auto + control_path: ~/.ssh/cm-%r@%h:%p + control_persist: 10m nodes: n1: host_ref: h1 identity_ref: dev - control: - path: none + ssh_options: + control_path: none n2: host_ref: h1 identity_ref: dev @@ -1549,23 +1552,24 @@ nodes: cfg, err := UnmarshalV2([]byte(yamlData)) if err != nil { - t.Fatalf("UnmarshalV2 failed on valid node control path: none: %v", err) + t.Fatalf("UnmarshalV2 failed on valid node control_path: none: %v", err) } - // Verify node n1 has raw control path none + // Verify node n1 has raw control_path none n1 := cfg.Nodes["n1"] - if n1.Control == nil { - t.Fatal("expected node n1 control to be non-nil") + if n1.SSHOptions == nil { + t.Fatal("expected node n1 ssh_options to be non-nil") } - if n1.Control.Path != "none" { - t.Fatalf("expected node n1 control path to be %q, got %q", "none", n1.Control.Path) + if n1.SSHOptions.ControlPath() != "none" { + t.Fatalf("expected node n1 control_path to be %q, got %q", "none", n1.SSHOptions.ControlPath()) } - // Verify effective control for node n1 disables multiplexing - eff, err := ssh.ResolveControl(n1.Control, cfg.Control) + // Verify effective ssh_options for node n1 disables multiplexing + effOpts, err := ssh.ResolveOptions(n1.SSHOptions, cfg.SSHOptions) if err != nil { - t.Fatalf("unexpected ResolveControl error: %v", err) + t.Fatalf("unexpected ResolveOptions error: %v", err) } + eff := effOpts.ControlConfig() if eff.Master != ssh.ControlMasterNo { t.Fatalf("expected effective master %q, got %q", ssh.ControlMasterNo, eff.Master) } @@ -1579,12 +1583,13 @@ nodes: t.Fatal("expected effective control to be disabled for node n1") } - // Verify node n2 inherits global control + // Verify node n2 inherits global ssh_options n2 := cfg.Nodes["n2"] - effN2, err := ssh.ResolveControl(n2.Control, cfg.Control) + effN2Opts, err := ssh.ResolveOptions(n2.SSHOptions, cfg.SSHOptions) if err != nil { - t.Fatalf("unexpected ResolveControl error for n2: %v", err) + t.Fatalf("unexpected ResolveOptions error for n2: %v", err) } + effN2 := effN2Opts.ControlConfig() if effN2.Master != ssh.ControlMasterAuto || effN2.Path != "~/.ssh/cm-%r@%h:%p" || effN2.Persist != "10m" { t.Fatalf("node n2 unexpected effective control: %+v", effN2) } diff --git a/pkg/config/types.go b/pkg/config/types.go index 6d14da2d..73e0f33b 100644 --- a/pkg/config/types.go +++ b/pkg/config/types.go @@ -18,7 +18,7 @@ type Configuration struct { MCP *MCPConfig `yaml:"mcp,omitempty"` PasswordPromptPattern string `yaml:"password_prompt_pattern,omitempty"` // 全局级自定义密码提示正则 Execution *ssh.ExecutionConfig `yaml:"execution,omitempty"` - Control *ssh.ControlConfig `yaml:"control,omitempty"` + SSHOptions *ssh.Options `yaml:"ssh_options,omitempty"` } // CanRememberCredentials checks configured write capability without accessing a @@ -51,6 +51,7 @@ type ConfigProvider interface { FindAlias(alias string) string ResolveProxyJumpChain(jumpChain string) (string, error) Snapshot() *Configuration + GetOpenSSHOptions(alias string) (*ssh.Options, error) GetOpenSSHControl(alias string) (*ssh.ControlConfig, error) } diff --git a/pkg/config/validate.go b/pkg/config/validate.go index 05f7ec5d..b88e01ab 100644 --- a/pkg/config/validate.go +++ b/pkg/config/validate.go @@ -10,6 +10,28 @@ import ( // DefaultControlPath specifies the standard OpenSSH multiplexing socket pattern. const DefaultControlPath = "~/.ssh/cm-%r@%h:%p" +// ValidateSSHOptions validates standalone ssh.Options field syntax and cross-field consistency. +func ValidateSSHOptions(opts *ssh.Options) error { + if opts == nil { + return nil + } + if err := opts.Validate(); err != nil { + return fmt.Errorf("%w: %w", ErrSchemaValidation, err) + } + return nil +} + +// ValidateEffectiveSSHOptions verifies the resolved effective OpenSSH options for a node. +func ValidateEffectiveSSHOptions(opts *ssh.Options) error { + if opts == nil { + return nil + } + if err := opts.ValidateEffective(); err != nil { + return fmt.Errorf("%w: %w", ErrSchemaValidation, err) + } + return nil +} + // ValidateControlConfig validates standalone ControlConfig field syntax and cross-field consistency. func ValidateControlConfig(ctrl *ssh.ControlConfig) error { if ctrl == nil { @@ -57,49 +79,7 @@ func ValidateControlConfig(ctrl *ssh.ControlConfig) error { // ValidateControlPathSyntax verifies raw ControlPath string token syntax without environment dependencies. func ValidateControlPathSyntax(rawPath string) error { - if rawPath == "" || rawPath == "none" { - return nil - } - - // Check environment variable format ${VAR} - if strings.Contains(rawPath, "${") { - idx := 0 - for { - start := strings.Index(rawPath[idx:], "${") - if start == -1 { - break - } - start += idx - end := strings.Index(rawPath[start:], "}") - if end == -1 { - return fmt.Errorf("unclosed environment variable in ControlPath: %q", rawPath[start:]) - } - end += start - varName := rawPath[start+2 : end] - if varName == "" || !isValidEnvVarIdentifier(varName) { - return fmt.Errorf("invalid environment variable name ${%s} in ControlPath", varName) - } - idx = end + 1 - } - } - - // Check % tokens - for i := 0; i < len(rawPath); i++ { - if rawPath[i] == '%' { - if i+1 >= len(rawPath) { - return fmt.Errorf("trailing '%%' in ControlPath") - } - i++ - switch rawPath[i] { - case '%', 'h', 'p', 'r', 'n', 'u', 'l', 'L', 'i', 'd', 'j', 'C': - // Valid OpenSSH tokens - default: - return fmt.Errorf("unknown ControlPath token %%%c", rawPath[i]) - } - } - } - - return nil + return ssh.ValidateControlPathSyntax(rawPath) } // ValidateEffectiveControl verifies the resolved effective multiplexing configuration for a node. @@ -122,21 +102,3 @@ func isActiveMasterMode(mode string) bool { mode == string(ssh.ControlMasterAuto) || mode == string(ssh.ControlMasterAutoAsk) } - -func isValidEnvVarIdentifier(s string) bool { - if len(s) == 0 { - return false - } - for i, r := range s { - if i == 0 { - if (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') && r != '_' { - return false - } - } else { - if (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '_' { - return false - } - } - } - return true -} diff --git a/pkg/models/types.go b/pkg/models/types.go index 1d7e2dbc..c6d1d0f4 100644 --- a/pkg/models/types.go +++ b/pkg/models/types.go @@ -231,8 +231,8 @@ type Node struct { // 执行环境配置 Execution *ssh.ExecutionConfig `yaml:"execution,omitempty" json:"execution,omitempty"` - // 连接复用配置 - Control *ssh.ControlConfig `yaml:"control,omitempty" json:"control,omitempty"` + // OpenSSH 客户端选项配置(支持连接复用、超时、主机密钥策略、算法与认证控制等) + SSHOptions *ssh.Options `yaml:"ssh_options,omitempty" json:"ssh_options,omitempty"` // OpenSSHAlias 记录运行时从 ~/.ssh/config 具体 Host 块派生该节点时的源别名(不序列化落盘) OpenSSHAlias string `yaml:"-" json:"-"` diff --git a/pkg/models/types_test.go b/pkg/models/types_test.go index 04d8ec5f..15dd21fd 100644 --- a/pkg/models/types_test.go +++ b/pkg/models/types_test.go @@ -189,46 +189,65 @@ func TestIdentity_CloneAndEqual(t *testing.T) { } } -func TestNode_ControlYAMLSerialization(t *testing.T) { - // 1. Without Control, field should be omitted in YAML - nodeWithoutControl := Node{ +func TestNode_SSHOptionsYAMLSerialization(t *testing.T) { + // 1. Without SSHOptions, field should be omitted in YAML + nodeWithoutOptions := Node{ HostRef: "srv1", IdentityRef: "admin", } - out, err := yaml.Marshal(nodeWithoutControl) + out, err := yaml.Marshal(nodeWithoutOptions) if err != nil { t.Fatalf("marshal error: %v", err) } - if strings.Contains(string(out), "control:") { - t.Fatalf("expected control to be omitted, got: %s", string(out)) + if strings.Contains(string(out), "ssh_options:") { + t.Fatalf("expected ssh_options to be omitted, got: %s", string(out)) } - // 2. With Control, field should be marshaled and unmarshaled correctly - nodeWithControl := Node{ + // 2. With SSHOptions, field should be marshaled in snake_case and unmarshaled correctly + nodeWithOptions := Node{ HostRef: "srv1", IdentityRef: "admin", - Control: &ssh.ControlConfig{ - Master: ssh.ControlMasterAuto, - Path: "~/.ssh/cm-%r@%h:%p", - Persist: "10m", - }, - } - data, err := yaml.Marshal(nodeWithControl) + SSHOptions: ssh.MustOptions( + ssh.OptionControlMaster, "auto", + ssh.OptionControlPath, "~/.ssh/cm-%r@%h:%p", + ssh.OptionControlPersist, "10m", + ssh.OptionStrictHostKeyChecking, "accept-new", + ), + } + data, err := yaml.Marshal(nodeWithOptions) if err != nil { t.Fatalf("marshal error: %v", err) } - if !strings.Contains(string(data), "control:") { - t.Fatalf("expected control in YAML, got: %s", string(data)) + if !strings.Contains(string(data), "ssh_options:") || !strings.Contains(string(data), "control_master: auto") { + t.Fatalf("expected ssh_options with snake_case in YAML, got: %s", string(data)) } var unmarshaled Node if err := yaml.Unmarshal(data, &unmarshaled); err != nil { t.Fatalf("unmarshal error: %v", err) } - if unmarshaled.Control == nil { - t.Fatal("expected unmarshaled Control to be non-nil") - } - if !unmarshaled.Control.Equal(nodeWithControl.Control) { - t.Fatalf("expected %v, got %v", nodeWithControl.Control, unmarshaled.Control) + if unmarshaled.SSHOptions == nil { + t.Fatal("expected unmarshaled SSHOptions to be non-nil") + } + if !unmarshaled.SSHOptions.Equal(nodeWithOptions.SSHOptions) { + t.Fatalf("expected %v, got %v", nodeWithOptions.SSHOptions, unmarshaled.SSHOptions) + } + + // 3. Manually edited YAML with OpenSSH PascalCase keys under ssh_options + pascalYAML := ` +host_ref: srv1 +identity_ref: admin +ssh_options: + ControlMaster: auto + ControlPath: ~/.ssh/cm-%r@%h:%p + ControlPersist: 10m + StrictHostKeyChecking: accept-new +` + var fromPascal Node + if err := yaml.Unmarshal([]byte(pascalYAML), &fromPascal); err != nil { + t.Fatalf("unmarshal PascalCase ssh_options error: %v", err) + } + if !fromPascal.SSHOptions.Equal(nodeWithOptions.SSHOptions) { + t.Fatalf("expected %v, got %v", nodeWithOptions.SSHOptions, fromPascal.SSHOptions) } } diff --git a/pkg/playbook/adversarial_concurrency_test.go b/pkg/playbook/adversarial_concurrency_test.go index be1f59b3..984e316c 100644 --- a/pkg/playbook/adversarial_concurrency_test.go +++ b/pkg/playbook/adversarial_concurrency_test.go @@ -49,10 +49,10 @@ func TestAdversarial_Playbook_HighConcurrencySocketSharing(t *testing.T) { HostRef: "shared-host", IdentityRef: "shared-id", Tags: []string{"web"}, - Control: &ssh.ControlConfig{ + SSHOptions: ssh.OptionsFromControl(&ssh.ControlConfig{ Master: ssh.ControlMasterAuto, Path: sockPath, - }, + }), }) } @@ -140,10 +140,10 @@ func TestAdversarial_Playbook_RapidSequentialMultiStep(t *testing.T) { cfg.Nodes.Set("node-seq", models.Node{ HostRef: "host-seq", IdentityRef: "id-seq", - Control: &ssh.ControlConfig{ + SSHOptions: ssh.OptionsFromControl(&ssh.ControlConfig{ Master: ssh.ControlMasterAuto, Path: sockPath, - }, + }), }) provider := config.NewProviderWithoutOpenSSH(cfg) @@ -211,10 +211,10 @@ func TestAdversarial_Playbook_ConcurrentSharedAndDirectNodes(t *testing.T) { cfg.Nodes.Set(fmt.Sprintf("shared-%02d", i), models.Node{ HostRef: "mixed-host", IdentityRef: "mixed-id", - Control: &ssh.ControlConfig{ + SSHOptions: ssh.OptionsFromControl(&ssh.ControlConfig{ Master: ssh.ControlMasterAuto, Path: sockPath, - }, + }), }) } @@ -223,10 +223,10 @@ func TestAdversarial_Playbook_ConcurrentSharedAndDirectNodes(t *testing.T) { cfg.Nodes.Set(fmt.Sprintf("direct-%02d", i), models.Node{ HostRef: "mixed-host", IdentityRef: "mixed-id", - Control: &ssh.ControlConfig{ + SSHOptions: ssh.OptionsFromControl(&ssh.ControlConfig{ Master: ssh.ControlMasterNo, Path: "none", - }, + }), }) } @@ -337,10 +337,10 @@ func TestAdversarial_Playbook_StaleSocketSimulatedCrashRecovery(t *testing.T) { cfg.Nodes.Set("node-crash", models.Node{ HostRef: "host-crash", IdentityRef: "id-crash", - Control: &ssh.ControlConfig{ + SSHOptions: ssh.OptionsFromControl(&ssh.ControlConfig{ Master: ssh.ControlMasterAuto, Path: staleSockPath, - }, + }), }) provider := config.NewProviderWithoutOpenSSH(cfg) diff --git a/pkg/playbook/playbook_control_test.go b/pkg/playbook/playbook_control_test.go index cf60e129..2975dd5d 100644 --- a/pkg/playbook/playbook_control_test.go +++ b/pkg/playbook/playbook_control_test.go @@ -117,10 +117,10 @@ func TestPlaybook_ActiveControlSocketReuse(t *testing.T) { HostRef: "host-1", IdentityRef: "id-1", Tags: []string{"web"}, - Control: &ssh.ControlConfig{ + SSHOptions: ssh.OptionsFromControl(&ssh.ControlConfig{ Master: ssh.ControlMasterAuto, Path: sockPath, - }, + }), }) provider := config.NewProviderWithoutOpenSSH(cfg) @@ -195,19 +195,19 @@ func TestPlaybook_CrossNodeSocketSharing(t *testing.T) { HostRef: "host-1", IdentityRef: "id-1", Tags: []string{"web"}, - Control: &ssh.ControlConfig{ + SSHOptions: ssh.OptionsFromControl(&ssh.ControlConfig{ Master: ssh.ControlMasterAuto, Path: sockPath, - }, + }), }) cfg.Nodes.Set("web-02", models.Node{ HostRef: "host-1", IdentityRef: "id-1", Tags: []string{"web"}, - Control: &ssh.ControlConfig{ + SSHOptions: ssh.OptionsFromControl(&ssh.ControlConfig{ Master: ssh.ControlMasterAuto, Path: sockPath, - }, + }), }) provider := config.NewProviderWithoutOpenSSH(cfg) @@ -275,10 +275,10 @@ func TestPlaybook_ControlPathNone_BypassesReuse(t *testing.T) { cfg.Nodes.Set("web-direct", models.Node{ HostRef: "host-1", IdentityRef: "id-1", - Control: &ssh.ControlConfig{ + SSHOptions: ssh.OptionsFromControl(&ssh.ControlConfig{ Master: ssh.ControlMasterNo, Path: "none", - }, + }), }) _ = sockPath // active socket exists, but node explicitly set "none" @@ -360,10 +360,10 @@ func TestPlaybook_StaleSocketAutoUnlinkAndFallback(t *testing.T) { cfg.Nodes.Set("web-stale", models.Node{ HostRef: "host-1", IdentityRef: "id-1", - Control: &ssh.ControlConfig{ + SSHOptions: ssh.OptionsFromControl(&ssh.ControlConfig{ Master: ssh.ControlMasterAuto, Path: staleSockPath, - }, + }), }) provider := config.NewProviderWithoutOpenSSH(cfg) From d4a1fbd51477464e961e82637f330def5c1441a3 Mon Sep 17 00:00:00 2001 From: wuyue Date: Sat, 7 Nov 2026 18:20:28 +0800 Subject: [PATCH 6/7] feat(ssh): enhance control socket error handling and improve path management - Added detailed error handling for control socket connection issues, including specific messages for "Connection refused" and "No such file or directory". - Improved handling of session control options in SSH adapter, ensuring proper configuration and fallback. - Refactored tests to utilize temporary directories for socket paths, enhancing test isolation and reliability. - Updated symbolic link handling in SFTP client to ensure compatibility across platforms, particularly Windows. - Introduced helper functions for creating temporary directories in tests to streamline socket path management. - Enhanced error messages for socket creation and binding, providing clearer feedback on failures. --- cmd/ssh_control.go | 54 ++++++++++++-------- cmd/ssh_control_guard_test.go | 43 ++++++++++------ core/sftp/transfer.go | 6 ++- core/sftp/transfer_symlink_test.go | 4 +- core/ssh/connector.go | 6 ++- core/ssh/connector_mux_reuse_test.go | 4 +- core/ssh/connector_mux_test.go | 14 ++--- core/ssh/execute_pipe_test.go | 21 +++++++- core/ssh/mux_client.go | 6 ++- core/ssh/mux_lifecycle_test.go | 18 +++---- core/ssh/mux_passenger_fd_test.go | 29 ++++++----- core/ssh/mux_proxy_flow_test.go | 6 +-- core/ssh/mux_proxy_test.go | 6 +-- core/ssh/mux_rights_other.go | 15 +++++- core/ssh/mux_server.go | 2 +- core/ssh/mux_server_test.go | 14 ++++- pkg/adapter/verification_test.go | 7 ++- pkg/playbook/adversarial_concurrency_test.go | 2 +- pkg/playbook/playbook_control_test.go | 15 +++++- 19 files changed, 183 insertions(+), 89 deletions(-) diff --git a/cmd/ssh_control.go b/cmd/ssh_control.go index b2ec69bd..06fd86af 100644 --- a/cmd/ssh_control.go +++ b/cmd/ssh_control.go @@ -7,6 +7,7 @@ import ( "io" "net" "os" + "runtime" "strconv" "strings" "syscall" @@ -97,6 +98,11 @@ func controlError(stderr io.Writer, msg string) error { func controlConnectError(stderr io.Writer, socketPath string, err error) error { errText := formatSyscallError(err) + if errText == "Connection refused" { + if _, statErr := os.Lstat(socketPath); errors.Is(statErr, os.ErrNotExist) { + errText = "No such file or directory" + } + } msg := fmt.Sprintf("Control socket connect(%s): %s", socketPath, errText) _, _ = fmt.Fprintln(stderr, msg) return &ControlExitError{Code: 255, Message: msg, Reported: true} @@ -108,27 +114,28 @@ func formatSyscallError(err error) string { } var sysErr syscall.Errno if errors.As(err, &sysErr) { - switch sysErr { - case syscall.ENOENT: + switch { + case sysErr == syscall.ENOENT: return "No such file or directory" - case syscall.ECONNREFUSED: + case sysErr == syscall.ECONNREFUSED || (runtime.GOOS == "windows" && sysErr == syscall.Errno(10061)): return "Connection refused" - case syscall.EACCES: + case sysErr == syscall.EACCES || (runtime.GOOS == "windows" && sysErr == syscall.Errno(10013)): return "Permission denied" - default: - return sysErr.Error() } } errStr := strings.ToLower(err.Error()) if strings.Contains(errStr, "no such file or directory") { return "No such file or directory" } - if strings.Contains(errStr, "connection refused") || strings.Contains(errStr, "wsaeconnrefused") { + if strings.Contains(errStr, "connection refused") || strings.Contains(errStr, "wsaeconnrefused") || strings.Contains(errStr, "actively refused") { return "Connection refused" } - if strings.Contains(errStr, "permission denied") { + if strings.Contains(errStr, "permission denied") || strings.Contains(errStr, "access is denied") { return "Permission denied" } + if sysErr != 0 { + return sysErr.Error() + } return err.Error() } @@ -549,13 +556,20 @@ func resolveControlProxyFallbackOptions(adp *adapter.SSHAdapter, host string, o } func buildControlProxyAdapterOptions(snapshot *config.Configuration, nodeID string, o *SshOptions) []adapter.Option { - sessionAuth := adapter.SessionAuth{ - Password: o.Password, - Passphrase: o.Passphrase, - KeyPath: utils.ToAbsolutePath(o.IdentityFile), - Remember: false, - OTP: o.OTP, - ProxyJump: o.JumpHost, + var sessionAuth adapter.SessionAuth + var sessionCtrlOpts adapter.SessionControlOptions + if o != nil { + sessionAuth = adapter.SessionAuth{ + Password: o.Password, + Passphrase: o.Passphrase, + KeyPath: utils.ToAbsolutePath(o.IdentityFile), + Remember: false, + OTP: o.OTP, + ProxyJump: o.JumpHost, + } + if parsed, ctrlErr := o.control.ToSessionControlOptions(); ctrlErr == nil { + sessionCtrlOpts = parsed + } } adpOpts := []adapter.Option{ adapter.WithCredentialRecording(false), @@ -564,12 +578,10 @@ func buildControlProxyAdapterOptions(snapshot *config.Configuration, nodeID stri if nodeID != "" { adpOpts = append(adpOpts, adapter.WithSessionAuthOverride(nodeID, sessionAuth)) } - if o != nil { - if sessionCtrlOpts, ctrlErr := o.control.ToSessionControlOptions(); ctrlErr == nil && !sessionCtrlOpts.IsEmpty() { - adpOpts = append(adpOpts, adapter.WithGlobalSessionControl(sessionCtrlOpts)) - if nodeID != "" { - adpOpts = append(adpOpts, adapter.WithSessionControlOverride(nodeID, sessionCtrlOpts)) - } + if !sessionCtrlOpts.IsEmpty() { + adpOpts = append(adpOpts, adapter.WithGlobalSessionControl(sessionCtrlOpts)) + if nodeID != "" { + adpOpts = append(adpOpts, adapter.WithSessionControlOverride(nodeID, sessionCtrlOpts)) } } if reg, regErr := utils.GetCredentialRegistry(snapshot); regErr == nil && reg != nil { diff --git a/cmd/ssh_control_guard_test.go b/cmd/ssh_control_guard_test.go index 8906d313..8cdeadf5 100644 --- a/cmd/ssh_control_guard_test.go +++ b/cmd/ssh_control_guard_test.go @@ -22,6 +22,16 @@ import ( cryptoSSH "golang.org/x/crypto/ssh" ) +func shortControlTempDir(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("", "xops-ctl-*") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + return dir +} + // TestControlFlagsValidateRejectsInvalidOption verifies that -o is validated // before a command resolves or prepares a node, and also when -S makes path // resolution return early. Otherwise a typo is silently ignored. @@ -200,7 +210,7 @@ func TestDrainOwnedMastersWaitsForSecondarySession(t *testing.T) { t.Fatalf("Atoi failed: %v", err) } - sockPath := filepath.Join(t.TempDir(), "drain.sock") + sockPath := filepath.Join(shortControlTempDir(t), "drain.sock") cfg := &config.Configuration{ Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), @@ -303,7 +313,7 @@ func TestReleaseMastersForHandoffRestoresSessionOnFailure(t *testing.T) { t.Fatalf("Atoi failed: %v", err) } - sockPath := filepath.Join(t.TempDir(), "restore.sock") + sockPath := filepath.Join(shortControlTempDir(t), "restore.sock") cfg := &config.Configuration{ Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), Hosts: concurrent.NewMap[string, models.Host](concurrent.HashString), @@ -482,7 +492,7 @@ func TestMultiHostHandoff_PreservesAllTargetMasters(t *testing.T) { hostB, portBStr, _ := net.SplitHostPort(fixB.Address) portB, _ := strconv.Atoi(portBStr) - tmpDir := t.TempDir() + tmpDir := shortControlTempDir(t) sockA := filepath.Join(tmpDir, "hostA.sock") sockB := filepath.Join(tmpDir, "hostB.sock") @@ -556,7 +566,7 @@ func TestHandoffSkipsImplicitTier4Control(t *testing.T) { host, portStr, _ := net.SplitHostPort(fix.Address) port, _ := strconv.Atoi(portStr) - sockPath := filepath.Join(t.TempDir(), "tier4.sock") + sockPath := filepath.Join(shortControlTempDir(t), "tier4.sock") openSSHContent := "Host web-tier4\n" + " ControlMaster auto\n" + @@ -619,7 +629,7 @@ func TestHandoffSkipsImplicitTier4Control(t *testing.T) { // R2-5: Even if Tier 1 overrides -S (ControlPath), inheriting ControlPersist // solely from Tier 4 must keep ImplicitTier4=true. - overridePath := filepath.Join(t.TempDir(), "override.sock") + overridePath := filepath.Join(shortControlTempDir(t), "override.sock") adpWithPathOverride := adapter.NewSSHAdapter( provider, adapter.WithSessionControlOverride("web-tier4", adapter.SessionControlOptions{Path: &overridePath}), @@ -649,7 +659,7 @@ func TestMuxDaemonArgsAndTargetResolutionForVirtualAndPendingNodes(t *testing.T) host, portStr, _ := net.SplitHostPort(fix.Address) port, _ := strconv.Atoi(portStr) - sockPath := filepath.Join(t.TempDir(), "r2-1.sock") + sockPath := filepath.Join(shortControlTempDir(t), "r2-1.sock") rawClient, err := cryptoSSH.Dial("tcp", fix.Address, &cryptoSSH.ClientConfig{ User: "fixture", @@ -748,7 +758,7 @@ func TestMuxDaemonArgsIsolationAndRememberSuppression(t *testing.T) { host, portStr, _ := net.SplitHostPort(fix.Address) port, _ := strconv.Atoi(portStr) - sockPath := filepath.Join(t.TempDir(), "r3-1.sock") + sockPath := filepath.Join(shortControlTempDir(t), "r3-1.sock") rawClient, err := cryptoSSH.Dial("tcp", fix.Address, &cryptoSSH.ClientConfig{ User: "fixture", @@ -1025,8 +1035,8 @@ func TestHandoffConnectorMastersDoesNotSkipPersistentJumpForNonPersistentTarget( host, portStr, _ := net.SplitHostPort(fix.Address) port, _ := strconv.Atoi(portStr) - jumpSock := filepath.Join(t.TempDir(), "jump.sock") - targetSock := filepath.Join(t.TempDir(), "target.sock") + jumpSock := filepath.Join(shortControlTempDir(t), "jump.sock") + targetSock := filepath.Join(shortControlTempDir(t), "target.sock") cfg := &config.Configuration{ Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), @@ -1107,8 +1117,8 @@ func TestMuxDaemonKeepsJumpMasterReferenceUntilTargetExits(t *testing.T) { host, portStr, _ := net.SplitHostPort(fix.Address) port, _ := strconv.Atoi(portStr) - jumpSock := filepath.Join(t.TempDir(), "jump-daemon.sock") - targetSock := filepath.Join(t.TempDir(), "target-daemon.sock") + jumpSock := filepath.Join(shortControlTempDir(t), "jump-daemon.sock") + targetSock := filepath.Join(shortControlTempDir(t), "target-daemon.sock") cfg := &config.Configuration{ Nodes: concurrent.NewMap[string, models.Node](concurrent.HashString), @@ -1705,7 +1715,7 @@ func TestAdHocOverrideWithWildcardOpenSSHAndSameIPJumpIdentityIsolation(t *testi } defer func() { _ = rawClient.Close() }() - tmpDir := t.TempDir() + tmpDir := shortControlTempDir(t) jumpMaster, err := ssh.StartMuxServer(ctx, rawClient, ssh.MuxMasterOptions{ Path: filepath.Join(tmpDir, "jump.sock"), Target: ssh.TargetContext{ @@ -1787,7 +1797,7 @@ func TestMuxDaemonKeyPathAndScpIsolationAndHostFlagAndLookupOpenSSHAlias(t *test } defer func() { _ = rawClient.Close() }() - tmpDir := t.TempDir() + tmpDir := shortControlTempDir(t) derivedMaster, err := ssh.StartMuxServer(ctx, rawClient, ssh.MuxMasterOptions{ Path: filepath.Join(tmpDir, "derived.sock"), Target: ssh.TargetContext{ @@ -2558,16 +2568,17 @@ Host jump-alias } // R13-1b: pending node with custom IdentityFile preserves KeyPath in ResolveTarget when queried by nodeID. + customKeyPath := filepath.Join(t.TempDir(), "pending-custom-key") prep, err := repo.PrepareNodeContext(ctx, config.EnsureNodeOptions{ Target: config.ConnectionTarget{Selector: "10.0.0.88", User: "ops", HasUser: true, Port: 2222, HasPort: true}, - IdentityFile: "/tmp/pending-custom-key", + IdentityFile: customKeyPath, }) if err != nil { t.Fatalf("PrepareNodeContext(10.0.0.88): %v", err) } pendingCtx := controlTarget(NewSshOptions(), repo, prep.NodeID) - if pendingCtx.KeyPath != "/tmp/pending-custom-key" { - t.Fatalf("expected controlTarget on pending node %q to retain KeyPath '/tmp/pending-custom-key', got %q", prep.NodeID, pendingCtx.KeyPath) + if pendingCtx.KeyPath != customKeyPath { + t.Fatalf("expected controlTarget on pending node %q to retain KeyPath %q, got %q", prep.NodeID, customKeyPath, pendingCtx.KeyPath) } // R13-2: controlTarget with provider == nil matches provider != nil for composite and explicit targets. diff --git a/core/sftp/transfer.go b/core/sftp/transfer.go index c43c96ad..abbe039d 100644 --- a/core/sftp/transfer.go +++ b/core/sftp/transfer.go @@ -11,6 +11,7 @@ import ( "os" "path" "path/filepath" + "runtime" "strings" "sync/atomic" @@ -1608,6 +1609,9 @@ func (c *Client) uploadLink(ctx context.Context, localPath, remotePath string, p if err != nil { return fmt.Errorf("read local symbolic link %q failed: %w", localPath, err) } + if runtime.GOOS == "windows" { + target = filepath.ToSlash(target) + } linkSize := int64(len(target)) skip, skipErr := c.shouldSkipUploadLink(remotePath, target) @@ -1752,7 +1756,7 @@ func (c *Client) shouldSkipDownloadLink(localPath, target string) (bool, error) return false, fmt.Errorf("cannot overwrite local destination directory %q with symbolic link", localPath) } if !c.config.Force && dstLstat.Mode()&os.ModeSymlink != 0 { - if existingTarget, readErr := os.Readlink(localPath); readErr == nil && existingTarget == target { + if existingTarget, readErr := os.Readlink(localPath); readErr == nil && (existingTarget == target || (runtime.GOOS == "windows" && filepath.ToSlash(existingTarget) == target)) { return true, nil } } diff --git a/core/sftp/transfer_symlink_test.go b/core/sftp/transfer_symlink_test.go index 5eacc0a9..95024944 100644 --- a/core/sftp/transfer_symlink_test.go +++ b/core/sftp/transfer_symlink_test.go @@ -440,7 +440,7 @@ func TestClient_DownloadDirectory_Symlink(t *testing.T) { if err != nil { t.Fatalf("readlink local symlink failed: %v", err) } - if gotTarget != relativeTarget { + if filepath.ToSlash(gotTarget) != relativeTarget { t.Errorf("expected symlink target %q, got %q", relativeTarget, gotTarget) } } @@ -509,7 +509,7 @@ func TestClient_DownloadDirectory_DanglingSymlink(t *testing.T) { if err != nil { t.Fatalf("readlink local dangling symlink failed: %v", err) } - if gotTarget != danglingTarget { + if filepath.ToSlash(gotTarget) != danglingTarget { t.Errorf("expected target %q, got %q", danglingTarget, gotTarget) } } diff --git a/core/ssh/connector.go b/core/ssh/connector.go index c9f64bd2..bf0af2c2 100644 --- a/core/ssh/connector.go +++ b/core/ssh/connector.go @@ -2414,7 +2414,11 @@ func (c *Connector) openCustomKnownHostsFiles(userKH string, cfg *ClientConfig, } func expandKnownHostsPath(pattern string, env Environment, target TargetContext) (string, error) { - return ExpandControlPath(pattern, env, target) + expanded, err := ExpandControlPath(pattern, env, target) + if err != nil || expanded == "" || strings.EqualFold(expanded, "none") || expanded == "/dev/null" { + return expanded, err + } + return filepath.Clean(filepath.FromSlash(expanded)), nil } func (c *Connector) handleKnownHostsKeyError( diff --git a/core/ssh/connector_mux_reuse_test.go b/core/ssh/connector_mux_reuse_test.go index c48f7895..29551f99 100644 --- a/core/ssh/connector_mux_reuse_test.go +++ b/core/ssh/connector_mux_reuse_test.go @@ -88,7 +88,7 @@ func TestConnectReusesJumpChainedTargetWithoutBastionAuth(t *testing.T) { bastionHost, bastionPort := fixtureEndpoint(t, bastion.Address) targetHost, targetPort := fixtureEndpoint(t, target.Address) - socketPath := filepath.Join(t.TempDir(), "chain.sock") + socketPath := filepath.Join(shortSocketDir(t), "chain.sock") provider := &jumpChainProvider{ cfgs: map[string]*ClientConfig{ @@ -170,7 +170,7 @@ func TestPlanConnectionDoesNotAttachToControlMaster(t *testing.T) { defer func() { _ = fixture.Close() }() host, port := fixtureEndpoint(t, fixture.Address) - socketPath := filepath.Join(t.TempDir(), "existing.sock") + socketPath := filepath.Join(shortSocketDir(t), "existing.sock") master, err := NewMuxServer(MuxServerOptions{ ControlPath: socketPath, SSHClient: newDummySSHClient(), diff --git a/core/ssh/connector_mux_test.go b/core/ssh/connector_mux_test.go index 55767a60..7ca62340 100644 --- a/core/ssh/connector_mux_test.go +++ b/core/ssh/connector_mux_test.go @@ -216,7 +216,7 @@ func TestConnector_ControlMasterDisabled(t *testing.T) { // when a stale control socket is replaced by a live master before removal, a // caller requesting ControlMaster=yes/ask (-M) is not demoted to a proxy client. func TestConnector_StaleSocketRecoveryDoesNotDemoteExplicitMaster(t *testing.T) { - tmpDir := t.TempDir() + tmpDir := shortSocketDir(t) staleFile := filepath.Join(tmpDir, "stale-orig") if err := os.WriteFile(staleFile, []byte("x"), 0o600); err != nil { t.Fatalf("WriteFile failed: %v", err) @@ -267,9 +267,9 @@ func TestConnector_StaleSocketRecoveryDoesNotDemoteExplicitMaster(t *testing.T) } // 2. Same file identity that became alive before unlink (origFi == currFi) - sameFi, statErr := os.Stat(sockPath) + sameFi, statErr := os.Lstat(sockPath) if statErr != nil { - t.Fatalf("Stat(sockPath) failed: %v", statErr) + t.Fatalf("Lstat(sockPath) failed: %v", statErr) } client, err = connector.handleStaleControlSocket(ctx, planNode, sockPath, target, sameFi) if err != nil { @@ -307,7 +307,7 @@ func (p *multiNodeMuxProvider) GetControlConfig(nodeID string) (*ControlConfig, // points to an existing regular file (or non-socket), tryConnectMultiplex and // handleStaleControlSocket never unlink it. func TestTryConnectMultiplex_NeverDeletesRegularFile(t *testing.T) { - tmpDir := t.TempDir() + tmpDir := shortSocketDir(t) regularFile := filepath.Join(tmpDir, "important-data.txt") const wantContent = "do-not-delete-me" if err := os.WriteFile(regularFile, []byte(wantContent), 0o600); err != nil { @@ -359,7 +359,7 @@ func TestTryConnectMultiplex_NeverDeletesRegularFile(t *testing.T) { // unresponsive Unix socket that times out duringMUX_MSG_HELLO is not treated as // a dead stale socket and is NOT unlinked on disk. func TestTryConnectMultiplex_TimeoutDoesNotUnlinkLiveMaster(t *testing.T) { - tmpDir := t.TempDir() + tmpDir := shortSocketDir(t) sockPath := filepath.Join(tmpDir, "slow-master.sock") ln, err := net.Listen("unix", sockPath) if err != nil { @@ -453,7 +453,7 @@ func TestProxyJump_TargetMasterShutdownPreservesBastion(t *testing.T) { } tPort, _ := strconv.Atoi(tPortStr) - tmpDir := t.TempDir() + tmpDir := shortSocketDir(t) bastionSock := filepath.Join(tmpDir, "bastion.sock") targetSock := filepath.Join(tmpDir, "target.sock") @@ -536,7 +536,7 @@ func TestProxyJump_TargetMasterShutdownPreservesBastion(t *testing.T) { // DialControlProxy unblocks immediately when its context is cancelled even if // the context carries no deadline. func TestDialAndPublishProxy_HonorsCancelWithoutDeadline(t *testing.T) { - tmpDir := t.TempDir() + tmpDir := shortSocketDir(t) sockPath := filepath.Join(tmpDir, "hang-proxy.sock") ln, err := net.Listen("unix", sockPath) if err != nil { diff --git a/core/ssh/execute_pipe_test.go b/core/ssh/execute_pipe_test.go index beeca446..b7ae3514 100644 --- a/core/ssh/execute_pipe_test.go +++ b/core/ssh/execute_pipe_test.go @@ -7,6 +7,7 @@ import ( "io" "net" "os" + "runtime" "strings" "sync" "sync/atomic" @@ -126,6 +127,17 @@ func (m *mockBufferingWriteCloser) String() string { return m.buf.String() } +type readerInputBridge struct{} + +func (readerInputBridge) Start(ctx context.Context, streams InteractiveIO, dst io.Writer) (InputCopy, error) { + done := make(chan error, 1) + go func() { + _, err := io.Copy(dst, streams.Stdin) + done <- err + }() + return NewInputCopy(ctx, func() error { return nil }, done) +} + // TestPipeCommandStdin_FileEOFClosesDestinationBeforeStop verifies that when an os.File // input reaches EOF, the destination is closed immediately so streaming commands can // finish before stopAndWait is called. @@ -144,7 +156,14 @@ func TestPipeCommandStdin_FileEOFClosesDestinationBeforeStop(t *testing.T) { _ = w.Close() // send EOF on input file writer := &mockBufferingWriteCloser{} - stopAndWait, err := pipeCommandStdin(r, writer) + var opts []inputPipelineOptions + if runtime.GOOS == "windows" { + opts = append(opts, inputPipelineOptions{ + ctx: context.Background(), + bridge: readerInputBridge{}, + }) + } + stopAndWait, err := pipeCommandStdin(r, writer, opts...) if err != nil { t.Fatalf("pipeCommandStdin failed: %v", err) } diff --git a/core/ssh/mux_client.go b/core/ssh/mux_client.go index 3cd7809e..a32771fc 100644 --- a/core/ssh/mux_client.go +++ b/core/ssh/mux_client.go @@ -546,11 +546,13 @@ func isConnectionRefused(err error) bool { return false } var sysErr syscall.Errno - if errors.As(err, &sysErr) && sysErr == syscall.ECONNREFUSED { + if errors.As(err, &sysErr) && (sysErr == syscall.ECONNREFUSED || (runtime.GOOS == "windows" && sysErr == syscall.Errno(10061))) { return true } errStr := strings.ToLower(err.Error()) - return strings.Contains(errStr, "connection refused") || strings.Contains(errStr, "wsaeconnrefused") + return strings.Contains(errStr, "connection refused") || + strings.Contains(errStr, "wsaeconnrefused") || + strings.Contains(errStr, "actively refused") } // isControlSocketFileInfo reports whether fi describes a valid control socket diff --git a/core/ssh/mux_lifecycle_test.go b/core/ssh/mux_lifecycle_test.go index a177b41c..24f92fd4 100644 --- a/core/ssh/mux_lifecycle_test.go +++ b/core/ssh/mux_lifecycle_test.go @@ -21,7 +21,7 @@ func TestControlClientRejectsForeignMaster(t *testing.T) { t.Skip("peer credentials are unavailable on Windows") } - socketPath := filepath.Join(t.TempDir(), "foreign.sock") + socketPath := filepath.Join(shortSocketDir(t), "foreign.sock") server, err := NewMuxServer(MuxServerOptions{ ControlPath: socketPath, SSHClient: newDummySSHClient(), @@ -72,7 +72,7 @@ func TestControlClientRejectsForeignMaster(t *testing.T) { func TestMuxServer_AskModeRequiresConfirmation(t *testing.T) { start := func(t *testing.T, confirm func(string) bool) *MuxServer { t.Helper() - socketPath := filepath.Join(t.TempDir(), "ask.sock") + socketPath := filepath.Join(shortSocketDir(t), "ask.sock") server, err := NewMuxServer(MuxServerOptions{ ControlPath: socketPath, SSHClient: newDummySSHClient(), @@ -135,7 +135,7 @@ func TestMuxServer_AskModeRequiresConfirmation(t *testing.T) { }) t.Run("not-ask master skips confirmation", func(t *testing.T) { - socketPath := filepath.Join(t.TempDir(), "plain.sock") + socketPath := filepath.Join(shortSocketDir(t), "plain.sock") server, err := NewMuxServer(MuxServerOptions{ ControlPath: socketPath, SSHClient: newDummySSHClient(), @@ -165,7 +165,7 @@ func TestConnectorCloseAllReleasesOwnedMaster(t *testing.T) { fixture := startMuxFixture(t, ctx, sshfixture.Options{}) - socketPath := filepath.Join(t.TempDir(), "owned.sock") + socketPath := filepath.Join(shortSocketDir(t), "owned.sock") provider := &testMuxProvider{ cfg: &ClientConfig{ NodeID: "owned-node", @@ -222,7 +222,7 @@ func TestStartMuxServerDefaultsPeerUIDCheck(t *testing.T) { start := func(t *testing.T, localUID string) *MuxServer { t.Helper() server, err := StartMuxServer(ctx, newDummySSHClient(), MuxMasterOptions{ - Path: filepath.Join(t.TempDir(), "peer-uid.sock"), + Path: filepath.Join(shortSocketDir(t), "peer-uid.sock"), Environment: Environment{LocalUID: localUID}, }) if err != nil { @@ -272,7 +272,7 @@ func TestControlClientRequiresExactPeerUID(t *testing.T) { t.Skip("this process already runs as root, so uid 0 is the correct peer") } - socketPath := filepath.Join(t.TempDir(), "root-expectation.sock") + socketPath := filepath.Join(shortSocketDir(t), "root-expectation.sock") server, err := NewMuxServer(MuxServerOptions{ ControlPath: socketPath, SSHClient: newDummySSHClient(), @@ -315,7 +315,7 @@ func TestConnectorOwnedMasterSurvivesIdleTimeout(t *testing.T) { defer func() { _ = fixture.Close() }() host, port := fixtureEndpoint(t, fixture.Address) - socketPath := filepath.Join(t.TempDir(), "owned-idle.sock") + socketPath := filepath.Join(shortSocketDir(t), "owned-idle.sock") provider := &testMuxProvider{ cfg: &ClientConfig{ NodeID: "idle-node", @@ -380,7 +380,7 @@ func TestMuxServerStopListeningRetiresIdleMaster(t *testing.T) { {name: "no-cmd master", initialSessions: 1, releaseOnStop: true}, } { t.Run(tc.name, func(t *testing.T) { - socketPath := filepath.Join(t.TempDir(), "stop-idle.sock") + socketPath := filepath.Join(shortSocketDir(t), "stop-idle.sock") server, err := NewMuxServer(MuxServerOptions{ ControlPath: socketPath, SSHClient: newDummySSHClient(), @@ -436,7 +436,7 @@ func TestConnectorReleaseMasterDropsOnlySessionReference(t *testing.T) { defer func() { _ = fixture.Close() }() host, port := fixtureEndpoint(t, fixture.Address) - socketPath := filepath.Join(t.TempDir(), "release.sock") + socketPath := filepath.Join(shortSocketDir(t), "release.sock") provider := &testMuxProvider{ cfg: &ClientConfig{ NodeID: "release-node", diff --git a/core/ssh/mux_passenger_fd_test.go b/core/ssh/mux_passenger_fd_test.go index 7f8b5140..7de12a82 100644 --- a/core/ssh/mux_passenger_fd_test.go +++ b/core/ssh/mux_passenger_fd_test.go @@ -282,7 +282,7 @@ func TestPassengerSession_TTYAllocFailSentAfterSessionOpenedWithSessionID(t *tes sshClient := cryptoSSH.NewClient(conn, chans, reqs) defer func() { _ = sshClient.Close() }() - sockPath := filepath.Join(t.TempDir(), "pty-fail.sock") + sockPath := filepath.Join(shortSocketDir(t), "pty-fail.sock") server, err := NewMuxServer(MuxServerOptions{ ControlPath: sockPath, SSHClient: sshClient, @@ -444,7 +444,7 @@ func TestPassengerSession_SubsystemAndStdinEOF(t *testing.T) { sshClient := cryptoSSH.NewClient(conn, chans, reqs) defer func() { _ = sshClient.Close() }() - sockPath := filepath.Join(t.TempDir(), "sftp-subsys.sock") + sockPath := filepath.Join(shortSocketDir(t), "sftp-subsys.sock") server, err := NewMuxServer(MuxServerOptions{ ControlPath: sockPath, SSHClient: sshClient, @@ -581,8 +581,17 @@ func (c *immediateExitSSHConn) OpenChannel(name string, _ []byte) (cryptoSSH.Cha return &immediateExitChannel{reqs: reqs}, reqs, nil } +func mustCloexecPipe(t *testing.T, p *[2]int) { + t.Helper() + if err := unix.Pipe(p[:]); err != nil { + t.Fatalf("unix.Pipe failed: %v", err) + } + unix.CloseOnExec(p[0]) + unix.CloseOnExec(p[1]) +} + // TestPassengerSession_BlockingPipeDoesNotDeadlockOnExit verifies that when a -// passenger client passes a blocking pipe fd (created via unix.Pipe2 without +// passenger client passes a blocking pipe fd (created via unix.Pipe without // O_NONBLOCK, mimicking a blocking TTY or pipe from native /usr/bin/ssh) whose // write end remains open in the client, remote command exit wakes the stdin // pump immediately via unix.Poll without mutating O_NONBLOCK on the shared open @@ -594,7 +603,7 @@ func TestPassengerSession_BlockingPipeDoesNotDeadlockOnExit(t *testing.T) { sshClient := cryptoSSH.NewClient(conn, chans, reqs) defer func() { _ = sshClient.Close() }() - sockPath := filepath.Join(t.TempDir(), "blocking-pipe.sock") + sockPath := filepath.Join(shortSocketDir(t), "blocking-pipe.sock") server, err := NewMuxServer(MuxServerOptions{ ControlPath: sockPath, SSHClient: sshClient, @@ -629,17 +638,11 @@ func TestPassengerSession_BlockingPipeDoesNotDeadlockOnExit(t *testing.T) { // Create raw blocking pipes (no O_NONBLOCK) so os.NewFile does not register // them with the nonblocking poller. Keep stdinPipe[1] open in the test! var stdinPipe, stdoutPipe, stderrPipe [2]int - if err := unix.Pipe2(stdinPipe[:], unix.O_CLOEXEC); err != nil { - t.Fatalf("unix.Pipe2(stdin) failed: %v", err) - } + mustCloexecPipe(t, &stdinPipe) defer func() { _ = unix.Close(stdinPipe[1]) }() - if err := unix.Pipe2(stdoutPipe[:], unix.O_CLOEXEC); err != nil { - t.Fatalf("unix.Pipe2(stdout) failed: %v", err) - } + mustCloexecPipe(t, &stdoutPipe) defer func() { _ = unix.Close(stdoutPipe[0]) }() - if err := unix.Pipe2(stderrPipe[:], unix.O_CLOEXEC); err != nil { - t.Fatalf("unix.Pipe2(stderr) failed: %v", err) - } + mustCloexecPipe(t, &stderrPipe) defer func() { _ = unix.Close(stderrPipe[0]) }() unixClient := clientConn.(*net.UnixConn) diff --git a/core/ssh/mux_proxy_flow_test.go b/core/ssh/mux_proxy_flow_test.go index e80acb15..4db48f88 100644 --- a/core/ssh/mux_proxy_flow_test.go +++ b/core/ssh/mux_proxy_flow_test.go @@ -173,7 +173,7 @@ func TestMuxServerPrepareHandoffRequiresIdleMaster(t *testing.T) { newServer := func(t *testing.T) *MuxServer { t.Helper() server, err := NewMuxServer(MuxServerOptions{ - ControlPath: t.TempDir() + "/handoff.sock", + ControlPath: filepath.Join(shortSocketDir(t), "handoff.sock"), SSHClient: newDummySSHClient(), PersistEnabled: true, PersistIndefinite: true, @@ -256,7 +256,7 @@ func TestMuxServerWaitForDrainAndCancelHandoff(t *testing.T) { defer cancel() server, err := NewMuxServer(MuxServerOptions{ - ControlPath: t.TempDir() + "/drain.sock", + ControlPath: filepath.Join(shortSocketDir(t), "drain.sock"), SSHClient: newDummySSHClient(), PersistEnabled: true, PersistIndefinite: true, @@ -535,7 +535,7 @@ func TestDialControlProxy_FailsClosedOnOversizedPacket(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - sockPath := filepath.Join(t.TempDir(), "oversized.sock") + sockPath := filepath.Join(shortSocketDir(t), "oversized.sock") ln, err := net.Listen("unix", sockPath) if err != nil { t.Fatalf("Listen unix failed: %v", err) diff --git a/core/ssh/mux_proxy_test.go b/core/ssh/mux_proxy_test.go index e8cff585..ccbf7f39 100644 --- a/core/ssh/mux_proxy_test.go +++ b/core/ssh/mux_proxy_test.go @@ -62,7 +62,7 @@ func startMuxFixture(t *testing.T, ctx context.Context, options sshfixture.Optio t.Fatalf("cryptoSSH.Dial master failed: %v", err) } - socketPath := filepath.Join(t.TempDir(), "master.sock") + socketPath := filepath.Join(shortSocketDir(t), "master.sock") server, err := NewMuxServer(MuxServerOptions{ ControlPath: socketPath, SSHClient: rawClient, @@ -362,7 +362,7 @@ func TestMuxServer_ShutdownKeepsReplacementSocket(t *testing.T) { t.Skip("hard-link socket replacement is not used on Windows") } - directory := t.TempDir() + directory := shortSocketDir(t) socketPath := filepath.Join(directory, "replace.sock") draining, err := NewMuxServer(MuxServerOptions{ @@ -588,7 +588,7 @@ func TestMuxProxy_OpenChannelFailurePreservesReasonAndHintsMaxSessions(t *testin rawClient := cryptoSSH.NewClient(conn, chans, reqs) defer func() { _ = rawClient.Close() }() - sockPath := filepath.Join(t.TempDir(), "maxsessions.sock") + sockPath := filepath.Join(shortSocketDir(t), "maxsessions.sock") server, err := NewMuxServer(MuxServerOptions{ ControlPath: sockPath, SSHClient: rawClient, diff --git a/core/ssh/mux_rights_other.go b/core/ssh/mux_rights_other.go index 423202bc..b3b14e23 100644 --- a/core/ssh/mux_rights_other.go +++ b/core/ssh/mux_rights_other.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "syscall" ) var errPassengerUnsupported = errors.New("passenger mode with fd passing is not supported on Windows; use MUX_C_PROXY") @@ -17,16 +18,28 @@ func bindAndLinkSocket(targetPath string) (net.Listener, error) { if err := os.MkdirAll(filepath.Dir(targetPath), 0700); err != nil { return nil, fmt.Errorf("create socket directory failed: %w", err) } + if _, err := os.Lstat(targetPath); err == nil { + return nil, fmt.Errorf("%w: %s", ErrMasterAlreadyExists, targetPath) + } // Windows AF_UNIX does not support hard-linking socket reparse points. // Bind targetPath directly. listener, err := net.Listen("unix", targetPath) if err != nil { - if strings.Contains(strings.ToLower(err.Error()), "address already in use") || strings.Contains(strings.ToLower(err.Error()), "access is denied") { + var sysErr syscall.Errno + errStr := strings.ToLower(err.Error()) + if (errors.As(err, &sysErr) && (sysErr == syscall.EADDRINUSE || sysErr == syscall.EEXIST || sysErr == syscall.Errno(10048))) || + strings.Contains(errStr, "address already in use") || + strings.Contains(errStr, "only one usage of each socket address") || + strings.Contains(errStr, "access is denied") || + strings.Contains(errStr, "file exists") { return nil, fmt.Errorf("%w: %s", ErrMasterAlreadyExists, targetPath) } return nil, err } + if unixLn, ok := listener.(*net.UnixListener); ok { + unixLn.SetUnlinkOnClose(false) + } return listener, nil } diff --git a/core/ssh/mux_server.go b/core/ssh/mux_server.go index d3bc2f74..75e0f626 100644 --- a/core/ssh/mux_server.go +++ b/core/ssh/mux_server.go @@ -139,7 +139,7 @@ func NewMuxServer(opts MuxServerOptions) (*MuxServer, error) { return nil, err } - origFi, _ := os.Stat(opts.ControlPath) + origFi, _ := os.Lstat(opts.ControlPath) server := &MuxServer{ controlPath: opts.ControlPath, diff --git a/core/ssh/mux_server_test.go b/core/ssh/mux_server_test.go index b4730f22..50b826d1 100644 --- a/core/ssh/mux_server_test.go +++ b/core/ssh/mux_server_test.go @@ -53,6 +53,16 @@ func newDummySSHClient() *cryptoSSH.Client { return cryptoSSH.NewClient(conn, chans, reqs) } +func shortSocketDir(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("", "xops-mux-*") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + return dir +} + func TestMuxServer_SocketCreation_PermissionsAndLink(t *testing.T) { tmpDir, err := os.MkdirTemp("", "mux-server-test-*") if err != nil { @@ -516,7 +526,7 @@ func TestMuxServer_ForegroundSession(t *testing.T) { } func TestMuxServer_MalformedRequestPreservesRequestID(t *testing.T) { - sockPath := filepath.Join(t.TempDir(), "malformed.sock") + sockPath := filepath.Join(shortSocketDir(t), "malformed.sock") server, err := NewMuxServer(MuxServerOptions{ ControlPath: sockPath, SSHClient: newDummySSHClient(), @@ -562,7 +572,7 @@ func TestMuxServer_MalformedRequestPreservesRequestID(t *testing.T) { } func TestMuxServer_ForwardKeyNormalizesEmptyListenHost(t *testing.T) { - sockPath := filepath.Join(t.TempDir(), "fwd-norm.sock") + sockPath := filepath.Join(shortSocketDir(t), "fwd-norm.sock") server, err := NewMuxServer(MuxServerOptions{ ControlPath: sockPath, SSHClient: newDummySSHClient(), diff --git a/pkg/adapter/verification_test.go b/pkg/adapter/verification_test.go index 0b99aae4..67815812 100644 --- a/pkg/adapter/verification_test.go +++ b/pkg/adapter/verification_test.go @@ -67,7 +67,12 @@ func TestVerifyConnection_IgnoresActiveControlSocket(t *testing.T) { t.Cleanup(func() { _ = fixtureClient.Close() }) _ = sshfixture.Password - sockPath := filepath.Join(t.TempDir(), "verify-bypass.sock") + sockDir, err := os.MkdirTemp("", "xops-verify-*") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(sockDir) }) + sockPath := filepath.Join(sockDir, "verify-bypass.sock") muxServer, err := ssh.NewMuxServer(ssh.MuxServerOptions{ ControlPath: sockPath, SSHClient: fixtureClient, diff --git a/pkg/playbook/adversarial_concurrency_test.go b/pkg/playbook/adversarial_concurrency_test.go index 984e316c..1968d755 100644 --- a/pkg/playbook/adversarial_concurrency_test.go +++ b/pkg/playbook/adversarial_concurrency_test.go @@ -301,7 +301,7 @@ func TestAdversarial_Playbook_StaleSocketSimulatedCrashRecovery(t *testing.T) { } defer func() { _ = fixture.Close() }() - tmpDir := t.TempDir() + tmpDir := shortPlaybookSocketDir(t) staleSockPath := filepath.Join(tmpDir, "crashed_playbook.sock") // Create a dead socket on disk diff --git a/pkg/playbook/playbook_control_test.go b/pkg/playbook/playbook_control_test.go index 2975dd5d..d192baaa 100644 --- a/pkg/playbook/playbook_control_test.go +++ b/pkg/playbook/playbook_control_test.go @@ -3,6 +3,7 @@ package playbook_test import ( "context" "net" + "os" "path/filepath" "strconv" "sync" @@ -44,6 +45,16 @@ func (r *diagnosticRecorder) hasEvent(kind corelog.DiagnosticKind, nodeID string return false } +func shortPlaybookSocketDir(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("", "xops-pb-*") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + return dir +} + // setupPlaybookFixture creates an sshfixture and a master connection with MuxServer listening on a socket. func setupPlaybookFixture(t *testing.T, ctx context.Context) (*sshfixture.Server, string, *ssh.MuxServer, string, uint16) { t.Helper() @@ -77,7 +88,7 @@ func setupPlaybookFixture(t *testing.T, ctx context.Context) (*sshfixture.Server t.Fatalf("cryptoSSH.Dial master failed: %v", err) } - tmpDir := t.TempDir() + tmpDir := shortPlaybookSocketDir(t) sockPath := filepath.Join(tmpDir, "playbook_master.sock") muxServer, err := ssh.NewMuxServer(ssh.MuxServerOptions{ @@ -336,7 +347,7 @@ func TestPlaybook_StaleSocketAutoUnlinkAndFallback(t *testing.T) { port := uint16(portInt) // Create a stale socket on disk without active listener - tmpDir := t.TempDir() + tmpDir := shortPlaybookSocketDir(t) staleSockPath := filepath.Join(tmpDir, "stale.sock") fd, err := syscall.Socket(syscall.AF_UNIX, syscall.SOCK_STREAM, 0) if err != nil { From 9cc9bc9edad0f02b7c7baa07a13096cd45906144 Mon Sep 17 00:00:00 2001 From: wuyue Date: Sat, 7 Nov 2026 18:42:06 +0800 Subject: [PATCH 7/7] feat(ssh): refactor socket path handling and improve error reporting for mux client --- cmd/ssh_option_flag_test.go | 8 ++++---- core/ssh/mux_client.go | 15 +++++++++------ core/ssh/mux_rights_unix.go | 9 ++++++--- pkg/playbook/playbook_control_test.go | 2 +- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/cmd/ssh_option_flag_test.go b/cmd/ssh_option_flag_test.go index d22f7283..e77ded5e 100644 --- a/cmd/ssh_option_flag_test.go +++ b/cmd/ssh_option_flag_test.go @@ -417,7 +417,7 @@ func TestWaitForControlSocketStopsWhenDaemonExits(t *testing.T) { exited := make(chan struct{}) close(exited) - socketPath := filepath.Join(t.TempDir(), "never.sock") + socketPath := filepath.Join(shortControlTempDir(t), "never.sock") start := time.Now() err := waitForControlSocket(context.Background(), socketPath, 10*time.Second, exited) if err == nil { @@ -440,7 +440,7 @@ func TestWaitForControlSocketStopsWhenDaemonExits(t *testing.T) { // only on the next probe. func TestWaitForControlSocketWakesOnDaemonExit(t *testing.T) { exited := make(chan struct{}) - socketPath := filepath.Join(t.TempDir(), "late.sock") + socketPath := filepath.Join(shortControlTempDir(t), "late.sock") go func() { time.Sleep(controlSocketPollInterval / 2) @@ -463,7 +463,7 @@ func TestWaitForControlSocketWakesOnDaemonExit(t *testing.T) { // TestWaitForControlSocketHonorsCancellation verifies that a canceled caller stops // the wait promptly and reports cancellation rather than a probe error. func TestWaitForControlSocketHonorsCancellation(t *testing.T) { - socketPath := filepath.Join(t.TempDir(), "canceled.sock") + socketPath := filepath.Join(shortControlTempDir(t), "canceled.sock") ctx, cancel := context.WithCancel(context.Background()) go func() { time.Sleep(controlSocketPollInterval / 2) @@ -544,7 +544,7 @@ func TestControlCommandAllowsExplicitSocketWithoutTarget(t *testing.T) { // TestReleaseMastersForHandoffClosesTargetSocket verifies that // releaseMastersForHandoff reserves and shuts down a persistent target master. func TestReleaseMastersForHandoffClosesTargetSocket(t *testing.T) { - targetSock := filepath.Join(t.TempDir(), "target.sock") + targetSock := filepath.Join(shortControlTempDir(t), "target.sock") targetMaster, err := ssh.StartMuxServer(context.Background(), nilDummySSHClient(t), ssh.MuxMasterOptions{ Path: targetSock, Target: ssh.TargetContext{Control: &ssh.ControlConfig{Master: ssh.ControlMasterAuto, Path: targetSock, Persist: "10m"}}, diff --git a/core/ssh/mux_client.go b/core/ssh/mux_client.go index a32771fc..889379af 100644 --- a/core/ssh/mux_client.go +++ b/core/ssh/mux_client.go @@ -139,6 +139,11 @@ func DialMuxClient(ctx context.Context, socketPath string, opts ...MuxClientOpti var dialer net.Dialer conn, err := dialer.DialContext(ctx, "unix", socketPath) if err != nil { + if runtime.GOOS == "windows" && isConnectionRefused(err) { + if _, statErr := os.Lstat(socketPath); errors.Is(statErr, os.ErrNotExist) { + return nil, fmt.Errorf("dial mux socket %q: %w", socketPath, statErr) + } + } return nil, fmt.Errorf("dial mux socket %q: %w", socketPath, err) } @@ -556,16 +561,14 @@ func isConnectionRefused(err error) bool { } // isControlSocketFileInfo reports whether fi describes a valid control socket -// entry on the current platform. On Unix only actual socket inodes qualify so a -// regular file at ControlPath is never mistaken for a stale socket and unlinked. +// entry on the current platform. Only actual socket inodes/reparse points +// qualify so a regular file at ControlPath is never mistaken for a stale socket +// and unlinked. func isControlSocketFileInfo(fi os.FileInfo) bool { if fi == nil { return false } - if fi.Mode()&os.ModeSocket != 0 { - return true - } - return runtime.GOOS == "windows" && fi.Mode().IsRegular() + return fi.Mode()&os.ModeSocket != 0 } func socketPathExists(path string) bool { diff --git a/core/ssh/mux_rights_unix.go b/core/ssh/mux_rights_unix.go index 676f410a..2583c13c 100644 --- a/core/ssh/mux_rights_unix.go +++ b/core/ssh/mux_rights_unix.go @@ -657,11 +657,14 @@ func recvFDs(conn *net.UnixConn, count int) ([]int, error) { return nil, fmt.Errorf(format, args...) } + const surplusFDSlack = 4 var data [1]byte for len(fds) < count { - // Reserve room for the descriptors still missing: an undersized buffer - // truncates the control message and silently drops descriptors. - oob := make([]byte, unix.CmsgSpace((count-len(fds))*4)) + // Reserve room for the descriptors still missing plus surplus slack: on + // 32-bit aligned cmsg platforms (e.g. Darwin), CmsgSpace(4) has room for + // only a single descriptor, so extra slack lets recvFDs receive and close + // surplus descriptors instead of hitting MSG_CTRUNC. + oob := make([]byte, unix.CmsgSpace((count-len(fds)+surplusFDSlack)*4)) _, oobn, flags, _, err := conn.ReadMsgUnix(data[:], oob) if err != nil { return fail("receive descriptors: %w", err) diff --git a/pkg/playbook/playbook_control_test.go b/pkg/playbook/playbook_control_test.go index d192baaa..607144cb 100644 --- a/pkg/playbook/playbook_control_test.go +++ b/pkg/playbook/playbook_control_test.go @@ -353,9 +353,9 @@ func TestPlaybook_StaleSocketAutoUnlinkAndFallback(t *testing.T) { if err != nil { t.Fatalf("create socket failed: %v", err) } - defer func() { _ = syscall.Close(fd) }() sa := &syscall.SockaddrUnix{Name: staleSockPath} if err := syscall.Bind(fd, sa); err != nil { + _ = syscall.Close(fd) t.Fatalf("bind failed: %v", err) } _ = syscall.Close(fd) // Close without listening, leaving stale socket file