Part 6: SSH/终端模块超深度逐行分析
AutoK3s v0.9.3 分析范围:pkg/hosts/executor.go、pkg/hosts/dialer/ssh.go、pkg/hosts/dialer/pty.go、pkg/hosts/dialer/docker.go、pkg/hosts/websocket.go 以及关联文件:pkg/utils/ssh.go、pkg/server/store/websocket/ssh/ssh.go、pkg/server/store/websocket/kubectl/kubectl.go、pkg/cluster/cluster.go
目录
1. 模块定位
1.1 系统位置
SSH/终端模块是 AutoK3s 中所有远程节点操作的核心通道。它处于系统中间层,向上服务于集群编排和 Web 终端,向下封装不同传输协议(SSH / Docker exec /本地 PTY)。
┌──────────────────────────────────────────────────────────────┐
│ 调用方层 │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ cluster.go │ │ server/ │ │ providers/ │ │
│ │ execute() │ │ websocket/ │ │ tencent/alibaba/… │ │
│ │ │ │ ssh/kubectl │ │ │ │
│ └──────┬──────┘ └──────┬───────┘ └──────────┬───────────┘ │
├─────────┼────────────────┼─────────────────────┼─────────────┤
│ │ 接口层 (hosts package) │ │
│ ┌──────▼──────────────────────────────────────▼──────────┐ │
│ │ Script 接口 Shell 接口 WebSocketDialer │ │
│ │ (ExecuteCommands) (OpenShell/ (终端IO桥接) │ │
│ │ Terminal) │ │
│ └──────┬──────────────────────────────────────┬──────────┘ │
├─────────┼──────────────────────────────────────┼─────────────┤
│ │ 实现层 (dialer package) │ │
│ ┌──────▼──────┐ ┌──────────┐ ┌─────────────▼──────────┐ │
│ │ SSHDialer │ │ PtyShell │ │ DockerShell │ │
│ │ SSHShell │ │ │ │ │ │
│ └─────────────┘ └──────────┘ └────────────────────────┘ │
├──────────────────────────────────────────────────────────────┤
│ 传输层 │
│ ┌─────────────┐ ┌──────────┐ ┌────────────────────────┐ │
│ │ golang.org/ │ │ creack/ │ │ docker/docker/client │ │
│ │ x/crypto/ssh│ │ pty │ │ │ │
│ └─────────────┘ └──────────┘ └────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
1.2 被调场景全景
| cluster.ProviderBase.execute() | dialer.NewSSHDialer() → ExecuteCommands() → Close() | Script |
| cluster.ProviderBase.executeWithRetry() | 包裹 execute(),额外2s×count次重试 | Script |
| cluster.terminal() | NewSSHDialer() → OpenShell() → SetIO() → Terminal() | Shell |
| cluster.base.ReleaseManifests() | NewSSHDialer() → ExecuteCommands() → Close() | Script |
| server/websocket/ssh | NewSSHDialer()/NewDockerShell() → OpenShell() → NewWebSocketDialer() → Terminal() → ReadMessage() | Shell + WebSocket |
| server/websocket/kubectl | NewPtyShell() → NewWebSocketDialer() → Terminal() → Write() → ReadMessage() | Shell + WebSocket |
| providers/tencent | NewSSHDialer() → ExecuteCommands() | Script |
| providers/alibaba | NewSSHDialer() → ExecuteCommands() | Script |
| providers/k3d | NewDockerShell() → Terminal() | Shell |
| airgap/file_scp.go | ExecuteCommands() (通过传入的 hosts.Script 接口) | Script |
2. 模块整体结构
2.1 Script 接口定义 (executor.go)
package hosts
type Script interface {
ExecuteCommands(cmds …string) (output string, err error)
Close() error
}
仅 6 行,定义了命令执行器的最小契约:
- ExecuteCommands:可变参数接收多条命令,返回合并输出和错误
- Close:释放连接资源
实现者: SSHDialer(通过 var _ hosts.Script = &SSHDialer{} 编译期断言)
2.2 Shell 接口定义 (websocket.go)
type Shell interface {
SetIO(stdout, stderr io.Writer, stdin io.ReadCloser)
ChangeWindowSize(win ShellWindowSize) error
Terminal() error
OpenTerminal(win ShellWindowSize) error
Wait() error
Write(b []byte) error
Close() error
}
8 个方法,定义了交互终端的完整契约:
| SetIO | 设置 stdout/stderr/stdin 三个 IO 流 |
| ChangeWindowSize | 动态调整终端窗口大小 |
| Terminal | CLI 模式启动终端(MakeRaw + 阻塞等待) |
| OpenTerminal | WebSocket 模式启动终端(非阻塞) |
| Wait | 等待远程命令退出 |
| Write | 向终端写入数据 |
| Close | 关闭终端会话 |
实现者:
- SSHShell — var _ hosts.Shell = &SSHShell{}
- PtyShell — var _ hosts.Shell = &PtyShell{}
- DockerShell — var _ hosts.Shell = &DockerShell{}
2.3 ShellWindowSize 结构体
type ShellWindowSize struct {
Width int
Height int
}
WebSocket 通信中的窗口尺寸载体,JSON 序列化后在 WebSocket TextMessage 中传输。
2.4 SSHDialer 结构体
type SSHDialer struct {
sshKey string // SSH私钥内容(非路径,已读取)
sshCert string // SSH证书内容(非路径,已读取)
sshAddress string // 连接地址 IP:Port 或 InstanceID
username string // SSH用户名
password string // SSH密码
passphrase string // SSH私钥密码短语
useSSHAgentAuth bool // 是否使用SSH Agent认证
conn *ssh.Client // 底层SSH客户端连接
uid int // 远程用户UID(懒加载)
logger *logrus.Logger // 日志器
shells map[hosts.Shell]hosts.Shell // 活跃Shell会话集合
}
字段分析:
- sshKey/sshCert:存储的是文件内容而非路径,在 NewSSHDialer 中通过 utils.SSHPrivateKeyPath() 读取
- sshAddress:优先使用 PublicIPAddress[0]:SSHPort,无IP时退化为 InstanceID
- conn:ssh.Dial() 返回的持久连接,ExecuteCommands 和 OpenShell 都基于它创建 Session
- uid:初始值 -1,首次 ExecuteCommands 时通过 id -u 命令懒加载,用于判断是否需要 sudo
- shells:用 map 跟踪所有打开的 Shell,Close() 时遍历关闭
2.5 SSHShell 结构体
type SSHShell struct {
Stdin io.ReadCloser // 标准输入流
Stdout io.Writer // 标准输出流
Stderr io.Writer // 标准错误流
Writer io.Writer // 日志写入器(未在核心流程中使用)
Term string // 终端类型(xterm/环境变量TERM)
Modes ssh.TerminalModes // 终端模式参数
session *ssh.Session // 底层SSH会话
dialer *SSHDialer // 所属Dialer引用
}
2.6 DockerShell 结构体
type DockerShell struct {
execID string // Docker exec ID
Stdin io.ReadCloser // 标准输入流
Stdout io.Writer // 标准输出流
Stderr io.Writer // 标准错误流
Writer io.Writer // 日志写入器
ctx context.Context // 上下文
client client.APIClient // Docker API客户端
response *dockertypes.HijackedResponse // hijacked连接(stdin/stdout共用)
}
关键设计: DockerShell 同时实现 Shell 接口,但不实现 Script 接口——Docker 模式仅支持交互终端,不支持批量命令执行。
2.7 PtyShell 结构体
type PtyShell struct {
Stdin io.ReadCloser
Stdout io.Writer
Stderr io.Writer
Writer io.Writer
ctx context.Context
conn *os.File // PTY master端
cmd *exec.Cmd // 本地命令
}
构建约束: //go:build darwin || linux — 仅支持 macOS 和 Linux。
2.8 WebSocketDialer 结构体
type WebSocketDialer struct {
dialer Shell // 底层Shell实现(SSHShell/PtyShell/DockerShell)
conn *websocket.Conn // WebSocket连接
reader *TerminalReader // 终端读取器
}
核心职责: 将 WebSocket 连接桥接到任意 Shell 实现,是 Web 终端的适配层。
2.9 辅助结构体
TerminalReader
type TerminalReader struct {
conn *websocket.Conn // WebSocket连接
reader io.Reader // 当前消息reader
resize func(size ShellWindowSize) // 窗口大小调整回调
ClosedCh chan bool // 关闭信号通道
}
BinaryWriter
type BinaryWriter struct {
conn *websocket.Conn
}
singleWriter
type singleWriter struct {
b io.Writer
mu sync.Mutex
}
线程安全的 Writer 包装器,将 logrus.Out 和输出 buffer 同时写入——既记录日志又捕获输出。
3. 核心业务逻辑深度解析
3.1 NewSSHDialer() — SSH连接建立全流程
func NewSSHDialer(n *types.Node, timeout bool, logger *logrus.Logger) (*SSHDialer, error)
逐行解析:
Step 1: 参数校验
if len(n.PublicIPAddress) <= 0 && n.InstanceID == "" {
return nil, errors.New("[ssh-dialer] no node IP or node ID is specified")
}
- 必须有公网IP或实例ID,否则无法建立连接
if logger == nil {
logger = logrus.StandardLogger()
}
- logger 为 nil 时降级到标准 logger
Step 2: 初始化 Dialer 结构体
d := &SSHDialer{
username: n.SSHUser,
password: n.SSHPassword,
passphrase: n.SSHKeyPassphrase,
useSSHAgentAuth: n.SSHAgentAuth,
sshCert: n.SSHCert,
logger: logger,
shells: map[hosts.Shell]hosts.Shell{},
uid: –1,
}
- uid: -1 标记为"未获取",后续 getUserID() 时判断
- shells 初始化为空 map
Step 3: 确定连接地址
if len(n.PublicIPAddress) > 0 {
d.sshAddress = fmt.Sprintf("%s:%s", n.PublicIPAddress[0], n.SSHPort)
} else {
d.sshAddress = n.InstanceID
}
- 优先使用公网IP + 端口(默认22)
- 无IP时使用 InstanceID(这在云提供商场景中可能被代理处理)
Step 4: 加载私钥和证书
if d.password == "" && d.sshKey == "" && !d.useSSHAgentAuth && len(n.SSHKeyPath) > 0 {
var err error
d.sshKey, err = utils.SSHPrivateKeyPath(n.SSHKeyPath)
if err != nil {
return nil, err
}
if d.sshCert == "" && len(n.SSHCertPath) > 0 {
d.sshCert, err = utils.SSHCertificatePath(n.SSHCertPath)
if err != nil {
return nil, err
}
}
}
认证优先级逻辑:
Step 5: Backoff 重试连接
try := 0
if err := wait.ExponentialBackoff(defaultBackoff, func() (bool, error) {
try++
logger.Infof("the %d/%d time tring to ssh to %s with user %s", try, defaultBackoff.Steps, d.sshAddress, d.username)
c, err := d.Dial(timeout)
if err != nil {
return false, nil // 返回 nil 而非 err —— 不中断重试
}
d.conn = c
return true, nil
}); err != nil {
return nil, fmt.Errorf("[ssh-dialer] init dialer [%s] error: %w", d.sshAddress, err)
}
重试参数(defaultBackoff):
var defaultBackoff = wait.Backoff{
Duration: 15 * time.Second, // 每次间隔15秒
Factor: 1, // 因子为1(固定间隔,非指数增长)
Steps: 5, // 最多5次
}
- 总最大等待时间: 5 × 15s = 75s
- Factor=1 意味着每次间隔不变,都是 15s(虽然名为 ExponentialBackoff,但 Factor=1 时退化为固定间隔)
- 关键设计: Dial() 失败时返回 (false, nil) 而非 (false, err),这确保 ExponentialBackoff 会继续重试而非提前终止。只有超过5次都失败,ExponentialBackoff 返回 wait.ErrWaitTimeout,被包装为 [ssh-dialer] init dialer 错误
3.2 Dial() — SSH握手
func (d *SSHDialer) Dial(t bool) (*ssh.Client, error) {
timeout := defaultBackoff.Duration // 15s
if !t {
timeout = 0 // 无超时
}
cfg, err := utils.GetSSHConfig(d.username, d.sshKey, d.passphrase, d.sshCert, d.password, timeout, d.useSSHAgentAuth)
if err != nil {
return nil, err
}
return ssh.Dial("tcp", d.sshAddress, cfg)
}
timeout 参数语义:
- t=true(绝大多数调用场景):连接超时 = 15s
- t=false:连接超时 = 0(无限等待)
utils.GetSSHConfig() 认证配置详解:
func GetSSHConfig(username, sshPrivateKeyString, passphrase, sshCert string, password string, timeout time.Duration, useAgentAuth bool) (*ssh.ClientConfig, error) {
config := &ssh.ClientConfig{
User: username,
Timeout: timeout,
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // ⚠️ 忽略主机密钥验证
}
// … 认证方式分支 …
}
⚠️ 安全注意: HostKeyCallback: ssh.InsecureIgnoreHostKey() 跳过主机密钥验证,存在中间人攻击风险,但在自动化运维场景中这是常见取舍。
认证方式分支(按优先级):
| 1 | useAgentAuth=true 且 SSH_AUTH_SOCK 环境变量存在 | SSH Agent | ssh.PublicKeysCallback(agent.NewClient(sshAgent).Signers) |
| 2 | sshPrivateKeyString != "" 且 passphrase != "" | 私钥+密码短语 | ssh.ParsePrivateKeyWithPassphrase() → ssh.PublicKeys(signer) |
| 3 | sshPrivateKeyString != "" 且 passphrase == "" | 私钥 | ssh.ParsePrivateKey() → ssh.PublicKeys(signer) |
| 4 | password != "" | 密码 | ssh.Password(password) |
证书处理(在私钥认证分支内):
if len(sshCert) > 0 {
key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(sshCert))
if _, ok := key.(*ssh.Certificate); !ok {
return config, fmt.Errorf("unable to cast public key to SSH certificate")
}
signer, err = ssh.NewCertSigner(key.(*ssh.Certificate), signer)
}
- 证书认证叠加在私钥认证之上
- 将已解析的 signer 升级为 certSigner
3.3 getUserID() — 远程UID获取
func (d *SSHDialer) getUserID() error {
if d.uid >= 0 {
return nil // 已缓存,直接返回
}
session, err := d.conn.NewSession()
if err != nil {
return err
}
defer func() { _ = session.Close() }()
output, err := session.Output("id -u")
if err != nil {
return fmt.Errorf("failed to get current user id from remote host %s, %v", d.sshAddress, err)
}
d.uid, err = strconv.Atoi(strings.TrimSpace(string(output)))
if err != nil {
return fmt.Errorf("failed to parse uid output from remote host, output: %s, %v", string(output), err)
}
return nil
}
设计要点:
- 懒加载:首次调用 ExecuteCommands 时触发
- 缓存:后续调用直接返回,避免重复 id -u 命令
- session.Output() 是便捷方法:创建session → 运行命令 → 捕获stdout → 关闭session
- TrimSpace 处理输出中的换行符
3.4 ExecuteCommands() — 远程命令执行
func (d *SSHDialer) ExecuteCommands(cmds …string) (string, error) {
// Step 1: 获取用户ID(判断是否需要sudo)
if err := d.getUserID(); err != nil {
return "", err
}
// Step 2: 确定sudo前缀
sudo := ""
if d.uid > 0 {
sudo = "sudo"
}
// Step 3: 创建新Session
session, err := d.conn.NewSession()
if err != nil {
return "", err
}
defer session.Close()
// Step 4: 命令包装(base64编码)
encodedCMD := d.wrapCommands(strings.Join(cmds, "\\n"))
cmd := fmt.Sprintf("echo \\"%s\\" | base64 -d | %s sh -", encodedCMD, sudo)
d.logger.Debugf("executing cmd: %s", cmd)
// Step 5: 输出捕获
output := bytes.NewBuffer([]byte{})
combinedOutput := singleWriter{
b: io.MultiWriter(d.logger.Out, output),
}
session.Stderr = &combinedOutput
session.Stdout = &combinedOutput
// Step 6: 执行
err = session.Run(cmd)
return output.String(), err
}
逐层深度解析:
Step 1-2: sudo 判断逻辑
if d.uid > 0 {
sudo = "sudo"
}
- uid == 0 → root 用户,不需要 sudo
- uid > 0 → 普通用户,需要 sudo
- uid == -1 → getUserID() 会先执行获取
Step 3: Session 生命周期
每次 ExecuteCommands 创建独立的 Session,通过 defer session.Close() 确保释放。这与 OpenShell 的持久会话不同。
Step 4: 命令包装机制(关键设计!)
const scriptWrapper = `#!/bin/sh
set -e
%s
`
func (d *SSHDialer) wrapCommands(cmd string) string {
return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(scriptWrapper, cmd)))
}
最终执行的命令格式:
echo "<base64编码>" | base64 -d | sudo sh –
为什么用 base64 包装?
示例: 执行 mkdir -p /etc/k3s 和 cat /etc/k3s/config.yaml
原始: mkdir -p /etc/k3s\\ncat /etc/k3s/config.yaml
包装: #!/bin/sh\\nset -e\\nmkdir -p /etc/k3s\\ncat /etc/k3s/config.yaml
编码: IyEvYmluL3NoCnNldCAtZQpta2RpciAtcCAvZXRjL2szcwpjYXQgL2V0Yy9rM3MvY29uZmlnLnlhbWwK
最终: echo "IyEvYmluL3NoCnNldCAtZQpta2RpciAtcCAvZXRjL2szcwpjYXQgL2V0Yy9rM3MvY29uZmlnLnlhbWwK" | base64 -d | sudo sh –
Step 5: 输出捕获架构
output := bytes.NewBuffer([]byte{})
combinedOutput := singleWriter{
b: io.MultiWriter(d.logger.Out, output),
}
session.Stderr = &combinedOutput
session.Stdout = &combinedOutput
数据流:
远程命令 stdout ─┐
├─→ singleWriter ─→ io.MultiWriter ─┬─→ logrus.Out (日志)
远程命令 stderr ─┘ └─→ output buffer (返回值)
- singleWriter 用 sync.Mutex 保证并发安全
- io.MultiWriter 实现 tee:同时写日志和缓冲区
- stdout 和 stderr 共用同一个 writer → 输出合并
- session.Run() 阻塞直到命令完成
Step 6: 返回值
err = session.Run(cmd)
return output.String(), err
- 返回合并的 stdout+stderr 文本
- session.Run() 在命令非零退出码时返回 *ssh.ExitError
3.5 OpenShell() — 打开交互终端会话
func (d *SSHDialer) OpenShell() (hosts.Shell, error) {
shell := &SSHShell{
dialer: d,
}
session, err := d.conn.NewSession()
if err != nil {
return nil, err
}
shell.session = session
d.shells[shell] = shell
return shell, nil
}
与 ExecuteCommands 的区别:
- 不执行命令,仅创建持久 Session
- 返回 Shell 接口,调用方后续通过 SetIO + Terminal 启动终端
- 注册到 d.shells map,Dialer Close 时统一清理
3.6 SSHShell.Terminal() — CLI 交互终端
func (d *SSHShell) Terminal() error {
var win hosts.ShellWindowSize
fdInfo, _ := term.GetFdInfo(d.Stdout)
fd := int(fdInfo)
// Step 1: 保存当前终端状态并切换到 raw 模式
oldState, err := terminal.MakeRaw(fd)
defer func() {
_ = terminal.Restore(fd, oldState)
}()
if err != nil {
return err
}
// Step 2: 获取当前终端窗口大小
win.Width, win.Height, err = terminal.GetSize(fd)
if err != nil {
return err
}
// Step 3: 打开远程终端
if err := d.OpenTerminal(win); err != nil {
return err
}
// Step 4: 阻塞等待会话结束
return d.session.Wait()
}
Terminal vs OpenTerminal 的区别:
| 调用场景 | CLI 直接操作 | WebSocket 交互 |
| MakeRaw | ✅ 修改本地终端为 raw 模式 | ❌ 不修改 |
| 获取窗口大小 | 从本地 fd 获取 | 由参数传入 |
| 阻塞 | session.Wait() 阻塞 | 不阻塞 |
| 恢复终端 | defer Restore | 不需要恢复 |
3.7 SSHShell.OpenTerminal() — 终端模式设置
func (d *SSHShell) OpenTerminal(win hosts.ShellWindowSize) error {
// Step 1: 设置终端类型
d.Term = os.Getenv("TERM")
if d.Term == "" {
d.Term = "xterm"
}
// Step 2: 设置终端模式
d.Modes = ssh.TerminalModes{
ssh.ECHO: 1, // 回显启用
ssh.TTY_OP_ISPEED: 14400, // 输入速度 14400 baud
ssh.TTY_OP_OSPEED: 14400, // 输出速度 14400 baud
}
// Step 3: 绑定 IO 流
d.session.Stdin = d.Stdin
d.session.Stdout = d.Stdout
d.session.Stderr = d.Stderr
// Step 4: 请求 PTY
if err := d.session.RequestPty(d.Term, win.Height, win.Width, d.Modes); err != nil {
return err
}
// Step 5: 启动 Shell
return d.session.Shell()
}
终端模式参数:
- ECHO: 1 — 启用字符回显
- TTY_OP_ISPEED/OSPEED: 14400 — 波特率设置(历史遗留参数,现代网络中无实际意义但SSH协议要求)
请求顺序: RequestPty → Shell。这是 SSH 协议的标准流程:先协商终端参数,再启动用户 shell。
3.8 SSHShell.Close()
func (d *SSHShell) Close() error {
defer delete(d.dialer.shells, d)
if d.session != nil {
if err := d.session.Close(); err != nil {
return err
}
}
return nil
}
- 先从 dialer 的 shells map 中移除自身
- 再关闭 SSH session
- 注意 defer delete 确保即使 Close 失败也会从 map 移除
3.9 SSHDialer.Close()
func (d *SSHDialer) Close() error {
if d.conn != nil {
return d.conn.Close()
}
for _, shell := range d.shells {
shell.Close()
delete(d.shells, shell)
}
return nil
}
设计缺陷分析:
- 当 conn != nil 时,直接关闭连接但不关闭已打开的 shells
- 当 conn == nil 时,遍历关闭 shells
- 实际上 conn 在 NewSSHDialer 成功后永远非 nil,所以 shells 清理分支几乎不会执行
- 但 conn.Close() 会隐式关闭所有基于该连接的 Session,所以功能上是正确的
3.10 Backoff 重试机制详解
系统中有两层重试:
第一层:SSH连接重试(NewSSHDialer 内部)
var defaultBackoff = wait.Backoff{
Duration: 15 * time.Second,
Factor: 1,
Steps: 5,
}
- 间隔:15s(固定)
- 次数:5次
- 总时间:75s
- 场景:SSH连接建立失败(网络不可达、认证失败等)
第二层:命令执行重试(executeWithRetry)
func (p *ProviderBase) executeWithRetry(count int, n *types.Node, cmds …string) (string, error) {
backoff := wait.Backoff{
Duration: 2 * time.Second,
Factor: 1,
Steps: count,
}
// … 每次重试都重新创建 SSHDialer …
}
- 间隔:2s(固定)
- 次数:由调用方指定
- 场景:命令执行失败
第三层:Docker连接重试(NewDockerShell 内部)
// 使用 common.Backoff
Backoff = wait.Backoff{
Duration: 30 * time.Second,
Factor: 1,
Steps: 20,
}
- 间隔:30s(固定)
- 次数:20次
- 总时间:600s(10分钟!)
- 场景:Docker exec 创建/附加失败
3.11 DockerDialer — Docker exec 方式连接容器
NewDockerShell() 全流程
func NewDockerShell(n *types.Node) (*DockerShell, error) {
Step 1: 参数校验
if n.InstanceID == "" {
return nil, errors.New("[docker-dialer] no container ID is specified")
}
Docker 模式必须有容器ID。
Step 2: 创建 Docker 客户端
d := &DockerShell{ctx: context.Background()}
docker, err := dockerutils.GetDockerClient()
使用 k3d 的 Docker 工具函数获取 Docker 客户端。
Step 3: 查找容器
f := filters.NewArgs()
f.Add("name", fmt.Sprintf("^/?(%s-)?%s$", k3dtypes.DefaultObjectNamePrefix, n.InstanceID))
containers, err := docker.ContainerList(d.ctx, dockertypes.ContainerListOptions{
Filters: f,
All: true,
})
容器名匹配正则: ^/?(k3d-)?<InstanceID>$
- 容器名以 / 开头(Docker惯例)
- 可能有 k3d- 前缀
- 核心匹配 InstanceID
Step 4: 容器校验
if len(containers) > 1 {
return nil, fmt.Errorf("…found: %d", len(containers))
}
if len(containers) == 0 {
return nil, fmt.Errorf("…didn't find container for node '%s'", n.InstanceID)
}
必须恰好找到1个容器。
Step 5: 创建 exec + 附加连接(带重试)
if err := wait.ExponentialBackoff(common.Backoff, func() (bool, error) {
exec, err := docker.ContainerExecCreate(d.ctx, container.ID, dockertypes.ExecConfig{
Privileged: true,
Tty: true,
AttachStdin: true,
AttachStderr: true,
AttachStdout: true,
Cmd: []string{"/bin/sh"},
})
if err != nil {
return false, err // ⚠️ 返回 err,会中断重试!
}
execAttach, err := docker.ContainerExecAttach(d.ctx, exec.ID, dockertypes.ExecStartCheck{
Tty: true,
})
if err != nil {
return false, err // ⚠️ 返回 err,会中断重试!
}
d.execID = exec.ID
d.response = &execAttach
return true, nil
}); err != nil { … }
⚠️ 与 SSHDialer 的关键差异:
- SSHDialer 重试中 return false, nil(不返回err)→ 总会重试满5次
- DockerDialer 重试中 return false, err(返回err)→ 首次错误就中断重试
- 这意味着 DockerDialer 的 common.Backoff(20次重试)实际上是无效的——任何错误都会立即终止
Exec 配置:
- Privileged: true — 特权模式
- Tty: true — 分配 TTY
- Cmd: ["/bin/sh"] — 启动 sh
DockerShell.Terminal() — CLI 终端
func (d *DockerShell) Terminal() error {
defer func() { _ = d.Close() }()
if err := d.ExecStart(true); err != nil {
return err
}
fd, _ := term.GetFdInfo(d.Stderr)
if term.IsTerminal(fd) {
if err := d.MonitorTtySize(d.ctx); err != nil {
logrus.Errorf("[docker-dialer] error monitoring tty size: %s", err.Error())
}
}
if err := d.Wait(); err != nil {
if !errors.Is(err, io.EOF) {
return err
}
}
return nil
}
流程:
DockerShell.ExecStart() — IO 流劫持
这是 DockerShell 的核心,借鉴自 Docker CLI 的 hijack 实现:
func (d *DockerShell) ExecStart(needRestore bool) error {
// Step 1: 设置输入流(含 raw 模式和 escape 键检测)
restoreInput, err := d.setInput(needRestore)
if err != nil {
return fmt.Errorf("[docker-dialer] unable to setup input stream: %s", err)
}
defer restoreInput()
// Step 2: 启动输出和输入流的异步拷贝
errCh := make(chan error, 1)
go func() {
defer close(errCh)
errCh <- func() error {
outputDone := d.beginOutputStream(restoreInput) // goroutine: response.Reader → Stderr
inputDone, detached := d.beginInputStream(restoreInput) // goroutine: Stdin → response.Conn
// Step 3: 等待任一完成
select {
case err := <-outputDone:
return err
case <-inputDone:
// stdin关闭后,等待stdout完成
if d.Stdout != nil || d.Stderr != nil {
select {
case err := <-outputDone:
return err
case <-d.ctx.Done():
return d.ctx.Err()
}
}
return nil
case err := <-detached:
return err // 用户按了 ctrl-p ctrl-q
case <-d.ctx.Done():
return d.ctx.Err()
}
}()
}()
if err := <-errCh; err != nil {
return err
}
return nil
}
三个并发 goroutine 的协作:
┌─────────────────────────────────────────┐
│ ExecStart │
│ │
│ ┌─────────────────────────────────┐ │
│ │ beginOutputStream (goroutine) │ │
│ │ response.Reader → io.Copy → Stderr │ │
│ │ → outputDone chan │ │
│ └─────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ beginInputStream (goroutine) │ │
│ │ Stdin → io.Copy → response.Conn │ │
│ │ → inputDone chan │ │
│ │ → detached chan (if escape key) │ │
│ └─────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ select { … } (main goroutine) │ │
│ │ 等待最先完成的通道 │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────┘
DockerShell.setInput() — 输入流设置
func (d *DockerShell) setInput(needRestore bool) (restore func(), err error) {
if d.Stdin == nil {
return func() {}, nil
}
var restoreOnce sync.Once
if needRestore {
inFd, _ := term.GetFdInfo(d.Stdin)
outFd, _ := term.GetFdInfo(d.Stderr)
inState, _ := term.SetRawTerminal(inFd)
outState, _ := term.SetRawTerminalOutput(outFd)
restore = func() {
restoreOnce.Do(func() {
if outState != nil { _ = term.RestoreTerminal(inFd, outState) }
if inState != nil { _ = term.RestoreTerminal(outFd, inState) }
})
}
} else {
restore = func() {}
}
// 转义键检测(ctrl-p ctrl-q)
escapeKeys := defaultEscapeKeys // {16, 17}
d.Stdin = ioutils.NewReadCloserWrapper(
term.NewEscapeProxy(d.Stdin, escapeKeys), d.Stdin.Close)
return restore, nil
}
关键设计:
- sync.Once 确保终端恢复只执行一次
- term.NewEscapeProxy 监听 stdin 中的 escape 序列(ctrl-p, ctrl-q),触发 EscapeError → detached 通道
- needRestore=false 用于 WebSocket 模式(不需要修改本地终端)
DockerShell.MonitorTtySize() — 终端大小监控
func (d *DockerShell) MonitorTtySize(ctx context.Context) error {
ttyFunc := d.ResizeTty
// 立即执行一次resize
if err := ttyFunc(ctx); err != nil {
go func() {
var err error
for retry := 0; retry < 5; retry++ {
time.Sleep(10 * time.Millisecond)
if err = ttyFunc(ctx); err == nil { break }
}
}()
}
if runtime.GOOS == "windows" {
// Windows: 轮询 250ms 检查窗口变化
go func() { … 250ms 轮询 … }()
} else {
// Unix: 监听 SIGWINCH 信号
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, dockerSig.SIGWINCH)
go func() {
for range sigChan {
_ = d.ResizeTty(ctx)
}
}()
}
return nil
}
平台差异处理:
- Unix:SIGWINCH 信号驱动,事件式
- Windows:250ms 轮询,对比式
3.12 PtyShell — 本地 PTY 终端
OpenTerminal()
func (d *PtyShell) OpenTerminal(win hosts.ShellWindowSize) error {
p, err := pty.StartWithSize(d.cmd, &pty.Winsize{
Rows: uint16(win.Height),
Cols: uint16(win.Width),
})
if err != nil {
return err
}
d.conn = p // PTY master fd
go func() {
_, _ = io.Copy(d.conn, d.Stdin) // stdin → pty master
}()
go func() {
_, _ = io.Copy(d.Stderr, d.conn) // pty master → stderr
}()
return nil
}
数据流:
WebSocket BinaryMessage → TerminalReader.Read() → Stdin → io.Copy → pty master → bash进程
↓
WebSocket ← BinaryWriter.Write() ← Stderr ← io.Copy ← pty master ← bash进程输出
Terminal() 直接返回错误:
func (d *PtyShell) Terminal() error {
return errors.New("pty terminal not supported")
}
PtyShell 不支持 CLI 模式,仅用于 WebSocket。
ChangeWindowSize()
func (d *PtyShell) ChangeWindowSize(win hosts.ShellWindowSize) error {
return pty.Setsize(d.conn, &pty.Winsize{
Rows: uint16(win.Height),
Cols: uint16(win.Width),
})
}
直接调用 creack/pty 库的 Setsize,通过 ioctl TIOCSWINSZ 修改 PTY 窗口大小。
3.13 WebSocket 终端处理
WebSocketDialer 初始化
func NewWebSocketDialer(conn *websocket.Conn, dialer Shell) *WebSocketDialer {
d := &WebSocketDialer{
conn: conn,
dialer: dialer,
}
r := NewTerminalReader(d.conn)
r.SetResizeFunction(d.ChangeWindowSize)
w := NewBinaryWriter(d.conn)
d.reader = r
d.dialer.SetIO(w, w, r) // stdout=w, stderr=w, stdin=r
return d
}
IO 绑定关系:
WebSocket → TerminalReader (stdin) → Shell
WebSocket ← BinaryWriter (stdout/stderr) ← Shell
- BinaryWriter 同时作为 stdout 和 stderr —— 远程输出统一写入 WebSocket
- TerminalReader 作为 stdin —— 从 WebSocket 读取输入
TerminalReader.Read() — WebSocket 消息分发
func (t *TerminalReader) Read(p []byte) (int, error) {
var msgType int
var err error
for {
if t.reader == nil {
// 从 WebSocket 读取下一条消息
msgType, t.reader, err = t.conn.NextReader()
if err != nil {
t.reader = nil
t.ClosedCh <- true
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseNoStatusReceived) {
return 0, io.EOF
}
return 0, err
}
}
switch msgType {
case websocket.TextMessage:
// 文本消息 = 窗口大小调整指令
body, e := ioutil.ReadAll(t.reader)
if e != nil { break }
r := ShellWindowSize{}
if err = json.Unmarshal(body, &r); err != nil { break }
if r.Width > 0 && r.Height > 0 {
t.resize(r) // 触发 ChangeWindowSize
}
case websocket.BinaryMessage:
// 二进制消息 = 终端输入数据
n, readErr := t.reader.Read(p)
return n, convert(readErr)
}
t.reader = nil // 消费完毕,重置以读取下一条
}
}
消息协议设计:
| TextMessage | {"Width": 80, "Height": 24} | 调整终端窗口大小 |
| BinaryMessage | 原始字节 | 终端输入数据 |
循环逻辑: TextMessage 处理后不返回,继续循环读下一条消息;BinaryMessage 返回数据给 Shell。这确保了窗口调整指令不会混入终端输入流。
BinaryWriter.Write()
func (s *BinaryWriter) Write(p []byte) (int, error) {
w, err := s.conn.NextWriter(websocket.BinaryMessage)
if err != nil {
return 0, convert(err)
}
defer func() { _ = w.Close() }()
var n int
if len(p) != 0 {
n, err = w.Write(p)
}
return n, err
}
- 每次写入创建一个新的 WebSocket 消息帧
- defer w.Close() 刷新帧
- 空切片不写入但仍创建帧
readMessage() — 会话生命周期管理
func readMessage(ctx context.Context, con *websocket.Conn, closeSession func(), wait func() error, stop chan bool) error {
sessionClosed := make(chan error, 1)
go func() {
sessionClosed <- wait() // 等待 Shell 会话结束
}()
for {
select {
case <-ctx.Done():
closeSession()
return nil
case <-sessionClosed:
closeSession()
close(sessionClosed)
_ = con.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "EOF"))
return nil
case isStop := <-stop:
if isStop {
closeSession()
close(stop)
return nil
}
}
}
}
三路 select 竞争:
| ctx.Done() | HTTP 上下文取消 | 客户端断开连接/请求超时 |
| sessionClosed | shell.Wait() 返回 | 远程 Shell 退出 |
| stop | TerminalReader.ClosedCh | WebSocket 读取错误/关闭 |
无论哪个触发,都会调用 closeSession() 关闭底层 Shell 连接。
convert() — 错误转换
func convert(err error) error {
if err == nil { return nil }
if e, ok := err.(*websocket.CloseError); ok && e.Code == websocket.CloseNormalClosure {
return io.EOF
}
return err
}
将 WebSocket 正常关闭错误转换为 io.EOF,使上层能优雅处理。
3.14 WebSocket SSH 处理器 (server/store/websocket/ssh/ssh.go)
func handler(apiOp *types.APIRequest) error {
// Step 1: 解析查询参数
provider := queryParams.Get("provider")
id := queryParams.Get("cluster")
node := queryParams.Get("node")
height := queryParams.Get("height") // 默认 150
width := queryParams.Get("width") // 默认 300
// Step 2: 升级 HTTP 到 WebSocket
upgrader.CheckOrigin = func(r *http.Request) bool { return true }
c, err := upgrader.Upgrade(apiOp.Response, apiOp.Request, nil)
// Step 3: 创建 Dialer
dialer, err := newDialer(id, node, c)
// Step 4: 启动终端
dialer.Terminal(rows, columns)
// Step 5: 消息循环
dialer.ReadMessage(apiOp.Context())
}
newDialer() 分支逻辑:
for _, n := range allNodes {
if n.InstanceID == node {
if state.Provider == "k3d" {
// K3D 提供商 → Docker exec
dialer, err := dialer.NewDockerShell(&n)
wsDialer = hosts.NewWebSocketDialer(conn, dialer)
} else {
// 其他提供商 → SSH
dialer, err := dialer.NewSSHDialer(&n, true, common.NewLogger(nil))
shell, err := dialer.OpenShell()
wsDialer = hosts.NewWebSocketDialer(conn, shell)
}
}
}
注意: SSH 模式下,NewWebSocketDialer 接收的是 SSHShell(通过 OpenShell() 获得),而非 SSHDialer。Docker 模式下接收的是 DockerShell 本身。
3.15 WebSocket Kubectl 处理器 (server/store/websocket/kubectl/kubectl.go)
func ptyHandler(apiOp *types.APIRequest) error {
// … 参数解析和 WebSocket 升级 …
dialer, err := dialer.NewPtyShell(exec.CommandContext(apiOp.Request.Context(), "bash"))
wsDialer := hosts.NewWebSocketDialer(c, dialer)
err = wsDialer.Terminal(rows, columns)
// 注入 kubectl alias
aliasCmd := fmt.Sprintf("alias kubectl='kubectl –context %s'\\n", apiOp.Name)
aliasCmd = fmt.Sprintf("%salias k='kubectl –context %s'\\n", aliasCmd, apiOp.Name)
err = wsDialer.Write([]byte(aliasCmd))
return wsDialer.ReadMessage(apiOp.Context())
}
特殊设计: 启动 bash 后立即注入两个 alias,让用户直接使用 kubectl / k 命令操作指定集群。
4. 认证流程图与重试机制
4.1 SSH 认证决策树
NewSSHDialer(n, timeout, logger)
│
├─ password != "" ?
│ └─ YES → 密码认证: ssh.Password(password)
│
├─ useSSHAgentAuth && SSH_AUTH_SOCK env exists ?
│ └─ YES → Agent认证: ssh.PublicKeysCallback(agent.Signers)
│
├─ sshKey != "" (已从文件读取) ?
│ │
│ ├─ passphrase != "" ?
│ │ └─ YES → 带密码短语私钥: ParsePrivateKeyWithPassphrase → PublicKeys(signer)
│ │
│ ├─ passphrase == "" ?
│ │ └─ YES → 普通私钥: ParsePrivateKey → PublicKeys(signer)
│ │
│ └─ sshCert != "" ?
│ └─ YES → 证书+私钥: NewCertSigner(cert, signer) → PublicKeys(certSigner)
│
└─ SSHKeyPath != "" ?
└─ YES → 读取文件 → 回到上面的私钥处理流程
实际优先级(在 GetSSHConfig 中):
在 NewSSHDialer 中的前置逻辑:
- 如果 password != "" → 不加载密钥文件
- 如果 useSSHAgentAuth → 不加载密钥文件
- 如果 sshKey 已有值 → 不加载密钥文件
- 只有以上都不满足且 SSHKeyPath != "" → 才从文件读取
4.2 重试机制全景
┌─────────────────────────────────────────────────────────────────┐
│ Layer 1: SSH 连接重试 (NewSSHDialer) │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Backoff: 15s × 5 = 75s max │ │
│ │ 策略: 固定间隔 (Factor=1) │ │
│ │ 行为: Dial失败 → (false, nil) → 继续重试 │ │
│ │ 超时: 15s/次 (timeout=true时) │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ Layer 2: 命令执行重试 (executeWithRetry) │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Backoff: 2s × count (调用方指定) │ │
│ │ 策略: 固定间隔 (Factor=1) │ │
│ │ 行为: 每次重试都重新创建 SSHDialer (含Layer1重试) │ │
│ │ 注意: Layer2 × Layer1 = 潜在 75s × count │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ Layer 3: Docker 连接重试 (NewDockerShell) │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Backoff: 30s × 20 = 600s max (10分钟!) │ │
│ │ 策略: 固定间隔 (Factor=1) │ │
│ │ 行为: err != nil → (false, err) → 立即中断重试 ⚠️ │ │
│ │ 实际效果: 首次失败即退出 │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
5. Mermaid 图集
5.1 SSH 连接流程图
#mermaid-svg-lw96VU5dglswd5Si{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-lw96VU5dglswd5Si .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-lw96VU5dglswd5Si .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-lw96VU5dglswd5Si .error-icon{fill:#552222;}#mermaid-svg-lw96VU5dglswd5Si .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-lw96VU5dglswd5Si .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-lw96VU5dglswd5Si .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-lw96VU5dglswd5Si .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-lw96VU5dglswd5Si .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-lw96VU5dglswd5Si .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-lw96VU5dglswd5Si .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-lw96VU5dglswd5Si .marker{fill:#333333;stroke:#333333;}#mermaid-svg-lw96VU5dglswd5Si .marker.cross{stroke:#333333;}#mermaid-svg-lw96VU5dglswd5Si svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-lw96VU5dglswd5Si p{margin:0;}#mermaid-svg-lw96VU5dglswd5Si .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-lw96VU5dglswd5Si .cluster-label text{fill:#333;}#mermaid-svg-lw96VU5dglswd5Si .cluster-label span{color:#333;}#mermaid-svg-lw96VU5dglswd5Si .cluster-label span p{background-color:transparent;}#mermaid-svg-lw96VU5dglswd5Si .label text,#mermaid-svg-lw96VU5dglswd5Si span{fill:#333;color:#333;}#mermaid-svg-lw96VU5dglswd5Si .node rect,#mermaid-svg-lw96VU5dglswd5Si .node circle,#mermaid-svg-lw96VU5dglswd5Si .node ellipse,#mermaid-svg-lw96VU5dglswd5Si .node polygon,#mermaid-svg-lw96VU5dglswd5Si .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-lw96VU5dglswd5Si .rough-node .label text,#mermaid-svg-lw96VU5dglswd5Si .node .label text,#mermaid-svg-lw96VU5dglswd5Si .image-shape .label,#mermaid-svg-lw96VU5dglswd5Si .icon-shape .label{text-anchor:middle;}#mermaid-svg-lw96VU5dglswd5Si .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-lw96VU5dglswd5Si .rough-node .label,#mermaid-svg-lw96VU5dglswd5Si .node .label,#mermaid-svg-lw96VU5dglswd5Si .image-shape .label,#mermaid-svg-lw96VU5dglswd5Si .icon-shape .label{text-align:center;}#mermaid-svg-lw96VU5dglswd5Si .node.clickable{cursor:pointer;}#mermaid-svg-lw96VU5dglswd5Si .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-lw96VU5dglswd5Si .arrowheadPath{fill:#333333;}#mermaid-svg-lw96VU5dglswd5Si .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-lw96VU5dglswd5Si .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-lw96VU5dglswd5Si .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lw96VU5dglswd5Si .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-lw96VU5dglswd5Si .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lw96VU5dglswd5Si .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-lw96VU5dglswd5Si .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-lw96VU5dglswd5Si .cluster text{fill:#333;}#mermaid-svg-lw96VU5dglswd5Si .cluster span{color:#333;}#mermaid-svg-lw96VU5dglswd5Si div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-lw96VU5dglswd5Si .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-lw96VU5dglswd5Si rect.text{fill:none;stroke-width:0;}#mermaid-svg-lw96VU5dglswd5Si .icon-shape,#mermaid-svg-lw96VU5dglswd5Si .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lw96VU5dglswd5Si .icon-shape p,#mermaid-svg-lw96VU5dglswd5Si .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-lw96VU5dglswd5Si .icon-shape .label rect,#mermaid-svg-lw96VU5dglswd5Si .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lw96VU5dglswd5Si .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-lw96VU5dglswd5Si .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-lw96VU5dglswd5Si :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
NO
YES
YES
NO
YES
YES
NO
NO
YES
NO
YES
NO
Dial
YES
NO
YES
YES
NO
YES
NO
NO
YES
NO
Dial(t)
timeout = 15s if t else 0
utils.GetSSHConfig()构建 ClientConfig
useAgentAuth ?
SSH Agent 认证
sshKey != '' ?
passphrase != '' ?
ParsePrivateKeyWithPassphrase
ParsePrivateKey
sshCert != '' ?
NewCertSigner
PublicKeys(signer)
password != '' ?
Password(password)
无认证方式
ssh.Dial tcp, sshAddress, cfg
NewSSHDialer(n, timeout, logger)
len(PublicIPAddress) > 0|| InstanceID != '' ?
return errorno node IP or node ID
初始化 SSHDialeruid=-1, shells={}
PublicIPAddress > 0 ?
sshAddress = IP:Port
sshAddress = InstanceID
password=='' && sshKey==''&& !useSSHAgentAuth&& SSHKeyPath != '' ?
utils.SSHPrivateKeyPath()读取私钥文件内容
sshCert=='' &&SSHCertPath != '' ?
utils.SSHCertificatePath()读取证书文件内容
进入 Backoff 重试循环
try++Dial(timeout)
ssh.Dial 成功 ?
d.conn = creturn dialer, nil
try < 5 ?
等待 15s
return errorinit dialer timeout
5.2 Script 与 Shell 接口类图
#mermaid-svg-CljTQwQgPywNhb6u{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-CljTQwQgPywNhb6u .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-CljTQwQgPywNhb6u .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-CljTQwQgPywNhb6u .error-icon{fill:#552222;}#mermaid-svg-CljTQwQgPywNhb6u .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-CljTQwQgPywNhb6u .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-CljTQwQgPywNhb6u .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-CljTQwQgPywNhb6u .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-CljTQwQgPywNhb6u .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-CljTQwQgPywNhb6u .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-CljTQwQgPywNhb6u .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-CljTQwQgPywNhb6u .marker{fill:#333333;stroke:#333333;}#mermaid-svg-CljTQwQgPywNhb6u .marker.cross{stroke:#333333;}#mermaid-svg-CljTQwQgPywNhb6u svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-CljTQwQgPywNhb6u p{margin:0;}#mermaid-svg-CljTQwQgPywNhb6u g.classGroup text{fill:#9370DB;stroke:none;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:10px;}#mermaid-svg-CljTQwQgPywNhb6u g.classGroup text .title{font-weight:bolder;}#mermaid-svg-CljTQwQgPywNhb6u .cluster-label text{fill:#333;}#mermaid-svg-CljTQwQgPywNhb6u .cluster-label span{color:#333;}#mermaid-svg-CljTQwQgPywNhb6u .cluster-label span p{background-color:transparent;}#mermaid-svg-CljTQwQgPywNhb6u .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-CljTQwQgPywNhb6u .cluster text{fill:#333;}#mermaid-svg-CljTQwQgPywNhb6u .cluster span{color:#333;}#mermaid-svg-CljTQwQgPywNhb6u .nodeLabel,#mermaid-svg-CljTQwQgPywNhb6u .edgeLabel{color:#131300;}#mermaid-svg-CljTQwQgPywNhb6u .edgeLabel .label rect{fill:#ECECFF;}#mermaid-svg-CljTQwQgPywNhb6u .label text{fill:#131300;}#mermaid-svg-CljTQwQgPywNhb6u .labelBkg{background:#ECECFF;}#mermaid-svg-CljTQwQgPywNhb6u .edgeLabel .label span{background:#ECECFF;}#mermaid-svg-CljTQwQgPywNhb6u .classTitle{font-weight:bolder;}#mermaid-svg-CljTQwQgPywNhb6u .node rect,#mermaid-svg-CljTQwQgPywNhb6u .node circle,#mermaid-svg-CljTQwQgPywNhb6u .node ellipse,#mermaid-svg-CljTQwQgPywNhb6u .node polygon,#mermaid-svg-CljTQwQgPywNhb6u .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-CljTQwQgPywNhb6u .divider{stroke:#9370DB;stroke-width:1;}#mermaid-svg-CljTQwQgPywNhb6u g.clickable{cursor:pointer;}#mermaid-svg-CljTQwQgPywNhb6u g.classGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-CljTQwQgPywNhb6u g.classGroup line{stroke:#9370DB;stroke-width:1;}#mermaid-svg-CljTQwQgPywNhb6u .classLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-CljTQwQgPywNhb6u .classLabel .label{fill:#9370DB;font-size:10px;}#mermaid-svg-CljTQwQgPywNhb6u .relation{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-CljTQwQgPywNhb6u .dashed-line{stroke-dasharray:3;}#mermaid-svg-CljTQwQgPywNhb6u .dotted-line{stroke-dasharray:1 2;}#mermaid-svg-CljTQwQgPywNhb6u #compositionStart,#mermaid-svg-CljTQwQgPywNhb6u .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-CljTQwQgPywNhb6u #compositionEnd,#mermaid-svg-CljTQwQgPywNhb6u .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-CljTQwQgPywNhb6u #dependencyStart,#mermaid-svg-CljTQwQgPywNhb6u .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-CljTQwQgPywNhb6u #dependencyStart,#mermaid-svg-CljTQwQgPywNhb6u .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-CljTQwQgPywNhb6u #extensionStart,#mermaid-svg-CljTQwQgPywNhb6u .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-CljTQwQgPywNhb6u #extensionEnd,#mermaid-svg-CljTQwQgPywNhb6u .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-CljTQwQgPywNhb6u #aggregationStart,#mermaid-svg-CljTQwQgPywNhb6u .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-CljTQwQgPywNhb6u #aggregationEnd,#mermaid-svg-CljTQwQgPywNhb6u .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-CljTQwQgPywNhb6u #lollipopStart,#mermaid-svg-CljTQwQgPywNhb6u .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-CljTQwQgPywNhb6u #lollipopEnd,#mermaid-svg-CljTQwQgPywNhb6u .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-CljTQwQgPywNhb6u .edgeTerminals{font-size:11px;line-height:initial;}#mermaid-svg-CljTQwQgPywNhb6u .classTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-CljTQwQgPywNhb6u .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-CljTQwQgPywNhb6u .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-CljTQwQgPywNhb6u :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
implements
implements
implements
implements
creates via OpenShell()
delegates
owns
creates
parsed from JSON
passed to ChangeWindowSize
«interface»
Script
+ExecuteCommands(cmds …string)(string, error)
+Close() : error
«interface»
Shell
+SetIO(stdout, stderr io.Writer, stdin io.ReadCloser)
+ChangeWindowSize(win ShellWindowSize) : error
+Terminal() : error
+OpenTerminal(win ShellWindowSize) : error
+Wait() : error
+Write(b []byte) : error
+Close() : error
ShellWindowSize
+Width int
+Height int
SSHDialer
-sshKey string
-sshCert string
-sshAddress string
-username string
-password string
-passphrase string
-useSSHAgentAuth bool
-conn *ssh.Client
-uid int
-logger *logrus.Logger
-shells map[Shell]Shell
+NewSSHDialer(n *Node, timeout bool, logger *Logger)(*SSHDialer, error)
+Dial(t bool)(*ssh.Client, error)
+GetClient() : *ssh.Client
-getUserID() : error
+Close() : error
-wrapCommands(cmd string) : string
+ExecuteCommands(cmds …string)(string, error)
+OpenShell()(Shell, error)
SSHShell
+Stdin io.ReadCloser
+Stdout io.Writer
+Stderr io.Writer
+Writer io.Writer
+Term string
+Modes ssh.TerminalModes
-session *ssh.Session
-dialer *SSHDialer
+Write([]byte) : error
+Wait() : error
+Close() : error
+SetIO(stdout, stderr, stdin)
+ChangeWindowSize(win) : error
+SetWriter(w io.Writer) : *SSHShell
+Terminal() : error
+OpenTerminal(win) : error
DockerShell
-execID string
+Stdin io.ReadCloser
+Stdout io.Writer
+Stderr io.Writer
+Writer io.Writer
-ctx context.Context
-client client.APIClient
-response *HijackedResponse
+NewDockerShell(n *Node)(*DockerShell, error)
+Close() : error
+SetStdio(stdout, stderr, stdin) : *DockerShell
+SetIO(stdout, stderr, stdin)
+SetWriter(w) : *DockerShell
+Terminal() : error
+OpenTerminal(win) : error
+ExecStart(needRestore bool) : error
+Wait() : error
+ResizeTtyTo(ctx, h, w) : error
+ResizeTty(ctx) : error
+MonitorTtySize(ctx) : error
-setInput(needRestore bool) (func(), error)
-beginOutputStream(restore) : chan error
-beginInputStream(restore)(chan struct, chan error)
+ChangeWindowSize(win) : error
+Write([]byte) : error
PtyShell
+Stdin io.ReadCloser
+Stdout io.Writer
+Stderr io.Writer
+Writer io.Writer
-ctx context.Context
-conn *os.File
-cmd *exec.Cmd
+NewPtyShell(cmd *exec.Cmd)(*PtyShell, error)
+Close() : error
+SetIO(stdout, stderr, stdin)
+Terminal() : error
+OpenTerminal(win) : error
+ChangeWindowSize(win) : error
+Wait() : error
+Write(b []byte) : error
WebSocketDialer
-dialer Shell
-conn *websocket.Conn
-reader *TerminalReader
+NewWebSocketDialer(conn, dialer) : *WebSocketDialer
+Close()
+Write(bytes []byte) : error
+Terminal(height, width int) : error
+ReadMessage(ctx) : error
+ChangeWindowSize(win)
TerminalReader
-conn *websocket.Conn
-reader io.Reader
+ClosedCh chan bool
-resize func(ShellWindowSize)
+NewTerminalReader(conn) : *TerminalReader
+Close() : error
+SetResizeFunction(fn)
+Read(p []byte)(int, error)
BinaryWriter
-conn *websocket.Conn
+NewBinaryWriter(conn) : *BinaryWriter
+Write(p []byte)(int, error)
5.3 ExecuteCommands 流程图
#mermaid-svg-fwb0HEx5cs42zyW9{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-fwb0HEx5cs42zyW9 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-fwb0HEx5cs42zyW9 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-fwb0HEx5cs42zyW9 .error-icon{fill:#552222;}#mermaid-svg-fwb0HEx5cs42zyW9 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-fwb0HEx5cs42zyW9 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-fwb0HEx5cs42zyW9 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-fwb0HEx5cs42zyW9 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-fwb0HEx5cs42zyW9 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-fwb0HEx5cs42zyW9 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-fwb0HEx5cs42zyW9 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-fwb0HEx5cs42zyW9 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-fwb0HEx5cs42zyW9 .marker.cross{stroke:#333333;}#mermaid-svg-fwb0HEx5cs42zyW9 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-fwb0HEx5cs42zyW9 p{margin:0;}#mermaid-svg-fwb0HEx5cs42zyW9 .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-fwb0HEx5cs42zyW9 .cluster-label text{fill:#333;}#mermaid-svg-fwb0HEx5cs42zyW9 .cluster-label span{color:#333;}#mermaid-svg-fwb0HEx5cs42zyW9 .cluster-label span p{background-color:transparent;}#mermaid-svg-fwb0HEx5cs42zyW9 .label text,#mermaid-svg-fwb0HEx5cs42zyW9 span{fill:#333;color:#333;}#mermaid-svg-fwb0HEx5cs42zyW9 .node rect,#mermaid-svg-fwb0HEx5cs42zyW9 .node circle,#mermaid-svg-fwb0HEx5cs42zyW9 .node ellipse,#mermaid-svg-fwb0HEx5cs42zyW9 .node polygon,#mermaid-svg-fwb0HEx5cs42zyW9 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-fwb0HEx5cs42zyW9 .rough-node .label text,#mermaid-svg-fwb0HEx5cs42zyW9 .node .label text,#mermaid-svg-fwb0HEx5cs42zyW9 .image-shape .label,#mermaid-svg-fwb0HEx5cs42zyW9 .icon-shape .label{text-anchor:middle;}#mermaid-svg-fwb0HEx5cs42zyW9 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-fwb0HEx5cs42zyW9 .rough-node .label,#mermaid-svg-fwb0HEx5cs42zyW9 .node .label,#mermaid-svg-fwb0HEx5cs42zyW9 .image-shape .label,#mermaid-svg-fwb0HEx5cs42zyW9 .icon-shape .label{text-align:center;}#mermaid-svg-fwb0HEx5cs42zyW9 .node.clickable{cursor:pointer;}#mermaid-svg-fwb0HEx5cs42zyW9 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-fwb0HEx5cs42zyW9 .arrowheadPath{fill:#333333;}#mermaid-svg-fwb0HEx5cs42zyW9 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-fwb0HEx5cs42zyW9 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-fwb0HEx5cs42zyW9 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fwb0HEx5cs42zyW9 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-fwb0HEx5cs42zyW9 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fwb0HEx5cs42zyW9 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-fwb0HEx5cs42zyW9 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-fwb0HEx5cs42zyW9 .cluster text{fill:#333;}#mermaid-svg-fwb0HEx5cs42zyW9 .cluster span{color:#333;}#mermaid-svg-fwb0HEx5cs42zyW9 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-fwb0HEx5cs42zyW9 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-fwb0HEx5cs42zyW9 rect.text{fill:none;stroke-width:0;}#mermaid-svg-fwb0HEx5cs42zyW9 .icon-shape,#mermaid-svg-fwb0HEx5cs42zyW9 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fwb0HEx5cs42zyW9 .icon-shape p,#mermaid-svg-fwb0HEx5cs42zyW9 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-fwb0HEx5cs42zyW9 .icon-shape .label rect,#mermaid-svg-fwb0HEx5cs42zyW9 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fwb0HEx5cs42zyW9 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-fwb0HEx5cs42zyW9 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-fwb0HEx5cs42zyW9 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
YES
NO
YES
NO
YES
NO
ExecuteCommands(cmds …string)
getUserID()
uid >= 0 ?(已缓存)
跳过
conn.NewSession()
session.Output(id -u)
strconv.Atoi(output)
d.uid = uid
uid > 0 ?(非root)
sudo = 'sudo'
sudo = ''
conn.NewSession()
cmds 用 \\ 连接
wrapCommands:包装为 #!/bin/shset -e
base64 编码
构造最终命令:echo base64 | base64 -d | sudo sh –
创建 output buffer
创建 singleWriter:MultiWriter(logger.Out, output)
session.Stderr = combinedOutput
session.Stdout = combinedOutput
session.Run(cmd)
Run 成功 ?
return output.String(), nil
return output.String(), err
defer: session.Close()
5.4 DockerDialer 流程图
#mermaid-svg-SVkSFcqDg1XwFkrP{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-SVkSFcqDg1XwFkrP .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-SVkSFcqDg1XwFkrP .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-SVkSFcqDg1XwFkrP .error-icon{fill:#552222;}#mermaid-svg-SVkSFcqDg1XwFkrP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-SVkSFcqDg1XwFkrP .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-SVkSFcqDg1XwFkrP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-SVkSFcqDg1XwFkrP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-SVkSFcqDg1XwFkrP .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-SVkSFcqDg1XwFkrP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-SVkSFcqDg1XwFkrP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-SVkSFcqDg1XwFkrP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-SVkSFcqDg1XwFkrP .marker.cross{stroke:#333333;}#mermaid-svg-SVkSFcqDg1XwFkrP svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-SVkSFcqDg1XwFkrP p{margin:0;}#mermaid-svg-SVkSFcqDg1XwFkrP .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-SVkSFcqDg1XwFkrP .cluster-label text{fill:#333;}#mermaid-svg-SVkSFcqDg1XwFkrP .cluster-label span{color:#333;}#mermaid-svg-SVkSFcqDg1XwFkrP .cluster-label span p{background-color:transparent;}#mermaid-svg-SVkSFcqDg1XwFkrP .label text,#mermaid-svg-SVkSFcqDg1XwFkrP span{fill:#333;color:#333;}#mermaid-svg-SVkSFcqDg1XwFkrP .node rect,#mermaid-svg-SVkSFcqDg1XwFkrP .node circle,#mermaid-svg-SVkSFcqDg1XwFkrP .node ellipse,#mermaid-svg-SVkSFcqDg1XwFkrP .node polygon,#mermaid-svg-SVkSFcqDg1XwFkrP .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-SVkSFcqDg1XwFkrP .rough-node .label text,#mermaid-svg-SVkSFcqDg1XwFkrP .node .label text,#mermaid-svg-SVkSFcqDg1XwFkrP .image-shape .label,#mermaid-svg-SVkSFcqDg1XwFkrP .icon-shape .label{text-anchor:middle;}#mermaid-svg-SVkSFcqDg1XwFkrP .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-SVkSFcqDg1XwFkrP .rough-node .label,#mermaid-svg-SVkSFcqDg1XwFkrP .node .label,#mermaid-svg-SVkSFcqDg1XwFkrP .image-shape .label,#mermaid-svg-SVkSFcqDg1XwFkrP .icon-shape .label{text-align:center;}#mermaid-svg-SVkSFcqDg1XwFkrP .node.clickable{cursor:pointer;}#mermaid-svg-SVkSFcqDg1XwFkrP .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-SVkSFcqDg1XwFkrP .arrowheadPath{fill:#333333;}#mermaid-svg-SVkSFcqDg1XwFkrP .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-SVkSFcqDg1XwFkrP .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-SVkSFcqDg1XwFkrP .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-SVkSFcqDg1XwFkrP .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-SVkSFcqDg1XwFkrP .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-SVkSFcqDg1XwFkrP .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-SVkSFcqDg1XwFkrP .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-SVkSFcqDg1XwFkrP .cluster text{fill:#333;}#mermaid-svg-SVkSFcqDg1XwFkrP .cluster span{color:#333;}#mermaid-svg-SVkSFcqDg1XwFkrP div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-SVkSFcqDg1XwFkrP .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-SVkSFcqDg1XwFkrP rect.text{fill:none;stroke-width:0;}#mermaid-svg-SVkSFcqDg1XwFkrP .icon-shape,#mermaid-svg-SVkSFcqDg1XwFkrP .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-SVkSFcqDg1XwFkrP .icon-shape p,#mermaid-svg-SVkSFcqDg1XwFkrP .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-SVkSFcqDg1XwFkrP .icon-shape .label rect,#mermaid-svg-SVkSFcqDg1XwFkrP .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-SVkSFcqDg1XwFkrP .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-SVkSFcqDg1XwFkrP .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-SVkSFcqDg1XwFkrP :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
NO
YES
>1
0
1
NO
YES
NO
YES
YES
NO
YES
NO
YES
NO
NewDockerShell(n *Node)
n.InstanceID != '' ?
error: no container ID
创建 DockerShellctx = Background
dockerutils.GetDockerClient()
构造 name filter:^/?(k3d-)?InstanceID$
docker.ContainerList(All: true)
len == 1 ?
error: multiple containers
error: no container found
Backoff 重试循环30s × 20
ContainerExecCreate:Privileged, Tty, /bin/sh
Create 成功 ?
return false, err⚠️ 立即中断重试
ContainerExecAttach:Tty=true
Attach 成功 ?
return false, err⚠️ 立即中断重试
d.execID = exec.IDd.response = execAttach
return dialer, nil
Terminal() (CLI模式)
defer Close()
ExecStart(needRestore=true)
setInput(true):SetRawTerminal + EscapeProxy
beginOutputStream:response.Reader → io.Copy → Stderr
beginInputStream:Stdin → io.Copy → response.Conn
select: outputDone/inputDone/detached/ctx.Done
term.IsTerminal ?
MonitorTtySize:SIGWINCH → ResizeTty
跳过监控
Wait():ContainerExecInspect
ExitCode == 0 ?
return nil
err == io.EOF ?
return err
OpenTerminal (WebSocket模式)
ExecStart(needRestore=false)
setInput(false):仅 EscapeProxy, 不设Raw
beginOutputStream + beginInputStream
非阻塞返回 nil
5.5 WebSocket 通信图
Remote Host
Shell (SSH/Docker/PTY)
BinaryWriter
TerminalReader
WebSocketDialer
API Server
Web Browser
Remote Host
Shell (SSH/Docker/PTY)
BinaryWriter
TerminalReader
WebSocketDialer
API Server
Web Browser
#mermaid-svg-7nqIIzuGPjgPKGjg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-7nqIIzuGPjgPKGjg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-7nqIIzuGPjgPKGjg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-7nqIIzuGPjgPKGjg .error-icon{fill:#552222;}#mermaid-svg-7nqIIzuGPjgPKGjg .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-7nqIIzuGPjgPKGjg .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-7nqIIzuGPjgPKGjg .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-7nqIIzuGPjgPKGjg .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-7nqIIzuGPjgPKGjg .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-7nqIIzuGPjgPKGjg .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-7nqIIzuGPjgPKGjg .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-7nqIIzuGPjgPKGjg .marker{fill:#333333;stroke:#333333;}#mermaid-svg-7nqIIzuGPjgPKGjg .marker.cross{stroke:#333333;}#mermaid-svg-7nqIIzuGPjgPKGjg svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-7nqIIzuGPjgPKGjg p{margin:0;}#mermaid-svg-7nqIIzuGPjgPKGjg .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-7nqIIzuGPjgPKGjg text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-7nqIIzuGPjgPKGjg .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-7nqIIzuGPjgPKGjg .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-7nqIIzuGPjgPKGjg .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-7nqIIzuGPjgPKGjg .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-7nqIIzuGPjgPKGjg #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-7nqIIzuGPjgPKGjg .sequenceNumber{fill:white;}#mermaid-svg-7nqIIzuGPjgPKGjg #sequencenumber{fill:#333;}#mermaid-svg-7nqIIzuGPjgPKGjg #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-7nqIIzuGPjgPKGjg .messageText{fill:#333;stroke:none;}#mermaid-svg-7nqIIzuGPjgPKGjg .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-7nqIIzuGPjgPKGjg .labelText,#mermaid-svg-7nqIIzuGPjgPKGjg .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-7nqIIzuGPjgPKGjg .loopText,#mermaid-svg-7nqIIzuGPjgPKGjg .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-7nqIIzuGPjgPKGjg .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-7nqIIzuGPjgPKGjg .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-7nqIIzuGPjgPKGjg .noteText,#mermaid-svg-7nqIIzuGPjgPKGjg .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-7nqIIzuGPjgPKGjg .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-7nqIIzuGPjgPKGjg .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-7nqIIzuGPjgPKGjg .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-7nqIIzuGPjgPKGjg .actorPopupMenu{position:absolute;}#mermaid-svg-7nqIIzuGPjgPKGjg .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-7nqIIzuGPjgPKGjg .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-7nqIIzuGPjgPKGjg .actor-man circle,#mermaid-svg-7nqIIzuGPjgPKGjg line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-7nqIIzuGPjgPKGjg :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
SSH: RequestPty + Shell
Docker: ExecStart(false)
PTY: pty.StartWithSize
alt
[用户输入]
alt
[窗口调整]
alt
[远程输出]
loop
[终端会话]
alt
[正常关闭]
[浏览器关闭]
[请求超时]
HTTP Upgrade (provider, cluster, node, height, width)
upgrader.Upgrade() → WebSocket
NewWebSocketDialer(conn, shell)
NewTerminalReader(conn)
SetResizeFunction(ChangeWindowSize)
NewBinaryWriter(conn)
SetIO(BW, BW, TR)
OpenTerminal(ShellWindowSize{150, 300})
ReadMessage(ctx)
BinaryMessage (键盘输入)
Read() → 返回二进制数据
stdin → io.Copy → Remote
传输输入
TextMessage {"Width":80,"Height":24}
json.Unmarshal → ShellWindowSize
resize(r) 回调
ChangeWindowSize(win)
WindowChange / ResizeTtyTo / pty.Setsize
stdout/stderr 输出
Write(输出数据)
BinaryMessage (终端输出)
会话结束
Wait() 返回
sessionClosed 信号
Close()
Close()
CloseMessage (EOF)
WebSocket Close
ClosedCh ← true
stop 信号
Close()
Close()
ctx.Done()
Close()
Close()
5.6 三种 Dialer 的统一架构对比
#mermaid-svg-dBGbimjqjzmwRLJY{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-dBGbimjqjzmwRLJY .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-dBGbimjqjzmwRLJY .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-dBGbimjqjzmwRLJY .error-icon{fill:#552222;}#mermaid-svg-dBGbimjqjzmwRLJY .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-dBGbimjqjzmwRLJY .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-dBGbimjqjzmwRLJY .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-dBGbimjqjzmwRLJY .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-dBGbimjqjzmwRLJY .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-dBGbimjqjzmwRLJY .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-dBGbimjqjzmwRLJY .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-dBGbimjqjzmwRLJY .marker{fill:#333333;stroke:#333333;}#mermaid-svg-dBGbimjqjzmwRLJY .marker.cross{stroke:#333333;}#mermaid-svg-dBGbimjqjzmwRLJY svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-dBGbimjqjzmwRLJY p{margin:0;}#mermaid-svg-dBGbimjqjzmwRLJY .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-dBGbimjqjzmwRLJY .cluster-label text{fill:#333;}#mermaid-svg-dBGbimjqjzmwRLJY .cluster-label span{color:#333;}#mermaid-svg-dBGbimjqjzmwRLJY .cluster-label span p{background-color:transparent;}#mermaid-svg-dBGbimjqjzmwRLJY .label text,#mermaid-svg-dBGbimjqjzmwRLJY span{fill:#333;color:#333;}#mermaid-svg-dBGbimjqjzmwRLJY .node rect,#mermaid-svg-dBGbimjqjzmwRLJY .node circle,#mermaid-svg-dBGbimjqjzmwRLJY .node ellipse,#mermaid-svg-dBGbimjqjzmwRLJY .node polygon,#mermaid-svg-dBGbimjqjzmwRLJY .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-dBGbimjqjzmwRLJY .rough-node .label text,#mermaid-svg-dBGbimjqjzmwRLJY .node .label text,#mermaid-svg-dBGbimjqjzmwRLJY .image-shape .label,#mermaid-svg-dBGbimjqjzmwRLJY .icon-shape .label{text-anchor:middle;}#mermaid-svg-dBGbimjqjzmwRLJY .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-dBGbimjqjzmwRLJY .rough-node .label,#mermaid-svg-dBGbimjqjzmwRLJY .node .label,#mermaid-svg-dBGbimjqjzmwRLJY .image-shape .label,#mermaid-svg-dBGbimjqjzmwRLJY .icon-shape .label{text-align:center;}#mermaid-svg-dBGbimjqjzmwRLJY .node.clickable{cursor:pointer;}#mermaid-svg-dBGbimjqjzmwRLJY .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-dBGbimjqjzmwRLJY .arrowheadPath{fill:#333333;}#mermaid-svg-dBGbimjqjzmwRLJY .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-dBGbimjqjzmwRLJY .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-dBGbimjqjzmwRLJY .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-dBGbimjqjzmwRLJY .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-dBGbimjqjzmwRLJY .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-dBGbimjqjzmwRLJY .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-dBGbimjqjzmwRLJY .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-dBGbimjqjzmwRLJY .cluster text{fill:#333;}#mermaid-svg-dBGbimjqjzmwRLJY .cluster span{color:#333;}#mermaid-svg-dBGbimjqjzmwRLJY div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-dBGbimjqjzmwRLJY .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-dBGbimjqjzmwRLJY rect.text{fill:none;stroke-width:0;}#mermaid-svg-dBGbimjqjzmwRLJY .icon-shape,#mermaid-svg-dBGbimjqjzmwRLJY .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-dBGbimjqjzmwRLJY .icon-shape p,#mermaid-svg-dBGbimjqjzmwRLJY .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-dBGbimjqjzmwRLJY .icon-shape .label rect,#mermaid-svg-dBGbimjqjzmwRLJY .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-dBGbimjqjzmwRLJY .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-dBGbimjqjzmwRLJY .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-dBGbimjqjzmwRLJY :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
Script 批量命令模式
cluster.execute()
NewSSHDialer()
ExecuteCommands(cmds…)
base64编码 → session.Run()
Close()
WebSocket Kubectl 模式
kubectl.KubeHandler()
HTTP → WebSocket Upgrade
NewPtyShell(bash)
NewWebSocketDialer(conn, pty)
Terminal(h, w) → OpenTerminal()
Write(alias commands)
ReadMessage(ctx)
WebSocket SSH 模式
YES
NO
ssh.Handler()
HTTP → WebSocket Upgrade
newDialer()
Provider == k3d ?
NewDockerShell()
NewSSHDialer() → OpenShell()
NewWebSocketDialer(conn, shell)
Terminal(h, w) → OpenTerminal()
ReadMessage(ctx)
CLI 模式 (autok3s ssh terminal)
cluster.terminal()
NewSSHDialer()
OpenShell()
SetIO(os.Stdout, os.Stderr, os.Stdin)
SSHShell.Terminal()
MakeRaw → OpenTerminal → Wait
附录:关键设计总结
A. 命令执行的 base64 包装设计
原始命令 → strings.Join(\\n) → #!/bin/sh\\nset -e\\n<cmds> → base64编码 → echo "base64" | base64 -d | sudo sh –
优点: 避免转义问题,支持多命令,强制错误传播 缺点: 无法看到实际执行的命令(日志中只有 base64),调试困难
B. 三种实现的功能矩阵
| Script (ExecuteCommands) | ✅ | ❌ | ❌ |
| Shell (OpenTerminal) | ✅ | ✅ | ✅ |
| CLI Terminal() | ✅ (SSHShell) | ✅ | ❌ |
| WebSocket Terminal() | ✅ (via WebSocketDialer) | ✅ | ✅ |
| ChangeWindowSize | ✅ (WindowChange) | ✅ (ContainerExecResize) | ✅ (pty.Setsize) |
| 平台限制 | 全平台 | 全平台 | darwin/linux only |
| 认证方式 | 密码/密钥/Agent/证书 | Docker socket | 无(本地) |
| 重试机制 | 15s×5 | 30s×20 (实际1次) | 无 |



