feat: 增强 ssh-agent 认证与转发可靠性
- 拆分 ssh-agent 认证、连接与 endpoint 解析逻辑 - 新增 IdentityAgent、SSHAgentTimeout、SSHAgentForwardTimeout 和调试事件 - 为 agent list/sign 操作增加独立 deadline,避免硬件 agent 卡死登录 - 支持 agent signer 失败后跳过坏 key 并重试后续 key - 优先处理 RSA-SHA2 签名,兼容现代 OpenSSH 认证要求 - 增强 agent forwarding 的探测、通道空闲超时和关闭清理 - 补充 Windows OpenSSH pipe 与 GPG S.gpg-agent.ssh socket 文件支持 - 增加相关回归测试和 Windows 编译验证覆盖
This commit is contained in:
@@ -4,26 +4,14 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/agent"
|
||||
)
|
||||
|
||||
var ErrHostKeyCallbackRequired = errors.New("host key callback is required; use DefaultAllowHostKeyCallback to explicitly allow any host key")
|
||||
var errSSHAgentUnavailable = errors.New("ssh-agent unavailable")
|
||||
var buildSSHAgentAuthMethodFunc = buildSSHAgentAuthMethod
|
||||
|
||||
var defaultAuthOrder = []AuthMethodKind{
|
||||
AuthMethodSSHAgent,
|
||||
AuthMethodPrivateKey,
|
||||
AuthMethodPassword,
|
||||
AuthMethodKeyboardInteractive,
|
||||
}
|
||||
|
||||
func DefaultAllowHostKeyCallback(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||
return nil
|
||||
@@ -47,11 +35,35 @@ func loginWithContext(ctx context.Context, info LoginInput) (*StarSSH, error) {
|
||||
loginCtx, cancel := contextWithLoginTimeout(ctx, authTimeout)
|
||||
defer cancel()
|
||||
|
||||
order, err := normalizeAuthOrder(info.AuthOrder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if shouldRetrySSHAgentAuth(info, order) {
|
||||
agentAttempt := newSSHAgentAuthAttempt()
|
||||
for {
|
||||
agentAttempt.begin()
|
||||
sshInfo, err := loginOnceWithContext(loginCtx, info, authTimeout, agentAttempt)
|
||||
if err == nil {
|
||||
return sshInfo, nil
|
||||
}
|
||||
if errors.Is(err, errRetrySSHAgentAuth) && loginCtx.Err() == nil {
|
||||
continue
|
||||
}
|
||||
return sshInfo, err
|
||||
}
|
||||
}
|
||||
|
||||
return loginOnceWithContext(loginCtx, info, authTimeout, nil)
|
||||
}
|
||||
|
||||
func loginOnceWithContext(ctx context.Context, info LoginInput, authTimeout time.Duration, agentAttempt *sshAgentAuthAttempt) (*StarSSH, error) {
|
||||
sshInfo := &StarSSH{
|
||||
LoginInfo: info,
|
||||
}
|
||||
|
||||
auth, authCleanup, err := buildAuthMethods(info)
|
||||
auth, authCleanup, err := buildAuthMethodsWithAgentAttempt(info, agentAttempt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -91,11 +103,11 @@ func loginWithContext(ctx context.Context, info LoginInput) (*StarSSH, error) {
|
||||
}
|
||||
|
||||
targetAddr := joinHostPort(info.Addr, info.Port)
|
||||
rawConn, upstream, err := dialTargetConn(loginCtx, info)
|
||||
rawConn, upstream, err := dialTargetConn(ctx, info)
|
||||
if err != nil {
|
||||
return sshInfo, err
|
||||
}
|
||||
restoreDeadline := applyConnDeadline(rawConn, loginCtx, authTimeout)
|
||||
restoreDeadline := applyConnDeadline(rawConn, ctx, authTimeout)
|
||||
defer restoreDeadline()
|
||||
|
||||
clientConn, chans, reqs, err := ssh.NewClientConn(rawConn, targetAddr, clientConfig)
|
||||
@@ -179,204 +191,3 @@ func effectiveDialTimeout(info LoginInput) time.Duration {
|
||||
return defaultLoginTimeout
|
||||
}
|
||||
}
|
||||
|
||||
func buildAuthMethods(info LoginInput) ([]ssh.AuthMethod, func(), error) {
|
||||
order, err := normalizeAuthOrder(info.AuthOrder)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
auth := make([]ssh.AuthMethod, 0, len(order))
|
||||
var agentErr error
|
||||
var cleanupFuncs []func()
|
||||
|
||||
for _, methodKind := range order {
|
||||
switch methodKind {
|
||||
case AuthMethodPrivateKey:
|
||||
method, err := buildPrivateKeyAuthMethod(info)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if method != nil {
|
||||
auth = append(auth, method)
|
||||
}
|
||||
case AuthMethodPassword:
|
||||
method := buildPasswordAuthMethod(info.Password, info.PasswordCallback)
|
||||
if method != nil {
|
||||
auth = append(auth, method)
|
||||
}
|
||||
case AuthMethodKeyboardInteractive:
|
||||
method := buildKeyboardInteractiveAuthMethod(info.Password, info.PasswordCallback, info.KeyboardInteractiveCallback)
|
||||
if method != nil {
|
||||
auth = append(auth, method)
|
||||
}
|
||||
case AuthMethodSSHAgent:
|
||||
if info.DisableSSHAgent {
|
||||
continue
|
||||
}
|
||||
agentMethod, cleanup, err := buildSSHAgentAuthMethodFunc(effectiveDialTimeout(info))
|
||||
if err != nil {
|
||||
agentErr = err
|
||||
continue
|
||||
}
|
||||
if agentMethod != nil {
|
||||
auth = append(auth, agentMethod)
|
||||
}
|
||||
if cleanup != nil {
|
||||
cleanupFuncs = append(cleanupFuncs, cleanup)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(auth) == 0 {
|
||||
if agentErr != nil {
|
||||
return nil, nil, fmt.Errorf("no authentication method provided; ssh-agent unavailable: %w", agentErr)
|
||||
}
|
||||
return nil, nil, errors.New("no authentication method provided: password, private key, or ssh-agent is required")
|
||||
}
|
||||
|
||||
return auth, composeCleanup(cleanupFuncs...), nil
|
||||
}
|
||||
|
||||
func normalizeAuthOrder(order []AuthMethodKind) ([]AuthMethodKind, error) {
|
||||
if len(order) == 0 {
|
||||
return append([]AuthMethodKind(nil), defaultAuthOrder...), nil
|
||||
}
|
||||
|
||||
normalized := make([]AuthMethodKind, 0, len(order))
|
||||
seen := make(map[AuthMethodKind]struct{}, len(order))
|
||||
for _, raw := range order {
|
||||
kind := AuthMethodKind(strings.ToLower(strings.TrimSpace(string(raw))))
|
||||
if kind == "" {
|
||||
return nil, errors.New("auth order contains an empty auth method")
|
||||
}
|
||||
if !isSupportedAuthMethodKind(kind) {
|
||||
return nil, fmt.Errorf("unsupported auth method %q", raw)
|
||||
}
|
||||
if _, exists := seen[kind]; exists {
|
||||
continue
|
||||
}
|
||||
seen[kind] = struct{}{}
|
||||
normalized = append(normalized, kind)
|
||||
}
|
||||
|
||||
if len(normalized) == 0 {
|
||||
return nil, errors.New("auth order is empty")
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func isSupportedAuthMethodKind(kind AuthMethodKind) bool {
|
||||
switch kind {
|
||||
case AuthMethodPrivateKey, AuthMethodPassword, AuthMethodKeyboardInteractive, AuthMethodSSHAgent:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func buildPrivateKeyAuthMethod(info LoginInput) (ssh.AuthMethod, error) {
|
||||
if strings.TrimSpace(info.Prikey) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
pemBytes := []byte(info.Prikey)
|
||||
if info.PrikeyPwd == "" {
|
||||
signer, err := ssh.ParsePrivateKey(pemBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ssh.PublicKeys(signer), nil
|
||||
}
|
||||
|
||||
signer, err := ssh.ParsePrivateKeyWithPassphrase(pemBytes, []byte(info.PrikeyPwd))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ssh.PublicKeys(signer), nil
|
||||
}
|
||||
|
||||
func buildPasswordAuthMethod(password string, callback func() (string, error)) ssh.AuthMethod {
|
||||
if password != "" {
|
||||
return ssh.Password(password)
|
||||
}
|
||||
if callback != nil {
|
||||
return ssh.PasswordCallback(callback)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildKeyboardInteractiveAuthMethod(
|
||||
password string,
|
||||
passwordCallback func() (string, error),
|
||||
challenge ssh.KeyboardInteractiveChallenge,
|
||||
) ssh.AuthMethod {
|
||||
if challenge != nil {
|
||||
return ssh.KeyboardInteractive(challenge)
|
||||
}
|
||||
if password == "" && passwordCallback == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
keyboardInteractiveChallenge := func(user, instruction string, questions []string, echos []bool) ([]string, error) {
|
||||
if len(questions) == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
answer := password
|
||||
if answer == "" {
|
||||
var err error
|
||||
answer, err = passwordCallback()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
answers := make([]string, len(questions))
|
||||
for i := range questions {
|
||||
answers[i] = answer
|
||||
}
|
||||
return answers, nil
|
||||
}
|
||||
return ssh.KeyboardInteractive(keyboardInteractiveChallenge)
|
||||
}
|
||||
|
||||
func buildSSHAgentAuthMethod(timeout time.Duration) (ssh.AuthMethod, func(), error) {
|
||||
conn, err := dialSSHAgent(timeout)
|
||||
if err != nil {
|
||||
if errors.Is(err, errSSHAgentUnavailable) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
if conn == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
signers, err := agent.NewClient(conn).Signers()
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(signers) == 0 {
|
||||
_ = conn.Close()
|
||||
return nil, nil, errors.New("ssh-agent has no loaded keys")
|
||||
}
|
||||
|
||||
return ssh.PublicKeys(signers...), func() {
|
||||
_ = conn.Close()
|
||||
}, nil
|
||||
}
|
||||
|
||||
func composeCleanup(funcs ...func()) func() {
|
||||
if len(funcs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return func() {
|
||||
for i := len(funcs) - 1; i >= 0; i-- {
|
||||
if funcs[i] != nil {
|
||||
funcs[i]()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user