diff --git a/internal/lkgateway/client_profile.go b/internal/lkgateway/client_profile.go new file mode 100644 index 0000000..681c48d --- /dev/null +++ b/internal/lkgateway/client_profile.go @@ -0,0 +1,268 @@ +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 = `
Локальный профиль есть: клиент ` + html.EscapeString(t) + `` + if !p.SavedAt.IsZero() { + status += ", сохранён " + p.SavedAt.Local().Format("02.01.2006 15:04") + } + status += ".
" + } else { + status = `
Локального профиля нет. На боевом он придёт с сервера лицензий при активации ключа; для стенда можно вставить JSON ниже.
` + } + var flashHTML string + if flash != "" { + cls := "ok" + if flashKind == "err" { + cls = "bad" + } + flashHTML = `
` + html.EscapeString(flash) + `
` + } + page := `Профиль клиента + +

Профиль клиента

+

Профиль — отличия настроек клиента от стандарта. Он предзаполняет ещё не +настроенные разделы мастера и ничего не перетирает. На боевом профиль приходит с сервера +лицензий (его правит администратор в портале под клиента); здесь можно выгрузить эталон и, для +стенда, применить профиль вручную.

+` + status + flashHTML + ` +
+
+
+
+

Вставьте JSON профиля клиента и примените (для стенда, пока lic не отдаёт его сам):

+ +
+
+

Секреты (пароли, DSN, токены) в эталон не выгружаются — заполните их под клиента.

+` + _, _ = w.Write([]byte(page)) +} diff --git a/internal/lkgateway/client_profile_test.go b/internal/lkgateway/client_profile_test.go new file mode 100644 index 0000000..84f7922 --- /dev/null +++ b/internal/lkgateway/client_profile_test.go @@ -0,0 +1,85 @@ +package lkgateway + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestRedactSecrets(t *testing.T) { + m := map[string]any{ + "host": "db", + "password": "p@ss", + "nested": map[string]any{ + "dsn": "postgres://u:pw@h/db", + "keep": "ok", + "token": "abc", + }, + "list": []any{map[string]any{"api_key": "x", "name": "n"}}, + } + redactSecrets(m) + if m["password"] != "" { + t.Errorf("password должен быть затёрт, получили %v", m["password"]) + } + n := m["nested"].(map[string]any) + if n["dsn"] != "" || n["token"] != "" { + t.Errorf("dsn/token должны быть затёрты: %v", n) + } + if n["keep"] != "ok" { + t.Errorf("несекретное поле потеряно: %v", n["keep"]) + } + if m["list"].([]any)[0].(map[string]any)["api_key"] != "" { + t.Errorf("api_key в массиве должен быть затёрт") + } +} + +func TestMergeEmptySections(t *testing.T) { + // Пустой раздел заполняется из профиля. + dst := Settings{} + prof := Settings{Postgres: PostgresSettings{DSN: "postgres://x"}} + filled := mergeEmptySections(&dst, prof) + if dst.Postgres.DSN != "postgres://x" { + t.Fatalf("пустой раздел должен заполниться из профиля, got %q", dst.Postgres.DSN) + } + if len(filled) != 1 || filled[0] != "Postgres" { + t.Fatalf("ожидали filled=[Postgres], got %v", filled) + } + // Настроенный раздел профиль не перетирает. + dst2 := Settings{Postgres: PostgresSettings{DSN: "mine"}} + prof2 := Settings{Postgres: PostgresSettings{DSN: "theirs"}} + filled2 := mergeEmptySections(&dst2, prof2) + if dst2.Postgres.DSN != "mine" { + t.Fatalf("настроенный раздел не должен перетираться, got %q", dst2.Postgres.DSN) + } + if len(filled2) != 0 { + t.Fatalf("ничего не должно заполниться, got %v", filled2) + } + // Лицензия не трогается профилем. + dst3 := Settings{} + prof3 := Settings{License: LicenseSettings{InstallID: "zzz"}} + mergeEmptySections(&dst3, prof3) + if dst3.License.InstallID != "" { + t.Fatalf("лицензия не должна заполняться из профиля, got %q", dst3.License.InstallID) + } +} + +func TestExportClientProfileRedacts(t *testing.T) { + rc := &RuntimeConfig{path: t.TempDir() + "/setup.json"} + rc.data = Settings{Postgres: PostgresSettings{DSN: "postgres://u:secret@h/db"}} + body, err := exportClientProfile(rc) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + t.Fatal(err) + } + s := m["settings"].(map[string]any) + pg := s["postgres"].(map[string]any) + if pg["dsn"] != "" { + t.Errorf("DSN должен быть затёрт в эталоне, got %v", pg["dsn"]) + } + if !strings.Contains(string(body), `"settings"`) { + t.Error("эталон должен содержать settings") + } +} diff --git a/internal/lkgateway/licenseclient.go b/internal/lkgateway/licenseclient.go index 0c9793f..dd8dd88 100644 --- a/internal/lkgateway/licenseclient.go +++ b/internal/lkgateway/licenseclient.go @@ -75,6 +75,7 @@ type licenseReply struct { Tenant string `json:"tenant"` ExpiresAt time.Time `json:"expires_at"` ContractUntil time.Time `json:"contract_until"` + ConfigProfile json.RawMessage `json:"config_profile,omitempty"` // профиль настроек клиента (client_profile.go) } func licenseCall(ctx context.Context, path string, body map[string]string) (*licenseReply, error) { @@ -168,6 +169,9 @@ func LicenseActivate(ctx context.Context, rc *RuntimeConfig, code string) error return err } log.Printf("lk-gateway: лицензия активирована на сервере лицензий: %s, лицензия %s", r.Tenant, r.LicenseID) + if len(r.ConfigProfile) > 0 { + saveAndApplyClientProfile(rc, r.ConfigProfile, r.Tenant) + } afterLicenseChange(rc) return nil } @@ -198,6 +202,9 @@ func LicenseRenew(ctx context.Context, rc *RuntimeConfig) error { if err := licenseAccept(rc, r.Token, "продлена"); err != nil { return err } + if len(r.ConfigProfile) > 0 { + saveAndApplyClientProfile(rc, r.ConfigProfile, r.Tenant) + } if nl, err := verifyLicenseToken(r.Token); err == nil && licenseScopeChanged(lic, nl) { afterLicenseChange(rc) // в лицензию добавили модуль или план — поставить, не дожидаясь перезапуска bj } else { diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go index 9e2d8ed..c0cc74d 100644 --- a/internal/lkgateway/runtimeconfig.go +++ b/internal/lkgateway/runtimeconfig.go @@ -488,6 +488,7 @@ func NewRuntimeConfig(path string) (*RuntimeConfig, error) { if rc.data.Crypto.SocketPath == "" { rc.data.Crypto.SocketPath = "/run/bj/crypto.sock" } + applyClientProfileFile(rc) // профиль клиента (lic): предзаполнить ещё не настроенные разделы return rc, nil } diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go index 3ac5188..913db44 100644 --- a/internal/lkgateway/server.go +++ b/internal/lkgateway/server.go @@ -234,6 +234,7 @@ func NewServer(cfg ServerConfig) (*Server, error) { registerHandbookRequest(mux, igwClient, igwChannel) registerChainTest(mux, rc, igwClient != nil) // «Проверить всю цепочку» (мастер, глава «Запуск») registerParticipantForm(mux, rc) // анкета участника M2M (регистрация в НРД, participant_form.go) + registerClientProfile(mux, rc) // профиль клиента: выгрузка эталона и предзаполнение (client_profile.go) return &Server{ cfg: cfg,