package lkgateway import ( "encoding/json" "errors" "os" "path/filepath" "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"` Crypto CryptoSettings `json:"crypto"` NSD NSDSettings `json:"nsd"` LK LKSettings `json:"lk"` CACerts CACertsSettings `json:"ca_certs"` News NewsSettings `json:"news"` LastTest *TestRunResult `json:"last_test,omitempty"` UpdatedAt time.Time `json:"updated_at"` } // 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 и переустанавливает сертификат, если он // поменялся. Все сертификаты идут в mroot/uRoot хранилища КриптоПро. 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"` } // CryptoSettings — путь к JCP, провайдер, лицензионный ключ. type CryptoSettings struct { Provider string `json:"provider"` // "stub" | "cryptopro" | "validata" | "vipnet" SocketPath string `json:"socket_path"` // UDS crypto-service JCPPath string `json:"jcp_path"` // путь до jcp.jar LicenseKey string `json:"license_key"` // лицензионный ключ КриптоПро } // 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"` // имя контейнера (на стороне ИШ) } // LKSettings — настройки callback в ЛК клиента. type LKSettings struct { CallbackURL string `json:"callback_url"` } // 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 } // 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() } // 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() 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() } 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" && s.Crypto.JCPPath != "", 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 == "" { return "DSN не задан — система в режиме in-memory (M2-демо)" } return "DSN настроен: " + maskDSN(dsn) } func cryptoMsg(c CryptoSettings) string { if c.Provider == "" || c.Provider == "stub" { return "Криптография не настроена (provider=stub). КриптоПро JCP не подключён." } if c.JCPPath == "" { return "Провайдер " + c.Provider + ", но путь к JCP не задан." } if c.LicenseKey == "" { return "Провайдер " + c.Provider + ", JCP есть, лицензия не введена." } return "Провайдер " + c.Provider + ", JCP подключён, лицензия введена." } func nsdMsg(n NSDSettings) string { if n.IGWBaseURL == "" { return "ИШ НРД не настроен — используется mock-режим (Decision через 3 сек)" } if n.Profile == "" { return "URL ИШ задан, но профиль не выбран" } return "Профиль " + n.Profile + ", ИШ " + n.IGWBaseURL } func lkMsg(l LKSettings) string { if l.CallbackURL == "" { return "Callback URL не настроен — используется встроенный lk-emulator" } 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 }