653 lines
18 KiB
Go
653 lines
18 KiB
Go
// Command deploy 本地目录 → 服务器目录的「差异比对 + 增量上传」工具。
|
||
//
|
||
// 用途:只上传本地改动过的文件,跳过未改动的文件,避免每次整包覆盖。
|
||
//
|
||
// 工作流程:
|
||
// 1. SSH 连上服务器,执行 md5sum 拿到远端目录的文件清单与指纹
|
||
// 2. 本地遍历目录计算 MD5(按排除规则跳过日志、可执行文件等)
|
||
// 3. 比对两份清单,得出「新增 / 已修改」的文件清单并打印
|
||
// 4. 确认后把差异文件打成 tar 流,通过 SSH 管道在远端解开
|
||
// 5. 回读远端指纹复核,确认上传结果
|
||
//
|
||
// 之所以用 Go 实现而不是直接调 scp:Windows 自带的 OpenSSH scp 不支持
|
||
// 非交互传密码(sshpass / pscp 都没装),x/crypto/ssh 可以直接用密码登录,
|
||
// 全程无交互,适合丢进 .bat 里一键执行。
|
||
//
|
||
// 用法:
|
||
//
|
||
// deploy -host 10.31.100.2 -user root -dir /www/wwwroot/api.yunzer.cn -local .
|
||
package main
|
||
|
||
import (
|
||
"archive/tar"
|
||
"bytes"
|
||
"crypto/md5"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"flag"
|
||
"fmt"
|
||
"io"
|
||
"net"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"golang.org/x/crypto/ssh"
|
||
)
|
||
|
||
// ============================ 默认排除规则 ============================
|
||
|
||
// defaultExcludeDirs 不参与同步的目录名
|
||
var defaultExcludeDirs = []string{
|
||
".git", ".idea", ".vscode", ".svn",
|
||
"node_modules", "bin", "dist", "tmp", "temp",
|
||
}
|
||
|
||
// defaultExcludeFiles 不参与同步的文件名(支持 * 通配)
|
||
var defaultExcludeFiles = []string{
|
||
"*.exe", "*.log", "*.pid", "*.bak", "*.tmp", "*.pub",
|
||
"nohup.out", "output.log", "go.log",
|
||
"test-output-*.txt", "migrate_*.csv",
|
||
"server", // 无扩展名的编译产物
|
||
"*_id_ed25519", // SSH 私钥:绝对不能传到服务器
|
||
".deploy_known_hosts",
|
||
}
|
||
|
||
// ============================ 配置 ============================
|
||
|
||
type options struct {
|
||
host string
|
||
user string
|
||
pass string
|
||
keyFile string
|
||
dir string
|
||
local string
|
||
restart string
|
||
execCmd string
|
||
excludes string
|
||
dryRun bool
|
||
yes bool
|
||
del bool
|
||
quiet bool
|
||
}
|
||
|
||
// errCancelled 用户在上传前确认时选择了取消(区别于真正的失败)
|
||
var errCancelled = errors.New("用户取消")
|
||
|
||
func main() {
|
||
var opt options
|
||
flag.StringVar(&opt.host, "host", "10.31.100.2", "服务器地址")
|
||
flag.StringVar(&opt.user, "user", "root", "SSH 用户名")
|
||
flag.StringVar(&opt.pass, "pass", "", "SSH 密码(留空则读环境变量 DEPLOY_PASS)")
|
||
flag.StringVar(&opt.keyFile, "key", "", "SSH 私钥路径(指定后用密钥登录,忽略密码)")
|
||
flag.StringVar(&opt.dir, "dir", "/www/wwwroot/api.yunzer.cn", "服务器目标目录")
|
||
flag.StringVar(&opt.local, "local", ".", "本地目录(要同步的根)")
|
||
flag.StringVar(&opt.restart, "restart", "", "上传后执行的远程重启命令(留空则不重启)")
|
||
flag.StringVar(&opt.execCmd, "exec", "", "只执行远程命令并输出结果,不做同步")
|
||
flag.StringVar(&opt.excludes, "exclude", "", "额外排除的文件名(逗号分隔,支持 * 通配)")
|
||
flag.BoolVar(&opt.dryRun, "dry-run", false, "只比对不上传")
|
||
flag.BoolVar(&opt.yes, "yes", false, "跳过上传前的确认")
|
||
flag.BoolVar(&opt.del, "delete", false, "同时删除远端存在、本地已删除的文件")
|
||
flag.BoolVar(&opt.quiet, "quiet", false, "精简输出")
|
||
flag.Parse()
|
||
|
||
if opt.pass == "" {
|
||
opt.pass = os.Getenv("DEPLOY_PASS")
|
||
}
|
||
if opt.pass == "" && opt.keyFile == "" {
|
||
fmt.Println("[错误] 未提供密码:请用 -pass 或环境变量 DEPLOY_PASS 指定")
|
||
os.Exit(2)
|
||
}
|
||
|
||
if err := run(opt); err != nil {
|
||
if errors.Is(err, errCancelled) {
|
||
os.Exit(3) // 取消不是失败,用独立退出码告知调用方
|
||
}
|
||
fmt.Printf("[失败] %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
}
|
||
|
||
// ============================ 主流程 ============================
|
||
|
||
func run(opt options) error {
|
||
localRoot, err := filepath.Abs(opt.local)
|
||
if err != nil {
|
||
return fmt.Errorf("解析本地目录失败: %w", err)
|
||
}
|
||
if _, err := os.Stat(localRoot); err != nil {
|
||
return fmt.Errorf("本地目录不存在: %s", localRoot)
|
||
}
|
||
|
||
excludeDirs := append([]string{}, defaultExcludeDirs...)
|
||
excludeFiles := append([]string{}, defaultExcludeFiles...)
|
||
for _, extra := range strings.Split(opt.excludes, ",") {
|
||
extra = strings.TrimSpace(extra)
|
||
if extra == "" {
|
||
continue
|
||
}
|
||
if strings.ContainsAny(extra, "/\\") {
|
||
excludeDirs = append(excludeDirs, extra)
|
||
} else {
|
||
excludeFiles = append(excludeFiles, extra)
|
||
}
|
||
}
|
||
|
||
fmt.Printf("本地目录 : %s\n", localRoot)
|
||
fmt.Printf("远程目录 : %s@%s:%s\n", opt.user, opt.host, opt.dir)
|
||
|
||
client, err := dial(opt, localRoot)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer client.Close()
|
||
|
||
// 只执行远程命令(不比对、不上传)
|
||
if strings.TrimSpace(opt.execCmd) != "" {
|
||
fmt.Printf("\n[执行] %s\n", opt.execCmd)
|
||
out, xerr := runRemote(client, opt.execCmd)
|
||
if strings.TrimSpace(out) != "" {
|
||
fmt.Println(strings.TrimSpace(out))
|
||
}
|
||
if xerr != nil {
|
||
return fmt.Errorf("远程命令失败: %w", xerr)
|
||
}
|
||
fmt.Println("[完成]")
|
||
return nil
|
||
}
|
||
|
||
// 1. 远端清单
|
||
remote, err := remoteInventory(client, opt.dir)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
fmt.Printf("远端文件 : %d 个\n", len(remote))
|
||
|
||
// 2. 本地清单
|
||
local, err := localInventory(localRoot, excludeDirs, excludeFiles)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
fmt.Printf("本地文件 : %d 个(已排除日志 / 可执行文件等)\n", len(local))
|
||
|
||
// 3. 比对
|
||
var changed, added []string
|
||
remoteOnly := make([]string, 0)
|
||
for rel, lh := range local {
|
||
rh, ok := remote[rel]
|
||
if !ok {
|
||
added = append(added, rel)
|
||
continue
|
||
}
|
||
if lh.MD5 != rh.MD5 {
|
||
changed = append(changed, rel)
|
||
}
|
||
}
|
||
for rel := range remote {
|
||
if _, ok := local[rel]; !ok {
|
||
remoteOnly = append(remoteOnly, rel)
|
||
}
|
||
}
|
||
sort.Strings(changed)
|
||
sort.Strings(added)
|
||
sort.Strings(remoteOnly)
|
||
|
||
fmt.Println()
|
||
if len(added) > 0 {
|
||
fmt.Printf("新增文件 %d 个:\n", len(added))
|
||
printList("+", added)
|
||
}
|
||
if len(changed) > 0 {
|
||
fmt.Printf("内容有变化 %d 个:\n", len(changed))
|
||
printList("M", changed)
|
||
}
|
||
if len(remoteOnly) > 0 {
|
||
fmt.Printf("远端存在、本地没有 %d 个%s\n", len(remoteOnly), map[bool]string{true: "(将删除)", false: "(保留,不删除)"}[opt.del])
|
||
if opt.del {
|
||
printList("-", remoteOnly)
|
||
}
|
||
}
|
||
fmt.Printf("\n未变化 %d 个(跳过)\n", len(local)-len(changed)-len(added))
|
||
|
||
toUpload := append(append([]string{}, changed...), added...)
|
||
if len(toUpload) == 0 && len(remoteOnly) == 0 {
|
||
fmt.Println("\n[完成] 没有需要更新的文件。")
|
||
return nil
|
||
}
|
||
if opt.dryRun {
|
||
fmt.Println("\n[试运行] 未实际上传(-dry-run)。")
|
||
return nil
|
||
}
|
||
|
||
if !opt.yes && !confirm(fmt.Sprintf("确认上传 %d 个文件到 %s?", len(toUpload), opt.dir)) {
|
||
fmt.Println("已取消。")
|
||
return errCancelled
|
||
}
|
||
|
||
// 4. 上传
|
||
if len(toUpload) > 0 {
|
||
fmt.Printf("\n[上传] 打包 %d 个文件并传输...\n", len(toUpload))
|
||
if err := upload(client, opt, localRoot, toUpload); err != nil {
|
||
return err
|
||
}
|
||
// 5. 复核
|
||
fmt.Println("[复核] 重新读取远端指纹...")
|
||
after, err := remoteInventory(client, opt.dir)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
bad := make([]string, 0)
|
||
for _, rel := range toUpload {
|
||
rh, ok := after[rel]
|
||
if !ok {
|
||
bad = append(bad, rel+"(远端缺失)")
|
||
continue
|
||
}
|
||
if lh := local[rel]; lh.MD5 != rh.MD5 {
|
||
bad = append(bad, rel+"(指纹不一致)")
|
||
}
|
||
}
|
||
if len(bad) > 0 {
|
||
for _, b := range bad {
|
||
fmt.Println(" ! " + b)
|
||
}
|
||
return fmt.Errorf("%d 个文件上传后校验不通过", len(bad))
|
||
}
|
||
fmt.Printf("[复核] %d 个文件指纹一致 ✓\n", len(toUpload))
|
||
}
|
||
|
||
// 6. 删除远端多余文件
|
||
if opt.del && len(remoteOnly) > 0 {
|
||
fmt.Printf("\n[删除] 清理远端 %d 个多余文件...\n", len(remoteOnly))
|
||
if err := removeRemote(client, opt.dir, remoteOnly); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
// 7. 重启
|
||
if strings.TrimSpace(opt.restart) != "" {
|
||
fmt.Printf("\n[重启] 执行: %s\n", opt.restart)
|
||
out, err := runRemote(client, opt.restart)
|
||
if err != nil {
|
||
return fmt.Errorf("重启失败: %w(远端输出: %s)", err, strings.TrimSpace(out))
|
||
}
|
||
if strings.TrimSpace(out) != "" {
|
||
fmt.Println(out)
|
||
}
|
||
fmt.Println("[重启] 完成")
|
||
} else {
|
||
fmt.Println("\n[提示] 未配置重启命令,服务不会自动重启;如需生效请手动重启(如 -restart \"supervisorctl restart <服务名>\")")
|
||
}
|
||
|
||
fmt.Println("\n[完成] 同步结束。")
|
||
return nil
|
||
}
|
||
|
||
// ============================ 文件清单 ============================
|
||
|
||
type fileInfo struct {
|
||
MD5 string
|
||
Size int64
|
||
Path string
|
||
}
|
||
|
||
// localInventory 遍历本地目录,计算 MD5
|
||
func localInventory(root string, excludeDirs, excludeFiles []string) (map[string]fileInfo, error) {
|
||
out := make(map[string]fileInfo)
|
||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rel, rerr := filepath.Rel(root, path)
|
||
if rerr != nil {
|
||
return rerr
|
||
}
|
||
rel = filepath.ToSlash(rel)
|
||
if rel == "." {
|
||
return nil
|
||
}
|
||
name := info.Name()
|
||
if info.IsDir() {
|
||
if matchAny(name, excludeDirs) {
|
||
return filepath.SkipDir
|
||
}
|
||
return nil
|
||
}
|
||
if !info.Mode().IsRegular() {
|
||
return nil // 跳过符号链接 / 设备文件等
|
||
}
|
||
if matchAny(name, excludeFiles) {
|
||
return nil
|
||
}
|
||
sum, size, herr := fileMD5(path)
|
||
if herr != nil {
|
||
return herr
|
||
}
|
||
out[rel] = fileInfo{MD5: sum, Size: size, Path: path}
|
||
return nil
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
// remoteInventory 远端 md5sum 清单
|
||
func remoteInventory(client *ssh.Client, dir string) (map[string]fileInfo, error) {
|
||
res := make(map[string]fileInfo)
|
||
|
||
// 目录不存在时按空目录处理(上传阶段会创建)
|
||
if out, err := runRemote(client, fmt.Sprintf("[ -d %s ] && echo yes || echo no", shQuote(dir))); err == nil {
|
||
if !strings.Contains(out, "yes") {
|
||
fmt.Printf("[提示] 远端目录 %s 不存在,将新建\n", dir)
|
||
return res, nil
|
||
}
|
||
}
|
||
|
||
cmd := fmt.Sprintf("cd %s && find . -type f -exec md5sum {} + 2>/dev/null", shQuote(dir))
|
||
out, err := runRemote(client, cmd)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取远端清单失败: %w(输出: %s)", err, strings.TrimSpace(out))
|
||
}
|
||
for _, line := range strings.Split(out, "\n") {
|
||
line = strings.TrimRight(line, "\r")
|
||
if strings.TrimSpace(line) == "" {
|
||
continue
|
||
}
|
||
// 格式: <md5> <path>
|
||
idx := strings.Index(line, " ")
|
||
if idx < 0 {
|
||
continue
|
||
}
|
||
sum := strings.TrimSpace(line[:idx])
|
||
rel := strings.TrimSpace(line[idx+2:])
|
||
rel = strings.TrimPrefix(rel, "./")
|
||
if rel == "" {
|
||
continue
|
||
}
|
||
res[rel] = fileInfo{MD5: sum, Path: rel}
|
||
}
|
||
if len(res) == 0 {
|
||
fmt.Printf("[提示] 远端目录 %s 目前是空的\n", dir)
|
||
}
|
||
return res, nil
|
||
}
|
||
|
||
func fileMD5(path string) (string, int64, error) {
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
return "", 0, err
|
||
}
|
||
defer f.Close()
|
||
h := md5.New()
|
||
size, err := io.Copy(h, f)
|
||
if err != nil {
|
||
return "", 0, err
|
||
}
|
||
return hex.EncodeToString(h.Sum(nil)), size, nil
|
||
}
|
||
|
||
// matchAny 名称是否命中排除规则(支持 * 通配)
|
||
func matchAny(name string, patterns []string) bool {
|
||
for _, p := range patterns {
|
||
if p == name {
|
||
return true
|
||
}
|
||
if ok, err := filepath.Match(p, name); err == nil && ok {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// printList 打印文件清单(最多 20 条,避免刷屏)
|
||
func printList(mark string, files []string) {
|
||
const maxShow = 20
|
||
for i, f := range files {
|
||
if i >= maxShow {
|
||
fmt.Printf(" …… 其余 %d 个省略\n", len(files)-maxShow)
|
||
break
|
||
}
|
||
fmt.Printf(" %s %s\n", mark, f)
|
||
}
|
||
}
|
||
|
||
// ============================ 上传 ============================
|
||
|
||
// upload 把差异文件打成 tar 流,通过 SSH 管道在远端解开
|
||
func upload(client *ssh.Client, opt options, localRoot string, files []string) error {
|
||
buf, err := buildTar(localRoot, files)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 远端目录不存在时先创建(tar 解包要求目标目录已存在)
|
||
if out, merr := runRemote(client, fmt.Sprintf("mkdir -p %s", shQuote(opt.dir))); merr != nil {
|
||
return fmt.Errorf("创建远端目录失败: %w(%s)", merr, strings.TrimSpace(out))
|
||
}
|
||
|
||
session, err := client.NewSession()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer session.Close()
|
||
|
||
var stderr bytes.Buffer
|
||
session.Stderr = &stderr
|
||
stdin, err := session.StdinPipe()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
stdout, err := session.StdoutPipe()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// 远端解包(tar 会自动创建缺失的目录)
|
||
if err := session.Start(fmt.Sprintf("tar -xf - -C %s", shQuote(opt.dir))); err != nil {
|
||
return err
|
||
}
|
||
go func() {
|
||
_, _ = stdin.Write(buf)
|
||
_ = stdin.Close()
|
||
}()
|
||
out, _ := io.ReadAll(stdout)
|
||
if err := session.Wait(); err != nil {
|
||
return fmt.Errorf("远端解包失败: %w(stderr: %s stdout: %s)", err, strings.TrimSpace(stderr.String()), strings.TrimSpace(string(out)))
|
||
}
|
||
|
||
// 属主对齐目标目录(服务器上多为 www:www,避免解包后变成 root:root)
|
||
if err := alignOwner(client, opt.dir, files); err != nil {
|
||
fmt.Printf("[提示] 属主修正跳过: %v\n", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// alignOwner 把上传文件的属主改成目标目录的属主
|
||
func alignOwner(client *ssh.Client, dir string, files []string) error {
|
||
if len(files) == 0 {
|
||
return nil
|
||
}
|
||
quoted := make([]string, 0, len(files))
|
||
for _, rel := range files {
|
||
quoted = append(quoted, shQuote(strings.TrimRight(dir, "/")+"/"+rel))
|
||
}
|
||
cmd := fmt.Sprintf("ref=$(stat -c '%%U:%%G' %s) && for f in %s; do chown -R \"$ref\" \"$f\"; done",
|
||
shQuote(dir), strings.Join(quoted, " "))
|
||
out, err := runRemote(client, cmd)
|
||
if err != nil {
|
||
return fmt.Errorf("%w(%s)", err, strings.TrimSpace(out))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// buildTar 生成待上传文件的 tar 包(含目录条目,保证远端目录结构存在)
|
||
func buildTar(localRoot string, files []string) ([]byte, error) {
|
||
dirs := make(map[string]bool)
|
||
for _, rel := range files {
|
||
parts := strings.Split(rel, "/")
|
||
for i := 1; i < len(parts); i++ {
|
||
dirs[strings.Join(parts[:i], "/")+"/"] = true
|
||
}
|
||
}
|
||
dirList := make([]string, 0, len(dirs))
|
||
for d := range dirs {
|
||
dirList = append(dirList, d)
|
||
}
|
||
sort.Strings(dirList)
|
||
|
||
buf := &bytes.Buffer{}
|
||
tw := tar.NewWriter(buf)
|
||
for _, d := range dirList {
|
||
if err := tw.WriteHeader(&tar.Header{
|
||
Name: d,
|
||
Typeflag: tar.TypeDir,
|
||
Mode: 0o755,
|
||
}); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
for _, rel := range files {
|
||
full := filepath.Join(localRoot, filepath.FromSlash(rel))
|
||
info, err := os.Stat(full)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
f, err := os.Open(full)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := tw.WriteHeader(&tar.Header{
|
||
Name: rel,
|
||
Typeflag: tar.TypeReg,
|
||
Mode: fileMode(rel),
|
||
Size: info.Size(),
|
||
ModTime: info.ModTime(),
|
||
}); err != nil {
|
||
f.Close()
|
||
return nil, err
|
||
}
|
||
if _, err := io.Copy(tw, f); err != nil {
|
||
f.Close()
|
||
return nil, err
|
||
}
|
||
f.Close()
|
||
}
|
||
if err := tw.Close(); err != nil {
|
||
return nil, err
|
||
}
|
||
return buf.Bytes(), nil
|
||
}
|
||
|
||
// fileMode 归一化文件权限:脚本可执行,其它一律 0644
|
||
// (Windows 本地读到的权限恒为 0666,直接带上会让服务器上的文件变成人人可写)
|
||
func fileMode(rel string) int64 {
|
||
switch strings.ToLower(filepath.Ext(rel)) {
|
||
case ".sh", ".bash", ".py", ".pl", ".rb":
|
||
return 0o755
|
||
}
|
||
return 0o644
|
||
}
|
||
|
||
// removeRemote 删除远端多余文件
|
||
func removeRemote(client *ssh.Client, dir string, files []string) error {
|
||
for _, rel := range files {
|
||
cmd := fmt.Sprintf("rm -f -- %s", shQuote(strings.TrimRight(dir, "/")+"/"+rel))
|
||
if out, err := runRemote(client, cmd); err != nil {
|
||
return fmt.Errorf("删除 %s 失败: %w(%s)", rel, err, strings.TrimSpace(out))
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ============================ SSH ============================
|
||
|
||
func dial(opt options, localRoot string) (*ssh.Client, error) {
|
||
var auth []ssh.AuthMethod
|
||
if opt.keyFile != "" {
|
||
key, err := os.ReadFile(opt.keyFile)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取私钥失败: %w", err)
|
||
}
|
||
signer, err := ssh.ParsePrivateKey(key)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("解析私钥失败: %w", err)
|
||
}
|
||
auth = append(auth, ssh.PublicKeys(signer))
|
||
} else {
|
||
auth = append(auth, ssh.Password(opt.pass))
|
||
}
|
||
|
||
cfg := &ssh.ClientConfig{
|
||
User: opt.user,
|
||
Auth: auth,
|
||
HostKeyCallback: hostKeyChecker(localRoot),
|
||
Timeout: 20 * time.Second,
|
||
}
|
||
addr := fmt.Sprintf("%s:%d", opt.host, 22)
|
||
client, err := ssh.Dial("tcp", addr, cfg)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("连接服务器失败: %w", err)
|
||
}
|
||
return client, nil
|
||
}
|
||
|
||
// hostKeyChecker 首次连接记录指纹,之后变化则拒绝连接(防止服务器被替换)
|
||
func hostKeyChecker(localRoot string) ssh.HostKeyCallback {
|
||
store := filepath.Join(localRoot, ".deploy_known_hosts")
|
||
known := map[string]string{}
|
||
if data, err := os.ReadFile(store); err == nil {
|
||
_ = json.Unmarshal(data, &known)
|
||
}
|
||
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||
fp := ssh.FingerprintSHA256(key)
|
||
if old, ok := known[hostname]; ok {
|
||
if old != fp {
|
||
return fmt.Errorf("服务器主机密钥已变化(%s 原 %s 现 %s),请确认后删除 %s 再执行", hostname, old, fp, store)
|
||
}
|
||
return nil
|
||
}
|
||
known[hostname] = fp
|
||
if data, err := json.MarshalIndent(known, "", " "); err == nil {
|
||
_ = os.WriteFile(store, data, 0o644)
|
||
}
|
||
fmt.Printf("[安全] 首次连接,已记录服务器指纹 %s(%s)\n", fp, store)
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// runRemote 执行远程命令并返回输出
|
||
func runRemote(client *ssh.Client, cmd string) (string, error) {
|
||
session, err := client.NewSession()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer session.Close()
|
||
var out, errBuf bytes.Buffer
|
||
session.Stdout = &out
|
||
session.Stderr = &errBuf
|
||
err = session.Run(cmd)
|
||
if err != nil {
|
||
return out.String() + errBuf.String(), err
|
||
}
|
||
return out.String(), nil
|
||
}
|
||
|
||
// ============================ 辅助 ============================
|
||
|
||
// shQuote 单引号包裹(内部单引号转义),避免命令注入与路径空格问题
|
||
func shQuote(s string) string {
|
||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||
}
|
||
|
||
// confirm 交互确认
|
||
func confirm(prompt string) bool {
|
||
fmt.Printf("%s [y/N]: ", prompt)
|
||
var answer string
|
||
_, err := fmt.Scanln(&answer)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
answer = strings.ToLower(strings.TrimSpace(answer))
|
||
return answer == "y" || answer == "yes"
|
||
}
|