diff --git a/deploy/linux/sbin/bj-igate-admin b/deploy/linux/sbin/bj-igate-admin index b45fbed..30d36d9 100755 --- a/deploy/linux/sbin/bj-igate-admin +++ b/deploy/linux/sbin/bj-igate-admin @@ -321,5 +321,47 @@ SQL err "канал $CODE$dep записан, но движок ИШ не перешёл в Running — см. журнал ИШ $DATA_DIR/logs-svc/log.txt (отпечаток УЦ, адрес контура, ключ)" fi ;; + # папки обмена шлюза по сети (SMB): для программ организации, которые кладут/забирают файлы через ИШ. bj это не + # нужно. Блок общих папок в smb.conf — между метками bj; заведённые вручную [igate_exchange] не трогаем. + share) + CONF=/etc/samba/smb.conf; B='# >>> bj: папки шлюза НРД'; E='# <<< bj: папки шлюза НРД' + on=$(j enabled); su=$(j user); sp=$(j password) + manual=false; [ -f "$CONF" ] && sed "/^$B/,/^$E/d" "$CONF" | grep -q '^\[igate_exchange\]' && manual=true + if [ "${on:-}" = status ] || [ -z "${on:-}" ]; then + mine=false; [ -f "$CONF" ] && grep -q "^$B" "$CONF" && mine=true + u=$( [ "$mine" = true ] && sed -n "/^$B/,/^$E/p" "$CONF" | sed -n 's/^ *valid users *= *//p' | head -1 ) + [ "$manual" = true ] && [ -z "$u" ] && u=$(sed -n '/^\[igate_exchange\]/,/^\[/p' "$CONF" | sed -n 's/^ *valid users *= *//p' | head -1) + echo "{\"ok\":true,\"on\":$( [ "$mine" = true ] || [ "$manual" = true ] && echo true || echo false),\"manual\":$manual,\"user\":$(q "$u"),\"smbd\":$(q "$(systemctl is-active smbd 2>/dev/null)")}"; exit 0 + fi + [ "$manual" = true ] && err "папка igate_exchange уже открыта вручную в $CONF — bj её не меняет; чтобы управлять из bj, уберите её из smb.conf" + if [ "$on" != true ]; then + [ -f "$CONF" ] && sed -i "/^$B/,/^$E/d" "$CONF" + systemctl reload smbd >/dev/null 2>&1 || true + ok "доступ к папкам шлюза по сети закрыт"; exit 0 + fi + [[ "$su" =~ ^[a-z][a-z0-9_-]{2,31}$ ]] || err "логин — латинские строчные буквы, цифры, _ и -, от 3 символов, начинается с буквы" + case "$su" in root|igate|bj|postgres) err "логин $su занят системой — выберите другой" ;; esac + if ! command -v smbd >/dev/null 2>&1; then + DEBIAN_FRONTEND=noninteractive apt-get install -y -q samba >/dev/null 2>&1 || err "не удалось установить Samba (apt-get install samba) — нужен доступ к репозиторию" + fi + id "$su" >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin "$su" || err "пользователь $su не создан" + if [ -n "$sp" ]; then + [ ${#sp} -ge 8 ] || err "пароль — не короче 8 символов" + printf '%s\n%s\n' "$sp" "$sp" | smbpasswd -s -a "$su" >/dev/null 2>&1 || err "пароль для $su не задан (smbpasswd)" + smbpasswd -e "$su" >/dev/null 2>&1 + elif ! pdbedit -L 2>/dev/null | grep -q "^$su:"; then + err "задайте пароль для $su" + fi + [ -f "$CONF" ] && sed -i "/^$B/,/^$E/d" "$CONF" + { echo "$B" + for c in test3 prod; do d=$(exchange_dir "$c"); [ -d "$d" ] || continue + n=igate_exchange; [ "$c" = prod ] && n=igate_exchange_prod + printf '[%s]\n comment = Шлюз НРД: папки обмена %s (INBOX/OUTBOX/SENT/ERRORS)\n path = %s\n browseable = yes\n read only = no\n valid users = %s\n force user = %s\n force group = %s\n' "$n" "$c" "$d" "$su" "$SVC_USER" "$SVC_USER" + done + echo "$E"; } >> "$CONF" + testparm -s "$CONF" >/dev/null 2>&1 || { sed -i "/^$B/,/^$E/d" "$CONF"; err "smb.conf после правки не проходит проверку (testparm) — правка отменена"; } + systemctl enable smbd >/dev/null 2>&1; systemctl restart smbd >/dev/null 2>&1 || err "служба smbd не перезапустилась" + ok "папки шлюза открыты по сети для $su: \\\\$(hostname -I | awk '{print $1}')\\igate_exchange" ;; + *) err "неизвестная команда: $cmd" ;; esac diff --git a/deploy/linux/sbin/bj-keymedia-install b/deploy/linux/sbin/bj-keymedia-install index 97cdd3e..2c86695 100755 --- a/deploy/linux/sbin/bj-keymedia-install +++ b/deploy/linux/sbin/bj-keymedia-install @@ -60,6 +60,22 @@ keysvc_restart(){ sleep 1 } +# ---- проверка ключа: подписать проверочный документ профилем ИШ активного контура (как подписывает ИШ) ---- +KEYCHECK="skip"; KEYDIAG="" +keysign(){ # 0 — ключ подписал проверочный документ под igate + local kt ko; kt=$(mktemp -d); echo "bj key check" > "$kt/in"; chown -R igate:igate "$kt" + ko=$(timeout -k 5 60 runuser -u igate -- env HOME=/var/lib/igate LD_LIBRARY_PATH="$VDLIB" "$ZPKI" -sign -profile "$PROFILE" -registry -in "$kt/in" -out "$kt/out.p7s" 2>&1) + if [ -s "$kt/out.p7s" ]; then rm -rf "$kt"; KEYCHECK=ok; return 0; fi + rm -rf "$kt" + KEYCHECK="fail: $(printf '%s' "$ko" | grep -iE 'ошибк|результат|error' | head -1 | tr -d '"\\' | head -c 200)"; return 1 +} +keydiag(){ # что видит ИШ: смонтирована ли ключевая флешка и какие ключи (*.vdk) ему доступны + local m v + m=$(findmnt -rn -o SOURCE,FSTYPE "$DEFAULT_KEYMEDIA" 2>/dev/null | grep -v autofs | head -1) + v=$(runuser -u igate -- env HOME=/var/lib/igate sh -c 'ls -1L "$HOME"/.Validata/vdkeys/*/*.vdk "$HOME"/.Validata/vdkeys/*.vdk 2>/dev/null' | xargs -r -n1 basename | tr '\n' ' ') + KEYDIAG="флешка в $DEFAULT_KEYMEDIA: ${m:-не смонтирована}; ключи, видимые ИШ: ${v:-нет}; служба ключей Валидаты (vdcrysvc): $(pgrep -x vdcrysvc >/dev/null && echo работает || echo 'не запущена')" +} + if [ "$ACTION" = "restart-ish" ]; then systemctl restart igate.service >/dev/null 2>&1 || systemctl restart igate-svc >/dev/null 2>&1 || true echo '{"ok":true,"action":"restart-ish"}'; exit 0 @@ -106,6 +122,14 @@ for p in sys.argv[1:]: print(json.dumps(out,ensure_ascii=False)) PY2 } +if [ "$ACTION" = "keycheck" ]; then + prof_dir; PROFILE="$PROFILE_NAME" + RNG=$([ -x /usr/local/sbin/bj-validata-rng ] && /usr/local/sbin/bj-validata-rng status 2>/dev/null | grep -o '"ready":[a-z]*' | cut -d: -f2) + keysvc_up_quiet(){ pgrep -x vdcrysvc >/dev/null || keysvc_restart; }; keysvc_up_quiet + keysign || keydiag + printf '{"ok":true,"profile":"%s","rng":"%s","keycheck":"%s","keydiag":"%s"}\n' "$PROFILE" "${RNG:-}" "$KEYCHECK" "$(printf '%s' "$KEYDIAG" | tr -d '"\\')" + exit 0 +fi if [ "$ACTION" = "certs-list" ]; then prof_dir echo "{\"ok\":true,\"profile\":\"$PROFILE_NAME\",\"dir\":\"$PROFDIR\",\"certs\":$(certs_list_json)}"; exit 0 @@ -328,20 +352,6 @@ PY fi # ---- проверка: ключ подписывает под igate (как будет подписывать ИШ) — проверочный документ профилем ---- -KEYCHECK="skip"; KEYDIAG="" -keysign(){ # 0 — ключ подписал проверочный документ под igate - local kt ko; kt=$(mktemp -d); echo "bj key check" > "$kt/in"; chown -R igate:igate "$kt" - ko=$(timeout -k 5 60 runuser -u igate -- env HOME=/var/lib/igate LD_LIBRARY_PATH="$VDLIB" "$ZPKI" -sign -profile "$PROFILE" -registry -in "$kt/in" -out "$kt/out.p7s" 2>&1) - if [ -s "$kt/out.p7s" ]; then rm -rf "$kt"; KEYCHECK=ok; return 0; fi - rm -rf "$kt" - KEYCHECK="fail: $(printf '%s' "$ko" | grep -iE 'ошибк|результат|error' | head -1 | tr -d '"\\' | head -c 200)"; return 1 -} -keydiag(){ # что видит ИШ: смонтирована ли ключевая флешка и какие ключи (*.vdk) ему доступны - local m v - m=$(findmnt -rn -o SOURCE,FSTYPE "$DEFAULT_KEYMEDIA" 2>/dev/null | grep -v autofs | head -1) - v=$(runuser -u igate -- env HOME=/var/lib/igate sh -c 'ls -1L "$HOME"/.Validata/vdkeys/*/*.vdk "$HOME"/.Validata/vdkeys/*.vdk 2>/dev/null' | xargs -r -n1 basename | tr '\n' ' ') - KEYDIAG="флешка в $DEFAULT_KEYMEDIA: ${m:-не смонтирована}; ключи, видимые ИШ: ${v:-нет}; служба ключей Валидаты (vdcrysvc): $(pgrep -x vdcrysvc >/dev/null && echo работает || echo 'не запущена')" -} if [ "$IGATE_RES" = connected ] || [ "$IGATE_RES" = pending-channel ]; then if ! keysign; then if [ -n "$TARGET_DEV" ] && [ -b "$TARGET_DEV" ] && [ -z "$(findmnt -rn -o FSTYPE "$DEFAULT_KEYMEDIA" 2>/dev/null | grep -v autofs)" ]; then diff --git a/deploy/linux/sbin/bj-keymedia-worker b/deploy/linux/sbin/bj-keymedia-worker index 626c7f1..337eed1 100755 --- a/deploy/linux/sbin/bj-keymedia-worker +++ b/deploy/linux/sbin/bj-keymedia-worker @@ -12,7 +12,7 @@ process_one(){ 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) ;; # интеграционный шлюз НРД: состояние, установка, канал контура (TEST3 | боевой); параметры — JSON в extra - igate-status|igate-install|igate-contour) out=$(BJ_EXTRA="$extra" /usr/local/sbin/bj-igate-admin "${action#igate-}" 2>&1) ;; + igate-status|igate-install|igate-contour|igate-share) out=$(BJ_EXTRA="$extra" /usr/local/sbin/bj-igate-admin "${action#igate-}" 2>&1) ;; # датчик случайных чисел Валидаты: проверка и сеанс «двигайте мышью» (движения — из мастера bj) rng-status|rng-start|rng-cancel) out=$(/usr/local/sbin/bj-validata-rng "${action#rng-}" 2>&1) ;; # системные настройки сервера: серверы времени (NTP) diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index b707e75..8f0a850 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -62,6 +62,15 @@ var templateFuncs = template.FuncMap{ "smbWin": smbWin, "smbView": smbView, "pgLocal": pgDSNLocal, + // СКЗИ готова: Валидата установлена и ключ организации подключён (как шаги мастера «Валидата» и «Ключ») + "cryptoReady": func() bool { + if globalRC == nil { + return false + } + v, certs := setupProbes(globalRC) + return v != "" && certs > 0 + }, + "withRet": func(d any, ret string) map[string]any { return map[string]any{"D": d, "Ret": ret} }, "whForm": func(w WorkHoursSettings, ret string) map[string]any { return map[string]any{"W": w, "Ret": ret} }, "weekdayNames": func() []string { return weekdayShort }, "depoAccPrefix": depoAccPrefix, diff --git a/internal/lkgateway/adrecheck.go b/internal/lkgateway/adrecheck.go index 95eba71..4466bb7 100644 --- a/internal/lkgateway/adrecheck.go +++ b/internal/lkgateway/adrecheck.go @@ -93,6 +93,11 @@ func adBindService(conn *ldap.Conn, a AuthSettings) error { // errADNotFound или errADDisabled; roleErr — nil или *adNoGroupError. // dropSessions — доступ снят, сессии нужно завершить. func adApplyRecheck(u *User, res *adResult, lookupErr, roleErr error) (changed, dropSessions bool, msg string) { + var noGroup *adNoGroupError + if lookupErr == nil && errors.As(roleErr, &noGroup) && u.BJRole != "" && roleAvailable(u.BJRole) { + // групп с ролью у сотрудника нет, но роль назначена в bj — она и действует + roleErr, res = nil, &adResult{Role: u.BJRole, MatchedBy: "роль, назначенная в bj", Groups: groupsOf(res)} + } if lookupErr != nil || roleErr != nil { reason := "нет в сопоставленных группах" if lookupErr != nil { @@ -225,3 +230,10 @@ func (h *setupHandlers) recheckAD(w http.ResponseWriter, r *http.Request) { } h.usersFlash(w, r, fmt.Sprintf("AD: перепроверено пользователей %d, изменений %d.", st.Checked, st.Changed)) } + +func groupsOf(res *adResult) []string { + if res == nil { + return nil + } + return res.Groups +} diff --git a/internal/lkgateway/adwizard.go b/internal/lkgateway/adwizard.go new file mode 100644 index 0000000..ad89ac5 --- /dev/null +++ b/internal/lkgateway/adwizard.go @@ -0,0 +1,129 @@ +package lkgateway + +// Сотрудники из домена в шаге «Учётные записи» (07.10.2026). Основной путь — создать учётную запись bj сразу +// (логин и пароль); второй, необязательный, — подключить домен Active Directory прямо здесь, проверить связь и +// выбрать сотрудника поиском: он входит своим доменным логином и паролем, bj проверяет пароль в домене. + +import ( + "fmt" + "net/http" + "strings" + "time" + + "github.com/go-ldap/ldap/v3" +) + +func (h *setupHandlers) registerADWizard(mux *http.ServeMux) { + mux.HandleFunc("/admin/setup/users/ad-connect", h.adConnect) + mux.HandleFunc("/admin/setup/users/ad-search", h.adSearch) +} + +// adConnect — POST: адрес контроллера, домен, сервисная учётная запись (для поиска сотрудников); остальные +// настройки входа не трогает. Сразу проверяет связь: контроллер отвечает и сервисная запись входит. +func (h *setupHandlers) adConnect(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + a := h.rc.Snapshot().Auth + a.ADServer = strings.TrimSpace(r.FormValue("ad_server")) + a.ADDomain = strings.TrimSpace(strings.TrimPrefix(r.FormValue("ad_domain"), "@")) + a.ADBindUser = strings.TrimSpace(r.FormValue("ad_bind_user")) + if pw := r.FormValue("ad_bind_password"); pw != "" { + a.ADBindPassword = pw + } + if a.ADServer != "" && !strings.Contains(a.ADServer, "://") { + a.ADServer = "ldaps://" + a.ADServer // как в проводнике вводят имя — по умолчанию защищённое подключение + } + var miss []string + for _, f := range []struct{ v, n string }{{a.ADServer, "контроллер домена"}, {a.ADDomain, "домен"}, {a.ADBindUser, "сервисная учётная запись"}, {a.ADBindPassword, "её пароль"}} { + if f.v == "" { + miss = append(miss, f.n) + } + } + if len(miss) > 0 { + h.usersFlash(w, r, "Домен: не заполнено: "+strings.Join(miss, ", ")) + return + } + probe, err := adProbe(a) + if err != nil { + h.usersFlash(w, r, "Домен: контроллер не отвечает — "+err.Error()+". Настройки не сохранены.") + return + } + conn, err := adDial(a) + if err == nil { + err = adBindService(conn, a) + conn.Close() + } + if err != nil { + h.usersFlash(w, r, "Домен: "+err.Error()+". Настройки не сохранены.") + return + } + a.ADEnabled = true + if err := h.rc.UpdateAuth(a); err != nil { + h.usersFlash(w, r, "Домен: настройки не сохранены — "+err.Error()) + return + } + h.usersFlash(w, r, "Домен подключён ("+probe+"): можно добавлять сотрудников из домена — они входят своим доменным логином и паролем.") +} + +// adUserView — сотрудник домена в результатах поиска. +type adUserView struct { + Login string `json:"login"` // sAMAccountName + Name string `json:"name"` + Email string `json:"email,omitempty"` + Have bool `json:"have,omitempty"` // уже есть в bj +} + +// adSearch — GET ?q=: сотрудники домена по фамилии, логину или почте (до 20, только включённые учётные записи). +func (h *setupHandlers) adSearch(w http.ResponseWriter, r *http.Request) { + a := h.rc.Snapshot().Auth + q := strings.TrimSpace(r.URL.Query().Get("q")) + if !a.ADEnabled || a.ADBindUser == "" { + jsonOut(w, map[string]any{"ok": false, "error": "домен не подключён"}) + return + } + if len([]rune(q)) < 2 { + jsonOut(w, map[string]any{"ok": true, "users": []adUserView{}}) + return + } + users, err := adSearchUsers(a, q, 20) + if err != nil { + jsonOut(w, map[string]any{"ok": false, "error": err.Error()}) + return + } + jsonOut(w, map[string]any{"ok": true, "users": users}) +} + +func adSearchUsers(a AuthSettings, q string, limit int) ([]adUserView, error) { + conn, err := adDial(a) + if err != nil { + return nil, err + } + defer conn.Close() + conn.SetTimeout(10 * time.Second) + if err := adBindService(conn, a); err != nil { + return nil, err + } + base := a.ADBaseDN + if base == "" { + base = adDefaultBase(conn) + } + e := ldap.EscapeFilter(q) + filter := fmt.Sprintf("(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2))(|(sAMAccountName=*%s*)(displayName=*%s*)(sn=*%s*)(mail=*%s*)))", e, e, e, e) + sr, err := conn.Search(ldap.NewSearchRequest(base, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, limit, 10, false, + filter, []string{"sAMAccountName", "displayName", "mail"}, nil)) + if err != nil && (sr == nil || len(sr.Entries) == 0) { // превышение лимита — не ошибка, берём найденное + return nil, fmt.Errorf("поиск в домене: %v", err) + } + var out []adUserView + for _, en := range sr.Entries { + u := adUserView{Login: en.GetAttributeValue("sAMAccountName"), Name: en.GetAttributeValue("displayName"), Email: en.GetAttributeValue("mail")} + if u.Login == "" { + continue + } + u.Have = globalUsers.Get(u.Login) != nil + out = append(out, u) + } + return out, nil +} diff --git a/internal/lkgateway/auth.go b/internal/lkgateway/auth.go index db5f319..3b1cf1f 100644 --- a/internal/lkgateway/auth.go +++ b/internal/lkgateway/auth.go @@ -20,6 +20,7 @@ import ( "crypto/x509" "encoding/hex" "encoding/json" + "errors" "fmt" "html" "html/template" @@ -179,7 +180,10 @@ type User struct { CreatedAt time.Time `json:"created_at"` LastLogin time.Time `json:"last_login,omitempty"` ADGroups []string `json:"ad_groups,omitempty"` - Note string `json:"note,omitempty"` + // BJRole — роль доменного пользователя, назначенная в bj (мастер, «Добавить из домена»): действует, когда + // ни одна группа домена не сопоставлена с ролью; сопоставленные группы — главнее. + BJRole string `json:"bj_role,omitempty"` + Note string `json:"note,omitempty"` } func (u *User) Can(perm string) bool { @@ -785,9 +789,6 @@ func authenticate(rc *RuntimeConfig, login, password string) (*User, error) { return nil, fmt.Errorf("неверный логин или пароль") } res, err := adAuthenticate(a, login, password) - if err != nil { - return nil, err - } key := login if i := strings.Index(key, "@"); i > 0 { key = key[:i] @@ -795,6 +796,15 @@ func authenticate(rc *RuntimeConfig, login, password string) (*User, error) { if i := strings.Index(key, `\`); i > 0 { key = key[i+1:] } + var noGroup *adNoGroupError + if err != nil && res != nil && errors.As(err, &noGroup) { + if pre := globalUsers.Get(key); pre != nil && pre.Source == "ad" && pre.BJRole != "" && roleAvailable(pre.BJRole) { + res.Role, res.MatchedBy, err = pre.BJRole, "роль, назначенная в bj", nil + } + } + if err != nil { + return nil, err + } if u = globalUsers.Get(key); u == nil { u = &User{Login: key, Source: "ad", Active: true, CreatedAt: time.Now(), Role: res.Role} } else if !u.Active { @@ -1280,6 +1290,9 @@ func (h *setupHandlers) saveUser(w http.ResponseWriter, r *http.Request) { if src := r.FormValue("source"); src == "ad" || src == "local" { u.Source = src } + if u.Source == "ad" { + u.BJRole = role // роль из bj — пока ни одна группа домена не сопоставлена + } pw := r.FormValue("password") if u.Source == "local" { if pw != "" { @@ -1445,6 +1458,7 @@ func (h *setupHandlers) testAD(w http.ResponseWriter, r *http.Request) { func registerUsersSetup(mux *http.ServeMux, h *setupHandlers) { mux.HandleFunc("/admin/setup/users", h.saveUser) + h.registerADWizard(mux) mux.HandleFunc("/admin/setup/users/delete", h.deleteUser) mux.HandleFunc("/admin/setup/users/session-drop", h.dropSession) mux.HandleFunc("/admin/setup/auth", h.saveAuth) diff --git a/internal/lkgateway/igate.go b/internal/lkgateway/igate.go index e3da48a..5445a71 100644 --- a/internal/lkgateway/igate.go +++ b/internal/lkgateway/igate.go @@ -14,6 +14,7 @@ import ( "os/exec" "path/filepath" "regexp" + "strconv" "strings" "time" @@ -258,6 +259,17 @@ func registerIgateAdmin(mux *http.ServeMux, rc *RuntimeConfig) { go igateContourRun(rc, contour, crypto, dep) jsonOut(w, map[string]any{"ok": true, "started": true}) }) + // папки обмена шлюза по сети (SMB) — для программ организации; GET — состояние, POST — открыть/закрыть + mux.HandleFunc("/admin/setup/igate/share", func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) // первый раз — установка Samba + defer cancel() + p := map[string]string{"enabled": "status"} + if r.Method == http.MethodPost { + p = map[string]string{"enabled": strconv.FormatBool(r.FormValue("enabled") != ""), "user": strings.TrimSpace(r.FormValue("user")), "password": r.FormValue("password")} + } + extra, _ := json.Marshal(p) + jsonOut(w, opResult(runKeymediaOp(ctx, "igate-share", map[string]string{"extra": string(extra)}))) + }) // ход записи канала: страница показывает его и после перезагрузки mux.HandleFunc("/admin/setup/igate/contour-status", func(w http.ResponseWriter, r *http.Request) { jsonOut(w, igateContourJob.view()) diff --git a/internal/lkgateway/notifyadmin_test.go b/internal/lkgateway/notifyadmin_test.go index 3ff0066..d51384d 100644 --- a/internal/lkgateway/notifyadmin_test.go +++ b/internal/lkgateway/notifyadmin_test.go @@ -206,3 +206,16 @@ func TestDeferForInstaller(t *testing.T) { t.Fatalf("без нового установщика: оставлено %d, отложено %v", len(kept), deferred) } } + +// Роль, назначенная в bj доменному сотруднику (мастер, «Добавить из домена»), действует, когда группы домена +// с ролями не сопоставлены; без неё — доступ снимается, как раньше. +func TestADRecheckBJRole(t *testing.T) { + u := &User{Login: "ivanov", Source: "ad", Role: "operator", BJRole: "operator", Active: true} + if changed, drop, _ := adApplyRecheck(u, &adResult{}, nil, &adNoGroupError{sam: "ivanov"}); drop || u.Role != "operator" || changed { + t.Fatalf("роль из bj должна остаться: changed=%v drop=%v role=%q", changed, drop, u.Role) + } + v := &User{Login: "petrov", Source: "ad", Role: "operator", Active: true} + if _, drop, _ := adApplyRecheck(v, &adResult{}, nil, &adNoGroupError{sam: "petrov"}); !drop || v.Role != "" { + t.Fatalf("без роли из bj доступ снимается: drop=%v role=%q", drop, v.Role) + } +} diff --git a/internal/lkgateway/nsdlinkwatch.go b/internal/lkgateway/nsdlinkwatch.go index 9ff91e6..8e93c61 100644 --- a/internal/lkgateway/nsdlinkwatch.go +++ b/internal/lkgateway/nsdlinkwatch.go @@ -26,7 +26,10 @@ const ( var igateLogFile = "/var/lib/igate/.local/share/NSD Integration Gateway/logs-svc/log.txt" // PID_363248 2026-09-25 15:42:59.1873 T16 DEBUG [WSL] WSL TEST3 GOST: GetPackageListFull (GET) -> #404 (HTTP Error 404: NotFound) -var nsdPollRe = regexp.MustCompile(`^PID_\d+ (\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)\.\d+ .*WSL ([^:]+): GetPackageListFull \(GET\) -> (OK|#\d+[^\r\n]*)`) +// PID_36882 2026-10-07 00:02:13.4942 T16 … — ИШ выравнивает номер процесса пробелами: с коротким номером (после +// перезапуска сервера) пробелов больше одного, и прежний шаблон с одним пробелом не находил ни одного опроса +// («НРД: нет данных опроса», серый индикатор при работающем обмене). +var nsdPollRe = regexp.MustCompile(`^PID_\d+\s+(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)\.\d+ .*WSL ([^:]+): GetPackageListFull \(GET\) -> (OK|#\d+[^\r\n]*)`) type nsdPoll struct { At time.Time diff --git a/internal/lkgateway/nsdlinkwatch_test.go b/internal/lkgateway/nsdlinkwatch_test.go index 2152110..3588066 100644 --- a/internal/lkgateway/nsdlinkwatch_test.go +++ b/internal/lkgateway/nsdlinkwatch_test.go @@ -18,3 +18,13 @@ PID_1 2026-09-25 15:40:00.1 T16 DEBUG [WSL] WSL TEST3 GOST: GetPa t.Fatalf("восстановление: %+v %+v", last, ff) } } + +// ИШ выравнивает номер процесса пробелами: короткий номер — несколько пробелов (07.10.2026, серый индикатор). +func TestNsdLastPollsPaddedPID(t *testing.T) { + log := "PID_36882 2026-10-07 00:02:13.4942 T16 DEBUG [WSL] WSL TEST3 GOST: GetPackageListFull (GET) -> OK (HTTP 200)\n" + + "PID_1050 2026-10-07 00:02:23.4942 T16 DEBUG [WSL] WSL TEST3 GOST: GetPackageListFull (GET) -> OK (HTTP 200)\n" + last, ff := nsdLastPolls([]byte(log)) + if last == nil || !last.OK || ff != nil || last.At.Second() != 23 { + t.Fatalf("опрос с выровненным номером процесса не распознан: %+v", last) + } +} diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index a4510cb..5626e92 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -429,6 +429,35 @@ func (h *setupHandlers) checkCrypto(w http.ResponseWriter, r *http.Request) { return } s := h.rc.Snapshot() + // Подписывает документы для НРД шлюз (ИШ) профилем Валидаты, который создал шаг «Ключ подписи»: проверяем + // именно его — версия СКЗИ, датчик случайных чисел, подпись проверочного документа этим профилем. + if !devMode(h.rc) || igateInstalled() { + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) + defer cancel() + ver, _, _ := validataVersion(ctx) + if ver == "" { + setupFlash(w, r, "СКЗИ «Валидата» на сервере не найдена — её ставит установщик bj") + return + } + res, err := runKeymediaOp(ctx, "keycheck", map[string]string{"channel": contourChannel(contourOf(s.NSD.Profile))}) + if err != nil { + setupFlash(w, r, "СКЗИ «Валидата» "+ver+": ключ не проверен — "+err.Error()) + return + } + prof, _ := res["profile"].(string) + kc, _ := res["keycheck"].(string) + rng := "датчик случайных чисел готов" + if res["rng"] == "false" { + rng = "датчик случайных чисел не инициализирован (шаг «Ключ подписи»)" + } + if kc == "ok" { + setupFlash(w, r, "✓ СКЗИ «Валидата» "+ver+", "+rng+"; ключ организации (профиль ИШ «"+prof+"») подписывает проверочный документ") + return + } + diag, _ := res["keydiag"].(string) + setupFlash(w, r, "✗ СКЗИ «Валидата» "+ver+", "+rng+"; ключ профиля «"+prof+"» не подписывает: "+strings.TrimPrefix(kc, "fail: ")+prefixed(". ", diag)) + return + } cli := cryptocli.New(cryptocli.Config{ Provider: cryptocli.Provider(s.Crypto.Provider), SocketPath: s.Crypto.SocketPath, diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html index 0711554..28b50d5 100644 --- a/internal/lkgateway/web/templates/admin_setup.html +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -4,19 +4,20 @@
{{/* ===== Боковая навигация разделов с индикаторами ===== */}}
-

СКЗИ «Валидата Клиент L»

-

Активный профиль: {{if .Settings.Crypto.Profile}}{{.Settings.Crypto.Profile}}{{else}}—{{end}} · провайдер {{if and (not devOn) (or (eq .Settings.Crypto.Provider "stub") (eq .Settings.Crypto.Provider ""))}}не настроен{{else}}{{.Settings.Crypto.Provider}}{{end}}. {{if internalDocs}}Подробно — справка{{end}}.

+

СКЗИ «Валидата Клиент L»

+

{{if cryptoReady}}✓ СКЗИ установлена, ключ организации подключён — документы для НРД подписывает шлюз НРД.{{else}}⚠ Ключ организации не подключён — мастер настройки, шаг «Ключ подписи».{{end}}

+ {{if devOn}}

Активный профиль: {{if .Settings.Crypto.Profile}}{{.Settings.Crypto.Profile}}{{else}}—{{end}} · провайдер {{if and (not devOn) (or (eq .Settings.Crypto.Provider "stub") (eq .Settings.Crypto.Provider ""))}}не настроен{{else}}{{.Settings.Crypto.Provider}}{{end}}. {{if internalDocs}}Подробно — справка{{end}}.

{{end}}
-
-
+
+ {{if devOn}}
+ {{end}}
Параметры провайдера (для совместимости) @@ -288,7 +291,8 @@

Свои сертификаты можно положить в /var/lib/bj/.bj/nsd-certs/*.cer — они добавляются по тем же правилам и никогда не удаляются автоматически.

- {{/* Сертификаты УЦ */}} + {{/* Сертификаты УЦ (старый способ: теперь сертификаты НРД приходят комплектом с bj) — только для разработки */}} + {{if devOn}}

Сертификаты УЦ (авто-загрузка)

URL .cer-файлов УЦ НРД (nsd.ru). Скачиваются, парсятся и сохраняются в /var/lib/bj/ca-certs/.

@@ -308,6 +312,7 @@ {{end}}
+ {{end}} {{/* ============ НРД ============ */}} diff --git a/internal/lkgateway/web/templates/admin_wizard.html b/internal/lkgateway/web/templates/admin_wizard.html index 0570679..2ed8f0b 100644 --- a/internal/lkgateway/web/templates/admin_wizard.html +++ b/internal/lkgateway/web/templates/admin_wizard.html @@ -520,33 +520,7 @@ {{if .Settings.Quik.Server}}{{template "mnpCheck" .}}{{end}} {{if .Settings.Quik.M2MMissing}}

✗ Подключение есть, но под этим логином не видно объектов M2M: {{.Settings.Quik.M2MMissing}}. Проверьте имя базы МНП и права логина (роль InstrEu) и нажмите «Проверить» ещё раз.

{{end}} {{if .Settings.Quik.Verified}} -
-

Как bj работает с QUIK

-
- -
- - -
-
- -
-

Выбор сохраняется сразу. Тонкая настройка — инструменты МНП, коды клиентов, поручения из терминала — в «Настройка → QUIK».

- -
-

{{quikRecipientsInfo}} После первого заполнения справочника депозитариев-получателей попросите администратора QUIK перезапустить модуль неторговых поручений — QUIK читает справочник только при запуске.

-
- + {{template "quikModeCard" (withRet . "/admin/wizard?step=5")}} {{else}}

Когда подключение проверено, здесь появится выбор, как bj работает с QUIK.

{{end}}{{end}} {{template "wizNav" .}} @@ -774,6 +748,47 @@

Пароль передайте сотруднику лично; сменить его можно в разделе Настройка → Пользователи.

+ {{$A := .Settings.Auth}} +
+ Добавить сотрудника из домена (Active Directory) — необязательно + {{if and $A.ADEnabled $A.ADBindUser}} +

Домен {{$A.ADDomain}} подключён. Найдите сотрудника — он будет входить в bj своим доменным логином и паролем; пароль проверяет домен.

+ +
+ + + + {{end}} +
{{if and $A.ADEnabled $A.ADBindUser}}Другой домен или учётная запись{{else}}Подключить домен{{end}} +

Нужны адрес контроллера домена и учётная запись для поиска сотрудников — подойдёт обычная, без прав администратора домена. bj сразу проверит связь.

+
+ + + + + +
+
+
+
+{{end}}