From fc3a3462cfa766d77f07d173d82fccf679d06838 Mon Sep 17 00:00:00 2001 From: zxyszx <299979470+zxyszx@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:15:19 +0800 Subject: [PATCH] release: prepare v1.2.32 --- .github/release-notes/v1.2.32.md | 11 + README.zh-CN.md | 2 + VERSION | 2 +- apps/api/internal/app/app.go | 3 + apps/api/internal/app/backup_handlers.go | 1339 +++++++++++++++++ apps/api/internal/app/backup_handlers_test.go | 173 +++ apps/api/internal/app/config.go | 4 + apps/api/internal/app/router_auth.go | 13 + apps/api/internal/app/telegram.go | 47 +- apps/web/src/components/protected-layout.tsx | 5 +- apps/web/src/lib/api-types.ts | 5 + apps/web/src/lib/api.ts | 13 +- apps/web/src/pages/admin.tsx | 330 +++- deploy/all-in-one/Dockerfile | 2 +- deploy/docker-compose.yml | 5 + docs/BACKUP_RESTORE.md | 71 + install.sh | 299 +++- tests/install_test.sh | 155 +- 18 files changed, 2463 insertions(+), 16 deletions(-) create mode 100644 .github/release-notes/v1.2.32.md create mode 100644 apps/api/internal/app/backup_handlers.go create mode 100644 apps/api/internal/app/backup_handlers_test.go create mode 100644 docs/BACKUP_RESTORE.md diff --git a/.github/release-notes/v1.2.32.md b/.github/release-notes/v1.2.32.md new file mode 100644 index 0000000..b718b9a --- /dev/null +++ b/.github/release-notes/v1.2.32.md @@ -0,0 +1,11 @@ +- 后台新增“备份与恢复”,可创建、校验、下载、删除完整加密备份;备份包含账号、邮件、附件、Maildir、DKIM、证书和部署配置。 +- 备份使用 AES-256-CBC、PBKDF2 和 SHA-256 校验;支持自行输入或生成 24 位恢复密码,并提供显示、复制和本地密码文件下载。 +- 新增 3、5、7、30 天及自定义周期的定时备份,可独立选择本地保留、Telegram 推送和 Google 云端硬盘。 +- Telegram 备份复用系统已绑定机器人,可沿用邮件通知接收方,也可自动查询多个群组并选择独立备份群组;邮件通知与备份推送互不干扰。 +- 新增 Google 云端硬盘 OAuth 配置、加密令牌保存、专用备份目录、手动上传和定时上传。 +- 安装脚本新增未安装状态管理菜单和“备份恢复”,自动扫描 `/root/` 下的多份备份并按时间排序,支持输入序号恢复。 +- 恢复流程增加压缩包路径、符号链接、特殊文件和 SQLite 完整性校验;失败时清理不完整安装并保留原始加密备份。 +- 优化备份页面的桌面与手机布局、状态对齐、配置弹窗和本地备份列表;修复未配置 Telegram 时本地备份被误报推送失败的问题。 +- 修复后台邮箱管理中失联归属账号可能产生重复列表标识的问题,并将同一归属账号的邮箱重新聚合显示。 + +**完整更新日志**:[v1.2.31...v1.2.32](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.31...v1.2.32) diff --git a/README.zh-CN.md b/README.zh-CN.md index 82d5fa2..814cf6e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -48,6 +48,8 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i ## 更新与回滚 +完整加密备份、Telegram 推送和新服务器恢复流程见 [备份与灾难恢复](docs/BACKUP_RESTORE.md)。 + ### 后台页面更新 超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。 diff --git a/VERSION b/VERSION index 0848465..3725851 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.31 +1.2.32 diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index a24a200..084fc83 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -38,6 +38,8 @@ type App struct { telegramPairMu sync.Mutex telegramPairs map[string]telegramPairing telegramDeliveryMu sync.Mutex + backupMu sync.Mutex + backupJob *backupJob } const ( @@ -130,6 +132,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) { a.startWorker(func() { a.smtpEventsCleanupWorker(workerCtx) }) a.startWorker(func() { a.statusWebhookWorker(workerCtx) }) a.startWorker(func() { a.telegramMailWorker(workerCtx) }) + a.startWorker(func() { a.backupScheduleWorker(workerCtx) }) return a, nil } diff --git a/apps/api/internal/app/backup_handlers.go b/apps/api/internal/app/backup_handlers.go new file mode 100644 index 0000000..3d7f5cf --- /dev/null +++ b/apps/api/internal/app/backup_handlers.go @@ -0,0 +1,1339 @@ +package app + +import ( + "archive/tar" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "net/url" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "golang.org/x/oauth2" +) + +const backupTelegramLimit = 49 << 20 + +type backupJob struct { + Status string `json:"status"` + StartedAt time.Time `json:"startedAt"` + Error string `json:"error,omitempty"` +} + +type backupItem struct { + Name string `json:"name"` + Size int64 `json:"size"` + CreatedAt time.Time `json:"createdAt"` + SHA256 string `json:"sha256,omitempty"` +} + +type backupListResponse struct { + Enabled bool `json:"enabled"` + TelegramSet bool `json:"telegramSet"` + TelegramLimit int64 `json:"telegramLimit"` + Job *backupJob `json:"job,omitempty"` + Items []backupItem `json:"items"` + Schedule backupSchedule `json:"schedule"` + GoogleDrive googleDriveStatus `json:"googleDrive"` +} + +type createBackupRequest struct { + Password string `json:"password"` + ConfirmPassword string `json:"confirmPassword"` + SendTelegram bool `json:"sendTelegram"` + UploadGoogleDrive bool `json:"uploadGoogleDrive"` +} + +type backupSchedule struct { + Enabled bool `json:"enabled"` + Days int `json:"days"` + PasswordSet bool `json:"passwordSet"` + ServerIP string `json:"serverIp"` + ChatID string `json:"chatId"` + TelegramMode string `json:"telegramMode"` + TelegramEnabled bool `json:"telegramEnabled"` + GoogleDriveEnabled bool `json:"googleDriveEnabled"` +} + +type updateBackupScheduleRequest struct { + Enabled bool `json:"enabled"` + Days int `json:"days"` + Password string `json:"password"` + ConfirmPassword string `json:"confirmPassword"` + ServerIP string `json:"serverIp"` + ChatID string `json:"chatId"` + TelegramMode string `json:"telegramMode"` + TelegramEnabled bool `json:"telegramEnabled"` + GoogleDriveEnabled bool `json:"googleDriveEnabled"` + GoogleClientID string `json:"googleClientId"` + GoogleClientSecret string `json:"googleClientSecret"` + GoogleFolderName string `json:"googleFolderName"` +} + +type testBackupTelegramRequest struct { + Mode string `json:"mode"` + ChatID string `json:"chatId"` +} + +type googleDriveStatus struct { + ClientID string `json:"clientId"` + ClientSecretSet bool `json:"clientSecretSet"` + Connected bool `json:"connected"` + FolderName string `json:"folderName"` +} + +type googleDriveOAuthState struct { + ExpiresAt int64 `json:"expiresAt"` + Nonce string `json:"nonce"` +} + +type googleDriveToken struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + Expiry time.Time `json:"expiry"` +} + +func (a *App) requireSystemAdmin(w http.ResponseWriter, r *http.Request) bool { + user := currentUser(r) + if user == nil || user.Role != "admin" { + respondError(w, http.StatusForbidden, "system administrator required") + return false + } + return true +} + +func (a *App) handleListBackups(w http.ResponseWriter, r *http.Request) { + if !a.requireSystemAdmin(w, r) { + return + } + items, err := a.listBackups() + if err != nil { + respondError(w, http.StatusInternalServerError, "无法读取备份列表") + return + } + a.backupMu.Lock() + job := a.backupJob + if job != nil { + copy := *job + job = © + } + a.backupMu.Unlock() + schedule, _ := a.loadBackupSchedule(r.Context()) + telegramToken, telegramDestination, _ := a.backupTelegramCredentials(r.Context(), schedule) + respondJSON(w, http.StatusOK, backupListResponse{ + Enabled: strings.TrimSpace(a.config().BackupSourceDir) != "" && strings.TrimSpace(a.config().BackupDir) != "", + TelegramSet: strings.TrimSpace(telegramToken) != "" && validTelegramPrivateChatID(telegramDestination), + TelegramLimit: backupTelegramLimit, Job: job, Items: items, Schedule: schedule, + GoogleDrive: a.loadGoogleDriveStatus(r.Context()), + }) +} + +func (a *App) handleCreateBackup(w http.ResponseWriter, r *http.Request) { + if !a.requireSystemAdmin(w, r) { + return + } + var req createBackupRequest + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + if !validBackupPassword(req.Password) { + badRequest(w, errors.New("备份密码至少需要 8 个字符")) + return + } + if req.Password != req.ConfirmPassword { + badRequest(w, errors.New("两次输入的备份密码不一致")) + return + } + cfg := a.config() + if strings.TrimSpace(cfg.BackupSourceDir) == "" || strings.TrimSpace(cfg.BackupDir) == "" { + respondError(w, http.StatusServiceUnavailable, "当前部署尚未启用完整备份") + return + } + a.backupMu.Lock() + if a.backupJob != nil && a.backupJob.Status == "running" { + a.backupMu.Unlock() + respondError(w, http.StatusConflict, "已有备份任务正在运行") + return + } + a.backupJob = &backupJob{Status: "running", StartedAt: a.now().UTC()} + a.backupMu.Unlock() + password, sendTelegram, uploadGoogleDrive := req.Password, req.SendTelegram, req.UploadGoogleDrive + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour) + defer cancel() + path, err := a.createDisasterBackup(ctx, password) + password = "" + if err == nil { + var deliveryErrors []error + if uploadGoogleDrive { + if driveErr := a.uploadBackupToGoogleDrive(ctx, path); driveErr != nil { + deliveryErrors = append(deliveryErrors, fmt.Errorf("google drive: %w", driveErr)) + } + } + if sendTelegram { + if telegramErr := a.sendBackupToTelegram(ctx, path); telegramErr != nil { + deliveryErrors = append(deliveryErrors, fmt.Errorf("telegram: %w", telegramErr)) + } + } + err = errors.Join(deliveryErrors...) + } + a.backupMu.Lock() + if err != nil { + a.backupJob.Status = "failed" + a.backupJob.Error = "本地备份或所选推送未全部完成,请查看服务日志" + a.log.Error("create disaster backup", "error", err) + } else { + a.backupJob.Status = "success" + } + a.backupMu.Unlock() + }() + respondJSON(w, http.StatusAccepted, map[string]any{"ok": true, "message": "备份任务已开始"}) +} + +func (a *App) handleUpdateBackupSettings(w http.ResponseWriter, r *http.Request) { + if !a.requireSystemAdmin(w, r) { + return + } + var req updateBackupScheduleRequest + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + if req.Days < 1 || req.Days > 365 { + badRequest(w, errors.New("备份周期必须为 1 至 365 天")) + return + } + var ciphertext string + _ = a.db.QueryRowContext(r.Context(), `SELECT value FROM system_settings WHERE key='backupPasswordCipher'`).Scan(&ciphertext) + if req.Password != "" { + if !validBackupPassword(req.Password) { + badRequest(w, errors.New("备份密码至少需要 8 个字符")) + return + } + if req.Password != req.ConfirmPassword { + badRequest(w, errors.New("两次输入的备份密码不一致")) + return + } + var err error + ciphertext, err = a.encryptBackupPassword(req.Password) + if err != nil { + respondError(w, 500, "无法安全保存备份密码") + return + } + } + if req.Enabled && ciphertext == "" { + badRequest(w, errors.New("启用定时备份前请设置备份密码")) + return + } + if req.Enabled && strings.TrimSpace(a.config().UpdateServiceToken) == "" { + badRequest(w, errors.New("当前部署缺少备份密码加密密钥,请先更新部署配置")) + return + } + chatID := strings.TrimSpace(req.ChatID) + if chatID != "" && !validTelegramPrivateChatID(chatID) { + badRequest(w, errors.New("备份 Telegram Chat ID 无效")) + return + } + telegramMode := strings.TrimSpace(req.TelegramMode) + if telegramMode != "custom" { + telegramMode = "system" + } + if telegramMode == "custom" && !validTelegramPrivateChatID(chatID) { + badRequest(w, errors.New("请选择备份群组并填写有效的 Chat ID")) + return + } + if req.Enabled && req.TelegramEnabled { + cfg := a.config() + destination := cfg.TelegramPrivateChatID + if telegramMode == "custom" { + destination = chatID + } + if cfg.TelegramBotToken == "" || !validTelegramPrivateChatID(destination) { + badRequest(w, errors.New("启用定时推送前请先在系统设置绑定 Telegram 机器人并配置接收位置")) + return + } + } + if req.Enabled && req.GoogleDriveEnabled && !a.loadGoogleDriveStatus(r.Context()).Connected { + badRequest(w, errors.New("启用 Google 云端硬盘备份前请先完成授权")) + return + } + secretCipher := "" + _ = a.db.QueryRowContext(r.Context(), `SELECT value FROM system_settings WHERE key='backupGoogleClientSecretCipher'`).Scan(&secretCipher) + if strings.TrimSpace(req.GoogleClientSecret) != "" { + var err error + secretCipher, err = a.encryptBackupPassword(strings.TrimSpace(req.GoogleClientSecret)) + if err != nil { + respondError(w, 500, "无法安全保存 Google 客户端密钥") + return + } + } + folderName := strings.TrimSpace(req.GoogleFolderName) + if folderName == "" { + folderName = "NewSzxcn Backups" + } + values := map[string]string{ + "backupScheduleEnabled": fmt.Sprint(req.Enabled), "backupScheduleDays": fmt.Sprint(req.Days), + "backupServerIp": strings.TrimSpace(req.ServerIP), "backupTelegramChatId": chatID, + "backupTelegramMode": telegramMode, + "backupPasswordCipher": ciphertext, "backupTelegramEnabled": fmt.Sprint(req.TelegramEnabled), + "backupGoogleDriveEnabled": fmt.Sprint(req.GoogleDriveEnabled), "backupGoogleClientId": strings.TrimSpace(req.GoogleClientID), + "backupGoogleClientSecretCipher": secretCipher, "backupGoogleFolderName": folderName, + } + now := a.now().UTC().Format(time.RFC3339Nano) + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + respondError(w, 500, "保存失败") + return + } + defer tx.Rollback() + for key, value := range values { + if _, err = tx.ExecContext(r.Context(), `INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`, key, value, now); err != nil { + respondError(w, 500, "保存失败") + return + } + } + if err = tx.Commit(); err != nil { + respondError(w, 500, "保存失败") + return + } + respondJSON(w, 200, backupSchedule{Enabled: req.Enabled, Days: req.Days, PasswordSet: ciphertext != "", ServerIP: strings.TrimSpace(req.ServerIP), ChatID: chatID, TelegramMode: telegramMode, TelegramEnabled: req.TelegramEnabled, GoogleDriveEnabled: req.GoogleDriveEnabled}) +} + +func validBackupPassword(password string) bool { + return len(password) >= 8 && len(password) <= 1024 && !strings.ContainsAny(password, "\r\n\x00") +} + +func (a *App) handleTestBackupTelegram(w http.ResponseWriter, r *http.Request) { + if !a.requireSystemAdmin(w, r) { + return + } + var req testBackupTelegramRequest + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + cfg := a.config() + token, chatID := strings.TrimSpace(cfg.TelegramBotToken), strings.TrimSpace(cfg.TelegramPrivateChatID) + if req.Mode == "custom" { + chatID = strings.TrimSpace(req.ChatID) + } + if token == "" || !validTelegramPrivateChatID(chatID) { + badRequest(w, errors.New("请先完成 Telegram 机器人和 Chat ID 配置")) + return + } + now := a.now().Local().Format("2006-01-02 15:04:05 MST") + message := "NewSzxcn 备份通知测试\n\nTelegram 备份接收配置正常。\n\n测试时间:" + htmlEscape(now) + if err := a.sendTelegramMessage(r.Context(), token, chatID, message); err != nil { + respondError(w, http.StatusBadGateway, err.Error()) + return + } + respondJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (a *App) handleDiscoverBackupTelegramGroup(w http.ResponseWriter, r *http.Request) { + if !a.requireSystemAdmin(w, r) { + return + } + var req telegramCredentialsRequest + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + token := strings.TrimSpace(a.config().TelegramBotToken) + code := strings.ToUpper(strings.TrimSpace(req.PairingCode)) + if token == "" || code == "" { + badRequest(w, errors.New("请先生成 Telegram 群组查询码")) + return + } + a.telegramPairMu.Lock() + pairing, ok := a.telegramPairs[code] + a.telegramPairMu.Unlock() + if !ok || !pairing.ExpiresAt.After(a.now().UTC()) || pairing.TokenFingerprint != telegramTokenFingerprint(token) { + badRequest(w, errors.New("Telegram 群组查询码无效或已过期,请重新生成")) + return + } + groups, err := a.discoverTelegramGroups(r.Context(), token, code) + if err != nil { + respondError(w, http.StatusBadGateway, err.Error()) + return + } + a.telegramPairMu.Lock() + delete(a.telegramPairs, code) + a.telegramPairMu.Unlock() + respondJSON(w, http.StatusOK, map[string]any{"items": groups}) +} + +func (a *App) googleDriveOAuthConfig(ctx context.Context) (*oauth2.Config, error) { + values := map[string]string{} + rows, err := a.db.QueryContext(ctx, `SELECT key,value FROM system_settings WHERE key IN ('backupGoogleClientId','backupGoogleClientSecretCipher')`) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var key, value string + if err := rows.Scan(&key, &value); err != nil { + return nil, err + } + values[key] = value + } + secret := "" + if values["backupGoogleClientSecretCipher"] != "" { + secret, err = a.decryptBackupPassword(values["backupGoogleClientSecretCipher"]) + if err != nil { + return nil, err + } + } + if strings.TrimSpace(values["backupGoogleClientId"]) == "" || secret == "" { + return nil, errors.New("请先填写并保存 Google OAuth 客户端 ID 和密钥") + } + return &oauth2.Config{ClientID: values["backupGoogleClientId"], ClientSecret: secret, RedirectURL: strings.TrimRight(a.config().PublicBaseURL, "/") + "/api/admin/backups/google-drive/callback", Scopes: []string{"https://www.googleapis.com/auth/drive.file"}, Endpoint: oauth2.Endpoint{AuthURL: "https://accounts.google.com/o/oauth2/v2/auth", TokenURL: "https://oauth2.googleapis.com/token"}}, nil +} + +func (a *App) handleGoogleDriveConnect(w http.ResponseWriter, r *http.Request) { + if !a.requireSystemAdmin(w, r) { + return + } + conf, err := a.googleDriveOAuthConfig(r.Context()) + if err != nil { + badRequest(w, err) + return + } + raw, _ := json.Marshal(googleDriveOAuthState{ExpiresAt: a.now().Add(10 * time.Minute).Unix(), Nonce: newID("drive")}) + state, err := a.encryptBackupPassword(string(raw)) + if err != nil { + respondError(w, 500, "无法创建授权请求") + return + } + state = base64.RawURLEncoding.EncodeToString([]byte(state)) + respondJSON(w, 200, map[string]string{"url": conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce)}) +} + +func (a *App) handleGoogleDriveCallback(w http.ResponseWriter, r *http.Request) { + if !a.requireSystemAdmin(w, r) { + return + } + encoded, err := base64.RawURLEncoding.DecodeString(r.URL.Query().Get("state")) + if err != nil { + badRequest(w, errors.New("Google 授权状态无效")) + return + } + plain, err := a.decryptBackupPassword(string(encoded)) + if err != nil { + badRequest(w, errors.New("Google 授权状态无效")) + return + } + var state googleDriveOAuthState + if json.Unmarshal([]byte(plain), &state) != nil || state.ExpiresAt < a.now().Unix() { + badRequest(w, errors.New("Google 授权已过期,请重新连接")) + return + } + if oauthErr := r.URL.Query().Get("error"); oauthErr != "" { + http.Redirect(w, r, strings.TrimRight(a.config().PublicBaseURL, "/")+"/admin?section=backups&drive=error", http.StatusFound) + return + } + conf, err := a.googleDriveOAuthConfig(r.Context()) + if err != nil { + badRequest(w, err) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + token, err := conf.Exchange(ctx, r.URL.Query().Get("code")) + if err != nil { + respondError(w, 400, "Google 授权交换失败") + return + } + raw, _ := json.Marshal(googleDriveToken{AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, Expiry: token.Expiry}) + ciphertext, err := a.encryptBackupPassword(string(raw)) + if err != nil { + respondError(w, 500, "无法保存 Google 授权") + return + } + now := a.now().UTC().Format(time.RFC3339Nano) + _, err = a.db.ExecContext(r.Context(), `INSERT INTO system_settings(key,value,updated_at) VALUES('backupGoogleTokenCipher',?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`, ciphertext, now) + if err != nil { + respondError(w, 500, "无法保存 Google 授权") + return + } + http.Redirect(w, r, strings.TrimRight(a.config().PublicBaseURL, "/")+"/admin?section=backups&drive=connected", http.StatusFound) +} + +func (a *App) handleGoogleDriveDisconnect(w http.ResponseWriter, r *http.Request) { + if !a.requireSystemAdmin(w, r) { + return + } + _, err := a.db.ExecContext(r.Context(), `DELETE FROM system_settings WHERE key IN ('backupGoogleTokenCipher','backupGoogleDriveEnabled')`) + if err != nil { + respondError(w, 500, "断开失败") + return + } + respondJSON(w, 200, map[string]bool{"ok": true}) +} + +func (a *App) loadGoogleDriveStatus(ctx context.Context) googleDriveStatus { + status := googleDriveStatus{FolderName: "NewSzxcn Backups"} + rows, err := a.db.QueryContext(ctx, `SELECT key,value FROM system_settings WHERE key IN ('backupGoogleClientId','backupGoogleClientSecretCipher','backupGoogleTokenCipher','backupGoogleFolderName')`) + if err != nil { + return status + } + defer rows.Close() + for rows.Next() { + var key, value string + if rows.Scan(&key, &value) != nil { + continue + } + switch key { + case "backupGoogleClientId": + status.ClientID = value + case "backupGoogleClientSecretCipher": + status.ClientSecretSet = value != "" + case "backupGoogleTokenCipher": + status.Connected = value != "" + case "backupGoogleFolderName": + if strings.TrimSpace(value) != "" { + status.FolderName = value + } + } + } + return status +} + +func (a *App) createDisasterBackup(ctx context.Context, password string) (string, error) { + cfg := a.config() + if cfg.BackupSourceDir == "" || cfg.BackupDir == "" { + return "", errors.New("backup directories are not configured") + } + if err := os.MkdirAll(cfg.BackupDir, 0o700); err != nil { + return "", err + } + work, err := os.MkdirTemp(cfg.BackupDir, ".staging-") + if err != nil { + return "", err + } + defer os.RemoveAll(work) + root := filepath.Join(work, "newszxcn-backup") + for _, dir := range []string{"data", "mail", "dkim", "certs"} { + if err := os.MkdirAll(filepath.Join(root, dir), 0o700); err != nil { + return "", err + } + } + quoted := strings.ReplaceAll(filepath.Join(root, "data", "lanqin.db"), "'", "''") + if _, err := a.db.ExecContext(ctx, "VACUUM INTO '"+quoted+"'"); err != nil { + return "", err + } + if err := copyTree(cfg.DataDir, filepath.Join(root, "data"), map[string]bool{"lanqin.db": true, "lanqin.db-wal": true, "lanqin.db-shm": true, "backups": true, "disaster-backups": true}); err != nil { + return "", err + } + if cfg.MaildirRoot != "" { + if err := copyTree(cfg.MaildirRoot, filepath.Join(root, "mail"), nil); err != nil { + return "", err + } + } + for _, item := range []struct{ src, dst string }{{"/var/lib/rspamd/dkim", "dkim"}, {"/certs", "certs"}} { + if err := copyTree(item.src, filepath.Join(root, item.dst), nil); err != nil && !os.IsNotExist(err) { + return "", err + } + } + for _, name := range []string{".env", "docker-compose.yml"} { + if err := copyFile(filepath.Join(cfg.BackupSourceDir, name), filepath.Join(root, name)); err != nil { + return "", err + } + } + manifest := map[string]any{"format": 1, "version": cfg.AppVersion, "createdAt": a.now().UTC(), "hostname": cfg.PublicHostname} + raw, _ := json.MarshalIndent(manifest, "", " ") + if err := os.WriteFile(filepath.Join(root, "manifest.json"), raw, 0o600); err != nil { + return "", err + } + tarPath := filepath.Join(work, "backup.tar") + if err := writeTar(tarPath, root); err != nil { + return "", err + } + zstPath := tarPath + ".zst" + if output, err := exec.CommandContext(ctx, "zstd", "-q", "-T0", "-10", tarPath, "-o", zstPath).CombinedOutput(); err != nil { + return "", fmt.Errorf("zstd: %w: %s", err, output) + } + name := fmt.Sprintf("newszxcn-backup-%s-%s.tar.zst.enc", a.now().UTC().Format("20060102-150405"), strings.TrimPrefix(cfg.AppVersion, "v")) + outPath := filepath.Join(cfg.BackupDir, name) + cmd := exec.CommandContext(ctx, "openssl", "enc", "-aes-256-cbc", "-salt", "-pbkdf2", "-iter", "200000", "-md", "sha256", "-in", zstPath, "-out", outPath, "-pass", "stdin") + cmd.Stdin = strings.NewReader(password) + if output, err := cmd.CombinedOutput(); err != nil { + os.Remove(outPath) + return "", fmt.Errorf("openssl: %w: %s", err, output) + } + if err := os.Chmod(outPath, 0o600); err != nil { + _ = os.Remove(outPath) + return "", err + } + sum, err := fileSHA256(outPath) + if err != nil { + _ = os.Remove(outPath) + return "", err + } + if err := os.WriteFile(outPath+".sha256", []byte(sum+" "+name+"\n"), 0o600); err != nil { + _ = os.Remove(outPath) + return "", err + } + if err := a.pruneDisasterBackups(10); err != nil { + a.log.Warn("prune disaster backups", "error", err) + } + return outPath, nil +} + +func (a *App) handleDownloadBackup(w http.ResponseWriter, r *http.Request) { + if !a.requireSystemAdmin(w, r) { + return + } + path, ok := a.backupPath(chi.URLParam(r, "name")) + if !ok { + respondError(w, http.StatusNotFound, "备份不存在") + return + } + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filepath.Base(path))) + w.Header().Set("Content-Type", "application/octet-stream") + http.ServeFile(w, r, path) +} + +func (a *App) handleVerifyBackup(w http.ResponseWriter, r *http.Request) { + if !a.requireSystemAdmin(w, r) { + return + } + path, ok := a.backupPath(chi.URLParam(r, "name")) + if !ok { + respondError(w, http.StatusNotFound, "备份不存在") + return + } + actual, err := fileSHA256(path) + if err != nil { + respondError(w, 500, "校验失败") + return + } + expectedRaw, err := os.ReadFile(path + ".sha256") + if err != nil { + respondError(w, 500, "校验文件缺失") + return + } + expected := strings.Fields(string(expectedRaw)) + valid := len(expected) > 0 && expected[0] == actual + respondJSON(w, 200, map[string]any{"ok": valid, "sha256": actual}) +} + +func (a *App) handleDeleteBackup(w http.ResponseWriter, r *http.Request) { + if !a.requireSystemAdmin(w, r) { + return + } + path, ok := a.backupPath(chi.URLParam(r, "name")) + if !ok { + respondError(w, 404, "备份不存在") + return + } + if err := os.Remove(path); err != nil { + respondError(w, 500, "删除失败") + return + } + _ = os.Remove(path + ".sha256") + respondJSON(w, 200, map[string]any{"ok": true}) +} + +func (a *App) handleSendBackupTelegram(w http.ResponseWriter, r *http.Request) { + if !a.requireSystemAdmin(w, r) { + return + } + path, ok := a.backupPath(chi.URLParam(r, "name")) + if !ok { + respondError(w, 404, "备份不存在") + return + } + if err := a.sendBackupToTelegram(r.Context(), path); err != nil { + a.log.Error("send backup telegram", "error", err) + respondError(w, 502, err.Error()) + return + } + respondJSON(w, 200, map[string]any{"ok": true}) +} + +func (a *App) handleSendBackupGoogleDrive(w http.ResponseWriter, r *http.Request) { + if !a.requireSystemAdmin(w, r) { + return + } + path, ok := a.backupPath(chi.URLParam(r, "name")) + if !ok { + respondError(w, 404, "备份不存在") + return + } + if err := a.uploadBackupToGoogleDrive(r.Context(), path); err != nil { + a.log.Error("upload backup to google drive", "error", err) + respondError(w, 502, "上传 Google 云端硬盘失败") + return + } + respondJSON(w, 200, map[string]bool{"ok": true}) +} + +func (a *App) googleDriveClient(ctx context.Context) (*http.Client, error) { + conf, err := a.googleDriveOAuthConfig(ctx) + if err != nil { + return nil, err + } + var ciphertext string + if err := a.db.QueryRowContext(ctx, `SELECT value FROM system_settings WHERE key='backupGoogleTokenCipher'`).Scan(&ciphertext); err != nil { + return nil, errors.New("Google 云端硬盘尚未连接") + } + plain, err := a.decryptBackupPassword(ciphertext) + if err != nil { + return nil, err + } + var saved googleDriveToken + if err := json.Unmarshal([]byte(plain), &saved); err != nil { + return nil, err + } + original := &oauth2.Token{AccessToken: saved.AccessToken, RefreshToken: saved.RefreshToken, Expiry: saved.Expiry, TokenType: "Bearer"} + refreshed, err := conf.TokenSource(ctx, original).Token() + if err != nil { + return nil, err + } + if refreshed.AccessToken != original.AccessToken || !refreshed.Expiry.Equal(original.Expiry) { + refreshToken := refreshed.RefreshToken + if refreshToken == "" { + refreshToken = saved.RefreshToken + } + raw, _ := json.Marshal(googleDriveToken{AccessToken: refreshed.AccessToken, RefreshToken: refreshToken, Expiry: refreshed.Expiry}) + ciphertext, encryptErr := a.encryptBackupPassword(string(raw)) + if encryptErr != nil { + return nil, encryptErr + } + now := a.now().UTC().Format(time.RFC3339Nano) + if _, err := a.db.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES('backupGoogleTokenCipher',?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`, ciphertext, now); err != nil { + return nil, err + } + } + return oauth2.NewClient(ctx, oauth2.StaticTokenSource(refreshed)), nil +} + +func (a *App) googleDriveFolderID(ctx context.Context, client *http.Client, name string) (string, error) { + escaped := strings.ReplaceAll(name, "'", "\\'") + query := fmt.Sprintf("name = '%s' and mimeType = 'application/vnd.google-apps.folder' and trashed = false", escaped) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://www.googleapis.com/drive/v3/files?spaces=drive&fields=files(id,name)&pageSize=1&q="+url.QueryEscape(query), nil) + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return "", fmt.Errorf("drive folder lookup %s: %s", resp.Status, raw) + } + var list struct { + Files []struct { + ID string `json:"id"` + } `json:"files"` + } + if err := json.NewDecoder(resp.Body).Decode(&list); err != nil { + return "", err + } + if len(list.Files) > 0 { + return list.Files[0].ID, nil + } + body, _ := json.Marshal(map[string]any{"name": name, "mimeType": "application/vnd.google-apps.folder"}) + req, _ = http.NewRequestWithContext(ctx, http.MethodPost, "https://www.googleapis.com/drive/v3/files?fields=id", strings.NewReader(string(body))) + req.Header.Set("Content-Type", "application/json") + resp, err = client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return "", fmt.Errorf("drive folder create %s: %s", resp.Status, raw) + } + var created struct { + ID string `json:"id"` + } + if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { + return "", err + } + if created.ID == "" { + return "", errors.New("Google 云端硬盘未返回文件夹 ID") + } + return created.ID, nil +} + +func (a *App) uploadBackupToGoogleDrive(ctx context.Context, path string) error { + client, err := a.googleDriveClient(ctx) + if err != nil { + return err + } + folder := a.loadGoogleDriveStatus(ctx).FolderName + folderID, err := a.googleDriveFolderID(ctx, client, folder) + if err != nil { + return err + } + req, err := newGoogleDriveUploadRequest(ctx, path, folderID) + if err != nil { + return err + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("drive upload %s: %s", resp.Status, raw) + } + return nil +} + +func newGoogleDriveUploadRequest(ctx context.Context, path, folderID string) (*http.Request, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + reader, writerSide := io.Pipe() + mw := multipart.NewWriter(writerSide) + go func() { + var writeErr error + defer func() { _ = file.Close(); _ = mw.Close(); _ = writerSide.CloseWithError(writeErr) }() + head := textproto.MIMEHeader{} + head.Set("Content-Type", "application/json; charset=UTF-8") + part, err := mw.CreatePart(head) + if err != nil { + writeErr = err + return + } + metadata, _ := json.Marshal(map[string]any{"name": filepath.Base(path), "parents": []string{folderID}}) + if _, err = part.Write(metadata); err != nil { + writeErr = err + return + } + head = textproto.MIMEHeader{} + head.Set("Content-Type", "application/octet-stream") + part, err = mw.CreatePart(head) + if err != nil { + writeErr = err + return + } + _, writeErr = io.Copy(part, file) + }() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,name", reader) + if err != nil { + _ = file.Close() + _ = writerSide.CloseWithError(err) + return nil, err + } + req.Header.Set("Content-Type", "multipart/related; boundary="+mw.Boundary()) + return req, nil +} + +func (a *App) sendBackupToTelegram(ctx context.Context, path string) error { + schedule, _ := a.loadBackupSchedule(ctx) + token, chatID, err := a.backupTelegramCredentials(ctx, schedule) + if err != nil { + return err + } + if token == "" || !validTelegramPrivateChatID(chatID) { + return errors.New("请先在系统设置绑定 Telegram 机器人") + } + info, err := os.Stat(path) + if err != nil { + return err + } + if info.Size() > backupTelegramLimit { + return errors.New("备份超过 Telegram 发送上限,请下载后保存到其他存储") + } + report, err := a.backupTelegramReport(ctx, path, info) + if err != nil { + return err + } + if err := a.sendTelegramMessage(ctx, token, chatID, report); err != nil { + return err + } + return a.sendTelegramDocument(ctx, token, chatID, path) +} + +func (a *App) backupTelegramCredentials(ctx context.Context, schedule backupSchedule) (string, string, error) { + cfg := a.config() + if schedule.TelegramMode != "custom" { + return strings.TrimSpace(cfg.TelegramBotToken), strings.TrimSpace(cfg.TelegramPrivateChatID), nil + } + return strings.TrimSpace(cfg.TelegramBotToken), strings.TrimSpace(schedule.ChatID), nil +} + +func (a *App) backupTelegramReport(ctx context.Context, path string, info os.FileInfo) (string, error) { + cfg := a.config() + schedule, _ := a.loadBackupSchedule(ctx) + sum, _ := fileSHA256(path) + domains, err := queryBackupStrings(ctx, a.db, `SELECT name FROM domains ORDER BY name`) + if err != nil { + return "", err + } + admins, err := queryBackupStrings(ctx, a.db, `SELECT email FROM users WHERE role='admin' ORDER BY email`) + if err != nil { + return "", err + } + users, err := queryBackupStrings(ctx, a.db, `SELECT email FROM users WHERE role='user' ORDER BY email`) + if err != nil { + return "", err + } + mailboxes, err := queryBackupStrings(ctx, a.db, `SELECT address FROM mailboxes ORDER BY address`) + if err != nil { + return "", err + } + list := func(items []string) string { + if len(items) == 0 { + return "无" + } + total, suffix := len(items), "" + if total > 10 { + items = items[:10] + suffix = fmt.Sprintf(" 等 %d 个", total) + } + for i := range items { + value, truncated := truncateRunes(items[i], 80) + if truncated { + value += "..." + } + items[i] = htmlEscape(value) + } + return strings.Join(items, "、") + suffix + } + serverIP := strings.TrimSpace(schedule.ServerIP) + if serverIP == "" { + serverIP = "未填写" + } + return fmt.Sprintf("%s 备份成功\n\n邮局域名:%s\n服务器 IP:%s\n系统版本:%s\n\n已有域名:\n%s\n\n管理员账号:\n%s\n\n普通用户账号:\n%s\n\n邮箱账号:\n%s\n\n备份文件:%s\n文件大小:%s\nSHA-256:%s\n\n恢复教程:\n1. 请不要解压、改名或修改压缩备份文件。\n2. 将原始附件上传到新服务器的 /root/ 目录。\n3. 运行官方安装脚本,显示管理菜单后输入 2,选择“备份恢复”。\n4. 选择“本地上传”,系统会自动检测 /root/ 中的备份。\n5. 只有一份时自动选中;多份时显示 1、2、3 等序号。\n6. 输入对应序号,例如输入 1 恢复第 1 份。\n7. 输入备份密码后开始恢复。没有检测到文件时才手动输入路径。\n8. 恢复完成后,账号继续使用原登录密码。\n9. 以后需要管理系统时,可以直接输入 ns 打开管理菜单。\n\n安全提示:备份密码不会发送到 Telegram,请从 1Password 等独立位置取用。", info.ModTime().Local().Format("2006-01-02"), htmlEscape(cfg.PublicHostname), htmlEscape(serverIP), htmlEscape(cfg.AppVersion), list(domains), list(admins), list(users), list(mailboxes), htmlEscape(filepath.Base(path)), humanBackupBytes(info.Size()), sum), nil +} + +func queryBackupStrings(ctx context.Context, db *sql.DB, query string) ([]string, error) { + rows, err := db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + var values []string + for rows.Next() { + var value string + if err := rows.Scan(&value); err != nil { + return nil, err + } + values = append(values, value) + } + return values, rows.Err() +} + +func humanBackupBytes(value int64) string { + if value < 1024 { + return fmt.Sprintf("%d B", value) + } + units := []string{"KB", "MB", "GB", "TB"} + size := float64(value) + unit := "B" + for _, next := range units { + size /= 1024 + unit = next + if size < 1024 { + break + } + } + return fmt.Sprintf("%.1f %s", size, unit) +} + +func (a *App) sendTelegramDocument(ctx context.Context, token, chatID, path string) error { + reader, pipeWriter := io.Pipe() + writer := multipart.NewWriter(pipeWriter) + go func() { + var writeErr error + defer func() { _ = writer.Close(); _ = pipeWriter.CloseWithError(writeErr) }() + if writeErr = writer.WriteField("chat_id", chatID); writeErr != nil { + return + } + if writeErr = writer.WriteField("caption", "NewSzxcn 加密备份\n请将备份密码单独保管,不要发送到同一聊天。"); writeErr != nil { + return + } + var part io.Writer + part, writeErr = writer.CreateFormFile("document", filepath.Base(path)) + if writeErr != nil { + return + } + var file *os.File + file, writeErr = os.Open(path) + if writeErr != nil { + return + } + defer file.Close() + _, writeErr = io.Copy(part, file) + }() + endpoint := strings.TrimRight(a.telegramURL, "/") + "/bot" + token + "/sendDocument" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, reader) + if err != nil { + return err + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + resp, err := (&http.Client{Timeout: 10 * time.Minute}).Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("telegram %s: %s", resp.Status, raw) + } + return nil +} + +func (a *App) listBackups() ([]backupItem, error) { + dir := a.config().BackupDir + if dir == "" { + return []backupItem{}, nil + } + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return []backupItem{}, nil + } + if err != nil { + return nil, err + } + items := make([]backupItem, 0) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".tar.zst.enc") { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + item := backupItem{Name: entry.Name(), Size: info.Size(), CreatedAt: info.ModTime().UTC()} + if raw, err := os.ReadFile(filepath.Join(dir, entry.Name()+".sha256")); err == nil { + fields := strings.Fields(string(raw)) + if len(fields) > 0 { + item.SHA256 = fields[0] + } + } + items = append(items, item) + } + sort.Slice(items, func(i, j int) bool { return items[i].CreatedAt.After(items[j].CreatedAt) }) + return items, nil +} + +func (a *App) pruneDisasterBackups(keep int) error { + items, err := a.listBackups() + if err != nil { + return err + } + for _, item := range items[minimumInt(keep, len(items)):] { + path, ok := a.backupPath(item.Name) + if !ok { + continue + } + if err := os.Remove(path); err != nil { + return err + } + _ = os.Remove(path + ".sha256") + } + return nil +} + +func minimumInt(left, right int) int { + if left < right { + return left + } + return right +} + +func (a *App) loadBackupSchedule(ctx context.Context) (backupSchedule, error) { + result := backupSchedule{Days: 7, TelegramMode: "system", TelegramEnabled: true} + rows, err := a.db.QueryContext(ctx, `SELECT key,value FROM system_settings WHERE key IN ('backupScheduleEnabled','backupScheduleDays','backupServerIp','backupTelegramChatId','backupTelegramMode','backupPasswordCipher','backupTelegramEnabled','backupGoogleDriveEnabled')`) + if err != nil { + return result, err + } + defer rows.Close() + for rows.Next() { + var key, value string + if err := rows.Scan(&key, &value); err != nil { + return result, err + } + switch key { + case "backupScheduleEnabled": + result.Enabled = value == "true" + case "backupScheduleDays": + if _, err := fmt.Sscan(value, &result.Days); err != nil || result.Days < 1 { + result.Days = 7 + } + case "backupServerIp": + result.ServerIP = value + case "backupTelegramChatId": + result.ChatID = value + case "backupTelegramMode": + if value == "custom" { + result.TelegramMode = "custom" + } + case "backupPasswordCipher": + result.PasswordSet = value != "" + case "backupTelegramEnabled": + result.TelegramEnabled = value == "true" + case "backupGoogleDriveEnabled": + result.GoogleDriveEnabled = value == "true" + } + } + return result, rows.Err() +} + +func (a *App) backupScheduleWorker(ctx context.Context) { + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + a.runScheduledBackup(ctx) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + a.runScheduledBackup(ctx) + } + } +} + +func (a *App) runScheduledBackup(ctx context.Context) { + schedule, err := a.loadBackupSchedule(ctx) + if err != nil || !schedule.Enabled { + return + } + var last string + _ = a.db.QueryRowContext(ctx, `SELECT value FROM system_settings WHERE key='backupScheduleLastRun'`).Scan(&last) + if parsed, err := time.Parse(time.RFC3339Nano, last); err == nil && a.now().UTC().Before(parsed.Add(time.Duration(schedule.Days)*24*time.Hour)) { + return + } + var ciphertext string + if err := a.db.QueryRowContext(ctx, `SELECT value FROM system_settings WHERE key='backupPasswordCipher'`).Scan(&ciphertext); err != nil { + return + } + password, err := a.decryptBackupPassword(ciphertext) + if err != nil { + a.log.Error("decrypt scheduled backup password", "error", err) + return + } + a.backupMu.Lock() + if a.backupJob != nil && a.backupJob.Status == "running" { + a.backupMu.Unlock() + return + } + a.backupJob = &backupJob{Status: "running", StartedAt: a.now().UTC()} + a.backupMu.Unlock() + path, runErr := a.createDisasterBackup(ctx, password) + password = "" + localBackupSucceeded := runErr == nil + if localBackupSucceeded { + now := a.now().UTC().Format(time.RFC3339Nano) + _, _ = a.db.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES('backupScheduleLastRun',?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`, now, now) + var deliveryErrors []error + if schedule.GoogleDriveEnabled { + if driveErr := a.uploadBackupToGoogleDrive(ctx, path); driveErr != nil { + deliveryErrors = append(deliveryErrors, fmt.Errorf("google drive: %w", driveErr)) + } + } + if schedule.TelegramEnabled { + if telegramErr := a.sendBackupToTelegram(ctx, path); telegramErr != nil { + deliveryErrors = append(deliveryErrors, fmt.Errorf("telegram: %w", telegramErr)) + } + } + runErr = errors.Join(deliveryErrors...) + } + status := "success" + publicError := "" + if runErr != nil { + status = "failed" + publicError = "定时备份或云端推送失败,请查看服务日志" + a.log.Error("scheduled backup", "error", runErr) + } + a.backupMu.Lock() + a.backupJob.Status, a.backupJob.Error = status, publicError + a.backupMu.Unlock() +} + +func (a *App) backupEncryptionKey() []byte { + cfg := a.config() + secret := strings.TrimSpace(cfg.UpdateServiceToken) + if secret == "" { + secret = strings.TrimSpace(cfg.ExternalIMAPSecretKey) + } + sum := sha256.Sum256([]byte("newszxcn-backup-schedule:" + secret)) + return sum[:] +} + +func (a *App) encryptBackupPassword(password string) (string, error) { + if strings.TrimSpace(a.config().UpdateServiceToken) == "" && strings.TrimSpace(a.config().ExternalIMAPSecretKey) == "" { + return "", errors.New("backup encryption key is not configured") + } + block, err := aes.NewCipher(a.backupEncryptionKey()) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(append(nonce, gcm.Seal(nil, nonce, []byte(password), nil)...)), nil +} + +func (a *App) decryptBackupPassword(value string) (string, error) { + if strings.TrimSpace(a.config().UpdateServiceToken) == "" && strings.TrimSpace(a.config().ExternalIMAPSecretKey) == "" { + return "", errors.New("backup encryption key is not configured") + } + raw, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return "", err + } + block, err := aes.NewCipher(a.backupEncryptionKey()) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + if len(raw) < gcm.NonceSize() { + return "", errors.New("invalid backup password ciphertext") + } + plain, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil) + if err != nil { + return "", err + } + return string(plain), nil +} + +func (a *App) backupPath(name string) (string, bool) { + if filepath.Base(name) != name || !strings.HasPrefix(name, "newszxcn-backup-") || !strings.HasSuffix(name, ".tar.zst.enc") { + return "", false + } + path := filepath.Join(a.config().BackupDir, name) + info, err := os.Stat(path) + return path, err == nil && !info.IsDir() +} + +func copyTree(src, dst string, skip map[string]bool) error { + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + if rel == "." { + return os.MkdirAll(dst, info.Mode().Perm()) + } + first := strings.Split(rel, string(os.PathSeparator))[0] + if skip[first] { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + target := filepath.Join(dst, rel) + if info.IsDir() { + return os.MkdirAll(target, info.Mode().Perm()) + } + if !info.Mode().IsRegular() { + return nil + } + return copyFile(path, target) + }) +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + info, err := in.Stat() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil { + return err + } + out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode().Perm()) + if err != nil { + return err + } + _, cpErr := io.Copy(out, in) + closeErr := out.Close() + if cpErr != nil { + return cpErr + } + return closeErr +} + +func writeTar(path, root string) error { + out, err := os.Create(path) + if err != nil { + return err + } + tw := tar.NewWriter(out) + err = filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(filepath.Dir(root), path) + header, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + header.Name = filepath.ToSlash(rel) + if err := tw.WriteHeader(header); err != nil { + return err + } + if info.Mode().IsRegular() { + f, err := os.Open(path) + if err != nil { + return err + } + _, err = io.Copy(tw, f) + f.Close() + return err + } + return nil + }) + closeErr := tw.Close() + fileCloseErr := out.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + return fileCloseErr +} +func fileSHA256(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + hash := sha256.New() + if _, err := io.Copy(hash, f); err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} diff --git a/apps/api/internal/app/backup_handlers_test.go b/apps/api/internal/app/backup_handlers_test.go new file mode 100644 index 0000000..302c34d --- /dev/null +++ b/apps/api/internal/app/backup_handlers_test.go @@ -0,0 +1,173 @@ +package app + +import ( + "context" + "encoding/json" + "io" + "mime" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBackupEndpointsRejectMismatchedConfirmation(t *testing.T) { + a := newTestApp(t) + stopTestWorkers(a) + server := httptest.NewServer(a.Router()) + defer server.Close() + admin := &testClient{t: t, server: server} + var response map[string]any + if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &response); code != http.StatusOK { + t.Fatalf("login code=%d body=%v", code, response) + } + response = nil + if code := admin.do("POST", "/api/admin/backups", map[string]any{"password": "BackupPassword123!", "confirmPassword": "DifferentPassword123!"}, &response); code != http.StatusBadRequest { + t.Fatalf("manual backup mismatch code=%d body=%v", code, response) + } + response = nil + if code := admin.do("POST", "/api/admin/backups/settings", map[string]any{"enabled": false, "days": 7, "password": "BackupPassword123!", "confirmPassword": "DifferentPassword123!"}, &response); code != http.StatusBadRequest { + t.Fatalf("scheduled backup mismatch code=%d body=%v", code, response) + } +} + +func TestDiscoverTelegramGroupsReturnsUniqueCandidates(t *testing.T) { + telegramServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"ok":true,"result":[`+ + `{"update_id":1,"message":{"text":"/newszxcn ABC123","chat":{"id":-1001,"type":"supergroup","title":"主备份"}}},`+ + `{"update_id":2,"message":{"text":"/newszxcn ABC123","chat":{"id":-1002,"type":"group","title":"异地备份"}}},`+ + `{"update_id":3,"message":{"text":"/newszxcn ABC123","chat":{"id":-1001,"type":"supergroup","title":"主备份"}}},`+ + `{"update_id":4,"message":{"text":"/newszxcn WRONG","chat":{"id":-1003,"type":"group","title":"无关群组"}}}]}`) + })) + defer telegramServer.Close() + a := newTestApp(t) + stopTestWorkers(a) + a.telegramURL = telegramServer.URL + groups, err := a.discoverTelegramGroups(context.Background(), "test-token", "ABC123") + if err != nil { + t.Fatal(err) + } + if len(groups) != 2 || groups[0].ChatID != "-1001" || groups[1].ChatID != "-1002" { + t.Fatalf("unexpected groups: %+v", groups) + } +} + +func TestGoogleDriveUploadRequestUsesMultipartRelated(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "newszxcn-backup-test.tar.zst.enc") + if err := os.WriteFile(path, []byte("encrypted backup"), 0o600); err != nil { + t.Fatal(err) + } + req, err := newGoogleDriveUploadRequest(context.Background(), path, "folder-123") + if err != nil { + t.Fatal(err) + } + mediaType, params, err := mime.ParseMediaType(req.Header.Get("Content-Type")) + if err != nil || mediaType != "multipart/related" || params["boundary"] == "" { + t.Fatalf("content type = %q, %v", req.Header.Get("Content-Type"), err) + } + reader := multipart.NewReader(req.Body, params["boundary"]) + metadataPart, err := reader.NextPart() + if err != nil { + t.Fatal(err) + } + var metadata struct { + Name string `json:"name"` + Parents []string `json:"parents"` + } + if err := json.NewDecoder(metadataPart).Decode(&metadata); err != nil { + t.Fatal(err) + } + if metadata.Name != filepath.Base(path) || len(metadata.Parents) != 1 || metadata.Parents[0] != "folder-123" { + t.Fatalf("metadata = %+v", metadata) + } + filePart, err := reader.NextPart() + if err != nil { + t.Fatal(err) + } + raw, err := io.ReadAll(filePart) + if err != nil || string(raw) != "encrypted backup" { + t.Fatalf("uploaded bytes = %q, %v", raw, err) + } +} + +func TestBackupEncryptionRequiresDeploymentSecret(t *testing.T) { + dir := t.TempDir() + a := newTestAppWithConfig(t, Config{ + Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"), + CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!", AllowInsecureHTTP: true, + }) + if _, err := a.encryptBackupPassword("BackupPassword123!"); err == nil { + t.Fatal("backup password encryption succeeded without a deployment secret") + } +} + +func TestBackupPasswordValidation(t *testing.T) { + for _, valid := range []string{"12345678", "Restore Password 123!"} { + if !validBackupPassword(valid) { + t.Errorf("valid password rejected: %q", valid) + } + } + for _, invalid := range []string{"1234567", "password\nvalue", "password\x00value", strings.Repeat("x", 1025)} { + if validBackupPassword(invalid) { + t.Errorf("invalid password accepted: %q", invalid) + } + } +} + +func TestBackupPasswordEncryptionAndTelegramReport(t *testing.T) { + dir := t.TempDir() + a := newTestAppWithConfig(t, Config{ + Addr: ":0", AppVersion: "v1.2.31", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"), + CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@newszxcn.com", AdminPassword: "ChangeMe123!", + PublicHostname: "mail.newszxcn.com", PublicBaseURL: "https://mail.newszxcn.com", AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret", + }) + + ciphertext, err := a.encryptBackupPassword("BackupPassword123!") + if err != nil || ciphertext == "BackupPassword123!" { + t.Fatalf("password encryption failed: %q %v", ciphertext, err) + } + plain, err := a.decryptBackupPassword(ciphertext) + if err != nil || plain != "BackupPassword123!" { + t.Fatalf("password decryption = %q, %v", plain, err) + } + if !validTelegramPrivateChatID("-1001234567890") { + t.Fatal("private Telegram group chat ID was rejected") + } + + now := a.now().UTC().Format("2006-01-02T15:04:05Z") + if _, err := a.db.Exec(`INSERT INTO domains(id,name,status,dkim_selector,dkim_public_key,dkim_private_key,dns_status,created_at,updated_at) VALUES('domain_xyes','xyes.me','active','mail','','','unchecked',?,?)`, now, now); err != nil { + t.Fatal(err) + } + if _, err := a.db.Exec(`INSERT INTO users(id,login_name,email,display_name,role,password_hash,created_at,updated_at) VALUES('user_xyes','user@xyes.me','user@xyes.me','User','user','hash',?,?)`, now, now); err != nil { + t.Fatal(err) + } + + path := filepath.Join(dir, "newszxcn-backup-20260811-120000-1.2.31.tar.zst.enc") + if err := os.WriteFile(path, []byte("encrypted backup"), 0o600); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + report, err := a.backupTelegramReport(context.Background(), path, info) + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{"备份成功", "mail.newszxcn.com", "已有域名", "newszxcn.com", "xyes.me", "管理员账号", "admin@newszxcn.com", "普通用户账号", "user@xyes.me", "请不要解压", "本地上传", "1Password"} { + if !strings.Contains(report, expected) { + t.Errorf("report missing %q: %s", expected, report) + } + } + if strings.Contains(report, "newszxcn.com(管理员)") { + t.Fatal("domain list incorrectly contains account role") + } + if strings.Contains(report, "BackupPassword123!") || strings.Contains(report, "ChangeMe123!") { + t.Fatal("report leaked a password") + } +} diff --git a/apps/api/internal/app/config.go b/apps/api/internal/app/config.go index 2d235c9..151c8af 100644 --- a/apps/api/internal/app/config.go +++ b/apps/api/internal/app/config.go @@ -67,6 +67,8 @@ type Config struct { ReleaseAPIURL string UpdateServiceURL string UpdateServiceToken string + BackupSourceDir string + BackupDir string } func LoadConfig() Config { @@ -131,6 +133,8 @@ func LoadConfig() Config { ReleaseAPIURL: getenv("LANQIN_RELEASE_API_URL", "https://api.github.com/repos/zxyszx/NewSzxcn-Email/releases/latest"), UpdateServiceURL: getenv("LANQIN_UPDATE_SERVICE_URL", ""), UpdateServiceToken: getenv("LANQIN_UPDATE_SERVICE_TOKEN", ""), + BackupSourceDir: getenv("LANQIN_BACKUP_SOURCE_DIR", ""), + BackupDir: getenv("LANQIN_BACKUP_DIR", filepath.Join(dataDir, "disaster-backups")), } } diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go index d791406..0c4f2b7 100644 --- a/apps/api/internal/app/router_auth.go +++ b/apps/api/internal/app/router_auth.go @@ -139,6 +139,19 @@ func (a *App) Router() http.Handler { r.Use(a.requireAdminAccess) r.Get("/admin/system/version", a.handleSystemVersion) r.Post("/admin/system/update", a.handleSystemUpdate) + r.Get("/admin/backups", a.handleListBackups) + r.Post("/admin/backups/settings", a.handleUpdateBackupSettings) + r.Post("/admin/backups/telegram/test", a.handleTestBackupTelegram) + r.Post("/admin/backups/telegram/discover-group", a.handleDiscoverBackupTelegramGroup) + r.Post("/admin/backups/google-drive/connect", a.handleGoogleDriveConnect) + r.Get("/admin/backups/google-drive/callback", a.handleGoogleDriveCallback) + r.Delete("/admin/backups/google-drive", a.handleGoogleDriveDisconnect) + r.Post("/admin/backups", a.handleCreateBackup) + r.Get("/admin/backups/{name}/download", a.handleDownloadBackup) + r.Post("/admin/backups/{name}/verify", a.handleVerifyBackup) + r.Post("/admin/backups/{name}/telegram", a.handleSendBackupTelegram) + r.Post("/admin/backups/{name}/google-drive", a.handleSendBackupGoogleDrive) + r.Delete("/admin/backups/{name}", a.handleDeleteBackup) r.With(a.requirePermission(PermissionAdminOverview)).Get("/admin/overview", a.handleAdminOverview) r.With(a.requireAnyPermission(PermissionUsersView, PermissionMailboxesView)).Get("/admin/users", a.handleListUsers) r.With(a.requirePermission(PermissionUsersCreate)).Post("/admin/users", a.handleCreateUser) diff --git a/apps/api/internal/app/telegram.go b/apps/api/internal/app/telegram.go index 7d62dd9..c0a9d3e 100644 --- a/apps/api/internal/app/telegram.go +++ b/apps/api/internal/app/telegram.go @@ -71,6 +71,7 @@ type telegramUpdate struct { Chat struct { ID int64 `json:"id"` Type string `json:"type"` + Title string `json:"title"` FirstName string `json:"first_name"` LastName string `json:"last_name"` Username string `json:"username"` @@ -112,7 +113,7 @@ func normalizeTelegramBodyMode(value string) string { func validTelegramPrivateChatID(value string) bool { id, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) - return err == nil && id > 0 + return err == nil && id != 0 } func (a *App) handleCreateTelegramPairing(w http.ResponseWriter, r *http.Request) { @@ -259,6 +260,50 @@ func (a *App) discoverTelegramPrivateChat(ctx context.Context, token, pairingCod return "", "", errors.New("未找到匹配的私聊,请打开机器人发送绑定码后重试") } +type telegramDiscoveredChat struct { + ChatID string `json:"chatId"` + DisplayName string `json:"displayName"` +} + +func (a *App) discoverTelegramGroups(ctx context.Context, token, pairingCode string) ([]telegramDiscoveredChat, error) { + var updates []telegramUpdate + if err := a.callTelegram(ctx, token, "getUpdates", map[string]any{ + "limit": 100, "timeout": 0, "allowed_updates": []string{"message"}, + }, &updates); err != nil { + return nil, err + } + found := make([]telegramDiscoveredChat, 0) + seen := make(map[int64]bool) + for i := len(updates) - 1; i >= 0; i-- { + message := updates[i].Message + if message == nil || (message.Chat.Type != "group" && message.Chat.Type != "supergroup") || message.Chat.ID >= 0 { + continue + } + text := strings.TrimSpace(message.Text) + fields := strings.Fields(text) + matches := strings.EqualFold(text, pairingCode) + if len(fields) == 2 && strings.HasPrefix(strings.ToLower(fields[0]), "/newszxcn") { + matches = strings.EqualFold(fields[1], pairingCode) + } + if !matches { + continue + } + if seen[message.Chat.ID] { + continue + } + seen[message.Chat.ID] = true + name := strings.TrimSpace(message.Chat.Title) + if name == "" { + name = "Telegram 群组" + } + found = append(found, telegramDiscoveredChat{ChatID: strconv.FormatInt(message.Chat.ID, 10), DisplayName: name}) + } + if len(found) == 0 { + return nil, errors.New("未找到匹配的群组,请确认机器人已加入群组,并在群里发送查询命令") + } + return found, nil +} + func newTelegramPairingCode() (string, error) { raw := make([]byte, 6) if _, err := rand.Read(raw); err != nil { diff --git a/apps/web/src/components/protected-layout.tsx b/apps/web/src/components/protected-layout.tsx index d0d3b90..e445435 100644 --- a/apps/web/src/components/protected-layout.tsx +++ b/apps/web/src/components/protected-layout.tsx @@ -1,6 +1,6 @@ import * as React from "react" import { Outlet, Link, useLocation } from "react-router-dom" -import { BarChart3, ClipboardList, Forward, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, ShieldCheck, UserCog } from "lucide-react" +import { ArchiveRestore, BarChart3, ClipboardList, Forward, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, ShieldCheck, UserCog } from "lucide-react" import { useMe } from "@/hooks/use-me" import { useLogout } from "@/hooks/use-logout" import { AuthGuard } from "@/components/auth-guard" @@ -35,6 +35,7 @@ const adminSections: { key: string; label: string; icon: React.ReactNode; permis { key: "aliases", label: "邮件转发", icon: , permissions: ["admin.aliases.view"] }, { key: "messages", label: "全部邮件", icon: , permissions: ["admin.messages.view"] }, { key: "sendAudit", label: "发送队列", icon: , permissions: ["admin.messages.view"] }, + { key: "backups", label: "备份与恢复", icon: , permissions: ["admin.settings.view"] }, { key: "settings", label: "系统设置", icon: , permissions: ["admin.settings.view", "admin.templates.view"] }, ] @@ -56,7 +57,7 @@ function ProtectedContent() { const isProfileRoute = location.pathname.startsWith("/profile") const isAdminRoute = location.pathname.startsWith("/admin") const adminSection = new URLSearchParams(location.search).get("section") || "overview" - const visibleAdminSections = adminSections.filter((item) => hasAnyPermission(user, item.permissions)) + const visibleAdminSections = adminSections.filter((item) => hasAnyPermission(user, item.permissions) && (item.key !== "backups" || user.role === "admin")) if (isMailRoute || isProfileRoute) { return diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index eb27d14..5bb7245 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -202,6 +202,11 @@ export type SystemUpdateResult = { targetVersion: string message: string } +export type BackupItem = { name: string; size: number; createdAt: string; sha256?: string } +export type BackupJob = { status: "running" | "success" | "failed"; startedAt: string; error?: string } +export type BackupSchedule = { enabled: boolean; days: number; passwordSet: boolean; serverIp: string; chatId: string; telegramMode: "system" | "custom"; telegramEnabled: boolean; googleDriveEnabled: boolean } +export type GoogleDriveBackupStatus = { clientId: string; clientSecretSet: boolean; connected: boolean; folderName: string } +export type BackupList = { enabled: boolean; telegramSet: boolean; telegramLimit: number; job?: BackupJob; items: BackupItem[]; schedule: BackupSchedule; googleDrive: GoogleDriveBackupStatus } export type SystemSettings = { publicHostname: string publicBaseUrl: string diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index ff59da6..0f14d6c 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken, TwoFactorEnableResponse, BulkMoveResult, TelegramPrivateChat, TelegramPairing } from "./api-types" +import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, BackupList, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken, TwoFactorEnableResponse, BulkMoveResult, TelegramPrivateChat, TelegramPairing } from "./api-types" export * from "./api-types" const REQUEST_TIMEOUT_MS = 15_000 @@ -211,6 +211,17 @@ export const api = { }, systemVersion: () => request("/api/admin/system/version"), updateSystem: () => request("/api/admin/system/update", { method: "POST", timeoutMs: 45_000 }), + backups: () => request("/api/admin/backups"), + createBackup: (password: string, confirmPassword: string, sendTelegram: boolean, uploadGoogleDrive: boolean) => request<{ ok: boolean; message: string }>("/api/admin/backups", { method: "POST", body: JSON.stringify({ password, confirmPassword, sendTelegram, uploadGoogleDrive }) }), + updateBackupSettings: (payload: { enabled: boolean; days: number; password: string; confirmPassword: string; serverIp: string; chatId: string; telegramMode: "system" | "custom"; telegramEnabled: boolean; googleDriveEnabled: boolean; googleClientId: string; googleClientSecret: string; googleFolderName: string }) => request("/api/admin/backups/settings", { method: "POST", body: JSON.stringify(payload) }), + testBackupTelegram: (payload: { mode: "system" | "custom"; chatId: string }) => request<{ ok: boolean }>("/api/admin/backups/telegram/test", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }), + discoverBackupTelegramGroup: (pairingCode: string) => request<{ items: TelegramPrivateChat[] }>("/api/admin/backups/telegram/discover-group", { method: "POST", body: JSON.stringify({ pairingCode }) }), + connectGoogleDrive: () => request<{ url: string }>("/api/admin/backups/google-drive/connect", { method: "POST" }), + disconnectGoogleDrive: () => request<{ ok: boolean }>("/api/admin/backups/google-drive", { method: "DELETE" }), + verifyBackup: (name: string) => request<{ ok: boolean; sha256: string }>(`/api/admin/backups/${encodeURIComponent(name)}/verify`, { method: "POST", timeoutMs: 60_000 }), + sendBackupTelegram: (name: string) => request<{ ok: boolean }>(`/api/admin/backups/${encodeURIComponent(name)}/telegram`, { method: "POST", timeoutMs: 10 * 60_000 }), + sendBackupGoogleDrive: (name: string) => request<{ ok: boolean }>(`/api/admin/backups/${encodeURIComponent(name)}/google-drive`, { method: "POST", timeoutMs: 30 * 60_000 }), + deleteBackup: (name: string) => request<{ ok: boolean }>(`/api/admin/backups/${encodeURIComponent(name)}`, { method: "DELETE" }), systemSettings: () => request("/api/admin/settings"), maildirSyncHealth: () => request("/api/admin/maildir-sync/health"), updateSystemSettings: (payload: SystemSettingsPayload) => request("/api/admin/settings", { method: "POST", body: JSON.stringify(payload) }), diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx index aeb00c6..b894790 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -2,7 +2,7 @@ import * as React from "react" import DOMPurify from "dompurify" import { useSearchParams } from "react-router-dom" import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { ArrowRight, BookOpen, CheckCircle2, ChevronDown, Circle, ClipboardList, Copy, ExternalLink, Github, Globe2, Mail, Mailbox, MoreHorizontal, RefreshCcw, Scale, Search, ShieldCheck, Star, Trash2, Users } from "lucide-react" +import { ArrowRight, BookOpen, CheckCircle2, ChevronDown, Circle, ClipboardList, Cloud, Copy, Download, ExternalLink, Eye, EyeOff, Github, Globe2, HardDrive, KeyRound, Loader2, Mail, Mailbox, MoreHorizontal, RefreshCcw, Scale, Search, Send, ShieldCheck, Star, Trash2, Users } from "lucide-react" import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, MaildirSyncHealth, PermissionGroup, PermissionInfo, PermissionLimits, SystemSettings } from "@/lib/api" import { cn, decodeMimeHeader, formatBytes, formatDate } from "@/lib/utils" import { Button } from "@/components/ui/button" @@ -26,7 +26,7 @@ import { useToast } from "@/hooks/use-toast" import { hasAnyPermission, hasPermission } from "@/lib/permissions" import type { PermissionKey, TelegramPairing } from "@/lib/api-types" -type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "sendAudit" | "settings" +type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "sendAudit" | "backups" | "settings" type SettingsTab = "base" | "smtp" | "storage" | "mail" | "notifications" | "externalImap" | "templates" | "security" | "about" type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void } @@ -39,6 +39,7 @@ const sectionMeta: Record [key, value.label])) as Record @@ -52,6 +53,7 @@ const sectionPermissions: Record = { aliases: ["admin.aliases.view"], messages: ["admin.messages.view"], sendAudit: ["admin.messages.view"], + backups: ["admin.settings.view"], settings: ["admin.settings.view", "admin.templates.view"], } const projectRepositoryUrl = "https://github.com/zxyszx/NewSzxcn-Email" @@ -102,7 +104,7 @@ export function AdminPage() { const aliasItems = aliases.data?.items || [] const userItems = users.data?.items || [] const assignablePermissionGroups = (permissionGroups.data?.items || []).filter((group) => group.id !== superAdminPermissionGroupId && group.id !== regularUserPermissionGroupId) - const visibleSections = sectionKeys.filter((key) => hasAnyPermission(user, sectionPermissions[key])) + const visibleSections = sectionKeys.filter((key) => hasAnyPermission(user, sectionPermissions[key]) && (key !== "backups" || user?.role === "admin")) const rawSection = params.get("section") as Section | null const section: Section = rawSection && visibleSections.includes(rawSection) ? rawSection : visibleSections[0] || "overview" const sectionQuery = section === "overview" ? overview @@ -155,6 +157,7 @@ export function AdminPage() { {section === "aliases" && } {section === "messages" && } {section === "sendAudit" && } + {section === "backups" && } {section === "settings" && } @@ -255,6 +258,321 @@ function InfoLine({ label, value }: { label: string; value: React.ReactNode }) { return
{label}{value}
} +function generateBackupPassword(length = 24) { + const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%" + const values = new Uint32Array(length) + crypto.getRandomValues(values) + return Array.from(values, (value) => alphabet[value % alphabet.length]).join("") +} + +function BackupsSection() { + const qc = useQueryClient() + const { toast } = useToast() + const backups = useQuery({ + queryKey: ["admin", "backups"], + queryFn: api.backups, + refetchInterval: (query) => query.state.data?.job?.status === "running" ? 2000 : false, + }) + const [createOpen, setCreateOpen] = React.useState(false) + const [password, setPassword] = React.useState("") + const [confirmPassword, setConfirmPassword] = React.useState("") + const [showCreatePassword, setShowCreatePassword] = React.useState(false) + const [sendAfterCreate, setSendAfterCreate] = React.useState(true) + const [driveAfterCreate, setDriveAfterCreate] = React.useState(false) + const [deleteName, setDeleteName] = React.useState("") + + const [scheduleEnabled, setScheduleEnabled] = React.useState(false) + const [scheduleDays, setScheduleDays] = React.useState("7") + const [customDays, setCustomDays] = React.useState("14") + const [schedulePassword, setSchedulePassword] = React.useState("") + const [scheduleConfirmPassword, setScheduleConfirmPassword] = React.useState("") + const [showSchedulePassword, setShowSchedulePassword] = React.useState(false) + const [serverIp, setServerIp] = React.useState("") + const [backupChatId, setBackupChatId] = React.useState("") + const [telegramMode, setTelegramMode] = React.useState<"system" | "custom">("system") + const [telegramEnabled, setTelegramEnabled] = React.useState(true) + const [googleDriveEnabled, setGoogleDriveEnabled] = React.useState(false) + const [googleClientId, setGoogleClientId] = React.useState("") + const [googleClientSecret, setGoogleClientSecret] = React.useState("") + const [googleFolderName, setGoogleFolderName] = React.useState("NewSzxcn Backups") + const [telegramConfigOpen, setTelegramConfigOpen] = React.useState(false) + const [backupGroupPairing, setBackupGroupPairing] = React.useState(null) + const [discoveredBackupGroups, setDiscoveredBackupGroups] = React.useState<{ chatId: string; displayName: string }[]>([]) + const [googleConfigOpen, setGoogleConfigOpen] = React.useState(false) + React.useEffect(() => { + if (!backups.data) return + const days = backups.data.schedule.days || 7 + setScheduleEnabled(backups.data.schedule.enabled) + setScheduleDays([3, 5, 7, 30].includes(days) ? String(days) : "custom") + setCustomDays(String(days)) + setServerIp(backups.data.schedule.serverIp || "") + setBackupChatId(backups.data.schedule.chatId || "") + setTelegramMode(backups.data.schedule.telegramMode === "custom" ? "custom" : "system") + setTelegramEnabled(backups.data.schedule.telegramEnabled) + setGoogleDriveEnabled(backups.data.schedule.googleDriveEnabled) + setGoogleClientId(backups.data.googleDrive.clientId || "") + setGoogleFolderName(backups.data.googleDrive.folderName || "NewSzxcn Backups") + setDriveAfterCreate(backups.data.googleDrive.connected) + setSendAfterCreate(backups.data.telegramSet) + }, [backups.data]) + React.useEffect(() => { + const drive = new URLSearchParams(window.location.search).get("drive") + if (!drive) return + toast({ title: drive === "connected" ? "Google 云端硬盘已连接" : "Google 授权未完成" }) + window.history.replaceState({}, "", "/admin?section=backups") + }, [toast]) + const create = useMutation({ + mutationFn: () => api.createBackup(password, confirmPassword, sendAfterCreate, driveAfterCreate), + onSuccess: async () => { + setCreateOpen(false); setPassword(""); setConfirmPassword("") + await qc.invalidateQueries({ queryKey: ["admin", "backups"] }) + toast({ title: "备份任务已开始", description: "可以留在此页面查看进度。" }) + }, + onError: (error) => toast({ title: "无法创建备份", description: error instanceof Error ? error.message : "请稍后重试" }), + }) + const saveSchedule = useMutation({ + mutationFn: () => api.updateBackupSettings({ enabled: scheduleEnabled, days: scheduleDays === "custom" ? Number(customDays) : Number(scheduleDays), password: schedulePassword, confirmPassword: scheduleConfirmPassword, serverIp, chatId: backupChatId, telegramMode, telegramEnabled, googleDriveEnabled, googleClientId, googleClientSecret, googleFolderName }), + onSuccess: async () => { setSchedulePassword(""); setScheduleConfirmPassword(""); setGoogleClientSecret(""); await qc.invalidateQueries({ queryKey: ["admin", "backups"] }); toast({ title: "备份设置已保存" }) }, + onError: (error) => toast({ title: "保存失败", description: error instanceof Error ? error.message : "请稍后重试" }), + }) + const verify = useMutation({ + mutationFn: api.verifyBackup, + onSuccess: (result) => toast({ title: result.ok ? "备份校验通过" : "备份校验失败", description: result.ok ? `SHA-256:${result.sha256.slice(0, 16)}...` : "文件可能已损坏,请勿用于恢复。" }), + onError: (error) => toast({ title: "校验失败", description: error instanceof Error ? error.message : "请稍后重试" }), + }) + const sendTelegram = useMutation({ + mutationFn: api.sendBackupTelegram, + onSuccess: () => toast({ title: "已发送到 Telegram" }), + onError: (error) => toast({ title: "发送失败", description: error instanceof Error ? error.message : "请稍后重试" }), + }) + const testBackupTelegram = useMutation({ + mutationFn: () => api.testBackupTelegram({ mode: telegramMode, chatId: backupChatId }), + onSuccess: () => toast({ title: "Telegram 测试通知已发送" }), + onError: (error) => toast({ title: "测试失败", description: error instanceof Error ? error.message : "请检查机器人和 Chat ID" }), + }) + const createBackupGroupPairing = useMutation({ + mutationFn: () => api.createTelegramPairing(""), + onSuccess: (pairing) => { setBackupGroupPairing(pairing); setDiscoveredBackupGroups([]); toast({ title: "群组查询码已生成" }) }, + onError: (error) => toast({ title: "无法生成查询码", description: error instanceof Error ? error.message : "请先绑定 Telegram 机器人" }), + }) + const discoverBackupGroup = useMutation({ + mutationFn: () => api.discoverBackupTelegramGroup(backupGroupPairing?.code || ""), + onSuccess: ({ items }) => { + setDiscoveredBackupGroups(items) + if (items.length === 1) setBackupChatId(items[0].chatId) + toast({ title: `找到 ${items.length} 个群组`, description: items.length === 1 ? "已自动选中" : "请选择备份群组" }) + }, + onError: (error) => toast({ title: "未找到群组", description: error instanceof Error ? error.message : "请在群组发送查询命令后重试" }), + }) + const sendDrive = useMutation({ + mutationFn: api.sendBackupGoogleDrive, + onSuccess: () => toast({ title: "已上传到 Google 云端硬盘" }), + onError: (error) => toast({ title: "上传失败", description: error instanceof Error ? error.message : "请稍后重试" }), + }) + const connectDrive = useMutation({ + mutationFn: async () => { + await api.updateBackupSettings({ enabled: scheduleEnabled, days: scheduleDays === "custom" ? Number(customDays) : Number(scheduleDays), password: schedulePassword, confirmPassword: scheduleConfirmPassword, serverIp, chatId: backupChatId, telegramMode, telegramEnabled, googleDriveEnabled: false, googleClientId, googleClientSecret, googleFolderName }) + return api.connectGoogleDrive() + }, + onSuccess: ({ url }) => { window.location.href = url }, + onError: (error) => toast({ title: "无法连接 Google 云端硬盘", description: error instanceof Error ? error.message : "请检查 OAuth 配置" }), + }) + const disconnectDrive = useMutation({ + mutationFn: api.disconnectGoogleDrive, + onSuccess: async () => { setGoogleDriveEnabled(false); await qc.invalidateQueries({ queryKey: ["admin", "backups"] }); toast({ title: "已断开 Google 云端硬盘" }) }, + }) + const remove = useMutation({ + mutationFn: api.deleteBackup, + onSuccess: async () => { setDeleteName(""); await qc.invalidateQueries({ queryKey: ["admin", "backups"] }); toast({ title: "备份已删除" }) }, + onError: (error) => toast({ title: "删除失败", description: error instanceof Error ? error.message : "请稍后重试" }), + }) + const job = backups.data?.job + const canCreate = backups.data?.enabled && job?.status !== "running" + function submitCreate() { + if (password.length < 8) { toast({ title: "密码至少需要 8 个字符" }); return } + if (password !== confirmPassword) { toast({ title: "两次输入的密码不一致" }); return } + create.mutate() + } + function generateCreatePassword() { + const generated = generateBackupPassword() + setPassword(generated) + setConfirmPassword(generated) + setShowCreatePassword(true) + toast({ title: "已生成 24 位备份密码", description: "请将密码保存到密码管理器,恢复时必须使用。" }) + } + function generateSchedulePassword() { + const generated = generateBackupPassword() + setSchedulePassword(generated) + setScheduleConfirmPassword(generated) + setShowSchedulePassword(true) + toast({ title: "已生成 24 位备份密码", description: "保存设置前,请先将密码存入密码管理器。" }) + } + async function copyBackupPassword(value: string) { + if (!value) return + await navigator.clipboard.writeText(value) + toast({ title: "备份密码已复制" }) + } + function downloadBackupPassword(value: string) { + if (!value) return + const createdAt = new Date().toLocaleString("zh-CN", { hour12: false }) + const content = `NewSzxcn Email 备份恢复密码\n\n密码:${value}\n生成时间:${createdAt}\n\n请妥善保管。恢复备份时必须输入此密码,系统无法找回。\n` + const url = URL.createObjectURL(new Blob([content], { type: "text/plain;charset=utf-8" })) + const link = document.createElement("a") + link.href = url + link.download = `newszxcn-backup-password-${new Date().toISOString().slice(0, 10)}.txt` + link.click() + URL.revokeObjectURL(url) + toast({ title: "密码文本已下载", description: "请导入密码管理器,不要与备份文件存放在一起。" }) + } + function PasswordTools({ value, visible, onVisibleChange, onGenerate }: { value: string; visible: boolean; onVisibleChange: (visible: boolean) => void; onGenerate: () => void }) { + return
+ + + + +
+ } + function submitSchedule() { + if (schedulePassword && schedulePassword.length < 8) { toast({ title: "备份密码至少需要 8 个字符" }); return } + if (schedulePassword !== scheduleConfirmPassword) { toast({ title: "两次输入的备份密码不一致" }); return } + if (scheduleEnabled && !schedulePassword && !backups.data?.schedule.passwordSet) { toast({ title: "请设置并确认备份密码" }); return } + saveSchedule.mutate() + } + return ( +
+
+ + +
完整加密备份

包含账号、邮件、附件、DKIM、证书和部署配置。

+ +
+ + {!backups.data?.enabled &&
当前部署尚未启用完整备份目录,请先更新服务器部署文件。
} + {job?.status === "failed" &&
{job.error || "备份生成失败"}
} + {job?.status === "success" &&
最近一次备份已完成。
} + {!job &&

创建时必须设置独立备份密码。密码不会保存,丢失后无法解密恢复。

} +
+
本地备份保留最近 10 份
+
+ {(backups.data?.items || []).slice(0, 4).map((item) => ( +
+
{item.name}
{formatDate(item.createdAt)} · {formatBytes(item.size)}
+ + + (backups.data?.telegramLimit || 0)} onClick={() => sendTelegram.mutate(item.name)}>发送到 Telegram + sendDrive.mutate(item.name)}>上传到 Google 云端硬盘 + 下载 + setDeleteName(item.name)}>删除 + +
+ ))} + {!backups.isLoading && (backups.data?.items.length || 0) === 0 &&
暂无完整备份
} +
+
+
+
+ + 新服务器恢复 + +

1. 将原始加密备份上传到 /root/,不要解压。

+

2. 运行官方安装脚本,菜单输入 2

+

3. 选择“本地上传”;多份备份会显示 1、2、3。

+

4. 输入序号和备份密码开始恢复。

+
恢复完成后可输入 ns 打开管理菜单。运行中的服务器不会在网页内覆盖数据。
+
+
+
+ +
定时备份

按周期创建加密备份并保存到选定位置。

+ +
+
{scheduleDays === "custom" && setCustomDays(event.target.value)} />}
+
setServerIp(event.target.value)} placeholder="例如 165.99.42.243" />
+
setSchedulePassword(event.target.value)} placeholder={backups.data?.schedule.passwordSet ? "已保存,留空不变" : "至少 8 个字符"} />
+
setScheduleConfirmPassword(event.target.value)} placeholder={schedulePassword ? "再次输入备份密码" : "留空则不修改"} />
+
+
+
+ +
Telegram{backups.data?.telegramSet ? "已配置" : "未配置"}

{telegramMode === "custom" ? "使用系统机器人推送到备份群组" : "沿用邮件通知接收方"}

+ + +
+
+ +
Google 云端硬盘{backups.data?.googleDrive.connected ? "已连接" : "未连接"}

{backups.data?.googleDrive.connected ? `保存到 ${googleFolderName}` : "长期保存加密备份"}

+ + +
+
+
+
+
+ + + Telegram 备份 +
+
+ {telegramMode === "system" ?
使用系统已绑定机器人,备份发送到邮件通知的接收方。
:
+
+ +
setBackupChatId(event.target.value)} placeholder="群组 Chat ID" />
+

请先将系统机器人加入该群组。邮件继续推送到原接收方,备份通知和附件只推送到此群组。

+ {backupGroupPairing &&
+

在每个候选群组发送下面的命令,然后点击“完成查询”:

+
/newszxcn {backupGroupPairing.code}
+ + {discoveredBackupGroups.length > 0 &&
{discoveredBackupGroups.map((group) => )}
} +
} +
+
} +
+ + +
+ + +
+
+

关闭后请点击页面下方“保存设置”使 Chat ID 生效。

+
+
+ + + Google 云端硬盘 +
+
setGoogleClientId(e.target.value)} />
+
setGoogleClientSecret(e.target.value)} placeholder={backups.data?.googleDrive.clientSecretSet ? "已安全保存,留空不变" : "请输入客户端密钥"} />
+
setGoogleFolderName(e.target.value)} />
+

Google Cloud 回调地址:{window.location.origin}/api/admin/backups/google-drive/callback

+
+ + {backups.data?.googleDrive.connected ? : } +
+
+
+
+ { if (!create.isPending) setCreateOpen(open) }}> + + 创建完整备份 +
+
setPassword(event.target.value)} placeholder="自己输入或自动生成" />
+
setConfirmPassword(event.target.value)} />
+
完成后发送到 Telegram
同时发送详细恢复说明和加密附件。
+
上传到 Google 云端硬盘
保存加密备份到已连接的云端文件夹。
+

下载的是明文密码文本,请导入密码管理器后妥善处理,不要与备份文件存放在同一位置。

+
+ +
+
+ { if (!open) setDeleteName("") }} title="删除这个备份?" description="删除后无法恢复,请确认已经在其他位置保存副本。" confirmText="删除备份" destructive pending={remove.isPending} onConfirm={() => remove.mutate(deleteName)} /> +
+ ) +} + function UsersSection({ users, permissionGroups, domains }: { users: AdminUser[]; permissionGroups: PermissionGroup[]; domains: Domain[] }) { const me = useMe() const user = me.data?.user @@ -707,9 +1025,13 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp if (left.primary !== right.primary) return left.primary ? -1 : 1 return left.address.localeCompare(right.address, "en", { sensitivity: "base" }) } + const orphanMailboxes = new Map() + for (const mailbox of mailboxes.filter((item) => !knownOwnerIDs.has(item.userId))) { + orphanMailboxes.set(mailbox.userId, [...(orphanMailboxes.get(mailbox.userId) || []), mailbox]) + } const mailboxGroups: Array<{ owner?: AdminUser; mailboxes: MailboxType[] }> = [ ...users.slice().sort(compareAdminUsers).map((owner) => ({ owner, mailboxes: mailboxes.filter((mailbox) => mailbox.userId === owner.id).sort(compareMailboxes) })), - ...mailboxes.filter((mailbox) => !knownOwnerIDs.has(mailbox.userId)).map((mailbox) => ({ owner: undefined, mailboxes: [mailbox] })), + ...Array.from(orphanMailboxes.values()).map((items) => ({ owner: undefined, mailboxes: items.sort(compareMailboxes) })), ] .filter((group) => group.mailboxes.length > 0) .filter((group) => !keyword || [group.owner ? accountPrimaryEmail(group.owner) : "", group.owner?.displayName || "", ...group.mailboxes.map((mailbox) => mailbox.address)].some((value) => value.toLowerCase().includes(keyword))) diff --git a/deploy/all-in-one/Dockerfile b/deploy/all-in-one/Dockerfile index d852e99..f94a686 100644 --- a/deploy/all-in-one/Dockerfile +++ b/deploy/all-in-one/Dockerfile @@ -35,7 +35,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ ca-certificates tzdata sqlite3 supervisor nginx \ postfix postfix-sqlite \ dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-sqlite ssl-cert \ - rspamd + rspamd zstd openssl COPY --from=api-build /out/lanqin-api /usr/local/bin/lanqin-api COPY --from=web-build /src/apps/web/dist /usr/share/nginx/html diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index edc1b08..de9a0b7 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -5,6 +5,8 @@ services: environment: LANQIN_UPDATE_SERVICE_URL: http://updater:8080/v1/update LANQIN_UPDATE_SERVICE_TOKEN: ${LANQIN_UPDATE_TOKEN:-} + LANQIN_BACKUP_SOURCE_DIR: /backup-source + LANQIN_BACKUP_DIR: /backups ports: - "${LANQIN_HTTP_BIND:-80}:80" - "${LANQIN_SMTP_BIND:-25}:25" @@ -17,6 +19,9 @@ services: - ./mail:/var/mail/vhosts - ./dkim:/var/lib/rspamd/dkim - ./certs:/certs:ro + - ./.env:/backup-source/.env:ro + - ./docker-compose.yml:/backup-source/docker-compose.yml:ro + - ./backups:/backups labels: com.centurylinklabs.watchtower.enable: "true" com.centurylinklabs.watchtower.scope: "newszxcn-email" diff --git a/docs/BACKUP_RESTORE.md b/docs/BACKUP_RESTORE.md new file mode 100644 index 0000000..b367282 --- /dev/null +++ b/docs/BACKUP_RESTORE.md @@ -0,0 +1,71 @@ +# NewSzxcn 完整备份与灾难恢复 + +## 后台创建并推送到 Telegram + +1. 使用系统管理员登录 NewSzxcn 后台。 +2. 在“系统设置 -> 通知”绑定 Telegram Bot Token 和私聊 Chat ID,并发送测试通知。 +3. 打开“备份与恢复”。 +4. 在“定时备份与 Telegram 推送”中选择每 3、5、7、30 天,或填写 1 至 365 天的自定义周期。 +5. 备份 Chat ID 留空时沿用邮件通知私聊;也可以填写一个仅管理员可见的私有群组 Chat ID,将邮件通知与备份文件分开。Bot 必须已经加入该群组。 +6. 填写服务器公网 IP。备份密码可以自己输入,也可以点击“生成 24 位”;必须另外保存到 1Password 等密码管理器。 +7. 开启“自动创建并推送”,保存设置。 +8. 首次配置建议点击“创建备份”,勾选“完成后发送到 Telegram”,确认机器人能收到说明消息和 `.tar.zst.enc` 加密附件。 + +Telegram 消息包含邮局域名、服务器 IP、系统版本、已有域名、管理员账号、普通用户账号、邮箱账号、文件大小、SHA-256 和恢复步骤。已有域名只列域名,不附加账号身份。 + +消息不会包含管理员密码、用户密码或备份密码。数据库只保存登录密码哈希,不能反向读取明文;恢复后账号继续使用原登录密码。备份密码与加密附件也不应保存在同一个 Telegram 会话中。 + +## 原服务器失联后的恢复 + +准备一台新的 Debian 或 Ubuntu 服务器。先把 Telegram 中的加密附件原样上传到新服务器的 `/root/` 目录,请不要解压、修改或固定填写某个示例文件名。备份日期和版本号每次可能不同。 + +确认文件已经上传后,首次执行官方脚本: + +```bash +curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash +``` + +脚本显示“尚未安装”管理菜单后,输入 `2`,选择“备份恢复”。在恢复完成后,以后需要管理系统时才使用 `ns` 打开管理菜单。 + +在“尚未安装”菜单选择: + +```text +================================================== + NewSzxcn Email 管理面板 +================================================== +状态:尚未安装 +-------------------------------------------------- +1. 一键安装 NewSzxcn Email +2. 备份恢复 +3. 退出 +================================================== +``` + +进入备份恢复菜单后,输入 `1` 选择“本地上传”。脚本会自动扫描 `/root/newszxcn-backup-*`:只有一份时直接选中;多份时按日期从新到旧显示为 `1、2、3` 等序号,输入对应序号,例如输入 `1` 恢复第 1 份。没有找到时才要求手动输入完整路径。选定后输入备份密码,脚本会检查压缩包路径、SQLite 完整性和必要目录,再启动服务。 + +恢复完成后: + +1. 如果新服务器 IP 改变,更新邮件主机的 A/AAAA、邮件域名的 MX/SPF,以及服务商处的 PTR 记录。 +2. 检查 DKIM 和 DMARC;DKIM 私钥已随备份恢复,但 DNS 仍应核对。 +3. 检查 TLS 证书是否适用于当前主机名,必要时重新签发。 +4. 登录网页并测试收信、发信、IMAP、POP3 和 SMTP Submission。 +5. 打开“备份与恢复”,重新测试 Telegram 推送。 + +## 备份内容 + +完整备份包括 SQLite 数据库、附件、Maildir 原始邮件、DKIM 私钥、TLS 证书、`.env`、Compose 配置、版本清单和 SHA-256 校验文件。备份使用 Zstandard 压缩,并以 AES-256-CBC、PBKDF2 200000 次迭代和 SHA-256 加密。 + +Telegram 适合保存体积较小的应急副本,不应作为唯一备份位置。超过 Telegram 发送上限的文件请从后台下载,并保存到 Google 云端硬盘、另一台服务器、对象存储或离线磁盘。 + +## Google 云端硬盘 + +后台“备份与恢复”支持将同一份加密备份保存到 Google 云端硬盘。系统只申请 `drive.file` 权限,只能管理由 NewSzxcn 创建的文件,不会读取云端硬盘中的其他文件。 + +1. 在 Google Cloud Console 创建项目并启用 Google Drive API。 +2. 配置 OAuth 同意屏幕,再创建“Web 应用”类型的 OAuth 客户端。 +3. 授权重定向 URI 填写 `https://你的邮局域名/api/admin/backups/google-drive/callback`,必须与后台系统设置中的公开访问地址一致。 +4. 在后台填写 OAuth 客户端 ID、客户端密钥和云端文件夹名称,先保存或直接点击“连接 Google”。 +5. 在 Google 授权页面确认后会自动返回“备份与恢复”,状态显示“已连接”。 +6. 可开启“用于定时备份”,也可在创建备份或已有备份菜单中单独上传。 + +OAuth 客户端密钥和刷新令牌会使用服务器内部密钥加密保存。Google 云端硬盘中只保存 `.tar.zst.enc` 加密备份,备份密码仍应单独保管。 diff --git a/install.sh b/install.sh index 12f4019..0c5270f 100755 --- a/install.sh +++ b/install.sh @@ -28,6 +28,7 @@ NewSzxcn Email 管理命令 menu 显示安装与运维菜单 install 首次安装;已有安装会先完整备份再重新安装 + restore 从完整备份目录或压缩包恢复到新服务器 update 备份数据库并更新到最新版 repair 检查并修复现有安装 status 查看容器与健康状态 @@ -161,6 +162,25 @@ ensure_cli_alias() { success "快捷命令已创建:输入 ns 可打开管理菜单。" } +ensure_cli_command() { + local source_dir tmp + [[ -x "${CLI_PATH}" ]] && return 0 + install -d -m 0755 "$(dirname "${CLI_PATH}")" + source_dir="$(script_dir || true)" + if [[ -n "${BASH_SOURCE[0]:-}" && "${BASH_SOURCE[0]}" != /dev/fd/* && -f "${source_dir}/install.sh" ]]; then + install -m 0755 "${source_dir}/install.sh" "${CLI_PATH}" + else + tmp="$(mktemp)" + if ! curl -fsSL "${RAW_BASE}/install.sh" -o "${tmp}" || ! bash -n "${tmp}"; then + rm -f "${tmp}" + warn "未能安装管理命令;完成安装后可重新运行官方脚本修复。" + return 0 + fi + install -m 0755 "${tmp}" "${CLI_PATH}" + rm -f "${tmp}" + fi +} + refresh_assets() { stage_assets apply_staged_assets @@ -1077,6 +1097,275 @@ do_install() { warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱后台配置指南。" } +validate_restore_source() { + local source="$1" + [[ -f "${source}/.env" ]] || { warn "备份缺少 .env。"; return 1; } + [[ -f "${source}/docker-compose.yml" ]] || { warn "备份缺少 docker-compose.yml。"; return 1; } + [[ -s "${source}/data/lanqin.db" ]] || { warn "备份缺少数据库 data/lanqin.db。"; return 1; } + [[ -d "${source}/mail" ]] || { warn "备份缺少 mail 邮件目录。"; return 1; } + [[ -d "${source}/dkim" ]] || { warn "备份缺少 dkim 密钥目录。"; return 1; } + [[ -d "${source}/certs" ]] || { warn "备份缺少 certs 证书目录。"; return 1; } +} + +validate_restore_database() { + local database="$1" result + if ! command -v sqlite3 >/dev/null 2>&1; then + log "正在安装 SQLite 校验工具..." + install_packages sqlite3 + fi + result="$(sqlite3 "${database}" 'PRAGMA integrity_check;' 2>/dev/null || true)" + [[ "${result}" == "ok" ]] || { warn "备份数据库完整性检查未通过。"; return 1; } +} + +locate_extracted_restore_root() { + local root="$1" candidate + if validate_restore_source "${root}" >/dev/null 2>&1; then + printf '%s' "${root}" + return 0 + fi + candidate="$(find "${root}" -mindepth 1 -maxdepth 2 -type f -name .env -print -quit 2>/dev/null || true)" + [[ -n "${candidate}" ]] || return 1 + candidate="$(dirname "${candidate}")" + validate_restore_source "${candidate}" >/dev/null 2>&1 || return 1 + printf '%s' "${candidate}" +} + +render_restore_menu() { + prompt_text '\n==================================================\n' + prompt_text ' NewSzxcn Email 备份恢复\n' + prompt_text '==================================================\n' + prompt_text '1. 本地上传\n' + prompt_text '2. 返回上一级\n' + prompt_text '==================================================\n' + prompt_text '请先将原始加密备份上传到新服务器的 /root/ 目录,不要解压。\n' + prompt_text '系统会自动检测 /root/ 目录中的 NewSzxcn 备份文件。\n' +} + +do_restore_menu() { + local choice + render_restore_menu + choice="$(prompt_menu_choice "1" "2")" || return 1 + case "${choice}" in + 1) do_restore_backup ;; + 2) success "已返回,未作任何修改。" ;; + esac +} + +prompt_restore_password() { + local password="${LANQIN_RESTORE_PASSWORD:-}" + if [[ -z "${password}" ]] && has_tty; then + read -r -s -p "备份密码: " password /dev/tty + fi + [[ -n "${password}" ]] || fail "加密备份必须提供备份密码。" + (( ${#password} >= 8 && ${#password} <= 1024 )) || fail "备份密码必须为 8 至 1024 个字符。" + [[ "${password}" != *$'\n'* && "${password}" != *$'\r'* ]] || fail "备份密码不能包含换行。" + printf '%s' "${password}" +} + +discover_restore_backups() { + local search_dir="${LANQIN_RESTORE_SEARCH_DIR:-/root}" path + local -a matches=() + [[ -d "${search_dir}" ]] || return 0 + while IFS= read -r path; do + case "${path}" in + *.tar.zst.enc|*.tar.zst|*.tar.gz|*.tgz|*.tar) matches+=("${path}") ;; + esac + done < <(find "${search_dir}" -maxdepth 1 -type f -name 'newszxcn-backup-*' -print 2>/dev/null | LC_ALL=C sort -r) + (( ${#matches[@]} > 0 )) || return 0 + printf '%s\n' "${matches[@]}" +} + +select_restore_source() { + local selection="${LANQIN_RESTORE_SELECTION:-}" path index + local -a backups=() + while IFS= read -r path; do + [[ -n "${path}" ]] && backups+=("${path}") + done < <(discover_restore_backups) + + if (( ${#backups[@]} == 1 )); then + prompt_text "[检测] 已找到备份:${backups[0]}\n" + printf '%s' "${backups[0]}" + return 0 + fi + if (( ${#backups[@]} > 1 )); then + prompt_text "[检测] 在 /root/ 找到 ${#backups[@]} 份备份:\n" + for index in "${!backups[@]}"; do + prompt_text "$((index + 1)). ${backups[index]}\n" + done + prompt_text "$(( ${#backups[@]} + 1 )). 手动输入其他路径\n" + if [[ -z "${selection}" ]] && has_tty; then + read -r -p "请输入要恢复的备份序号 [1]: " selection = 1 && selection <= ${#backups[@]} )); then + prompt_text "[选择] 将使用第 ${selection} 份备份开始恢复。\n" + printf '%s' "${backups[selection-1]}" + return 0 + fi + [[ "${selection}" == "$(( ${#backups[@]} + 1 ))" ]] || fail "备份序号无效。" + else + prompt_text "[提示] /root/ 目录没有检测到 NewSzxcn 备份,请手动输入路径。\n" + fi + if has_tty; then + read -r -p "备份文件完整路径: " path /dev/null 2>&1 || install_packages openssl + command -v zstd >/dev/null 2>&1 || install_packages zstd + password="$(prompt_restore_password)" + decrypted="${destination}/backup.tar.zst" + if ! openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 -md sha256 -in "${source}" -out "${decrypted}" -pass fd:3 3<<<"${password}" 2>/dev/null; then + fail "备份密码错误或加密备份已损坏。" + fi + if zstd -dc "${decrypted}" 2>/dev/null | tar -tf - | archive_has_unsafe_paths; then + fail "备份压缩包包含不安全路径,已拒绝恢复。" + fi + if zstd -dc "${decrypted}" 2>/dev/null | tar -tvf - | archive_has_unsafe_types; then + fail "备份压缩包包含链接或特殊文件,已拒绝恢复。" + fi + zstd -dc "${decrypted}" 2>/dev/null | tar -xf - -C "${destination}" \ + || fail "加密备份无法解压,请检查文件和密码。" + rm -f "${decrypted}" + ;; + *.tar.zst) + command -v zstd >/dev/null 2>&1 || install_packages zstd + if zstd -dc "${source}" 2>/dev/null | tar -tf - | archive_has_unsafe_paths; then + fail "备份压缩包包含不安全路径,已拒绝恢复。" + fi + if zstd -dc "${source}" 2>/dev/null | tar -tvf - | archive_has_unsafe_types; then + fail "备份压缩包包含链接或特殊文件,已拒绝恢复。" + fi + zstd -dc "${source}" 2>/dev/null | tar -xf - -C "${destination}" \ + || fail "Zstandard 备份无法解压。" + ;; + *.tar|*.tar.gz|*.tgz) + if tar -tf "${source}" | archive_has_unsafe_paths; then + fail "备份压缩包包含不安全路径,已拒绝恢复。" + fi + if tar -tvf "${source}" | archive_has_unsafe_types; then + fail "备份压缩包包含链接或特殊文件,已拒绝恢复。" + fi + tar -xf "${source}" -C "${destination}" || fail "备份压缩包无法解压。" + ;; + *) + fail "不支持的备份格式;请选择 .tar.zst.enc、.tar.zst、.tar.gz、.tgz 或 .tar。" + ;; + esac +} + +do_restore_backup() { + local source="${LANQIN_RESTORE_SOURCE:-}" extracted="" restore_root staging image_ref image nginx_backup="" + ! installation_configured || fail "当前服务器已经存在安装配置;为防止覆盖运行数据,只能在空白新服务器执行完整恢复。" + [[ -n "${source}" ]] || source="$(select_restore_source)" + [[ -n "${source}" ]] || fail "请提供备份目录或备份压缩包路径。" + source="$(readlink -f "${source}" 2>/dev/null || true)" + [[ -e "${source}" ]] || fail "备份不存在:${source}" + + if [[ -d "${source}" ]]; then + restore_root="${source}" + else + command -v tar >/dev/null 2>&1 || install_packages tar + extracted="$(mktemp -d)" + extract_restore_archive "${source}" "${extracted}" + restore_root="$(locate_extracted_restore_root "${extracted}" || true)" + fi + if [[ -z "${restore_root}" ]] || ! validate_restore_source "${restore_root}"; then + [[ -n "${extracted}" ]] && rm -rf "${extracted}" + fail "这不是可恢复的 NewSzxcn 完整备份。" + fi + if ! validate_restore_database "${restore_root}/data/lanqin.db"; then + [[ -n "${extracted}" ]] && rm -rf "${extracted}" + fail "备份数据库已损坏,未写入任何 NewSzxcn 数据。" + fi + + staging="${INSTALL_DIR}.restore-staging-$(date -u +%Y%m%dT%H%M%SZ)" + [[ ! -e "${INSTALL_DIR}" || -z "$(find "${INSTALL_DIR}" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]] \ + || fail "${INSTALL_DIR} 已有文件,已取消恢复以免覆盖数据。" + rm -rf "${staging}" + install -d -m 0700 "${staging}" + cp -a "${restore_root}/." "${staging}/" + [[ -n "${extracted}" ]] && rm -rf "${extracted}" + rm -rf "${INSTALL_DIR}" + mv "${staging}" "${INSTALL_DIR}" + chmod 0600 "${INSTALL_DIR}/.env" + + if [[ -f "${NGINX_CONFIG}" ]]; then + nginx_backup="$(mktemp)" + cp -a "${NGINX_CONFIG}" "${nginx_backup}" + fi + + if ! ( + refresh_assets + ensure_update_token + ensure_admin_email_config + configure_runtime_bindings + ensure_docker + configure_firewall + prepare_directories + log "正在拉取恢复所需的 NewSzxcn Email 镜像..." + compose pull + image_ref="$(env_value LANQIN_IMAGE || true)" + image_ref="${image_ref:-ghcr.io/zxyszx/newszxcn-email:latest}" + image="$(docker image inspect --format '{{.Id}}' "${image_ref}" 2>/dev/null || true)" + [[ -n "${image}" ]] || fail "无法检查恢复数据库:镜像不存在。" + sqlite_integrity_check "${INSTALL_DIR}/data/lanqin.db" "${image}" || fail "备份数据库完整性检查未通过,服务未启动。" + log "备份检查通过,正在启动服务..." + compose up -d --remove-orphans + wait_for_health 90 || fail "恢复后的服务未通过健康检查,请执行 newszxcn-email logs。" + configure_web_mode + ); then + warn "恢复未完成,正在清理本次未成功的安装。" + compose down --remove-orphans >/dev/null 2>&1 || true + rm -rf "${INSTALL_DIR}" + if [[ -n "${nginx_backup}" && -f "${nginx_backup}" ]]; then + cp -a "${nginx_backup}" "${NGINX_CONFIG}" + elif [[ -f "${NGINX_CONFIG}" ]]; then + rm -f "${NGINX_CONFIG}" + fi + rm -f "${nginx_backup}" + nginx -t >/dev/null 2>&1 && systemctl reload nginx >/dev/null 2>&1 || true + fail "恢复失败,原始备份文件未修改;修复问题后可重新执行备份恢复。" + fi + rm -f "${nginx_backup}" + ensure_cli_alias + generate_guide >/dev/null || warn "数据已恢复,但配置指南生成失败,可稍后执行 newszxcn-email guide。" + success "备份恢复完成:$(env_value LANQIN_PUBLIC_BASE_URL)" + warn "如果服务器 IP 已更换,请更新 A、MX、SPF、PTR,并重新检查 TLS 证书。" +} + do_update() { require_installation ensure_docker @@ -1503,7 +1792,8 @@ render_uninstalled_menu() { prompt_text '状态:尚未安装\n' prompt_text '--------------------------------------------------\n' prompt_text '1. 一键安装 NewSzxcn Email\n' - prompt_text '0. 退出\n' + prompt_text '2. 备份恢复\n' + prompt_text '3. 退出\n' prompt_text '==================================================\n' } @@ -1541,10 +1831,11 @@ do_menu() { local default_choice="2" public_url="" choice status version if ! installation_configured; then render_uninstalled_menu - choice="$(prompt_menu_choice "1" "1")" || return 1 + choice="$(prompt_menu_choice "1" "3")" || return 1 case "${choice}" in - 0) success "已退出,未作任何修改。" ;; + 3) success "已退出,未作任何修改。" ;; 1) do_install ;; + 2) do_restore_menu ;; esac return fi @@ -1581,6 +1872,7 @@ if [[ "${LANQIN_SOURCE_ONLY:-false}" == "true" ]]; then fi if [[ "${EUID}" -eq 0 ]]; then + ensure_cli_command ensure_cli_alias fi @@ -1588,6 +1880,7 @@ case "${COMMAND}" in help|-h|--help) usage ;; menu) require_root; require_curl; do_menu ;; install) require_root; require_curl; do_install ;; + restore) require_root; require_curl; do_restore_menu ;; update) require_root; require_curl; do_update ;; repair) require_root; require_curl; do_repair_install ;; status) require_root; require_curl; do_status ;; diff --git a/tests/install_test.sh b/tests/install_test.sh index 820ad94..0bf1c81 100644 --- a/tests/install_test.sh +++ b/tests/install_test.sh @@ -159,6 +159,8 @@ test_menu_rendering() ( [[ "${output}" == *'NewSzxcn Email 管理面板'* ]] || fail_test "uninstalled menu title missing" [[ "${output}" == *'状态:尚未安装'* ]] || fail_test "uninstalled menu status missing" [[ "${output}" == *'1. 一键安装 NewSzxcn Email'* ]] || fail_test "uninstalled menu install action missing" + [[ "${output}" == *'2. 备份恢复'* ]] || fail_test "uninstalled menu restore action missing" + [[ "${output}" == *'3. 退出'* ]] || fail_test "uninstalled menu exit action missing" [[ "${output}" != *'更新系统'* ]] || fail_test "uninstalled menu exposes update action" [[ "${output}" != *'卸载服务'* ]] || fail_test "uninstalled menu exposes uninstall action" @@ -186,12 +188,15 @@ test_menu_dispatch() ( mkdir -p "${INSTALL_DIR}" prompt_text() { :; } do_install() { printf 'install\n' > "${action_file}"; } + do_restore_menu() { printf 'restore-menu\n' > "${action_file}"; } do_menu grep -Fq 'install' "${action_file}" || fail_test "uninstalled menu did not dispatch install" - if (LANQIN_MENU_ACTION=2 do_menu >/dev/null 2>&1); then - fail_test "uninstalled menu accepted unavailable update action" - fi + LANQIN_MENU_ACTION=2 + do_menu + grep -Fq 'restore-menu' "${action_file}" || fail_test "uninstalled menu did not dispatch restore" + LANQIN_MENU_ACTION=3 + do_menu >/dev/null printf 'LANQIN_PUBLIC_BASE_URL=https://mail.example.com\n' > "${INSTALL_DIR}/.env" printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml" @@ -430,6 +435,144 @@ test_cli_alias_safety() ( grep -Fq 'occupied' "${CLI_ALIAS_PATH}" || fail_test "existing ns command was overwritten" ) +test_restore_source_validation() ( + local temp_dir + temp_dir="$(mktemp -d)" + mkdir -p "${temp_dir}/data" "${temp_dir}/mail" "${temp_dir}/dkim" "${temp_dir}/certs" + printf 'config\n' > "${temp_dir}/.env" + printf 'services: {}\n' > "${temp_dir}/docker-compose.yml" + sqlite3 "${temp_dir}/data/lanqin.db" 'CREATE TABLE restore_test (id INTEGER PRIMARY KEY);' + validate_restore_source "${temp_dir}" || fail_test "valid restore source rejected" + validate_restore_database "${temp_dir}/data/lanqin.db" || fail_test "valid restore database rejected" + printf 'damaged\n' > "${temp_dir}/data/lanqin.db" + if validate_restore_database "${temp_dir}/data/lanqin.db" >/dev/null 2>&1; then + fail_test "damaged restore database accepted" + fi + rm -f "${temp_dir}/data/lanqin.db" + if validate_restore_source "${temp_dir}" >/dev/null 2>&1; then + fail_test "restore source without database accepted" + fi +) + +test_restore_menu_rendering_and_dispatch() ( + local output action_file LANQIN_MENU_ACTION=1 + action_file="$(mktemp)" + prompt_text() { printf '%b' "$1"; } + output="$(render_restore_menu)" + [[ "${output}" == *'NewSzxcn Email 备份恢复'* ]] || fail_test "restore menu title missing" + [[ "${output}" == *'1. 本地上传'* ]] || fail_test "restore local upload action missing" + [[ "${output}" == *'2. 返回上一级'* ]] || fail_test "restore back action missing" + [[ "${output}" == *'自动检测 /root/'* ]] || fail_test "restore automatic discovery hint missing" + prompt_text() { :; } + do_restore_backup() { printf 'restore\n' > "${action_file}"; } + do_restore_menu + grep -Fq 'restore' "${action_file}" || fail_test "restore menu did not dispatch local upload" + LANQIN_MENU_ACTION=2 + do_restore_menu >/dev/null + unset LANQIN_MENU_ACTION +) + +test_restore_backup_discovery() ( + local temp_dir output selected + temp_dir="$(mktemp -d)" + LANQIN_RESTORE_SEARCH_DIR="${temp_dir}" + touch "${temp_dir}/unrelated.tar.zst.enc" + output="$(discover_restore_backups)" + [[ -z "${output}" ]] || fail_test "unrelated archive was discovered" + + touch "${temp_dir}/newszxcn-backup-20260810-120000-1.2.30.tar.zst.enc" + selected="$(select_restore_source)" + assert_eq "${temp_dir}/newszxcn-backup-20260810-120000-1.2.30.tar.zst.enc" "${selected}" "single discovered restore backup" + + touch "${temp_dir}/newszxcn-backup-20260812-120000-1.2.32.tar.zst.enc" + touch "${temp_dir}/newszxcn-backup-20260811-120000-1.2.31.tar.zst.enc" + output="$(discover_restore_backups)" + assert_eq "newszxcn-backup-20260812-120000-1.2.32.tar.zst.enc" "$(printf '%s\n' "${output}" | head -n 1 | xargs basename)" "newest restore backup ordering" + LANQIN_RESTORE_SELECTION=2 + selected="$(select_restore_source)" + assert_eq "${temp_dir}/newszxcn-backup-20260811-120000-1.2.31.tar.zst.enc" "${selected}" "selected discovered restore backup" +) + +test_encrypted_restore_archive() ( + local temp_dir source_dir archive extracted password='RestorePassword123!' + temp_dir="$(mktemp -d)" + source_dir="${temp_dir}/source/newszxcn-email" + archive="${temp_dir}/newszxcn-backup.tar.zst.enc" + extracted="${temp_dir}/extracted" + mkdir -p "${source_dir}/data" "${source_dir}/mail" "${source_dir}/dkim" "${source_dir}/certs" "${extracted}" + printf 'config\n' > "${source_dir}/.env" + printf 'services: {}\n' > "${source_dir}/docker-compose.yml" + sqlite3 "${source_dir}/data/lanqin.db" 'CREATE TABLE restore_test (id INTEGER PRIMARY KEY);' + zstd() { + if [[ "$*" == '-q -c' ]]; then + gzip -c + elif [[ "$1" == '-dc' ]]; then + gzip -dc "$2" + else + return 1 + fi + } + tar -C "${temp_dir}/source" -cf - newszxcn-email | zstd -q -c | \ + openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -md sha256 -out "${archive}" -pass fd:3 3<<<"${password}" + LANQIN_RESTORE_PASSWORD="${password}" extract_restore_archive "${archive}" "${extracted}" + validate_restore_source "${extracted}/newszxcn-email" || fail_test "encrypted restore archive extraction failed" +) + +test_failed_full_restore_cleans_partial_install() ( + local temp_dir source_dir archive password='RestorePassword123!' + temp_dir="$(mktemp -d)" + source_dir="${temp_dir}/source/newszxcn-backup" + archive="${temp_dir}/newszxcn-backup-20260812-120000-1.2.31.tar.zst.enc" + INSTALL_DIR="${temp_dir}/install" + NGINX_CONFIG="${temp_dir}/nginx/newszxcn.conf" + CERT_DIR="${temp_dir}/certs" + LANQIN_RESTORE_SOURCE="${archive}" + LANQIN_RESTORE_PASSWORD="${password}" + mkdir -p "${source_dir}/data" "${source_dir}/mail" "${source_dir}/dkim" "${source_dir}/certs" "$(dirname "${NGINX_CONFIG}")" + printf 'LANQIN_PUBLIC_BASE_URL=https://mail.example.com\n' > "${source_dir}/.env" + printf 'services: {}\n' > "${source_dir}/docker-compose.yml" + sqlite3 "${source_dir}/data/lanqin.db" 'CREATE TABLE restore_test (id INTEGER PRIMARY KEY);' + zstd() { + if [[ "$*" == '-q -c' ]]; then + gzip -c + elif [[ "$1" == '-dc' ]]; then + gzip -dc "$2" + else + return 1 + fi + } + tar -C "${temp_dir}/source" -cf - newszxcn-backup | zstd -q -c | \ + openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -md sha256 -out "${archive}" -pass fd:3 3<<<"${password}" + refresh_assets() { return 0; } + ensure_update_token() { return 0; } + ensure_admin_email_config() { return 0; } + configure_runtime_bindings() { return 0; } + ensure_docker() { return 0; } + configure_firewall() { return 0; } + prepare_directories() { return 0; } + compose() { + case "$1" in + pull) return 1 ;; + down) return 0 ;; + esac + return 0 + } + if (do_restore_backup >/dev/null 2>&1); then + fail_test "failed full restore unexpectedly succeeded" + fi + [[ ! -e "${INSTALL_DIR}" ]] || fail_test "failed restore left a partial installation" + [[ -f "${archive}" ]] || fail_test "failed restore removed the original encrypted backup" +) + +test_restore_archive_path_validation() ( + printf 'safe/path\n' | archive_has_unsafe_paths && fail_test "safe archive path rejected" + printf '../escape\n' | archive_has_unsafe_paths || fail_test "parent archive path accepted" + printf '/absolute\n' | archive_has_unsafe_paths || fail_test "absolute archive path accepted" + printf '%s\n' '-rw------- root/root 1 2026-08-12 00:00 safe' | archive_has_unsafe_types && fail_test "regular archive file rejected" + printf '%s\n' 'drwx------ root/root 0 2026-08-12 00:00 safe/' | archive_has_unsafe_types && fail_test "archive directory rejected" + printf '%s\n' 'lrwxrwxrwx root/root 0 2026-08-12 00:00 unsafe -> /etc' | archive_has_unsafe_types || fail_test "archive symlink accepted" +) + test_compose_runtime_image_pin() ( local temp_dir calls temp_dir="$(mktemp -d)" @@ -707,6 +850,12 @@ test_offline_database_backup test_guide_generation test_acme_cron_detection test_cli_alias_safety +test_restore_source_validation +test_restore_menu_rendering_and_dispatch +test_restore_backup_discovery +test_encrypted_restore_archive +test_failed_full_restore_cleans_partial_install +test_restore_archive_path_validation test_compose_runtime_image_pin test_update_snapshot_restore test_snapshot_restores_absent_optional_files