Files
Bridge-and-Join-s/cmd/bj-license-server/store.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

461 lines
14 KiB
Go

package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"math/big"
"net"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
// Признаки лицензии, которые понимает bj (internal/lkgateway/licensecheck.go).
var knownFeatures = []struct{ Key, Title string }{
{"updates", "Обновления"},
{"web-cabinet", "Личный кабинет клиента (bj-lk)"},
{"quik-mnp", "QUIK: неторговые поручения (M2M)"},
{"quik-export", "QUIK: экспорт биржевой информации"},
{"npr", "Риск-монитор НПР / УДС"},
}
var knownPlans = []struct{ Key, Title string }{
{"enterprise", "Enterprise"},
{"pro", "Pro"},
{"free", "Free"},
}
const maxEvents = 20000
var (
loginRe = regexp.MustCompile(`^[A-Za-z0-9_.\-]{2,40}$`)
uuidRe = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
innRe = regexp.MustCompile(`^(\d{10}|\d{12})$`)
)
type User struct {
Login string `json:"login"`
Name string `json:"name,omitempty"`
PassHash string `json:"pass_hash"`
Disabled bool `json:"disabled,omitempty"`
CreatedAt time.Time `json:"created_at"`
LastLogin time.Time `json:"last_login,omitempty"`
// Второй фактор (TOTP): секрет base32 и последний принятый шаг — один код не принимается дважды.
TOTPSecret string `json:"totp_secret,omitempty"`
TOTPLast int64 `json:"totp_last,omitempty"`
HasTOTP bool `json:"-"` // для шаблонов: секрет в представления не отдаётся
Email string `json:"email,omitempty"` // учётная запись в «ЗЕТИТ Ключ» (zetitkey.go)
}
type Tenant struct {
ID string `json:"id"`
Name string `json:"name"`
INN string `json:"inn,omitempty"`
Contact string `json:"contact,omitempty"`
Note string `json:"note,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type License struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
Plan string `json:"plan"`
Features []string `json:"features,omitempty"`
MaxNodes int `json:"max_nodes"`
ContractUntil time.Time `json:"contract_until"`
Code string `json:"code"`
Note string `json:"note,omitempty"`
RevokedAt time.Time `json:"revoked_at,omitempty"`
RevokeReason string `json:"revoke_reason,omitempty"`
CreatedAt time.Time `json:"created_at"`
CreatedBy string `json:"created_by,omitempty"`
}
func (l License) Revoked() bool { return !l.RevokedAt.IsZero() }
func (l License) ContractExpired(now time.Time) bool {
return !l.ContractUntil.IsZero() && now.After(l.ContractUntil)
}
func (l License) HasFeature(f string) bool {
for _, x := range l.Features {
if x == f {
return true
}
}
return false
}
type Install struct {
InstallID string `json:"install_id"`
LicenseID string `json:"license_id"`
Machine string `json:"machine,omitempty"`
Hostname string `json:"hostname,omitempty"`
Version string `json:"version,omitempty"`
IP string `json:"ip,omitempty"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
TokenExpires time.Time `json:"token_expires"`
Blocked bool `json:"blocked,omitempty"`
BlockReason string `json:"block_reason,omitempty"`
}
type Event struct {
At time.Time `json:"at"`
Kind string `json:"kind"`
LicenseID string `json:"license_id,omitempty"`
InstallID string `json:"install_id,omitempty"`
Actor string `json:"actor,omitempty"`
IP string `json:"ip,omitempty"`
Text string `json:"text"`
}
// TrustedIP — адрес установки, которая успешно активировала или продлила лицензию. fail2ban
// не блокирует такие адреса за ошибки API (jail bj-license-api, ignorecommand по trusted-ips.txt);
// на вход в кабинет доверие не распространяется.
type TrustedIP struct {
IP string `json:"ip"`
LicenseID string `json:"license_id"`
InstallID string `json:"install_id"`
Hostname string `json:"hostname,omitempty"`
AddedAt time.Time `json:"added_at"`
LastSeen time.Time `json:"last_seen"`
}
type State struct {
Users []*User `json:"users"`
Tenants []*Tenant `json:"tenants"`
Licenses []*License `json:"licenses"`
Installs []*Install `json:"installs"`
Events []*Event `json:"events"`
Trusted []*TrustedIP `json:"trusted,omitempty"`
// Защита (раздел «Защита»): белый список администраторов и желаемый режим.
AllowIPs []*AllowIP `json:"allow_ips,omitempty"`
Firewall FirewallState `json:"firewall"`
// Доступ к кабинету (access.go): свой адрес вместо /admin и вход только из белого списка.
AdminPath string `json:"admin_path,omitempty"`
AdminAllowOnly bool `json:"admin_allow_only,omitempty"`
// Второй фактор через «ЗЕТИТ Ключ» (zetitkey.go).
ZetitKey ZetitKeyConf `json:"zetit_key,omitempty"`
}
// trustedFile — список доверенных IP установок для fail2ban и режима обороны.
const trustedFile = "trusted-ips.txt"
type store struct {
mu sync.RWMutex
path string
s State
}
func openStore(path string) (*store, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, err
}
st := &store{path: path}
b, err := os.ReadFile(path)
switch {
case errors.Is(err, os.ErrNotExist):
return st, nil
case err != nil:
return nil, err
}
if err := json.Unmarshal(b, &st.s); err != nil {
return nil, fmt.Errorf("разбор %s: %w", path, err)
}
if err := st.writeTrustedLocked(); err != nil {
log.Printf("доверенные IP: %v", err)
}
return st, nil
}
// trustedPath — список доверенных IP для fail2ban, рядом с файлом состояния.
func (st *store) trustedPath() string {
return filepath.Join(filepath.Dir(st.path), trustedFile)
}
// writeTrustedLocked — переписывает trusted-ips.txt (по адресу в строке) и файлы защиты; вызывать под блокировкой.
func (st *store) writeTrustedLocked() error {
body := "# Доверенные IP установок bj (успешная активация или продление лицензии). Файл пишет bj-license-server.\n"
for _, ip := range st.s.trustedIPs() {
body += ip + "\n"
}
if err := writeIfChanged(st.trustedPath(), []byte(body), 0o600); err != nil {
return err
}
return st.writeFirewallLocked()
}
func (st *store) saveLocked() error {
b, err := json.MarshalIndent(&st.s, "", " ")
if err != nil {
return err
}
tmp := st.path + ".tmp"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return err
}
if _, err := f.Write(b); err != nil {
f.Close()
return err
}
if err := f.Sync(); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
if err := os.Rename(tmp, st.path); err != nil {
return err
}
if err := st.writeTrustedLocked(); err != nil {
log.Printf("доверенные IP: %v", err)
}
return nil
}
// write — изменение состояния под блокировкой; сохраняется, если fn не вернула ошибку.
func (st *store) write(fn func(s *State) error) error {
st.mu.Lock()
defer st.mu.Unlock()
if err := fn(&st.s); err != nil {
return err
}
return st.saveLocked()
}
func (st *store) read(fn func(s *State)) {
st.mu.RLock()
defer st.mu.RUnlock()
fn(&st.s)
}
// backupLoop — раз в сутки копия state.json рядом (state.json.YYYYMMDD), хранится 30 копий.
func (st *store) backupLoop() {
for {
st.mu.RLock()
b, err := json.Marshal(&st.s)
st.mu.RUnlock()
if err == nil {
name := st.path + "." + time.Now().Format("20060102")
if _, e := os.Stat(name); errors.Is(e, os.ErrNotExist) {
if e := os.WriteFile(name, b, 0o600); e != nil {
log.Printf("резервная копия: %v", e)
}
}
old, _ := filepath.Glob(st.path + ".2*")
sort.Strings(old)
for len(old) > 30 {
_ = os.Remove(old[0])
old = old[1:]
}
}
time.Sleep(6 * time.Hour)
}
}
// ---- выборки и изменения (вызывать внутри read/write) ----
// trustIP — адрес установки в доверенные после успешной активации или продления.
// Возвращает true, если для этой установки адрес новый.
func (s *State) trustIP(inst *Install, ip string, now time.Time) bool {
p := net.ParseIP(strings.TrimSpace(ip))
if p == nil || p.IsLoopback() || p.IsUnspecified() {
return false
}
ip = p.String()
for _, t := range s.Trusted {
if t.IP == ip && t.InstallID == inst.InstallID {
t.LicenseID, t.Hostname, t.LastSeen = inst.LicenseID, inst.Hostname, now
return false
}
}
s.Trusted = append(s.Trusted, &TrustedIP{IP: ip, LicenseID: inst.LicenseID, InstallID: inst.InstallID,
Hostname: inst.Hostname, AddedAt: now, LastSeen: now})
return true
}
// untrust — убирает доверенные адреса, подходящие под условие; возвращает убранные.
func (s *State) untrust(match func(t *TrustedIP) bool) []*TrustedIP {
var keep, gone []*TrustedIP
for _, t := range s.Trusted {
if match(t) {
gone = append(gone, t)
} else {
keep = append(keep, t)
}
}
s.Trusted = keep
return gone
}
// trustedIPs — адреса для fail2ban: без отозванных лицензий и заблокированных установок.
func (s *State) trustedIPs() []string {
seen := map[string]bool{}
var out []string
for _, t := range s.Trusted {
if l := s.license(t.LicenseID); l == nil || l.Revoked() {
continue
}
if i := s.install(t.InstallID); i == nil || i.Blocked {
continue
}
if !seen[t.IP] {
seen[t.IP] = true
out = append(out, t.IP)
}
}
sort.Strings(out)
return out
}
func (s *State) user(login string) *User {
for _, u := range s.Users {
if strings.EqualFold(u.Login, login) {
return u
}
}
return nil
}
func (s *State) tenant(id string) *Tenant {
for _, t := range s.Tenants {
if t.ID == id {
return t
}
}
return nil
}
func (s *State) license(id string) *License {
for _, l := range s.Licenses {
if l.ID == id {
return l
}
}
return nil
}
func (s *State) licenseByCode(code string) *License {
for _, l := range s.Licenses {
if l.Code == code {
return l
}
}
return nil
}
func (s *State) install(id string) *Install {
for _, i := range s.Installs {
if strings.EqualFold(i.InstallID, id) {
return i
}
}
return nil
}
func (s *State) installsOf(licenseID string) []*Install {
var out []*Install
for _, i := range s.Installs {
if i.LicenseID == licenseID {
out = append(out, i)
}
}
sort.Slice(out, func(a, b int) bool { return out[a].LastSeen.After(out[b].LastSeen) })
return out
}
func (s *State) activeInstalls(licenseID string) int {
n := 0
for _, i := range s.Installs {
if i.LicenseID == licenseID && !i.Blocked {
n++
}
}
return n
}
func (s *State) addEvent(kind, licenseID, installID, actor, ip, text string) {
s.Events = append(s.Events, &Event{At: time.Now(), Kind: kind, LicenseID: licenseID, InstallID: installID, Actor: actor, IP: ip, Text: text})
if len(s.Events) > maxEvents {
s.Events = append([]*Event(nil), s.Events[len(s.Events)-maxEvents:]...)
}
}
func (s *State) setUserPassword(login, name, password, actor string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
return err
}
u := s.user(login)
if u == nil {
u = &User{Login: login, Name: name, CreatedAt: time.Now()}
s.Users = append(s.Users, u)
s.addEvent("user_add", "", "", actor, "", "добавлен администратор "+login)
} else {
s.addEvent("user_password", "", "", actor, "", "сменён пароль администратора "+login)
}
if name != "" {
u.Name = name
}
u.PassHash = string(hash)
return nil
}
// ---- идентификаторы ----
func newID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
b[6] = b[6]&0x0f | 0x40
b[8] = b[8]&0x3f | 0x80
h := hex.EncodeToString(b)
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:]
}
const codeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // без 0/O и 1/I
func randomFrom(alphabet string, n int) string {
var sb strings.Builder
max := big.NewInt(int64(len(alphabet)))
for i := 0; i < n; i++ {
k, _ := rand.Int(rand.Reader, max)
sb.WriteByte(alphabet[k.Int64()])
}
return sb.String()
}
// newActivationCode — BJ-XXXX-XXXX-XXXX (60 бит случайности).
func newActivationCode() string {
c := randomFrom(codeAlphabet, 12)
return "BJ-" + c[0:4] + "-" + c[4:8] + "-" + c[8:12]
}
// normalizeCode — код в каноническом виде: верхний регистр, дефисы по местам.
func normalizeCode(s string) string {
s = strings.ToUpper(strings.NewReplacer("-", "", " ", "").Replace(strings.TrimSpace(s)))
if len(s) == 14 && strings.HasPrefix(s, "BJ") {
s = s[2:]
}
if len(s) != 12 {
return ""
}
return "BJ-" + s[0:4] + "-" + s[4:8] + "-" + s[8:12]
}
func randomPassword(n int) string {
return randomFrom("abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789", n)
}