Профиль клиента: выгрузка эталона и предзаполнение мастера под клиента

Страница /admin/setup/client-profile: скачать эталон текущей конфигурации (секреты
затёрты) и — для стенда — применить профиль клиента вручную. На боевом профиль
приходит с сервера лицензий (поле config_profile в ответе активации/продления) и
предзаполняет ещё НЕ настроенные разделы мастера, ничего не перетирая. Локальный
фолбэк — файл client-profile.json рядом с setup.json. Администратор правит профиль
под клиента на lic (отличия от стандарта).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XG3zNMMf2LMoDpXo22mwMX
This commit is contained in:
fontvielle
2026-10-16 09:58:23 +03:00
parent 3ba8dcc931
commit 13832fefad
5 changed files with 362 additions and 0 deletions
+268
View File
@@ -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 = `<div class="ok">Локальный профиль есть: клиент <b>` + html.EscapeString(t) + `</b>`
if !p.SavedAt.IsZero() {
status += ", сохранён " + p.SavedAt.Local().Format("02.01.2006 15:04")
}
status += ".</div>"
} else {
status = `<div class="note">Локального профиля нет. На боевом он придёт с сервера лицензий при активации ключа; для стенда можно вставить JSON ниже.</div>`
}
var flashHTML string
if flash != "" {
cls := "ok"
if flashKind == "err" {
cls = "bad"
}
flashHTML = `<div class="` + cls + `">` + html.EscapeString(flash) + `</div>`
}
page := `<!doctype html><html lang="ru"><head><meta charset="utf-8"><title>Профиль клиента</title>
<style>body{font:14px/1.5 system-ui,Arial;margin:24px;max-width:900px;color:#111}
h1{font-size:20px}textarea{width:100%;min-height:220px;box-sizing:border-box;font:12px/1.4 ui-monospace,Consolas,monospace;padding:10px;border:1px solid #bbb;border-radius:8px}
.btns{margin:16px 0;display:flex;gap:10px;flex-wrap:wrap}
button{padding:8px 16px;border:1px solid #888;border-radius:6px;background:#f3f3f3;cursor:pointer}
button.primary{background:#1763cc;color:#fff;border-color:#1763cc}
.ok{background:#e7f6e7;border:1px solid #58a858;padding:8px 12px;border-radius:6px;margin:10px 0}
.bad{background:#fdecec;border:1px solid #d66;padding:8px 12px;border-radius:6px;margin:10px 0}
.note{color:#555;font-size:13px;margin:10px 0}</style></head><body>
<h1>Профиль клиента</h1>
<p class="note">Профиль — отличия настроек клиента от стандарта. Он <b>предзаполняет</b> ещё не
настроенные разделы мастера и ничего не перетирает. На боевом профиль приходит с сервера
лицензий (его правит администратор в портале под клиента); здесь можно выгрузить эталон и, для
стенда, применить профиль вручную.</p>
` + status + flashHTML + `
<div class="btns">
<form method="post" style="margin:0"><button type="submit" name="action" value="export" formtarget="_blank">Скачать эталон (текущая конфигурация)</button></form>
</div>
<form method="post">
<p class="note">Вставьте JSON профиля клиента и примените (для стенда, пока lic не отдаёт его сам):</p>
<textarea name="profile" placeholder='{"tenant":"...","settings":{ ... }}'></textarea>
<div class="btns"><button type="submit" name="action" value="apply" class="primary">Применить профиль</button></div>
</form>
<p class="note">Секреты (пароли, DSN, токены) в эталон не выгружаются — заполните их под клиента.</p>
</body></html>`
_, _ = w.Write([]byte(page))
}
+85
View File
@@ -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")
}
}
+7
View File
@@ -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 {
+1
View File
@@ -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
}
+1
View File
@@ -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,