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
+129 -81
View File
@@ -1,96 +1,144 @@
// Command bj-license-server — онлайн-сервис учёта и отзыва лицензий.
// Command bj-license-server — сервер лицензирования Bridge&Join (https://lic.zetit.ru).
//
// Базовая модель лицензирования офлайновая: bj-server проверяет подпись и
// срок сам. Этот сервер нужен для:
// - реестра выданных лицензий (учёт);
// - ОТЗЫВА (revocation) до окончания срока;
// - проверки клиентом «не отозвана ли» (опциональный online-чек).
// Выдаёт и продлевает лицензии установкам bj по коду активации (контракт —
// docs/license-server-api.md) и даёт веб-кабинет администратора: клиенты, лицензии,
// установки, журнал, администраторы. Вход в кабинет — логин и пароль.
//
// Хранилище — JSON-файл со списком отозванных ID (для каркаса; в проде —
// PostgreSQL). API:
// Хранилище — один JSON-файл с атомарной записью и ежедневными копиями рядом: клиентов
// десятки, записей немного, отдельная СУБД не нужна.
//
// GET /v1/check?id=<license-id> → {"revoked":bool}
// GET /healthz
//
// Управление отзывом — правкой файла revoked.json (или будущим admin API).
// bj-license-server serve -addr 127.0.0.1:8091 -data /var/lib/bj-license/state.json -key /etc/bj-license/license.priv
// bj-license-server user-set -data /var/lib/bj-license/state.json -login admin
// (пароль из stdin; пусто — сгенерировать; сервер должен быть остановлен)
// bj-license-server fw-apply -dir /var/lib/bj-license [-off] (root, из systemd: защита, см. firewall.go)
package main
import (
"encoding/json"
"bufio"
"crypto/ed25519"
"flag"
"fmt"
"log"
"net/http"
"os"
"sync"
"strings"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license"
)
type store struct {
mu sync.RWMutex
path string
revoked map[string]bool
}
func newStore(path string) *store {
s := &store{path: path, revoked: map[string]bool{}}
s.load()
return s
}
func (s *store) load() {
s.mu.Lock()
defer s.mu.Unlock()
b, err := os.ReadFile(s.path)
if err != nil {
return // файла нет — пустой список
}
var ids []string
if err := json.Unmarshal(b, &ids); err != nil {
log.Printf("license-server: разбор %s: %v", s.path, err)
return
}
s.revoked = map[string]bool{}
for _, id := range ids {
s.revoked[id] = true
}
log.Printf("license-server: загружено отозванных лицензий: %d", len(s.revoked))
}
func (s *store) isRevoked(id string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.revoked[id]
}
func main() {
addr := flag.String("addr", ":8091", "адрес прослушивания")
file := flag.String("revoked", "./revoked.json", "JSON-файл со списком отозванных license ID")
flag.Parse()
st := newStore(*file)
// Перечитываем файл отзывов раз в минуту (горячее применение).
go func() {
t := time.NewTicker(time.Minute)
defer t.Stop()
for range t.C {
st.load()
}
}()
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
mux.HandleFunc("/v1/check", func(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
if id == "" {
http.Error(w, "id required", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]bool{"revoked": st.isRevoked(id)})
})
log.Printf("license-server: слушаю %s, отзывы из %s", *addr, *file)
srv := &http.Server{Addr: *addr, Handler: mux, ReadHeaderTimeout: 10 * time.Second}
log.Fatal(srv.ListenAndServe())
if len(os.Args) < 2 {
usage()
}
switch os.Args[1] {
case "serve":
serve(os.Args[2:])
case "user-set":
userSet(os.Args[2:])
case "fw-apply":
fwApply(os.Args[2:])
case "fw-ignore":
fwIgnore(os.Args[2:])
case "admin-info":
adminInfo(os.Args[2:])
case "admin-config":
adminConfig(os.Args[2:])
default:
usage()
}
}
func usage() {
fmt.Fprintln(os.Stderr, "bj-license-server serve [-addr 127.0.0.1:8091] [-data state.json] [-key license.priv] [-keyid main]")
fmt.Fprintln(os.Stderr, "bj-license-server user-set -login admin [-name Имя] [-data state.json] (пароль из stdin)")
fmt.Fprintln(os.Stderr, "bj-license-server fw-apply [-dir /var/lib/bj-license] [-off] (root: применить защиту; -off — аварийно выключить оборону)")
fmt.Fprintln(os.Stderr, "bj-license-server fw-ignore [-dir /var/lib/bj-license] <ip> api|admin (ignorecommand fail2ban)")
fmt.Fprintln(os.Stderr, "bj-license-server admin-info [-data state.json] (адрес кабинета, белый список, второй фактор)")
fmt.Fprintln(os.Stderr, "bj-license-server admin-config [-data state.json] [-allow IP=комментарий ...] [-path свой|random|admin] [-allow-only on|off] [-reset-2fa логин] (сервер должен быть остановлен)")
os.Exit(2)
}
func serve(args []string) {
fs := flag.NewFlagSet("serve", flag.ExitOnError)
addr := fs.String("addr", "127.0.0.1:8091", "адрес прослушивания (снаружи — через nginx)")
data := fs.String("data", "/var/lib/bj-license/state.json", "файл состояния")
keyPath := fs.String("key", "/etc/bj-license/license.priv", "закрытый ключ подписи лицензий (base64 seed Ed25519)")
keyID := fs.String("keyid", "main", "идентификатор ключа в токене")
_ = fs.Parse(args)
priv, err := license.LoadPrivateKey(*keyPath)
if err != nil {
log.Fatalf("ключ подписи: %v", err)
}
st, err := openStore(*data)
if err != nil {
log.Fatalf("хранилище: %v", err)
}
srv := &server{
st: st,
priv: priv,
pub: priv.Public().(ed25519.PublicKey),
keyID: *keyID,
sessions: newSessions(),
fails: newLimiter(8, 15*time.Minute),
apiRate: newLimiter(60, time.Minute),
}
st.read(func(s *State) { srv.fails.set(loginLimitOf(s)) })
if err := srv.loadTemplates(); err != nil {
log.Fatalf("шаблоны: %v", err)
}
mux := http.NewServeMux()
srv.routes(mux)
go st.backupLoop()
st.read(func(s *State) {
log.Printf("bj-license-server: слушаю %s; клиентов %d, лицензий %d, установок %d, администраторов %d",
*addr, len(s.Tenants), len(s.Licenses), len(s.Installs), len(s.Users))
if len(s.Users) == 0 {
log.Printf("bj-license-server: администраторов нет — создайте: bj-license-server user-set -login admin")
}
})
hs := &http.Server{
Addr: *addr,
Handler: securityHeaders(srv.adminGate(mux)),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
}
log.Fatal(hs.ListenAndServe())
}
func userSet(args []string) {
fs := flag.NewFlagSet("user-set", flag.ExitOnError)
data := fs.String("data", "/var/lib/bj-license/state.json", "файл состояния")
login := fs.String("login", "", "логин администратора")
name := fs.String("name", "", "имя (для журнала)")
_ = fs.Parse(args)
if !loginRe.MatchString(*login) {
log.Fatal("логин: латиница, цифры, точка, дефис, подчёркивание")
}
st, err := openStore(*data)
if err != nil {
log.Fatal(err)
}
pw, _ := bufio.NewReader(os.Stdin).ReadString('\n')
pw = strings.TrimRight(pw, "\r\n")
generated := false
if pw == "" {
pw, generated = randomPassword(16), true
}
if len(pw) < 10 {
log.Fatal("пароль короче 10 символов")
}
if err := st.write(func(s *State) error {
return s.setUserPassword(*login, *name, pw, "cli")
}); err != nil {
log.Fatal(err)
}
if generated {
fmt.Printf("Администратор %s: пароль %s\n", *login, pw)
} else {
fmt.Printf("Администратор %s: пароль задан\n", *login)
}
}