Files
Bridge-and-Join-s/internal/lkgateway/backup.go
T
zuevav d23ddf6e0c Оповещения: группа «Администратор системы», блок «Кому писать» открыт; копии: адрес хранилища как в проводнике
Администратору (адрес из «Почты») — сбои (ИШ, связь с НРД, службы bj, DEPO) и обновления (новый выпуск bj,
ИШ, лицензия, сертификаты); отделам — переводы и поручения. Существующие установки: администратор получает
всё, что получал. Новые оповещения: «Доступно обновление bj» (раз на выпуск), «Служба … не работает /
снова работает» (модули и служба ключей Валидаты). Резервные копии: \\сервер\папка или сервер + общая
папка по полям → //сервер/папка; понятная причина отказа доступа.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
2026-10-06 22:44:35 +03:00

241 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}