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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user