sshx

package
v0.10.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 10, 2026 License: MIT Imports: 10 Imported by: 0

Documentation

Overview

Package sshx 是 SSH 客户端的通用原语:拨号、私钥、密钥类型识别、保活。

为什么不直接用 golang.org/x/crypto/ssh:它给的是协议原语,拨号这一步要 做的判断一个都没替你做——连接超时、握手超时、调用方的总预算、以及 「超时」与「被拒绝」这两种失败的区分,都要自己写一遍。写错一次的后果 不是报错难看,而是调用方永远等下去。

本包的五件事:

  • Dial:两段界限的拨号(连接一段、握手一段),并接受调用方的总预算
  • DialWithPolicy:Dial + 预算内按类重拨(超时抖动重拨、拒绝判定不重拨, 预算跨尝试共享不放大——#125)
  • LoadSigner / DetectKeyType:私钥的读、解析与类型识别
  • PublicKeyType:公钥的类型识别
  • StartKeepAlive:空闲连接的保活

用法:

signer, err := sshx.LoadSigner("/root/.ssh/id_rsa")
if err != nil {
	return err
}
client, err := sshx.Dial(ctx, "10.0.0.1:2222", &ssh.ClientConfig{
	User:            "ops",
	Auth:            []ssh.AuthMethod{ssh.PublicKeys(signer)},
	HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}, sshx.DialOptions{Budget: 10 * time.Second})

与 x/crypto/ssh 的关系是叠加不是替代:拨号与凭据用本包,会话与通道 仍然用 x/crypto/ssh。

Index

Constants

View Source
const (
	// DefaultConnectTimeout 界 TCP 连接。
	DefaultConnectTimeout = 15 * time.Second

	// DefaultHandshakeTimeout 界 SSH 协议握手。
	//
	// 健康的握手是毫秒级的,所以这个数是它的许多倍:它在这里是为了接住
	// 病态情况,不是为了催健康的那个。
	//
	// 不对称决定了这个数。误判超时会把快路径推去降级路径,每次调用多花
	// 十秒;而漏了超时会让调用永远不返回,永远不返回的调用没有降级可言。
	// 宁可偏向长的一边——那是便宜的错误。
	DefaultHandshakeTimeout = 5 * time.Second
)

拨号的两道界限。

View Source
const DefaultKeepAliveInterval = 30 * time.Second

DefaultKeepAliveInterval 是戳连接的间隔。

View Source
const KeepAliveRequest = "keepalive@openssh.com"

KeepAliveRequest 是 OpenSSH 的全局 keepalive 请求名。

调用方拿着它往连接上发这个请求(`client.SendRequest(sshx.KeepAliveRequest, false, nil)`)——WantReply 是 false,因为等回包意味着等一个回应,而一个 既不应也不关的服务端会让这个请求卡在连接的整个余生里。

Variables

View Source
var (
	// ErrHandshakeTimeout 标记「握手超时」:对端接受了连接但从不说 SSH
	// (透明代理、空转的端口转发、回了 SYN 就沉默的中间盒)。典型的降级
	// 决定是重拨或走降级路径。
	ErrHandshakeTimeout = errors.New("ssh handshake timeout")

	// ErrHandshakeRejected 标记「握手被拒绝」:对端说了 SSH 但不让进
	// (链里通常是 "unable to authenticate")。典型的降级决定是**不**重拨
	// ——凭据不对,重拨只是多打几次凭据、掩盖真错误。
	ErrHandshakeRejected = errors.New("ssh handshake rejected")
)

握手失败的类型化哨兵(#116)。它们的措辞**不会**出现在 Dial 的错误文本 里——错误文本保持原措辞(人读),分类走类型:errors.Is 判哨兵、 errors.As 判下面的结构体,两条路都能走。

View Source
var ErrBusy = errors.New("the connection is busy")

ErrBusy 说连接当时正忙着,所以这一下没戳。

它不是失败:忙着说明连接不闲,不闲的连接不需要保温。

Functions

func DetectKeyType

func DetectKeyType(ctx context.Context, path string) (string, error)

DetectKeyType 报告私钥文件的密钥类型("ssh-rsa" / "ssh-ed25519" / "ecdsa-sha2-nistp256" 这类)。

三级回落,越靠前越准:

  1. 直接解析私钥。最准,而且不需要外部进程——公钥类型就在签名器里。
  2. ssh-keygen -y。解析不了的私钥(比如带口令的)在这里还有救:有 agent 时 ssh-keygen 能读出公钥。
  3. 文件头嗅探。最后的手段,只能给出粗粒度的结论。

ctx 管住第二级里那个外部进程:ssh-keygen 在一个卡住的 agent 上会一直 等下去,而调用方不该为此无限期等下去。nil ctx 按 context.Background 处理(#100,与 #79 对 Dial 的兜底同仓口径)——exec.CommandContext 对 nil ctx 直接 panic,而「解析不了的私钥」(带口令/损坏/非 OpenSSH 格式) 必然走到这一级,nil ctx 不能是进程崩掉的开关。

读不出文件是错误(调用方拿到的路径就是错的);读得出但认不出类型不是 错误——返回 "unknown" 或粗粒度结论,让调用方自己去判断。

func Dial

func Dial(ctx context.Context, addr string, cc *ssh.ClientConfig, opts DialOptions) (*ssh.Client, error)

Dial 打开到 addr 的 SSH 连接。

ctx 对连接阶段有效:放弃的调用方不会留下一个开着的 socket。握手由固定 界限、调用方剩余预算与 ctx 剩余时间三者中最小的那个界定——三者都在 **拨号完成之后**求值(#106):预算是总预算、锚定在 Dial 入口,ctx 的 截止时间同样压住握手;连接阶段耗掉的 T 不会再让总耗时可接近 2×预算、 或超出 ctx 截止时间约 T。 nil ctx 按 context.Background 处理(#79,与同仓 ratelimit.TokenBucket.WaitN / syncx.Gate.Acquire / retry.DoValue 的兜底口径一致),不留给调用方一个 nil 解引用 panic。

返回的 client 拥有这条连接:Close 它就关掉 socket。

失败分两种,措辞不同,因为调用方对它们的反应不同:一种是超时(对端没 说 SSH),一种是拒绝(对端说了但不让进)。调用方常常要在这两者之间做 降级决定,所以它们不能被揉成同一句话。

这个区分**不能只靠措辞**(#116):措辞是给人读的,机器要分类得走类型。 超时返回 *HandshakeTimeoutError、拒绝返回 *HandshakeRejectedError,二者 都能被 errors.As 判别;配套哨兵 ErrHandshakeTimeout / ErrHandshakeRejected 供 errors.Is 使用。措辞保持原样(人读的那路不受影响),分类走类型—— 集成方的重试逻辑不再对上游措辞做字符串匹配。

func DialWithPolicy added in v0.9.0

func DialWithPolicy(ctx context.Context, addr string, cc *ssh.ClientConfig, opts DialOptions, policy DialPolicy) (*ssh.Client, error)

DialWithPolicy = Dial + 预算内按类重拨(#125)。

总预算(opts.Budget)与 ctx 截止时间**跨所有尝试共享**——每次尝试拿到的 是「此刻剩余的预算」,重拨不放大超时。这是本原语必须钉死的一点:预算若 按次重置,N 次重拨就是把调用方的等待放大 N 倍的超时放大器。Backoff 的 等待同样从这份预算里扣。

分类从 Dial 原样继承:超时返回 *HandshakeTimeoutError、拒绝返回 *HandshakeRejectedError(errors.Is / errors.As 判别)。预算在两次尝试之间 耗尽时不再发起注定失败的尝试,返回包着最后一次失败的预算错误(类型化 错误仍在原因链上);其余情况最后一次尝试的错误**原样返回**(不重包—— 调用方的分类逻辑与单次 Dial 同一口径)。

nil cc / nil ctx 的兜底与 Dial 一致。

func LoadSigner

func LoadSigner(path string) (ssh.Signer, error)

LoadSigner 读私钥文件并解析成签名器。

只认未加密的私钥:带口令的私钥要口令或者要 agent,这两样都不是本包的 事(调用方手里有 ssh-agent 的话,agent 本身会完成握手)。

func ParsePublicKey

func ParsePublicKey(publicKey string) (ssh.PublicKey, error)

ParsePublicKey 解析一行 SSH 公钥(authorized_keys 的那种格式)。

它比「看第一个字段」严格:字段对但内容是坏 base64 的公钥,在这里会被 拒掉,而在字符串前缀检查里它是「通过」的——然后在登录那一刻才失败。

⚠️ 但它**按行扫描**:解析不了的行被跳过,取第一条能解析的。所以输入里 有多把钥匙时,它判的是「第一条能解析的」,不是「第一把」。要的是 「恰好一把」就用 ParseSinglePublicKey。

func ParseSinglePublicKey added in v0.6.1

func ParseSinglePublicKey(publicKey string) (ssh.PublicKey, error)

ParseSinglePublicKey 解析**恰好一把** SSH 公钥。

它拒掉 ParsePublicKey 容忍的两种输入:

  • **多行**:ssh.ParseAuthorizedKey 是逐行扫描的,解析不了的行被跳过。 于是「第一行 ed25519、第二行 rsa」会被判成 rsa——而调用方接下来通常 是把**整个字符串**存下去,堡垒机存的是两把钥匙,且会安静地接受那把 ed25519。**被检查的东西与被存下的东西不是同一个**,这类漏洞极难查。
  • **一行里塞了两把**:authorized_keys 是一行一把;两把挤进一行时, 第二把会落进 comment 的位置(comment 是自由文本,语法上与真正的 注释不可分)。判据是「comment 里还藏着一把能解析的公钥」——解析 得出,这一行实际存下的就不止一把。

说清它为什么比 ParsePublicKey 严:ParsePublicKey 回答的是「这里面有 一把能用的钥匙吗」,本函数回答的是「这里面**只有**一把能用的钥匙吗」。 要把这段字符串原样存进某个系统时,后者才是该问的那个。

func PublicKeyType

func PublicKeyType(publicKey string) string

PublicKeyType 报告一行 SSH 公钥的类型("ssh-rsa" / "ssh-ed25519" / "ecdsa-sha2-nistp256" 这类)。

解析不了就退回首字段——「这是什么类型」这个问题即使公钥本身是坏的也 答得出来,而调用方要的往往正是那句「你给的是 ed25519,而这里只收 ssh-rsa」。空串表示这一行什么都没说。

func StartKeepAlive

func StartKeepAlive(poke func() error, done <-chan struct{}, onDead func(), interval time.Duration)

StartKeepAlive 定期戳连接,直到 done 被关掉。

poke 发出这个请求并报告发生了什么:ErrBusy 表示连接正忙着(跳过这一 下),别的错误表示连接没了。onDead 在那时被调用——它是调用方自己的信号: 把连接标记为坏掉,下次调用开一条新的。

interval 是参数,这样测试不必等半分钟才看到一次保活。零值即默认。

它在返回之前不等待:保活是后台的,done 关掉之后 goroutine 自己退出。

Types

type DialOptions

type DialOptions struct {
	// Connect 界 TCP 连接。零值即 DefaultConnectTimeout。
	Connect time.Duration
	// Handshake 界 SSH 握手。零值即 DefaultHandshakeTimeout。
	Handshake time.Duration
	// Budget 是调用方给这次调用的**总预算**(#106):锚定在 Dial 入口,
	// 压住的是「连接 + 握手」的**总和**——连接阶段花掉的每一毫秒都从
	// 预算里扣,握手窗口按剩余预算夹取。给了两秒的调用方不该坐在一个
	// 五秒的握手里等一个自己不会用的结果,也不该等 2×预算。
	Budget time.Duration
	// contains filtered or unexported fields
}

DialOptions 界住打开一次连接的过程。

三个值都是零值即默认(见各自的默认常量)。它们是参数而不是常量,这样 测试才能用短的值:要等五秒才看得到一次超时的测试,没人跑。

type DialPolicy added in v0.9.0

type DialPolicy struct {
	// Retries 预算内的重拨次数上限(总尝试 = 1 + Retries)。零值即默认 1。
	Retries int
	// Retryable 判定一次失败是否值得重拨。nil = 默认只认 ErrHandshakeTimeout
	// ——超时是抖动,「连接从未建立」的前提下重拨安全;拒绝类
	// (ErrHandshakeRejected)必须由调用方显式放行才重拨:凭据不对是判定
	// 不是抖动,重拨只是多打错凭据、把真错误盖掉。
	Retryable func(error) bool
	// Backoff 第 attempt 次重拨(从 1 起算)前的等待;nil = 立即重拨。等待
	// 与尝试共享同一份总预算与 ctx 截止时间:等待被夹到剩余预算/截止时间内,
	// 不因重拨放大。
	Backoff func(attempt int) time.Duration
}

DialPolicy 决定哪类失败可以重拨、拨几次、预算怎么花(#125)。

零值可用:重拨 1 次、只认 ErrHandshakeTimeout、立即重拨——即「预算内的 握手超时重拨一次」这条下游实证出来的默认策略。

type HandshakeRejectedError added in v0.7.14

type HandshakeRejectedError struct {
	// Addr 是对端地址。
	Addr string
	// Err 是底层握手错误(x/crypto/ssh 包装过的原因链)。
	Err error
}

HandshakeRejectedError 是 Dial 的「握手被拒绝」失败(#116)。

errors.As 可判;errors.Is(err, ErrHandshakeRejected) 同样成立。措辞与 既有文本逐字一致:

the SSH handshake with <addr> failed: <原因链>

Unwrap 保留原因链(拒绝本身在链里通常是 "unable to authenticate")。

func (*HandshakeRejectedError) Error added in v0.7.14

func (e *HandshakeRejectedError) Error() string

func (*HandshakeRejectedError) Is added in v0.7.14

func (e *HandshakeRejectedError) Is(target error) bool

Is 让 errors.Is(err, ErrHandshakeRejected) 成立。

func (*HandshakeRejectedError) Unwrap added in v0.7.14

func (e *HandshakeRejectedError) Unwrap() error

type HandshakeTimeoutError added in v0.7.14

type HandshakeTimeoutError struct {
	// Addr 是对端地址。
	Addr string
	// Bound 是这次握手实际生效的界限(连接与预算/ctx 截止夹取之后的值)。
	Bound time.Duration
	// Err 是底层握手错误(x/crypto/ssh 包装过的原因链)。
	Err error
}

HandshakeTimeoutError 是 Dial 的「握手超时」失败(#116)。

errors.As 可判;errors.Is(err, ErrHandshakeTimeout) 同样成立。措辞与 既有文本逐字一致(兼容只做字符串匹配的既有调用方):

the SSH handshake with <addr> did not finish within <bound>: <原因链>

Unwrap 保留原因链——对链上的 net.Error 做 errors.As(以及取 os 权限 错误等)都照旧可用。

func (*HandshakeTimeoutError) Error added in v0.7.14

func (e *HandshakeTimeoutError) Error() string

func (*HandshakeTimeoutError) Is added in v0.7.14

func (e *HandshakeTimeoutError) Is(target error) bool

Is 让 errors.Is(err, ErrHandshakeTimeout) 成立。

func (*HandshakeTimeoutError) Unwrap added in v0.7.14

func (e *HandshakeTimeoutError) Unwrap() error

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL