feat(license): сервер лицензий lic.zetit.ru — кабинет, онлайн-активация и защита

- bj-license-server: активация по коду и продление токенов (API /v1), кабинет
  администратора (клиенты, лицензии, установки, журнал, администраторы)
- защита: доверенные IP установок, jail fail2ban api/admin, раздел «Защита»
  с режимом обороны, белым списком и сроками блокировок (root-помощник fw-apply)
- доступ к кабинету: свой адрес вместо /admin, вход только из белого списка,
  второй фактор — TOTP или подтверждение в «ЗЕТИТ Ключ» (push/код)
- deploy/license: unit-файлы, fail2ban, path/timer помощника; контракт API в docs

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-09-15 19:41:11 +03:00
parent 586ffb3a31
commit 86aa452ed6
19 changed files with 4659 additions and 99 deletions
+291
View File
@@ -0,0 +1,291 @@
package main
// API для установок bj: активация по коду и продление токена (docs/license-server-api.md).
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license"
)
const (
tokenTTL = 60 * 24 * time.Hour
renewAfterSec = 86400
)
type apiRequest struct {
Code string `json:"code"`
Token string `json:"token"`
InstallID string `json:"install_id"`
Machine string `json:"machine"`
Hostname string `json:"hostname"`
Version string `json:"version"`
}
type apiResponse 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"`
RenewAfterSec int `json:"renew_after_sec"`
}
type apiError struct {
Status int `json:"-"`
Code string `json:"error"`
Message string `json:"message"`
}
func apiErr(status int, code, msg string) *apiError {
return &apiError{Status: status, Code: code, Message: msg}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func clip(s string, n int) string {
s = strings.TrimSpace(s)
if r := []rune(s); len(r) > n {
return string(r[:n])
}
return s
}
func (srv *server) decodeAPI(w http.ResponseWriter, r *http.Request) (*apiRequest, bool) {
if !srv.apiRate.allow(clientIP(r)) {
writeJSON(w, http.StatusTooManyRequests, apiErr(429, "rate_limited", "слишком много запросов, повторите позже"))
return nil, false
}
var in apiRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, apiErr(400, "bad_request", "тело запроса — JSON по docs/license-server-api.md"))
return nil, false
}
in.InstallID = strings.ToLower(strings.TrimSpace(in.InstallID))
if !uuidRe.MatchString(in.InstallID) {
writeJSON(w, http.StatusBadRequest, apiErr(400, "bad_request", "install_id: ожидается UUID"))
return nil, false
}
in.Machine, in.Hostname, in.Version = clip(in.Machine, 128), clip(in.Hostname, 128), clip(in.Version, 40)
return &in, true
}
// issueToken — подписанная лицензия под установку: срок токена 60 дней, не дольше договора.
func (srv *server) issueToken(s *State, lic *License, installID string, now time.Time) (string, time.Time, error) {
exp := now.Add(tokenTTL)
if !lic.ContractUntil.IsZero() && lic.ContractUntil.Before(exp) {
exp = lic.ContractUntil
}
tenant := ""
if t := s.tenant(lic.TenantID); t != nil {
tenant = t.Name
}
l := &license.License{
Schema: license.CurrentSchema, ID: lic.ID, Tenant: tenant, Product: "bj-server",
Plan: license.Plan(lic.Plan), IssuedAt: now.UTC(), ExpiresAt: exp.UTC(),
Features: lic.Features, MaxNodes: lic.MaxNodes, InstallID: installID, ContractUntil: lic.ContractUntil.UTC(),
}
tok, err := license.Sign(l, srv.priv, srv.keyID)
if err != nil {
return "", time.Time{}, err
}
return tok.Encode(), exp, nil
}
func (srv *server) answer(s *State, lic *License, token string, exp time.Time) *apiResponse {
tenant := ""
if t := s.tenant(lic.TenantID); t != nil {
tenant = t.Name
}
return &apiResponse{Token: token, LicenseID: lic.ID, Tenant: tenant, ExpiresAt: exp.UTC(), ContractUntil: lic.ContractUntil.UTC(), RenewAfterSec: renewAfterSec}
}
// touchInstall — сведения об установке из запроса; смена machine-id — предупреждение в журнал.
func touchInstall(s *State, inst *Install, in *apiRequest, ip string, now time.Time) {
if in.Machine != "" && inst.Machine != "" && in.Machine != inst.Machine {
s.addEvent("machine_changed", inst.LicenseID, inst.InstallID, "api", ip,
fmt.Sprintf("сменился machine-id установки (%s → %s): возможно, копия сервера", short(inst.Machine), short(in.Machine)))
}
if in.Machine != "" {
inst.Machine = in.Machine
}
if in.Hostname != "" {
inst.Hostname = in.Hostname
}
if in.Version != "" {
inst.Version = in.Version
}
inst.IP, inst.LastSeen = ip, now
}
// trustInstallIP — после успешной активации или продления адрес установки попадает в доверенные fail2ban.
func trustInstallIP(s *State, inst *Install, ip string, now time.Time) {
if s.trustIP(inst, ip, now) {
s.addEvent("trust_ip", inst.LicenseID, inst.InstallID, "api", ip, "IP "+ip+" добавлен в доверенные fail2ban")
}
}
func hostNote(in *apiRequest) string {
if in.Hostname == "" {
return ""
}
return " (" + in.Hostname + ")"
}
// POST /v1/activate
func (srv *server) apiActivate(w http.ResponseWriter, r *http.Request) {
in, ok := srv.decodeAPI(w, r)
if !ok {
return
}
ip := clientIP(r)
code := normalizeCode(in.Code)
var resp *apiResponse
var fail *apiError
err := srv.st.write(func(s *State) error {
now := time.Now()
var lic *License
if code != "" {
lic = s.licenseByCode(code)
}
if lic == nil {
fail = apiErr(http.StatusNotFound, "unknown_code", "код активации не найден")
log.Printf("api denied: unknown_code ip=%s", ip) // строку ловит fail2ban
s.addEvent("activate_denied", "", in.InstallID, "api", ip, "неизвестный код активации"+hostNote(in))
return nil
}
inst := s.install(in.InstallID)
switch {
case lic.Revoked():
fail = apiErr(http.StatusForbidden, "revoked", "лицензия отозвана")
case lic.ContractExpired(now):
fail = apiErr(http.StatusForbidden, "contract_expired", "срок договора истёк "+lic.ContractUntil.In(msk).Format("02.01.2006"))
case inst != nil && inst.Blocked:
fail = apiErr(http.StatusForbidden, "install_blocked", "установка заблокирована: "+inst.BlockReason)
case (inst == nil || inst.LicenseID != lic.ID) && lic.MaxNodes > 0 && s.activeInstalls(lic.ID) >= lic.MaxNodes:
fail = apiErr(http.StatusConflict, "max_nodes", fmt.Sprintf("исчерпан лимит установок по лицензии (%d)", lic.MaxNodes))
}
if fail != nil {
s.addEvent("activate_denied", lic.ID, in.InstallID, "api", ip, fail.Message+hostNote(in))
return nil
}
text := "активирована новая установка" + hostNote(in)
switch {
case inst == nil:
inst = &Install{InstallID: in.InstallID, FirstSeen: now}
s.Installs = append(s.Installs, inst)
case inst.LicenseID != lic.ID:
text = "установка переведена на эту лицензию с " + short(inst.LicenseID) + hostNote(in)
default:
text = "повторная активация установки" + hostNote(in)
}
inst.LicenseID = lic.ID
touchInstall(s, inst, in, ip, now)
trustInstallIP(s, inst, ip, now)
token, exp, err := srv.issueToken(s, lic, inst.InstallID, now)
if err != nil {
return err
}
inst.TokenExpires = exp
s.addEvent("activate", lic.ID, inst.InstallID, "api", ip, text+", токен до "+exp.In(msk).Format("02.01.2006"))
resp = srv.answer(s, lic, token, exp)
return nil
})
srv.finishAPI(w, resp, fail, err)
}
// POST /v1/renew
func (srv *server) apiRenew(w http.ResponseWriter, r *http.Request) {
in, ok := srv.decodeAPI(w, r)
if !ok {
return
}
ip := clientIP(r)
tok, err := license.DecodeToken(in.Token)
if err != nil {
writeJSON(w, http.StatusUnauthorized, apiErr(401, "bad_token", "токен не разобран"))
return
}
l, err := license.Verify(tok, srv.pub)
if err != nil {
log.Printf("api denied: bad_token ip=%s", ip) // строку ловит fail2ban
writeJSON(w, http.StatusUnauthorized, apiErr(401, "bad_token", "подпись токена недействительна"))
return
}
if l.InstallID == "" || !strings.EqualFold(l.InstallID, in.InstallID) {
writeJSON(w, http.StatusForbidden, apiErr(403, "install_mismatch", "токен выдан другой установке"))
return
}
var resp *apiResponse
var fail *apiError
err = srv.st.write(func(s *State) error {
now := time.Now()
lic := s.license(l.ID)
if lic == nil {
fail = apiErr(http.StatusUnauthorized, "bad_token", "лицензия из токена не найдена")
return nil
}
inst := s.install(in.InstallID)
switch {
case inst == nil || inst.LicenseID != lic.ID:
fail = apiErr(http.StatusNotFound, "unknown_install", "установка не зарегистрирована по этой лицензии — активируйте кодом")
case lic.Revoked():
fail = apiErr(http.StatusForbidden, "revoked", "лицензия отозвана")
case lic.ContractExpired(now):
fail = apiErr(http.StatusForbidden, "contract_expired", "срок договора истёк "+lic.ContractUntil.In(msk).Format("02.01.2006"))
case inst.Blocked:
fail = apiErr(http.StatusForbidden, "install_blocked", "установка заблокирована: "+inst.BlockReason)
}
if fail != nil {
s.addEvent("renew_denied", lic.ID, in.InstallID, "api", ip, fail.Message+hostNote(in))
return nil
}
touchInstall(s, inst, in, ip, now)
trustInstallIP(s, inst, ip, now)
token, exp, err := srv.issueToken(s, lic, inst.InstallID, now)
if err != nil {
return err
}
inst.TokenExpires = exp
s.addEvent("renew", lic.ID, inst.InstallID, "api", ip, fmt.Sprintf("продление%s, версия %s, токен до %s", hostNote(in), pickStr(in.Version, "—"), exp.In(msk).Format("02.01.2006")))
resp = srv.answer(s, lic, token, exp)
return nil
})
srv.finishAPI(w, resp, fail, err)
}
func (srv *server) finishAPI(w http.ResponseWriter, resp *apiResponse, fail *apiError, err error) {
switch {
case err != nil:
writeJSON(w, http.StatusInternalServerError, apiErr(500, "internal", "внутренняя ошибка сервера лицензий"))
case fail != nil:
writeJSON(w, fail.Status, fail)
default:
writeJSON(w, http.StatusOK, resp)
}
}
func pickStr(v, def string) string {
if strings.TrimSpace(v) == "" {
return def
}
return v
}
func short(id string) string {
if len(id) > 8 {
return id[:8]
}
return id
}