Files
membank/auto-check/pkg/sshclient/sshclient.go
张威33321 a8fa159f0f chore: 同步本地更改
- 新增 aio-mcp 项目框架
- 新增 .trae/skills/ OCR/SKU 设计工具
- 更新 auto-check 配置和 stress 包
- 删除 fnhelp 文档目录
- 更新 Docker 压力测试脚本
2026-08-27 20:38:13 +08:00

203 lines
5.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package sshclient
import (
"fmt"
"io"
"net"
"os"
"path"
"strings"
"time"
"github.com/pkg/sftp"
"golang.org/x/crypto/ssh"
)
// Config SSH 连接配置(不含连接状态)
type Config struct {
User string
Password string
KeyFile string
Port int
Timeout time.Duration
SudoPwd string // 非 root 用户执行特权命令(sudo)时的密码
}
// NewConfig 创建配置
func NewConfig(user, password, keyFile string, port int, timeout time.Duration) *Config {
return &Config{
User: user,
Password: password,
KeyFile: keyFile,
Port: port,
Timeout: timeout,
}
}
// Connect 建立 SSH 连接,由调用方负责 Close
func Connect(ip string, cfg *Config) (*ssh.Client, error) {
sshCfg, err := cfg.buildSSHConfig()
if err != nil {
return nil, err
}
addr := net.JoinHostPort(ip, fmt.Sprintf("%d", cfg.Port))
client, err := ssh.Dial("tcp", addr, sshCfg)
if err != nil {
return nil, fmt.Errorf("SSH 连接 %s 失败: %w", addr, err)
}
// KeepAlive长时压测如 fnstable 门禁的 memtester 可达 30-120 分钟)期间
// 连接上无数据流动,定期发 keepalive 请求防止 NAT/防火墙掐断空闲 TCP 连接。
// 连接关闭后 SendRequest 返回错误goroutine 自行退出。
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
if _, _, err := client.SendRequest("keepalive@golang.org", true, nil); err != nil {
return
}
}
}()
return client, nil
}
// RunCommand 在已有 SSH 连接上执行命令,返回输出(实现 stress.Executor 接口)
func RunCommand(client *ssh.Client, command string) (string, error) {
session, err := client.NewSession()
if err != nil {
return "", fmt.Errorf("创建会话失败: %w", err)
}
defer session.Close()
out, err := session.CombinedOutput(command)
if err != nil {
return cleanOutput(out), fmt.Errorf("命令执行失败: %w", err)
}
return cleanOutput(out), nil
}
// isNoiseLine 判断某行是否为登录横幅/欢迎语等噪声fnOS 的 admin 用户会把
// "Could not chdir to home directory" 之类写入 stdout污染命令解析
func isNoiseLine(line string) bool {
s := strings.TrimSpace(line)
if s == "" {
return false
}
prefixes := []string{
"Could not chdir to",
"Last login:",
"Welcome to",
"/etc/motd",
"Permission denied",
"bash: line 1:",
}
for _, p := range prefixes {
if strings.HasPrefix(s, p) {
return true
}
}
// 某些横幅是 "Could: command not found" 这类由前面噪声行产生,
// 形如 "xxx: command not found" 且前半段是噪声关键词的也过滤
if strings.Contains(s, ": command not found") {
return true
}
return false
}
// cleanOutput 过滤掉登录横幅等噪声行,仅保留真实命令输出
func cleanOutput(raw []byte) string {
lines := strings.Split(string(raw), "\n")
var kept []string
for _, ln := range lines {
if isNoiseLine(ln) {
continue
}
kept = append(kept, ln)
}
return strings.Join(kept, "\n")
}
// EnsureHome 确保远端登录用户的 HOME 目录存在,消除 fnOS "Could not chdir to
// home directory" 登录横幅(该横幅会写入 stdout 污染命令输出)。
func EnsureHome(client *ssh.Client) {
out, err := RunCommand(client, "echo $HOME")
home := strings.TrimSpace(out)
if err != nil || home == "" || home == "/" {
return
}
RunCommand(client, fmt.Sprintf("mkdir -p %s", home))
}
// UploadFile 通过 SFTP 把本地文件上传到远端指定路径(复用已有 SSH 连接)
func UploadFile(client *ssh.Client, localPath, remotePath string) error {
src, err := os.Open(localPath)
if err != nil {
return fmt.Errorf("打开本地文件失败: %w", err)
}
defer src.Close()
info, err := src.Stat()
if err != nil {
return fmt.Errorf("读取文件信息失败: %w", err)
}
sftpCli, err := sftp.NewClient(client)
if err != nil {
return fmt.Errorf("创建 SFTP 会话失败: %w", err)
}
defer sftpCli.Close()
// 确保远端目录存在(/tmp 一般已存在,兜底处理)
if dir := path.Dir(remotePath); dir != "" && dir != "/" {
_ = sftpCli.MkdirAll(dir)
}
dst, err := sftpCli.Create(remotePath)
if err != nil {
return fmt.Errorf("创建远端文件失败: %w", err)
}
defer dst.Close()
fmt.Printf(" [上传] %s -> %s (%.1f MB)...\n", localPath, remotePath, float64(info.Size())/1024/1024)
start := time.Now()
if _, err := io.Copy(dst, src); err != nil {
return fmt.Errorf("文件传输失败: %w", err)
}
fmt.Printf(" [上传] 完成,耗时 %.1fs\n", time.Since(start).Seconds())
return nil
}
// buildSSHConfig 构建认证配置
func (c *Config) buildSSHConfig() (*ssh.ClientConfig, error) {
var authMethods []ssh.AuthMethod
if c.Password != "" {
authMethods = append(authMethods, ssh.Password(c.Password))
}
if c.KeyFile != "" {
key, err := os.ReadFile(c.KeyFile)
if err != nil {
return nil, fmt.Errorf("读取密钥文件失败: %w", err)
}
signer, err := ssh.ParsePrivateKey(key)
if err != nil {
return nil, fmt.Errorf("解析私钥失败: %w", err)
}
authMethods = append(authMethods, ssh.PublicKeys(signer))
}
if len(authMethods) == 0 {
return nil, fmt.Errorf("未提供认证方式(密码或密钥)")
}
return &ssh.ClientConfig{
User: c.User,
Auth: authMethods,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: c.Timeout,
}, nil
}