13832fefad
Страница /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
269 lines
12 KiB
Go
269 lines
12 KiB
Go
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))
|
|
}
|