package lkgateway // Резервное копирование сервера bj (05.10.2026): базы PostgreSQL bj и igate, данные bj и шлюза НРД — на // сетевое хранилище (SMB) по расписанию; восстановление — в мастере настройки (переустановка сервера). // Делает root: таймер bj-backup.timer → /usr/local/sbin/bj-backup; настройка, проверка, список и // восстановление — /usr/local/sbin/bj-backup-admin через root-демон bj-keymedia. Пароль хранилища bj // не хранит: он передаётся root-скрипту и лежит в /etc/bj/backup-smb.cred (только root). import ( "context" "encoding/json" "fmt" "net/http" "os" "strconv" "strings" "time" ) // BackupSettings — куда и как часто копировать (без пароля). type BackupSettings struct { Enabled bool `json:"enabled"` Share string `json:"share,omitempty"` // //nas/backup Dir string `json:"dir,omitempty"` // bj-backup User string `json:"user,omitempty"` PasswordSet bool `json:"password_set,omitempty"` EveryHours int `json:"every_hours,omitempty"` KeepDays int `json:"keep_days,omitempty"` SavedAt time.Time `json:"saved_at,omitempty"` } func (b BackupSettings) every() int { switch b.EveryHours { case 1, 2, 3, 4, 6, 8, 12, 24: return b.EveryHours } return 6 } func (b BackupSettings) keep() int { if b.KeepDays >= 1 { return b.KeepDays } return 14 } func (b BackupSettings) dir() string { return pickStr(strings.TrimSpace(b.Dir), "bj-backup") } // smbLocation — адрес хранилища, как его вводят люди (\\nas\backup\bj как в проводнике, //nas/backup, smb://nas/backup; // или сервер в поле «Хранилище», а общая папка — в поле «Папка»), → общая папка для smbclient (//сервер/папка) и // путь внутри неё. Раньше «\\10.10.10.11\» + «backup_test» превращалось в //10.10.10.11//backup_test — без общей // папки, и доступ не проверялся. Только сервер без общей папки — share «//сервер» (форма скажет, чего не хватает). func smbLocation(share, dir string) (string, string) { parts := func(s string) []string { s = strings.ReplaceAll(strings.TrimSpace(s), `\`, "/") if i := strings.Index(s, "://"); i > 0 && strings.EqualFold(s[:i], "smb") { s = s[i+3:] } var out []string for _, p := range strings.Split(s, "/") { if p = strings.TrimSpace(p); p != "" { out = append(out, p) } } return out } sp, dp := parts(share), parts(dir) if len(sp) == 0 { return "", strings.Join(dp, "/") } if len(sp) == 1 { // только сервер: общая папка — начало «Папки» if len(dp) == 0 { return "//" + sp[0], "" } sp, dp = []string{sp[0], dp[0]}, dp[1:] } sub := append(append([]string{}, sp[2:]...), dp...) return "//" + sp[0] + "/" + sp[1], strings.Join(sub, "/") } // smbWin — адрес хранилища так, как его видят в проводнике Windows (//nas/backup → \\nas\backup). func smbWin(share string) string { if !strings.HasPrefix(share, "//") { return share } return strings.ReplaceAll(share, "/", `\`) } // smbShareOK — в адресе есть и сервер, и общая папка. func smbShareOK(share string) bool { return strings.Count(strings.TrimPrefix(share, "//"), "/") == 1 } // BackupStatus — итог последней копии (пишет root-скрипт). type BackupStatus struct { At string `json:"at"` OK bool `json:"ok"` Dir string `json:"dir"` Msg string `json:"msg"` } const ( backupStatusFile = "/var/lib/bj/backup-status.json" restoreStatusFile = "/var/lib/bj/restore-status.json" ) func backupStatus() *BackupStatus { raw, err := os.ReadFile(backupStatusFile) if err != nil { return nil } var s BackupStatus if json.Unmarshal(raw, &s) != nil { return nil } return &s } // backupOp — команда root-скрипту bj-backup-admin (config|test|run|list|verify|restore) с параметрами. func backupOp(ctx context.Context, op string, params map[string]string) (map[string]any, error) { extra, _ := json.Marshal(params) return runKeymediaOp(ctx, "backup-"+op, map[string]string{"extra": string(extra)}) } // backupParams — хранилище из формы (share/dir/user/password); пустое — из настроек (root берёт свои). func backupParams(r *http.Request) map[string]string { p := map[string]string{} for _, k := range []string{"share", "dir", "user", "password", "name"} { if v := strings.TrimSpace(r.FormValue(k)); v != "" { p[k] = v } } if p["share"] != "" { // адрес как в проводнике → общая папка и путь внутри неё p["share"], p["dir"] = smbLocation(p["share"], p["dir"]) if p["dir"] == "" { delete(p, "dir") } } return p } func jsonOut(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(v) } func opResult(res map[string]any, err error) map[string]any { if err != nil { return map[string]any{"ok": false, "error": err.Error()} } return res } func registerBackup(mux *http.ServeMux, rc *RuntimeConfig) { // сохранить настройки (и передать root-скрипту: конфигурация, пароль, расписание таймера) mux.HandleFunc("/admin/setup/backup", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } b := rc.Snapshot().Backup b.Enabled = r.FormValue("enabled") != "" b.Share, b.Dir = smbLocation(r.FormValue("share"), r.FormValue("dir")) b.User = strings.TrimSpace(r.FormValue("user")) b.EveryHours, _ = strconv.Atoi(r.FormValue("every_hours")) b.KeepDays, _ = strconv.Atoi(r.FormValue("keep_days")) pass := r.FormValue("password") if b.Share == "" { setupFlash(w, r, `Резервное копирование: укажите сетевую папку, как в проводнике, например \\nas\backup`) return } if !smbShareOK(b.Share) { setupFlash(w, r, `Резервное копирование: укажите не только сервер, но и общую папку на нём — например \\`+strings.TrimPrefix(b.Share, "//")+`\backup`) return } ctx, cancel := context.WithTimeout(r.Context(), time.Minute) defer cancel() res, err := backupOp(ctx, "config", map[string]string{"share": b.Share, "dir": b.dir(), "user": b.User, "password": pass, "every_hours": strconv.Itoa(b.every()), "keep_days": strconv.Itoa(b.keep()), "enabled": strconv.FormatBool(b.Enabled)}) if err != nil { setupFlash(w, r, "Резервное копирование: не сохранено — "+err.Error()) return } if pass != "" { b.PasswordSet = true } b.SavedAt = time.Now() _ = rc.UpdateBackup(b) setupFlash(w, r, "Резервное копирование: "+fmt.Sprint(res["output"])) }) mux.HandleFunc("/admin/setup/backup/test", func(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), time.Minute) defer cancel() jsonOut(w, opResult(backupOp(ctx, "test", backupParams(r)))) }) mux.HandleFunc("/admin/setup/backup/run", func(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() jsonOut(w, opResult(backupOp(ctx, "run", nil))) }) mux.HandleFunc("/admin/setup/backup/list", func(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), time.Minute) defer cancel() jsonOut(w, opResult(backupOp(ctx, "list", backupParams(r)))) }) // проверка копии: скачать и сверить контрольные суммы (ничего не восстанавливает) mux.HandleFunc("/admin/setup/backup/verify", func(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) defer cancel() jsonOut(w, opResult(backupOp(ctx, "verify", backupParams(r)))) }) mux.HandleFunc("/admin/setup/backup/status", func(w http.ResponseWriter, r *http.Request) { out := map[string]any{"backup": backupStatus()} if raw, err := os.ReadFile(restoreStatusFile); err == nil { var rs map[string]any if json.Unmarshal(raw, &rs) == nil { out["restore"] = rs } } jsonOut(w, out) }) // восстановление: останавливает bj и шлюз НРД, поэтому выполняется в фоне; страница опрашивает status mux.HandleFunc("/admin/setup/backup/restore", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } ctx, cancel := context.WithTimeout(r.Context(), time.Minute) defer cancel() jsonOut(w, opResult(backupOp(ctx, "restore", backupParams(r)))) }) } // backupWhen — время копии из статуса (RFC 3339) в виде «05.10.2026 14:46». func backupWhen(at string) string { t, err := time.Parse(time.RFC3339, at) if err != nil { return at } return t.Local().Format("02.01.2006 15:04") }