Профиль клиента: выгрузка эталона и предзаполнение мастера под клиента
Страница /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:
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user