优化小程序部分
This commit is contained in:
1 parent
727319a6eb
commit
d4e89788c5
46 files changed
+4906
-63
No files matched your search
@@ -0,0 +1,652 @@
|
||||
// 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"
|
||||
}
|
||||
Reference in new issue
Block a user