86aa452ed6
- 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>
145 lines
5.7 KiB
Go
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)
|
|
}
|
|
}
|