d23ddf6e0c
Администратору (адрес из «Почты») — сбои (ИШ, связь с НРД, службы bj, DEPO) и обновления (новый выпуск bj, ИШ, лицензия, сертификаты); отделам — переводы и поручения. Существующие установки: администратор получает всё, что получал. Новые оповещения: «Доступно обновление bj» (раз на выпуск), «Служба … не работает / снова работает» (модули и служба ключей Валидаты). Резервные копии: \\сервер\папка или сервер + общая папка по полям → //сервер/папка; понятная причина отказа доступа. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
241 lines
9.5 KiB
Go
241 lines
9.5 KiB
Go
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")
|
||
}
|