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 }