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) }