Files
2026-10-31 19:17:37 +08:00

653 lines
18 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.
// 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"
}