d67324e943
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
242 lines
10 KiB
Go
242 lines
10 KiB
Go
package lkgateway
|
|
|
|
// Онлайн-лицензирование: активация установки кодом и ежесуточное продление ключа на
|
|
// сервере лицензий (docs/license-server-api.md). Адрес сервера и открытый ключ зашиты в bj
|
|
// и в настройках не меняются. Офлайн-ключ для закрытого контура вставляется вручную.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/hwid"
|
|
"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()
|
|
body["board"], body["mac"] = hwid.Board(), hwid.MAC() // отпечаток сервера: хэши, без самих значений
|
|
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
|
|
}
|
|
|
|
// licenseDeactivate — сервер отключён от лицензии в портале клиента (или лицензия отозвана): ключ
|
|
// удаляется сразу, не дожидаясь его срока, — модули и обновления отключаются. Вернуть: ввести ключ
|
|
// продукта заново (если в лицензии есть свободное место).
|
|
func licenseDeactivate(rc *RuntimeConfig, reason string) {
|
|
s := rc.Snapshot().License
|
|
if s.Key == "" {
|
|
return
|
|
}
|
|
s.Key = ""
|
|
s.LastAttempt, s.LastOK, s.LastMsg = time.Now(), false, "лицензия отключена: "+reason
|
|
if err := rc.SaveLicense(s); err != nil {
|
|
log.Printf("lk-gateway: отключение лицензии: %v", err)
|
|
return
|
|
}
|
|
log.Printf("lk-gateway: лицензия на этом сервере отключена: %s", reason)
|
|
raiseSystemAlert(rc, "Лицензия на этом сервере отключена",
|
|
"Причина: "+reason+". Модули по лицензии и обновления отключены. Если это ошибка — введите ключ продукта заново в «Настройка → Лицензия» (нужно свободное место в лицензии) или обратитесь в ZETIT.")
|
|
}
|
|
|
|
// 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) && (ae.Code == "install_released" || ae.Code == "revoked") {
|
|
licenseDeactivate(rc, ae.Message)
|
|
return err
|
|
}
|
|
if errors.As(err, &ae) && time.Since(licenseAlertAt) > 12*time.Hour {
|
|
licenseAlertAt = time.Now()
|
|
raiseSystemAlert(rc, "Лицензия: сервер лицензий отказал в продлении",
|
|
fmt.Sprintf("%s (%s). Текущий ключ действует до %s; если его не продлить, модули по лицензии отключатся — обратитесь в ZETIT.", ae.Message, ae.Code, lic.ExpiresAt.Local().Format("02.01.2006")))
|
|
}
|
|
return err
|
|
}
|
|
if err := licenseAccept(rc, r.Token, "продлена"); err != nil {
|
|
return err
|
|
}
|
|
enforceModuleLicenseAsync(rc) // состав лицензии мог измениться — службы модулей в соответствие
|
|
return nil
|
|
}
|
|
|
|
// licenseNeedsRenew — онлайн-ключ и 15 минут без продления: так режим разработки, включённый или
|
|
// выключенный на сервере лицензий, доходит до установки быстро. После неудачи — не чаще раза в час.
|
|
func licenseNeedsRenew(rc *RuntimeConfig) bool {
|
|
s := rc.Snapshot().License
|
|
lic, err := verifyLicenseToken(s.Key)
|
|
if err != nil || lic.InstallID == "" {
|
|
return false
|
|
}
|
|
if !s.LastOK && !s.LastAttempt.IsZero() && time.Since(s.LastAttempt) < time.Hour && time.Since(s.LastSync) < 23*time.Hour {
|
|
return false
|
|
}
|
|
return time.Since(s.LastSync) >= 15*time.Minute
|
|
}
|
|
|
|
// licenseLoop — раз в 5 минут предупреждает о скором окончании лицензии и проверяет, пора ли продлить
|
|
// ключ (продление — раз в сутки, при сбое — повтор через час).
|
|
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 = 5 * time.Minute
|
|
licenseExpiryWarn(s.rc, time.Now())
|
|
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()
|
|
}
|
|
}
|