diff --git a/cmd/bj-installer/main.go b/cmd/bj-installer/main.go index ef9d2e8..e3d1d1c 100644 --- a/cmd/bj-installer/main.go +++ b/cmd/bj-installer/main.go @@ -258,7 +258,7 @@ func memTotal() uint64 { } func (in *installer) stepPackages() (string, error) { - pkgs := []string{"ca-certificates", "curl", "postgresql", "postgresql-client", "default-jre-headless", "pcscd", "python3"} + pkgs := []string{"ca-certificates", "curl", "postgresql", "postgresql-client", "default-jre-headless", "pcscd", "python3", "smbclient"} env := append(os.Environ(), "DEBIAN_FRONTEND=noninteractive") // Образ кладёт пакеты со всеми зависимостями в /var/lib/bj/debs — ставим с диска, без сети. // Не вышло (другая система, не хватает зависимости) — обычная установка из репозитория. diff --git a/deploy/embed.go b/deploy/embed.go index 9f1b7a9..64fa696 100644 --- a/deploy/embed.go +++ b/deploy/embed.go @@ -6,5 +6,5 @@ import "embed" // Files — linux/sbin/* (в /usr/local/sbin) и systemd/*.service (в /etc/systemd/system). // -//go:embed linux/sbin/* systemd/bj-server.service systemd/bj-crypto.service systemd/bj-keymedia.service systemd/bj-npr.service systemd/bj-quikexport.service systemd/bj-lk.service systemd/bj-welcome.service systemd/bj-welcome.timer +//go:embed linux/sbin/* systemd/bj-server.service systemd/bj-crypto.service systemd/bj-keymedia.service systemd/bj-npr.service systemd/bj-quikexport.service systemd/bj-lk.service systemd/bj-welcome.service systemd/bj-welcome.timer systemd/bj-backup.service systemd/bj-backup.timer var Files embed.FS diff --git a/deploy/iso/build-ubuntu-updates-layer.sh b/deploy/iso/build-ubuntu-updates-layer.sh index c5a68a5..08f9d4a 100755 --- a/deploy/iso/build-ubuntu-updates-layer.sh +++ b/deploy/iso/build-ubuntu-updates-layer.sh @@ -19,7 +19,7 @@ OUT=${2:?выходной каталог} [ "$(id -u)" = 0 ] || { echo "нужен root"; exit 1; } for c in mksquashfs xorriso; do command -v $c >/dev/null || { echo "нужен $c"; exit 1; }; done # пакеты — как в bj-installer (stepPackages) -BJ_PKGS="ca-certificates curl postgresql postgresql-client default-jre-headless pcscd python3" +BJ_PKGS="ca-certificates curl postgresql postgresql-client default-jre-headless pcscd python3 smbclient" LAYER=ubuntu-server-minimal.ubuntu-server.bj.squashfs mkdir -p "$OUT" diff --git a/deploy/iso/preseed.cfg b/deploy/iso/preseed.cfg index 0086bfe..4a27dc9 100644 --- a/deploy/iso/preseed.cfg +++ b/deploy/iso/preseed.cfg @@ -59,7 +59,7 @@ d-i partman-md/device_remove_md boolean true # Пакеты: без графики; SSH и всё, что нужно bj, ставится сразу tasksel tasksel/first multiselect standard, ssh-server -d-i pkgsel/include string ca-certificates curl postgresql postgresql-client default-jre-headless pcscd python3 sudo +d-i pkgsel/include string ca-certificates curl postgresql postgresql-client default-jre-headless pcscd python3 smbclient sudo d-i pkgsel/upgrade select safe-upgrade popularity-contest popularity-contest/participate boolean false diff --git a/deploy/linux/sbin/bj-backup b/deploy/linux/sbin/bj-backup new file mode 100755 index 0000000..d463bec --- /dev/null +++ b/deploy/linux/sbin/bj-backup @@ -0,0 +1,63 @@ +#!/bin/bash +# bj-backup — резервная копия сервера bj на сетевое хранилище (SMB): базы PostgreSQL bj и igate, +# данные bj (/var/lib/bj/.bj, /etc/bj) и шлюза НРД (/var/lib/igate без журналов). +# Куда, как часто и сколько хранить — /etc/bj/backup.conf (пишет bj-backup-admin из настроек bj), +# логин хранилища — /etc/bj/backup-smb.cred (только root). Итог — /var/lib/bj/backup-status.json. +set -uo pipefail +CONF=/etc/bj/backup.conf +CRED=/etc/bj/backup-smb.cred +STATUS=/var/lib/bj/backup-status.json +[ -r "$CONF" ] && . "$CONF" +SHARE=${SHARE:-} +DIR=${DIR:-BJ_Backup/bj} +KEEP_DAYS=${KEEP_DAYS:-14} +TS=$(date +%Y-%m-%d_%H%M) +W=$(mktemp -d /var/tmp/bj-backup.XXXXXX) +trap 'rm -rf "$W"' EXIT +cd / +status() { python3 - "$STATUS" "$1" "$DIR/$TS" "$2" <<'PY' +import json,sys,datetime +p,ok,d,msg=sys.argv[1:] +json.dump({"at":datetime.datetime.now().astimezone().isoformat(timespec="seconds"),"ok":ok=="true","dir":d,"msg":msg},open(p,"w"),ensure_ascii=False) +PY +chown bj:bj "$STATUS" 2>/dev/null || true; } +fail() { status false "$1"; echo "ОШИБКА: $1" >&2; exit 1; } +smb() { smbclient "$SHARE" -A "$CRED" -m SMB3 -c "$1"; } +# tar: «файл изменился во время чтения» (код 1) для работающих служб — не ошибка +tarok() { tar "$@"; local rc=$?; [ $rc -le 1 ]; } + +[ -n "$SHARE" ] || fail "не задано хранилище (Настройка → Резервное копирование)" +[ -r "$CRED" ] || fail "нет логина хранилища (Настройка → Резервное копирование)" + +runuser -u postgres -- pg_dump -Fc bj > "$W/bj.pgdump" || fail "дамп базы bj" +if runuser -u postgres -- psql -Atc "select 1 from pg_database where datname='igate'" 2>/dev/null | grep -q 1; then + runuser -u postgres -- pg_dump -Fc igate > "$W/igate.pgdump" || fail "дамп базы igate" +fi +tarok -C / -czf "$W/bj-data.tar.gz" --exclude=etc/bj/backup-smb.cred var/lib/bj/.bj etc/bj || fail "архив данных bj" +if [ -d /var/lib/igate ]; then + tarok -C / -czf "$W/igate-data.tar.gz" --warning=no-file-changed --exclude='var/lib/igate/.cache' \ + --exclude='var/lib/igate/NSD Integration Gateway/logs*' --exclude='var/lib/igate/logs-cli' var/lib/igate || fail "архив шлюза НРД" +fi +cat > "$W/backup-info.txt" </dev/null | head -1) +Восстановление: мастер настройки → «Восстановить из резервной копии» или bj-backup-admin restore. +INFO +(cd "$W" && sha256sum * > SHA256SUMS) +SIZE=$(du -sh "$W" | cut -f1) + +top=${DIR%%/*} +smb "mkdir \"$top\"; mkdir \"$DIR\"" >/dev/null 2>&1 || true +smb "mkdir \"$DIR/$TS\"; cd \"$DIR/$TS\"; lcd $W; prompt; mput *" >/dev/null || fail "запись на $SHARE/$DIR" +for f in $(cd "$W" && ls); do + smb "cd \"$DIR/$TS\"; ls \"$f\"" >/dev/null 2>&1 || fail "на хранилище нет $f" +done + +CUT=$(date -d "-$KEEP_DAYS days" +%Y-%m-%d_%H%M) +smb "cd \"$DIR\"; ls" 2>/dev/null | awk '{print $1}' | grep -E '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{4}$' | while read -r d; do + if [[ "$d" < "$CUT" ]]; then + smb "cd \"$DIR/$d\"; prompt; mask *; mdel *; cd ..; rmdir \"$d\"" >/dev/null 2>&1 && echo "удалена старая копия $d" + fi +done +status true "размер $SIZE" +echo "копия $DIR/$TS готова ($SIZE)" diff --git a/deploy/linux/sbin/bj-backup-admin b/deploy/linux/sbin/bj-backup-admin new file mode 100755 index 0000000..e849435 --- /dev/null +++ b/deploy/linux/sbin/bj-backup-admin @@ -0,0 +1,154 @@ +#!/bin/bash +# bj-backup-admin — управление резервным копированием bj (вызывает root-демон bj-keymedia по запросу из +# настроек bj). Параметры — JSON в BJ_EXTRA: share, dir, user, password, every_hours, keep_days, enabled, name. +# config — сохранить хранилище, расписание и хранение; включить/выключить таймер +# test — проверить доступ на запись к хранилищу +# run — сделать копию сейчас (в фоне) +# list — список копий на хранилище +# verify — проверить копию name: скачать и сверить контрольные суммы (ничего не восстанавливает) +# restore — восстановить копию name (в фоне: останавливает bj и шлюз НРД) +set -uo pipefail +CONF=/etc/bj/backup.conf +CRED=/etc/bj/backup-smb.cred +RSTATUS=/var/lib/bj/restore-status.json +cmd=${1:-} +j() { python3 -c "import json,os,sys;print(json.loads(os.environ.get('BJ_EXTRA') or '{}').get(sys.argv[1],''))" "$1"; } +q() { python3 -c "import json,sys;print(json.dumps(sys.argv[1],ensure_ascii=False))" "$1"; } +ok() { echo "{\"ok\":true,\"output\":$(q "$1")}"; } +err() { echo "{\"ok\":false,\"error\":$(q "$1")}"; exit 0; } +[ -r "$CONF" ] && . "$CONF" +# хранилище из запроса (восстановление на новом сервере, проверка до сохранения) или из настроек +SHARE_IN=$(j share); DIR_IN=$(j dir); USER_IN=$(j user); PASS_IN=$(j password) +SHARE=${SHARE_IN:-${SHARE:-}}; DIR=${DIR_IN:-${DIR:-BJ_Backup/bj}} +case "$SHARE" in //*) ;; \\\\*) SHARE=$(printf '%s' "$SHARE" | tr '\\' '/') ;; ?*) SHARE="//$SHARE" ;; esac +TMPCRED="" +credfile() { + if [ -n "$USER_IN" ] && [ -n "$PASS_IN" ]; then + TMPCRED=$(mktemp /run/bj-backup-cred.XXXXXX); chmod 600 "$TMPCRED" + printf 'username=%s\npassword=%s\n' "$USER_IN" "$PASS_IN" > "$TMPCRED"; echo "$TMPCRED" + else echo "$CRED"; fi +} +trap '[ -n "$TMPCRED" ] && rm -f "$TMPCRED"' EXIT +# smbclient ставит установщик; на серверах, установленных раньше, доставляем при первом обращении +case "$cmd" in config|test|list|verify|restore) + command -v smbclient >/dev/null 2>&1 || DEBIAN_FRONTEND=noninteractive apt-get install -y -qq smbclient >/dev/null 2>&1 + command -v smbclient >/dev/null 2>&1 || err "на сервере нет smbclient и его не удалось установить (apt-get install smbclient)" ;; +esac +smbx() { smbclient "$SHARE" -A "$1" -m SMB3 -c "$2" 2>&1; } +# расписание таймера: каждые N часов (24 — раз в сутки в 02:00) +schedule() { + install -d -m 755 /etc/systemd/system/bj-backup.timer.d + if [ "$1" = 24 ]; then cal='*-*-* 02:00:00'; else cal="*-*-* 00/$1:00:00"; fi + printf '[Timer]\nOnCalendar=\nOnCalendar=%s\n' "$cal" > /etc/systemd/system/bj-backup.timer.d/schedule.conf + systemctl daemon-reload +} + +case "$cmd" in + config) + every=$(j every_hours); keep=$(j keep_days); en=$(j enabled) + case "$every" in 1|2|3|4|6|8|12|24) ;; *) every=6 ;; esac + [[ "$keep" =~ ^[0-9]+$ ]] && [ "$keep" -ge 1 ] || keep=14 + [ -n "$SHARE" ] || err "не указано хранилище" + install -d -m 755 /etc/bj + umask 077 + case "$en" in True|true) enabled=1 ;; *) enabled=0 ;; esac + printf 'SHARE=%q\nDIR=%q\nKEEP_DAYS=%s\nEVERY_HOURS=%s\nENABLED=%s\n' "$SHARE" "$DIR" "$keep" "$every" "$enabled" > "$CONF" + if [ -n "$USER_IN" ] && [ -n "$PASS_IN" ]; then + printf 'username=%s\npassword=%s\n' "$USER_IN" "$PASS_IN" > "$CRED"; chmod 600 "$CRED" + elif [ -n "$USER_IN" ] && [ -r "$CRED" ]; then # сменили только логин — пароль прежний + sed -i "s/^username=.*/username=$USER_IN/" "$CRED" + fi + schedule "$every" + if [ "$enabled" = 1 ]; then + systemctl enable --now bj-backup.timer >/dev/null 2>&1 || err "не удалось включить таймер bj-backup.timer" + ok "сохранено: $SHARE/$DIR, каждые $every ч, хранение $keep дн., расписание включено" + else + systemctl disable --now bj-backup.timer >/dev/null 2>&1 + ok "сохранено, расписание выключено" + fi ;; + test) + [ -n "$SHARE" ] || err "не указано хранилище" + c=$(credfile); [ -r "$c" ] || err "не указан логин и пароль хранилища" + f=/tmp/bj-backup-test.$$; echo test > "$f"; top=${DIR%%/*} + smbx "$c" "mkdir \"$top\"; mkdir \"$DIR\"" >/dev/null + out=$(smbx "$c" "cd \"$DIR\"; put $f bj-write-test.txt; del bj-write-test.txt"); rc=$?; rm -f "$f" + if [ $rc -ne 0 ] || printf '%s' "$out" | grep -q "NT_STATUS"; then err "нет доступа на запись в $SHARE/$DIR: $(printf '%s' "$out" | grep -o 'NT_STATUS[A-Z_]*' | head -1)"; fi + ok "доступ на запись к $SHARE/$DIR есть" ;; + run) + systemctl start --no-block bj-backup.service || err "не удалось запустить bj-backup.service" + ok "копирование запущено" ;; + list) + [ -n "$SHARE" ] || err "не указано хранилище" + c=$(credfile); [ -r "$c" ] || err "не указан логин и пароль хранилища" + out=$(smbx "$c" "cd \"$DIR\"; ls") + printf '%s' "$out" | grep -q "NT_STATUS" && err "хранилище: $(printf '%s' "$out" | grep -o 'NT_STATUS[A-Z_]*' | head -1)" + names=$(printf '%s\n' "$out" | awk '{print $1}' | grep -E '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{4}$' | sort -r | head -200) + echo "{\"ok\":true,\"list\":$(python3 -c 'import json,sys;print(json.dumps([l for l in sys.stdin.read().split() if l]))' <<< "$names")}" ;; + verify) + name=$(j name) + [[ "$name" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{4}$ ]] || err "не выбрана копия" + [ -n "$SHARE" ] || err "не указано хранилище" + c=$(credfile); [ -r "$c" ] || err "не указан логин и пароль хранилища" + W=$(mktemp -d /var/tmp/bj-verify.XXXXXX); trap 'rm -rf "$W"; [ -n "$TMPCRED" ] && rm -f "$TMPCRED"' EXIT + out=$(smbclient "$SHARE" -A "$c" -m SMB3 -c "cd \"$DIR/$name\"; lcd $W; prompt; mget *" 2>&1) + printf '%s' "$out" | grep -q "NT_STATUS" && err "не удалось скачать копию $name: $(printf '%s' "$out" | grep -o 'NT_STATUS[A-Z_]*' | head -1)" + [ -s "$W/SHA256SUMS" ] || err "в копии $name нет SHA256SUMS — копия неполная" + (cd "$W" && sha256sum -c SHA256SUMS >/dev/null 2>&1) || err "контрольные суммы копии $name не сходятся — копия повреждена" + [ -s "$W/bj.pgdump" ] && [ -s "$W/bj-data.tar.gz" ] || err "в копии $name нет базы или данных bj" + pg_restore -l < "$W/bj.pgdump" >/dev/null 2>&1 || err "база bj в копии $name не читается" + tar -tzf "$W/bj-data.tar.gz" >/dev/null 2>&1 || err "архив данных bj в копии $name не читается" + ok "копия $name цела: $(cd "$W" && ls | grep -vc SHA256SUMS) файлов, $(du -sh "$W" | cut -f1), контрольные суммы сходятся, база и архивы читаются" ;; + restore) + name=$(j name) + [[ "$name" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{4}$ ]] || err "не выбрана копия" + [ -n "$SHARE" ] || err "не указано хранилище" + c=$(credfile); [ -r "$c" ] || err "не указан логин и пароль хранилища" + rc=/run/bj-restore.cred; cp "$c" "$rc"; chmod 600 "$rc" + printf 'SHARE=%q\nDIR=%q\nNAME=%q\n' "$SHARE" "$DIR" "$name" > /run/bj-restore.conf; chmod 600 /run/bj-restore.conf + echo '{"stage":"start","msg":"восстановление запущено"}' > "$RSTATUS"; chown bj:bj "$RSTATUS" 2>/dev/null + systemd-run --unit="bj-restore-$(date +%s)" --collect /usr/local/sbin/bj-backup-admin restore-now >/dev/null 2>&1 || err "не удалось запустить восстановление" + ok "восстановление копии $name запущено: bj перезапустится" ;; + restore-now) + . /run/bj-restore.conf + st() { python3 - "$RSTATUS" "$1" "$2" <<'PY' +import json,sys,datetime +json.dump({"at":datetime.datetime.now().astimezone().isoformat(timespec="seconds"),"stage":sys.argv[2],"msg":sys.argv[3]},open(sys.argv[1],"w"),ensure_ascii=False) +PY + chown bj:bj "$RSTATUS" 2>/dev/null; } + die() { st error "$1"; rm -f /run/bj-restore.cred /run/bj-restore.conf; systemctl start igate bj-server >/dev/null 2>&1 + for u in bj-npr bj-lk; do systemctl is-enabled "$u" >/dev/null 2>&1 && systemctl start "$u" >/dev/null 2>&1; done; exit 1; } + W=$(mktemp -d /var/tmp/bj-restore.XXXXXX) + st download "скачивание копии $NAME" + smbclient "$SHARE" -A /run/bj-restore.cred -m SMB3 -c "cd \"$DIR/$NAME\"; lcd $W; prompt; mget *" >/dev/null 2>&1 || die "не удалось скачать копию $NAME" + (cd "$W" && sha256sum -c SHA256SUMS >/dev/null 2>&1) || die "контрольные суммы копии не сходятся — копия повреждена" + [ -s "$W/bj.pgdump" ] && [ -s "$W/bj-data.tar.gz" ] || die "в копии нет базы или данных bj" + st stop "остановка bj и шлюза НРД" + systemctl stop bj-lk bj-npr bj-server igate >/dev/null 2>&1 + st db "восстановление базы bj" + # каталог копии доступен только root — дамп подаём postgres через stdin; сообщения — по-английски для разбора ошибок + LC_ALL=C PGOPTIONS='-c lc_messages=C' runuser -u postgres -- pg_restore --clean --if-exists -d bj < "$W/bj.pgdump" >/dev/null 2>"$W/pg.err" + # «does not exist» при --clean на пустой базе — не ошибка; остальное — ошибка + if grep -i "error" "$W/pg.err" | grep -vqi "does not exist"; then die "база bj: $(grep -i error "$W/pg.err" | grep -vi 'does not exist' | head -1)"; fi + if [ -s "$W/igate.pgdump" ] && runuser -u postgres -- psql -Atc "select 1 from pg_database where datname='igate'" | grep -q 1; then + st db "восстановление базы шлюза НРД" + LC_ALL=C PGOPTIONS='-c lc_messages=C' runuser -u postgres -- pg_restore --clean --if-exists -d igate < "$W/igate.pgdump" >/dev/null 2>&1 + fi + st files "восстановление данных bj и шлюза НРД" + tar -C / -xzf "$W/bj-data.tar.gz" || die "распаковка данных bj" + [ -s "$W/igate-data.tar.gz" ] && { tar -C / -xzf "$W/igate-data.tar.gz" || die "распаковка данных шлюза НРД"; } + # копия хранилища в восстановленных настройках есть, а пароль в неё не входит — берём тот, с которым + # восстанавливали, и возвращаем расписание копий + if [ -r "$CONF" ]; then + install -m 600 /run/bj-restore.cred "$CRED" + (. "$CONF"; schedule "${EVERY_HOURS:-6}"; [ "${ENABLED:-1}" = 1 ] && systemctl enable bj-backup.timer >/dev/null 2>&1) + fi + rm -f /run/bj-restore.cred + st start "запуск служб" + systemctl start igate >/dev/null 2>&1 + systemctl start bj-server >/dev/null 2>&1 + systemctl is-enabled bj-backup.timer >/dev/null 2>&1 && systemctl start bj-backup.timer >/dev/null 2>&1 + for u in bj-npr bj-lk; do systemctl is-enabled "$u" >/dev/null 2>&1 && systemctl start "$u" >/dev/null 2>&1; done + rm -rf "$W" /run/bj-restore.conf + st done "восстановлено из копии $NAME" ;; + *) err "неизвестная команда: $cmd" ;; +esac diff --git a/deploy/linux/sbin/bj-keymedia-worker b/deploy/linux/sbin/bj-keymedia-worker index 75dc773..d7e63d9 100755 --- a/deploy/linux/sbin/bj-keymedia-worker +++ b/deploy/linux/sbin/bj-keymedia-worker @@ -11,6 +11,8 @@ process_one(){ case "$action" in quikexport-*) out=$(BJ_EXTRA="$extra" /usr/local/sbin/bj-quikexport-admin "${action#quikexport-}" "$staging" 2>&1) ;; component-install) out=$(/usr/local/sbin/bj-component-install "$staging" 2>&1) ;; + # резервное копирование и восстановление (настройки bj → bj-backup-admin); параметры — JSON в extra + backup-config|backup-test|backup-run|backup-list|backup-verify|backup-restore) out=$(BJ_EXTRA="$extra" /usr/local/sbin/bj-backup-admin "${action#backup-}" 2>&1) ;; # Перезапуск службы модуля из админки. Список закрытый: чужую службу # (и сам этот демон) отсюда тронуть нельзя. unit-restart) diff --git a/deploy/systemd/bj-backup.service b/deploy/systemd/bj-backup.service new file mode 100644 index 0000000..dece6b6 --- /dev/null +++ b/deploy/systemd/bj-backup.service @@ -0,0 +1,10 @@ +[Unit] +Description=Bridge&Join: резервная копия на сетевое хранилище +After=network-online.target postgresql.service +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/bj-backup +Nice=10 +IOSchedulingClass=idle diff --git a/deploy/systemd/bj-backup.timer b/deploy/systemd/bj-backup.timer new file mode 100644 index 0000000..f23e16f --- /dev/null +++ b/deploy/systemd/bj-backup.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Bridge&Join: резервная копия по расписанию (расписание — Настройка → Резервное копирование) + +[Timer] +OnCalendar=*-*-* 00/6:00:00 +RandomizedDelaySec=5min +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index 16b7624..fc920d9 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -56,6 +56,8 @@ var templateFuncs = template.FuncMap{ "realNSDAs": realNSDAsText, "quikRecipientsInfo": quikRecipientsInfo, "lkPortfolioStates": lkPortfolioStates, + "backupStatus": backupStatus, + "intList": func(v ...int) []int { return v }, "nsdSegTip": nsdSegTip, "devOn": authTemplateFuncs["devOn"], "devLicensed": authTemplateFuncs["devLicensed"], diff --git a/internal/lkgateway/backup.go b/internal/lkgateway/backup.go new file mode 100644 index 0000000..63e570e --- /dev/null +++ b/internal/lkgateway/backup.go @@ -0,0 +1,179 @@ +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"` // //10.10.10.11/Backup + Dir string `json:"dir,omitempty"` // BJ_Backup/bj + 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/bj") } + +// 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 + } + } + 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 = strings.TrimSpace(r.FormValue("share")) + b.Dir = strings.TrimSpace(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, "Резервное копирование: укажите хранилище, например //10.10.10.11/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)))) + }) +} diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go index c3bf321..e96a836 100644 --- a/internal/lkgateway/runtimeconfig.go +++ b/internal/lkgateway/runtimeconfig.go @@ -39,6 +39,7 @@ type Settings struct { NPR NPRSettings `json:"npr"` QuikExport json.RawMessage `json:"quik_export,omitempty"` // устарело: настройки у службы bj-quikexport Auth AuthSettings `json:"auth"` + Backup BackupSettings `json:"backup,omitempty"` LastTest *TestRunResult `json:"last_test,omitempty"` UpdatedAt time.Time `json:"updated_at"` } @@ -449,6 +450,15 @@ func (r *RuntimeConfig) UpdateCACerts(s CACertsSettings) error { return r.save() } +// UpdateBackup сохраняет настройки резервного копирования. +func (r *RuntimeConfig) UpdateBackup(s BackupSettings) error { + r.mu.Lock() + r.data.Backup = s + r.data.UpdatedAt = time.Now() + r.mu.Unlock() + return r.save() +} + // UpdateNews заменяет всю ленту новостей. func (r *RuntimeConfig) UpdateNews(s NewsSettings) error { r.mu.Lock() diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go index d523a3e..f2dfbd1 100644 --- a/internal/lkgateway/server.go +++ b/internal/lkgateway/server.go @@ -215,6 +215,7 @@ func NewServer(cfg ServerConfig) (*Server, error) { registerManual(mux, rc, svc) // ручной ввод исходящих переводов по бумажным поручениям registerJournal(mux, adminTpl, svc, incomingStore) registerNSDHealth(mux, rc) // связь с НРД: шапка (json) и «Проверить связь сейчас» + registerBackup(mux, rc) // резервное копирование и восстановление (backup.go) registerLKAPI(mux, rc, svc) // API для модуля личного кабинета клиента (bj-lk) registerHealth(mux) registerQuikOps(mux, rc, svc) diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html index fbfc552..629fa42 100644 --- a/internal/lkgateway/web/templates/admin_setup.html +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -17,6 +17,7 @@ + {{if devOn}} {{end}} @@ -988,6 +989,72 @@ {{/* ============ ЛИЦЕНЗИЯ ============ */}} + {{/* ============ РЕЗЕРВНОЕ КОПИРОВАНИЕ ============ */}} +
+

Резервное копирование

+ {{$b := .Settings.Backup}}{{$bs := backupStatus}} +
+

Копии сервера bj на сетевое хранилище

+

Что копируется: база bj (заявки, сделки, кабинет), база и папка шлюза НРД (настройки, сертификаты, полученные от НРД документы), настройки и данные bj. Пароль хранилища bj у себя не хранит — он лежит на сервере в файле, доступном только системе. Восстановить копию можно в мастере настройки (шаг 1, «Восстановить из резервной копии»).

+

{{if $bs}}Последняя копия: {{$bs.At}} — {{if $bs.OK}}✓ {{$bs.Dir}}, {{$bs.Msg}}{{else}}✗ {{$bs.Msg}}{{end}}{{else}}Копий ещё не было.{{end}}

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + + + +
+
+
+ +
+
+

diff --git a/internal/lkgateway/web/templates/admin_wizard.html b/internal/lkgateway/web/templates/admin_wizard.html index 8107e7b..0a92b10 100644 --- a/internal/lkgateway/web/templates/admin_wizard.html +++ b/internal/lkgateway/web/templates/admin_wizard.html @@ -50,6 +50,62 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); } {{/* ============= ШАГ 1: Лицензия ============= */}} {{if eq .Step 1}} +
+
+ Переустановка сервера? Восстановить из резервной копии +

Если у вас есть резервная копия bj на сетевом хранилище (Настройка → Резервное копирование), можно восстановить её вместо настройки с нуля: вернутся настройки, пользователи, лицензия, журнал заявок, база и папка шлюза НРД. Текущие данные этого сервера будут заменены. bj и шлюз НРД на время восстановления остановятся — обычно 1–3 минуты.

+
+
+ + +
+
+ + +
+
+ +
+
+ +
+

Шаг 1. Лицензия

Что это. Ключ продукта (BJ-XXXX-XXXX-XXXX) привязывает этот сервер к вашей лицензии.