Files
Bridge-and-Join-s/internal/lkgateway/licenseclient.go
T

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()
}
}