Резервное копирование bj на SMB: настройки (хранилище, расписание, хранение), копия сейчас, список и проверка копий; восстановление из копии на шаге 1 мастера; таймер bj-backup, smbclient в установщике
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
@@ -258,7 +258,7 @@ func memTotal() uint64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (in *installer) stepPackages() (string, error) {
|
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")
|
env := append(os.Environ(), "DEBIAN_FRONTEND=noninteractive")
|
||||||
// Образ кладёт пакеты со всеми зависимостями в /var/lib/bj/debs — ставим с диска, без сети.
|
// Образ кладёт пакеты со всеми зависимостями в /var/lib/bj/debs — ставим с диска, без сети.
|
||||||
// Не вышло (другая система, не хватает зависимости) — обычная установка из репозитория.
|
// Не вышло (другая система, не хватает зависимости) — обычная установка из репозитория.
|
||||||
|
|||||||
+1
-1
@@ -6,5 +6,5 @@ import "embed"
|
|||||||
|
|
||||||
// Files — linux/sbin/* (в /usr/local/sbin) и systemd/*.service (в /etc/systemd/system).
|
// 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
|
var Files embed.FS
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ OUT=${2:?выходной каталог}
|
|||||||
[ "$(id -u)" = 0 ] || { echo "нужен root"; exit 1; }
|
[ "$(id -u)" = 0 ] || { echo "нужен root"; exit 1; }
|
||||||
for c in mksquashfs xorriso; do command -v $c >/dev/null || { echo "нужен $c"; exit 1; }; done
|
for c in mksquashfs xorriso; do command -v $c >/dev/null || { echo "нужен $c"; exit 1; }; done
|
||||||
# пакеты — как в bj-installer (stepPackages)
|
# пакеты — как в 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
|
LAYER=ubuntu-server-minimal.ubuntu-server.bj.squashfs
|
||||||
|
|
||||||
mkdir -p "$OUT"
|
mkdir -p "$OUT"
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ d-i partman-md/device_remove_md boolean true
|
|||||||
|
|
||||||
# Пакеты: без графики; SSH и всё, что нужно bj, ставится сразу
|
# Пакеты: без графики; SSH и всё, что нужно bj, ставится сразу
|
||||||
tasksel tasksel/first multiselect standard, ssh-server
|
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
|
d-i pkgsel/upgrade select safe-upgrade
|
||||||
popularity-contest popularity-contest/participate boolean false
|
popularity-contest popularity-contest/participate boolean false
|
||||||
|
|
||||||
|
|||||||
Executable
+63
@@ -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" <<INFO
|
||||||
|
Bridge&Join — резервная копия сервера $(hostname) от $(date '+%d.%m.%Y %H:%M:%S')
|
||||||
|
Версия bj: $(/opt/bj/bj-server -version 2>/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)"
|
||||||
Executable
+154
@@ -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
|
||||||
@@ -11,6 +11,8 @@ process_one(){
|
|||||||
case "$action" in
|
case "$action" in
|
||||||
quikexport-*) out=$(BJ_EXTRA="$extra" /usr/local/sbin/bj-quikexport-admin "${action#quikexport-}" "$staging" 2>&1) ;;
|
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) ;;
|
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)
|
unit-restart)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Bridge&Join: резервная копия по расписанию (расписание — Настройка → Резервное копирование)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 00/6:00:00
|
||||||
|
RandomizedDelaySec=5min
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -56,6 +56,8 @@ var templateFuncs = template.FuncMap{
|
|||||||
"realNSDAs": realNSDAsText,
|
"realNSDAs": realNSDAsText,
|
||||||
"quikRecipientsInfo": quikRecipientsInfo,
|
"quikRecipientsInfo": quikRecipientsInfo,
|
||||||
"lkPortfolioStates": lkPortfolioStates,
|
"lkPortfolioStates": lkPortfolioStates,
|
||||||
|
"backupStatus": backupStatus,
|
||||||
|
"intList": func(v ...int) []int { return v },
|
||||||
"nsdSegTip": nsdSegTip,
|
"nsdSegTip": nsdSegTip,
|
||||||
"devOn": authTemplateFuncs["devOn"],
|
"devOn": authTemplateFuncs["devOn"],
|
||||||
"devLicensed": authTemplateFuncs["devLicensed"],
|
"devLicensed": authTemplateFuncs["devLicensed"],
|
||||||
|
|||||||
@@ -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))))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ type Settings struct {
|
|||||||
NPR NPRSettings `json:"npr"`
|
NPR NPRSettings `json:"npr"`
|
||||||
QuikExport json.RawMessage `json:"quik_export,omitempty"` // устарело: настройки у службы bj-quikexport
|
QuikExport json.RawMessage `json:"quik_export,omitempty"` // устарело: настройки у службы bj-quikexport
|
||||||
Auth AuthSettings `json:"auth"`
|
Auth AuthSettings `json:"auth"`
|
||||||
|
Backup BackupSettings `json:"backup,omitempty"`
|
||||||
LastTest *TestRunResult `json:"last_test,omitempty"`
|
LastTest *TestRunResult `json:"last_test,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -449,6 +450,15 @@ func (r *RuntimeConfig) UpdateCACerts(s CACertsSettings) error {
|
|||||||
return r.save()
|
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 заменяет всю ленту новостей.
|
// UpdateNews заменяет всю ленту новостей.
|
||||||
func (r *RuntimeConfig) UpdateNews(s NewsSettings) error {
|
func (r *RuntimeConfig) UpdateNews(s NewsSettings) error {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ func NewServer(cfg ServerConfig) (*Server, error) {
|
|||||||
registerManual(mux, rc, svc) // ручной ввод исходящих переводов по бумажным поручениям
|
registerManual(mux, rc, svc) // ручной ввод исходящих переводов по бумажным поручениям
|
||||||
registerJournal(mux, adminTpl, svc, incomingStore)
|
registerJournal(mux, adminTpl, svc, incomingStore)
|
||||||
registerNSDHealth(mux, rc) // связь с НРД: шапка (json) и «Проверить связь сейчас»
|
registerNSDHealth(mux, rc) // связь с НРД: шапка (json) и «Проверить связь сейчас»
|
||||||
|
registerBackup(mux, rc) // резервное копирование и восстановление (backup.go)
|
||||||
registerLKAPI(mux, rc, svc) // API для модуля личного кабинета клиента (bj-lk)
|
registerLKAPI(mux, rc, svc) // API для модуля личного кабинета клиента (bj-lk)
|
||||||
registerHealth(mux)
|
registerHealth(mux)
|
||||||
registerQuikOps(mux, rc, svc)
|
registerQuikOps(mux, rc, svc)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
<button data-sec="tests"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3h6M10 3v6l-5 8.5A2 2 0 0 0 6.7 21h10.6a2 2 0 0 0 1.7-3.5L14 9V3"/><path d="M8 15h8"/></svg></span>Диагностика</button>
|
<button data-sec="tests"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3h6M10 3v6l-5 8.5A2 2 0 0 0 6.7 21h10.6a2 2 0 0 0 1.7-3.5L14 9V3"/><path d="M8 15h8"/></svg></span>Диагностика</button>
|
||||||
<button data-sec="alerts"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg></span>Оповещения<span class="ind {{if or .Settings.Alerts.SMTP.Enabled .Settings.Alerts.Zabbix.Enabled}}ok{{end}}"></span></button>
|
<button data-sec="alerts"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg></span>Оповещения<span class="ind {{if or .Settings.Alerts.SMTP.Enabled .Settings.Alerts.Zabbix.Enabled}}ok{{end}}"></span></button>
|
||||||
<button data-sec="update"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20V8M6 12l6-6 6 6"/><path d="M5 4h14"/></svg></span>Обновления{{if .Settings.Update.Available}}<span class="ind warn"></span>{{end}}</button>
|
<button data-sec="update"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20V8M6 12l6-6 6 6"/><path d="M5 4h14"/></svg></span>Обновления{{if .Settings.Update.Available}}<span class="ind warn"></span>{{end}}</button>
|
||||||
|
<button data-sec="backup"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="6" rx="7" ry="2.5"/><path d="M5 6v6c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5V6"/><path d="M12 17v4M9 19l3 2 3-2"/></svg></span>Резервное копирование{{$bs := backupStatus}}{{if and .Settings.Backup.Enabled $bs}}{{if not $bs.OK}}<span class="ind err"></span>{{end}}{{else if not .Settings.Backup.Enabled}}<span class="ind warn"></span>{{end}}</button>
|
||||||
<button data-sec="license"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="12" r="4"/><path d="M12 12h9M18 12v3M15 12v2"/></svg></span>Лицензия{{if .License.Present}}<span class="ind {{if .License.Valid}}ok{{else}}err{{end}}"></span>{{end}}</button>
|
<button data-sec="license"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="12" r="4"/><path d="M12 12h9M18 12v3M15 12v2"/></svg></span>Лицензия{{if .License.Present}}<span class="ind {{if .License.Valid}}ok{{else}}err{{end}}"></span>{{end}}</button>
|
||||||
<button data-sec="branding"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="8.5" cy="10" r="1.5"/><path d="M21 16l-5-5L5 19"/></svg></span>Оформление{{if .Settings.Branding.LogoDataURI}}<span class="ind ok"></span>{{end}}</button>
|
<button data-sec="branding"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="8.5" cy="10" r="1.5"/><path d="M21 16l-5-5L5 19"/></svg></span>Оформление{{if .Settings.Branding.LogoDataURI}}<span class="ind ok"></span>{{end}}</button>
|
||||||
{{if devOn}} <button data-sec="advanced"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg></span>Перспективные{{if .AdvancedUnlocked}}<span class="ind ok"></span>{{end}}</button>{{end}}
|
{{if devOn}} <button data-sec="advanced"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg></span>Перспективные{{if .AdvancedUnlocked}}<span class="ind ok"></span>{{end}}</button>{{end}}
|
||||||
@@ -988,6 +989,72 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{{/* ============ ЛИЦЕНЗИЯ ============ */}}
|
{{/* ============ ЛИЦЕНЗИЯ ============ */}}
|
||||||
|
{{/* ============ РЕЗЕРВНОЕ КОПИРОВАНИЕ ============ */}}
|
||||||
|
<section class="settings-section" id="sec-backup">
|
||||||
|
<h1>Резервное копирование</h1>
|
||||||
|
{{$b := .Settings.Backup}}{{$bs := backupStatus}}
|
||||||
|
<div class="card">
|
||||||
|
<h2><span class="dot {{if not $b.Enabled}}warn{{else if and $bs $bs.OK}}ok{{else}}err{{end}}"></span>Копии сервера bj на сетевое хранилище</h2>
|
||||||
|
<p class="muted">Что копируется: база bj (заявки, сделки, кабинет), база и папка шлюза НРД (настройки, сертификаты, полученные от НРД документы), настройки и данные bj. Пароль хранилища bj у себя не хранит — он лежит на сервере в файле, доступном только системе. Восстановить копию можно в мастере настройки (шаг 1, «Восстановить из резервной копии»).</p>
|
||||||
|
<p style="margin:8px 0">{{if $bs}}Последняя копия: <b>{{$bs.At}}</b> — {{if $bs.OK}}<span style="color:var(--ok)">✓ {{$bs.Dir}}, {{$bs.Msg}}</span>{{else}}<span style="color:var(--err)">✗ {{$bs.Msg}}</span>{{end}}{{else}}<span class="muted">Копий ещё не было.</span>{{end}}</p>
|
||||||
|
<form method="post" action="/admin/setup/backup" id="bk-form" style="display:grid;gap:10px;max-width:640px">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;font-weight:400"><input type="checkbox" name="enabled" style="width:auto"{{if $b.Enabled}} checked{{end}}> <b>Делать копии по расписанию</b></label>
|
||||||
|
<div style="display:grid;grid-template-columns:2fr 1fr;gap:10px">
|
||||||
|
<label>Хранилище (SMB)<input name="share" value="{{$b.Share}}" placeholder="//10.10.10.11/Backup"></label>
|
||||||
|
<label>Папка<input name="dir" value="{{$b.Dir}}" placeholder="BJ_Backup/bj"></label>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
|
||||||
|
<label>Логин хранилища<input name="user" value="{{$b.User}}" autocomplete="off"></label>
|
||||||
|
<label>Пароль {{if $b.PasswordSet}}<span class="muted">(задан; пусто — оставить)</span>{{end}}<input name="password" type="password" autocomplete="new-password"></label>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
|
||||||
|
<label>Как часто<select name="every_hours">{{$e := $b.EveryHours}}{{if not $e}}{{$e = 6}}{{end}}{{range $h := intList 1 2 3 4 6 8 12 24}}<option value="{{$h}}"{{if eq $h $e}} selected{{end}}>{{if eq $h 24}}раз в сутки (02:00){{else}}каждые {{$h}} ч{{end}}</option>{{end}}</select></label>
|
||||||
|
<label>Хранить, дней<input name="keep_days" type="number" min="1" max="365" value="{{if $b.KeepDays}}{{$b.KeepDays}}{{else}}14{{end}}"></label>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
|
<button class="btn" type="submit">Сохранить</button>
|
||||||
|
<button class="btn" type="button" id="bk-test" style="background:var(--card);color:var(--text);border:1px solid var(--border)">Проверить доступ</button>
|
||||||
|
<button class="btn" type="button" id="bk-run" style="background:var(--card);color:var(--text);border:1px solid var(--border)">Сделать копию сейчас</button>
|
||||||
|
<button class="btn" type="button" id="bk-list" style="background:var(--card);color:var(--text);border:1px solid var(--border)">Копии на хранилище</button>
|
||||||
|
</div>
|
||||||
|
<div id="bk-out" class="muted" style="font-size:13px"></div>
|
||||||
|
</form>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var f=document.getElementById('bk-form'), out=document.getElementById('bk-out');
|
||||||
|
function esc(s){return String(s).replace(/[&<>"]/g,function(c){return{'&':'&','<':'<','>':'>','"':'"'}[c]})}
|
||||||
|
function nice(n){ var m=n.match(/^(\d{4})-(\d{2})-(\d{2})_(\d{2})(\d{2})$/); return m? m[3]+'.'+m[2]+'.'+m[1]+' '+m[4]+':'+m[5] : n; }
|
||||||
|
function post(url, withForm){
|
||||||
|
var p=new URLSearchParams();
|
||||||
|
if(withForm){ ['share','dir','user','password'].forEach(function(n){ var v=f[n].value.trim(); if(v) p.set(n,v); }); }
|
||||||
|
return fetch(url,{method:'POST',body:p,cache:'no-store'}).then(function(r){return r.json()});
|
||||||
|
}
|
||||||
|
document.getElementById('bk-test').onclick=function(){ out.textContent='Проверяю доступ…';
|
||||||
|
post('/admin/setup/backup/test',true).then(function(d){ out.innerHTML=d.ok?'<span style="color:var(--ok)">✓ '+esc(d.output)+'</span>':'<span style="color:var(--err)">✗ '+esc(d.error)+'</span>'; }); };
|
||||||
|
document.getElementById('bk-run').onclick=function(){ out.textContent='Запускаю копию…';
|
||||||
|
post('/admin/setup/backup/run',false).then(function(d){ if(!d.ok){out.innerHTML='<span style="color:var(--err)">✗ '+esc(d.error)+'</span>';return}
|
||||||
|
out.textContent='Копия делается (обычно до минуты)…'; var t0=Date.now();
|
||||||
|
var iv=setInterval(function(){ fetch('/admin/setup/backup/status',{cache:'no-store'}).then(function(r){return r.json()}).then(function(s){
|
||||||
|
var b=s.backup; if(b && Date.parse(b.at)>=t0-60000){ clearInterval(iv); out.innerHTML=b.ok?'<span style="color:var(--ok)">✓ копия готова: '+esc(b.dir)+', '+esc(b.msg)+'</span>':'<span style="color:var(--err)">✗ '+esc(b.msg)+'</span>'; }
|
||||||
|
if(Date.now()-t0>600000) clearInterval(iv); }); },3000); }); };
|
||||||
|
document.getElementById('bk-list').onclick=function(){ out.textContent='Читаю список…';
|
||||||
|
post('/admin/setup/backup/list',true).then(function(d){ if(!d.ok){out.innerHTML='<span style="color:var(--err)">✗ '+esc(d.error)+'</span>';return}
|
||||||
|
if(!d.list||!d.list.length){ out.textContent='Копий на хранилище нет.'; return; }
|
||||||
|
out.innerHTML='Копий: <b>'+d.list.length+'</b>. «Проверить» скачивает копию и сверяет контрольные суммы — ничего не восстанавливает.'+
|
||||||
|
'<div style="display:grid;gap:4px;margin-top:6px">'+d.list.slice(0,60).map(function(n){
|
||||||
|
return '<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap"><code>'+esc(nice(n))+'</code><button type="button" class="btn bk-verify" data-name="'+esc(n)+'" style="padding:2px 10px;font-size:12px;background:var(--card);color:var(--text);border:1px solid var(--border)">Проверить</button><span class="bk-vres" style="font-size:12px"></span></div>';
|
||||||
|
}).join('')+'</div>';
|
||||||
|
out.querySelectorAll('.bk-verify').forEach(function(btn){ btn.onclick=function(){
|
||||||
|
var res=btn.nextElementSibling; res.textContent='скачиваю и проверяю…'; btn.disabled=true;
|
||||||
|
var p=new URLSearchParams(); ['share','dir','user','password'].forEach(function(k){ var v=f[k].value.trim(); if(v) p.set(k,v); }); p.set('name',btn.dataset.name);
|
||||||
|
fetch('/admin/setup/backup/verify',{method:'POST',body:p,cache:'no-store'}).then(function(r){return r.json()}).then(function(v){
|
||||||
|
btn.disabled=false; res.innerHTML=v.ok?'<span style="color:var(--ok)">✓ '+esc(v.output)+'</span>':'<span style="color:var(--err)">✗ '+esc(v.error)+'</span>'; });
|
||||||
|
}; }); }); };
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="settings-section" id="sec-license">
|
<section class="settings-section" id="sec-license">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>
|
<h2>
|
||||||
|
|||||||
@@ -50,6 +50,62 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
|
|||||||
|
|
||||||
{{/* ============= ШАГ 1: Лицензия ============= */}}
|
{{/* ============= ШАГ 1: Лицензия ============= */}}
|
||||||
{{if eq .Step 1}}
|
{{if eq .Step 1}}
|
||||||
|
<div class="card" id="wiz-restore" style="border-style:dashed">
|
||||||
|
<details>
|
||||||
|
<summary style="cursor:pointer;font-weight:600">Переустановка сервера? Восстановить из резервной копии</summary>
|
||||||
|
<p class="muted" style="font-size:13px;margin:8px 0">Если у вас есть резервная копия bj на сетевом хранилище (Настройка → Резервное копирование), можно восстановить её вместо настройки с нуля: вернутся настройки, пользователи, лицензия, журнал заявок, база и папка шлюза НРД. Текущие данные этого сервера будут заменены. bj и шлюз НРД на время восстановления остановятся — обычно 1–3 минуты.</p>
|
||||||
|
<form id="rs-form" style="display:grid;gap:8px;max-width:640px" onsubmit="return false">
|
||||||
|
<div style="display:grid;grid-template-columns:2fr 1fr;gap:8px">
|
||||||
|
<label>Хранилище (SMB)<input name="share" placeholder="//10.10.10.11/Backup" value="{{.Settings.Backup.Share}}"></label>
|
||||||
|
<label>Папка<input name="dir" placeholder="BJ_Backup/bj" value="{{.Settings.Backup.Dir}}"></label>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||||||
|
<label>Логин хранилища<input name="user" autocomplete="off" value="{{.Settings.Backup.User}}"></label>
|
||||||
|
<label>Пароль<input name="password" type="password" autocomplete="new-password"></label>
|
||||||
|
</div>
|
||||||
|
<div><button class="btn" type="button" id="rs-list">Показать копии</button></div>
|
||||||
|
<div id="rs-pick" style="display:none;gap:8px;align-items:flex-end;flex-wrap:wrap">
|
||||||
|
<label>Копия<select name="name" id="rs-name" style="min-width:220px"></select></label>
|
||||||
|
<button class="btn" type="button" id="rs-go" style="background:var(--err);border-color:var(--err)">Восстановить</button>
|
||||||
|
</div>
|
||||||
|
<div id="rs-out" class="muted" style="font-size:13px"></div>
|
||||||
|
</form>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var f=document.getElementById('rs-form'), out=document.getElementById('rs-out'), pick=document.getElementById('rs-pick'), sel=document.getElementById('rs-name');
|
||||||
|
function esc(s){return String(s).replace(/[&<>"]/g,function(c){return{'&':'&','<':'<','>':'>','"':'"'}[c]})}
|
||||||
|
function params(){ var p=new URLSearchParams(); ['share','dir','user','password'].forEach(function(n){ var v=f[n].value.trim(); if(v) p.set(n,v); }); return p; }
|
||||||
|
function nice(n){ var m=n.match(/^(\d{4})-(\d{2})-(\d{2})_(\d{2})(\d{2})$/); return m? m[3]+'.'+m[2]+'.'+m[1]+' '+m[4]+':'+m[5] : n; }
|
||||||
|
document.getElementById('rs-list').onclick=function(){
|
||||||
|
out.textContent='Читаю список копий…'; pick.style.display='none';
|
||||||
|
fetch('/admin/setup/backup/list',{method:'POST',body:params(),cache:'no-store'}).then(function(r){return r.json()}).then(function(d){
|
||||||
|
if(!d.ok){ out.innerHTML='<span style="color:var(--err)">✗ '+esc(d.error)+'</span>'; return; }
|
||||||
|
if(!d.list||!d.list.length){ out.textContent='На хранилище копий нет.'; return; }
|
||||||
|
sel.innerHTML=d.list.map(function(n){return '<option value="'+esc(n)+'">'+esc(nice(n))+'</option>'}).join('');
|
||||||
|
pick.style.display='flex'; out.textContent='Найдено копий: '+d.list.length+'. Выберите копию — обычно самую свежую.';
|
||||||
|
}).catch(function(){ out.textContent='Не удалось получить список.'; });
|
||||||
|
};
|
||||||
|
document.getElementById('rs-go').onclick=function(){
|
||||||
|
if(!confirm('Восстановить копию от '+nice(sel.value)+'? Текущие настройки и данные этого сервера будут заменены, bj и шлюз НРД перезапустятся.')) return;
|
||||||
|
var p=params(); p.set('name', sel.value); var t0=Date.now();
|
||||||
|
out.textContent='Запускаю восстановление…';
|
||||||
|
fetch('/admin/setup/backup/restore',{method:'POST',body:p,cache:'no-store'}).then(function(r){return r.json()}).then(function(d){
|
||||||
|
if(!d.ok){ out.innerHTML='<span style="color:var(--err)">✗ '+esc(d.error)+'</span>'; return; }
|
||||||
|
var iv=setInterval(function(){
|
||||||
|
fetch('/admin/setup/backup/status',{cache:'no-store'}).then(function(r){return r.json()}).then(function(s){
|
||||||
|
var r=s.restore; if(!r) return;
|
||||||
|
out.textContent='Восстановление: '+r.msg;
|
||||||
|
if(r.stage==='done'){ clearInterval(iv); out.innerHTML='<span style="color:var(--ok)">✓ '+esc(r.msg)+'. Сейчас откроется вход — войдите учётной записью из восстановленной копии.</span>'; setTimeout(function(){ location.href='/admin/'; }, 4000); }
|
||||||
|
if(r.stage==='error'){ clearInterval(iv); out.innerHTML='<span style="color:var(--err)">✗ '+esc(r.msg)+'</span>'; }
|
||||||
|
}).catch(function(){ out.textContent='bj перезапускается, жду…'; });
|
||||||
|
if(Date.now()-t0>900000) clearInterval(iv);
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2><span class="dot {{if .Done.License}}ok{{else}}err{{end}}"></span>Шаг 1. Лицензия</h2>
|
<h2><span class="dot {{if .Done.License}}ok{{else}}err{{end}}"></span>Шаг 1. Лицензия</h2>
|
||||||
<div class="help-block"><strong>Что это.</strong> Ключ продукта (BJ-XXXX-XXXX-XXXX) привязывает этот сервер к вашей лицензии.<br>
|
<div class="help-block"><strong>Что это.</strong> Ключ продукта (BJ-XXXX-XXXX-XXXX) привязывает этот сервер к вашей лицензии.<br>
|
||||||
|
|||||||
Reference in New Issue
Block a user