diff --git a/cmd/bj-installer/main.go b/cmd/bj-installer/main.go index b9bcea5..52e09c8 100644 --- a/cmd/bj-installer/main.go +++ b/cmd/bj-installer/main.go @@ -232,10 +232,15 @@ func (in *installer) stepPrecheck() (string, error) { } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() + // прокси организации — из окружения (https_proxy / HTTPS_PROXY), как у curl req, _ := http.NewRequestWithContext(ctx, http.MethodGet, in.server+"/healthz", nil) resp, err := http.DefaultClient.Do(req) if err != nil { - return "", fmt.Errorf("нет связи с %s: %v", in.server, err) + if in.bundle != "" { + // установка с образа — всё нужное на диске; ключ продукта и обновления — позже, когда будет связь + return fmt.Sprintf("%s, %.1f ГБ памяти; нет связи с %s — ставим с образа, ключ продукта введёте в браузере, когда появится доступ (можно через прокси: «Настройка → Сервер»)", name, float64(mem)/(1<<30), in.server), nil + } + return "", fmt.Errorf("нет связи с %s: %v (если выход в интернет через прокси — запустите с переменной https_proxy=http://прокси:порт)", in.server, err) } resp.Body.Close() return fmt.Sprintf("%s, %.1f ГБ памяти, связь с сервером лицензий есть", name, float64(mem)/(1<<30)), nil diff --git a/cmd/bj-server/main.go b/cmd/bj-server/main.go index e37cf69..30a4b58 100644 --- a/cmd/bj-server/main.go +++ b/cmd/bj-server/main.go @@ -37,7 +37,9 @@ const serviceName = "bj-server" func main() { addr := getenv("BJ_HTTP_ADDR", ":8080") - defaultSender := m2m.DeponentCode(getenv("BJ_M2M_SENDER", "MC0079200000")) + // Код отправителя — код участника нашей организации из настроек (шаг «Организация»); переменная — только + // для старых установок. Чужого кода по умолчанию нет: без организации переводы не формируются. + defaultSender := m2m.DeponentCode(getenv("BJ_M2M_SENDER", "")) defaultReceiver := m2m.DeponentCode(getenv("BJ_M2M_RECEIVER", "MC0010300000")) setupPath := os.Getenv("BJ_SETUP_PATH") diff --git a/deploy/linux/sbin/bj-backup b/deploy/linux/sbin/bj-backup index d6f0a0e..6bf7e72 100755 --- a/deploy/linux/sbin/bj-backup +++ b/deploy/linux/sbin/bj-backup @@ -33,10 +33,16 @@ runuser -u postgres -- pg_dump -Fc bj > "$W/bj.pgdump" || fail "дамп баз 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" +# Не копируем: пароль хранилища; то, что принадлежит этой машине (отпечаток железа, список установленных +# компонентов — на новом сервере они свои); ключевые носители (флешка ключа монтируется в +# /var/lib/igate/keymedia): ключи СКЗИ не должны уходить на сетевое хранилище — при восстановлении носитель +# подключают заново. --one-file-system — не заходить ни в какие смонтированные носители. +tarok -C / -czf "$W/bj-data.tar.gz" --one-file-system --exclude=etc/bj/backup-smb.cred --exclude=etc/bj/hw-id \ + --exclude=var/lib/bj/.bj/components.json 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 "архив шлюза НРД" + tarok -C / -czf "$W/igate-data.tar.gz" --one-file-system --warning=no-file-changed --exclude='var/lib/igate/.cache' \ + --exclude='var/lib/igate/keymedia/*' --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 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 + if [ -s "$W/igate.pgdump" ]; then st db "восстановление базы шлюза НРД" + # новый сервер: ИШ ещё не ставили — готовим пользователя, роль, базу и доступ без пароля (как ставит + # bj-igate-admin), чтобы данные и каналы ИШ вернулись; сам пакет ИШ потом ставится на шаге «Шлюз НРД» + id igate >/dev/null 2>&1 || useradd --system --home-dir /var/lib/igate --create-home --shell /usr/sbin/nologin igate + runuser -u postgres -- psql -tAc "select 1 from pg_roles where rolname='igate'" | grep -q 1 || runuser -u postgres -- psql -qc "create role igate login" + runuser -u postgres -- psql -lqt | cut -d'|' -f1 | grep -qw igate || runuser -u postgres -- createdb -O igate igate + hba=$(runuser -u postgres -- psql -tAc "show hba_file") + if [ -f "$hba" ] && ! grep -q '^# bj-igate-trust' "$hba"; then + cp -a "$hba" "$hba.bak-bj-igate" + { printf '# bj-igate-trust\nlocal igate igate trust\nhost igate igate 127.0.0.1/32 trust\nhost igate igate ::1/128 trust\n'; cat "$hba.bak-bj-igate"; } > "$hba" + runuser -u postgres -- psql -qtAc "select pg_reload_conf()" >/dev/null + fi 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 "распаковка данных шлюза НРД"; } + # своё у этой машины (отпечаток железа, установленные компоненты) — сохраняем поверх копии; ключевые + # носители из старых копий не раскладываем: ключ СКЗИ — только на своём носителе + mkdir -p "$W/keep"; cp -a /var/lib/bj/.bj/components.json /etc/bj/hw-id "$W/keep/" 2>/dev/null + tar -C / -xzf "$W/bj-data.tar.gz" --exclude=etc/bj/hw-id --exclude=var/lib/bj/.bj/components.json || die "распаковка данных bj" + [ -f "$W/keep/components.json" ] && cp -a "$W/keep/components.json" /var/lib/bj/.bj/components.json + [ -f "$W/keep/hw-id" ] && cp -a "$W/keep/hw-id" /etc/bj/hw-id + # пароль пользователя базы bj — как в восстановленных настройках: установщик нового сервера задал свой + # случайный пароль, и без этого bj не подключился бы к своей базе (ушёл бы в хранение в памяти) + pw=$(python3 - <<'PY' +import json,urllib.parse +try: + d=json.load(open("/var/lib/bj/.bj/setup.json")).get("postgres",{}).get("dsn","") + u=urllib.parse.urlparse(d) + if u.username=="bj" and u.password and u.hostname in ("127.0.0.1","localhost"): print(urllib.parse.unquote(u.password)) +except Exception: pass +PY +) + if [ -n "$pw" ]; then + printf "ALTER ROLE bj WITH PASSWORD '%s';\n" "$(printf '%s' "$pw" | sed "s/'/''/g")" | runuser -u postgres -- psql -q >/dev/null 2>&1 || die "не удалось выставить пароль базы bj из копии" + fi + [ -s "$W/igate-data.tar.gz" ] && { tar -C / -xzf "$W/igate-data.tar.gz" --exclude='var/lib/igate/keymedia/*' || die "распаковка данных шлюза НРД"; } # копия хранилища в восстановленных настройках есть, а пароль в неё не входит — берём тот, с которым # восстанавливали, и возвращаем расписание копий if [ -r "$CONF" ]; then @@ -148,7 +178,12 @@ PY 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 + had_igate=0; [ -s "$W/igate-data.tar.gz" ] && had_igate=1 rm -rf "$W" /run/bj-restore.conf - st done "восстановлено из копии $NAME" ;; + if [ "$had_igate" = 1 ] && [ ! -x /opt/nsd/igate/igate-svc/igate-svc ]; then + st done "восстановлено из копии $NAME. Шлюз НРД на этом сервере не установлен — установите его в мастере (шаг «Шлюз НРД»): настройки и каналы из копии подхватятся; ключ — подключите носитель и установите мастером ключа" + else + st done "восстановлено из копии $NAME" + fi ;; *) err "неизвестная команда: $cmd" ;; esac diff --git a/deploy/linux/sbin/bj-igate-admin b/deploy/linux/sbin/bj-igate-admin index 0658395..15d5edf 100755 --- a/deploy/linux/sbin/bj-igate-admin +++ b/deploy/linux/sbin/bj-igate-admin @@ -114,9 +114,11 @@ PY st deps "системные пакеты для работы ИШ без графики" need="" for p in gnome-keyring dbus xvfb xauth libsecret-tools unzip curl; do dpkg -s "$p" >/dev/null 2>&1 || need="$need $p"; done + px=$(j proxy) # прокси организации (из настроек bj) — для apt и скачивания с nsd.ru + aptpx=(); [ -n "$px" ] && aptpx=(-o "Acquire::http::Proxy=$px" -o "Acquire::https::Proxy=$px") if [ -n "$need" ]; then - DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends $need || \ - { DEBIAN_FRONTEND=noninteractive apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends $need; } || die "не удалось установить пакеты:$need" + DEBIAN_FRONTEND=noninteractive apt-get "${aptpx[@]}" install -y -qq --no-install-recommends $need || \ + { DEBIAN_FRONTEND=noninteractive apt-get "${aptpx[@]}" update -qq && DEBIAN_FRONTEND=noninteractive apt-get "${aptpx[@]}" install -y -qq --no-install-recommends $need; } || die "не удалось установить пакеты:$need" fi st package "дистрибутив ИШ" @@ -125,7 +127,7 @@ PY v=$(printf '%s' "$ver" | tr . -) url="https://www.nsd.ru/media/integratsionnyj-shlyuz/$v/igate-$v-amd64.zip" echo "скачивание $url" - curl -fsSL -m 600 -A 'Mozilla/5.0 (X11; Linux x86_64) bj-installer' -H 'Accept: */*' -o "$W/igate.zip" "$url" || die "не удалось скачать дистрибутив ИШ $ver с сайта НРД ($url)" + curl -fsSL -m 600 ${px:+-x "$px"} -A 'Mozilla/5.0 (X11; Linux x86_64) bj-installer' -H 'Accept: */*' -o "$W/igate.zip" "$url" || die "не удалось скачать дистрибутив ИШ $ver с сайта НРД ($url)" file="$W/igate.zip" fi case "$file" in @@ -200,6 +202,8 @@ StartLimitBurst=8 User=$SVC_USER WorkingDirectory=$HOME_DIR # самоочистка перед стартом: осиротевшие сокеты, keyring, X-локи — иначе перезапуски срываются +# каталог сеанса igate (XDG_RUNTIME_DIR): обычно его создаёт systemd-logind (linger), но без него keyring не стартует +ExecStartPre=+/usr/bin/install -d -o $SVC_USER -g $SVC_USER -m 700 /run/user/$uid ExecStartPre=-/bin/bash -c 'rm -f /tmp/CoreFxPipe_NSD.IGate.* $HOME_DIR/.local/share/keyrings/login.keyring; find /tmp -maxdepth 1 -name ".X9*-lock" -delete 2>/dev/null; true' ExecStart=/usr/bin/dbus-run-session -- $WRAPPER Restart=on-failure @@ -215,28 +219,31 @@ EOF run_as_igate_keyring "timeout 90 $IGATE_DIR/igate-cli/igate-cli --data $HOME_DIR /dev/null 2>&1; true" psqlq -c "select to_regclass('public.parameters') is not null" | grep -q t || die "ИШ не создал схему базы (igate-cli). Журнал: $LOG" fi - # глобальные параметры: Web API на localhost:8090 без авторизации и запуск движка без графики + # Общие параметры ИШ. Обязательные для работы без графики и для bj (движок при старте, Web API на + # localhost:8090 без авторизации) — выставляем всегда; остальные — только если их ещё нет: настройки ИШ, + # который у клиента уже работает (очистка, хранение, прокси), не трогаем. Значения по умолчанию — эталон GUI. psqlq <<'SQL' || die "не записаны параметры ИШ" -DELETE FROM parameters WHERE chanel_id IS NULL AND name IN ('runEngineOnStartApp','server.useServer','server.host','server.port','server.scheme', - 'server.authentication.enable','server.authentication.userName','server.authentication.password','server.certificate.storage', - 'server.certificate.store.location','server.certificate.store.name','server.certificate.file.path','server.certificate.file.password', - 'wsl.httpsMode','wsl.maxConnsPerServer','wsl.proxy.mode','wsl.proxy.address','wsl.proxy.port','wsl.proxy.username','wsl.proxy.password', - 'enableDbLogging','cleanAutomatically','cleanAtTime','cleanWhenLarger','cleanVacuum','storePeriod','archiveAutomatically'); -INSERT INTO parameters(name, value, chanel_id) VALUES - ('runEngineOnStartApp','True',NULL),('server.useServer','True',NULL),('server.host','localhost',NULL),('server.port','8090',NULL), - ('server.scheme','Http',NULL),('server.authentication.enable','False',NULL),('server.authentication.userName','',NULL), - ('server.authentication.password','',NULL),('server.certificate.storage','File',NULL),('server.certificate.store.location','CurrentUser',NULL), - ('server.certificate.store.name','My',NULL),('server.certificate.file.path','',NULL),('server.certificate.file.password','',NULL), - ('wsl.httpsMode','Auto',NULL),('wsl.maxConnsPerServer','4',NULL),('wsl.proxy.mode','None',NULL),('wsl.proxy.address','',NULL), - ('wsl.proxy.port','0',NULL),('wsl.proxy.username','',NULL),('wsl.proxy.password','',NULL),('enableDbLogging','False',NULL), - ('cleanAutomatically','True',NULL),('cleanAtTime','00:30:00',NULL),('cleanWhenLarger','1024',NULL),('cleanVacuum','False',NULL), - ('storePeriod','30',NULL),('archiveAutomatically','False',NULL); +CREATE TEMP TABLE must(n text, v text); +INSERT INTO must VALUES ('runEngineOnStartApp','True'),('server.useServer','True'),('server.host','localhost'), + ('server.port','8090'),('server.scheme','Http'),('server.authentication.enable','False'); +CREATE TEMP TABLE dflt(n text, v text); +INSERT INTO dflt VALUES ('server.authentication.userName',''),('server.authentication.password',''),('server.certificate.storage','File'), + ('server.certificate.store.location','CurrentUser'),('server.certificate.store.name','My'),('server.certificate.file.path',''), + ('server.certificate.file.password',''),('wsl.httpsMode','Auto'),('wsl.maxConnsPerServer','4'),('wsl.proxy.mode','None'), + ('wsl.proxy.address',''),('wsl.proxy.port','0'),('wsl.proxy.username',''),('wsl.proxy.password',''),('enableDbLogging','False'), + ('cleanAutomatically','False'),('cleanAtTime','00:30:00'),('cleanWhenLarger','1024'),('cleanVacuum','False'),('storePeriod','15'), + ('archiveAutomatically','False'); +UPDATE parameters p SET value = m.v FROM must m WHERE p.chanel_id IS NULL AND p.name = m.n; +INSERT INTO parameters(name, value, chanel_id) + SELECT x.n, x.v, NULL FROM (SELECT * FROM must UNION ALL SELECT * FROM dflt) x + WHERE NOT EXISTS (SELECT 1 FROM parameters p WHERE p.chanel_id IS NULL AND p.name = x.n); SQL st start "запуск ИШ" systemctl enable igate >/dev/null 2>&1 systemctl restart igate || die "служба igate не запустилась" wait_engine 120 || die "ИШ запущен, но движок не перешёл в Running за 2 минуты. Журнал ИШ: $DATA_DIR/logs-svc/log.txt" + case "$(j file)" in /var/lib/bj/igate-upload/*) rm -f "$(j file)" ;; esac # загруженный архив больше не нужен st done "ИШ $(igate_version) установлен и работает; дальше — канал контура (TEST3 или боевой)" ;; contour) @@ -247,6 +254,8 @@ SQL [[ "$dep" =~ ^[A-Z]{2}[0-9]{10}$ ]] || err "депозитарный код — вида MC0000000000" [[ -z "$prof" || "$prof" =~ ^[A-Za-z0-9_-]+$ ]] || err "недопустимое имя профиля Валидаты" [ -x "$IGATE_DIR/igate-svc/igate-svc" ] || err "ИШ не установлен" + # ключ этого контура ставили раньше, чем канал: профиль запомнен установкой ключа + if [ -z "$prof" ] && [ -s "$HOME_DIR/.bj-profile-$CODE" ]; then prof=$(tr -cd 'A-Za-z0-9_-' < "$HOME_DIR/.bj-profile-$CODE"); fi DIR=$(exchange_dir "$contour"); install -d -o "$SVC_USER" -g "$SVC_USER" -m 755 "$DIR" psqlq -v code="$CODE$dep" -v name="$NAME" -v ep="$EP" -v fp="$FP" -v typ="$TYPE" -v dep="$dep" -v prof="$prof" -v dir="$DIR" <<'SQL' || err "канал не записан в базу ИШ" BEGIN; diff --git a/deploy/linux/sbin/bj-keymedia-install b/deploy/linux/sbin/bj-keymedia-install index 0c31018..0ef254b 100755 --- a/deploy/linux/sbin/bj-keymedia-install +++ b/deploy/linux/sbin/bj-keymedia-install @@ -199,7 +199,15 @@ open(ini,"w").write("\n".join(L)) PY fi # подключить профиль к ИШ: cryptography.profile в БД igate (канал 33) - if [ "$IGATE_RES" = "ok" ]; then + nchan=1 + if [ "$IGATE_RES" = "ok" ] && [ -n "$CHAN_PREFIX" ]; then + nchan=$(su -s /bin/sh postgres -c "psql -d igate -tAc \"select count(*) from channels where code like '${CHAN_PREFIX}%'\"" 2>/dev/null | tr -d '[:space:]') + fi + if [ "$IGATE_RES" = "ok" ] && [ "${nchan:-0}" = 0 ]; then + # канала этого контура в ИШ ещё нет: профиль запоминаем — bj-igate-admin подключит его при настройке канала + printf '%s\n' "$PROFILE" > "/var/lib/igate/.bj-profile-$CHAN_PREFIX"; chown igate:igate "/var/lib/igate/.bj-profile-$CHAN_PREFIX" 2>/dev/null + IGATE_RES="pending-channel" + elif [ "$IGATE_RES" = "ok" ]; then # только канал контура этого ключа (BJ_CHANNEL); старый bj без контура — все каналы, как раньше. Канал с ключом включаем. if su -s /bin/sh postgres -c "psql -d igate -tAc \"update parameters set value='$PROFILE' where name='cryptography.profile' and $(chan_where); update channels set enable=true where id in (select chanel_id from parameters where name='cryptography.profile' and value='$PROFILE'); update parameters set value='True' where name='enable' and chanel_id in (select chanel_id from parameters where name='cryptography.profile' and value='$PROFILE')\"" >/dev/null 2>&1; then IGATE_RES="connected" diff --git a/deploy/linux/sbin/bj-network b/deploy/linux/sbin/bj-network index 7fe4145..b5dc705 100755 --- a/deploy/linux/sbin/bj-network +++ b/deploy/linux/sbin/bj-network @@ -26,11 +26,14 @@ F=/etc/netplan/60-bj-static.yaml chmod 600 "$F" # прежние файлы с DHCP для этого интерфейса — выключаем, чтобы не было двух адресов for f in /etc/netplan/*.yaml; do [ "$f" = "$F" ] && continue; grep -q "$IF" "$f" 2>/dev/null && mv "$f" "$f.disabled-by-bj"; done +# на Ubuntu с образа сетью управляет cloud-init: без запрета при перезагрузке он может вернуть DHCP +CI=/etc/cloud/cloud.cfg.d/99-bj-disable-network-config.cfg +[ -d /etc/cloud/cloud.cfg.d ] && printf 'network: {config: disabled}\n' > "$CI" echo " Применяю. Если связь пропадёт — через 120 секунд вернутся прежние настройки." if netplan try --timeout 120; then echo " ✓ Адрес ${ADDR%/*} закреплён. Веб-вход: https://${ADDR%/*}:8443/" [ -x /usr/local/sbin/bj-welcome ] && /usr/local/sbin/bj-welcome else for f in /etc/netplan/*.disabled-by-bj; do [ -f "$f" ] && mv "$f" "${f%.disabled-by-bj}"; done - rm -f "$F"; netplan apply; echo " Настройки не приняты — оставлен прежний адрес." + rm -f "$F" "$CI"; netplan apply; echo " Настройки не приняты — оставлен прежний адрес." fi diff --git a/deploy/systemd/bj-server.service b/deploy/systemd/bj-server.service index 71f55b4..b1c9fe9 100644 --- a/deploy/systemd/bj-server.service +++ b/deploy/systemd/bj-server.service @@ -16,8 +16,6 @@ RestartSec=5 # Конфигурация — через ENV или ~/.bj/setup.json (UI /admin/setup). Environment=BJ_HTTP_ADDR=:8080 Environment=BJ_SETUP_PATH=/var/lib/bj/.bj/setup.json -Environment=BJ_M2M_SENDER=MC0079200000 -Environment=BJ_M2M_RECEIVER=MC0010300000 # КриптоПро CSP кладёт .so в /opt/cprocsp/lib/amd64 без записи в # /etc/ld.so.conf.d. Чтобы Go-PKCS#11 клиент (cryptocli) нашёл diff --git a/internal/lkgateway/firstrun.go b/internal/lkgateway/firstrun.go index 619d4e9..b3d78ca 100644 --- a/internal/lkgateway/firstrun.go +++ b/internal/lkgateway/firstrun.go @@ -93,6 +93,11 @@ func registerFirstRunRestore(mux *http.ServeMux) { mux.HandleFunc("/first-run/backup/list", op("list")) mux.HandleFunc("/first-run/backup/restore", op("restore")) mux.HandleFunc("/first-run/backup/status", func(w http.ResponseWriter, r *http.Request) { + // без входа — только пока сервер ждёт первичной настройки или восстановление шло в последние полчаса + if st, err := os.Stat(restoreStatusFile); !firstRunPending() && (err != nil || time.Since(st.ModTime()) > 30*time.Minute) { + http.NotFound(w, r) + return + } out := map[string]any{} if raw, err := os.ReadFile(restoreStatusFile); err == nil { var rs map[string]any diff --git a/internal/lkgateway/igate.go b/internal/lkgateway/igate.go index d926639..699f492 100644 --- a/internal/lkgateway/igate.go +++ b/internal/lkgateway/igate.go @@ -1,6 +1,6 @@ package lkgateway -// Интеграционный шлюз НРД (ИШ, служба igate) на этом сервере: состояние для мастера (шаг 8). +// Интеграционный шлюз НРД (ИШ, служба igate) на этом сервере: состояние для мастера (шаг 7). import ( "context" @@ -112,7 +112,10 @@ func registerIgateAdmin(mux *http.ServeMux, rc *RuntimeConfig) { return } s := rc.Snapshot() - res["active_contour"] = contourOf(s.NSD.Profile) + res["active_contour"] = "" + if s.NSD.Profile != "" { + res["active_contour"] = contourOf(s.NSD.Profile) + } res["active_profile"] = s.NSD.Profile res["deponent"] = pickStr(s.NSD.DeponentCode, orgCode(s)) res["depo_db"] = s.Fancy.DepoDB() @@ -153,6 +156,9 @@ func registerIgateAdmin(mux *http.ServeMux, rc *RuntimeConfig) { jsonOut(w, map[string]any{"ok": false, "error": "загрузите архив ИШ или укажите версию вида 107.0.930"}) return } + if u := rc.Snapshot().Proxy.url(); u != nil { + params["proxy"] = u.String() // скачивание с nsd.ru и пакеты — через прокси организации + } extra, _ := json.Marshal(params) ctx, cancel := context.WithTimeout(r.Context(), time.Minute) defer cancel() @@ -190,7 +196,21 @@ func registerIgateAdmin(mux *http.ServeMux, rc *RuntimeConfig) { extra, _ := json.Marshal(map[string]string{"contour": contour, "crypto": crypto, "deponent": dep}) ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) defer cancel() - jsonOut(w, opResult(runKeymediaOp(ctx, "igate-contour", map[string]string{"extra": string(extra)}))) + res, err := runKeymediaOp(ctx, "igate-contour", map[string]string{"extra": string(extra)}) + if err == nil && res["ok"] == true && rc.Snapshot().NSD.Profile == "" { + // контур в bj ещё не выбран (новая установка) — первый настроенный канал и есть рабочий + n := rc.Snapshot().NSD + n.Profile, n.IGWBaseURL = contour+"-"+crypto, pickStr(n.IGWBaseURL, igateDefaultURL) + if rc.UpdateNSD(n) == nil { + res["output"] = asString(res["output"]) + ". bj работает с этим контуром и перезапускается — обновите страницу через 15 секунд" + go func() { + time.Sleep(2 * time.Second) + _, _ = restartUnit(context.Background(), "bj-server") + }() + } + } + refreshCertStore(rc) + jsonOut(w, opResult(res, err)) }) // переключить bj на контур: профиль отправки, база DEPO контура; bj перезапускается mux.HandleFunc("/admin/setup/igate/switch", func(w http.ResponseWriter, r *http.Request) { @@ -256,6 +276,9 @@ func registerIgateAdmin(mux *http.ServeMux, rc *RuntimeConfig) { n.Profile = contour + "-" + crypto n.IGWBaseURL = pickStr(n.IGWBaseURL, igateDefaultURL) n.SwitchedAt = time.Now() + nc := s.NSDCerts + nc.LastStore, nc.LastRows, nc.LastProfile = nil, nil, "" // справочник другого контура — перечитается после перезапуска + _ = rc.UpdateNSDCerts(nc) if target != "" && target != f.DepoDB() { f.DepoDatabase = target f.DepoTablesCheck, f.DepoTablesCheckOK = "", false // таблицы и обработку в другой базе — проверить заново @@ -278,6 +301,23 @@ func registerIgateAdmin(mux *http.ServeMux, rc *RuntimeConfig) { } }() name := map[string]string{"prod": "боевой", "test3": "тестовый TEST3"}[contour] - jsonOut(w, map[string]any{"ok": true, "output": "bj переключён на " + name + " контур (база DEPO " + rc.Snapshot().Fancy.DepoDB() + ") и перезапускается — обновите страницу через 15 секунд"}) + msg := "bj переключён на " + name + " контур (база DEPO " + rc.Snapshot().Fancy.DepoDB() + ") и перезапускается — обновите страницу через 15 секунд" + if !rc.Snapshot().Fancy.DepoTablesCheckOK { + msg += ". База DEPO сменилась — пройдите шаг 3 (таблицы обмена и обработка в этой базе)" + } + jsonOut(w, map[string]any{"ok": true, "output": msg}) }) } + +// refreshCertStore — перечитать справочник сертификатов ИШ сейчас (после установки ключа, настройки канала, +// смены контура), не дожидаясь плановой сверки раз в 6 часов: от него зависят шаг «Сертификаты» и сроки. +func refreshCertStore(rc *RuntimeConfig) { + go func() { + time.Sleep(20 * time.Second) // ИШ перезапускается после изменений + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + if _, err := nsdCertsCheck(ctx, rc); err != nil { + log.Printf("nsdcerts: перечитать справочник: %v", err) + } + }() +} diff --git a/internal/lkgateway/keywizard.go b/internal/lkgateway/keywizard.go index 32c90b5..383f889 100644 --- a/internal/lkgateway/keywizard.go +++ b/internal/lkgateway/keywizard.go @@ -101,8 +101,41 @@ type flashDrive struct { // keyWizardData — данные шаблона admin_keywizard.html. type keyWizardData struct { page - State *keyWizardState - Drives []flashDrive + State *keyWizardState + Drives []flashDrive + SuggestContour string // контур по сертификату в архиве: test3 (издатель «Тестовый УЦ») | prod | "" (не понятно) + SuggestIssuer string +} + +// keyArchiveContour — контур ключа по сертификатам в распакованном архиве (не самоподписанным — это УЦ): +// издатель «Тестовый УЦ» — TEST3, иначе боевой. ГОСТ-сертификаты разбираются по TBS (parseNSDCert). +func keyArchiveContour(dir string) (contour, issuer string) { + _ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || info.Size() > 64<<10 || contour != "" { + return nil + } + switch strings.ToLower(filepath.Ext(p)) { + case ".cer", ".crt", ".pem": + default: + return nil + } + raw, err := os.ReadFile(p) + if err != nil { + return nil + } + ci, _, err := parseNSDCert(raw) + if err != nil || ci.SelfSigned || ci.IssuerCN == "" { + return nil + } + issuer = ci.IssuerCN + if strings.Contains(strings.ToLower(ci.IssuerCN), "тестов") { + contour = "test3" + } else { + contour = "prod" + } + return nil + }) + return contour, issuer } const keymediaMount = "/var/lib/igate/keymedia" @@ -203,7 +236,11 @@ func (h *setupHandlers) renderKeyWizard(w http.ResponseWriter, r *http.Request) if st.StagingID != "" && !st.Done { drives = listFlashDrives() } - render(w, h.tpl.a.keyWizard, keyWizardData{page: nowPage("Установка ключа", "setup"), State: st, Drives: drives}) + d := keyWizardData{page: nowPage("Установка ключа", "setup"), State: st, Drives: drives} + if st.StagingID != "" && !st.Done { + d.SuggestContour, d.SuggestIssuer = keyArchiveContour(filepath.Join("/var/lib/bj/media/iso", st.StagingID)) + } + render(w, h.tpl.a.keyWizard, d) } func (h *setupHandlers) keyWizardReset(w http.ResponseWriter, r *http.Request) { @@ -384,6 +421,7 @@ func (h *setupHandlers) keyWizardInstall(w http.ResponseWriter, r *http.Request) // Ключ уже на флешке и в ИШ — распаковка staging больше не нужна. _ = os.RemoveAll(staging) + refreshCertStore(h.rc) // шаг «Сертификаты» и сроки — сразу по новому ключу, а не через 6 часов final := "Готово! Ключ на флешке, справочник сформирован, ИШ перезапущен и работает." if dev { @@ -472,6 +510,8 @@ func igateRu(s string) string { switch s { case "connected": return "сформирован, зарегистрирован и подключён к ИШ (cryptography.profile):" + case "pending-channel": + return "сформирован и зарегистрирован; канала этого контура в ИШ ещё нет — ключ подключится, когда вы настроите канал на шаге «Шлюз НРД»:" case "ok": return "сформирован и зарегистрирован (но не выбран в ИШ):" case "skip": diff --git a/internal/lkgateway/lkconnector.go b/internal/lkgateway/lkconnector.go index 118674e..551377b 100644 --- a/internal/lkgateway/lkconnector.go +++ b/internal/lkgateway/lkconnector.go @@ -3,6 +3,7 @@ package lkgateway import ( "context" "encoding/json" + "errors" "fmt" "io" "log" @@ -413,6 +414,10 @@ func pullOneLKClaim(ctx context.Context, rc *RuntimeConfig, svc *Service, ep LKE } req, err := svc.BuildRequest(ctx, claimReq) if err != nil { + if errors.Is(err, errReceiverUnknown) { // справочник есть, а депозитария в нём нет — повтор не поможет + lkRejectInvalid(ctx, rc, ep, id, &lkDataError{Code: "M2M15", Text: "депозитарий получателя не найден в справочнике участников M2M НРД — проверьте ИНН депозитария получателя"}) + return "rejected", nil + } if strings.Contains(err.Error(), "невалиден") { // данные не проходят формат НРД — повтор не поможет lkRejectInvalid(ctx, rc, ep, id, &lkDataError{Text: "данные заявки не прошли проверку формата НРД: " + err.Error()}) return "rejected", nil diff --git a/internal/lkgateway/onboarding_test.go b/internal/lkgateway/onboarding_test.go index caf3ae4..5d21a4b 100644 --- a/internal/lkgateway/onboarding_test.go +++ b/internal/lkgateway/onboarding_test.go @@ -1,6 +1,8 @@ package lkgateway import ( + "context" + "errors" "net/http" "net/http/httptest" "os" @@ -144,3 +146,52 @@ func TestHTTPSSelfSignedAndRedirect(t *testing.T) { } } } + +func TestReceiverUnknownRejected(t *testing.T) { + if !handbookLoaded() { + t.Skip("справочник участников не вшит") + } + svc := &Service{defaultSender: "MC0413600000", defaultReceiver: "MC0010300000"} + if _, err := svc.BuildRequest(context.Background(), CreateClaimRequest{ReceivingDepositoryINN: "0000000001"}); err == nil || !errors.Is(err, errReceiverUnknown) { + t.Fatalf("неизвестный депозитарий получателя должен давать errReceiverUnknown, а не чужой код: %v", err) + } +} + +func TestBuildRequestNeedsOrganization(t *testing.T) { + svc := &Service{} + if _, err := svc.BuildRequest(context.Background(), CreateClaimRequest{}); err == nil || !strings.Contains(err.Error(), "организация не задана") { + t.Fatalf("без кода отправителя перевод не должен формироваться: %v", err) + } +} + +func TestProxyBypass(t *testing.T) { + for host, want := range map[string]bool{"localhost": true, "10.10.10.21": true, "192.168.1.5": true, "127.0.0.1": true, "sql002": true, "bj.local": true, + "lic.zetit.ru": false, "www.nsd.ru": false, "8.8.8.8": false, "corp.example.ru": true, "a.corp.example.ru": true} { + if got := proxyBypass(host, "corp.example.ru"); got != want { + t.Errorf("proxyBypass(%s)=%v, want %v", host, got, want) + } + } + p := ProxySettings{Address: "proxy.example.ru:3128", User: "u", Password: "p@ss"} + if u := p.url(); u == nil || u.String() != "http://u:p%40ss@proxy.example.ru:3128" { + t.Errorf("url: %v", u) + } +} + +func TestKeyArchiveContour(t *testing.T) { + dir := t.TempDir() + raw, err := nsdCertsFS.ReadFile("nsd-certs/nsd-test-gost-petrov-20240924.cer") + if err != nil { + t.Skip(err) + } + _ = os.MkdirAll(filepath.Join(dir, "spr1"), 0o755) + _ = os.WriteFile(filepath.Join(dir, "spr1", "user.cer"), raw, 0o644) + if c, iss := keyArchiveContour(dir); c != "test3" || iss == "" { + t.Fatalf("тестовый сертификат: %q %q", c, iss) + } + raw, _ = nsdCertsFS.ReadFile("nsd-certs/nsd-prod-gost-shlyappo-20260821.cer") + dir2 := t.TempDir() + _ = os.WriteFile(filepath.Join(dir2, "user.cer"), raw, 0o644) + if c, _ := keyArchiveContour(dir2); c != "prod" { + t.Fatalf("боевой сертификат: %q", c) + } +} diff --git a/internal/lkgateway/proxy.go b/internal/lkgateway/proxy.go new file mode 100644 index 0000000..d091b8b --- /dev/null +++ b/internal/lkgateway/proxy.go @@ -0,0 +1,131 @@ +package lkgateway + +// Прокси для выхода в интернет (05.10.2026). Во многих организациях сервер ходит в интернет только через +// прокси: без него не активировать ключ продукта, не получить обновления, не скачать ИШ и документы НРД. +// Прокси из настроек действует на всё, что bj открывает в интернете; адреса локальной сети (шлюз НРД, учёт, +// модули, свой кабинет) — напрямую. Не задан — как раньше (переменные окружения службы). + +import ( + "context" + "fmt" + "net" + "net/http" + "net/url" + "strings" + "time" +) + +// ProxySettings — прокси организации. +type ProxySettings struct { + Address string `json:"address,omitempty"` // http://proxy.corp.local:3128 + User string `json:"user,omitempty"` + Password string `json:"password,omitempty"` + NoProxy string `json:"no_proxy,omitempty"` // ещё адреса напрямую, через запятую: corp.local, 10.20.0.5 +} + +// url — адрес прокси с логином (nil — прокси не задан). +func (p ProxySettings) url() *url.URL { + a := strings.TrimSpace(p.Address) + if a == "" { + return nil + } + if !strings.Contains(a, "://") { + a = "http://" + a + } + u, err := url.Parse(a) + if err != nil || u.Host == "" { + return nil + } + if p.User != "" { + u.User = url.UserPassword(p.User, p.Password) + } + return u +} + +// proxyBypass — адрес локальной сети или из списка «напрямую». +func proxyBypass(host, extra string) bool { + host = strings.ToLower(strings.TrimSpace(host)) + if host == "" || host == "localhost" || !strings.Contains(host, ".") || strings.HasSuffix(host, ".local") { + return true + } + if ip := net.ParseIP(host); ip != nil { + return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() + } + for _, x := range strings.Split(extra, ",") { + x = strings.ToLower(strings.TrimPrefix(strings.TrimSpace(x), ".")) + if x != "" && (host == x || strings.HasSuffix(host, "."+x)) { + return true + } + } + return false +} + +// proxyFunc — прокси из настроек bj (без них — напрямую). +func proxyFunc(rc *RuntimeConfig) func(*http.Request) (*url.URL, error) { + return func(req *http.Request) (*url.URL, error) { + p := rc.Snapshot().Proxy + u := p.url() + if u == nil || proxyBypass(req.URL.Hostname(), p.NoProxy) { + return nil, nil + } + return u, nil + } +} + +// applyProxy — прокси настроек для всех HTTP-запросов bj (сервер лицензий, обновления, сайт НРД). +func applyProxy(rc *RuntimeConfig) { + if t, ok := http.DefaultTransport.(*http.Transport); ok { + env, mine := t.Proxy, proxyFunc(rc) + t.Proxy = func(req *http.Request) (*url.URL, error) { + if rc.Snapshot().Proxy.url() != nil { + return mine(req) + } + if env != nil { + return env(req) + } + return nil, nil + } + } + if t, ok := noProxyClient.Transport.(*http.Transport); ok { + t.Proxy = proxyFunc(rc) // документы НРД: прокси окружения не берём (он может блокировать nsd.ru), прокси bj — да + } +} + +func registerProxy(mux *http.ServeMux, rc *RuntimeConfig) { + mux.HandleFunc("/admin/setup/proxy", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + p := rc.Snapshot().Proxy + p.Address = strings.TrimSpace(r.FormValue("address")) + p.User = strings.TrimSpace(r.FormValue("user")) + if pw := r.FormValue("password"); pw != "" || p.User == "" { + p.Password = pw // пароль в страницу не выводится: пусто — прежний + } + p.NoProxy = strings.TrimSpace(r.FormValue("no_proxy")) + if p.Address != "" && p.url() == nil { + setupFlash(w, r, "Прокси: адрес вида http://proxy.example.ru:3128") + return + } + if err := rc.UpdateProxy(p); err != nil { + setupFlash(w, r, "Прокси: "+err.Error()) + return + } + if p.Address == "" { + setupFlash(w, r, "Прокси выключен: bj выходит в интернет напрямую") + return + } + // проверка: сервер лицензий через прокси + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://lic.zetit.ru/healthz", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + setupFlash(w, r, "Прокси сохранён, но сервер лицензий через него недоступен: "+err.Error()) + return + } + resp.Body.Close() + setupFlash(w, r, fmt.Sprintf("Прокси сохранён: сервер лицензий через него отвечает (HTTP %d)", resp.StatusCode)) + }) +} diff --git a/internal/lkgateway/readiness.go b/internal/lkgateway/readiness.go index 2b786c3..d8d5ca0 100644 --- a/internal/lkgateway/readiness.go +++ b/internal/lkgateway/readiness.go @@ -7,7 +7,9 @@ package lkgateway import ( "context" + "encoding/json" "fmt" + "net" "net/http" "os/exec" "sort" @@ -119,7 +121,7 @@ func readinessChecks(ctx context.Context, rc *RuntimeConfig) []ReadyCheck { return c }) run(func() ReadyCheck { - c := ReadyCheck{Order: 7, Title: "Сертификат ключа организации", Link: "/admin/wizard?step=7", Required: true} + c := ReadyCheck{Order: 8, Title: "Сертификат ключа организации", Link: "/admin/wizard?step=8", Required: true} own := ownValidCerts(storeCertsForUI(s)) if len(own) == 0 { c.State, c.Detail = "err", "в справочнике ИШ нет действующего сертификата организации — установите ключ мастером установки ключа" @@ -138,7 +140,7 @@ func readinessChecks(ctx context.Context, rc *RuntimeConfig) []ReadyCheck { return c }) run(func() ReadyCheck { - c := ReadyCheck{Order: 8, Title: "Шлюз НРД", Link: "/admin/wizard?step=8", Required: true} + c := ReadyCheck{Order: 7, Title: "Шлюз НРД", Link: "/admin/wizard?step=7", Required: true} up, msg := igateState(ctx, s.NSD.IGWBaseURL) switch { case !up: @@ -147,6 +149,12 @@ func readinessChecks(ctx context.Context, rc *RuntimeConfig) []ReadyCheck { c.State, c.Detail = "err", "шлюз отвечает, но контур НРД в bj не выбран" default: c.State, c.Detail = "ok", msg+"; контур "+s.NSD.Profile + nsdWatch.Lock() + okAt := nsdWatch.LastPollOK + nsdWatch.Unlock() + if !okAt.IsZero() { + c.Detail += "; последний успешный обмен с НРД " + okAt.Format("02.01 15:04") + } if down, det := nsdLinkStatus(); down { c.State, c.Detail = "err", det } @@ -221,11 +229,105 @@ func readinessChecks(ctx context.Context, rc *RuntimeConfig) []ReadyCheck { } return c }) + run(func() ReadyCheck { + c := ReadyCheck{Order: 13, Title: "Справочник участников M2M НРД", Link: "/admin/handbook"} + raw, _ := readHandbook() + at := handbookAt(raw) + var hb struct { + Participants []HandbookEntry `json:"participants"` + } + _ = json.Unmarshal(raw, &hb) + switch { + case len(hb.Participants) == 0: + c.State, c.Detail = "err", "справочника нет — получателя перевода не определить" + case !at.IsZero() && time.Since(at) > 14*24*time.Hour: + c.State, c.Detail = "warn", fmt.Sprintf("%d участников, от %s — давно не обновлялся (обновляется по связи с НРД)", len(hb.Participants), at.Format("02.01.2006")) + default: + c.State, c.Detail = "ok", fmt.Sprintf("%d участников, от %s", len(hb.Participants), at.Format("02.01.2006")) + } + return c + }) + run(func() ReadyCheck { return readyNetwork(ctx, s) }) + run(func() ReadyCheck { + c := ReadyCheck{Order: 15, Title: "Учётные записи", Link: "/admin/setup#users"} + var admins, others int + for _, u := range globalUsers.List() { + if !u.Active { + continue + } + if u.Role == "admin" { + admins++ + } else { + others++ + } + } + switch { + case s.Auth.ADEnabled: + c.State, c.Detail = "ok", fmt.Sprintf("вход через Active Directory; локальных: администраторов %d, других %d", admins, others) + case others == 0: + c.State, c.Detail = "warn", fmt.Sprintf("только администратор (%d) — заведите учётные записи операторов: у каждого свой вход, в журнале видно, кто что сделал", admins) + default: + c.State, c.Detail = "ok", fmt.Sprintf("администраторов %d, других пользователей %d", admins, others) + } + return c + }) wg.Wait() sort.Slice(out, func(i, j int) bool { return out[i].Order < out[j].Order }) return out } +// readyNetwork — сетевые доступы, без которых обмен не заработает: контур НРД (ИШ ходит туда сам по ГОСТ-TLS — +// здесь только TCP), справочник сертификатов Мосбиржи (LDAP) и сервер лицензий (ключ, продление, обновления). +func readyNetwork(ctx context.Context, s Settings) ReadyCheck { + c := ReadyCheck{Order: 14, Title: "Сетевые доступы", Link: "/admin/setup#server"} + type target struct{ name, addr string } + var ts []target + switch contourOf(s.NSD.Profile) { + case "prod": + if strings.HasSuffix(s.NSD.Profile, "rsa") { + ts = append(ts, target{"НРД (боевой, RSA)", "edor.nsd.ru:443"}, target{"справочник Мосбиржи (LDAP)", "vcert.pki.moex.com:50003"}) + } else { + ts = append(ts, target{"НРД (боевой, ГОСТ)", "edog.nsd.ru:443"}, target{"справочник Мосбиржи (LDAP)", "vcert.pki.moex.com:50001"}) + } + default: + if strings.HasSuffix(s.NSD.Profile, "rsa") { + ts = append(ts, target{"НРД (TEST3, RSA)", "rsa-t3.nsd.ru:443"}, target{"справочник Мосбиржи (LDAP)", "vcert.pki.moex.com:50007"}) + } else { + ts = append(ts, target{"НРД (TEST3, ГОСТ)", "gost-t3.nsd.ru:443"}, target{"справочник Мосбиржи (LDAP)", "vcert.pki.moex.com:50005"}) + } + } + if strings.TrimSpace(s.Proxy.Address) == "" { // через прокси прямого TCP не будет — сервер лицензий проверяет его работа + ts = append(ts, target{"сервер лицензий", "lic.zetit.ru:443"}) + } + var bad, good []string + var mu sync.Mutex + var wg sync.WaitGroup + for _, t := range ts { + wg.Add(1) + go func(t target) { + defer wg.Done() + d := net.Dialer{Timeout: 4 * time.Second} + conn, err := d.DialContext(ctx, "tcp", t.addr) + mu.Lock() + defer mu.Unlock() + if err != nil { + bad = append(bad, t.name+" ("+t.addr+")") + return + } + conn.Close() + good = append(good, t.name) + }(t) + } + wg.Wait() + sort.Strings(bad) + if len(bad) > 0 { + c.State, c.Detail = "err", "нет доступа: "+strings.Join(bad, ", ")+" — откройте эти адреса на межсетевом экране организации" + return c + } + c.State, c.Detail = "ok", "доступны: "+strings.Join(good, ", ") + return c +} + func readyDepo(ctx context.Context, rc *RuntimeConfig) ReadyCheck { f := rc.Snapshot().Fancy c := ReadyCheck{Order: 3, Title: "Учёт DEPO", Link: "/admin/wizard?step=3&sub=done", Required: true} @@ -273,6 +375,19 @@ func readyDepo(ctx context.Context, rc *RuntimeConfig) ReadyCheck { return c } +// lastReadiness — итог последней проверки готовности: по нему отмечается шаг 10 мастера. +var lastReadiness struct { + sync.Mutex + ready bool + at time.Time +} + +func readinessDone() bool { + lastReadiness.Lock() + defer lastReadiness.Unlock() + return lastReadiness.ready && time.Since(lastReadiness.at) < 24*time.Hour +} + func registerReadiness(mux *http.ServeMux, rc *RuntimeConfig) { mux.HandleFunc("/admin/setup/readiness", func(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) @@ -284,6 +399,9 @@ func registerReadiness(mux *http.ServeMux, rc *RuntimeConfig) { ready = false } } + lastReadiness.Lock() + lastReadiness.ready, lastReadiness.at = ready, time.Now() + lastReadiness.Unlock() jsonOut(w, map[string]any{"checks": checks, "ready": ready, "at": time.Now().Format("02.01.2006 15:04:05")}) }) } diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go index 6fad29b..d2902f8 100644 --- a/internal/lkgateway/runtimeconfig.go +++ b/internal/lkgateway/runtimeconfig.go @@ -42,6 +42,7 @@ type Settings struct { Backup BackupSettings `json:"backup,omitempty"` HTTPS HTTPSSettings `json:"https,omitempty"` Time TimeSettings `json:"time,omitempty"` + Proxy ProxySettings `json:"proxy,omitempty"` LastTest *TestRunResult `json:"last_test,omitempty"` UpdatedAt time.Time `json:"updated_at"` } @@ -480,6 +481,15 @@ func (r *RuntimeConfig) UpdateHTTPS(s HTTPSSettings) error { return r.save() } +// UpdateProxy сохраняет прокси для выхода в интернет. +func (r *RuntimeConfig) UpdateProxy(s ProxySettings) error { + r.mu.Lock() + r.data.Proxy = s + r.data.UpdatedAt = time.Now() + r.mu.Unlock() + return r.save() +} + // UpdateTime сохраняет настройки синхронизации времени. func (r *RuntimeConfig) UpdateTime(s TimeSettings) error { r.mu.Lock() diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go index b8a08a8..349c38f 100644 --- a/internal/lkgateway/server.go +++ b/internal/lkgateway/server.go @@ -214,13 +214,15 @@ func NewServer(cfg ServerConfig) (*Server, error) { registerOutgoing(mux, adminTpl, outDeps) registerManual(mux, rc, svc) // ручной ввод исходящих переводов по бумажным поручениям registerJournal(mux, adminTpl, svc, incomingStore) - registerNSDHealth(mux, rc) // связь с НРД: шапка (json) и «Проверить связь сейчас» - registerBackup(mux, rc) // резервное копирование и восстановление (backup.go) - registerIgate(mux, rc) // интеграционный шлюз НРД: состояние и установка (igate.go) - registerReadiness(mux, rc) // итоговая проверка готовности (мастер, шаг 10) - registerQuikReady(mux, rc) // проверка модуля неторговых поручений QUIK (только чтение) - registerHTTPS(mux, rc) // HTTPS веб-интерфейса: перенаправление, свой сертификат - registerTime(mux, rc) // сервер времени (NTP) + registerNSDHealth(mux, rc) // связь с НРД: шапка (json) и «Проверить связь сейчас» + registerBackup(mux, rc) // резервное копирование и восстановление (backup.go) + registerIgate(mux, rc) // интеграционный шлюз НРД: состояние и установка (igate.go) + registerReadiness(mux, rc) // итоговая проверка готовности (мастер, шаг 10) + registerQuikReady(mux, rc) // проверка модуля неторговых поручений QUIK (только чтение) + registerHTTPS(mux, rc) // HTTPS веб-интерфейса: перенаправление, свой сертификат + registerTime(mux, rc) // сервер времени (NTP) + registerProxy(mux, rc) // прокси для выхода в интернет + applyProxy(rc) registerLKAPI(mux, rc, svc) // API для модуля личного кабинета клиента (bj-lk) registerHealth(mux) registerQuikOps(mux, rc, svc) diff --git a/internal/lkgateway/server_test.go b/internal/lkgateway/server_test.go index d429acf..71bafaf 100644 --- a/internal/lkgateway/server_test.go +++ b/internal/lkgateway/server_test.go @@ -41,7 +41,7 @@ func validBody() string { "document": {"document_type": "21", "series": "4512", "number": "654321"} }, "transferring_depository_inn": "0702345678", - "receiving_depository_inn": "0710987654", + "receiving_depository_inn": "7702015515", "cost_info": {"no": {}}, "securities": [{ "security_code": "MM0766162534", diff --git a/internal/lkgateway/service.go b/internal/lkgateway/service.go index 8e2741b..ea44354 100644 --- a/internal/lkgateway/service.go +++ b/internal/lkgateway/service.go @@ -82,12 +82,19 @@ func (s *Service) CreateClaim(ctx context.Context, in CreateClaimRequest) (Creat // ReferenceID, реквизиты) БЕЗ отправки и без создания заявки. Нужно, чтобы // сначала положить запрос в БД Fancy на проверку, а отправить позже. func (s *Service) BuildRequest(ctx context.Context, in CreateClaimRequest) (*m2m.M2MTransferRequest, error) { + if s.defaultSender == "" { + return nil, errors.New("организация не задана: укажите код участника НРД (мастер настройки, шаг «Организация») — без него перевод не сформировать") + } in = withNSDSecCodes(in) domainClaim, err := dtoToClaim(in) if err != nil { return nil, fmt.Errorf("lkgateway: dtoToClaim: %w", err) } receiver := s.receiverFor(in) + if in.ReceiverCodeOverride == "" && handbookCodeByINN(in.ReceivingDepositoryINN) == "" && handbookLoaded() && !devMode(globalRC) { + // запасной код получателя из настроек — чужой участник: НРД отклонит перевод (M2M15). Лучше сказать сразу. + return nil, fmt.Errorf("%w: ИНН %s", errReceiverUnknown, in.ReceivingDepositoryINN) + } req, err := m2mcore.EnrichRequest(ctx, s.store, domainClaim, m2mcore.SenderReceiver{ SenderCode: s.defaultSender, ReceiverCode: receiver, @@ -98,6 +105,15 @@ func (s *Service) BuildRequest(ctx context.Context, in CreateClaimRequest) (*m2m return req, nil } +// errReceiverUnknown — депозитария получателя нет в справочнике участников M2M НРД. +var errReceiverUnknown = errors.New("депозитарий получателя не найден в справочнике участников M2M НРД") + +// handbookLoaded — справочник участников M2M получен (без него получателя не определить ни для кого). +func handbookLoaded() bool { + raw, err := readHandbook() + return err == nil && len(raw) > 200 +} + // receiverFor — код получателя (зачисляющего депозитария) в заголовке запроса: явный (робот НРД, // тест-кейсы) → по ИНН зачисляющего депозитария из справочника участников M2M → из настроек. // Без справочника заявки ЛК уходили с кодом из настроек, и НРД отвечал M2M15 (29.09.2026). diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index 0b91fe1..3d5115b 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -245,7 +245,7 @@ func (h *setupHandlers) renderWizard(w http.ResponseWriter, r *http.Request) { d.Done.Crypto = d.ValidataVer != "" || (s.Crypto.Provider != "" && s.Crypto.Provider != "stub" && s.Crypto.ModulePath != "") d.Done.Certs = len(ownValidCerts(d.Certs)) > 0 d.Done.NSD = doneNSD(s) - d.Done.TestRun = s.LastTest != nil || (!d.DevMode && d.Done.NSD) // вне режима разработки тестовой заявки нет + d.Done.TestRun = s.LastTest != nil || (!d.DevMode && readinessDone()) // вне режима разработки — итоговая проверка готовности пройдена // Мастер идёт по порядку: каждый шаг показывается, даже если он уже выполнен (установщиком, // лицензией) — на нём написано, что сделано, и пользователь сам нажимает «Далее». Без ?step @@ -332,7 +332,8 @@ func depoServerEdition(ctx context.Context, f FancyMSSQLSettings) (string, bool) } // wizardSteps — шагов в мастере: 1 лицензия, 2 база bj, 3 учёт DEPO, 4 наша организация, 5 QUIK МНП, -// 6 Валидата, 7 сертификаты, 8 шлюз НРД, 9 оповещения и резервные копии, 10 проверка готовности. +// 6 Валидата, 7 шлюз НРД, 8 ключ и сертификаты (ключ ставится в профиль ИШ — после шлюза), +// 9 оповещения и резервные копии, 10 проверка готовности. const wizardSteps = 10 // wizardProgressFile — номер последнего пройденного шага мастера (переход «Далее» на шаг N diff --git a/internal/lkgateway/setupsteps.go b/internal/lkgateway/setupsteps.go index bc9221f..fe859ee 100644 --- a/internal/lkgateway/setupsteps.go +++ b/internal/lkgateway/setupsteps.go @@ -59,8 +59,8 @@ func setupSteps(rc *RuntimeConfig) []SetupStep { } steps = append(steps, SetupStep{6, "Валидата", validata != "" || (s.Crypto.Provider != "" && s.Crypto.Provider != "stub" && s.Crypto.ModulePath != "")}, - SetupStep{7, "Сертификаты", certs > 0}, - SetupStep{8, "Шлюз НРД", doneNSD(s)}, + SetupStep{7, "Шлюз НРД", doneNSD(s)}, + SetupStep{8, "Ключ и сертификаты", certs > 0}, SetupStep{9, "Оповещения и копии", doneAlerts(s)}, ) return steps diff --git a/internal/lkgateway/tlsserve.go b/internal/lkgateway/tlsserve.go index a4d132b..04de118 100644 --- a/internal/lkgateway/tlsserve.go +++ b/internal/lkgateway/tlsserve.go @@ -166,8 +166,46 @@ func httpsRedirect(rc *RuntimeConfig, next http.Handler) http.Handler { }) } +// selfSignedStale — самоподписанный сертификат выписан не на все нынешние адреса сервера (адрес сменили, +// копию восстановили на другой машине): браузер ругался бы ещё и на имя — выпускаем заново. +func selfSignedStale() bool { + raw, err := os.ReadFile(tlsCertPath()) + if err != nil { + return false + } + b, _ := pem.Decode(raw) + if b == nil { + return false + } + c, err := x509.ParseCertificate(b.Bytes) + if err != nil { + return false + } + _, ips := localAddrs() + for _, ip := range ips { + if ip.IsLoopback() || ip.To4() == nil { + continue + } + found := false + for _, have := range c.IPAddresses { + if have.Equal(ip) { + found = true + break + } + } + if !found { + return true + } + } + return false +} + // startHTTPS — слушатель HTTPS рядом с основным HTTP. Ошибка не роняет bj: интерфейс остаётся по HTTP. func startHTTPS(rc *RuntimeConfig, handler http.Handler) *http.Server { + if !rc.Snapshot().HTTPS.Custom && selfSignedStale() { + log.Printf("https: адреса сервера изменились — новый самоподписанный сертификат") + _ = ensureSelfSigned(true) + } if err := ensureSelfSigned(false); err != nil { log.Printf("https: сертификат не создан: %v — HTTPS выключен", err) return nil diff --git a/internal/lkgateway/web/templates/admin_keywizard.html b/internal/lkgateway/web/templates/admin_keywizard.html index 590531b..8c00cc9 100644 --- a/internal/lkgateway/web/templates/admin_keywizard.html +++ b/internal/lkgateway/web/templates/admin_keywizard.html @@ -39,7 +39,7 @@ {{if devOn}}
-
{{else}}→ Проверить подключения{{end}} + {{else}}→ Вернуться в мастер настройки Проверить подключения{{end}}
@@ -76,8 +76,10 @@
- - + {{$c := .SuggestContour}}{{if not $c}}{{$c = currentContour}}{{end}} + {{if .SuggestIssuer}}

В архиве сертификат от «{{.SuggestIssuer}}» — это {{if eq .SuggestContour "test3"}}тестовый{{else}}боевой{{end}} ключ.

{{end}} + + У тестового и боевого контуров разные ключи: ключ подключается только к каналу ИШ своего контура.
diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html index 2472828..b095ff6 100644 --- a/internal/lkgateway/web/templates/admin_setup.html +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -966,6 +966,19 @@ +
+

Прокси для выхода в интернет

+

Если сервер выходит в интернет только через прокси: через него bj обращается к серверу лицензий (ключ продукта, продление, обновления), к сайту НРД (документы, дистрибутив ИШ). Адреса локальной сети — шлюз, учёт, модули — всегда напрямую. Пусто — без прокси.

+
+
+ + + +
+ +
+
+

Сеть

Сервер: {{serverAddrs}}

diff --git a/internal/lkgateway/web/templates/admin_wizard.html b/internal/lkgateway/web/templates/admin_wizard.html index 2c9ff64..e2f02c5 100644 --- a/internal/lkgateway/web/templates/admin_wizard.html +++ b/internal/lkgateway/web/templates/admin_wizard.html @@ -36,8 +36,8 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); } {{if .Done.Org}}✓{{else}}4{{end}}Организация {{if .Done.Quik}}✓{{else}}5{{end}}QUIK МНП {{if .Done.Crypto}}✓{{else}}6{{end}}Валидата - {{if .Done.Certs}}✓{{else}}7{{end}}Сертификаты - {{if .Done.NSD}}✓{{else}}8{{end}}Шлюз НРД + {{if .Done.NSD}}✓{{else}}7{{end}}Шлюз НРД + {{if .Done.Certs}}✓{{else}}8{{end}}Ключ {{if .Done.Alerts}}✓{{else}}9{{end}}Оповещения {{if .Done.TestRun}}✓{{else}}10{{end}}Проверка
@@ -381,10 +381,10 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); } {{if eq .Step 6}}

Шаг 6. СКЗИ «Валидата Клиент L»

-

СКЗИ «Валидата Клиент L» проверяет подписи НРД на квитанциях и сообщениях и нужно для работы с ключами и сертификатами (шаг 7).

+

СКЗИ «Валидата Клиент L» проверяет подписи НРД на квитанциях и сообщениях и нужно для работы с ключами и сертификатами (шаг 8).

{{if .ValidataVer}}

✓ Валидата установлена вместе с bj: версия {{.ValidataVer}}, каталог /opt/Validata/VDCSP. Ничего настраивать на этом шаге не нужно.

-

bj работает с Валидатой через свою службу криптографии bj-crypto{{if eq .CryptoSvc "active"}} — она запущена{{else}} — сейчас она не запущена: служба ставится обновлением с сервера лицензий (шаг 1, кнопка «Обновить сейчас»), а работает с ключом, установленным на шаге 7{{end}}.

+

bj работает с Валидатой через свою службу криптографии bj-crypto{{if eq .CryptoSvc "active"}} — она запущена{{else}} — сейчас она не запущена: служба ставится обновлением с сервера лицензий (шаг 1, кнопка «Обновить сейчас»), а работает с ключом, установленным на шаге 8{{end}}.

{{else}}

Валидата на этом сервере не найдена.

@@ -403,11 +403,44 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
{{end}} -{{/* ============= ШАГ 7: Сертификаты ============= */}} +{{/* ============= ШАГ 7: Шлюз НРД (до ключа: ключ ставится в профиль ИШ) ============= */}} {{if eq .Step 7}}
-

Шаг 7. Сертификаты

+

Шаг 7. Интеграционный шлюз НРД (ИШ)

+
+ Что это. Интеграционный шлюз — программа НРД на этом же сервере: через неё bj отправляет пакеты M2M в НРД и получает ответы. bj работает со шлюзом по его адресу на сервере (обычно http://localhost:8090).
+ К кому обратиться. Дистрибутив ИШ выпускает НРД (nsd.ru/workflow/system/programs). Доступ к контурам — сначала тестовому TEST3, затем боевому — НРД открывает по заявке: обратитесь к своему менеджеру НРД или в поддержку электронного документооборота. +
+ {{template "igateInstall" .}} +
Дополнительно: адрес шлюза (обычно не нужно) +
+ + {{if .Settings.NSD.IGWv904}}{{end}} + + + + + +
+
+
+

Депозитарные реквизиты в НРД: {{if and .Settings.NSD.DeponentCode .Settings.NSD.AccountID .Settings.NSD.SectionID}}код {{.Settings.NSD.DeponentCode}}, счёт {{.Settings.NSD.AccountID}}, раздел {{.Settings.NSD.SectionID}}{{else}}не заданы{{end}} — на шаге 4.

+
+ ← К шагу 6 + {{if .Done.NSD}}К шагу 8: ключ →{{else}}Пропустить ({{if devOn}}mock-режим{{else}}обмен с НРД не настроен{{end}}) →{{end}} +
+
+{{end}} + +{{/* ============= ШАГ 8: Ключ и сертификаты (после шлюза: ключ ставится в профиль ИШ) ============= */}} +{{if eq .Step 8}} +
+

Шаг 8. Ключ и сертификаты

Здесь две части: ключ и сертификат вашей организации (ставите вы) и сертификаты НРД и удостоверяющих центров (bj ведёт их сам).

+ {{if not .Done.NSD}}

Ключ устанавливается в профиль интеграционного шлюза — сначала установите шлюз НРД на шаге 7.

{{end}}

1. Ключ и сертификат организации

Что это. Ключ электронной подписи вашей организации для обмена с НРД и его сертификат.
@@ -423,7 +456,7 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); } Откуда берутся. НРД и Московская биржа публикуют сертификаты в постоянных местах: комплект ГОСТ МБ, НКЦ и НРД — https://fs.moex.com/cdp/sert/GOST.zip, сертификаты УЦ — https://fs.moex.com/cdp/. Когда там появляется новый сертификат, мы добавляем его в комплект bj, и он приезжает к вам с обновлением.
Состояние, ручная проверка и выключение — «Настройка → Криптография → Сертификаты НРД в справочнике ИШ».
- {{if .Settings.NSDCerts.LastRows}}

Последняя сверка: {{.Settings.NSDCerts.LastRun.Format "02.01.2006 15:04"}}{{if .Settings.NSDCerts.LastProfile}}, профиль ИШ {{.Settings.NSDCerts.LastProfile}}{{end}}.

{{else}}

Первая сверка пройдёт, когда будут настроены ключ и шлюз НРД (шаг 8).

{{end}} + {{if .Settings.NSDCerts.LastRows}}

Последняя сверка: {{.Settings.NSDCerts.LastRun.Format "02.01.2006 15:04"}}{{if .Settings.NSDCerts.LastProfile}}, профиль ИШ {{.Settings.NSDCerts.LastProfile}}{{end}}.

{{else}}

Первая сверка пройдёт сразу после установки ключа (шлюз НРД — шаг 7).

{{end}} {{if .Certs}}

Установленные сертификаты ({{len .Certs}})

@@ -445,41 +478,9 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }

Ключ организации ещё не установлен. Можно пропустить этот шаг и вернуться к нему, когда будет архив ключа.

{{end}} -
- ← К шагу 6 - К шагу 8 → -
-
-{{end}} - -{{/* ============= ШАГ 8: Шлюз НРД ============= */}} -{{if eq .Step 8}} -
-

Шаг 8. Интеграционный шлюз НРД (ИШ)

-
- Что это. Интеграционный шлюз — программа НРД на этом же сервере: через неё bj отправляет пакеты M2M в НРД и получает ответы. bj работает со шлюзом по его адресу на сервере (обычно http://localhost:8090).
- К кому обратиться. Дистрибутив ИШ выпускает НРД (nsd.ru/workflow/system/programs). Доступ к контурам — сначала тестовому TEST3, затем боевому — НРД открывает по заявке: обратитесь к своему менеджеру НРД или в поддержку электронного документооборота. -
- {{template "igateInstall" .}} -
Дополнительно: адрес шлюза (обычно не нужно) -
- - {{if .Settings.NSD.IGWv904}}{{end}} - - - - - -
-
-
-

Депозитарные реквизиты в НРД: {{if and .Settings.NSD.DeponentCode .Settings.NSD.AccountID .Settings.NSD.SectionID}}код {{.Settings.NSD.DeponentCode}}, счёт {{.Settings.NSD.AccountID}}, раздел {{.Settings.NSD.SectionID}}{{else}}не заданы{{end}} — на шаге 4.

← К шагу 7 - {{if .Done.NSD}}К шагу 9 →{{else}}Пропустить ({{if devOn}}mock-режим{{else}}обмен с НРД не настроен{{end}}) →{{end}} + К шагу 9 →
{{end}}