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>
This commit is contained in:
zuevav
2026-09-15 19:41:11 +03:00
parent 586ffb3a31
commit 86aa452ed6
19 changed files with 4659 additions and 99 deletions
+512
View File
@@ -0,0 +1,512 @@
package main
// Доступ к кабинету: свой адрес вместо /admin, вход только из белого списка администраторов и
// второй фактор (TOTP, RFC 6238).
//
// Маршруты кабинета внутри остаются /admin/…: adminGate переписывает внешний адрес (State.AdminPath)
// во внутренний, на прямые обращения к /admin при своём адресе отвечает 404 и подменяет Location в
// ответах. Ссылки в шаблонах строятся функцией {{adm}}. При AdminAllowOnly кабинет и страница входа
// видны только адресам белого списка администраторов, остальным — 404. API /v1 не затрагивается.
//
// По SSH: bj-license-server admin-info — адрес и настройки доступа; fw-apply -off — аварийно открыть
// кабинет всем и выключить оборону; admin-config — изменить доступ при остановленном сервере.
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"encoding/base32"
"encoding/binary"
"flag"
"fmt"
"html/template"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"rsc.io/qr"
)
const (
adminDefault = "/admin"
adminOpenFile = "admin-open" // создаёт fw-apply -off: кабинет открыт всем до повторного включения ограничения
totpIssuer = "BJ License (lic.zetit.ru)"
publicHost = "lic.zetit.ru"
)
var adminSlugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{7,63}$`)
// adminBase — внешний адрес кабинета: "/admin" или "/<AdminPath>".
func (srv *server) adminBase() string {
base := adminDefault
srv.st.read(func(s *State) {
if s.AdminPath != "" {
base = "/" + s.AdminPath
}
})
return base
}
func (srv *server) adminOpen() bool {
_, err := os.Stat(filepath.Join(srv.dataDir(), adminOpenFile))
return err == nil
}
// adminIPBlocked — кабинет ограничен белым списком, а адрес в него не входит.
func (srv *server) adminIPBlocked(ip string) bool {
blocked := false
srv.st.read(func(s *State) { blocked = s.AdminAllowOnly && !s.allowCovers(ip, false) })
return blocked && !srv.adminOpen()
}
// locRewriter — Location: /admin/… в ответах кабинета заменяется текущим адресом кабинета.
type locRewriter struct {
http.ResponseWriter
srv *server
done bool
}
func (lw *locRewriter) fix() {
if lw.done {
return
}
lw.done = true
h := lw.Header()
if l := h.Get("Location"); l == adminDefault || strings.HasPrefix(l, adminDefault+"/") || strings.HasPrefix(l, adminDefault+"?") {
h.Set("Location", lw.srv.adminBase()+strings.TrimPrefix(l, adminDefault))
}
}
func (lw *locRewriter) WriteHeader(code int) {
lw.fix()
lw.ResponseWriter.WriteHeader(code)
}
func (lw *locRewriter) Write(b []byte) (int, error) {
lw.fix()
return lw.ResponseWriter.Write(b)
}
// adminGate — внешний адрес кабинета, 404 на /admin при своём адресе, ограничение белым списком.
func (srv *server) adminGate(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base, p := srv.adminBase(), r.URL.Path
under := func(prefix string) bool { return p == prefix || strings.HasPrefix(p, prefix+"/") }
switch {
case base != adminDefault && under(adminDefault):
http.NotFound(w, r)
return
case base != adminDefault && under(base):
r2 := r.Clone(r.Context())
r2.URL.Path = adminDefault + strings.TrimPrefix(p, base)
r2.URL.RawPath = ""
r = r2
case !under(adminDefault):
h.ServeHTTP(w, r) // API, healthz, главная
return
}
if srv.adminIPBlocked(clientIP(r)) {
http.NotFound(w, r)
return
}
h.ServeHTTP(&locRewriter{ResponseWriter: w, srv: srv}, r)
})
}
func (srv *server) setSessionCookie(w http.ResponseWriter, r *http.Request, token string) {
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: token, Path: srv.adminBase(), HttpOnly: true, Secure: isHTTPS(r),
SameSite: http.SameSiteStrictMode, MaxAge: int(sessionMax.Seconds())})
}
// ---- TOTP ----
var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)
func newTOTPSecret() string {
b := make([]byte, 20)
_, _ = rand.Read(b)
return b32.EncodeToString(b)
}
func totpAt(secret string, counter int64) (string, bool) {
key, err := b32.DecodeString(strings.ToUpper(secret))
if err != nil {
return "", false
}
var msg [8]byte
binary.BigEndian.PutUint64(msg[:], uint64(counter))
m := hmac.New(sha1.New, key)
m.Write(msg[:])
sum := m.Sum(nil)
off := sum[len(sum)-1] & 0x0f
v := (binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff) % 1000000
return fmt.Sprintf("%06d", v), true
}
// totpCheck — код текущего, предыдущего или следующего 30-секундного шага, ещё не использованный
// (шаг больше last). Возвращает шаг, чтобы запомнить его и не принять тот же код повторно.
func totpCheck(secret, code string, last int64, now time.Time) (int64, bool) {
code = strings.ReplaceAll(strings.TrimSpace(code), " ", "")
if len(code) != 6 {
return 0, false
}
step := now.Unix() / 30
for d := int64(-1); d <= 1; d++ {
c := step + d
if c <= last {
continue
}
if want, ok := totpAt(secret, c); ok && hmac.Equal([]byte(want), []byte(code)) {
return c, true
}
}
return 0, false
}
func totpURI(login, secret string) string {
return "otpauth://totp/" + url.PathEscape(totpIssuer+":"+login) + "?secret=" + secret +
"&issuer=" + url.QueryEscape(totpIssuer) + "&algorithm=SHA1&digits=6&period=30"
}
// qrSVG — QR-код строкой SVG (с полем в 4 модуля).
func qrSVG(text string) template.HTML {
c, err := qr.Encode(text, qr.M)
if err != nil {
return ""
}
n := c.Size + 8
var b strings.Builder
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" width="220" height="220" shape-rendering="crispEdges" role="img" aria-label="QR-код для приложения"><rect width="%d" height="%d" fill="#fff"/><path fill="#000" d="`, n, n, n, n)
for y := 0; y < c.Size; y++ {
for x := 0; x < c.Size; x++ {
if c.Black(x, y) {
fmt.Fprintf(&b, "M%d %dh1v1h-1z", x+4, y+4)
}
}
}
b.WriteString(`"/></svg>`)
return template.HTML(b.String())
}
func groupBy4(s string) string {
var parts []string
for i := 0; i < len(s); i += 4 {
parts = append(parts, s[i:min(i+4, len(s))])
}
return strings.Join(parts, " ")
}
// ---- страницы ----
type totpPageData struct {
Enabled bool
SecretGrouped string
URI template.URL
QR template.HTML
}
// meTOTPPage — GET /admin/me/2fa: подключение или отключение второго фактора.
func (srv *server) meTOTPPage(w http.ResponseWriter, r *http.Request, sess *session) {
var d totpPageData
srv.st.read(func(s *State) {
if u := s.user(sess.Login); u != nil {
d.Enabled = u.TOTPSecret != ""
}
})
if !d.Enabled {
srv.sessions.mu.Lock()
if sess.PendingTOTP == "" {
sess.PendingTOTP = newTOTPSecret()
}
secret := sess.PendingTOTP
srv.sessions.mu.Unlock()
uri := totpURI(sess.Login, secret)
d.SecretGrouped, d.URI, d.QR = groupBy4(secret), template.URL(uri), qrSVG(uri)
}
w.Header().Set("Cache-Control", "no-store")
srv.render(w, r, sess, "totp", "Второй фактор", "users", d)
}
func (srv *server) meTOTPEnable(w http.ResponseWriter, r *http.Request, sess *session) {
srv.sessions.mu.Lock()
secret := sess.PendingTOTP
srv.sessions.mu.Unlock()
if secret == "" {
back(w, r, "/admin/me/2fa", "", "обновите страницу и добавьте ключ в приложение заново")
return
}
step, ok := totpCheck(secret, r.PostFormValue("code"), 0, time.Now())
if !ok {
back(w, r, "/admin/me/2fa", "", "код не подошёл: проверьте, что время на телефоне точное, и введите текущий код")
return
}
err := srv.st.write(func(s *State) error {
u := s.user(sess.Login)
if u == nil {
return fmt.Errorf("администратор не найден")
}
u.TOTPSecret, u.TOTPLast = secret, step
s.addEvent("totp_on", "", "", sess.Login, clientIP(r), "подключён второй фактор входа")
return nil
})
if err != nil {
back(w, r, "/admin/me/2fa", "", err.Error())
return
}
srv.sessions.mu.Lock()
sess.PendingTOTP = ""
srv.sessions.mu.Unlock()
back(w, r, "/admin/me/2fa", "Второй фактор подключён: при входе теперь нужен код из приложения.", "")
}
func (srv *server) meTOTPDisable(w http.ResponseWriter, r *http.Request, sess *session) {
var bad string
err := srv.st.write(func(s *State) error {
u := s.user(sess.Login)
if u == nil || u.TOTPSecret == "" {
bad = "второй фактор не подключён"
return nil
}
if _, ok := totpCheck(u.TOTPSecret, r.PostFormValue("code"), 0, time.Now()); !ok {
bad = "код не подошёл"
return nil
}
u.TOTPSecret, u.TOTPLast = "", 0
s.addEvent("totp_off", "", "", sess.Login, clientIP(r), "второй фактор входа отключён")
return nil
})
if err != nil || bad != "" {
back(w, r, "/admin/me/2fa", "", pickStr(bad, fmt.Sprint(err)))
return
}
back(w, r, "/admin/me/2fa", "Второй фактор отключён.", "")
}
// userTOTPReset — POST /admin/users/{login}/2fa-reset: сброс второго фактора другого администратора.
func (srv *server) userTOTPReset(w http.ResponseWriter, r *http.Request, sess *session) {
login := r.PathValue("login")
if strings.EqualFold(login, sess.Login) {
back(w, r, "/admin/users", "", "свой второй фактор отключается на странице «Мой второй фактор» с кодом из приложения")
return
}
err := srv.st.write(func(s *State) error {
u := s.user(login)
if u == nil {
return fmt.Errorf("администратор не найден")
}
u.TOTPSecret, u.TOTPLast = "", 0
s.addEvent("totp_reset", "", "", sess.Login, clientIP(r), "сброшен второй фактор администратора "+u.Login)
return nil
})
if err != nil {
back(w, r, "/admin/users", "", err.Error())
return
}
srv.sessions.dropUser(login, "")
back(w, r, "/admin/users", "Второй фактор "+login+" сброшен, его сессии завершены. При следующем входе хватит пароля.", "")
}
// securityAccess — POST /admin/security/access: адрес кабинета и ограничение белым списком.
func (srv *server) securityAccess(w http.ResponseWriter, r *http.Request, sess *session) {
op, myIP := r.PostFormValue("op"), clientIP(r)
oldBase := srv.adminBase()
var bad, okMsg string
err := srv.st.write(func(s *State) error {
switch op {
case "path", "path-random", "path-default":
slug := strings.ToLower(strings.Trim(strings.TrimSpace(r.PostFormValue("path")), "/"))
switch {
case op == "path-random":
slug = "k" + randHex(12)
case op == "path-default" || slug == "admin":
slug = ""
case !adminSlugRe.MatchString(slug):
bad = "адрес: от 8 до 64 символов — латинские строчные буквы, цифры и дефис"
return nil
}
if slug == s.AdminPath {
bad = "адрес не изменился"
return nil
}
s.AdminPath = slug
shown := adminDefault
if slug != "" {
shown = "/" + slug
}
s.addEvent("admin_path", "", "", sess.Login, myIP, "адрес кабинета изменён на "+shown)
okMsg = "Новый адрес кабинета: https://" + publicHost + shown + "/ — сохраните его в закладки. Прежний адрес больше не открывается."
case "allow-only-on":
if n, ok := normalizeNet(myIP); ok && !s.allowCovers(myIP, false) {
s.AllowIPs = append(s.AllowIPs, &AllowIP{Net: n, Comment: "добавлен автоматически при ограничении кабинета", AddedBy: sess.Login, AddedAt: time.Now()})
s.addEvent("fw_allow_add", "", "", sess.Login, myIP, "IP "+n+" добавлен в белый список (ограничение кабинета)")
}
s.AdminAllowOnly = true
s.addEvent("admin_allow_only_on", "", "", sess.Login, myIP, "кабинет доступен только из белого списка администраторов")
_ = os.Remove(filepath.Join(srv.dataDir(), adminOpenFile))
okMsg = "Кабинет открывается только с адресов белого списка, остальным — «не найдено»."
case "allow-only-off":
s.AdminAllowOnly = false
s.addEvent("admin_allow_only_off", "", "", sess.Login, myIP, "ограничение кабинета белым списком снято")
okMsg = "Ограничение снято: страница входа доступна с любого адреса."
default:
bad = "неизвестная команда"
}
return nil
})
if err != nil || bad != "" {
back(w, r, "/admin/security", "", pickStr(bad, fmt.Sprint(err)))
return
}
if newBase := srv.adminBase(); newBase != oldBase {
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: oldBase, HttpOnly: true, Secure: isHTTPS(r), MaxAge: -1})
srv.setSessionCookie(w, r, sess.Token)
}
back(w, r, "/admin/security", okMsg, "")
}
// ---- консоль ----
type multiFlag []string
func (m *multiFlag) String() string { return strings.Join(*m, ",") }
func (m *multiFlag) Set(v string) error { *m = append(*m, v); return nil }
// adminConfig — `bj-license-server admin-config`: доступ к кабинету из консоли (сервер остановлен).
func adminConfig(args []string) {
fs := flag.NewFlagSet("admin-config", flag.ExitOnError)
data := fs.String("data", "/var/lib/bj-license/state.json", "файл состояния")
var allow multiFlag
fs.Var(&allow, "allow", "добавить в белый список администраторов: IP[=комментарий] (флаг можно повторять)")
path := fs.String("path", "", "адрес кабинета: свой (8–64 символа a-z, 0-9, дефис), random — сгенерировать, admin — стандартный")
only := fs.String("allow-only", "", "кабинет только из белого списка: on | off")
reset := fs.String("reset-2fa", "", "сбросить второй фактор администратора (логин)")
zkFlag := fs.String("zetit-key", "", "вход через ЗЕТИТ Ключ: off — выключить (аварийно)")
_ = fs.Parse(args)
st, err := openStore(*data)
if err != nil {
log.Fatal(err)
}
err = st.write(func(s *State) error {
now := time.Now()
for _, a := range allow {
ipPart, comment, _ := strings.Cut(a, "=")
n, ok := normalizeNet(ipPart)
if !ok {
return fmt.Errorf("неверный адрес %q", ipPart)
}
exists := false
for _, x := range s.AllowIPs {
exists = exists || x.Net == n
}
if !exists {
s.AllowIPs = append(s.AllowIPs, &AllowIP{Net: n, Comment: strings.TrimSpace(comment), AddedBy: "консоль", AddedAt: now})
s.addEvent("fw_allow_add", "", "", "консоль", "", "IP "+n+" добавлен в белый список")
}
}
switch p := strings.ToLower(strings.Trim(*path, "/")); {
case p == "":
case p == "admin":
s.AdminPath = ""
s.addEvent("admin_path", "", "", "консоль", "", "адрес кабинета: стандартный /admin")
case p == "random":
s.AdminPath = "k" + randHex(12)
s.addEvent("admin_path", "", "", "консоль", "", "адрес кабинета изменён")
case adminSlugRe.MatchString(p):
s.AdminPath = p
s.addEvent("admin_path", "", "", "консоль", "", "адрес кабинета изменён")
default:
return fmt.Errorf("адрес: 8–64 символа, латинские строчные буквы, цифры и дефис")
}
switch *only {
case "":
case "on":
if len(s.AllowIPs) == 0 {
return fmt.Errorf("белый список пуст — ограничение закроет кабинет всем")
}
s.AdminAllowOnly = true
s.addEvent("admin_allow_only_on", "", "", "консоль", "", "кабинет доступен только из белого списка администраторов")
case "off":
s.AdminAllowOnly = false
s.addEvent("admin_allow_only_off", "", "", "консоль", "", "ограничение кабинета белым списком снято")
default:
return fmt.Errorf("-allow-only: on или off")
}
switch *zkFlag {
case "":
case "off":
s.ZetitKey.Enabled = false
s.addEvent("zetit_key", "", "", "консоль", "", "вход через ЗЕТИТ Ключ выключен")
default:
return fmt.Errorf("-zetit-key: только off")
}
if *reset != "" {
u := s.user(*reset)
if u == nil {
return fmt.Errorf("администратор %s не найден", *reset)
}
u.TOTPSecret, u.TOTPLast = "", 0
s.addEvent("totp_reset", "", "", "консоль", "", "сброшен второй фактор администратора "+u.Login)
}
return nil
})
if err != nil {
log.Fatal(err)
}
if *only == "on" {
_ = os.Remove(filepath.Join(filepath.Dir(*data), adminOpenFile))
}
printAccess(st)
}
// adminInfo — `bj-license-server admin-info`: адрес и настройки доступа (только чтение).
func adminInfo(args []string) {
fs := flag.NewFlagSet("admin-info", flag.ExitOnError)
data := fs.String("data", "/var/lib/bj-license/state.json", "файл состояния")
_ = fs.Parse(args)
st, err := openStore(*data)
if err != nil {
log.Fatal(err)
}
printAccess(st)
}
func printAccess(st *store) {
_, openErr := os.Stat(filepath.Join(filepath.Dir(st.path), adminOpenFile))
st.read(func(s *State) {
path := adminDefault
if s.AdminPath != "" {
path = "/" + s.AdminPath
}
fmt.Printf("Адрес кабинета: https://%s%s/\n", publicHost, path)
switch {
case s.AdminAllowOnly && openErr == nil:
fmt.Println("Только из белого списка: включено, но кабинет открыт аварийно (fw-apply -off)")
case s.AdminAllowOnly:
fmt.Println("Только из белого списка: включено")
default:
fmt.Println("Только из белого списка: выключено")
}
fmt.Println("Белый список администраторов:")
for _, a := range s.AllowIPs {
fmt.Printf(" %-20s %s\n", a.Net, a.Comment)
}
if s.ZetitKey.Enabled {
fmt.Println("Вход через ЗЕТИТ Ключ: включён (" + pickStr(s.ZetitKey.URL, zetitKeyDefaultURL) + ")")
} else {
fmt.Println("Вход через ЗЕТИТ Ключ: выключен")
}
fmt.Println("Администраторы (второй фактор):")
for _, u := range s.Users {
state := "не подключён"
if u.TOTPSecret != "" {
state = "подключён"
}
fmt.Printf(" %-24s TOTP %s, e-mail %s\n", u.Login, state, pickStr(u.Email, "не указан"))
}
})
}
File diff suppressed because it is too large Load Diff
+291
View File
@@ -0,0 +1,291 @@
package main
// API для установок bj: активация по коду и продление токена (docs/license-server-api.md).
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license"
)
const (
tokenTTL = 60 * 24 * time.Hour
renewAfterSec = 86400
)
type apiRequest struct {
Code string `json:"code"`
Token string `json:"token"`
InstallID string `json:"install_id"`
Machine string `json:"machine"`
Hostname string `json:"hostname"`
Version string `json:"version"`
}
type apiResponse struct {
Token string `json:"token"`
LicenseID string `json:"license_id"`
Tenant string `json:"tenant"`
ExpiresAt time.Time `json:"expires_at"`
ContractUntil time.Time `json:"contract_until"`
RenewAfterSec int `json:"renew_after_sec"`
}
type apiError struct {
Status int `json:"-"`
Code string `json:"error"`
Message string `json:"message"`
}
func apiErr(status int, code, msg string) *apiError {
return &apiError{Status: status, Code: code, Message: msg}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func clip(s string, n int) string {
s = strings.TrimSpace(s)
if r := []rune(s); len(r) > n {
return string(r[:n])
}
return s
}
func (srv *server) decodeAPI(w http.ResponseWriter, r *http.Request) (*apiRequest, bool) {
if !srv.apiRate.allow(clientIP(r)) {
writeJSON(w, http.StatusTooManyRequests, apiErr(429, "rate_limited", "слишком много запросов, повторите позже"))
return nil, false
}
var in apiRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, apiErr(400, "bad_request", "тело запроса — JSON по docs/license-server-api.md"))
return nil, false
}
in.InstallID = strings.ToLower(strings.TrimSpace(in.InstallID))
if !uuidRe.MatchString(in.InstallID) {
writeJSON(w, http.StatusBadRequest, apiErr(400, "bad_request", "install_id: ожидается UUID"))
return nil, false
}
in.Machine, in.Hostname, in.Version = clip(in.Machine, 128), clip(in.Hostname, 128), clip(in.Version, 40)
return &in, true
}
// issueToken — подписанная лицензия под установку: срок токена 60 дней, не дольше договора.
func (srv *server) issueToken(s *State, lic *License, installID string, now time.Time) (string, time.Time, error) {
exp := now.Add(tokenTTL)
if !lic.ContractUntil.IsZero() && lic.ContractUntil.Before(exp) {
exp = lic.ContractUntil
}
tenant := ""
if t := s.tenant(lic.TenantID); t != nil {
tenant = t.Name
}
l := &license.License{
Schema: license.CurrentSchema, ID: lic.ID, Tenant: tenant, Product: "bj-server",
Plan: license.Plan(lic.Plan), IssuedAt: now.UTC(), ExpiresAt: exp.UTC(),
Features: lic.Features, MaxNodes: lic.MaxNodes, InstallID: installID, ContractUntil: lic.ContractUntil.UTC(),
}
tok, err := license.Sign(l, srv.priv, srv.keyID)
if err != nil {
return "", time.Time{}, err
}
return tok.Encode(), exp, nil
}
func (srv *server) answer(s *State, lic *License, token string, exp time.Time) *apiResponse {
tenant := ""
if t := s.tenant(lic.TenantID); t != nil {
tenant = t.Name
}
return &apiResponse{Token: token, LicenseID: lic.ID, Tenant: tenant, ExpiresAt: exp.UTC(), ContractUntil: lic.ContractUntil.UTC(), RenewAfterSec: renewAfterSec}
}
// touchInstall — сведения об установке из запроса; смена machine-id — предупреждение в журнал.
func touchInstall(s *State, inst *Install, in *apiRequest, ip string, now time.Time) {
if in.Machine != "" && inst.Machine != "" && in.Machine != inst.Machine {
s.addEvent("machine_changed", inst.LicenseID, inst.InstallID, "api", ip,
fmt.Sprintf("сменился machine-id установки (%s → %s): возможно, копия сервера", short(inst.Machine), short(in.Machine)))
}
if in.Machine != "" {
inst.Machine = in.Machine
}
if in.Hostname != "" {
inst.Hostname = in.Hostname
}
if in.Version != "" {
inst.Version = in.Version
}
inst.IP, inst.LastSeen = ip, now
}
// trustInstallIP — после успешной активации или продления адрес установки попадает в доверенные fail2ban.
func trustInstallIP(s *State, inst *Install, ip string, now time.Time) {
if s.trustIP(inst, ip, now) {
s.addEvent("trust_ip", inst.LicenseID, inst.InstallID, "api", ip, "IP "+ip+" добавлен в доверенные fail2ban")
}
}
func hostNote(in *apiRequest) string {
if in.Hostname == "" {
return ""
}
return " (" + in.Hostname + ")"
}
// POST /v1/activate
func (srv *server) apiActivate(w http.ResponseWriter, r *http.Request) {
in, ok := srv.decodeAPI(w, r)
if !ok {
return
}
ip := clientIP(r)
code := normalizeCode(in.Code)
var resp *apiResponse
var fail *apiError
err := srv.st.write(func(s *State) error {
now := time.Now()
var lic *License
if code != "" {
lic = s.licenseByCode(code)
}
if lic == nil {
fail = apiErr(http.StatusNotFound, "unknown_code", "код активации не найден")
log.Printf("api denied: unknown_code ip=%s", ip) // строку ловит fail2ban
s.addEvent("activate_denied", "", in.InstallID, "api", ip, "неизвестный код активации"+hostNote(in))
return nil
}
inst := s.install(in.InstallID)
switch {
case lic.Revoked():
fail = apiErr(http.StatusForbidden, "revoked", "лицензия отозвана")
case lic.ContractExpired(now):
fail = apiErr(http.StatusForbidden, "contract_expired", "срок договора истёк "+lic.ContractUntil.In(msk).Format("02.01.2006"))
case inst != nil && inst.Blocked:
fail = apiErr(http.StatusForbidden, "install_blocked", "установка заблокирована: "+inst.BlockReason)
case (inst == nil || inst.LicenseID != lic.ID) && lic.MaxNodes > 0 && s.activeInstalls(lic.ID) >= lic.MaxNodes:
fail = apiErr(http.StatusConflict, "max_nodes", fmt.Sprintf("исчерпан лимит установок по лицензии (%d)", lic.MaxNodes))
}
if fail != nil {
s.addEvent("activate_denied", lic.ID, in.InstallID, "api", ip, fail.Message+hostNote(in))
return nil
}
text := "активирована новая установка" + hostNote(in)
switch {
case inst == nil:
inst = &Install{InstallID: in.InstallID, FirstSeen: now}
s.Installs = append(s.Installs, inst)
case inst.LicenseID != lic.ID:
text = "установка переведена на эту лицензию с " + short(inst.LicenseID) + hostNote(in)
default:
text = "повторная активация установки" + hostNote(in)
}
inst.LicenseID = lic.ID
touchInstall(s, inst, in, ip, now)
trustInstallIP(s, inst, ip, now)
token, exp, err := srv.issueToken(s, lic, inst.InstallID, now)
if err != nil {
return err
}
inst.TokenExpires = exp
s.addEvent("activate", lic.ID, inst.InstallID, "api", ip, text+", токен до "+exp.In(msk).Format("02.01.2006"))
resp = srv.answer(s, lic, token, exp)
return nil
})
srv.finishAPI(w, resp, fail, err)
}
// POST /v1/renew
func (srv *server) apiRenew(w http.ResponseWriter, r *http.Request) {
in, ok := srv.decodeAPI(w, r)
if !ok {
return
}
ip := clientIP(r)
tok, err := license.DecodeToken(in.Token)
if err != nil {
writeJSON(w, http.StatusUnauthorized, apiErr(401, "bad_token", "токен не разобран"))
return
}
l, err := license.Verify(tok, srv.pub)
if err != nil {
log.Printf("api denied: bad_token ip=%s", ip) // строку ловит fail2ban
writeJSON(w, http.StatusUnauthorized, apiErr(401, "bad_token", "подпись токена недействительна"))
return
}
if l.InstallID == "" || !strings.EqualFold(l.InstallID, in.InstallID) {
writeJSON(w, http.StatusForbidden, apiErr(403, "install_mismatch", "токен выдан другой установке"))
return
}
var resp *apiResponse
var fail *apiError
err = srv.st.write(func(s *State) error {
now := time.Now()
lic := s.license(l.ID)
if lic == nil {
fail = apiErr(http.StatusUnauthorized, "bad_token", "лицензия из токена не найдена")
return nil
}
inst := s.install(in.InstallID)
switch {
case inst == nil || inst.LicenseID != lic.ID:
fail = apiErr(http.StatusNotFound, "unknown_install", "установка не зарегистрирована по этой лицензии — активируйте кодом")
case lic.Revoked():
fail = apiErr(http.StatusForbidden, "revoked", "лицензия отозвана")
case lic.ContractExpired(now):
fail = apiErr(http.StatusForbidden, "contract_expired", "срок договора истёк "+lic.ContractUntil.In(msk).Format("02.01.2006"))
case inst.Blocked:
fail = apiErr(http.StatusForbidden, "install_blocked", "установка заблокирована: "+inst.BlockReason)
}
if fail != nil {
s.addEvent("renew_denied", lic.ID, in.InstallID, "api", ip, fail.Message+hostNote(in))
return nil
}
touchInstall(s, inst, in, ip, now)
trustInstallIP(s, inst, ip, now)
token, exp, err := srv.issueToken(s, lic, inst.InstallID, now)
if err != nil {
return err
}
inst.TokenExpires = exp
s.addEvent("renew", lic.ID, inst.InstallID, "api", ip, fmt.Sprintf("продление%s, версия %s, токен до %s", hostNote(in), pickStr(in.Version, "—"), exp.In(msk).Format("02.01.2006")))
resp = srv.answer(s, lic, token, exp)
return nil
})
srv.finishAPI(w, resp, fail, err)
}
func (srv *server) finishAPI(w http.ResponseWriter, resp *apiResponse, fail *apiError, err error) {
switch {
case err != nil:
writeJSON(w, http.StatusInternalServerError, apiErr(500, "internal", "внутренняя ошибка сервера лицензий"))
case fail != nil:
writeJSON(w, fail.Status, fail)
default:
writeJSON(w, http.StatusOK, resp)
}
}
func pickStr(v, def string) string {
if strings.TrimSpace(v) == "" {
return def
}
return v
}
func short(id string) string {
if len(id) > 8 {
return id[:8]
}
return id
}
+917
View File
@@ -0,0 +1,917 @@
package main
// Защита сервера лицензий из кабинета: fail2ban и «режим обороны».
//
// Кабинет работает непривилегированно (пользователь bj-license) и только пишет желаемое
// состояние рядом с state.json: белый список администраторов (allow-ips.txt) и режим с
// разовыми командами fail2ban (fw-control.json). Применяет их root-помощник
// `bj-license-server fw-apply` — systemd запускает его при изменении fw-control.json
// (bj-license-fw.path) и раз в 30 секунд (bj-license-fw.timer); фактическое состояние
// он пишет в fw-status.json, его и показывает кабинет.
//
// Режим обороны: на порты 80/443 пускаются только адреса из белого списка (ipset
// bj-allow / bj-allow6) — доверенные IP установок (trusted-ips.txt) и адреса
// администраторов (allow-ips.txt). SSH не затрагивается. Аварийное выключение по SSH:
//
// /opt/bj-license/bj-license-server fw-apply -off
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"syscall"
"time"
)
const (
fwControlFile = "fw-control.json"
fwStatusFile = "fw-status.json"
fwAllowFile = "allow-ips.txt"
fwOffFile = "fw-off" // создаёт fw-apply -off; удаляет кабинет при повторном включении обороны
fwSetV4 = "bj-allow"
fwSetV6 = "bj-allow6"
fwRuleComment = "bj-license-lockdown"
fwStatusStale = 2 * time.Minute
)
// fwJails — jail fail2ban, которыми управляет кабинет.
var fwJails = []string{"bj-license-api", "bj-license-admin", "sshd"}
// AllowIP — адрес или сеть администратора в белом списке.
type AllowIP struct {
Net string `json:"net"`
Comment string `json:"comment,omitempty"`
AddedBy string `json:"added_by,omitempty"`
AddedAt time.Time `json:"added_at"`
}
// FirewallState — желаемое состояние защиты: пишет кабинет, применяет fw-apply.
type FirewallState struct {
Mode string `json:"mode,omitempty"` // "" — обычный, lockdown — оборона
Until time.Time `json:"until,omitempty"`
UpdatedBy string `json:"updated_by,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
Actions []fwAction `json:"actions,omitempty"`
// Параметры блокировок jail (секунды; bantime -1 — навсегда). Пусто — значения из jail.d/bj-license.conf.
Jails map[string]fwJailConf `json:"jails,omitempty"`
// Блокировка входа в кабинет самим приложением (до fail2ban): попыток и срок.
LoginAttempts int `json:"login_attempts,omitempty"`
LoginLockSec int64 `json:"login_lock_sec,omitempty"`
}
// fwJailConf — параметры блокировки jail.
type fwJailConf struct {
BanTime int64 `json:"bantime"`
FindTime int64 `json:"findtime"`
MaxRetry int `json:"maxretry"`
}
// fwDefaultJail — значения из deploy/license/fail2ban/jail.d/bj-license.conf.
func fwDefaultJail(name string) fwJailConf {
if name == "sshd" {
return fwJailConf{BanTime: 3600, FindTime: 600, MaxRetry: 5}
}
return fwJailConf{BanTime: 3600, FindTime: 600, MaxRetry: 10}
}
type fwPreset struct {
Sec int64
Label string
}
var (
fwBanPresets = []fwPreset{{900, "15 минут"}, {3600, "1 час"}, {7200, "2 часа"}, {21600, "6 часов"}, {43200, "12 часов"},
{86400, "1 день"}, {259200, "3 дня"}, {604800, "1 неделя"}, {2592000, "1 месяц"}, {7776000, "3 месяца"},
{31536000, "1 год"}, {-1, "навсегда"}}
fwFindPresets = []fwPreset{{600, "10 минут"}, {3600, "1 час"}, {21600, "6 часов"}, {86400, "1 день"}, {604800, "1 неделя"}}
fwRetryPresets = []int{3, 5, 10, 20}
)
// durRu — срок по-русски: из пресетов или в секундах.
func durRu(sec int64) string {
for _, p := range fwBanPresets {
if p.Sec == sec {
return p.Label
}
}
for _, p := range fwFindPresets {
if p.Sec == sec {
return p.Label
}
}
return strconv.FormatInt(sec, 10) + " с"
}
func fwPresetOK(list []fwPreset, sec int64) bool {
for _, p := range list {
if p.Sec == sec {
return true
}
}
return false
}
// loginLimitOf — ограничение входа в кабинет: попыток и срок (по умолчанию 8 за 15 минут).
func loginLimitOf(s *State) (int, time.Duration) {
n, sec := s.Firewall.LoginAttempts, s.Firewall.LoginLockSec
if n <= 0 {
n = 8
}
if sec <= 0 {
sec = 900
}
return n, time.Duration(sec) * time.Second
}
// fwAction — разовая команда fail2ban из кабинета.
type fwAction struct {
ID string `json:"id"`
Op string `json:"op"` // ban | unban
Jail string `json:"jail"`
IP string `json:"ip"`
At time.Time `json:"at"`
By string `json:"by,omitempty"`
}
type fwJailStatus struct {
Name string `json:"name"`
BanTime int64 `json:"bantime"`
FindTime int64 `json:"findtime"`
MaxRetry int `json:"maxretry"`
CurrentlyFailed int `json:"currently_failed"`
TotalFailed int `json:"total_failed"`
CurrentlyBanned int `json:"currently_banned"`
TotalBanned int `json:"total_banned"`
Banned []string `json:"banned"`
}
// fwStatus — фактическое состояние, пишет fw-apply.
type fwStatus struct {
At time.Time `json:"at"`
Lockdown bool `json:"lockdown"` // правило обороны действует
Emergency bool `json:"emergency"` // оборона выключена аварийно (fw-apply -off)
Until time.Time `json:"until,omitempty"`
AllowCount int `json:"allow_count"`
Jails []fwJailStatus `json:"jails"`
Done []string `json:"done,omitempty"`
Errors []string `json:"errors,omitempty"`
}
// normalizeNet — IP или сеть в каноническом виде; слишком широкие сети не принимаются.
func normalizeNet(s string) (string, bool) {
s = strings.TrimSpace(s)
if ip := net.ParseIP(s); ip != nil {
return ip.String(), true
}
_, n, err := net.ParseCIDR(s)
if err != nil {
return "", false
}
ones, bits := n.Mask.Size()
if (bits == 32 && ones < 16) || (bits == 128 && ones < 32) {
return "", false
}
return n.String(), true
}
// netContains — адрес ip попадает в IP или сеть entry.
func netContains(entry, ip string) bool {
p := net.ParseIP(strings.TrimSpace(ip))
if p == nil {
return false
}
if e := net.ParseIP(entry); e != nil {
return e.Equal(p)
}
_, n, err := net.ParseCIDR(entry)
return err == nil && n.Contains(p)
}
// allowCovers — адрес в белом списке администраторов (includeTrusted — или среди доверенных установок).
func (s *State) allowCovers(ip string, includeTrusted bool) bool {
for _, a := range s.AllowIPs {
if netContains(a.Net, ip) {
return true
}
}
if includeTrusted {
for _, t := range s.trustedIPs() {
if netContains(t, ip) {
return true
}
}
}
return false
}
// writeIfChanged — атомарная запись файла, только если содержимое изменилось.
func writeIfChanged(path string, data []byte, perm os.FileMode) error {
if old, err := os.ReadFile(path); err == nil && bytes.Equal(old, data) {
return nil
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, perm); err != nil {
return err
}
return os.Rename(tmp, path)
}
// writeFirewallLocked — allow-ips.txt и fw-control.json для fw-apply; вызывать под блокировкой.
func (st *store) writeFirewallLocked() error {
dir := filepath.Dir(st.path)
body := "# Белый список администраторов (IP или сеть). Файл пишет bj-license-server.\n"
for _, a := range st.s.AllowIPs {
body += a.Net + "\n"
}
if err := writeIfChanged(filepath.Join(dir, fwAllowFile), []byte(body), 0o600); err != nil {
return err
}
b, err := json.MarshalIndent(st.s.Firewall, "", " ")
if err != nil {
return err
}
return writeIfChanged(filepath.Join(dir, fwControlFile), b, 0o600)
}
func fwReadStatus(dir string) fwStatus {
var st fwStatus
if b, err := os.ReadFile(filepath.Join(dir, fwStatusFile)); err == nil {
_ = json.Unmarshal(b, &st)
}
return st
}
// ---- root-помощник ----
// fwApply — `bj-license-server fw-apply [-dir …] [-off]`: применить желаемое состояние (от root).
func fwApply(args []string) {
fs := flag.NewFlagSet("fw-apply", flag.ExitOnError)
dir := fs.String("dir", "/var/lib/bj-license", "каталог данных сервера лицензий")
off := fs.Bool("off", false, "аварийно выключить режим обороны (до повторного включения в кабинете)")
_ = fs.Parse(args)
if os.Geteuid() != 0 {
log.Fatal("fw-apply запускается от root")
}
if *off {
if err := os.WriteFile(filepath.Join(*dir, fwOffFile), []byte(time.Now().Format(time.RFC3339)+"\n"), 0o644); err != nil {
log.Fatalf("аварийное выключение: %v", err)
}
chownLikeDir(*dir, filepath.Join(*dir, fwOffFile))
// Заодно открыть кабинет всем адресам, если он ограничен белым списком (access.go).
if err := os.WriteFile(filepath.Join(*dir, adminOpenFile), []byte(time.Now().Format(time.RFC3339)+"\n"), 0o644); err == nil {
chownLikeDir(*dir, filepath.Join(*dir, adminOpenFile))
}
}
st := fwRun(*dir)
if err := fwWriteStatus(*dir, st); err != nil {
log.Printf("fw-status.json: %v", err)
}
for _, e := range st.Errors {
log.Print(e)
}
if *off {
fmt.Println("Режим обороны выключен аварийно, кабинет открыт для всех адресов. Включить снова — в кабинете, раздел «Защита».")
fmt.Println("Адрес кабинета: bj-license-server admin-info")
}
}
func fwRun(dir string) fwStatus {
now := time.Now()
st := fwStatus{At: now}
prev := fwReadStatus(dir)
var ctl FirewallState
if b, err := os.ReadFile(filepath.Join(dir, fwControlFile)); err == nil {
if err := json.Unmarshal(b, &ctl); err != nil {
st.Errors = append(st.Errors, "fw-control.json: "+err.Error())
}
}
if _, err := os.Stat(filepath.Join(dir, fwOffFile)); err == nil {
st.Emergency = true
}
want := ctl.Mode == "lockdown" && !st.Emergency && (ctl.Until.IsZero() || now.Before(ctl.Until))
v4, v6 := fwReadAllow(dir)
st.AllowCount = len(v4) + len(v6)
if err := fwSyncSet(fwSetV4, "inet", v4); err != nil {
st.Errors = append(st.Errors, err.Error())
want = false
}
if err := fwSyncSet(fwSetV6, "inet6", v6); err != nil {
st.Errors = append(st.Errors, err.Error())
want = false
}
if want && st.AllowCount == 0 {
st.Errors = append(st.Errors, "белый список пуст — режим обороны не включён, иначе доступ закроется всем")
want = false
}
ok4 := fwRule("iptables", fwSetV4, want, &st.Errors)
ok6 := fwRule("ip6tables", fwSetV6, want, &st.Errors)
st.Lockdown = want && ok4 && ok6
if st.Lockdown {
st.Until = ctl.Until
}
if err := fwApplyJailConf(ctl.Jails); err != nil {
st.Errors = append(st.Errors, err.Error())
}
done := map[string]bool{}
for _, id := range prev.Done {
done[id] = true
}
for _, a := range ctl.Actions {
if !done[a.ID] {
if err := fwRunAction(a); err != nil {
st.Errors = append(st.Errors, err.Error())
}
done[a.ID] = true
}
st.Done = append(st.Done, a.ID) // только команды, ещё лежащие в fw-control.json
}
for _, j := range fwJails {
js, err := fwJail(j)
if err != nil {
st.Errors = append(st.Errors, err.Error())
continue
}
st.Jails = append(st.Jails, js)
}
return st
}
// fwReadAllow — белый список для ipset: доверенные установки и администраторы.
func fwReadAllow(dir string) (v4, v6 []string) {
seen := map[string]bool{}
for _, name := range []string{trustedFile, fwAllowFile} {
b, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
continue
}
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
n, ok := normalizeNet(line)
if !ok || seen[n] {
continue
}
seen[n] = true
if strings.Contains(n, ":") {
v6 = append(v6, n)
} else {
v4 = append(v4, n)
}
}
}
return v4, v6
}
// fwSyncSet — содержимое ipset заменяется атомарно (временный набор + swap).
func fwSyncSet(name, family string, nets []string) error {
tmp := name + "-tmp"
var b strings.Builder
fmt.Fprintf(&b, "create %s hash:net family %s -exist\n", name, family)
fmt.Fprintf(&b, "create %s hash:net family %s -exist\nflush %s\n", tmp, family, tmp)
for _, n := range nets {
fmt.Fprintf(&b, "add %s %s -exist\n", tmp, n)
}
fmt.Fprintf(&b, "swap %s %s\ndestroy %s\n", tmp, name, tmp)
cmd := exec.Command("ipset", "restore")
cmd.Stdin = strings.NewReader(b.String())
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("ipset %s: %v: %s", name, err, strings.TrimSpace(string(out)))
}
return nil
}
// fwRuleSpec — правило обороны: 80/443 только из белого списка.
func fwRuleSpec(set string) []string {
return []string{"-p", "tcp", "-m", "multiport", "--dports", "80,443", "-m", "set", "!", "--match-set", set, "src",
"-m", "comment", "--comment", fwRuleComment, "-j", "DROP"}
}
// fwRule — привести наличие правила к want; true — правило в нужном состоянии.
func fwRule(bin, set string, want bool, errs *[]string) bool {
has := func() bool {
return exec.Command(bin, append([]string{"-w", "-C", "INPUT"}, fwRuleSpec(set)...)...).Run() == nil
}
if want && !has() {
if out, err := exec.Command(bin, append([]string{"-w", "-I", "INPUT", "1"}, fwRuleSpec(set)...)...).CombinedOutput(); err != nil {
*errs = append(*errs, fmt.Sprintf("%s: включение обороны: %v: %s", bin, err, strings.TrimSpace(string(out))))
return false
}
}
for i := 0; !want && has() && i < 10; i++ {
if out, err := exec.Command(bin, append([]string{"-w", "-D", "INPUT"}, fwRuleSpec(set)...)...).CombinedOutput(); err != nil {
*errs = append(*errs, fmt.Sprintf("%s: выключение обороны: %v: %s", bin, err, strings.TrimSpace(string(out))))
return false
}
}
return want == has()
}
func fwRunAction(a fwAction) error {
ip := net.ParseIP(a.IP)
known := false
for _, j := range fwJails {
known = known || j == a.Jail
}
if ip == nil || !known || (a.Op != "ban" && a.Op != "unban") {
return fmt.Errorf("команда %s %s %s отклонена: неверные параметры", a.Op, a.Jail, a.IP)
}
out, err := exec.Command("fail2ban-client", "set", a.Jail, a.Op+"ip", ip.String()).CombinedOutput()
if err != nil {
return fmt.Errorf("fail2ban %s %s %s: %v: %s", a.Jail, a.Op, ip, err, strings.TrimSpace(string(out)))
}
return nil
}
const (
fwJailOverride = "/etc/fail2ban/jail.d/zz-bj-license-ui.local"
fwServerDefault = "/etc/fail2ban/fail2ban.d/zz-bj-license-ui.local"
)
// fwApplyJailConf — параметры блокировок из кабинета в override fail2ban; при изменении — reload.
func fwApplyJailConf(conf map[string]fwJailConf) error {
var b strings.Builder
b.WriteString("# Параметры блокировок из кабинета сервера лицензий (раздел «Защита»). Файл пишет bj-license-server fw-apply.\n")
var maxBan int64 = 86400
forever := false
for _, j := range fwJails {
c, ok := conf[j]
if !ok {
continue
}
if c.BanTime == -1 {
forever = true
} else if c.BanTime > maxBan {
maxBan = c.BanTime
}
fmt.Fprintf(&b, "\n[%s]\nbantime = %d\nfindtime = %d\nmaxretry = %d\n", j, c.BanTime, c.FindTime, c.MaxRetry)
}
old, _ := os.ReadFile(fwJailOverride)
if string(old) == b.String() {
return nil
}
if err := os.WriteFile(fwJailOverride, []byte(b.String()), 0o644); err != nil {
return fmt.Errorf("параметры блокировок: %w", err)
}
// Долгие баны должны пережить перезапуск fail2ban: база банов хранится дольше самого долгого срока.
purge := maxBan + 86400
if forever && purge < 5*31536000 {
purge = 5 * 31536000
}
_ = os.WriteFile(fwServerDefault, []byte(fmt.Sprintf("# Файл пишет bj-license-server fw-apply.\n[Definition]\ndbpurgeage = %d\n", purge)), 0o644)
if out, err := exec.Command("fail2ban-client", "reload").CombinedOutput(); err != nil {
return fmt.Errorf("fail2ban reload: %v: %s", err, strings.TrimSpace(string(out)))
}
_ = exec.Command("fail2ban-client", "set", "dbpurgeage", strconv.FormatInt(purge, 10)).Run()
return nil
}
var fwNumRe = regexp.MustCompile(`(Currently failed|Total failed|Currently banned|Total banned):\s*(\d+)`)
func fwJail(name string) (fwJailStatus, error) {
js := fwJailStatus{Name: name}
out, err := exec.Command("fail2ban-client", "status", name).CombinedOutput()
if err != nil {
return js, fmt.Errorf("fail2ban status %s: %v: %s", name, err, strings.TrimSpace(string(out)))
}
text := string(out)
for _, m := range fwNumRe.FindAllStringSubmatch(text, -1) {
n, _ := strconv.Atoi(m[2])
switch m[1] {
case "Currently failed":
js.CurrentlyFailed = n
case "Total failed":
js.TotalFailed = n
case "Currently banned":
js.CurrentlyBanned = n
case "Total banned":
js.TotalBanned = n
}
}
if i := strings.Index(text, "Banned IP list:"); i >= 0 {
line, _, _ := strings.Cut(text[i+len("Banned IP list:"):], "\n")
js.Banned = strings.Fields(line)
}
get := func(opt string) int64 {
out, err := exec.Command("fail2ban-client", "get", name, opt).Output()
if err != nil {
return 0
}
n, _ := strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64)
return n
}
js.BanTime, js.FindTime, js.MaxRetry = get("bantime"), get("findtime"), int(get("maxretry"))
return js, nil
}
func chownLikeDir(dir, path string) {
if fi, err := os.Stat(dir); err == nil {
if s, ok := fi.Sys().(*syscall.Stat_t); ok {
_ = os.Chown(path, int(s.Uid), int(s.Gid))
}
}
}
func fwWriteStatus(dir string, st fwStatus) error {
b, err := json.MarshalIndent(st, "", " ")
if err != nil {
return err
}
tmp := filepath.Join(dir, fwStatusFile+".tmp")
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
chownLikeDir(dir, tmp)
return os.Rename(tmp, filepath.Join(dir, fwStatusFile))
}
// fwIgnore — `bj-license-server fw-ignore [-dir …] <ip> api|admin` для ignorecommand fail2ban:
// код 0 — адрес не блокировать. api — доверенные установки и белый список, admin — только белый список.
func fwIgnore(args []string) {
fs := flag.NewFlagSet("fw-ignore", flag.ExitOnError)
dir := fs.String("dir", "/var/lib/bj-license", "каталог данных сервера лицензий")
_ = fs.Parse(args)
ip, kind := fs.Arg(0), fs.Arg(1)
if net.ParseIP(ip) == nil {
os.Exit(1)
}
files := []string{fwAllowFile}
if kind != "admin" {
files = append(files, trustedFile)
}
for _, name := range files {
b, err := os.ReadFile(filepath.Join(*dir, name))
if err != nil {
continue
}
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "#") && netContains(line, ip) {
os.Exit(0)
}
}
}
os.Exit(1)
}
// ---- кабинет: раздел «Защита» ----
type securityData struct {
Status fwStatus
StatusFresh bool
Wanted FirewallState
WantedActive bool
Allow []AllowIP
TrustedCount int
Pending []fwAction
MyIP string
MyIPCovered bool
Jails []string
// Доступ к кабинету.
Host string
AdminBase string
AdminDefault bool
AdminAllowOnly bool
AdminOpen bool
TwoFA, Admins int
ZKEnabled bool
ZKHasKey bool
ZKURL string
ZKWithEmail int
Limits []fwLimitRow
BanPresets []fwPreset
FindPresets []fwPreset
RetryPresets []int
LoginTries int
LoginLockSec int64
}
// fwLimitRow — строка карточки «Параметры блокировок».
type fwLimitRow struct {
Jail, Title string
Conf fwJailConf
Effective string // действующие значения в fail2ban
HasEffective bool
BanOpts []fwPreset
FindOpts []fwPreset
RetryOpts []int
}
// withPreset — варианты выбора с текущим значением, даже если его нет среди стандартных.
func withPreset(list []fwPreset, sec int64) []fwPreset {
if fwPresetOK(list, sec) {
return list
}
return append(append([]fwPreset{}, list...), fwPreset{sec, durRu(sec)})
}
func withRetry(list []int, n int) []int {
for _, v := range list {
if v == n {
return list
}
}
return append(append([]int{}, list...), n)
}
func (srv *server) dataDir() string { return filepath.Dir(srv.st.path) }
// fwPrune — убрать из желаемого состояния команды, которые помощник уже выполнил.
func fwPrune(s *State, st fwStatus) {
done := map[string]bool{}
for _, id := range st.Done {
done[id] = true
}
var keep []fwAction
for _, a := range s.Firewall.Actions {
if !done[a.ID] {
keep = append(keep, a)
}
}
s.Firewall.Actions = keep
}
// fwWait — подождать, пока помощник применит изменения (до 5 секунд).
func (srv *server) fwWait(since time.Time) {
for i := 0; i < 20; i++ {
if fwReadStatus(srv.dataDir()).At.After(since) {
return
}
time.Sleep(250 * time.Millisecond)
}
}
func (srv *server) securityPage(w http.ResponseWriter, r *http.Request, sess *session) {
st := fwReadStatus(srv.dataDir())
d := securityData{Status: st, StatusFresh: !st.At.IsZero() && time.Since(st.At) < fwStatusStale, MyIP: clientIP(r), Jails: fwJails}
srv.st.read(func(s *State) {
d.Wanted = s.Firewall
d.WantedActive = s.Firewall.Mode == "lockdown" && (s.Firewall.Until.IsZero() || time.Now().Before(s.Firewall.Until))
for _, a := range s.AllowIPs {
d.Allow = append(d.Allow, *a)
}
d.TrustedCount = len(s.trustedIPs())
done := map[string]bool{}
for _, id := range st.Done {
done[id] = true
}
for _, a := range s.Firewall.Actions {
if !done[a.ID] {
d.Pending = append(d.Pending, a)
}
}
d.MyIPCovered = s.allowCovers(d.MyIP, false)
d.AdminAllowOnly, d.Admins = s.AdminAllowOnly, len(s.Users)
d.ZKEnabled, d.ZKHasKey, d.ZKURL = s.ZetitKey.Enabled, s.ZetitKey.AppKey != "", pickStr(s.ZetitKey.URL, zetitKeyDefaultURL)
for _, u := range s.Users {
if u.Email != "" {
d.ZKWithEmail++
}
}
for _, u := range s.Users {
if u.TOTPSecret != "" {
d.TwoFA++
}
}
n, lock := loginLimitOf(s)
d.LoginTries, d.LoginLockSec = n, int64(lock/time.Second)
for _, j := range fwJails {
c, configured := s.Firewall.Jails[j]
if !configured {
c = fwDefaultJail(j)
}
row := fwLimitRow{Jail: j, Title: fwJailTitle(j)}
for _, js := range st.Jails {
if js.Name == j && js.MaxRetry > 0 {
row.HasEffective = true
row.Effective = fmt.Sprintf("сейчас: бан %s, %d попыток за %s", durRu(js.BanTime), js.MaxRetry, durRu(js.FindTime))
if !configured { // ещё не настраивали в кабинете — показываем то, что действует
c = fwJailConf{BanTime: js.BanTime, FindTime: js.FindTime, MaxRetry: js.MaxRetry}
}
}
}
row.Conf = c
row.BanOpts, row.FindOpts, row.RetryOpts = withPreset(fwBanPresets, c.BanTime), withPreset(fwFindPresets, c.FindTime), withRetry(fwRetryPresets, c.MaxRetry)
d.Limits = append(d.Limits, row)
}
})
d.BanPresets, d.FindPresets, d.RetryPresets = fwBanPresets, fwFindPresets, fwRetryPresets
d.Host, d.AdminBase, d.AdminOpen = publicHost, srv.adminBase(), srv.adminOpen()
d.AdminDefault = d.AdminBase == adminDefault
srv.render(w, r, sess, "security", "Защита", "security", d)
}
func fwJailTitle(j string) string {
switch j {
case "bj-license-api":
return "Активация и продление (API)"
case "bj-license-admin":
return "Вход в кабинет (fail2ban)"
case "sshd":
return "SSH"
}
return j
}
// securityLimits — POST /admin/security/limits: сроки и пороги блокировок.
func (srv *server) securityLimits(w http.ResponseWriter, r *http.Request, sess *session) {
conf := map[string]fwJailConf{}
var changes []string
for _, j := range fwJails {
ban, e1 := strconv.ParseInt(r.PostFormValue("ban_"+j), 10, 64)
find, e2 := strconv.ParseInt(r.PostFormValue("find_"+j), 10, 64)
retry, e3 := strconv.Atoi(r.PostFormValue("retry_" + j))
if e1 != nil || e2 != nil || e3 != nil || (ban != -1 && (ban < 60 || ban > 5*31536000)) || find < 60 || find > 31536000 || retry < 1 || retry > 100 {
back(w, r, "/admin/security", "", "неверные параметры для "+fwJailTitle(j))
return
}
conf[j] = fwJailConf{BanTime: ban, FindTime: find, MaxRetry: retry}
changes = append(changes, fmt.Sprintf("%s: бан %s, %d попыток за %s", fwJailTitle(j), durRu(ban), retry, durRu(find)))
}
tries, e1 := strconv.Atoi(r.PostFormValue("login_tries"))
lock, e2 := strconv.ParseInt(r.PostFormValue("login_lock"), 10, 64)
if e1 != nil || e2 != nil || tries < 3 || tries > 100 || lock <= 0 || !fwPresetOK(fwBanPresets, lock) {
back(w, r, "/admin/security", "", "неверные параметры блокировки входа в кабинет")
return
}
changes = append(changes, fmt.Sprintf("вход в кабинет (приложение): %d попыток, блокировка %s", tries, durRu(lock)))
start := time.Now()
err := srv.st.write(func(s *State) error {
s.Firewall.Jails, s.Firewall.LoginAttempts, s.Firewall.LoginLockSec = conf, tries, lock
s.Firewall.UpdatedBy, s.Firewall.UpdatedAt = sess.Login, start
s.addEvent("fw_limits", "", "", sess.Login, clientIP(r), "параметры блокировок: "+strings.Join(changes, "; "))
return nil
})
if err != nil {
back(w, r, "/admin/security", "", err.Error())
return
}
srv.fails.set(tries, time.Duration(lock)*time.Second)
srv.fwWait(start)
back(w, r, "/admin/security", "Параметры блокировок сохранены. fail2ban перечитывает настройки, действующие значения обновятся в течение 30 секунд.", "")
}
// securityMode — POST /admin/security/mode: включить (op=on, hours) или выключить (op=off) оборону.
func (srv *server) securityMode(w http.ResponseWriter, r *http.Request, sess *session) {
op, myIP := r.PostFormValue("op"), clientIP(r)
start := time.Now()
status := fwReadStatus(srv.dataDir())
var bad string
err := srv.st.write(func(s *State) error {
fwPrune(s, status)
switch op {
case "on":
hours, _ := strconv.Atoi(r.PostFormValue("hours"))
if hours < 0 || hours > 24*30 {
bad = "неверный срок"
return nil
}
if n, ok := normalizeNet(myIP); ok && !s.allowCovers(myIP, false) {
s.AllowIPs = append(s.AllowIPs, &AllowIP{Net: n, Comment: "добавлен автоматически при включении обороны", AddedBy: sess.Login, AddedAt: start})
s.addEvent("fw_allow_add", "", "", sess.Login, myIP, "IP "+n+" добавлен в белый список (включение обороны)")
}
s.Firewall.Mode, s.Firewall.Until = "lockdown", time.Time{}
text := "включён режим обороны: на 80/443 только белый список"
if hours > 0 {
s.Firewall.Until = start.Add(time.Duration(hours) * time.Hour)
text += ", до " + s.Firewall.Until.In(msk).Format("02.01.2006 15:04")
}
s.addEvent("fw_lockdown_on", "", "", sess.Login, myIP, text)
_ = os.Remove(filepath.Join(srv.dataDir(), fwOffFile))
case "off":
s.Firewall.Mode, s.Firewall.Until = "", time.Time{}
s.addEvent("fw_lockdown_off", "", "", sess.Login, myIP, "режим обороны выключен")
default:
bad = "неизвестная команда"
return nil
}
s.Firewall.UpdatedBy, s.Firewall.UpdatedAt = sess.Login, start
return nil
})
if err != nil || bad != "" {
back(w, r, "/admin/security", "", pickStr(bad, fmt.Sprint(err)))
return
}
srv.fwWait(start)
if op == "on" {
back(w, r, "/admin/security", "Режим обороны включается: доступ к сайту и API — только из белого списка.", "")
return
}
back(w, r, "/admin/security", "Режим обороны выключен: доступ открыт, fail2ban работает как обычно.", "")
}
// securityAllow — POST /admin/security/allow: добавить (op=add) или убрать (op=remove) адрес белого списка.
func (srv *server) securityAllow(w http.ResponseWriter, r *http.Request, sess *session) {
op, myIP := r.PostFormValue("op"), clientIP(r)
start := time.Now()
var bad, okMsg string
err := srv.st.write(func(s *State) error {
switch op {
case "add":
n, ok := normalizeNet(r.PostFormValue("net"))
if !ok {
bad = "укажите IP или сеть (IPv4 не шире /16, IPv6 не шире /32)"
return nil
}
for _, a := range s.AllowIPs {
if a.Net == n {
bad = n + " уже в белом списке"
return nil
}
}
s.AllowIPs = append(s.AllowIPs, &AllowIP{Net: n, Comment: clip(r.PostFormValue("comment"), 120), AddedBy: sess.Login, AddedAt: start})
s.addEvent("fw_allow_add", "", "", sess.Login, myIP, "IP "+n+" добавлен в белый список")
okMsg = n + " добавлен в белый список"
case "remove":
n := strings.TrimSpace(r.PostFormValue("net"))
var keep []*AllowIP
for _, a := range s.AllowIPs {
if a.Net != n {
keep = append(keep, a)
}
}
if len(keep) == len(s.AllowIPs) {
bad = "адрес не найден в белом списке"
return nil
}
lockdown := s.Firewall.Mode == "lockdown" && (s.Firewall.Until.IsZero() || start.Before(s.Firewall.Until))
if (lockdown || s.AdminAllowOnly) && netContains(n, myIP) {
rest := &State{AllowIPs: keep}
if !rest.allowCovers(myIP, false) {
bad = n + " покрывает ваш адрес " + myIP + ", а кабинет сейчас ограничен белым списком: после удаления доступ закроется. Сначала снимите ограничение или выключите оборону."
return nil
}
}
s.AllowIPs = keep
s.addEvent("fw_allow_remove", "", "", sess.Login, myIP, "IP "+n+" убран из белого списка")
okMsg = n + " убран из белого списка"
default:
bad = "неизвестная команда"
}
return nil
})
if err != nil || bad != "" {
back(w, r, "/admin/security", "", pickStr(bad, fmt.Sprint(err)))
return
}
srv.fwWait(start)
back(w, r, "/admin/security", okMsg, "")
}
// securityBan — POST /admin/security/ban: заблокировать (op=ban) или разблокировать (op=unban) IP в jail.
func (srv *server) securityBan(w http.ResponseWriter, r *http.Request, sess *session) {
op, jail, myIP := r.PostFormValue("op"), r.PostFormValue("jail"), clientIP(r)
ip := net.ParseIP(strings.TrimSpace(r.PostFormValue("ip")))
known := false
for _, j := range fwJails {
known = known || j == jail
}
switch {
case ip == nil:
back(w, r, "/admin/security", "", "укажите IP-адрес")
return
case !known || (op != "ban" && op != "unban"):
back(w, r, "/admin/security", "", "неверная команда")
return
case op == "ban" && ip.String() == net.ParseIP(myIP).String():
back(w, r, "/admin/security", "", "это ваш текущий адрес — блокировать его нельзя")
return
}
start := time.Now()
status := fwReadStatus(srv.dataDir())
err := srv.st.write(func(s *State) error {
fwPrune(s, status)
s.Firewall.Actions = append(s.Firewall.Actions, fwAction{ID: randHex(8), Op: op, Jail: jail, IP: ip.String(), At: start, By: sess.Login})
verb := "заблокирован"
if op == "unban" {
verb = "разблокирован"
}
s.addEvent("fw_"+op, "", "", sess.Login, myIP, "IP "+ip.String()+" "+verb+" вручную в "+jail)
return nil
})
if err != nil {
back(w, r, "/admin/security", "", err.Error())
return
}
srv.fwWait(start)
if op == "ban" {
back(w, r, "/admin/security", "IP "+ip.String()+" заблокирован в "+jail+" (на срок bantime jail).", "")
return
}
back(w, r, "/admin/security", "IP "+ip.String()+" разблокирован в "+jail+".", "")
}
+127 -79
View File
@@ -1,96 +1,144 @@
// Command bj-license-server — онлайн-сервис учёта и отзыва лицензий.
// Command bj-license-server — сервер лицензирования Bridge&Join (https://lic.zetit.ru).
//
// Базовая модель лицензирования офлайновая: bj-server проверяет подпись и
// срок сам. Этот сервер нужен для:
// - реестра выданных лицензий (учёт);
// - ОТЗЫВА (revocation) до окончания срока;
// - проверки клиентом «не отозвана ли» (опциональный online-чек).
// Выдаёт и продлевает лицензии установкам bj по коду активации (контракт —
// docs/license-server-api.md) и даёт веб-кабинет администратора: клиенты, лицензии,
// установки, журнал, администраторы. Вход в кабинет — логин и пароль.
//
// Хранилище — JSON-файл со списком отозванных ID (для каркаса; в проде —
// PostgreSQL). API:
// Хранилище — один JSON-файл с атомарной записью и ежедневными копиями рядом: клиентов
// десятки, записей немного, отдельная СУБД не нужна.
//
// GET /v1/check?id=<license-id> → {"revoked":bool}
// GET /healthz
//
// Управление отзывом — правкой файла revoked.json (или будущим admin API).
// 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 (
"encoding/json"
"bufio"
"crypto/ed25519"
"flag"
"fmt"
"log"
"net/http"
"os"
"sync"
"strings"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license"
)
type store struct {
mu sync.RWMutex
path string
revoked map[string]bool
}
func newStore(path string) *store {
s := &store{path: path, revoked: map[string]bool{}}
s.load()
return s
}
func (s *store) load() {
s.mu.Lock()
defer s.mu.Unlock()
b, err := os.ReadFile(s.path)
if err != nil {
return // файла нет — пустой список
}
var ids []string
if err := json.Unmarshal(b, &ids); err != nil {
log.Printf("license-server: разбор %s: %v", s.path, err)
return
}
s.revoked = map[string]bool{}
for _, id := range ids {
s.revoked[id] = true
}
log.Printf("license-server: загружено отозванных лицензий: %d", len(s.revoked))
}
func (s *store) isRevoked(id string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.revoked[id]
}
func main() {
addr := flag.String("addr", ":8091", "адрес прослушивания")
file := flag.String("revoked", "./revoked.json", "JSON-файл со списком отозванных license ID")
flag.Parse()
st := newStore(*file)
// Перечитываем файл отзывов раз в минуту (горячее применение).
go func() {
t := time.NewTicker(time.Minute)
defer t.Stop()
for range t.C {
st.load()
if len(os.Args) < 2 {
usage()
}
}()
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
mux.HandleFunc("/v1/check", func(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
if id == "" {
http.Error(w, "id required", http.StatusBadRequest)
return
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)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]bool{"revoked": st.isRevoked(id)})
})
log.Printf("license-server: слушаю %s, отзывы из %s", *addr, *file)
srv := &http.Server{Addr: *addr, Handler: mux, ReadHeaderTimeout: 10 * time.Second}
log.Fatal(srv.ListenAndServe())
}
+460
View File
@@ -0,0 +1,460 @@
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)
}
+484
View File
@@ -0,0 +1,484 @@
{{define "style"}}<style>
:root{--bg:#f4f5f9;--card:#fff;--card2:#f8f9fc;--text:#1a1d26;--muted:#6b7280;--accent:#3b4fd8;--accent2:#e8562a;--border:#e3e5ee;--ok:#15803d;--warn:#b45309;--bad:#b91c1c;--okbg:#e8f6ee;--warnbg:#fdf3e2;--badbg:#fdecec}
@media (prefers-color-scheme:dark){:root{--bg:#131620;--card:#1c202d;--card2:#232838;--text:#e6e8f0;--muted:#9aa0b3;--accent:#8190ff;--border:#2d3244;--ok:#4ade80;--warn:#fbbf24;--bad:#f87171;--okbg:#16301f;--warnbg:#33280f;--badbg:#3a1a1a}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
header{background:var(--card);border-bottom:1px solid var(--border);padding:0 20px;display:flex;align-items:center;gap:24px;flex-wrap:wrap;min-height:56px}
.brand{display:flex;align-items:center;gap:9px;font-weight:700;font-size:16px;white-space:nowrap;color:var(--text)}
.brand em{font-style:normal;color:var(--accent)}.brand small{font-weight:500;color:var(--muted);font-size:13px}
nav{display:flex;gap:4px;flex-wrap:wrap;flex:1}
nav a{padding:7px 11px;border-radius:8px;color:var(--muted);font-weight:500}
nav a.on{background:var(--card2);color:var(--text)}nav a:hover{text-decoration:none;color:var(--text)}
.me{display:flex;align-items:center;gap:10px;color:var(--muted);font-size:13px}
main{max-width:1180px;margin:0 auto;padding:22px 20px 60px}
h1{font-size:22px;margin:0 0 16px}h2{font-size:16px;margin:0 0 12px}
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin-bottom:16px}
.grid{display:grid;gap:16px;grid-template-columns:repeat(auto-fit,minmax(240px,1fr))}
.stats{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));margin-bottom:16px}
.stat{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:14px 16px}
.stat b{display:block;font-size:26px;line-height:1.2}.stat span{color:var(--muted);font-size:13px}
.table-wrap{overflow-x:auto}
table{width:100%;border-collapse:collapse}
th{text-align:left;font-size:12px;font-weight:600;color:var(--muted);padding:8px 10px;border-bottom:1px solid var(--border);white-space:nowrap}
td{padding:9px 10px;border-bottom:1px solid var(--border);vertical-align:top}
tr:last-child td{border-bottom:0}
.muted{color:var(--muted)}.small{font-size:12px}
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px;font-weight:600;white-space:nowrap}
.badge.ok{background:var(--okbg);color:var(--ok)}.badge.warn{background:var(--warnbg);color:var(--warn)}.badge.bad{background:var(--badbg);color:var(--bad)}
.chip{display:inline-block;padding:1px 7px;margin:1px 2px 1px 0;border:1px solid var(--border);border-radius:6px;font-size:12px;background:var(--card2)}
.flash{padding:10px 14px;border-radius:10px;margin-bottom:16px;font-weight:500}
.flash.ok{background:var(--okbg);color:var(--ok)}.flash.err{background:var(--badbg);color:var(--bad)}
form.f{display:grid;gap:12px}
label{display:block;font-size:12px;color:var(--muted);margin-bottom:4px}
input[type=text],input[type=password],input[type=number],input[type=date],select,textarea{width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--text);font:inherit}
textarea{min-height:64px;resize:vertical}
input:focus,select:focus,textarea:focus{outline:2px solid var(--accent);outline-offset:1px;border-color:transparent}
.row2{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(200px,1fr))}
.checks{display:flex;flex-wrap:wrap;gap:6px 16px}.checks label{display:flex;gap:6px;align-items:center;font-size:14px;color:var(--text);margin:0}
.btn{display:inline-block;padding:8px 14px;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--text);font:inherit;font-weight:600;cursor:pointer}
.btn:hover{background:var(--card2);text-decoration:none}
.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}.btn.primary:hover{filter:brightness(1.07)}
.btn.danger{color:var(--bad)}.btn.sm{padding:4px 9px;font-size:12px}
.actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.code{font:600 20px/1.3 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.06em;padding:10px 14px;background:var(--card2);border:1px dashed var(--border);border-radius:10px;display:inline-block;user-select:all}
.token{font:12px/1.4 ui-monospace,monospace;word-break:break-all;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:10px;user-select:all}
.kv{display:grid;grid-template-columns:minmax(140px,max-content) 1fr;gap:6px 16px}.kv dt{color:var(--muted)}.kv dd{margin:0}
details summary{cursor:pointer;font-weight:600}
.mono{font-family:ui-monospace,monospace;font-size:12px}
.none::before{content:"\2014";color:var(--muted)} /* пустое значение */
@media (max-width:640px){header{padding:8px 16px;gap:8px}main{padding:16px}.kv{grid-template-columns:1fr}}
</style>{{end}}
{{define "logo"}}<svg width="24" height="24" viewBox="0 0 26 26" fill="none" aria-hidden="true"><path d="M5.5 16 C 9 8.5, 17 8.5, 20.5 16" stroke="#3b4fd8" stroke-width="1.9" stroke-linecap="round"/><circle cx="5.5" cy="16" r="3.1" fill="#3b4fd8"/><circle cx="20.5" cy="16" r="3.1" fill="#e8562a"/></svg>{{end}}
{{define "head"}}<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · лицензии Bridge&amp;Join</title>{{template "style"}}</head><body>
<header>
<a class="brand" href="{{adm}}/">{{template "logo"}}<span>Bridge<em>&amp;Join</em></span><small>лицензии</small></a>
<nav>
<a href="{{adm}}/" {{if eq .Nav "dash"}}class="on"{{end}}>Обзор</a>
<a href="{{adm}}/tenants" {{if eq .Nav "tenants"}}class="on"{{end}}>Клиенты</a>
<a href="{{adm}}/licenses" {{if eq .Nav "licenses"}}class="on"{{end}}>Лицензии</a>
<a href="{{adm}}/installs" {{if eq .Nav "installs"}}class="on"{{end}}>Установки</a>
<a href="{{adm}}/security" {{if eq .Nav "security"}}class="on"{{end}}>Защита</a>
<a href="{{adm}}/events" {{if eq .Nav "events"}}class="on"{{end}}>Журнал</a>
<a href="{{adm}}/users" {{if eq .Nav "users"}}class="on"{{end}}>Администраторы</a>
</nav>
<div class="me">{{if .Me}}<span>{{if .Me.Name}}{{.Me.Name}}{{else}}{{.Me.Login}}{{end}}</span>
<form method="post" action="{{adm}}/logout" style="margin:0"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="btn sm" type="submit">Выйти</button></form>{{end}}</div>
</header>
<main>
{{if .OK}}<div class="flash ok">{{.OK}}</div>{{end}}
{{if .Err}}<div class="flash err">{{.Err}}</div>{{end}}
{{end}}
{{define "foot"}}</main></body></html>{{end}}
{{define "login"}}<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Вход · лицензии Bridge&amp;Join</title>{{template "style"}}</head>
<body style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:16px">
<div class="card" style="width:100%;max-width:380px;padding:28px 30px">
<div class="brand" style="font-size:20px;margin-bottom:4px">{{template "logo"}}<span>Bridge<em>&amp;Join</em></span></div>
<p class="muted" style="margin:0 0 18px;font-size:13px">Сервер лицензирования: вход администратора</p>
<form class="f" method="post" action="{{adm}}/login">
<input type="hidden" name="next" value="{{.Data.Next}}">
<div><label for="login">Логин</label><input id="login" type="text" name="login" value="{{.Data.Login}}" autocomplete="username" autofocus required></div>
<div><label for="password">Пароль</label><input id="password" type="password" name="password" autocomplete="current-password" required></div>
<div><label for="code">Код из приложения <span class="muted" style="font-weight:400">(если подключён второй фактор)</span></label><input id="code" type="text" name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6"></div>
<button class="btn primary" type="submit" style="width:100%;padding:10px">Войти</button>
{{if .Err}}<div class="flash err" style="margin:0">{{.Err}}</div>{{end}}
</form>
</div></body></html>{{end}}
{{define "login2fa"}}<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Подтверждение входа · лицензии Bridge&amp;Join</title>{{template "style"}}</head>
<body style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:16px">
<div class="card" style="width:100%;max-width:400px;padding:28px 30px">
<div class="brand" style="font-size:20px;margin-bottom:4px">{{template "logo"}}<span>Bridge<em>&amp;Join</em></span></div>
<h2 style="margin:10px 0 6px">Подтвердите вход</h2>
<p class="muted" style="margin:0 0 6px;font-size:13px" id="zk-msg">Запрос отправлен в приложение «ЗЕТИТ Ключ» ({{.Data.Email}}). Подтвердите вход на телефоне — страница продолжит сама.</p>
<p style="font-size:30px;font-weight:700;margin:4px 0 14px" id="zk-count">{{.Data.Left}}</p>
<form id="zk-finish" method="post" action="{{adm}}/login/2fa/finish"><input type="hidden" name="t" value="{{.Data.Token}}"></form>
<form class="f" method="post" action="{{adm}}/login/2fa/code">
<input type="hidden" name="t" value="{{.Data.Token}}">
<div><label for="code">Или введите код из приложения</label><input id="code" type="text" name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required></div>
<button class="btn primary" type="submit" style="width:100%;padding:10px">Войти с кодом</button>
{{if .Err}}<div class="flash err" style="margin:0">{{.Err}}</div>{{end}}
</form>
<p class="small" style="margin:14px 0 0"><a href="{{adm}}/login">Отмена</a></p>
</div>
<script>
(function(){
var token = {{.Data.Token}}, left = {{.Data.Left}}, base = {{adm}}, done = false;
var cnt = document.getElementById('zk-count'), msg = document.getElementById('zk-msg');
function expired(){ msg.textContent = 'Время подтверждения истекло. Введите код из приложения или войдите заново.'; }
var tick = setInterval(function(){
left = Math.max(0, left - 1); cnt.textContent = left;
if (left === 0) { clearInterval(tick); if (!done) expired(); }
}, 1000);
function poll(){
if (done || left <= 0) return;
fetch(base + '/login/2fa/status?t=' + encodeURIComponent(token), {credentials: 'same-origin', cache: 'no-store'})
.then(function(r){ return r.json(); })
.then(function(j){
if (j.status === 'approved' || j.status === 'denied') {
done = true;
msg.textContent = j.status === 'approved' ? 'Вход подтверждён.' : 'Вход отклонён в приложении.';
document.getElementById('zk-finish').submit();
return;
}
if (j.status === 'expired') { done = true; expired(); return; }
setTimeout(poll, 2500);
})
.catch(function(){ setTimeout(poll, 4000); });
}
if (left > 0) setTimeout(poll, 2000); else expired();
})();
</script></body></html>{{end}}
{{define "evtable"}}<div class="table-wrap"><table>
<thead><tr><th>Когда</th><th>Событие</th><th>Клиент</th><th>Кто</th><th>IP</th></tr></thead><tbody>
{{range .}}<tr>
<td class="small" style="white-space:nowrap">{{dt .At}}</td>
<td>{{.Text}}{{if .InstallID}} <span class="mono muted">{{short .InstallID}}</span>{{end}}</td>
<td>{{if .LicenseID}}<a href="{{adm}}/licenses/{{.LicenseID}}">{{if .TenantName}}{{.TenantName}}{{else}}{{short .LicenseID}}{{end}}</a>{{else}}<span class="none"></span>{{end}}</td>
<td class="small">{{.Actor}}</td><td class="small mono">{{.IP}}</td>
</tr>{{else}}<tr><td colspan="5" class="muted">Событий пока нет</td></tr>{{end}}
</tbody></table></div>{{end}}
{{define "dashboard"}}{{template "head" .}}
<h1>Обзор</h1>
<div class="stats">
<div class="stat"><b>{{.Data.Tenants}}</b><span>клиентов</span></div>
<div class="stat"><b>{{.Data.Active}}</b><span>действующих лицензий</span></div>
<div class="stat"><b>{{.Data.Revoked}}</b><span>отозвано</span></div>
<div class="stat"><b>{{.Data.Installs}}</b><span>установок всего</span></div>
<div class="stat"><b>{{.Data.Online}}</b><span>на связи за 48 ч</span></div>
</div>
{{if .Data.Expiring}}<div class="card"><h2>Требуют внимания</h2><div class="table-wrap"><table>
<thead><tr><th>Клиент</th><th>План</th><th>Договор до</th><th>Статус</th></tr></thead><tbody>
{{range .Data.Expiring}}<tr><td><a href="{{adm}}/licenses/{{.ID}}">{{.TenantName}}</a></td><td>{{plan .Plan}}</td><td>{{d .ContractUntil}}</td><td><span class="badge {{.StatusClass}}">{{.Status}}</span></td></tr>{{end}}
</tbody></table></div></div>{{end}}
<div class="card"><h2>Последние события</h2>{{template "evtable" .Data.Events}}<p class="small" style="margin:10px 0 0"><a href="{{adm}}/events">Весь журнал →</a></p></div>
{{template "foot" .}}{{end}}
{{define "tenants"}}{{template "head" .}}
<h1>Клиенты</h1>
<div class="card"><div class="table-wrap"><table>
<thead><tr><th>Клиент</th><th>ИНН</th><th>Контакт</th><th>Лицензий</th><th>Установок</th><th>Добавлен</th></tr></thead><tbody>
{{range .Data}}<tr><td><a href="{{adm}}/tenants/{{.ID}}"><b>{{.Name}}</b></a></td><td class="mono">{{.INN}}</td><td class="small">{{.Contact}}</td><td>{{.Licenses}}</td><td>{{.Installs}}</td><td class="small">{{d .CreatedAt}}</td></tr>
{{else}}<tr><td colspan="6" class="muted">Клиентов пока нет. Добавьте первого ниже.</td></tr>{{end}}
</tbody></table></div></div>
<div class="card"><h2>Новый клиент</h2>
<form class="f" method="post" action="{{adm}}/tenants"><input type="hidden" name="csrf" value="{{.CSRF}}">
<div class="row2">
<div><label>Название</label><input type="text" name="name" required placeholder="АО «ИК Фонтвьель»"></div>
<div><label>ИНН</label><input type="text" name="inn" inputmode="numeric" pattern="\d{10}|\d{12}" placeholder="10 или 12 цифр"></div>
</div>
<div><label>Контакт</label><input type="text" name="contact" placeholder="ответственный, почта, телефон"></div>
<div><label>Примечание</label><textarea name="note"></textarea></div>
<div><button class="btn primary" type="submit">Добавить клиента</button></div>
</form></div>
{{template "foot" .}}{{end}}
{{define "tenant"}}{{template "head" .}}
<p class="small" style="margin:0 0 6px"><a href="{{adm}}/tenants">← Клиенты</a></p>
<h1>{{.Data.Tenant.Name}}</h1>
<div class="card"><div class="actions" style="justify-content:space-between;margin-bottom:10px"><h2 style="margin:0">Лицензии</h2><a class="btn primary" href="{{adm}}/licenses?tenant={{.Data.Tenant.ID}}#new">Выпустить лицензию</a></div>
<div class="table-wrap"><table>
<thead><tr><th>Лицензия</th><th>План</th><th>Установок</th><th>Договор до</th><th>Статус</th></tr></thead><tbody>
{{range .Data.Licenses}}<tr><td><a class="mono" href="{{adm}}/licenses/{{.ID}}">{{.Code}}</a></td><td>{{plan .Plan}}</td><td>{{.Active}} из {{if .MaxNodes}}{{.MaxNodes}}{{else}}∞{{end}}</td><td>{{d .ContractUntil}}</td><td><span class="badge {{.StatusClass}}">{{.Status}}</span></td></tr>
{{else}}<tr><td colspan="5" class="muted">Лицензий нет</td></tr>{{end}}
</tbody></table></div></div>
<div class="card"><h2>Реквизиты</h2>
<form class="f" method="post" action="{{adm}}/tenants/{{.Data.Tenant.ID}}"><input type="hidden" name="csrf" value="{{.CSRF}}">
<div class="row2">
<div><label>Название</label><input type="text" name="name" value="{{.Data.Tenant.Name}}" required></div>
<div><label>ИНН</label><input type="text" name="inn" value="{{.Data.Tenant.INN}}" inputmode="numeric" pattern="\d{10}|\d{12}"></div>
</div>
<div><label>Контакт</label><input type="text" name="contact" value="{{.Data.Tenant.Contact}}"></div>
<div><label>Примечание</label><textarea name="note">{{.Data.Tenant.Note}}</textarea></div>
<div class="actions"><button class="btn primary" type="submit">Сохранить</button><span class="muted small">добавлен {{dt .Data.Tenant.CreatedAt}}</span></div>
</form></div>
{{template "foot" .}}{{end}}
{{define "terms"}}
<div class="row2">
<div><label>План</label><select name="plan">{{$cur := .Plan}}{{range $.Plans}}<option value="{{.Key}}" {{if eq .Key $cur}}selected{{end}}>{{.Title}}</option>{{end}}</select></div>
<div><label>Установок (0 = без лимита)</label><input type="number" name="max_nodes" min="0" max="1000" value="{{.MaxNodes}}"></div>
<div><label>Договор действует до</label><input type="date" name="contract_until" value="{{dateInput .ContractUntil}}" required></div>
</div>
<div><label>Модули</label><div class="checks">{{$f := .Features}}{{range $.All}}<label><input type="checkbox" name="features" value="{{.Key}}" {{if has $f .Key}}checked{{end}}> {{.Title}}</label>{{end}}</div></div>
<div><label>Примечание (видно только здесь)</label><textarea name="note">{{.Note}}</textarea></div>
{{end}}
{{define "licenses"}}{{template "head" .}}
<h1>Лицензии</h1>
<div class="card"><div class="table-wrap"><table>
<thead><tr><th>Клиент</th><th>Код активации</th><th>План</th><th>Модули</th><th>Установок</th><th>Договор до</th><th>Статус</th></tr></thead><tbody>
{{range .Data.Rows}}<tr>
<td><a href="{{adm}}/licenses/{{.ID}}"><b>{{.TenantName}}</b></a></td><td class="mono">{{.Code}}</td><td>{{plan .Plan}}</td>
<td>{{range .Features}}<span class="chip">{{feature .}}</span>{{else}}<span class="none"></span>{{end}}</td>
<td>{{.Active}} из {{if .MaxNodes}}{{.MaxNodes}}{{else}}∞{{end}}</td><td>{{d .ContractUntil}}</td><td><span class="badge {{.StatusClass}}">{{.Status}}</span></td>
</tr>{{else}}<tr><td colspan="7" class="muted">Лицензий пока нет.</td></tr>{{end}}
</tbody></table></div></div>
<div class="card" id="new"><h2>Выпустить лицензию</h2>
{{if .Data.Tenants}}
<form class="f" method="post" action="{{adm}}/licenses"><input type="hidden" name="csrf" value="{{.CSRF}}">
<div><label>Клиент</label><select name="tenant_id" required>{{$sel := .Data.Tenant}}{{range .Data.Tenants}}<option value="{{.ID}}" {{if eq .ID $sel}}selected{{end}}>{{.Name}}{{if .INN}} · ИНН {{.INN}}{{end}}</option>{{end}}</select></div>
{{template "terms" (dict2 .Plans .Features)}}
<div><button class="btn primary" type="submit">Выпустить и получить код активации</button></div>
</form>
{{else}}<p class="muted">Сначала <a href="{{adm}}/tenants">добавьте клиента</a>.</p>{{end}}
</div>
{{template "foot" .}}{{end}}
{{define "license"}}{{template "head" .}}{{$row := .Data.Row}}{{$csrf := .CSRF}}
<p class="small" style="margin:0 0 6px"><a href="{{adm}}/licenses">← Лицензии</a> · <a href="{{adm}}/tenants/{{$row.TenantID}}">{{$row.TenantName}}</a></p>
<h1>{{$row.TenantName}} <span class="badge {{$row.StatusClass}}" style="vertical-align:middle">{{$row.Status}}</span></h1>
{{if .Data.OfflineToken}}<div class="card" style="border-color:var(--accent)"><h2>Офлайн-ключ выпущен</h2>
<p class="muted small" style="margin-top:0">Ключ показывается один раз. Вставьте его в bj: Настройка → Лицензия. Действует до {{d $row.ContractUntil}} без связи с сервером; отзыв на него не распространяется.</p>
<div class="token">{{.Data.OfflineToken}}</div></div>{{end}}
<div class="grid">
<div class="card"><h2>Код активации</h2>
<div class="code">{{$row.Code}}</div>
<p class="muted small">Клиент вводит код в bj (Настройка → Лицензия → «Активировать»). Одним кодом активируется до {{if $row.MaxNodes}}{{$row.MaxNodes}}{{else}}любого числа{{end}} установок; сейчас активных {{$row.Active}}.</p>
<form method="post" action="{{adm}}/licenses/{{$row.ID}}/code" onsubmit="return confirm('Выпустить новый код? Прежний перестанет действовать; активированные установки продолжат работать.')">
<input type="hidden" name="csrf" value="{{$csrf}}"><button class="btn sm" type="submit">Выпустить новый код</button></form>
</div>
<div class="card"><h2>Сводка</h2><dl class="kv">
<dt>План</dt><dd>{{plan $row.Plan}}</dd>
<dt>Модули</dt><dd>{{range $row.Features}}<span class="chip">{{feature .}}</span>{{else}}<span class="none"></span>{{end}}</dd>
<dt>Договор до</dt><dd>{{d $row.ContractUntil}}{{if not $row.Revoked}} <span class="muted small">(осталось {{daysLeft $row.ContractUntil}} дн.)</span>{{end}}</dd>
<dt>Выпущена</dt><dd>{{dt $row.CreatedAt}}{{if $row.CreatedBy}} · {{$row.CreatedBy}}{{end}}</dd>
<dt>ID</dt><dd class="mono">{{$row.ID}}</dd>
{{if $row.Revoked}}<dt>Отозвана</dt><dd>{{dt $row.RevokedAt}}: {{$row.RevokeReason}}</dd>{{end}}
</dl></div>
</div>
<div class="card"><h2>Установки</h2><div class="table-wrap"><table>
<thead><tr><th>Сервер</th><th>Версия</th><th>IP</th><th>Первая активация</th><th>Последнее продление</th><th>Токен до</th><th></th></tr></thead><tbody>
{{range .Data.Installs}}<tr>
<td><b>{{if .Hostname}}{{.Hostname}}{{else}}<span class="none"></span>{{end}}</b><div class="mono muted">{{.InstallID}}</div>{{if .Blocked}}<span class="badge bad">заблокирована</span> <span class="small">{{.BlockReason}}</span>{{else if .Stale}}<span class="badge warn">нет связи</span>{{end}}</td>
<td class="small">{{.Version}}</td><td class="small mono">{{.IP}}</td><td class="small">{{dt .FirstSeen}}</td>
<td class="small">{{dt .LastSeen}}<div class="muted">{{ago .LastSeen}}</div></td><td class="small">{{d .TokenExpires}}</td>
<td>{{if .Blocked}}<form method="post" action="{{adm}}/licenses/{{$row.ID}}/installs/{{.InstallID}}/unblock"><input type="hidden" name="csrf" value="{{$csrf}}"><button class="btn sm" type="submit">Разблокировать</button></form>
{{else}}<details><summary class="small">Заблокировать</summary><form class="f" method="post" action="{{adm}}/licenses/{{$row.ID}}/installs/{{.InstallID}}/block" style="margin-top:6px;min-width:200px"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="text" name="reason" placeholder="причина" required><button class="btn sm danger" type="submit">Заблокировать</button></form></details>{{end}}</td>
</tr>{{else}}<tr><td colspan="7" class="muted">Установок ещё нет: они появятся после активации кодом.</td></tr>{{end}}
</tbody></table></div></div>
<div class="card"><h2>Условия</h2>
<form class="f" method="post" action="{{adm}}/licenses/{{$row.ID}}/edit"><input type="hidden" name="csrf" value="{{$csrf}}">
{{template "terms" (dict3 .Plans .Features $row.License)}}
<div class="actions"><button class="btn primary" type="submit">Сохранить условия</button><span class="muted small">установки получат их при следующем продлении</span></div>
</form></div>
<div class="grid">
<div class="card"><h2>Офлайн-ключ</h2>
<p class="muted small" style="margin-top:0">Для закрытого контура без доступа к серверу: ключ без привязки к установке, действует до окончания договора. Отозвать его нельзя.</p>
<form method="post" action="{{adm}}/licenses/{{$row.ID}}/offline" onsubmit="return confirm('Выпустить офлайн-ключ до {{d $row.ContractUntil}}? Отозвать его будет нельзя.')"><input type="hidden" name="csrf" value="{{$csrf}}"><button class="btn" type="submit" {{if ne $row.StatusClass "ok"}}{{if ne $row.StatusClass "warn"}}disabled{{end}}{{end}}>Выпустить офлайн-ключ</button></form>
</div>
<div class="card"><h2>{{if $row.Revoked}}Восстановление{{else}}Отзыв{{end}}</h2>
{{if $row.Revoked}}
<form method="post" action="{{adm}}/licenses/{{$row.ID}}/restore"><input type="hidden" name="csrf" value="{{$csrf}}"><button class="btn" type="submit">Восстановить лицензию</button></form>
{{else}}
<p class="muted small" style="margin-top:0">Продления прекращаются; выданные токены доживают свой срок (до 60 дней).</p>
<form class="f" method="post" action="{{adm}}/licenses/{{$row.ID}}/revoke" onsubmit="return confirm('Отозвать лицензию?')"><input type="hidden" name="csrf" value="{{$csrf}}">
<input type="text" name="reason" placeholder="причина отзыва" required><div><button class="btn danger" type="submit">Отозвать лицензию</button></div></form>
{{end}}
</div>
</div>
<div class="card"><h2>Журнал лицензии</h2>{{template "evtable" .Data.Events}}</div>
{{template "foot" .}}{{end}}
{{define "installs"}}{{template "head" .}}{{$csrf := .CSRF}}
<h1>Установки</h1>
<div class="card"><div class="table-wrap"><table>
<thead><tr><th>Клиент</th><th>Сервер</th><th>Версия</th><th>IP</th><th>Последнее продление</th><th>Токен до</th><th>Статус</th></tr></thead><tbody>
{{range .Data.Installs}}<tr>
<td><a href="{{adm}}/licenses/{{.LicenseID}}">{{if .TenantName}}{{.TenantName}}{{else}}{{short .LicenseID}}{{end}}</a></td>
<td><b>{{if .Hostname}}{{.Hostname}}{{else}}<span class="none"></span>{{end}}</b><div class="mono muted">{{short .InstallID}}</div></td>
<td class="small">{{.Version}}</td><td class="small mono">{{.IP}}</td><td class="small">{{dt .LastSeen}}<div class="muted">{{ago .LastSeen}}</div></td><td class="small">{{d .TokenExpires}}</td>
<td>{{if .Blocked}}<span class="badge bad">заблокирована</span>{{else if .Stale}}<span class="badge warn">нет связи</span>{{else}}<span class="badge ok">на связи</span>{{end}}</td>
</tr>{{else}}<tr><td colspan="7" class="muted">Установок пока нет.</td></tr>{{end}}
</tbody></table></div></div>
<div class="card"><h2>Доверенные IP (fail2ban)</h2>
<p class="muted small" style="margin:0 0 10px">Адрес попадает сюда после успешной активации или продления лицензии. fail2ban не блокирует его за ошибки API (неверный код, испорченный токен). На вход в кабинет и SSH доверие не распространяется. При отзыве лицензии или блокировке установки адрес исключается автоматически.</p>
<div class="table-wrap"><table>
<thead><tr><th>IP</th><th>Клиент</th><th>Сервер</th><th>Добавлен</th><th>Последнее обращение</th><th></th></tr></thead><tbody>
{{range .Data.Trusted}}<tr>
<td class="mono"><b>{{.IP}}</b></td>
<td><a href="{{adm}}/licenses/{{.LicenseID}}">{{if .TenantName}}{{.TenantName}}{{else}}{{short .LicenseID}}{{end}}</a></td>
<td class="small">{{if .Hostname}}{{.Hostname}}{{else}}<span class="none"></span>{{end}}<div class="mono muted">{{short .InstallID}}</div></td>
<td class="small">{{dt .AddedAt}}</td><td class="small">{{dt .LastSeen}}<div class="muted">{{ago .LastSeen}}</div></td>
<td><form method="post" action="{{adm}}/trusted/remove"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="ip" value="{{.IP}}"><input type="hidden" name="install" value="{{.InstallID}}"><button class="btn sm danger" type="submit">Исключить</button></form></td>
</tr>{{else}}<tr><td colspan="6" class="muted">Доверенных адресов пока нет: они появятся после первой активации или продления.</td></tr>{{end}}
</tbody></table></div></div>
{{template "foot" .}}{{end}}
{{define "security"}}{{template "head" .}}{{$csrf := .CSRF}}{{$d := .Data}}
<h1>Защита</h1>
<div class="card"><h2>Доступ к кабинету</h2>
<div class="grid">
<div>
<h3 style="margin:0 0 6px;font-size:15px">Адрес кабинета</h3>
<p style="margin:0 0 8px"><span class="mono"><b>https://{{$d.Host}}{{$d.AdminBase}}/</b></span> {{if $d.AdminDefault}}<span class="badge warn">стандартный</span>{{else}}<span class="badge ok">свой</span>{{end}}</p>
<form method="post" action="{{adm}}/security/access" class="actions" onsubmit="return confirm('Сменить адрес кабинета? Прежний перестанет открываться.')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="path"><input name="path" placeholder="свой адрес, 8–64 символа: a-z, 0-9, дефис" pattern="[a-z0-9][a-z0-9\-]{7,63}" required style="max-width:280px"><button class="btn" type="submit">Сохранить</button></form>
<div class="actions" style="margin-top:6px">
<form method="post" action="{{adm}}/security/access" onsubmit="return confirm('Сгенерировать новый адрес кабинета? Прежний перестанет открываться.')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="path-random"><button class="btn" type="submit">Сгенерировать случайный</button></form>
{{if not $d.AdminDefault}}<form method="post" action="{{adm}}/security/access" onsubmit="return confirm('Вернуть стандартный адрес /admin?')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="path-default"><button class="btn sm" type="submit">Вернуть /admin</button></form>{{end}}
</div>
<p class="muted small" style="margin:8px 0 0">При своём адресе /admin и главная страница отвечают «не найдено», и боты не находят форму входа. Сохраните адрес в закладки и не пересылайте. Узнать по SSH: <span class="mono">/opt/bj-license/bj-license-server admin-info</span></p>
</div>
<div>
<h3 style="margin:0 0 6px;font-size:15px">Только из белого списка</h3>
<p style="margin:0 0 8px">{{if and $d.AdminAllowOnly $d.AdminOpen}}<span class="badge warn">открыт аварийно по SSH</span> Ограничение включено, но сейчас не действует.{{else if $d.AdminAllowOnly}}<span class="badge ok">включено</span> Кабинет и страница входа видны только адресам белого списка администраторов, остальным — «не найдено».{{else}}<span class="badge warn">выключено</span> Страница входа открывается с любого адреса.{{end}}</p>
{{if and $d.AdminAllowOnly (not $d.AdminOpen)}}<form method="post" action="{{adm}}/security/access" onsubmit="return confirm('Снять ограничение? Страница входа станет доступна с любого адреса.')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="allow-only-off"><button class="btn" type="submit">Снять ограничение</button></form>
{{else}}<form method="post" action="{{adm}}/security/access" onsubmit="return confirm('Открывать кабинет только с адресов белого списка?')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="allow-only-on"><button class="btn primary" type="submit">Включить ограничение</button></form>{{end}}
<p class="muted small" style="margin:8px 0 0">API активации и продления это не затрагивает. Ваш адрес <span class="mono">{{$d.MyIP}}</span> {{if $d.MyIPCovered}}в белом списке{{else}}добавится в белый список при включении{{end}}. Аварийно открыть кабинет по SSH: <span class="mono">/opt/bj-license/bj-license-server fw-apply -off</span></p>
</div>
<div>
<h3 style="margin:0 0 6px;font-size:15px">Второй фактор</h3>
<p style="margin:0 0 8px">{{if eq $d.TwoFA $d.Admins}}<span class="badge ok">у всех</span>{{else}}<span class="badge warn">{{$d.TwoFA}} из {{$d.Admins}}</span>{{end}} администраторов входят с кодом из приложения.</p>
<div class="actions"><a class="btn" href="{{adm}}/me/2fa">Мой второй фактор</a><a class="btn" href="{{adm}}/users">Администраторы</a></div>
<p class="muted small" style="margin:8px 0 0">Даже зная пароль, без телефона войти нельзя. Потерянный телефон: второй фактор сбрасывает другой администратор или по SSH <span class="mono">admin-config -reset-2fa логин</span> при остановленном сервере.</p>
</div>
</div></div>
<div class="card"><h2>ЗЕТИТ Ключ</h2>
<p style="margin:0 0 10px">{{if $d.ZKEnabled}}<span class="badge ok">включён</span> После пароля вход подтверждается push-уведомлением в приложении «ЗЕТИТ Ключ» или кодом из него. Действует для администраторов с указанным e-mail ({{$d.ZKWithEmail}} из {{$d.Admins}}).{{else}}<span class="badge warn">выключен</span> Второй фактор через приложение «ЗЕТИТ Ключ», как при входе в ZETIT Connect.{{end}}</p>
<form class="f" method="post" action="{{adm}}/security/zetit-key" style="max-width:620px"><input type="hidden" name="csrf" value="{{$csrf}}">
<div><label>Адрес API</label><input type="url" name="url" value="{{$d.ZKURL}}" placeholder="https://key.zetit.ru/api/v1/connect-auth"></div>
<div><label>Ключ приложения (X-Connect-Key)</label><input type="password" name="app_key" autocomplete="off" placeholder="{{if $d.ZKHasKey}}задан — оставьте пустым, чтобы не менять{{else}}панель ЗЕТИТ Ключ → Система → Внешние приложения → Добавить{{end}}"></div>
<label style="display:flex;gap:8px;align-items:center;font-weight:600"><input type="checkbox" name="enabled" value="1" {{if $d.ZKEnabled}}checked{{end}}> Требовать подтверждение в ЗЕТИТ Ключ при входе</label>
<div class="actions"><button class="btn primary" type="submit" name="op" value="save">Сохранить</button><button class="btn" type="submit" name="op" value="test">Проверить мой e-mail</button></div>
</form>
<p class="muted small" style="margin:10px 0 0">E-mail администраторов задаётся на странице <a href="{{adm}}/users">«Администраторы»</a> и должен совпадать с учётной записью в ЗЕТИТ Ключ. Включить можно, только если ваш e-mail найден в Ключе с привязанным приложением. У администраторов без e-mail действует пароль и код приложения-аутентификатора, если он подключён. Если Ключ недоступен, вход с e-mail невозможен — аварийно выключить по SSH при остановленном сервере: <span class="mono">bj-license-server admin-config -zetit-key off</span></p>
</div>
{{if not $d.StatusFresh}}<div class="card"><span class="badge bad">Помощник защиты не отвечает</span>
<p class="small" style="margin:8px 0 0">{{if $d.Status.At.IsZero}}Статуса ещё нет.{{else}}Последний отчёт {{dt $d.Status.At}} ({{ago $d.Status.At}}).{{end}} Команды из этого раздела не применяются. На сервере: <span class="mono">systemctl status bj-license-fw.timer bj-license-fw.path</span></p></div>{{end}}
<div class="card">
<h2>Режим обороны</h2>
<p>{{if $d.Status.Lockdown}}<span class="badge bad">Оборона включена</span> На сайт и API пускаются только адреса из белого списка ({{$d.Status.AllowCount}}){{if not $d.Status.Until.IsZero}}, автоматически выключится {{dt $d.Status.Until}}{{end}}.
{{else if $d.Status.Emergency}}<span class="badge warn">Оборона выключена аварийно по SSH</span> Включить снова можно кнопкой ниже.
{{else}}<span class="badge ok">Обычный режим</span> Доступ открыт всем, fail2ban блокирует адреса, которые перебирают пароли и коды.{{end}}</p>
{{if $d.WantedActive}}
<form method="post" action="{{adm}}/security/mode" class="actions" onsubmit="return confirm('Выключить режим обороны и открыть доступ всем?')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="off"><button class="btn primary" type="submit">Выключить оборону</button></form>
{{else}}
<form method="post" action="{{adm}}/security/mode" class="actions" onsubmit="return confirm('Включить режим обороны? Сайт и API станут недоступны всем, кроме белого списка.')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="on">
<select name="hours"><option value="1">на 1 час</option><option value="6">на 6 часов</option><option value="24" selected>на 24 часа</option><option value="0">без срока</option></select>
<button class="btn danger" type="submit">Включить оборону</button></form>
{{end}}
<p class="muted small" style="margin:10px 0 0">Для атаки на сервер: все, кроме белого списка, получают отказ на уровне файрвола (порты 80 и 443). В белом списке — доверенные IP установок ({{$d.TrustedCount}}, <a href="{{adm}}/installs">Установки</a>) и адреса администраторов ниже. Ваш адрес <span class="mono">{{$d.MyIP}}</span> {{if $d.MyIPCovered}}уже в белом списке{{else}}добавится в белый список автоматически при включении{{end}}. SSH не затрагивается. Пока оборона включена, Let's Encrypt не сможет продлить сертификат. Аварийно выключить по SSH: <span class="mono">/opt/bj-license/bj-license-server fw-apply -off</span></p>
{{if $d.Status.Errors}}<div style="margin-top:10px">{{range $d.Status.Errors}}<div class="badge bad" style="display:block;margin-top:4px;white-space:normal">{{.}}</div>{{end}}</div>{{end}}
</div>
<div class="card"><h2>Белый список администраторов</h2>
<p class="muted small" style="margin:0 0 10px">Адреса офиса и администраторов. Они не блокируются fail2ban (ни за вход в кабинет, ни за API) и пропускаются в режиме обороны.</p>
<div class="table-wrap"><table>
<thead><tr><th>IP или сеть</th><th>Комментарий</th><th>Добавил</th><th></th></tr></thead><tbody>
{{range $d.Allow}}<tr><td class="mono"><b>{{.Net}}</b></td><td class="small">{{.Comment}}</td><td class="small">{{.AddedBy}}<div class="muted">{{dt .AddedAt}}</div></td>
<td><form method="post" action="{{adm}}/security/allow"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="remove"><input type="hidden" name="net" value="{{.Net}}"><button class="btn sm danger" type="submit">Убрать</button></form></td></tr>
{{else}}<tr><td colspan="4" class="muted">Пока пусто.</td></tr>{{end}}
</tbody></table></div>
<form method="post" action="{{adm}}/security/allow" class="actions" style="margin-top:10px"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="add">
<input name="net" placeholder="IP или сеть, например 203.0.113.10 или 203.0.113.0/24" required style="max-width:340px"><input name="comment" placeholder="комментарий" style="max-width:260px"><button class="btn" type="submit">Добавить</button></form>
</div>
<div class="card"><h2>Параметры блокировок</h2>
<p class="muted small" style="margin:0 0 10px">Сколько неудачных попыток за период приводят к блокировке адреса и на какой срок. Долгие блокировки сохраняются и после перезапуска fail2ban. Белый список и доверенные установки не блокируются.</p>
<form method="post" action="{{adm}}/security/limits"><input type="hidden" name="csrf" value="{{$csrf}}">
<div class="table-wrap"><table>
<thead><tr><th>Что защищаем</th><th>Попыток</th><th>За период</th><th>Блокировать на</th></tr></thead><tbody>
{{range $d.Limits}}{{$row := .}}<tr><td><b>{{.Title}}</b>{{if .HasEffective}}<div class="muted small">{{.Effective}}</div>{{end}}</td>
<td><select name="retry_{{.Jail}}">{{range .RetryOpts}}<option value="{{.}}" {{if eq . $row.Conf.MaxRetry}}selected{{end}}>{{.}}</option>{{end}}</select></td>
<td><select name="find_{{.Jail}}">{{range .FindOpts}}<option value="{{.Sec}}" {{if eq .Sec $row.Conf.FindTime}}selected{{end}}>{{.Label}}</option>{{end}}</select></td>
<td><select name="ban_{{.Jail}}">{{range .BanOpts}}<option value="{{.Sec}}" {{if eq .Sec $row.Conf.BanTime}}selected{{end}}>{{.Label}}</option>{{end}}</select></td></tr>
{{end}}
<tr><td><b>Вход в кабинет (приложение)</b><div class="muted small">срабатывает раньше fail2ban: после N неудачных попыток форма входа отказывает этому адресу и логину</div></td>
<td><select name="login_tries">{{range $d.RetryPresets}}<option value="{{.}}" {{if eq . $d.LoginTries}}selected{{end}}>{{.}}</option>{{end}}{{if and (ne $d.LoginTries 3) (ne $d.LoginTries 5) (ne $d.LoginTries 10) (ne $d.LoginTries 20)}}<option value="{{$d.LoginTries}}" selected>{{$d.LoginTries}}</option>{{end}}</select></td>
<td class="muted small">за срок блокировки</td>
<td><select name="login_lock">{{range $d.BanPresets}}{{if ne .Sec -1}}<option value="{{.Sec}}" {{if eq .Sec $d.LoginLockSec}}selected{{end}}>{{.Label}}</option>{{end}}{{end}}</select></td></tr>
</tbody></table></div>
<div class="actions" style="margin-top:10px"><button class="btn primary" type="submit">Сохранить параметры</button></div>
</form>
</div>
<div class="card"><h2>fail2ban</h2>
<div class="table-wrap"><table>
<thead><tr><th>Jail</th><th>Ошибок сейчас / всего</th><th>Заблокировано сейчас / всего</th><th>Заблокированные адреса</th></tr></thead><tbody>
{{range $d.Status.Jails}}{{$jail := .Name}}<tr><td><b>{{.Name}}</b><div class="muted small">{{if eq .Name "bj-license-api"}}коды активации и токены{{else if eq .Name "bj-license-admin"}}вход в кабинет{{else if eq .Name "sshd"}}SSH{{end}}</div></td>
<td>{{.CurrentlyFailed}} / {{.TotalFailed}}</td><td>{{.CurrentlyBanned}} / {{.TotalBanned}}</td>
<td>{{range .Banned}}<form method="post" action="{{adm}}/security/ban" class="actions" style="margin:0 0 4px"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="unban"><input type="hidden" name="jail" value="{{$jail}}"><input type="hidden" name="ip" value="{{.}}"><span class="mono">{{.}}</span><button class="btn sm" type="submit">Разблокировать</button></form>{{else}}<span class="muted">нет</span>{{end}}</td></tr>
{{else}}<tr><td colspan="4" class="muted">Данных пока нет.</td></tr>{{end}}
</tbody></table></div>
{{if $d.Pending}}<p class="small" style="margin:10px 0 0"><b>Ожидают применения:</b> {{range $d.Pending}}<span class="mono">{{.Op}} {{.IP}} ({{.Jail}})</span> {{end}}</p>{{end}}
<form method="post" action="{{adm}}/security/ban" class="actions" style="margin-top:12px"><input type="hidden" name="csrf" value="{{$csrf}}">
<input name="ip" placeholder="IP-адрес" required style="max-width:220px">
<select name="jail">{{range $d.Jails}}<option value="{{.}}">{{.}}</option>{{end}}</select>
<select name="op"><option value="ban">Заблокировать</option><option value="unban">Разблокировать</option></select>
<button class="btn" type="submit">Выполнить</button></form>
<p class="muted small" style="margin:8px 0 0">Ручная блокировка действует срок блокировки jail из «Параметров блокировок». Статус обновляется раз в 30 секунд{{if not $d.Status.At.IsZero}}, последний — {{dt $d.Status.At}}{{end}}.</p>
</div>
{{template "foot" .}}{{end}}
{{define "totp"}}{{template "head" .}}{{$csrf := .CSRF}}{{$d := .Data}}
<h1>Второй фактор входа</h1>
<div class="card">
{{if $d.Enabled}}
<p><span class="badge ok">Подключён</span> При входе в кабинет, кроме пароля, нужен шестизначный код из приложения.</p>
<form class="f" method="post" action="{{adm}}/me/2fa/disable" style="max-width:360px"><input type="hidden" name="csrf" value="{{$csrf}}">
<div><label>Код из приложения, чтобы отключить</label><input name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required></div>
<div><button class="btn danger" type="submit">Отключить второй фактор</button></div></form>
{{else}}
<p style="margin-top:0">Установите на телефон приложение для одноразовых кодов: Яндекс Ключ, Google Authenticator, Microsoft Authenticator или любое с поддержкой TOTP.</p>
<div class="grid">
<div><h2>1. Добавьте ключ в приложение</h2>
{{if $d.QR}}<div style="background:#fff;display:inline-block;padding:8px;border-radius:8px">{{$d.QR}}</div>{{end}}
<p class="small">Отсканируйте QR-код или введите ключ вручную: <span class="mono"><b>{{$d.SecretGrouped}}</b></span>. Если открыли кабинет на телефоне — <a href="{{$d.URI}}">добавить в приложение</a>.</p></div>
<div><h2>2. Подтвердите кодом</h2>
<form class="f" method="post" action="{{adm}}/me/2fa/enable"><input type="hidden" name="csrf" value="{{$csrf}}">
<div><label>Шестизначный код из приложения</label><input name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required autofocus></div>
<div><button class="btn primary" type="submit">Подключить</button></div></form>
<p class="muted small">Ключ показывается только на этой странице. Если телефон потеряется, второй фактор сбросит другой администратор на странице «Администраторы».</p></div>
</div>
{{end}}
</div>
{{template "foot" .}}{{end}}
{{define "events"}}{{template "head" .}}
<h1>Журнал</h1>
<div class="card">{{template "evtable" .Data}}<p class="muted small" style="margin:10px 0 0">Последние 500 событий.</p></div>
{{template "foot" .}}{{end}}
{{define "users"}}{{template "head" .}}{{$csrf := .CSRF}}{{$me := .Me}}
<h1>Администраторы</h1>
<div class="card"><div class="table-wrap"><table>
<thead><tr><th>Логин</th><th>Имя</th><th>E-mail (ЗЕТИТ Ключ)</th><th>Последний вход</th><th>Статус</th><th>Второй фактор</th><th>Сменить пароль</th><th></th></tr></thead><tbody>
{{range .Data}}<tr>
<td><b>{{.Login}}</b></td><td>{{.Name}}</td><td><form method="post" action="{{adm}}/users/{{.Login}}/email" class="actions" style="margin:0"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="email" name="email" value="{{.Email}}" placeholder="e-mail в ЗЕТИТ Ключ" style="max-width:220px"><button class="btn sm" type="submit">Сохранить</button></form></td><td class="small">{{dt .LastLogin}}</td>
<td>{{if .Disabled}}<span class="badge bad">отключён</span>{{else}}<span class="badge ok">активен</span>{{end}}</td>
<td>{{if .HasTOTP}}<span class="badge ok">подключён</span>{{if ne .Login $me.Login}}<form method="post" action="{{adm}}/users/{{.Login}}/2fa-reset" style="margin:4px 0 0" onsubmit="return confirm('Сбросить второй фактор администратора?')"><input type="hidden" name="csrf" value="{{$csrf}}"><button class="btn sm" type="submit">Сбросить</button></form>{{end}}{{else}}<span class="badge warn">нет</span>{{end}}{{if eq .Login $me.Login}}<div><a class="small" href="{{adm}}/me/2fa">{{if .HasTOTP}}управлять{{else}}подключить{{end}}</a></div>{{end}}</td>
<td><form class="actions" method="post" action="{{adm}}/users/{{.Login}}/password"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="password" name="password" placeholder="новый пароль, от 10 символов" minlength="10" required autocomplete="new-password" style="max-width:230px"><button class="btn sm" type="submit">Задать</button></form></td>
<td>{{if ne .Login $me.Login}}<form method="post" action="{{adm}}/users/{{.Login}}/toggle"><input type="hidden" name="csrf" value="{{$csrf}}"><button class="btn sm {{if not .Disabled}}danger{{end}}" type="submit">{{if .Disabled}}Включить{{else}}Отключить{{end}}</button></form>{{end}}</td>
</tr>{{end}}
</tbody></table></div></div>
<div class="grid">
<div class="card"><h2>Новый администратор</h2>
<form class="f" method="post" action="{{adm}}/users"><input type="hidden" name="csrf" value="{{$csrf}}">
<div class="row2"><div><label>Логин</label><input type="text" name="login" required pattern="[A-Za-z0-9_.\-]{2,40}"></div><div><label>Имя</label><input type="text" name="name"></div></div>
<div><label>E-mail в ЗЕТИТ Ключ <span class="muted" style="font-weight:400">(для подтверждения входа в приложении)</span></label><input type="email" name="email"></div>
<div><label>Пароль (от 10 символов)</label><input type="password" name="password" minlength="10" required autocomplete="new-password"></div>
<div><button class="btn primary" type="submit">Добавить</button></div>
</form></div>
<div class="card"><h2>Мой пароль</h2>
<form class="f" method="post" action="{{adm}}/me/password"><input type="hidden" name="csrf" value="{{$csrf}}">
<div><label>Текущий пароль</label><input type="password" name="current" required autocomplete="current-password"></div>
<div class="row2"><div><label>Новый пароль</label><input type="password" name="password" minlength="10" required autocomplete="new-password"></div><div><label>Повтор</label><input type="password" name="password2" minlength="10" required autocomplete="new-password"></div></div>
<div><button class="btn primary" type="submit">Сменить пароль</button></div>
</form></div>
</div>
{{template "foot" .}}{{end}}
+389
View File
@@ -0,0 +1,389 @@
package main
// Второй фактор через «ЗЕТИТ Ключ» — так же, как вход в ZETIT Connect. После пароля сервер лицензий
// просит Ключ подтвердить вход: push-уведомление на телефон администратора или код из приложения.
// Статус опрашивает только сервер; вход разрешается лишь при approved (push) или success (код).
// API: Zetit_key/docs/EXTERNAL_APP_2FA.md, способ 1 (приложение с X-Connect-Key).
//
// Действует для администраторов с указанным e-mail, когда интеграция включена в разделе «Защита».
// Остальные входят по паролю и, если подключён, по коду приложения-аутентификатора (access.go).
// Аварийно выключить по SSH: bj-license-server admin-config -zetit-key off (сервер остановлен).
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
const (
zetitKeyDefaultURL = "https://key.zetit.ru/api/v1/connect-auth"
zkPendingTTL = 5 * time.Minute // push живёт 60 секунд, код из приложения можно ввести и позже
)
// ZetitKeyConf — настройки интеграции с «ЗЕТИТ Ключ».
type ZetitKeyConf struct {
Enabled bool `json:"enabled,omitempty"`
URL string `json:"url,omitempty"` // по умолчанию https://key.zetit.ru/api/v1/connect-auth
AppKey string `json:"app_key,omitempty"` // X-Connect-Key приложения из панели Ключа
}
func (c ZetitKeyConf) active() bool { return c.Enabled && c.AppKey != "" }
var zkClient = &http.Client{Timeout: 8 * time.Second}
func zkCall(c ZetitKeyConf, method, path string, body, out any) error {
var rd io.Reader
if body != nil {
b, _ := json.Marshal(body)
rd = bytes.NewReader(b)
}
req, err := http.NewRequest(method, strings.TrimRight(pickStr(c.URL, zetitKeyDefaultURL), "/")+path, rd)
if err != nil {
return err
}
req.Header.Set("X-Connect-Key", c.AppKey)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := zkClient.Do(req)
if err != nil {
return fmt.Errorf("сервер недоступен: %v", err)
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("ответ %d: %s", resp.StatusCode, clip(string(data), 200))
}
if out != nil {
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("ответ не разобран: %v", err)
}
}
return nil
}
type zkLookupResult struct {
Exists bool `json:"exists"`
Has2FA bool `json:"has_2fa"`
FullName string `json:"full_name"`
}
type zkVerifyResult struct {
RequestID string `json:"request_id"`
Status string `json:"status"`
NeedsSetup bool `json:"needs_setup"`
}
func zkLookup(c ZetitKeyConf, email string) (zkLookupResult, error) {
var r zkLookupResult
return r, zkCall(c, http.MethodGet, "/lookup?email="+url.QueryEscape(email), nil, &r)
}
func zkStart(c ZetitKeyConf, email, ip string) (zkVerifyResult, error) {
var r zkVerifyResult
return r, zkCall(c, http.MethodPost, "/verify", map[string]string{"email": email, "source_ip": ip}, &r)
}
func zkStatus(c ZetitKeyConf, requestID string) (string, error) {
var r zkVerifyResult
err := zkCall(c, http.MethodGet, "/verify/"+url.PathEscape(requestID), nil, &r)
return r.Status, err
}
func zkCode(c ZetitKeyConf, email, code string) (bool, error) {
var r struct {
Success bool `json:"success"`
}
err := zkCall(c, http.MethodPost, "/verify-totp", map[string]string{"email": email, "code": code}, &r)
return r.Success, err
}
// ---- вход с подтверждением ----
// pendingLogin — пароль проверен, ждём подтверждения в Ключе.
type pendingLogin struct {
Token, Login, Email, IP, RequestID, Next string
Created time.Time
}
type pendingLogins struct {
mu sync.Mutex
m map[string]*pendingLogin
}
var zkPending = &pendingLogins{m: map[string]*pendingLogin{}}
func (p *pendingLogins) put(pl *pendingLogin) {
p.mu.Lock()
defer p.mu.Unlock()
for k, v := range p.m {
if time.Since(v.Created) > zkPendingTTL {
delete(p.m, k)
}
}
p.m[pl.Token] = pl
}
// get — ожидающий вход по токену с того же адреса.
func (p *pendingLogins) get(token, ip string) *pendingLogin {
p.mu.Lock()
defer p.mu.Unlock()
pl := p.m[token]
if pl == nil || pl.IP != ip || time.Since(pl.Created) > zkPendingTTL {
return nil
}
return pl
}
func (p *pendingLogins) drop(token string) {
p.mu.Lock()
delete(p.m, token)
p.mu.Unlock()
}
func (srv *server) zetitKey() ZetitKeyConf {
var c ZetitKeyConf
srv.st.read(func(s *State) { c = s.ZetitKey })
return c
}
// finishLogin — второй фактор пройден (или не требуется): сессия и переход в кабинет.
func (srv *server) finishLogin(w http.ResponseWriter, r *http.Request, login, ip, next string, totpStep int64, how string) {
srv.fails.reset(ip + "|" + strings.ToLower(login))
sess := srv.sessions.create(login)
_ = srv.st.write(func(s *State) error {
if u := s.user(login); u != nil {
u.LastLogin = time.Now()
sess.Login = u.Login
if totpStep > 0 {
u.TOTPLast = totpStep
}
}
s.addEvent("login", "", "", login, ip, "вход в кабинет"+how)
return nil
})
srv.setSessionCookie(w, r, sess.Token)
http.Redirect(w, r, next, http.StatusSeeOther)
}
func (srv *server) renderLoginErr(w http.ResponseWriter, login, next, msg string, status int) {
p := page{Title: "Вход", Err: msg, Data: map[string]string{"Next": next, "Login": login}}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_ = srv.tpl.ExecuteTemplate(w, "login", p)
}
// loginReject — отказ на втором факторе: счётчик неудач, строка для fail2ban, журнал.
func (srv *server) loginReject(w http.ResponseWriter, login, ip, next, eventText, msg string) {
srv.fails.add(ip + "|" + strings.ToLower(login))
srv.fails.add(ip)
log.Printf("login failed: user=%q ip=%s", login, ip) // строку ловит fail2ban (deploy/license/fail2ban)
time.Sleep(600 * time.Millisecond)
_ = srv.st.write(func(s *State) error {
s.addEvent("login_failed", "", "", login, ip, eventText)
return nil
})
srv.renderLoginErr(w, login, next, msg, http.StatusUnauthorized)
}
func (srv *server) renderLogin2FA(w http.ResponseWriter, pl *pendingLogin, errMsg string) {
left := 60 - int(time.Since(pl.Created).Seconds())
if left < 0 {
left = 0
}
p := page{Title: "Подтверждение входа", Err: errMsg, Data: map[string]any{"Token": pl.Token, "Email": pl.Email, "Left": left}}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = srv.tpl.ExecuteTemplate(w, "login2fa", p)
}
// login2FAStatus — GET /admin/login/2fa/status?t=…: статус push, опрос идёт с сервера.
func (srv *server) login2FAStatus(w http.ResponseWriter, r *http.Request) {
pl := zkPending.get(r.URL.Query().Get("t"), clientIP(r))
status := "expired"
if pl != nil && time.Since(pl.Created) <= 70*time.Second {
if st, err := zkStatus(srv.zetitKey(), pl.RequestID); err == nil {
status = st
} else {
status = "pending" // сбой связи — страница спросит ещё раз
}
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, map[string]string{"status": status})
}
// login2FAFinish — POST /admin/login/2fa/finish: страница увидела ответ, сервер перепроверяет его сам.
func (srv *server) login2FAFinish(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
ip := clientIP(r)
pl := zkPending.get(r.PostFormValue("t"), ip)
if pl == nil {
srv.renderLoginErr(w, "", "/admin/", "Время подтверждения истекло, войдите заново.", http.StatusUnauthorized)
return
}
st, err := zkStatus(srv.zetitKey(), pl.RequestID)
switch {
case err != nil:
srv.renderLogin2FA(w, pl, "ЗЕТИТ Ключ не ответил: "+err.Error()+". Введите код из приложения.")
case st == "approved":
zkPending.drop(pl.Token)
srv.finishLogin(w, r, pl.Login, ip, pl.Next, 0, " (ЗЕТИТ Ключ, push)")
case st == "denied":
zkPending.drop(pl.Token)
srv.loginReject(w, pl.Login, ip, pl.Next, "вход отклонён в ЗЕТИТ Ключ", "Вход отклонён в приложении «ЗЕТИТ Ключ».")
case st == "expired":
srv.renderLogin2FA(w, pl, "Время подтверждения истекло. Введите код из приложения или войдите заново.")
default:
srv.renderLogin2FA(w, pl, "Вход ещё не подтверждён в приложении.")
}
}
// login2FACode — POST /admin/login/2fa/code: код из приложения «ЗЕТИТ Ключ».
func (srv *server) login2FACode(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
ip := clientIP(r)
pl := zkPending.get(r.PostFormValue("t"), ip)
if pl == nil {
srv.renderLoginErr(w, "", "/admin/", "Время подтверждения истекло, войдите заново.", http.StatusUnauthorized)
return
}
if srv.fails.blocked(ip+"|"+strings.ToLower(pl.Login)) || srv.fails.blocked(ip) {
zkPending.drop(pl.Token)
srv.renderLoginErr(w, pl.Login, pl.Next, "Слишком много неудачных попыток. Вход заблокирован на "+durRu(int64(srv.fails.window()/time.Second))+".", http.StatusTooManyRequests)
return
}
ok, err := zkCode(srv.zetitKey(), pl.Email, strings.TrimSpace(r.PostFormValue("code")))
switch {
case err != nil:
srv.renderLogin2FA(w, pl, "Не удалось проверить код: "+err.Error())
case ok:
zkPending.drop(pl.Token)
srv.finishLogin(w, r, pl.Login, ip, pl.Next, 0, " (ЗЕТИТ Ключ, код)")
default:
srv.fails.add(ip + "|" + strings.ToLower(pl.Login))
srv.fails.add(ip)
log.Printf("login failed: user=%q ip=%s", pl.Login, ip)
_ = srv.st.write(func(s *State) error {
s.addEvent("login_failed", "", "", pl.Login, ip, "неверный код ЗЕТИТ Ключ")
return nil
})
srv.renderLogin2FA(w, pl, "Код неверен или уже использован.")
}
}
// ---- настройки ----
func validEmail(e string) bool {
at := strings.Index(e, "@")
return len(e) <= 120 && at > 0 && at < len(e)-3 && strings.Contains(e[at:], ".") && !strings.ContainsAny(e, " <>\"'")
}
// userEmail — POST /admin/users/{login}/email: e-mail администратора в «ЗЕТИТ Ключ».
func (srv *server) userEmail(w http.ResponseWriter, r *http.Request, sess *session) {
login, email := r.PathValue("login"), strings.TrimSpace(r.PostFormValue("email"))
if email != "" && !validEmail(email) {
back(w, r, "/admin/users", "", "неверный e-mail")
return
}
var bad string
err := srv.st.write(func(s *State) error {
u := s.user(login)
if u == nil {
return fmt.Errorf("администратор не найден")
}
if strings.EqualFold(u.Login, sess.Login) && email == "" && s.ZetitKey.active() {
bad = "вход через ЗЕТИТ Ключ включён: без e-mail вы войдёте только по паролю. Сначала выключите интеграцию в «Защите»."
return nil
}
u.Email = email
s.addEvent("user_email", "", "", sess.Login, clientIP(r), "e-mail администратора "+u.Login+": "+pickStr(email, "удалён"))
return nil
})
if err != nil || bad != "" {
back(w, r, "/admin/users", "", pickStr(bad, fmt.Sprint(err)))
return
}
back(w, r, "/admin/users", "E-mail "+login+" сохранён", "")
}
// securityZetitKey — POST /admin/security/zetit-key: адрес API, ключ приложения, включение, проверка.
func (srv *server) securityZetitKey(w http.ResponseWriter, r *http.Request, sess *session) {
op := r.PostFormValue("op")
var conf ZetitKeyConf
var myEmail string
srv.st.read(func(s *State) {
conf = s.ZetitKey
if u := s.user(sess.Login); u != nil {
myEmail = u.Email
}
})
if u := strings.TrimSpace(r.PostFormValue("url")); u != "" {
pu, err := url.Parse(u)
if err != nil || pu.Scheme != "https" || pu.Host == "" {
back(w, r, "/admin/security", "", "адрес API ЗЕТИТ Ключ: https://…")
return
}
conf.URL = strings.TrimRight(u, "/")
}
if k := strings.TrimSpace(r.PostFormValue("app_key")); k != "" {
conf.AppKey = k
}
check := func() (string, bool) {
switch {
case conf.AppKey == "":
return "не задан ключ приложения", false
case myEmail == "":
return "укажите свой e-mail на странице «Администраторы»", false
}
lk, err := zkLookup(conf, myEmail)
switch {
case err != nil:
return "ЗЕТИТ Ключ: " + err.Error(), false
case !lk.Exists:
return myEmail + " не найден в ЗЕТИТ Ключ", false
case !lk.Has2FA:
return myEmail + " найден в ЗЕТИТ Ключ, но приложение не привязано", false
}
return "ЗЕТИТ Ключ отвечает: " + myEmail + pickStr(" ("+lk.FullName+")", "") + " найден, приложение привязано", true
}
msg, ok := "", true
if op == "test" || r.PostFormValue("enabled") == "1" {
msg, ok = check()
}
switch {
case op == "test":
case r.PostFormValue("enabled") == "1" && !ok:
back(w, r, "/admin/security", "", "Не включено: "+msg)
return
default:
conf.Enabled = r.PostFormValue("enabled") == "1"
}
err := srv.st.write(func(s *State) error {
s.ZetitKey = conf
state := "выключен"
if conf.Enabled {
state = "включён"
}
s.addEvent("zetit_key", "", "", sess.Login, clientIP(r), "настройки ЗЕТИТ Ключ сохранены, вход через Ключ "+state)
return nil
})
switch {
case err != nil:
back(w, r, "/admin/security", "", err.Error())
case op == "test" && !ok:
back(w, r, "/admin/security", "", "Проверка: "+msg)
case op == "test":
back(w, r, "/admin/security", "Проверка: "+msg, "")
case conf.Enabled:
back(w, r, "/admin/security", "Вход через ЗЕТИТ Ключ включён. "+msg+".", "")
default:
back(w, r, "/admin/security", "Настройки ЗЕТИТ Ключ сохранены, вход через Ключ выключен.", "")
}
}
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=Bridge&Join — команды защиты из кабинета сервера лицензий
[Path]
PathChanged=/var/lib/bj-license/fw-control.json
PathChanged=/var/lib/bj-license/allow-ips.txt
PathChanged=/var/lib/bj-license/trusted-ips.txt
Unit=bj-license-fw.service
[Install]
WantedBy=multi-user.target
+7
View File
@@ -0,0 +1,7 @@
[Unit]
Description=Bridge&Join — применение защиты сервера лицензий (белый список, режим обороны, команды fail2ban)
After=fail2ban.service
[Service]
Type=oneshot
ExecStart=/opt/bj-license/bj-license-server fw-apply -dir /var/lib/bj-license
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=Bridge&Join — статус защиты сервера лицензий раз в 30 секунд (и восстановление правил после перезагрузки)
[Timer]
OnBootSec=30s
OnUnitActiveSec=30s
AccuracySec=5s
Unit=bj-license-fw.service
[Install]
WantedBy=timers.target
+22
View File
@@ -0,0 +1,22 @@
[Unit]
Description=Bridge&Join — сервер лицензирования (lic.zetit.ru)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=bj-license
Group=bj-license
ExecStart=/opt/bj-license/bj-license-server serve -addr 127.0.0.1:8091 -data /var/lib/bj-license/state.json -key /etc/bj-license/license.priv -keyid main
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/bj-license
ReadOnlyPaths=/etc/bj-license
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,6 @@
# fail2ban: перебор паролей кабинета сервера лицензий bj
# (строки пишет bj-license-server в журнал systemd; IP клиента берётся из X-Real-IP nginx).
[Definition]
failregex = ^.*login failed: user=".*" ip=<HOST>$
ignoreregex =
journalmatch = _SYSTEMD_UNIT=bj-license-server.service
@@ -0,0 +1,5 @@
# fail2ban: перебор кодов активации и токенов API сервера лицензий bj.
[Definition]
failregex = ^.*api denied: (unknown_code|bad_token) ip=<HOST>$
ignoreregex =
journalmatch = _SYSTEMD_UNIT=bj-license-server.service
@@ -0,0 +1,30 @@
# Сервер лицензий lic.zetit.ru: блокировка IP на уровне файрвола.
# Вход в кабинет: не блокируется только белый список администраторов (раздел «Защита»).
[bj-license-admin]
enabled = true
backend = systemd
filter = bj-license-admin
port = http,https
maxretry = 10
findtime = 10m
bantime = 1h
ignorecommand = /opt/bj-license/bj-license-server fw-ignore -dir /var/lib/bj-license <ip> admin
# API активации и продления: не блокируются адреса установок, успешно активировавших или продливших
# лицензию (trusted-ips.txt), и белый список администраторов (allow-ips.txt).
[bj-license-api]
enabled = true
backend = systemd
filter = bj-license-api
port = http,https
maxretry = 10
findtime = 10m
bantime = 1h
ignorecommand = /opt/bj-license/bj-license-server fw-ignore -dir /var/lib/bj-license <ip> api
[sshd]
enabled = true
maxretry = 5
findtime = 10m
bantime = 1h
+82
View File
@@ -0,0 +1,82 @@
# Сервер лицензирования и обновлений bj — контракт API
Сервер: **https://lic.zetit.ru** (ВМ 46.173.19.220, Ubuntu 26.04). Адрес зашит в bj
(`internal/lkgateway`, константа), в интерфейсе не меняется. С этого же сервера bj
получает обновления.
## Модель
- Лицензия — прежний токен `internal/license`: подписанный Ed25519 JSON `License`,
строка `base64(payload).base64(sig).keyid`. Ключ подписи — `~/keys/license.priv`
(keyid `main`); открытый ключ зашит в bj.
- Клиенту выдаётся **код активации** (`BJ-XXXX-XXXX-XXXX`), один на лицензию; им
активируется до `max_nodes` установок (0 — без лимита).
- При активации сервер выдаёт токен **на 60 дней** (`expires_at`), но не дольше
`contract_until`; в токене `install_id` установки и `contract_until` договора.
- bj продлевает токен **раз в сутки**. Отзыв = сервер больше не продлевает; уже
выданный токен доживает свой срок (запас на недоступность сервера).
- Офлайн-ключи (`bj-license issue`, без `install_id`) продолжают работать — для
закрытых контуров без интернета.
## Идентификатор установки
`install_id` — UUID v4, создаётся bj при первом запуске и хранится в
`/var/lib/bj/.bj/setup.json` (`license.install_id`). Вместе с ним bj передаёт
`machine` — sha256(`/etc/machine-id`) в hex (для учёта и обнаружения клонов, не для
отказа).
## Запросы
Все тела — JSON, `Content-Type: application/json`. Ошибки — HTTP 4xx/5xx и
`{"error": "<код>", "message": "<текст по-русски>"}`.
### POST /v1/activate
```json
{"code": "BJ-7K3M-Q9XP-2HDA", "install_id": "…uuid…", "machine": "…sha256…",
"hostname": "bj-server-01", "version": "0.1.0"}
```
200:
```json
{"token": "<payload.sig.main>", "license_id": "…uuid…", "tenant": "ИК Фонтвьель",
"expires_at": "2026-11-14T10:00:00Z", "contract_until": "2027-09-14T00:00:00Z",
"renew_after_sec": 86400}
```
Коды ошибок: `bad_request`, `unknown_code`, `revoked`, `contract_expired`,
`max_nodes` (лимит установок исчерпан). Повторная активация той же `install_id` —
не новая установка, просто новый токен.
### POST /v1/renew
```json
{"token": "<текущий токен>", "install_id": "…", "machine": "…", "hostname": "…", "version": "…"}
```
Сервер проверяет подпись токена (срок **не** проверяет — продлить можно и
истёкший), совпадение `install_id` с токеном, что установка известна и лицензия не
отозвана. 200 — тот же формат, что у activate. Ошибки: `bad_token`,
`install_mismatch`, `unknown_install`, `revoked`, `contract_expired`.
Каждый успешный renew обновляет `last_seen`, `version`, `hostname`, IP установки.
### Обновления
- `GET /v1/{channel}/manifest.json` — подписанный манифест (`internal/release`),
без авторизации.
- `GET /v1/{channel}/files/{file}` — только с заголовком
`Authorization: License <token>`: подпись верна, срок не истёк, лицензия не
отозвана, в признаках есть `updates` (или план `enterprise`). Иначе 401/403 с
JSON-ошибкой. Ключ подписи манифестов отдельный от лицензионного.
### GET /healthz
`ok`.
## Кабинет администратора
`https://lic.zetit.ru/admin/` — вход по логину и паролю (bcrypt), только для нас:
клиенты (название, ИНН), лицензии (план, признаки, max_nodes, contract_until, код
активации, отзыв/восстановление), установки (install_id, hostname, версия, IP,
последнее продление, срок токена), журнал событий, выпуск офлайн-ключа для
закрытого контура.
+16 -6
View File
@@ -1,21 +1,31 @@
module git.zetit.ru/zuevav/Bridge-and-Join-s
go 1.25.0
go 1.25.7
require (
github.com/jackc/pgx/v5 v5.7.4
golang.org/x/text v0.34.0
github.com/microsoft/go-mssqldb v1.10.0
golang.org/x/crypto v0.54.0
golang.org/x/text v0.40.0
google.golang.org/grpc v1.81.1
google.golang.org/protobuf v1.36.11
)
require rsc.io/qr v0.2.0 // indirect
require (
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
github.com/go-ldap/ldap/v3 v3.4.14
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
)
+46 -12
View File
@@ -1,12 +1,36 @@
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA=
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs=
github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -21,13 +45,21 @@ github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/microsoft/go-mssqldb v1.10.0 h1:pHEt+Qz6YFPWqREq10mqSE524QQo+/QremwTCQht7TY=
github.com/microsoft/go-mssqldb v1.10.0/go.mod h1:mnG7lGa9iYJbzJqGCXyuQCegStKMr3kogDLD6+bmggg=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
@@ -40,16 +72,16 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
@@ -62,3 +94,5 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
+6
View File
@@ -47,6 +47,12 @@ type License struct {
Features []string `json:"features,omitempty"` // "updates","web-cabinet",...
MaxNodes int `json:"max_nodes,omitempty"` // лимит инсталляций (0 = без лимита)
Note string `json:"note,omitempty"`
// Онлайн-лицензирование (lic.zetit.ru, docs/license-server-api.md): токен выдаётся
// сервером на короткий срок (ExpiresAt) под конкретную установку и продлевается,
// пока действует договор (ContractUntil). У офлайн-ключей оба поля пустые.
InstallID string `json:"install_id,omitempty"`
ContractUntil time.Time `json:"contract_until,omitempty"`
}
// Token — лицензия + подпись. Именно это вводит клиент (одна base64-строка