Files
Bridge-and-Join-s/cmd/bj-license-server/main.go
T
zuevav 86aa452ed6 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>
2026-09-15 19:41:11 +03:00

145 lines
5.7 KiB
Go

// Command bj-license-server — сервер лицензирования Bridge&Join (https://lic.zetit.ru).
//
// Выдаёт и продлевает лицензии установкам bj по коду активации (контракт —
// docs/license-server-api.md) и даёт веб-кабинет администратора: клиенты, лицензии,
// установки, журнал, администраторы. Вход в кабинет — логин и пароль.
//
// Хранилище — один JSON-файл с атомарной записью и ежедневными копиями рядом: клиентов
// десятки, записей немного, отдельная СУБД не нужна.
//
// 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 (
"bufio"
"crypto/ed25519"
"flag"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license"
)
func main() {
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)
}
}