package lkgateway import ( "encoding/json" "html" "log" "net/http" "os" "path/filepath" "reflect" "regexp" "strings" "time" ) // clientProfile — профиль настроек под конкретного клиента. Храним его у себя на сервере // лицензий (lic); администратор правит его в портале под клиента (отличия от стандарта). // Сервер лицензий отдаёт профиль установке при активации/продлении ключа (поле config_profile). // Локально профиль может лежать файлом <каталог setup.json>/client-profile.json — запасной путь // для стенда, пока сторона lic ещё не отдаёт профиль. // // Профиль ПРЕДЗАПОЛНЯЕТ ещё не настроенные разделы мастера и НИЧЕГО не перетирает: уже // настроенный раздел остаётся как есть, оператор на месте всё может поправить. type clientProfile struct { Tenant string `json:"tenant,omitempty"` Note string `json:"note,omitempty"` SavedAt time.Time `json:"saved_at,omitempty"` Settings Settings `json:"settings"` } func clientProfilePath(rc *RuntimeConfig) string { return filepath.Join(filepath.Dir(rc.path), "client-profile.json") } // secretKeyRe — ключи JSON, значения которых не выгружаем в эталон (пароли, DSN с логином, // токены, секреты). Эталон — отправная точка настройки клиента на lic; пароли и реквизиты там // заполняет администратор под конкретного клиента. var secretKeyRe = regexp.MustCompile(`(?i)(password|passwd|secret|token|dsn|private|_priv|apikey|api_key)`) // redactSecrets рекурсивно затирает значения секретных ключей в JSON-дереве. func redactSecrets(v any) any { switch t := v.(type) { case map[string]any: for k, val := range t { if secretKeyRe.MatchString(k) { if _, ok := val.(string); ok { t[k] = "" } else { delete(t, k) } continue } t[k] = redactSecrets(val) } return t case []any: for i := range t { t[i] = redactSecrets(t[i]) } return t } return v } // exportClientProfile — текущая конфигурация стенда как эталон-профиль (секреты затёрты, // лицензия и результат проверки убраны). func exportClientProfile(rc *RuntimeConfig) ([]byte, error) { s := rc.Snapshot() s.License = LicenseSettings{} s.LastTest = nil raw, err := json.Marshal(s) if err != nil { return nil, err } var m any if err := json.Unmarshal(raw, &m); err != nil { return nil, err } p := map[string]any{ "note": "Эталон конфигурации (выгружен из стенда). Подстройте под клиента на lic; пароли и реквизиты заполните заново.", "saved_at": time.Now().UTC(), "settings": redactSecrets(m), } return json.MarshalIndent(p, "", " ") } // mergeEmptySections заполняет в dst только ПОЛНОСТЬЮ пустые разделы значениями из prof. // Настроенные (ненулевые) разделы не трогает. Лицензия и служебные поля пропускаются. func mergeEmptySections(dst *Settings, prof Settings) (filled []string) { skip := map[string]bool{"License": true, "UpdatedAt": true, "LastTest": true, "QuikExport": true} dv := reflect.ValueOf(dst).Elem() pv := reflect.ValueOf(prof) tp := dv.Type() for i := 0; i < dv.NumField(); i++ { name := tp.Field(i).Name if skip[name] || !dv.Field(i).CanSet() { continue } if dv.Field(i).IsZero() && !pv.Field(i).IsZero() { dv.Field(i).Set(pv.Field(i)) filled = append(filled, name) } } return filled } // applyProfileSettings вливает профиль в пустые разделы настроек и сохраняет, если что-то добавилось. func (r *RuntimeConfig) applyProfileSettings(prof Settings) (filled []string, err error) { r.mu.Lock() filled = mergeEmptySections(&r.data, prof) if len(filled) > 0 { r.data.UpdatedAt = time.Now().UTC() } r.mu.Unlock() if len(filled) > 0 { err = r.save() } return filled, err } func loadClientProfileFile(rc *RuntimeConfig) (*clientProfile, error) { raw, err := os.ReadFile(clientProfilePath(rc)) if err != nil { return nil, err } var p clientProfile if err := json.Unmarshal(raw, &p); err != nil { return nil, err } return &p, nil } // applyClientProfileFile — при старте: если рядом лежит client-profile.json, предзаполнить им // ещё не настроенные разделы. Безопасно: нет файла или он пуст — ничего не меняется. func applyClientProfileFile(rc *RuntimeConfig) { p, err := loadClientProfileFile(rc) if err != nil { if !os.IsNotExist(err) { log.Printf("setup: профиль клиента не прочитан: %v", err) } return } if filled, err := rc.applyProfileSettings(p.Settings); err != nil { log.Printf("setup: профиль клиента не применён: %v", err) } else if len(filled) > 0 { log.Printf("setup: профиль клиента предзаполнил разделы: %s", strings.Join(filled, ", ")) } } // saveAndApplyClientProfile — профиль пришёл от сервера лицензий (config_profile): сохранить // локально и предзаполнить им пустые разделы. func saveAndApplyClientProfile(rc *RuntimeConfig, raw []byte, tenant string) { var p clientProfile if err := json.Unmarshal(raw, &p); err != nil { log.Printf("setup: профиль клиента с сервера лицензий не разобран: %v", err) return } if p.Tenant == "" { p.Tenant = tenant } p.SavedAt = time.Now().UTC() if out, err := json.MarshalIndent(p, "", " "); err == nil { _ = os.WriteFile(clientProfilePath(rc), out, 0o600) } if filled, err := rc.applyProfileSettings(p.Settings); err == nil && len(filled) > 0 { log.Printf("setup: профиль клиента (%s) предзаполнил разделы: %s", p.Tenant, strings.Join(filled, ", ")) } } func registerClientProfile(mux *http.ServeMux, rc *RuntimeConfig) { mux.HandleFunc("/admin/setup/client-profile", func(w http.ResponseWriter, r *http.Request) { var flash, flashKind string if r.Method == http.MethodPost { _ = r.ParseForm() switch r.FormValue("action") { case "export": body, err := exportClientProfile(rc) if err != nil { http.Error(w, "выгрузка не удалась: "+err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Disposition", `attachment; filename="client-profile-standard.json"`) _, _ = w.Write(body) return case "apply": txt := strings.TrimSpace(r.FormValue("profile")) if txt == "" { flash, flashKind = "Вставьте JSON профиля.", "err" break } var p clientProfile if err := json.Unmarshal([]byte(txt), &p); err != nil { flash, flashKind = "JSON не разобран: "+err.Error(), "err" break } if out, err := json.MarshalIndent(p, "", " "); err == nil { if err := os.WriteFile(clientProfilePath(rc), out, 0o600); err != nil { flash, flashKind = "профиль не сохранён: "+err.Error(), "err" break } } filled, err := rc.applyProfileSettings(p.Settings) switch { case err != nil: flash, flashKind = "профиль не применён: "+err.Error(), "err" case len(filled) == 0: flash, flashKind = "Профиль сохранён. Новых разделов не предзаполнено — настроенное профиль не перетирает.", "ok" default: flash, flashKind = "Профиль применён. Предзаполнены разделы: "+strings.Join(filled, ", ")+". Проверьте в мастере настройки.", "ok" } } } clientProfileRender(w, rc, flash, flashKind) }) } func clientProfileRender(w http.ResponseWriter, rc *RuntimeConfig, flash, flashKind string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") var status string if p, err := loadClientProfileFile(rc); err == nil { t := p.Tenant if t == "" { t = "—" } status = `
Профиль — отличия настроек клиента от стандарта. Он предзаполняет ещё не настроенные разделы мастера и ничего не перетирает. На боевом профиль приходит с сервера лицензий (его правит администратор в портале под клиента); здесь можно выгрузить эталон и, для стенда, применить профиль вручную.
` + status + flashHTML + `Секреты (пароли, DSN, токены) в эталон не выгружаются — заполните их под клиента.
` _, _ = w.Write([]byte(page)) }