feat(bj): модуль ЛК на PostgreSQL, обмен с DEPO, боевой ЛК с имитатором НРД, QUIK, НПР
- bj_exchange и «кран» Fancy убраны: данные модуля ЛК в PostgreSQL (схема lk) - трансферные таблицы FTV_M2M: создание при установке, события, справочник участников - сверка с DEPO: счёт, ФИО, дата рождения, ИИС по договору, квал-инвестор из опции DEPO - боевой ЛК ЕСИА Финанс при тестовом ИШ: журнал, отказ через DEPO, статусы в ЛК - онлайн-лицензирование в bj, план тестирования M2M для контрагента (интерактивный HTML) - модули QUIK МНП, экспорт QUIK, НПР/УДС; документы и тестовые материалы Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
package lkgateway
|
||||
|
||||
// Онлайн-лицензирование: активация установки кодом и ежесуточное продление ключа на
|
||||
// сервере лицензий (docs/license-server-api.md). Адрес сервера и открытый ключ зашиты в bj
|
||||
// и в настройках не меняются. Офлайн-ключ для закрытого контура вставляется вручную.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// licenseServer — сервер лицензий. var — только для подмены в тестах.
|
||||
var licenseServer = "https://lic.zetit.ru"
|
||||
|
||||
var licenseHTTP = &http.Client{Timeout: 25 * time.Second}
|
||||
|
||||
// errLicenseOffline — у установки офлайн-ключ, продлевать на сервере нечего.
|
||||
var errLicenseOffline = errors.New("ключ офлайн (закрытый контур): продление на сервере лицензий не выполняется")
|
||||
|
||||
var licenseAlertAt time.Time
|
||||
|
||||
// ensureInstallID — идентификатор установки: создаётся один раз и хранится в настройках.
|
||||
func ensureInstallID(rc *RuntimeConfig) string {
|
||||
s := rc.Snapshot().License
|
||||
if uuidRe.MatchString(s.InstallID) {
|
||||
return strings.ToLower(s.InstallID)
|
||||
}
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
b[6] = b[6]&0x0f | 0x40
|
||||
b[8] = b[8]&0x3f | 0x80
|
||||
h := hex.EncodeToString(b)
|
||||
s.InstallID = h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:]
|
||||
if err := rc.SaveLicense(s); err != nil {
|
||||
log.Printf("lk-gateway: лицензия: сохранение идентификатора установки: %v", err)
|
||||
}
|
||||
return s.InstallID
|
||||
}
|
||||
|
||||
// machineHash — sha256 от /etc/machine-id: серверу лицензий, чтобы заметить копию сервера.
|
||||
func machineHash() string {
|
||||
b, err := os.ReadFile("/etc/machine-id")
|
||||
if err != nil || strings.TrimSpace(string(b)) == "" {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(string(b))))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
type licenseAPIError struct {
|
||||
Status int `json:"-"`
|
||||
Code string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (e *licenseAPIError) Error() string { return e.Message }
|
||||
|
||||
type licenseReply struct {
|
||||
Token string `json:"token"`
|
||||
LicenseID string `json:"license_id"`
|
||||
Tenant string `json:"tenant"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
ContractUntil time.Time `json:"contract_until"`
|
||||
}
|
||||
|
||||
func licenseCall(ctx context.Context, path string, body map[string]string) (*licenseReply, error) {
|
||||
host, _ := os.Hostname()
|
||||
body["hostname"], body["version"], body["machine"] = host, BuildVersion, machineHash()
|
||||
b, _ := json.Marshal(body)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, licenseServer+path, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "bj-server/"+BuildVersion)
|
||||
resp, err := licenseHTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("сервер лицензий недоступен: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var e licenseAPIError
|
||||
if json.Unmarshal(raw, &e) == nil && e.Code != "" {
|
||||
e.Status = resp.StatusCode
|
||||
return nil, &e
|
||||
}
|
||||
return nil, fmt.Errorf("сервер лицензий: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var r licenseReply
|
||||
if err := json.Unmarshal(raw, &r); err != nil || r.Token == "" {
|
||||
return nil, fmt.Errorf("сервер лицензий вернул непонятный ответ")
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// licenseRecord — итог обращения к серверу лицензий (для карточки); token != "" — новый ключ.
|
||||
func licenseRecord(rc *RuntimeConfig, token, msg string, ok bool) error {
|
||||
s := rc.Snapshot().License
|
||||
now := time.Now()
|
||||
s.LastAttempt, s.LastOK, s.LastMsg = now, ok, msg
|
||||
if ok {
|
||||
s.LastSync = now
|
||||
}
|
||||
if token != "" {
|
||||
s.Key = token
|
||||
}
|
||||
return rc.SaveLicense(s)
|
||||
}
|
||||
|
||||
// licenseAccept — проверить ключ от сервера (подпись зашитым ключом, эта установка) и сохранить.
|
||||
func licenseAccept(rc *RuntimeConfig, token, how string) error {
|
||||
installID := ensureInstallID(rc)
|
||||
lic, err := verifyLicenseToken(token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("сервер вернул недействительный ключ: %w", err)
|
||||
}
|
||||
if !strings.EqualFold(lic.InstallID, installID) {
|
||||
return fmt.Errorf("сервер вернул ключ для другой установки")
|
||||
}
|
||||
return licenseRecord(rc, token, fmt.Sprintf("%s: ключ до %s", how, lic.ExpiresAt.Local().Format("02.01.2006")), true)
|
||||
}
|
||||
|
||||
// LicenseActivate — активация этой установки кодом на сервере лицензий.
|
||||
func LicenseActivate(ctx context.Context, rc *RuntimeConfig, code string) error {
|
||||
installID := ensureInstallID(rc)
|
||||
r, err := licenseCall(ctx, "/v1/activate", map[string]string{"code": strings.TrimSpace(code), "install_id": installID})
|
||||
if err != nil {
|
||||
_ = licenseRecord(rc, "", "активация не удалась: "+err.Error(), false)
|
||||
return err
|
||||
}
|
||||
if err := licenseAccept(rc, r.Token, "активирована"); err != nil {
|
||||
_ = licenseRecord(rc, "", err.Error(), false)
|
||||
return err
|
||||
}
|
||||
log.Printf("lk-gateway: лицензия активирована на сервере лицензий: %s, лицензия %s", r.Tenant, r.LicenseID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LicenseRenew — продление ключа этой установки.
|
||||
func LicenseRenew(ctx context.Context, rc *RuntimeConfig) error {
|
||||
installID := ensureInstallID(rc)
|
||||
cur := rc.Snapshot().License.Key
|
||||
lic, err := verifyLicenseToken(cur)
|
||||
if err != nil || lic.InstallID == "" {
|
||||
return errLicenseOffline
|
||||
}
|
||||
r, err := licenseCall(ctx, "/v1/renew", map[string]string{"token": cur, "install_id": installID})
|
||||
if err != nil {
|
||||
_ = licenseRecord(rc, "", "продление не удалось: "+err.Error(), false)
|
||||
var ae *licenseAPIError
|
||||
if errors.As(err, &ae) && time.Since(licenseAlertAt) > 12*time.Hour {
|
||||
licenseAlertAt = time.Now()
|
||||
raiseSystemAlert(rc, "Лицензия: сервер лицензий отказал в продлении",
|
||||
fmt.Sprintf("%s (%s). Текущий ключ действует до %s; после этого модули по лицензии отключатся.", ae.Message, ae.Code, lic.ExpiresAt.Local().Format("02.01.2006")))
|
||||
}
|
||||
return err
|
||||
}
|
||||
return licenseAccept(rc, r.Token, "продлена")
|
||||
}
|
||||
|
||||
// licenseNeedsRenew — онлайн-ключ, и сутки без успешного продления.
|
||||
func licenseNeedsRenew(rc *RuntimeConfig) bool {
|
||||
s := rc.Snapshot().License
|
||||
lic, err := verifyLicenseToken(s.Key)
|
||||
if err != nil || lic.InstallID == "" {
|
||||
return false
|
||||
}
|
||||
return time.Since(s.LastSync) >= 23*time.Hour
|
||||
}
|
||||
|
||||
// licenseLoop — раз в час проверяет, пора ли продлить ключ (продление — раз в сутки,
|
||||
// при сбое — повтор через час).
|
||||
func (s *Server) licenseLoop(ctx context.Context) {
|
||||
ensureInstallID(s.rc)
|
||||
wait := 2 * time.Minute
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(wait):
|
||||
}
|
||||
wait = time.Hour
|
||||
if !licenseNeedsRenew(s.rc) {
|
||||
continue
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, time.Minute)
|
||||
if err := LicenseRenew(cctx, s.rc); err != nil {
|
||||
log.Printf("lk-gateway: лицензия: %v", err)
|
||||
} else {
|
||||
log.Printf("lk-gateway: лицензия продлена: %s", s.rc.Snapshot().License.LastMsg)
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user