package lkgateway import ( "encoding/json" "errors" "os" "path/filepath" "strings" "sync" "time" ) // RuntimeConfig — конфигурация подсистем, редактируемая через admin UI // без перезапуска. Сохраняется в JSON-файл (BJ_SETUP_PATH или // ~/.bj/setup.json), грузится при старте. type RuntimeConfig struct { mu sync.RWMutex path string data Settings } // Settings — сериализуемое представление настроек. type Settings struct { Postgres PostgresSettings `json:"postgres"` Fancy FancyMSSQLSettings `json:"fancy_mssql"` Quik QuikMNPSettings `json:"quik_mnp"` M2MAuto M2MAutoSettings `json:"m2m_auto"` Crypto CryptoSettings `json:"crypto"` NSD NSDSettings `json:"nsd"` LK LKSettings `json:"lk"` CACerts CACertsSettings `json:"ca_certs"` NSDCerts NSDCertsSettings `json:"nsd_certs"` News NewsSettings `json:"news"` Alerts AlertSettings `json:"alerts"` Incoming IncomingNotifySettings `json:"incoming_notify"` Update UpdateSettings `json:"update"` License LicenseSettings `json:"license"` Branding BrandingSettings `json:"branding"` NPR NPRSettings `json:"npr"` QuikExport json.RawMessage `json:"quik_export,omitempty"` // устарело: настройки у службы bj-quikexport Auth AuthSettings `json:"auth"` LastTest *TestRunResult `json:"last_test,omitempty"` UpdatedAt time.Time `json:"updated_at"` } // QuikMNPSettings — подключение к БД Модуля неторговых поручений QUIK (MSSQL). // Используется для блокировки бумаг при выводе (M2M). БД боевая — работаем на // чтение + вызовы штатных хранимых процедур API (роль InstrEu). Прямых INSERT/ // UPDATE в таблицы НЕ делаем. type QuikMNPSettings struct { Server string `json:"server"` // host или host\instance Port int `json:"port"` // напр. 8849 (нестандартный) Database string `json:"database"` // имя БД МНП (если требуется) User string `json:"user"` // SQL-логин (роль InstrEu), напр. BROKER_88 Password string `json:"password"` // пароль Encrypt string `json:"encrypt"` // disable | true (по умолчанию true+trust для боевой) Trust bool `json:"trust"` // TrustServerCertificate (самоподписанный серт) Verified bool `json:"verified"` // последнее тест-подключение прошло LastReport string `json:"last_report"` // полный текст последнего теста (для копирования коллегам) LastAt time.Time `json:"last_at"` // время последнего теста // Автоматический цикл M2M в МНП (пишущие операции к боевой БД). WritesEnabled bool `json:"writes_enabled"` // главный предохранитель: без него ни одной записи в МНП Emulate bool `json:"emulate"` // имитация: шаги цикла проходят без записи в МНП (нет пользователя QUIK для блокировок) AutoOut bool `json:"auto_out"` // исходящие: регистрировать «Вывод ЦБ (M2M)» при отправке, подписывать/снимать по ответу AutoIn bool `json:"auto_in"` // входящие: регистрировать «Ввод ЦБ (M2M)» после приёма поручения НРД PollTerminal bool `json:"poll_terminal"` // подхватывать поручения, поданные клиентами из QUIK (Export) Account string `json:"account"` // торговый счёт для регистрации (напр. L01+00000F00) SecOut string `json:"sec_out"` // код МНП-инструмента вывода (пусто = определить по классу INSTROUTM2M) SecIn string `json:"sec_in"` // код МНП-инструмента ввода (пусто = по классу INSTRINM2M) ClientCodeMap map[string]string `json:"client_code_map"` // исключения: номер портфеля DEPO («БР-4-4») → код клиента QUIK } // LicenseSettings — лицензионный ключ (подписанный токен). type LicenseSettings struct { Key string `json:"key"` // компактный токен payload.signature.keyid PublicKey string `json:"public_key,omitempty"` // устарело: ключ проверки зашит в bj, поле не используется // Онлайн-лицензирование (licenseclient.go). InstallID string `json:"install_id,omitempty"` // идентификатор этой установки на сервере лицензий LastSync time.Time `json:"last_sync,omitempty"` // последнее успешное обращение к серверу лицензий LastAttempt time.Time `json:"last_attempt,omitempty"` // последняя попытка LastOK bool `json:"last_ok,omitempty"` LastMsg string `json:"last_msg,omitempty"` } // UpdateSettings — авто-обновления из артефактории (#18/#20). type UpdateSettings struct { BaseURL string `json:"base_url"` // https://updates.example.com Channel string `json:"channel"` // "stable" | "beta" PublicKey string `json:"public_key"` // base64 Ed25519 (если не зашит в бинарь) AutoCheck bool `json:"auto_check"` // проверять автоматически LastCheck time.Time `json:"last_check"` // когда последний раз проверяли LastResult string `json:"last_result"` // текст результата проверки Available string `json:"available_version"` // доступная версия (если новее) Notes string `json:"notes,omitempty"` // заметки доступного релиза // Components — версии компонентов в последнем проверенном релизе (имя → версия). Components map[string]string `json:"components,omitempty"` // ComponentsChannel — канал, в котором эти версии были найдены. Если канал // сменили, данные прошлой проверки к новому каналу не относятся: предлагать // по ним обновление нельзя (кнопка «Обновить» уходила бы в пустоту). ComponentsChannel string `json:"components_channel,omitempty"` } // NewsSettings — лента новостей (события системы, окна техработ, обновления // документации НРД). События добавляются вручную через UI или автоматически // doc-watcher'ом и cron-задачами. Каждое событие может быть скрыто (Dismissed) // оператором, но не удалено — лента служит «журналом» для аудита. type NewsSettings struct { Items []NewsItem `json:"items"` DocSources []DocSource `json:"doc_sources"` // URL'ы для авто-проверки (NSD pages) LastDocCheck time.Time `json:"last_doc_check"` DocCheckResult string `json:"doc_check_result"` } // NewsItem — одно событие в ленте. type NewsItem struct { ID string `json:"id"` // уникальный идентификатор для dismiss At time.Time `json:"at"` Kind string `json:"kind"` // "maintenance" | "feature" | "doc-update" | "manual" | "system" Title string `json:"title"` Body string `json:"body"` URL string `json:"url,omitempty"` // ссылка на источник ValidFrom time.Time `json:"valid_from,omitempty"` // для maintenance окон ValidTo time.Time `json:"valid_to,omitempty"` Dismissed bool `json:"dismissed"` } // DocSource — страница НРД, которую doc-watcher периодически проверяет. type DocSource struct { URL string `json:"url"` Name string `json:"name"` // человекочитаемое имя LastChecked time.Time `json:"last_checked"` KnownPDFs map[string]string `json:"known_pdfs"` // url → sha256 } // CACertsSettings — URL'ы для авто-загрузки сертификатов УЦ НРД и нашего // УЦ. Список редактируется пользователем; раз в сутки фоновая горутина // перекачивает каждый URL и сохраняет сертификат, если он поменялся. type CACertsSettings struct { URLs []string `json:"urls"` AutoUpdate bool `json:"auto_update"` LastFetch time.Time `json:"last_fetch"` LastFetchLog string `json:"last_fetch_log"` FetchedCerts []FetchedCACert `json:"fetched_certs"` } // FetchedCACert — информация о последнем удачно скачанном сертификате. type FetchedCACert struct { URL string `json:"url"` SHA256 string `json:"sha256"` SubjectCN string `json:"subject_cn"` IssuerCN string `json:"issuer_cn"` NotAfter time.Time `json:"not_after"` Store string `json:"store"` FetchedAt time.Time `json:"fetched_at"` Error string `json:"error,omitempty"` } // PostgresSettings — DSN для подключения к БД (M2-шаг-3). type PostgresSettings struct { DSN string `json:"dsn"` } // FancyMSSQLSettings — подключение к MSSQL-серверу клиента: канал обмена с // Fancy (очередь запросов из ЛК + ответы «крана») и журнал обмена с НРД. // Аутентификация — SQL-логин (SQL Server Authentication). type FancyMSSQLSettings struct { DBType string `json:"db_type"` // "mssql" | "postgres" (пусто = mssql) Server string `json:"server"` // host или host\instance Port int `json:"port"` // 1433 (MSSQL) / 5432 (Postgres) User string `json:"user"` // логин Password string `json:"password"` // пароль Encrypt string `json:"encrypt"` // MSSQL: disable|false|true; Postgres: sslmode // Обработка трансферных таблиц DEPO силами bj (вместо джобы SQL Agent bj2depo): // bj сам вызывает процедуру DepoJobProc каждые DepoJobEverySec секунд. DepoJobRun bool `json:"depo_job_run"` DepoJobMode string `json:"depo_job_mode"` // bj | sql-auto | sql-manual (пусто = не выбран) DepoJobCheck string `json:"depo_job_check"` // результат последней проверки готовности DepoJobCheckOK bool `json:"depo_job_check_ok"` DepoJobCheckAt time.Time `json:"depo_job_check_at"` DepoJobEverySec int `json:"depo_job_every_sec"` DepoJobProc string `json:"depo_job_proc"` DepoJobRunAs string `json:"depo_job_run_as,omitempty"` // пользователь базы DEPO для EXECUTE AS процедуры (пусто = SYSDBA) DepoJobLast string `json:"depo_job_last"` // последний результат (для карточки) DepoJobLastAt time.Time `json:"depo_job_last_at"` // Трансферные таблицы FTV_M2M* в базе DEPO (depotables.go). DepoDatabase string `json:"depo_database"` // база DEPO; пусто = BJ_DEPO_DATA SpectreDatabase string `json:"spectre_database,omitempty"` // база SPECTRE (брокерский учёт); пусто = BJ_SPECTRE_DATA IISContractMarker string `json:"iis_contract_marker,omitempty"` // начало названия договора ИИС в DEPO (через запятую); пусто = БРИИС DepoTablesMode string `json:"depo_tables_mode"` // auto (создаёт bj) | script (администратор СУБД) DepoTablesCheck string `json:"depo_tables_check"` DepoTablesCheckOK bool `json:"depo_tables_check_ok"` DepoTablesCheckAt time.Time `json:"depo_tables_check_at"` } // IsPostgres сообщает, выбран ли PostgreSQL (иначе — MSSQL по умолчанию). func (s FancyMSSQLSettings) IsPostgres() bool { return s.DBType == "postgres" } // DefaultPort возвращает порт по типу СУБД, если он не задан явно. func (s FancyMSSQLSettings) DefaultPort() int { if s.Port != 0 { return s.Port } if s.IsPostgres() { return 5432 } return 1433 } // CryptoSettings — путь к PKCS#11 модулю и тип провайдера. type CryptoSettings struct { Provider string `json:"provider"` // "stub" | "validata" SocketPath string `json:"socket_path"` // UDS crypto-service ModulePath string `json:"module_path"` // путь до .so модуля PKCS#11 Profile string `json:"profile"` // активный профиль Валидаты (имя из pki1.conf) } // NSDSettings — профиль и подключение к ИШ НРД. type NSDSettings struct { Profile string `json:"profile"` // "guest-gost", "test3-gost", ... IGWBaseURL string `json:"igw_base_url"` // http://host:port KeyContainer string `json:"key_container"` // имя контейнера (на стороне ИШ) // Депозитарные реквизиты клиента — откуда списываются бумаги // (SettlementLocation в M2MTransferRequest). Из договора/письма НРД. // Наша организация — участник M2M (выбирается из справочника НРД или вводится при настройке). OrgCode string `json:"org_code,omitempty"` // код участника (отправитель пакетов), напр. MC0000000000 OrgINN string `json:"org_inn,omitempty"` // ИНН — передающий/зачисляющий депозитарий в пакетах OrgName string `json:"org_name,omitempty"` DeponentCode string `json:"deponent_code"` // депозитарный код в НРД (место хранения) AccountID string `json:"account_id"` // депозитарный счёт SectionID string `json:"section_id"` // раздел депозитарного счёта // IGWv904 — ИШ обновлён до версии 107.0.0.904+ (Astra 107.0.907+): с этой // версии в config.xml ОБЯЗАТЕЛЬНО указывать businessLine=MOST для M2M; на // версиях младше — НЕ допускается. По умолчанию false (совместимо со старым // ИШ — элемент не добавляется). IGWv904 bool `json:"igw_v904"` } // LKSettings — настройки callback в ЛК клиента. type LKSettings struct { CallbackURL string `json:"callback_url"` // PublicURL — адрес личного кабинета клиента (стенд/бой), куда оператор // заходит подать тестовую заявку. Показывается ссылкой на дашборде. PublicURL string `json:"lk_public_url"` // APIKey — ключ доступа для модуля Личного кабинета клиента (bj-lk). // Модуль ставится на веб-сервер клиента и обращается к bj-server по этому // ключу (заголовок X-BJ-Key). Пустой ключ = модульный API выключен. APIKey string `json:"lk_api_key"` // CoreURL — адрес ядра bj, по которому кабинет к нему обращается (кабинет стоит // на отдельном веб-сервере). Пусто — подставляем адрес текущего сервера. CoreURL string `json:"lk_core_url,omitempty"` // AgentMode — режим ожидания: у ядра нет адреса, доступного из интернета, // поэтому не кабинет обращается к ядру, а ядро само подключается к кабинету. AgentMode bool `json:"lk_agent_mode,omitempty"` // CabinetURL — адрес кабинета на хостинге, куда подключается ядро в режиме ожидания. CabinetURL string `json:"lk_cabinet_url,omitempty"` // PreviewOn — кабинет запущен на этом же сервере на отдельном порту (просмотр из сети // депозитария без размещения на хостинге). PreviewListen — адрес:порт, по умолчанию :8098. PreviewOn bool `json:"lk_preview_on,omitempty"` PreviewListen string `json:"lk_preview_listen,omitempty"` // BindNetwork — привязывать сессию клиента не только к браузеру, но и к сети /24. // Строже, но мобильных клиентов будет выбрасывать при смене адреса оператором. BindNetwork bool `json:"lk_bind_network,omitempty"` // Интеграция с ЛК (потребление back_office API платформы): два окружения. ActiveEnv string `json:"lk_active_env"` // "test" | "prod" Test LKEndpoint `json:"lk_test"` Prod LKEndpoint `json:"lk_prod"` // FXSource — откуда брать курсы валют для оценки валютных выпусков: // пусто/"cbr" — Банк России (открытый XML, без ключа), "accounting" — курс // учётной системы (OD_VALUES_RATES), если сервер стоит без выхода в интернет. FXSource string `json:"lk_fx_source,omitempty"` // SnapshotMode — ежедневный срез стоимости портфелей (из него кабинет рисует график): // пусто — автоматически (ведём, если настроен экспорт QUIK), "on" — всегда, "off" — не вести. // SnapshotAt — время снятия среза, «ЧЧ:ММ» (пусто = 20:30, после закрытия торгов). SnapshotMode string `json:"lk_snapshot_mode,omitempty"` SnapshotAt string `json:"lk_snapshot_at,omitempty"` // EmulateNSD — заявки ЛК проходят весь путь (сверка DEPO, блокировка QUIK, сделка, // статус в ЛК), но в НРД не уходят: отправку и ответ контрагента имитирует bj. // Для боевого ЛК при тестовом профиле ИШ имитация включается принудительно. EmulateNSD bool `json:"lk_emulate_nsd"` } // LKEndpoint — реквизиты подключения к API ЛК (HTTP Basic) для одного окружения. type LKEndpoint struct { BaseURL string `json:"base_url"` // напр. https://fontvielle.ef.winr.pro/manager/api Login string `json:"login"` // Basic-логин Password string `json:"password"` // Basic-пароль LastOK bool `json:"last_ok"` // результат последней «Проверить связь» LastMsg string `json:"last_msg"` // текст результата LastAt time.Time `json:"last_at"` // время последней проверки } // TestRunResult — результат последнего тестового прогона. type TestRunResult struct { StartedAt time.Time `json:"started_at"` FinishedAt time.Time `json:"finished_at"` ClaimID string `json:"claim_id"` FinalStatus string `json:"final_status"` OK bool `json:"ok"` Message string `json:"message"` } // NewRuntimeConfig создаёт runtime-конфиг, читая JSON из path (или дефолт). func NewRuntimeConfig(path string) (*RuntimeConfig, error) { if path == "" { home, _ := os.UserHomeDir() if home == "" { home = "." } path = filepath.Join(home, ".bj", "setup.json") } rc := &RuntimeConfig{path: path} if err := rc.load(); err != nil && !errors.Is(err, os.ErrNotExist) { return nil, err } // Гарантируем разумные дефолты. if rc.data.Crypto.Provider == "" { rc.data.Crypto.Provider = "stub" } if rc.data.Crypto.SocketPath == "" { rc.data.Crypto.SocketPath = "/run/bj/crypto.sock" } return rc, nil } // Snapshot возвращает копию текущих настроек. func (r *RuntimeConfig) Snapshot() Settings { r.mu.RLock() defer r.mu.RUnlock() out := r.data if r.data.LastTest != nil { t := *r.data.LastTest out.LastTest = &t } return out } // BrandingSettings — оформление отчётов (логотип организации в шапке). type BrandingSettings struct { LogoDataURI string `json:"logo_data_uri,omitempty"` // data:image/...;base64,... (пусто = встроенный логотип по умолчанию) LogoName string `json:"logo_name,omitempty"` // имя загруженного файла (для UI) } // UpdateBranding сохраняет настройки оформления (логотип отчётов). func (r *RuntimeConfig) UpdateBranding(s BrandingSettings) error { r.mu.Lock() r.data.Branding = s r.data.UpdatedAt = time.Now().UTC() r.mu.Unlock() return r.save() } // UpdatePostgres сохраняет postgres-настройки. func (r *RuntimeConfig) UpdatePostgres(s PostgresSettings) error { r.mu.Lock() r.data.Postgres = s r.data.UpdatedAt = time.Now().UTC() r.mu.Unlock() return r.save() } // UpdateFancy сохраняет настройки подключения к MSSQL (канал обмена с Fancy). func (r *RuntimeConfig) UpdateFancy(s FancyMSSQLSettings) error { r.mu.Lock() r.data.Fancy = s r.data.UpdatedAt = time.Now().UTC() r.mu.Unlock() return r.save() } // UpdateQuik сохраняет настройки подключения к БД МНП QUIK. func (r *RuntimeConfig) UpdateQuik(s QuikMNPSettings) error { r.mu.Lock() r.data.Quik = s r.data.UpdatedAt = time.Now().UTC() r.mu.Unlock() return r.save() } // SaveLicense сохраняет лицензионные настройки. func (r *RuntimeConfig) SaveLicense(s LicenseSettings) error { r.mu.Lock() r.data.License = s r.data.UpdatedAt = time.Now().UTC() r.mu.Unlock() return r.save() } // SaveUpdateSettings сохраняет настройки авто-обновлений. func (r *RuntimeConfig) SaveUpdateSettings(s UpdateSettings) error { r.mu.Lock() r.data.Update = s r.data.UpdatedAt = time.Now().UTC() r.mu.Unlock() return r.save() } // UpdateCrypto сохраняет crypto-настройки. func (r *RuntimeConfig) UpdateCrypto(s CryptoSettings) error { r.mu.Lock() r.data.Crypto = s r.data.UpdatedAt = time.Now().UTC() r.mu.Unlock() return r.save() } // UpdateNSD сохраняет NSD-настройки. func (r *RuntimeConfig) UpdateNSD(s NSDSettings) error { r.mu.Lock() r.data.NSD = s r.data.UpdatedAt = time.Now().UTC() r.mu.Unlock() applyBusinessLine(s.IGWv904) // сразу применяем businessLine к генератору config.xml return r.save() } // UpdateLK сохраняет LK callback URL. // UpdateCACerts сохраняет настройки авто-загрузки сертификатов УЦ. func (r *RuntimeConfig) UpdateCACerts(s CACertsSettings) error { r.mu.Lock() r.data.CACerts = s r.data.UpdatedAt = time.Now() r.mu.Unlock() return r.save() } // UpdateNews заменяет всю ленту новостей. func (r *RuntimeConfig) UpdateNews(s NewsSettings) error { r.mu.Lock() r.data.News = s r.data.UpdatedAt = time.Now() r.mu.Unlock() return r.save() } // AddNews добавляет новость в начало ленты (newest first). Если в ленте уже // есть новость с таким же ID — она обновляется (вместо дубликата). func (r *RuntimeConfig) AddNews(item NewsItem) error { r.mu.Lock() if item.ID == "" { item.ID = item.At.Format("20060102-150405") + "-" + item.Kind } if item.At.IsZero() { item.At = time.Now() } // Дедуп по ID. replaced := false for i, ex := range r.data.News.Items { if ex.ID == item.ID { r.data.News.Items[i] = item replaced = true break } } if !replaced { r.data.News.Items = append([]NewsItem{item}, r.data.News.Items...) } r.data.UpdatedAt = time.Now() r.mu.Unlock() return r.save() } // DismissNews помечает новость скрытой по ID (не удаляет — для аудита). func (r *RuntimeConfig) DismissNews(id string) error { r.mu.Lock() for i := range r.data.News.Items { if r.data.News.Items[i].ID == id { r.data.News.Items[i].Dismissed = true } } r.data.UpdatedAt = time.Now() r.mu.Unlock() return r.save() } // AlertSettings — каналы внешней доставки системных оповещений (сверх ленты // «Новости»). SMTP — почта оператору; Zabbix — trapper-хук в мониторинг. type AlertSettings struct { SMTP SMTPSettings `json:"smtp"` Zabbix ZabbixSettings `json:"zabbix"` } // SMTPSettings — параметры отправки уведомлений по e-mail. type SMTPSettings struct { Enabled bool `json:"enabled"` Host string `json:"host"` Port int `json:"port"` // 587 (STARTTLS), 465 (SSL), 25 Security string `json:"security"` // "starttls" | "ssl" | "none" Username string `json:"username"` Password string `json:"password"` From string `json:"from"` To string `json:"to"` // получатели через запятую LastMsg string `json:"last_msg"` LastAt time.Time `json:"last_at"` } // ZabbixSettings — trapper-хук: bj шлёт значение элемента данных на сервер/прокси // Zabbix (протокол zabbix_sender). В Zabbix заводится host + item типа // «Zabbix trapper» с указанным ключом; триггер по значению шлёт уведомления. type ZabbixSettings struct { Enabled bool `json:"enabled"` Server string `json:"server"` // адрес сервера/прокси Zabbix Port int `json:"port"` // 10051 по умолчанию Host string `json:"host"` // имя хоста в Zabbix (host.host) Key string `json:"key"` // ключ элемента-trapper, напр. bj.alert LastMsg string `json:"last_msg"` LastAt time.Time `json:"last_at"` } func (r *RuntimeConfig) UpdateAlerts(s AlertSettings) error { r.mu.Lock() r.data.Alerts = s r.data.UpdatedAt = time.Now().UTC() r.mu.Unlock() return r.save() } // IncomingNotifySettings — уведомления депозитарию о входящих бумагах (перевод // НАМ по M2M). Пока — письмо на почту «к вам переведена бумага, требует учёта»; // позже здесь же сформируем пакет для загрузки в учётную систему. type IncomingNotifySettings struct { Enabled bool `json:"enabled"` Emails string `json:"emails"` // депозитарий (через запятую) EmailsBack string `json:"emails_back"` // бэк-офис EmailsMiddle string `json:"emails_middle"` // мидл-офис // Маршрутизация: тип уведомления → группы через запятую (depo,back,middle). // Типы: incoming (входящие переводы), orders (сроки депо-поручений), quik (QUIK МНП), system (прочие). Routes map[string]string `json:"routes"` } // Группы получателей и типы уведомлений (порядок — для формы). var notifyGroups = []struct{ Key, Title string }{{"depo", "Депозитарий"}, {"back", "Бэк-офис"}, {"middle", "Мидл-офис"}} var notifyKinds = []struct{ Key, Title string }{ {"incoming", "Входящие переводы (получен / принят)"}, {"orders", "Сроки депо-поручений НРД (MS010 5 мин / 30 дней)"}, {"quik", "QUIK МНП (ошибки блокировки / списания / зачисления)"}, {"system", "Прочие системные оповещения"}, } // defaultNotifyRoutes — маршруты по умолчанию, пока оператор не настроил свои. var defaultNotifyRoutes = map[string]string{"incoming": "depo,back", "orders": "depo,back", "quik": "back,middle", "system": "back"} // RouteHas — получает ли группа уведомления данного типа. func (s IncomingNotifySettings) RouteHas(kind, group string) bool { r := s.Routes if r == nil { r = defaultNotifyRoutes } for _, g := range strings.Split(r[kind], ",") { if strings.TrimSpace(g) == group { return true } } return false } // GroupEmails — адреса группы. func (s IncomingNotifySettings) GroupEmails(group string) string { switch group { case "depo": return s.Emails case "back": return s.EmailsBack case "middle": return s.EmailsMiddle } return "" } // Recipients — адреса всех групп, на которые направлен тип уведомления (без дублей). func (s IncomingNotifySettings) Recipients(kind string) string { seen := map[string]bool{} var out []string for _, g := range notifyGroups { if !s.RouteHas(kind, g.Key) { continue } for _, e := range strings.Split(s.GroupEmails(g.Key), ",") { e = strings.TrimSpace(e) if e == "" || seen[strings.ToLower(e)] { continue } seen[strings.ToLower(e)] = true out = append(out, e) } } return strings.Join(out, ", ") } func (r *RuntimeConfig) UpdateIncoming(s IncomingNotifySettings) error { r.mu.Lock() r.data.Incoming = s r.data.UpdatedAt = time.Now().UTC() r.mu.Unlock() return r.save() } func (r *RuntimeConfig) UpdateLK(s LKSettings) error { r.mu.Lock() r.data.LK = s r.data.UpdatedAt = time.Now().UTC() r.mu.Unlock() return r.save() } // RecordTestRun сохраняет результат теста. func (r *RuntimeConfig) RecordTestRun(res TestRunResult) error { r.mu.Lock() r.data.LastTest = &res r.data.UpdatedAt = time.Now().UTC() r.mu.Unlock() return r.save() } // load читает JSON в r.data. func (r *RuntimeConfig) load() error { raw, err := os.ReadFile(r.path) if err != nil { return err } return json.Unmarshal(raw, &r.data) } // save пишет JSON в r.path атомарно через tmp + rename. func (r *RuntimeConfig) save() error { r.mu.RLock() raw, err := json.MarshalIndent(r.data, "", " ") r.mu.RUnlock() if err != nil { return err } dir := filepath.Dir(r.path) if err := os.MkdirAll(dir, 0o755); err != nil { return err } tmp := r.path + ".tmp" if err := os.WriteFile(tmp, raw, 0o600); err != nil { return err } return os.Rename(tmp, r.path) } // Readiness — сводная готовность подсистемы. type Readiness struct { Name string `json:"name"` Ready bool `json:"ready"` // полностью настроена и проверена Configured bool `json:"configured"` // есть пользовательский конфиг (не stub) Message string `json:"message"` } // ReadinessSummary возвращает компактную сводку для UI/admin. func (r *RuntimeConfig) ReadinessSummary() []Readiness { s := r.Snapshot() out := []Readiness{ { Name: "postgres", Configured: s.Postgres.DSN != "", Ready: false, // настоящий ping будет в checks.go Message: posMsg(s.Postgres.DSN), }, { Name: "crypto-service", Configured: s.Crypto.Provider != "" && s.Crypto.Provider != "stub", Ready: false, Message: cryptoMsg(s.Crypto), }, { Name: "nsd-adapter", Configured: s.NSD.IGWBaseURL != "" && s.NSD.Profile != "", Ready: false, Message: nsdMsg(s.NSD), }, { Name: "lk-callback", Configured: s.LK.CallbackURL != "", Ready: false, Message: lkMsg(s.LK), }, } return out } func posMsg(dsn string) string { if dsn == "" { if devMode(globalRC) { return "DSN не задан — система в режиме in-memory (M2-демо)" } return "База данных не настроена — данные не сохраняются между перезапусками" } return "DSN настроен: " + maskDSN(dsn) } func cryptoMsg(c CryptoSettings) string { if c.Provider == "" || c.Provider == "stub" { if devMode(globalRC) { return "Криптография не настроена (provider=stub) — реальная подпись недоступна." } return "Средство подписи (СКЗИ) не настроено — подпись и обмен с НРД недоступны." } if c.ModulePath == "" { return "Провайдер " + c.Provider + ", путь к PKCS#11 модулю не задан." } return "Провайдер " + c.Provider + ", PKCS#11 модуль: " + c.ModulePath } func nsdMsg(n NSDSettings) string { if n.IGWBaseURL == "" { if devMode(globalRC) { return "ИШ НРД не настроен — используется mock-режим (Decision через 3 сек)" } return "ИШ НРД не настроен — обмен с НРД не выполняется" } if n.Profile == "" { return "URL ИШ задан, но профиль не выбран" } return "Профиль " + n.Profile + ", ИШ " + n.IGWBaseURL } func lkMsg(l LKSettings) string { if l.CallbackURL == "" { if devMode(globalRC) { return "Callback URL не настроен — используется встроенный lk-emulator" } return "Callback URL не настроен — статусы в личный кабинет не отправляются" } return "Callback URL: " + l.CallbackURL } // maskDSN скрывает пароль в DSN для отображения в UI. func maskDSN(dsn string) string { // простая маскировка: ищем :///user:pass@host const sep = "@" if idx := indexAt(dsn, sep); idx > 0 { if colon := lastColonBefore(dsn, idx); colon > 0 && colon < idx { return dsn[:colon+1] + "***" + dsn[idx:] } } return dsn } func indexAt(s, sub string) int { for i := 0; i+len(sub) <= len(s); i++ { if s[i:i+len(sub)] == sub { return i } } return -1 } func lastColonBefore(s string, idx int) int { for i := idx - 1; i >= 0; i-- { if s[i] == ':' { return i } } return -1 }