// 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 } // 格式: 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" }