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