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