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>
461 lines
14 KiB
Go
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)
|
|
}
|