release: prepare v1.2.34
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions

This commit is contained in:
zxyszx
2026-08-12 17:58:48 +08:00
parent 48a1d53133
commit 1dbc33b0dc
8 changed files with 282 additions and 30 deletions
+103 -13
View File
@@ -66,6 +66,7 @@ type backupSchedule struct {
Enabled bool `json:"enabled"`
Days int `json:"days"`
PasswordSet bool `json:"passwordSet"`
PasswordHint string `json:"passwordHint,omitempty"`
ServerIP string `json:"serverIp"`
ChatID string `json:"chatId"`
TelegramMode string `json:"telegramMode"`
@@ -124,6 +125,11 @@ type updateBackupScheduleRequest struct {
GoogleFolderName string `json:"googleFolderName"`
}
type updateBackupPasswordRequest struct {
Password string `json:"password"`
ConfirmPassword string `json:"confirmPassword"`
}
type testBackupTelegramRequest struct {
Mode string `json:"mode"`
ChatID string `json:"chatId"`
@@ -192,27 +198,53 @@ func (a *App) handleCreateBackup(w http.ResponseWriter, r *http.Request) {
badRequest(w, err)
return
}
if !validBackupPassword(req.Password) {
badRequest(w, errors.New("备份密码至少需要 8 个字符"))
a.backupMu.Lock()
locked := true
defer func() {
if locked {
a.backupMu.Unlock()
}
}()
if a.backupJob != nil && a.backupJob.Status == "running" {
respondError(w, http.StatusConflict, "已有备份任务正在运行")
return
}
if req.Password != req.ConfirmPassword {
badRequest(w, errors.New("两次输入的备份密码不一致"))
password, err := a.savedBackupPassword(r.Context())
if err != nil && !errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusInternalServerError, "无法读取已保存的备份密码")
return
}
if password == "" {
if !validBackupPassword(req.Password) {
badRequest(w, errors.New("首次创建备份时,密码至少需要 8 个字符"))
return
}
if req.Password != req.ConfirmPassword {
badRequest(w, errors.New("两次输入的备份密码不一致"))
return
}
}
if !a.backupAssetsAvailable() {
respondError(w, http.StatusServiceUnavailable, "当前部署尚未启用完整备份")
return
}
a.backupMu.Lock()
if a.backupJob != nil && a.backupJob.Status == "running" {
a.backupMu.Unlock()
respondError(w, http.StatusConflict, "已有备份任务正在运行")
return
if password == "" {
ciphertext, encryptErr := a.encryptBackupPassword(req.Password)
if encryptErr != nil {
respondError(w, http.StatusInternalServerError, "无法安全保存备份密码")
return
}
now := a.now().UTC().Format(time.RFC3339Nano)
if _, err = a.db.ExecContext(r.Context(), `INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`, ciphertext, now); err != nil {
respondError(w, http.StatusInternalServerError, "无法保存备份密码")
return
}
password = req.Password
}
a.backupJob = &backupJob{Status: "running", StartedAt: a.now().UTC()}
a.backupMu.Unlock()
password, sendTelegram, uploadGoogleDrive := req.Password, req.SendTelegram, req.UploadGoogleDrive
locked = false
sendTelegram, uploadGoogleDrive := req.SendTelegram, req.UploadGoogleDrive
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
defer cancel()
@@ -351,13 +383,66 @@ func (a *App) handleUpdateBackupSettings(w http.ResponseWriter, r *http.Request)
respondError(w, 500, "保存失败")
return
}
respondJSON(w, 200, backupSchedule{Enabled: req.Enabled, Days: req.Days, PasswordSet: ciphertext != "", ServerIP: detectPublicServerIP(r.Context(), a.config().PublicHostname), ChatID: chatID, TelegramMode: telegramMode, TelegramEnabled: req.TelegramEnabled, GoogleDriveEnabled: req.GoogleDriveEnabled})
passwordHint := ""
if password, err := a.decryptBackupPassword(ciphertext); err == nil {
passwordHint = backupPasswordHint(password)
}
respondJSON(w, 200, backupSchedule{Enabled: req.Enabled, Days: req.Days, PasswordSet: ciphertext != "", PasswordHint: passwordHint, ServerIP: detectPublicServerIP(r.Context(), a.config().PublicHostname), ChatID: chatID, TelegramMode: telegramMode, TelegramEnabled: req.TelegramEnabled, GoogleDriveEnabled: req.GoogleDriveEnabled})
}
func (a *App) handleUpdateBackupPassword(w http.ResponseWriter, r *http.Request) {
if !a.requireSystemAdmin(w, r) {
return
}
var req updateBackupPasswordRequest
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
}
ciphertext, err := a.encryptBackupPassword(req.Password)
if err != nil {
respondError(w, http.StatusInternalServerError, "无法安全保存备份密码")
return
}
now := a.now().UTC().Format(time.RFC3339Nano)
if _, err = a.db.ExecContext(r.Context(), `INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`, ciphertext, now); err != nil {
respondError(w, http.StatusInternalServerError, "无法保存备份密码")
return
}
respondJSON(w, http.StatusOK, map[string]any{"passwordSet": true, "passwordHint": backupPasswordHint(req.Password)})
}
func validBackupPassword(password string) bool {
return len(password) >= 8 && len(password) <= 1024 && !strings.ContainsAny(password, "\r\n\x00")
}
func backupPasswordHint(password string) string {
runes := []rune(password)
if len(runes) < 2 {
return ""
}
return string(runes[0]) + strings.Repeat("•", minimumInt(len(runes)-2, 10)) + string(runes[len(runes)-1])
}
func (a *App) savedBackupPassword(ctx context.Context) (string, error) {
var ciphertext string
if err := a.db.QueryRowContext(ctx, `SELECT value FROM system_settings WHERE key='backupPasswordCipher'`).Scan(&ciphertext); err != nil {
return "", err
}
if strings.TrimSpace(ciphertext) == "" {
return "", sql.ErrNoRows
}
return a.decryptBackupPassword(ciphertext)
}
func (a *App) handleTestBackupTelegram(w http.ResponseWriter, r *http.Request) {
if !a.requireSystemAdmin(w, r) {
return
@@ -653,8 +738,8 @@ func (a *App) backupAssetsAvailable() bool {
func writeRuntimeBackupEnv(path string) error {
values := make([]string, 0)
containerOnly := map[string]bool{
"LANQIN_BACKUP_DIR": true,
"LANQIN_BACKUP_SOURCE_DIR": true,
"LANQIN_BACKUP_DIR": true,
"LANQIN_BACKUP_SOURCE_DIR": true,
"LANQIN_UPDATE_SERVICE_TOKEN": true,
"LANQIN_UPDATE_SERVICE_URL": true,
}
@@ -1160,6 +1245,11 @@ func (a *App) loadBackupSchedule(ctx context.Context) (backupSchedule, error) {
}
case "backupPasswordCipher":
result.PasswordSet = value != ""
if value != "" {
if password, err := a.decryptBackupPassword(value); err == nil {
result.PasswordHint = backupPasswordHint(password)
}
}
case "backupTelegramEnabled":
result.TelegramEnabled = value == "true"
case "backupGoogleDriveEnabled":
@@ -13,6 +13,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
)
func TestBackupEndpointsRejectMismatchedConfirmation(t *testing.T) {
@@ -120,6 +121,137 @@ func TestBackupPasswordValidation(t *testing.T) {
}
}
func TestBackupPasswordHint(t *testing.T) {
if got := backupPasswordHint("A23456789Z"); got != "A••••••••Z" {
t.Fatalf("password hint = %q", got)
}
if got := backupPasswordHint("ab"); got != "ab" {
t.Fatalf("two-character password hint = %q", got)
}
if got := backupPasswordHint(""); got != "" {
t.Fatalf("empty password hint = %q", got)
}
}
func TestSavedBackupPasswordAndHint(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, UpdateServiceToken: "test-update-secret",
})
stopTestWorkers(a)
ciphertext, err := a.encryptBackupPassword("A23456789Z")
if err != nil {
t.Fatal(err)
}
now := a.now().UTC().Format("2006-01-02T15:04:05Z")
if _, err = a.db.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?)`, ciphertext, now); err != nil {
t.Fatal(err)
}
password, err := a.savedBackupPassword(context.Background())
if err != nil || password != "A23456789Z" {
t.Fatalf("saved password = %q, %v", password, err)
}
schedule, err := a.loadBackupSchedule(context.Background())
if err != nil || !schedule.PasswordSet || schedule.PasswordHint != "A••••••••Z" {
t.Fatalf("schedule password state = %+v, %v", schedule, err)
}
}
func TestUpdateBackupPasswordDoesNotChangeScheduleSettings(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, UpdateServiceToken: "test-update-secret",
})
stopTestWorkers(a)
now := a.now().UTC().Format(time.RFC3339Nano)
for key, value := range map[string]string{
"backupScheduleEnabled": "true",
"backupScheduleDays": "30",
"backupTelegramMode": "custom",
"backupTelegramChatId": "-1001234567890",
"backupGoogleFolderName": "Existing Backups",
} {
if _, err := a.db.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?)`, key, value, now); err != nil {
t.Fatal(err)
}
}
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@example.com", "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/password", map[string]string{"password": "NewSharedPassword9", "confirmPassword": "NewSharedPassword9"}, &response); code != http.StatusOK {
t.Fatalf("password update code=%d body=%v", code, response)
}
if response["passwordHint"] != "N••••••••••9" {
t.Fatalf("password hint = %v", response["passwordHint"])
}
password, err := a.savedBackupPassword(context.Background())
if err != nil || password != "NewSharedPassword9" {
t.Fatalf("saved password = %q, %v", password, err)
}
for key, want := range map[string]string{
"backupScheduleEnabled": "true",
"backupScheduleDays": "30",
"backupTelegramMode": "custom",
"backupTelegramChatId": "-1001234567890",
"backupGoogleFolderName": "Existing Backups",
} {
var got string
if err := a.db.QueryRow(`SELECT value FROM system_settings WHERE key=?`, key).Scan(&got); err != nil || got != want {
t.Fatalf("setting %s = %q, %v; want %q", key, got, err, want)
}
}
}
func TestManualBackupReusesSavedPassword(t *testing.T) {
dir := t.TempDir()
deployDir := filepath.Join(dir, "deploy")
if err := os.MkdirAll(deployDir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(deployDir, "docker-compose.yml"), []byte("services: {}\n"), 0o600); err != nil {
t.Fatal(err)
}
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, UpdateServiceToken: "test-update-secret", BackupSourceDir: deployDir,
BackupDir: filepath.Join(dir, "data", "disaster-backups"),
})
stopTestWorkers(a)
ciphertext, err := a.encryptBackupPassword("SharedBackupPassword9")
if err != nil {
t.Fatal(err)
}
now := a.now().UTC().Format("2006-01-02T15:04:05Z")
if _, err = a.db.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?)`, ciphertext, now); err != nil {
t.Fatal(err)
}
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@example.com", "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": "", "confirmPassword": "", "sendTelegram": false, "uploadGoogleDrive": false}, &response); code != http.StatusAccepted {
t.Fatalf("manual backup code=%d body=%v", code, response)
}
password, err := a.savedBackupPassword(context.Background())
if err != nil || password != "SharedBackupPassword9" {
t.Fatalf("saved password changed: %q, %v", password, err)
}
}
func TestPublicServerIPValidation(t *testing.T) {
for _, value := range []string{"203.0.113.10", "2001:4860:4860::8888"} {
if !isPublicIP(net.ParseIP(value)) {
+1
View File
@@ -141,6 +141,7 @@ func (a *App) Router() http.Handler {
r.Post("/admin/system/update", a.handleSystemUpdate)
r.Get("/admin/backups", a.handleListBackups)
r.Post("/admin/backups/settings", a.handleUpdateBackupSettings)
r.Post("/admin/backups/password", a.handleUpdateBackupPassword)
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)