diff --git a/cmd/bj-license-server/access.go b/cmd/bj-license-server/access.go deleted file mode 100644 index 6291628..0000000 --- a/cmd/bj-license-server/access.go +++ /dev/null @@ -1,512 +0,0 @@ -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" или "/". -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, ``) - 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, "не указан")) - } - }) -} diff --git a/cmd/bj-license-server/admin.go b/cmd/bj-license-server/admin.go deleted file mode 100644 index ce27e4d..0000000 --- a/cmd/bj-license-server/admin.go +++ /dev/null @@ -1,1235 +0,0 @@ -package main - -// Веб-кабинет администратора сервера лицензий: вход по логину и паролю, клиенты, -// лицензии, установки, журнал, администраторы. - -import ( - "crypto/ed25519" - "crypto/rand" - "crypto/subtle" - "embed" - "encoding/hex" - "fmt" - "html/template" - "log" - "net" - "net/http" - "net/url" - "sort" - "strconv" - "strings" - "sync" - "time" - - "golang.org/x/crypto/bcrypt" - - "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license" -) - -//go:embed web/*.html -var webFS embed.FS - -var msk = time.FixedZone("МСК", 3*60*60) - -const ( - sessionCookie = "bjlic_session" - sessionIdle = 8 * time.Hour - sessionMax = 24 * time.Hour -) - -type server struct { - st *store - priv ed25519.PrivateKey - pub ed25519.PublicKey - keyID string - sessions *sessions - fails *limiter - apiRate *limiter - tpl *template.Template -} - -// ---- сессии и ограничения ---- - -type session struct { - Token, Login, CSRF string - Created, Seen time.Time - PendingTOTP string // ключ второго фактора, который администратор сейчас подключает -} - -type sessions struct { - mu sync.Mutex - m map[string]*session -} - -func newSessions() *sessions { return &sessions{m: map[string]*session{}} } - -func randHex(n int) string { - b := make([]byte, n) - _, _ = rand.Read(b) - return hex.EncodeToString(b) -} - -func (ss *sessions) create(login string) *session { - s := &session{Token: randHex(32), Login: login, CSRF: randHex(16), Created: time.Now(), Seen: time.Now()} - ss.mu.Lock() - ss.m[s.Token] = s - ss.mu.Unlock() - return s -} - -func (ss *sessions) get(token string) *session { - ss.mu.Lock() - defer ss.mu.Unlock() - s, ok := ss.m[token] - if !ok { - return nil - } - if time.Since(s.Seen) > sessionIdle || time.Since(s.Created) > sessionMax { - delete(ss.m, token) - return nil - } - s.Seen = time.Now() - cp := *s - return &cp -} - -func (ss *sessions) drop(token string) { - ss.mu.Lock() - delete(ss.m, token) - ss.mu.Unlock() -} - -// dropUser — завершить сессии пользователя, кроме keep. -func (ss *sessions) dropUser(login, keep string) { - ss.mu.Lock() - for t, s := range ss.m { - if strings.EqualFold(s.Login, login) && t != keep { - delete(ss.m, t) - } - } - ss.mu.Unlock() -} - -type limiter struct { - mu sync.Mutex - n int - per time.Duration - m map[string][]time.Time -} - -func newLimiter(n int, per time.Duration) *limiter { - return &limiter{n: n, per: per, m: map[string][]time.Time{}} -} - -func (l *limiter) countLocked(key string) int { - var keep []time.Time - for _, t := range l.m[key] { - if time.Since(t) < l.per { - keep = append(keep, t) - } - } - if len(keep) == 0 { - delete(l.m, key) - } else { - l.m[key] = keep - } - return len(keep) -} - -func (l *limiter) blocked(key string) bool { - l.mu.Lock() - defer l.mu.Unlock() - return l.countLocked(key) >= l.n -} - -func (l *limiter) add(key string) { - l.mu.Lock() - l.m[key] = append(l.m[key], time.Now()) - l.mu.Unlock() -} - -// set — изменить порог и срок (раздел «Защита»). -func (l *limiter) set(n int, per time.Duration) { - l.mu.Lock() - l.n, l.per = n, per - l.mu.Unlock() -} - -func (l *limiter) window() time.Duration { - l.mu.Lock() - defer l.mu.Unlock() - return l.per -} - -func (l *limiter) reset(key string) { - l.mu.Lock() - delete(l.m, key) - l.mu.Unlock() -} - -// allow — учесть попытку и сказать, укладывается ли она в лимит. -func (l *limiter) allow(key string) bool { - l.mu.Lock() - defer l.mu.Unlock() - if l.countLocked(key) >= l.n { - return false - } - l.m[key] = append(l.m[key], time.Now()) - return true -} - -// clientIP — адрес клиента; за nginx на этой же машине — из X-Real-IP. -func clientIP(r *http.Request) string { - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - host = r.RemoteAddr - } - if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() { - if x := strings.TrimSpace(r.Header.Get("X-Real-IP")); x != "" { - return x - } - } - return host -} - -func securityHeaders(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - hd := w.Header() - hd.Set("X-Frame-Options", "DENY") - hd.Set("X-Content-Type-Options", "nosniff") - hd.Set("Referrer-Policy", "same-origin") - hd.Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'") - h.ServeHTTP(w, r) - }) -} - -// ---- шаблоны ---- - -func featureTitle(key string) string { - for _, f := range knownFeatures { - if f.Key == key { - return f.Title - } - } - return key -} - -func planTitle(key string) string { - for _, p := range knownPlans { - if p.Key == key { - return p.Title - } - } - return key -} - -func (srv *server) loadTemplates() error { - funcs := template.FuncMap{ - "adm": srv.adminBase, // внешний адрес кабинета для ссылок - "dt": func(t time.Time) string { - if t.IsZero() { - return "—" - } - return t.In(msk).Format("02.01.2006 15:04") - }, - "d": func(t time.Time) string { - if t.IsZero() { - return "—" - } - return t.In(msk).Format("02.01.2006") - }, - "dateInput": func(t time.Time) string { - if t.IsZero() { - return "" - } - return t.In(msk).Format("2006-01-02") - }, - "ago": func(t time.Time) string { - if t.IsZero() { - return "никогда" - } - d := time.Since(t) - switch { - case d < time.Minute: - return "только что" - case d < time.Hour: - return fmt.Sprintf("%d мин назад", int(d.Minutes())) - case d < 48*time.Hour: - return fmt.Sprintf("%d ч назад", int(d.Hours())) - default: - return fmt.Sprintf("%d дн назад", int(d.Hours()/24)) - } - }, - "daysLeft": func(t time.Time) int { return int(time.Until(t).Hours() / 24) }, - // dict2 — форма условий новой лицензии (значения по умолчанию), dict3 — существующей. - "dict2": func(plans, all []struct{ Key, Title string }) termsView { - return termsView{Plans: plans, All: all, Plan: "enterprise", MaxNodes: 1, - ContractUntil: time.Now().AddDate(1, 0, 0), Features: []string{"updates"}} - }, - "dict3": func(plans, all []struct{ Key, Title string }, l License) termsView { - return termsView{Plans: plans, All: all, Plan: l.Plan, MaxNodes: l.MaxNodes, ContractUntil: l.ContractUntil, Features: l.Features, Note: l.Note} - }, - "feature": featureTitle, - "plan": planTitle, - "short": short, - "has": func(list []string, s string) bool { - for _, x := range list { - if x == s { - return true - } - } - return false - }, - } - t, err := template.New("").Funcs(funcs).ParseFS(webFS, "web/*.html") - if err != nil { - return err - } - srv.tpl = t - return nil -} - -// termsView — данные формы условий лицензии (шаблон "terms"). -type termsView struct { - Plans, All []struct{ Key, Title string } - Plan, Note string - MaxNodes int - ContractUntil time.Time - Features []string -} - -type page struct { - Title string - Nav string - Me *User - CSRF string - OK, Err string - Features []struct{ Key, Title string } - Plans []struct{ Key, Title string } - Data any -} - -func (srv *server) render(w http.ResponseWriter, r *http.Request, sess *session, name, title, nav string, data any) { - p := page{Title: title, Nav: nav, OK: r.URL.Query().Get("ok"), Err: r.URL.Query().Get("err"), - Features: knownFeatures, Plans: knownPlans, Data: data} - if sess != nil { - p.CSRF = sess.CSRF - srv.st.read(func(s *State) { - if u := s.user(sess.Login); u != nil { - cp := *u - cp.PassHash, cp.HasTOTP, cp.TOTPSecret = "", u.TOTPSecret != "", "" - p.Me = &cp - } - }) - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Header().Set("Cache-Control", "no-store") - if err := srv.tpl.ExecuteTemplate(w, name, p); err != nil { - log.Printf("шаблон %s: %v", name, err) - } -} - -func back(w http.ResponseWriter, r *http.Request, path, ok, errMsg string) { - q := url.Values{} - if ok != "" { - q.Set("ok", ok) - } - if errMsg != "" { - q.Set("err", errMsg) - } - if len(q) > 0 { - path += "?" + q.Encode() - } - http.Redirect(w, r, path, http.StatusSeeOther) -} - -// ---- маршруты ---- - -func (srv *server) routes(mux *http.ServeMux) { - mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }) - mux.HandleFunc("POST /v1/activate", srv.apiActivate) - mux.HandleFunc("POST /v1/renew", srv.apiRenew) - mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { - if srv.adminBase() != adminDefault { // свой адрес кабинета — главная ничего не подсказывает - http.NotFound(w, r) - return - } - http.Redirect(w, r, "/admin/", http.StatusFound) - }) - - mux.HandleFunc("GET /admin/login", srv.loginPage) - mux.HandleFunc("POST /admin/login", srv.loginPost) - mux.HandleFunc("GET /admin/login/2fa/status", srv.login2FAStatus) - mux.HandleFunc("POST /admin/login/2fa/finish", srv.login2FAFinish) - mux.HandleFunc("POST /admin/login/2fa/code", srv.login2FACode) - mux.HandleFunc("POST /admin/logout", srv.auth(srv.logout)) - mux.HandleFunc("GET /admin/{$}", srv.auth(srv.dashboard)) - - mux.HandleFunc("GET /admin/tenants", srv.auth(srv.tenantsPage)) - mux.HandleFunc("POST /admin/tenants", srv.auth(srv.tenantCreate)) - mux.HandleFunc("GET /admin/tenants/{id}", srv.auth(srv.tenantPage)) - mux.HandleFunc("POST /admin/tenants/{id}", srv.auth(srv.tenantUpdate)) - - mux.HandleFunc("GET /admin/licenses", srv.auth(srv.licensesPage)) - mux.HandleFunc("POST /admin/licenses", srv.auth(srv.licenseCreate)) - mux.HandleFunc("GET /admin/licenses/{id}", srv.auth(srv.licensePage)) - mux.HandleFunc("POST /admin/licenses/{id}/edit", srv.auth(srv.licenseUpdate)) - mux.HandleFunc("POST /admin/licenses/{id}/revoke", srv.auth(srv.licenseRevoke)) - mux.HandleFunc("POST /admin/licenses/{id}/restore", srv.auth(srv.licenseRestore)) - mux.HandleFunc("POST /admin/licenses/{id}/code", srv.auth(srv.licenseNewCode)) - mux.HandleFunc("POST /admin/licenses/{id}/offline", srv.auth(srv.licenseOffline)) - mux.HandleFunc("POST /admin/licenses/{id}/installs/{iid}/block", srv.auth(srv.installBlock)) - mux.HandleFunc("POST /admin/licenses/{id}/installs/{iid}/unblock", srv.auth(srv.installUnblock)) - - mux.HandleFunc("GET /admin/installs", srv.auth(srv.installsPage)) - mux.HandleFunc("POST /admin/trusted/remove", srv.auth(srv.trustedRemove)) - mux.HandleFunc("GET /admin/security", srv.auth(srv.securityPage)) - mux.HandleFunc("POST /admin/security/mode", srv.auth(srv.securityMode)) - mux.HandleFunc("POST /admin/security/allow", srv.auth(srv.securityAllow)) - mux.HandleFunc("POST /admin/security/ban", srv.auth(srv.securityBan)) - mux.HandleFunc("POST /admin/security/limits", srv.auth(srv.securityLimits)) - mux.HandleFunc("POST /admin/security/access", srv.auth(srv.securityAccess)) - mux.HandleFunc("GET /admin/me/2fa", srv.auth(srv.meTOTPPage)) - mux.HandleFunc("POST /admin/me/2fa/enable", srv.auth(srv.meTOTPEnable)) - mux.HandleFunc("POST /admin/me/2fa/disable", srv.auth(srv.meTOTPDisable)) - mux.HandleFunc("POST /admin/users/{login}/2fa-reset", srv.auth(srv.userTOTPReset)) - mux.HandleFunc("POST /admin/users/{login}/email", srv.auth(srv.userEmail)) - mux.HandleFunc("POST /admin/security/zetit-key", srv.auth(srv.securityZetitKey)) - mux.HandleFunc("GET /admin/events", srv.auth(srv.eventsPage)) - - mux.HandleFunc("GET /admin/users", srv.auth(srv.usersPage)) - mux.HandleFunc("POST /admin/users", srv.auth(srv.userCreate)) - mux.HandleFunc("POST /admin/users/{login}/password", srv.auth(srv.userPassword)) - mux.HandleFunc("POST /admin/users/{login}/toggle", srv.auth(srv.userToggle)) - mux.HandleFunc("POST /admin/me/password", srv.auth(srv.mePassword)) -} - -type authed func(w http.ResponseWriter, r *http.Request, sess *session) - -func isHTTPS(r *http.Request) bool { - return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") -} - -// auth — вход обязателен; для POST сверяется CSRF-токен сессии. -func (srv *server) auth(h authed) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - var sess *session - if c, err := r.Cookie(sessionCookie); err == nil { - sess = srv.sessions.get(c.Value) - } - if sess != nil { - active := false - srv.st.read(func(s *State) { - u := s.user(sess.Login) - active = u != nil && !u.Disabled - }) - if !active { - srv.sessions.drop(sess.Token) - sess = nil - } - } - if sess == nil { - if r.Method == http.MethodGet { - http.Redirect(w, r, "/admin/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther) - } else { - http.Redirect(w, r, "/admin/login", http.StatusSeeOther) - } - return - } - if r.Method == http.MethodPost { - _ = r.ParseForm() - if subtle.ConstantTimeCompare([]byte(r.PostFormValue("csrf")), []byte(sess.CSRF)) != 1 { - http.Error(w, "Сессия устарела — обновите страницу", http.StatusForbidden) - return - } - } - h(w, r, sess) - } -} - -// ---- вход ---- - -func safeNext(next string) string { - if strings.HasPrefix(next, "/admin/") && !strings.HasPrefix(next, "//") && !strings.Contains(next, "\\") { - return next - } - return "/admin/" -} - -func (srv *server) loginPage(w http.ResponseWriter, r *http.Request) { - srv.render(w, r, nil, "login", "Вход", "", map[string]string{"Next": safeNext(r.URL.Query().Get("next")), "Login": ""}) -} - -func (srv *server) loginPost(w http.ResponseWriter, r *http.Request) { - _ = r.ParseForm() - login := strings.TrimSpace(r.PostFormValue("login")) - pw := r.PostFormValue("password") - next := safeNext(r.PostFormValue("next")) - ip := clientIP(r) - key := ip + "|" + strings.ToLower(login) - fail := func(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) - } - if srv.fails.blocked(key) || srv.fails.blocked(ip) { - fail("Слишком много неудачных попыток. Вход заблокирован на "+durRu(int64(srv.fails.window()/time.Second))+".", http.StatusTooManyRequests) - return - } - var hash, totpSecret, email string - var totpLast int64 - var zk ZetitKeyConf - disabled := false - srv.st.read(func(s *State) { - if u := s.user(login); u != nil { - hash, disabled, totpSecret, totpLast, email = u.PassHash, u.Disabled, u.TOTPSecret, u.TOTPLast, u.Email - } - zk = s.ZetitKey - }) - loginFailed := func(eventText string) { - srv.fails.add(key) - 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 - }) - // Один текст для неверного пароля и кода: не подсказываем, подключён ли второй фактор. - fail("Неверный логин, пароль или код", http.StatusUnauthorized) - } - if hash == "" || disabled || bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) != nil { - loginFailed("неудачный вход") - return - } - // Второй фактор через «ЗЕТИТ Ключ» (zetitkey.go): интеграция включена и у администратора есть e-mail. - if zk.active() && email != "" { - if code := strings.TrimSpace(r.PostFormValue("code")); code != "" { - ok, err := zkCode(zk, email, code) - switch { - case err != nil: - fail("Не удалось проверить код в ЗЕТИТ Ключ: "+err.Error(), http.StatusServiceUnavailable) - case !ok: - loginFailed("неверный код ЗЕТИТ Ключ") - default: - srv.finishLogin(w, r, login, ip, next, 0, " (ЗЕТИТ Ключ, код)") - } - return - } - v, err := zkStart(zk, email, ip) - if err != nil { - fail("Не удалось запросить подтверждение в ЗЕТИТ Ключ: "+err.Error(), http.StatusServiceUnavailable) - return - } - if v.NeedsSetup || v.RequestID == "" { - fail("Для входа нужно подтверждение в ЗЕТИТ Ключ, но приложение для "+email+" не привязано.", http.StatusForbidden) - return - } - pl := &pendingLogin{Token: randHex(24), Login: login, Email: email, IP: ip, RequestID: v.RequestID, Next: next, Created: time.Now()} - zkPending.put(pl) - srv.renderLogin2FA(w, pl, "") - return - } - var totpStep int64 - if totpSecret != "" { - step, ok := totpCheck(totpSecret, r.PostFormValue("code"), totpLast, time.Now()) - if !ok { - loginFailed("неверный код второго фактора") - return - } - totpStep = step - } - srv.finishLogin(w, r, login, ip, next, totpStep, "") -} - -func (srv *server) logout(w http.ResponseWriter, r *http.Request, sess *session) { - srv.sessions.drop(sess.Token) - http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: srv.adminBase(), HttpOnly: true, Secure: isHTTPS(r), MaxAge: -1}) - http.Redirect(w, r, "/admin/login", http.StatusSeeOther) -} - -// ---- представления ---- - -type licenseRow struct { - License - TenantName string - Installs int - Active int - Status string - StatusClass string -} - -func licStatus(l *License, now time.Time) (string, string) { - switch { - case l.Revoked(): - return "отозвана", "bad" - case l.ContractExpired(now): - return "договор истёк", "bad" - case !l.ContractUntil.IsZero() && l.ContractUntil.Sub(now) < 30*24*time.Hour: - return "договор истекает", "warn" - } - return "действует", "ok" -} - -func (s *State) licenseRow(l *License, now time.Time) licenseRow { - row := licenseRow{License: *l} - if t := s.tenant(l.TenantID); t != nil { - row.TenantName = t.Name - } - for _, i := range s.Installs { - if i.LicenseID == l.ID { - row.Installs++ - if !i.Blocked { - row.Active++ - } - } - } - row.Status, row.StatusClass = licStatus(l, now) - return row -} - -type installRow struct { - Install - TenantName string - Stale bool // давно не продлевалась -} - -type eventRow struct { - Event - TenantName string -} - -func (s *State) eventRows(filter func(*Event) bool, limit int) []eventRow { - var out []eventRow - for i := len(s.Events) - 1; i >= 0 && len(out) < limit; i-- { - e := s.Events[i] - if filter != nil && !filter(e) { - continue - } - row := eventRow{Event: *e} - if l := s.license(e.LicenseID); l != nil { - if t := s.tenant(l.TenantID); t != nil { - row.TenantName = t.Name - } - } - out = append(out, row) - } - return out -} - -func (srv *server) dashboard(w http.ResponseWriter, r *http.Request, sess *session) { - type dash struct { - Tenants, Licenses, Active, Revoked, Installs, Online int - Expiring []licenseRow - Events []eventRow - } - var d dash - now := time.Now() - srv.st.read(func(s *State) { - d.Tenants, d.Licenses, d.Installs = len(s.Tenants), len(s.Licenses), len(s.Installs) - for _, l := range s.Licenses { - if l.Revoked() { - d.Revoked++ - continue - } - if !l.ContractExpired(now) { - d.Active++ - } - if row := s.licenseRow(l, now); row.StatusClass != "ok" { - d.Expiring = append(d.Expiring, row) - } - } - for _, i := range s.Installs { - if now.Sub(i.LastSeen) < 48*time.Hour && !i.Blocked { - d.Online++ - } - } - d.Events = s.eventRows(nil, 15) - }) - srv.render(w, r, sess, "dashboard", "Обзор", "dash", d) -} - -// ---- клиенты ---- - -func (srv *server) tenantsPage(w http.ResponseWriter, r *http.Request, sess *session) { - type row struct { - Tenant - Licenses, Installs int - } - var rows []row - srv.st.read(func(s *State) { - for _, t := range s.Tenants { - rr := row{Tenant: *t} - for _, l := range s.Licenses { - if l.TenantID == t.ID { - rr.Licenses++ - rr.Installs += len(s.installsOf(l.ID)) - } - } - rows = append(rows, rr) - } - }) - sort.Slice(rows, func(i, j int) bool { return strings.ToLower(rows[i].Name) < strings.ToLower(rows[j].Name) }) - srv.render(w, r, sess, "tenants", "Клиенты", "tenants", rows) -} - -func tenantFromForm(r *http.Request) (Tenant, string) { - t := Tenant{ - Name: clip(r.PostFormValue("name"), 200), - INN: clip(r.PostFormValue("inn"), 12), - Contact: clip(r.PostFormValue("contact"), 300), - Note: clip(r.PostFormValue("note"), 1000), - } - if t.Name == "" { - return t, "укажите название клиента" - } - if t.INN != "" && !innRe.MatchString(t.INN) { - return t, "ИНН — 10 или 12 цифр" - } - return t, "" -} - -func (srv *server) tenantCreate(w http.ResponseWriter, r *http.Request, sess *session) { - t, bad := tenantFromForm(r) - if bad != "" { - back(w, r, "/admin/tenants", "", bad) - return - } - t.ID, t.CreatedAt = newID(), time.Now() - err := srv.st.write(func(s *State) error { - s.Tenants = append(s.Tenants, &t) - s.addEvent("tenant_add", "", "", sess.Login, clientIP(r), "добавлен клиент «"+t.Name+"»") - return nil - }) - if err != nil { - back(w, r, "/admin/tenants", "", err.Error()) - return - } - back(w, r, "/admin/tenants/"+t.ID, "Клиент добавлен. Теперь выпустите ему лицензию.", "") -} - -func (srv *server) tenantPage(w http.ResponseWriter, r *http.Request, sess *session) { - type view struct { - Tenant Tenant - Licenses []licenseRow - } - var v view - found := false - now := time.Now() - srv.st.read(func(s *State) { - t := s.tenant(r.PathValue("id")) - if t == nil { - return - } - found, v.Tenant = true, *t - for _, l := range s.Licenses { - if l.TenantID == t.ID { - v.Licenses = append(v.Licenses, s.licenseRow(l, now)) - } - } - }) - if !found { - http.NotFound(w, r) - return - } - srv.render(w, r, sess, "tenant", v.Tenant.Name, "tenants", v) -} - -func (srv *server) tenantUpdate(w http.ResponseWriter, r *http.Request, sess *session) { - id := r.PathValue("id") - nt, bad := tenantFromForm(r) - if bad != "" { - back(w, r, "/admin/tenants/"+id, "", bad) - return - } - err := srv.st.write(func(s *State) error { - t := s.tenant(id) - if t == nil { - return fmt.Errorf("клиент не найден") - } - t.Name, t.INN, t.Contact, t.Note = nt.Name, nt.INN, nt.Contact, nt.Note - s.addEvent("tenant_edit", "", "", sess.Login, clientIP(r), "изменён клиент «"+t.Name+"»") - return nil - }) - if err != nil { - back(w, r, "/admin/tenants/"+id, "", err.Error()) - return - } - back(w, r, "/admin/tenants/"+id, "Сохранено", "") -} - -// ---- лицензии ---- - -func (srv *server) licensesPage(w http.ResponseWriter, r *http.Request, sess *session) { - type view struct { - Rows []licenseRow - Tenants []Tenant - Tenant string // предвыбранный клиент в форме - } - v := view{Tenant: r.URL.Query().Get("tenant")} - now := time.Now() - srv.st.read(func(s *State) { - for _, l := range s.Licenses { - v.Rows = append(v.Rows, s.licenseRow(l, now)) - } - for _, t := range s.Tenants { - v.Tenants = append(v.Tenants, *t) - } - }) - sort.Slice(v.Rows, func(i, j int) bool { return v.Rows[i].CreatedAt.After(v.Rows[j].CreatedAt) }) - sort.Slice(v.Tenants, func(i, j int) bool { return strings.ToLower(v.Tenants[i].Name) < strings.ToLower(v.Tenants[j].Name) }) - srv.render(w, r, sess, "licenses", "Лицензии", "licenses", v) -} - -// licenseTermsFromForm — план, признаки, лимит установок, срок договора, примечание. -func licenseTermsFromForm(r *http.Request) (License, string) { - var l License - l.Plan = r.PostFormValue("plan") - if planTitle(l.Plan) == l.Plan { - return l, "выберите план" - } - for _, f := range knownFeatures { - for _, v := range r.PostForm["features"] { - if v == f.Key { - l.Features = append(l.Features, f.Key) - } - } - } - n, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("max_nodes"))) - if err != nil || n < 0 || n > 1000 { - return l, "лимит установок — число от 0 (без лимита) до 1000" - } - l.MaxNodes = n - day, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(r.PostFormValue("contract_until")), msk) - if err != nil { - return l, "укажите дату окончания договора" - } - l.ContractUntil = day.Add(24*time.Hour - time.Second) // до конца дня по Москве - l.Note = clip(r.PostFormValue("note"), 1000) - return l, "" -} - -func (srv *server) licenseCreate(w http.ResponseWriter, r *http.Request, sess *session) { - terms, bad := licenseTermsFromForm(r) - tenantID := r.PostFormValue("tenant_id") - if bad != "" { - back(w, r, "/admin/licenses?tenant="+url.QueryEscape(tenantID), "", bad) - return - } - lic := terms - lic.ID, lic.TenantID, lic.CreatedAt, lic.CreatedBy = newID(), tenantID, time.Now(), sess.Login - err := srv.st.write(func(s *State) error { - t := s.tenant(tenantID) - if t == nil { - return fmt.Errorf("выберите клиента") - } - for lic.Code == "" || s.licenseByCode(lic.Code) != nil { - lic.Code = newActivationCode() - } - s.Licenses = append(s.Licenses, &lic) - s.addEvent("license_add", lic.ID, "", sess.Login, clientIP(r), - fmt.Sprintf("выпущена лицензия для «%s»: %s, установок %s, договор до %s", t.Name, planTitle(lic.Plan), nodesText(lic.MaxNodes), lic.ContractUntil.In(msk).Format("02.01.2006"))) - return nil - }) - if err != nil { - back(w, r, "/admin/licenses?tenant="+url.QueryEscape(tenantID), "", err.Error()) - return - } - back(w, r, "/admin/licenses/"+lic.ID, "Лицензия выпущена. Передайте клиенту код активации.", "") -} - -func nodesText(n int) string { - if n == 0 { - return "без лимита" - } - return strconv.Itoa(n) -} - -func (srv *server) licensePage(w http.ResponseWriter, r *http.Request, sess *session) { - srv.renderLicense(w, r, sess, r.PathValue("id"), "") -} - -func (srv *server) renderLicense(w http.ResponseWriter, r *http.Request, sess *session, id, offlineToken string) { - type view struct { - Row licenseRow - Installs []installRow - Events []eventRow - OfflineToken string - } - var v view - found := false - now := time.Now() - srv.st.read(func(s *State) { - l := s.license(id) - if l == nil { - return - } - found = true - v.Row = s.licenseRow(l, now) - for _, i := range s.installsOf(l.ID) { - v.Installs = append(v.Installs, installRow{Install: *i, TenantName: v.Row.TenantName, Stale: now.Sub(i.LastSeen) > 72*time.Hour}) - } - v.Events = s.eventRows(func(e *Event) bool { return e.LicenseID == l.ID }, 100) - }) - if !found { - http.NotFound(w, r) - return - } - v.OfflineToken = offlineToken - srv.render(w, r, sess, "license", "Лицензия "+v.Row.TenantName, "licenses", v) -} - -func (srv *server) licenseUpdate(w http.ResponseWriter, r *http.Request, sess *session) { - id := r.PathValue("id") - terms, bad := licenseTermsFromForm(r) - if bad != "" { - back(w, r, "/admin/licenses/"+id, "", bad) - return - } - err := srv.st.write(func(s *State) error { - l := s.license(id) - if l == nil { - return fmt.Errorf("лицензия не найдена") - } - l.Plan, l.Features, l.MaxNodes, l.ContractUntil, l.Note = terms.Plan, terms.Features, terms.MaxNodes, terms.ContractUntil, terms.Note - s.addEvent("license_edit", l.ID, "", sess.Login, clientIP(r), - fmt.Sprintf("изменены условия: %s, признаки [%s], установок %s, договор до %s — применятся при следующем продлении", planTitle(l.Plan), strings.Join(l.Features, ", "), nodesText(l.MaxNodes), l.ContractUntil.In(msk).Format("02.01.2006"))) - return nil - }) - if err != nil { - back(w, r, "/admin/licenses/"+id, "", err.Error()) - return - } - back(w, r, "/admin/licenses/"+id, "Условия сохранены. Установки получат их при следующем продлении (раз в сутки).", "") -} - -func (srv *server) licenseRevoke(w http.ResponseWriter, r *http.Request, sess *session) { - id := r.PathValue("id") - reason := clip(r.PostFormValue("reason"), 300) - if reason == "" { - back(w, r, "/admin/licenses/"+id, "", "укажите причину отзыва") - return - } - err := srv.st.write(func(s *State) error { - l := s.license(id) - if l == nil { - return fmt.Errorf("лицензия не найдена") - } - l.RevokedAt, l.RevokeReason = time.Now(), reason - s.addEvent("license_revoke", l.ID, "", sess.Login, clientIP(r), "лицензия отозвана: "+reason) - for _, t := range s.untrust(func(t *TrustedIP) bool { return t.LicenseID == l.ID }) { - s.addEvent("untrust_ip", l.ID, t.InstallID, sess.Login, clientIP(r), "IP "+t.IP+" исключён из доверенных fail2ban: лицензия отозвана") - } - return nil - }) - if err != nil { - back(w, r, "/admin/licenses/"+id, "", err.Error()) - return - } - back(w, r, "/admin/licenses/"+id, "Лицензия отозвана: продления прекращены, выданные токены доживут свой срок.", "") -} - -func (srv *server) licenseRestore(w http.ResponseWriter, r *http.Request, sess *session) { - id := r.PathValue("id") - err := srv.st.write(func(s *State) error { - l := s.license(id) - if l == nil { - return fmt.Errorf("лицензия не найдена") - } - l.RevokedAt, l.RevokeReason = time.Time{}, "" - s.addEvent("license_restore", l.ID, "", sess.Login, clientIP(r), "лицензия восстановлена") - return nil - }) - if err != nil { - back(w, r, "/admin/licenses/"+id, "", err.Error()) - return - } - back(w, r, "/admin/licenses/"+id, "Лицензия восстановлена", "") -} - -func (srv *server) licenseNewCode(w http.ResponseWriter, r *http.Request, sess *session) { - id := r.PathValue("id") - err := srv.st.write(func(s *State) error { - l := s.license(id) - if l == nil { - return fmt.Errorf("лицензия не найдена") - } - code := "" - for code == "" || s.licenseByCode(code) != nil { - code = newActivationCode() - } - l.Code = code - s.addEvent("license_code", l.ID, "", sess.Login, clientIP(r), "выпущен новый код активации, прежний больше не действует") - return nil - }) - if err != nil { - back(w, r, "/admin/licenses/"+id, "", err.Error()) - return - } - back(w, r, "/admin/licenses/"+id, "Новый код активации выпущен. Уже активированные установки продолжают работать.", "") -} - -// licenseOffline — ключ для закрытого контура без доступа к серверу (без привязки к установке). -func (srv *server) licenseOffline(w http.ResponseWriter, r *http.Request, sess *session) { - id := r.PathValue("id") - var token, bad string - err := srv.st.write(func(s *State) error { - l := s.license(id) - switch { - case l == nil: - bad = "лицензия не найдена" - case l.Revoked(): - bad = "лицензия отозвана" - case l.ContractExpired(time.Now()): - bad = "срок договора истёк" - } - if bad != "" { - return nil - } - now := time.Now() - tenant := "" - if t := s.tenant(l.TenantID); t != nil { - tenant = t.Name - } - lic := &license.License{Schema: license.CurrentSchema, ID: l.ID, Tenant: tenant, Product: "bj-server", Plan: license.Plan(l.Plan), - IssuedAt: now.UTC(), ExpiresAt: l.ContractUntil.UTC(), Features: l.Features, MaxNodes: l.MaxNodes, Note: "офлайн-ключ"} - tok, err := license.Sign(lic, srv.priv, srv.keyID) - if err != nil { - return err - } - token = tok.Encode() - s.addEvent("license_offline", l.ID, "", sess.Login, clientIP(r), "выпущен офлайн-ключ до "+l.ContractUntil.In(msk).Format("02.01.2006")+" (закрытый контур)") - return nil - }) - if err != nil || bad != "" { - back(w, r, "/admin/licenses/"+id, "", pickStr(bad, fmt.Sprint(err))) - return - } - srv.renderLicense(w, r, sess, id, token) -} - -func (srv *server) installBlock(w http.ResponseWriter, r *http.Request, sess *session) { - srv.installSetBlocked(w, r, sess, true) -} - -func (srv *server) installUnblock(w http.ResponseWriter, r *http.Request, sess *session) { - srv.installSetBlocked(w, r, sess, false) -} - -func (srv *server) installSetBlocked(w http.ResponseWriter, r *http.Request, sess *session, block bool) { - id, iid := r.PathValue("id"), r.PathValue("iid") - reason := clip(r.PostFormValue("reason"), 300) - if block && reason == "" { - back(w, r, "/admin/licenses/"+id, "", "укажите причину блокировки") - return - } - err := srv.st.write(func(s *State) error { - i := s.install(iid) - if i == nil || i.LicenseID != id { - return fmt.Errorf("установка не найдена") - } - i.Blocked, i.BlockReason = block, reason - if block { - s.addEvent("install_block", id, i.InstallID, sess.Login, clientIP(r), "установка "+pickStr(i.Hostname, short(i.InstallID))+" заблокирована: "+reason) - for _, t := range s.untrust(func(t *TrustedIP) bool { return t.InstallID == i.InstallID }) { - s.addEvent("untrust_ip", id, i.InstallID, sess.Login, clientIP(r), "IP "+t.IP+" исключён из доверенных fail2ban: установка заблокирована") - } - } else { - i.BlockReason = "" - s.addEvent("install_unblock", id, i.InstallID, sess.Login, clientIP(r), "установка "+pickStr(i.Hostname, short(i.InstallID))+" разблокирована") - } - return nil - }) - if err != nil { - back(w, r, "/admin/licenses/"+id, "", err.Error()) - return - } - if block { - back(w, r, "/admin/licenses/"+id, "Установка заблокирована: продление прекращено, место в лимите освобождено.", "") - return - } - back(w, r, "/admin/licenses/"+id, "Установка разблокирована", "") -} - -// trustedRow — строка списка доверенных IP. -type trustedRow struct { - TrustedIP - TenantName string -} - -type installsData struct { - Installs []installRow - Trusted []trustedRow -} - -func (srv *server) installsPage(w http.ResponseWriter, r *http.Request, sess *session) { - var data installsData - now := time.Now() - srv.st.read(func(s *State) { - tenantOf := func(licenseID string) string { - if l := s.license(licenseID); l != nil { - if t := s.tenant(l.TenantID); t != nil { - return t.Name - } - } - return "" - } - for _, i := range s.Installs { - data.Installs = append(data.Installs, installRow{Install: *i, Stale: now.Sub(i.LastSeen) > 72*time.Hour, TenantName: tenantOf(i.LicenseID)}) - } - for _, t := range s.Trusted { - data.Trusted = append(data.Trusted, trustedRow{TrustedIP: *t, TenantName: tenantOf(t.LicenseID)}) - } - }) - sort.Slice(data.Installs, func(i, j int) bool { return data.Installs[i].LastSeen.After(data.Installs[j].LastSeen) }) - sort.Slice(data.Trusted, func(i, j int) bool { return data.Trusted[i].LastSeen.After(data.Trusted[j].LastSeen) }) - srv.render(w, r, sess, "installs", "Установки", "installs", data) -} - -// trustedRemove — POST /admin/trusted/remove: исключить IP установки из доверенных fail2ban. -func (srv *server) trustedRemove(w http.ResponseWriter, r *http.Request, sess *session) { - ip, iid := clip(r.PostFormValue("ip"), 64), clip(r.PostFormValue("install"), 64) - var gone int - err := srv.st.write(func(s *State) error { - for _, t := range s.untrust(func(t *TrustedIP) bool { return t.IP == ip && t.InstallID == iid }) { - gone++ - s.addEvent("untrust_ip", t.LicenseID, t.InstallID, sess.Login, clientIP(r), "IP "+t.IP+" исключён из доверенных fail2ban вручную") - } - return nil - }) - switch { - case err != nil: - back(w, r, "/admin/installs", "", err.Error()) - case gone == 0: - back(w, r, "/admin/installs", "", "адрес не найден в доверенных") - default: - back(w, r, "/admin/installs", "IP "+ip+" исключён из доверенных. При следующем продлении установка добавит его снова; чтобы этого не было, заблокируйте установку.", "") - } -} - -func (srv *server) eventsPage(w http.ResponseWriter, r *http.Request, sess *session) { - var rows []eventRow - srv.st.read(func(s *State) { rows = s.eventRows(nil, 500) }) - srv.render(w, r, sess, "events", "Журнал", "events", rows) -} - -// ---- администраторы ---- - -func (srv *server) usersPage(w http.ResponseWriter, r *http.Request, sess *session) { - var users []User - srv.st.read(func(s *State) { - for _, u := range s.Users { - cp := *u - cp.PassHash, cp.HasTOTP, cp.TOTPSecret = "", u.TOTPSecret != "", "" - users = append(users, cp) - } - }) - sort.Slice(users, func(i, j int) bool { return users[i].Login < users[j].Login }) - srv.render(w, r, sess, "users", "Администраторы", "users", users) -} - -func (srv *server) userCreate(w http.ResponseWriter, r *http.Request, sess *session) { - login := strings.TrimSpace(r.PostFormValue("login")) - pw := r.PostFormValue("password") - if !loginRe.MatchString(login) { - back(w, r, "/admin/users", "", "логин: 2–40 символов, латиница, цифры, точка, дефис, подчёркивание") - return - } - if len(pw) < 10 { - back(w, r, "/admin/users", "", "пароль не короче 10 символов") - return - } - err := srv.st.write(func(s *State) error { - if s.user(login) != nil { - return fmt.Errorf("администратор %s уже есть", login) - } - if err := s.setUserPassword(login, clip(r.PostFormValue("name"), 100), pw, sess.Login); err != nil { - return err - } - if e := strings.TrimSpace(r.PostFormValue("email")); e != "" && validEmail(e) { - s.user(login).Email = e - } - return nil - }) - if err != nil { - back(w, r, "/admin/users", "", err.Error()) - return - } - back(w, r, "/admin/users", "Администратор "+login+" добавлен", "") -} - -func (srv *server) userPassword(w http.ResponseWriter, r *http.Request, sess *session) { - login := r.PathValue("login") - pw := r.PostFormValue("password") - if len(pw) < 10 { - back(w, r, "/admin/users", "", "пароль не короче 10 символов") - return - } - err := srv.st.write(func(s *State) error { - if s.user(login) == nil { - return fmt.Errorf("администратор не найден") - } - return s.setUserPassword(login, "", pw, sess.Login) - }) - if err != nil { - back(w, r, "/admin/users", "", err.Error()) - return - } - srv.sessions.dropUser(login, sess.Token) - back(w, r, "/admin/users", "Пароль "+login+" изменён, его сессии завершены", "") -} - -func (srv *server) userToggle(w http.ResponseWriter, r *http.Request, sess *session) { - login := r.PathValue("login") - if strings.EqualFold(login, sess.Login) { - back(w, r, "/admin/users", "", "нельзя отключить самого себя") - return - } - var disabled bool - err := srv.st.write(func(s *State) error { - u := s.user(login) - if u == nil { - return fmt.Errorf("администратор не найден") - } - u.Disabled = !u.Disabled - disabled = u.Disabled - if disabled { - s.addEvent("user_disable", "", "", sess.Login, clientIP(r), "отключён администратор "+u.Login) - } else { - s.addEvent("user_enable", "", "", sess.Login, clientIP(r), "включён администратор "+u.Login) - } - return nil - }) - if err != nil { - back(w, r, "/admin/users", "", err.Error()) - return - } - if disabled { - srv.sessions.dropUser(login, "") - back(w, r, "/admin/users", "Администратор "+login+" отключён", "") - return - } - back(w, r, "/admin/users", "Администратор "+login+" включён", "") -} - -func (srv *server) mePassword(w http.ResponseWriter, r *http.Request, sess *session) { - cur, pw, pw2 := r.PostFormValue("current"), r.PostFormValue("password"), r.PostFormValue("password2") - if len(pw) < 10 || pw != pw2 { - back(w, r, "/admin/users", "", "новый пароль не короче 10 символов и должен совпадать с повтором") - return - } - err := srv.st.write(func(s *State) error { - u := s.user(sess.Login) - if u == nil || bcrypt.CompareHashAndPassword([]byte(u.PassHash), []byte(cur)) != nil { - return fmt.Errorf("текущий пароль неверен") - } - return s.setUserPassword(u.Login, "", pw, sess.Login) - }) - if err != nil { - back(w, r, "/admin/users", "", err.Error()) - return - } - srv.sessions.dropUser(sess.Login, sess.Token) - back(w, r, "/admin/users", "Ваш пароль изменён", "") -} diff --git a/cmd/bj-license-server/api.go b/cmd/bj-license-server/api.go deleted file mode 100644 index 7afb7ad..0000000 --- a/cmd/bj-license-server/api.go +++ /dev/null @@ -1,291 +0,0 @@ -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 -} diff --git a/cmd/bj-license-server/firewall.go b/cmd/bj-license-server/firewall.go deleted file mode 100644 index 805a64b..0000000 --- a/cmd/bj-license-server/firewall.go +++ /dev/null @@ -1,917 +0,0 @@ -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 …] 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+".", "") -} diff --git a/cmd/bj-license-server/main.go b/cmd/bj-license-server/main.go deleted file mode 100644 index 2eeacbd..0000000 --- a/cmd/bj-license-server/main.go +++ /dev/null @@ -1,144 +0,0 @@ -// Command bj-license-server — сервер лицензирования Bridge&Join (https://lic.zetit.ru). -// -// Выдаёт и продлевает лицензии установкам bj по коду активации (контракт — -// docs/license-server-api.md) и даёт веб-кабинет администратора: клиенты, лицензии, -// установки, журнал, администраторы. Вход в кабинет — логин и пароль. -// -// Хранилище — один JSON-файл с атомарной записью и ежедневными копиями рядом: клиентов -// десятки, записей немного, отдельная СУБД не нужна. -// -// bj-license-server serve -addr 127.0.0.1:8091 -data /var/lib/bj-license/state.json -key /etc/bj-license/license.priv -// bj-license-server user-set -data /var/lib/bj-license/state.json -login admin -// (пароль из stdin; пусто — сгенерировать; сервер должен быть остановлен) -// bj-license-server fw-apply -dir /var/lib/bj-license [-off] (root, из systemd: защита, см. firewall.go) -package main - -import ( - "bufio" - "crypto/ed25519" - "flag" - "fmt" - "log" - "net/http" - "os" - "strings" - "time" - - "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license" -) - -func main() { - if len(os.Args) < 2 { - usage() - } - switch os.Args[1] { - case "serve": - serve(os.Args[2:]) - case "user-set": - userSet(os.Args[2:]) - case "fw-apply": - fwApply(os.Args[2:]) - case "fw-ignore": - fwIgnore(os.Args[2:]) - case "admin-info": - adminInfo(os.Args[2:]) - case "admin-config": - adminConfig(os.Args[2:]) - default: - usage() - } -} - -func usage() { - fmt.Fprintln(os.Stderr, "bj-license-server serve [-addr 127.0.0.1:8091] [-data state.json] [-key license.priv] [-keyid main]") - fmt.Fprintln(os.Stderr, "bj-license-server user-set -login admin [-name Имя] [-data state.json] (пароль из stdin)") - fmt.Fprintln(os.Stderr, "bj-license-server fw-apply [-dir /var/lib/bj-license] [-off] (root: применить защиту; -off — аварийно выключить оборону)") - fmt.Fprintln(os.Stderr, "bj-license-server fw-ignore [-dir /var/lib/bj-license] 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) - } -} diff --git a/cmd/bj-license-server/store.go b/cmd/bj-license-server/store.go deleted file mode 100644 index 5b34246..0000000 --- a/cmd/bj-license-server/store.go +++ /dev/null @@ -1,460 +0,0 @@ -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) -} diff --git a/cmd/bj-license-server/web/pages.html b/cmd/bj-license-server/web/pages.html deleted file mode 100644 index 66f95ea..0000000 --- a/cmd/bj-license-server/web/pages.html +++ /dev/null @@ -1,484 +0,0 @@ -{{define "style"}}{{end}} - -{{define "logo"}}{{end}} - -{{define "head"}}{{.Title}} · лицензии Bridge&Join{{template "style"}} -
- {{template "logo"}}Bridge&Joinлицензии - -
{{if .Me}}{{if .Me.Name}}{{.Me.Name}}{{else}}{{.Me.Login}}{{end}} -
{{end}}
-
-
-{{if .OK}}
{{.OK}}
{{end}} -{{if .Err}}
{{.Err}}
{{end}} -{{end}} - -{{define "foot"}}
{{end}} - -{{define "login"}}Вход · лицензии Bridge&Join{{template "style"}} - -
-
{{template "logo"}}Bridge&Join
-

Сервер лицензирования: вход администратора

-
- -
-
-
- - {{if .Err}}
{{.Err}}
{{end}} -
-
{{end}} - -{{define "login2fa"}}Подтверждение входа · лицензии Bridge&Join{{template "style"}} - -
-
{{template "logo"}}Bridge&Join
-

Подтвердите вход

-

Запрос отправлен в приложение «ЗЕТИТ Ключ» ({{.Data.Email}}). Подтвердите вход на телефоне, и страница продолжит сама.

-

{{.Data.Left}}

-
-
- -
- - {{if .Err}}
{{.Err}}
{{end}} -
-

Отмена

-
-{{end}} - -{{define "evtable"}}
- -{{range .}} - - - - -{{else}}{{end}} -
КогдаСобытиеКлиентКтоIP
{{dt .At}}{{.Text}}{{if .InstallID}} {{short .InstallID}}{{end}}{{if .LicenseID}}{{if .TenantName}}{{.TenantName}}{{else}}{{short .LicenseID}}{{end}}{{else}}{{end}}{{.Actor}}{{.IP}}
Событий пока нет
{{end}} - -{{define "dashboard"}}{{template "head" .}} -

Обзор

-
-
{{.Data.Tenants}}клиентов
-
{{.Data.Active}}действующих лицензий
-
{{.Data.Revoked}}отозвано
-
{{.Data.Installs}}установок всего
-
{{.Data.Online}}на связи за 48 ч
-
-{{if .Data.Expiring}}

Требуют внимания

- -{{range .Data.Expiring}}{{end}} -
КлиентПланДоговор доСтатус
{{.TenantName}}{{plan .Plan}}{{d .ContractUntil}}{{.Status}}
{{end}} -

Последние события

{{template "evtable" .Data.Events}}

Весь журнал →

-{{template "foot" .}}{{end}} - -{{define "tenants"}}{{template "head" .}} -

Клиенты

-
- -{{range .Data}} -{{else}}{{end}} -
КлиентИННКонтактЛицензийУстановокДобавлен
{{.Name}}{{.INN}}{{.Contact}}{{.Licenses}}{{.Installs}}{{d .CreatedAt}}
Клиентов пока нет. Добавьте первого ниже.
-

Новый клиент

-
-
-
-
-
-
-
-
-
-{{template "foot" .}}{{end}} - -{{define "tenant"}}{{template "head" .}} -

← Клиенты

-

{{.Data.Tenant.Name}}

-
-
- -{{range .Data.Licenses}} -{{else}}{{end}} -
ЛицензияПланУстановокДоговор доСтатус
{{.Code}}{{plan .Plan}}{{.Active}} из {{if .MaxNodes}}{{.MaxNodes}}{{else}}∞{{end}}{{d .ContractUntil}}{{.Status}}
Лицензий нет
-

Реквизиты

-
-
-
-
-
-
-
-
добавлен {{dt .Data.Tenant.CreatedAt}}
-
-{{template "foot" .}}{{end}} - -{{define "terms"}} -
-
-
-
-
-
{{$f := .Features}}{{range $.All}}{{end}}
-
-{{end}} - -{{define "licenses"}}{{template "head" .}} -

Лицензии

-
- -{{range .Data.Rows}} - - - -{{else}}{{end}} -
КлиентКод активацииПланМодулиУстановокДоговор доСтатус
{{.TenantName}}{{.Code}}{{plan .Plan}}{{range .Features}}{{feature .}}{{else}}{{end}}{{.Active}} из {{if .MaxNodes}}{{.MaxNodes}}{{else}}∞{{end}}{{d .ContractUntil}}{{.Status}}
Лицензий пока нет.
-

Выпустить лицензию

-{{if .Data.Tenants}} -
-
- {{template "terms" (dict2 .Plans .Features)}} -
-
-{{else}}

Сначала добавьте клиента.

{{end}} -
-{{template "foot" .}}{{end}} - -{{define "license"}}{{template "head" .}}{{$row := .Data.Row}}{{$csrf := .CSRF}} -

← Лицензии · {{$row.TenantName}}

-

{{$row.TenantName}} {{$row.Status}}

- -{{if .Data.OfflineToken}}

Офлайн-ключ выпущен

-

Ключ показывается один раз. Вставьте его в bj: Настройка → Лицензия. Действует до {{d $row.ContractUntil}} без связи с сервером; отзыв на него не распространяется.

-
{{.Data.OfflineToken}}
{{end}} - -
-

Код активации

-
{{$row.Code}}
-

Клиент вводит код в bj (Настройка → Лицензия → «Активировать»). Одним кодом активируется до {{if $row.MaxNodes}}{{$row.MaxNodes}}{{else}}любого числа{{end}} установок; сейчас активных {{$row.Active}}.

-
-
-
-

Сводка

-
План
{{plan $row.Plan}}
-
Модули
{{range $row.Features}}{{feature .}}{{else}}{{end}}
-
Договор до
{{d $row.ContractUntil}}{{if not $row.Revoked}} (осталось {{daysLeft $row.ContractUntil}} дн.){{end}}
-
Выпущена
{{dt $row.CreatedAt}}{{if $row.CreatedBy}} · {{$row.CreatedBy}}{{end}}
-
ID
{{$row.ID}}
- {{if $row.Revoked}}
Отозвана
{{dt $row.RevokedAt}}: {{$row.RevokeReason}}
{{end}} -
-
- -

Установки

- -{{range .Data.Installs}} - - - - -{{else}}{{end}} -
СерверВерсияIPПервая активацияПоследнее продлениеТокен до
{{if .Hostname}}{{.Hostname}}{{else}}{{end}}
{{.InstallID}}
{{if .Blocked}}заблокирована {{.BlockReason}}{{else if .Stale}}нет связи{{end}}
{{.Version}}{{.IP}}{{dt .FirstSeen}}{{dt .LastSeen}}
{{ago .LastSeen}}
{{d .TokenExpires}}{{if .Blocked}}
- {{else}}
Заблокировать
{{end}}
Установок ещё нет: они появятся после активации кодом.
- -

Условия

-
- {{template "terms" (dict3 .Plans .Features $row.License)}} -
установки получат их при следующем продлении
-
- -
-

Офлайн-ключ

-

Для закрытого контура без доступа к серверу: ключ без привязки к установке, действует до окончания договора. Отозвать его нельзя.

-
-
-

{{if $row.Revoked}}Восстановление{{else}}Отзыв{{end}}

-{{if $row.Revoked}} -
-{{else}} -

Продления прекращаются; выданные токены доживают свой срок (до 60 дней).

-
-
-{{end}} -
-
- -

Журнал лицензии

{{template "evtable" .Data.Events}}
-{{template "foot" .}}{{end}} - -{{define "installs"}}{{template "head" .}}{{$csrf := .CSRF}} -

Установки

-
- -{{range .Data.Installs}} - - - - -{{else}}{{end}} -
КлиентСерверВерсияIPПоследнее продлениеТокен доСтатус
{{if .TenantName}}{{.TenantName}}{{else}}{{short .LicenseID}}{{end}}{{if .Hostname}}{{.Hostname}}{{else}}{{end}}
{{short .InstallID}}
{{.Version}}{{.IP}}{{dt .LastSeen}}
{{ago .LastSeen}}
{{d .TokenExpires}}{{if .Blocked}}заблокирована{{else if .Stale}}нет связи{{else}}на связи{{end}}
Установок пока нет.
-

Доверенные IP (fail2ban)

-

Адрес попадает сюда после успешной активации или продления лицензии. fail2ban не блокирует его за ошибки API (неверный код, испорченный токен). На вход в кабинет и SSH доверие не распространяется. При отзыве лицензии или блокировке установки адрес исключается автоматически.

-
- -{{range .Data.Trusted}} - - - - - -{{else}}{{end}} -
IPКлиентСерверДобавленПоследнее обращение
{{.IP}}{{if .TenantName}}{{.TenantName}}{{else}}{{short .LicenseID}}{{end}}{{if .Hostname}}{{.Hostname}}{{else}}{{end}}
{{short .InstallID}}
{{dt .AddedAt}}{{dt .LastSeen}}
{{ago .LastSeen}}
Доверенных адресов пока нет: они появятся после первой активации или продления.
-{{template "foot" .}}{{end}} - -{{define "security"}}{{template "head" .}}{{$csrf := .CSRF}}{{$d := .Data}} -

Защита

-

Доступ к кабинету

-
-
-

Адрес кабинета

-

https://{{$d.Host}}{{$d.AdminBase}}/ {{if $d.AdminDefault}}стандартный{{else}}свой{{end}}

-
-
-
-{{if not $d.AdminDefault}}
{{end}} -
-

При своём адресе /admin и главная страница отвечают «не найдено», и боты не находят форму входа. Сохраните адрес в закладки и не пересылайте. Узнать по SSH: /opt/bj-license/bj-license-server admin-info

-
-
-

Только из белого списка

-

{{if and $d.AdminAllowOnly $d.AdminOpen}}открыт аварийно по SSH Ограничение включено, но сейчас не действует.{{else if $d.AdminAllowOnly}}включено Кабинет и страница входа видны только адресам белого списка администраторов, остальным отвечает «не найдено».{{else}}выключено Страница входа открывается с любого адреса.{{end}}

-{{if and $d.AdminAllowOnly (not $d.AdminOpen)}}
-{{else}}
{{end}} -

API активации и продления это не затрагивает. Ваш адрес {{$d.MyIP}} {{if $d.MyIPCovered}}в белом списке{{else}}добавится в белый список при включении{{end}}. Аварийно открыть кабинет по SSH: /opt/bj-license/bj-license-server fw-apply -off

-
-
-

Второй фактор

-

{{if eq $d.TwoFA $d.Admins}}у всех{{else}}{{$d.TwoFA}} из {{$d.Admins}}{{end}} администраторов входят с кодом из приложения.

- -

Даже зная пароль, без телефона войти нельзя. Потерянный телефон: второй фактор сбрасывает другой администратор или по SSH admin-config -reset-2fa логин при остановленном сервере.

-
-
- -

ЗЕТИТ Ключ

-

{{if $d.ZKEnabled}}включён После пароля вход подтверждается push-уведомлением в приложении «ЗЕТИТ Ключ» или кодом из него. Действует для администраторов с указанным e-mail ({{$d.ZKWithEmail}} из {{$d.Admins}}).{{else}}выключен Второй фактор через приложение «ЗЕТИТ Ключ», как при входе в ZETIT Connect.{{end}}

-
-
-
- -
-
-

E-mail администраторов задаётся на странице «Администраторы» и должен совпадать с учётной записью в ЗЕТИТ Ключ. Включить можно, только если ваш e-mail найден в Ключе с привязанным приложением. У администраторов без e-mail действует пароль и код приложения-аутентификатора, если он подключён. Если Ключ недоступен, вход с e-mail невозможен. Аварийно выключить по SSH при остановленном сервере: bj-license-server admin-config -zetit-key off

-
- -{{if not $d.StatusFresh}}
Помощник защиты не отвечает -

{{if $d.Status.At.IsZero}}Статуса ещё нет.{{else}}Последний отчёт {{dt $d.Status.At}} ({{ago $d.Status.At}}).{{end}} Команды из этого раздела не применяются. На сервере: systemctl status bj-license-fw.timer bj-license-fw.path

{{end}} - -
-

Режим обороны

-

{{if $d.Status.Lockdown}}Оборона включена На сайт и API пускаются только адреса из белого списка ({{$d.Status.AllowCount}}){{if not $d.Status.Until.IsZero}}, автоматически выключится {{dt $d.Status.Until}}{{end}}. -{{else if $d.Status.Emergency}}Оборона выключена аварийно по SSH Включить снова можно кнопкой ниже. -{{else}}Обычный режим Доступ открыт всем, fail2ban блокирует адреса, которые перебирают пароли и коды.{{end}}

-{{if $d.WantedActive}} -
-{{else}} -
- -
-{{end}} -

Для атаки на сервер: все, кроме белого списка, получают отказ на уровне файрвола (порты 80 и 443). В белом списке: доверенные IP установок ({{$d.TrustedCount}}, Установки) и адреса администраторов ниже. Ваш адрес {{$d.MyIP}} {{if $d.MyIPCovered}}уже в белом списке{{else}}добавится в белый список автоматически при включении{{end}}. SSH не затрагивается. Пока оборона включена, Let's Encrypt не сможет продлить сертификат. Аварийно выключить по SSH: /opt/bj-license/bj-license-server fw-apply -off

-{{if $d.Status.Errors}}
{{range $d.Status.Errors}}
{{.}}
{{end}}
{{end}} -
- -

Белый список администраторов

-

Адреса офиса и администраторов. Они не блокируются fail2ban (ни за вход в кабинет, ни за API) и пропускаются в режиме обороны.

-
- -{{range $d.Allow}} - -{{else}}{{end}} -
IP или сетьКомментарийДобавил
{{.Net}}{{.Comment}}{{.AddedBy}}
{{dt .AddedAt}}
Пока пусто.
-
-
-
- -

Параметры блокировок

-

Сколько неудачных попыток за период приводят к блокировке адреса и на какой срок. Долгие блокировки сохраняются и после перезапуска fail2ban. Белый список и доверенные установки не блокируются.

-
-
- -{{range $d.Limits}}{{$row := .}} - - - -{{end}} - - - - -
Что защищаемПопытокЗа периодБлокировать на
{{.Title}}{{if .HasEffective}}
{{.Effective}}
{{end}}
Вход в кабинет (приложение)
срабатывает раньше fail2ban: после N неудачных попыток форма входа отказывает этому адресу и логину
за срок блокировки
-
-
-
- -

fail2ban

-
- -{{range $d.Status.Jails}}{{$jail := .Name}} - - -{{else}}{{end}} -
JailОшибок сейчас / всегоЗаблокировано сейчас / всегоЗаблокированные адреса
{{.Name}}
{{if eq .Name "bj-license-api"}}коды активации и токены{{else if eq .Name "bj-license-admin"}}вход в кабинет{{else if eq .Name "sshd"}}SSH{{end}}
{{.CurrentlyFailed}} / {{.TotalFailed}}{{.CurrentlyBanned}} / {{.TotalBanned}}{{range .Banned}}
{{.}}
{{else}}нет{{end}}
Данных пока нет.
-{{if $d.Pending}}

Ожидают применения: {{range $d.Pending}}{{.Op}} {{.IP}} ({{.Jail}}) {{end}}

{{end}} -
- - - -
-

Ручная блокировка действует срок блокировки jail из «Параметров блокировок». Статус обновляется раз в 30 секунд{{if not $d.Status.At.IsZero}}, последний раз {{dt $d.Status.At}}{{end}}.

-
-{{template "foot" .}}{{end}} - -{{define "totp"}}{{template "head" .}}{{$csrf := .CSRF}}{{$d := .Data}} -

Второй фактор входа

-
-{{if $d.Enabled}} -

Подключён При входе в кабинет, кроме пароля, нужен шестизначный код из приложения.

-
-
-
-{{else}} -

Установите на телефон приложение для одноразовых кодов: Яндекс Ключ, Google Authenticator, Microsoft Authenticator или любое с поддержкой TOTP.

-
-

1. Добавьте ключ в приложение

-{{if $d.QR}}
{{$d.QR}}
{{end}} -

Отсканируйте QR-код или введите ключ вручную: {{$d.SecretGrouped}}. Если открыли кабинет на телефоне, добавить в приложение.

-

2. Подтвердите кодом

-
-
-
-

Ключ показывается только на этой странице. Если телефон потеряется, второй фактор сбросит другой администратор на странице «Администраторы».

-
-{{end}} -
-{{template "foot" .}}{{end}} - -{{define "events"}}{{template "head" .}} -

Журнал

-
{{template "evtable" .Data}}

Последние 500 событий.

-{{template "foot" .}}{{end}} - -{{define "users"}}{{template "head" .}}{{$csrf := .CSRF}}{{$me := .Me}} -

Администраторы

-
- -{{range .Data}} - - - - - -{{end}} -
ЛогинИмяE-mail (ЗЕТИТ Ключ)Последний входСтатусВторой факторСменить пароль
{{.Login}}{{.Name}}
{{dt .LastLogin}}{{if .Disabled}}отключён{{else}}активен{{end}}{{if .HasTOTP}}подключён{{if ne .Login $me.Login}}
{{end}}{{else}}нет{{end}}{{if eq .Login $me.Login}}{{end}}
{{if ne .Login $me.Login}}
{{end}}
-
-

Новый администратор

-
-
-
-
-
-
-

Мой пароль

-
-
-
-
-
-
-{{template "foot" .}}{{end}} diff --git a/cmd/bj-license-server/zetitkey.go b/cmd/bj-license-server/zetitkey.go deleted file mode 100644 index c343742..0000000 --- a/cmd/bj-license-server/zetitkey.go +++ /dev/null @@ -1,389 +0,0 @@ -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", "Настройки ЗЕТИТ Ключ сохранены, вход через Ключ выключен.", "") - } -} diff --git a/cmd/bj-license/main.go b/cmd/bj-license/main.go deleted file mode 100644 index dc09886..0000000 --- a/cmd/bj-license/main.go +++ /dev/null @@ -1,192 +0,0 @@ -// Command bj-license — инструмент издателя: генерация ключей подписи, -// выпуск годовых лицензий и проверка. -// -// bj-license keygen -out ./keys/license -// bj-license issue -tenant "ООО Ромашка" -plan pro -days 365 \ -// -features updates,web-cabinet -key ./keys/license.priv -keyid main -// bj-license verify -key-file license.key -pub ./keys/license.pub -package main - -import ( - "crypto/ed25519" - "crypto/rand" - "encoding/base64" - "encoding/hex" - "fmt" - "os" - "strings" - "time" - - "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license" -) - -// newUUID — UUID v4 без внешних зависимостей. -func newUUID() string { - var b [16]byte - _, _ = rand.Read(b[:]) - b[6] = (b[6] & 0x0f) | 0x40 // версия 4 - 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:32] -} - -func main() { - if len(os.Args) < 2 { - usage() - } - switch os.Args[1] { - case "keygen": - keygen(os.Args[2:]) - case "issue": - issue(os.Args[2:]) - case "verify": - verify(os.Args[2:]) - default: - usage() - } -} - -func usage() { - fmt.Fprintln(os.Stderr, "bj-license keygen -out ") - fmt.Fprintln(os.Stderr, "bj-license issue -tenant -plan free|pro|enterprise -days -features a,b -key [-keyid id] [-max-nodes n] [-note txt]") - fmt.Fprintln(os.Stderr, "bj-license verify -key-file -pub ") - os.Exit(2) -} - -func keygen(args []string) { - out := "license" - for i := 0; i < len(args)-1; i++ { - if args[i] == "-out" { - out = args[i+1] - } - } - pub, priv, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - fatal("keygen: %v", err) - } - if err := os.WriteFile(out+".priv", []byte(base64.StdEncoding.EncodeToString(priv.Seed())+"\n"), 0o600); err != nil { - fatal("write priv: %v", err) - } - if err := os.WriteFile(out+".pub", []byte(base64.StdEncoding.EncodeToString(pub)+"\n"), 0o644); err != nil { - fatal("write pub: %v", err) - } - fmt.Printf("Приватный ключ лицензий: %s.priv (СЕКРЕТ)\n", out) - fmt.Printf("Публичный ключ (зашить в bj-server):\n %s\n", base64.StdEncoding.EncodeToString(pub)) -} - -func issue(args []string) { - a := parseArgs(args) - tenant := a["tenant"] - keyPath := a["key"] - if tenant == "" || keyPath == "" { - fatal("issue: требуются -tenant и -key") - } - plan := license.Plan(orDefault(a["plan"], "pro")) - days := atoiDefault(a["days"], 365) - keyID := orDefault(a["keyid"], "main") - - priv, err := license.LoadPrivateKey(keyPath) - if err != nil { - fatal("load key: %v", err) - } - - now := time.Now().UTC() - var feats []string - if a["features"] != "" { - feats = strings.Split(a["features"], ",") - } - l := &license.License{ - Schema: license.CurrentSchema, - ID: newUUID(), - Tenant: tenant, - Product: "bj-server", - Plan: plan, - IssuedAt: now, - ExpiresAt: now.AddDate(0, 0, days), - Features: feats, - MaxNodes: atoiDefault(a["max-nodes"], 0), - Note: a["note"], - } - tok, err := license.Sign(l, priv, keyID) - if err != nil { - fatal("sign: %v", err) - } - fmt.Printf("Лицензия выпущена: tenant=%q plan=%s до %s (%d дней)\n", - tenant, plan, l.ExpiresAt.Format("02.01.2006"), days) - fmt.Printf("ID: %s\n", l.ID) - fmt.Println("Ключ для клиента (вставить в bj-server → Лицензия):") - fmt.Println(tok.Encode()) -} - -func verify(args []string) { - a := parseArgs(args) - if a["key-file"] == "" || a["pub"] == "" { - fatal("verify: требуются -key-file и -pub") - } - raw, err := os.ReadFile(a["key-file"]) - if err != nil { - fatal("read key-file: %v", err) - } - pubB, err := os.ReadFile(a["pub"]) - if err != nil { - fatal("read pub: %v", err) - } - pub, err := license.ParsePublicKey(strings.TrimSpace(string(pubB))) - if err != nil { - fatal("pub: %v", err) - } - tok, err := license.DecodeToken(string(raw)) - if err != nil { - fatal("decode: %v", err) - } - l, err := license.Verify(tok, pub) - if err != nil { - fatal("verify: %v", err) - } - now := time.Now().UTC() - fmt.Printf("Подпись валидна. tenant=%q plan=%s\n", l.Tenant, l.Plan) - fmt.Printf("Действует: %s — %s (осталось %d дней)\n", - l.IssuedAt.Format("02.01.2006"), l.ExpiresAt.Format("02.01.2006"), l.DaysLeft(now)) - if err := l.Valid(now); err != nil { - fmt.Printf("СТАТУС: %v\n", err) - } else { - fmt.Printf("СТАТУС: активна, обновления %v\n", l.AllowsUpdates()) - } -} - -// --- helpers --- - -func parseArgs(args []string) map[string]string { - m := map[string]string{} - for i := 0; i < len(args); i++ { - if strings.HasPrefix(args[i], "-") && i+1 < len(args) { - m[strings.TrimPrefix(args[i], "-")] = args[i+1] - i++ - } - } - return m -} - -func orDefault(s, def string) string { - if s == "" { - return def - } - return s -} - -func atoiDefault(s string, def int) int { - if s == "" { - return def - } - var n int - _, err := fmt.Sscanf(s, "%d", &n) - if err != nil { - return def - } - return n -} - -func fatal(format string, a ...any) { - fmt.Fprintf(os.Stderr, "bj-license: "+format+"\n", a...) - os.Exit(1) -} diff --git a/deploy/license/README.md b/deploy/license/README.md deleted file mode 100644 index 697ae72..0000000 --- a/deploy/license/README.md +++ /dev/null @@ -1,71 +0,0 @@ -# Лицензирование Bridge-and-Join-s (#19) - -Годовые лицензии с офлайн-проверкой по подписи Ed25519 + опциональный -онлайн-отзыв. - -## Компоненты - -- `internal/license` — формат лицензии + подпись/проверка (offline). -- `cmd/bj-license` — издательский CLI: keygen, issue, verify. -- `cmd/bj-license-server` — онлайн-реестр отзывов (revocation). -- Интеграция в bj-server: `internal/lkgateway/licensecheck.go` — проверка - лицензии + гейт обновлений; UI раздел «Лицензия». - -## Модель - -Лицензия — самодостаточный подписанный токен: `payload.signature.keyid`. -bj-server проверяет подпись зашитым публичным ключом и срок **офлайн** — -работает без связи с сервером. Online-сервер нужен только для отзыва. - -**Гейт обновлений:** если лицензирование включено (есть публичный ключ), -авто-обновление (#20) выполняется только при валидной лицензии с фичей -`updates`. Без лицензирования (публичный ключ не зашит) — открытый режим, -гейты не действуют (бесплатная редакция / разработка). - -## Издателю - -```bash -# 1. Ключи лицензий (однократно; приватный — в секрете!) -bj-license keygen -out ./keys/license -# публичный base64 — зашить в bj-server (DefaultLicensePublicKey) - -# 2. Выпустить годовую лицензию клиенту -bj-license issue -tenant "ООО Ромашка" -plan pro -days 365 \ - -features updates,web-cabinet -key ./keys/license.priv -keyid main -# → выводит ключ payload.signature.keyid — отдать клиенту - -# 3. Проверить -bj-license verify -key-file license.key -pub ./keys/license.pub -``` - -Планы: `free` (без фич), `pro` (перечисленные features), `enterprise` -(всё включено). Фичи: `updates`, `web-cabinet`, … - -## Клиенту (on-prem bj-server) - -Админ → Настройка → **Лицензия** → вставить ключ → «Активировать». -Проверка офлайн; статус (организация, план, срок, обновления) виден сразу. - -## Онлайн-отзыв (опционально) - -```bash -bj-license-server --addr :8091 --revoked /var/lib/bj-license/revoked.json -``` - -`revoked.json` — JSON-массив отозванных license ID: - -```json -["28db4973-fde8-434c-b102-e83623eede2c"] -``` - -`GET /v1/check?id=` → `{"revoked":true|false}`. Перечитывается раз в -минуту. В проде заменить файл на PostgreSQL + admin API выпуска/отзыва. - -## Зашивка публичного ключа в релиз - -```bash -go build -ldflags "\ - -X .../lkgateway.DefaultLicensePublicKey= \ - -X .../lkgateway.DefaultUpdatePublicKey= \ - -X .../lkgateway.BuildVersion=1.0.0" -o bj-server ./cmd/bj-server/ -``` diff --git a/deploy/license/bj-license-fw.path b/deploy/license/bj-license-fw.path deleted file mode 100644 index f0ee7e8..0000000 --- a/deploy/license/bj-license-fw.path +++ /dev/null @@ -1,11 +0,0 @@ -[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 diff --git a/deploy/license/bj-license-fw.service b/deploy/license/bj-license-fw.service deleted file mode 100644 index 752779f..0000000 --- a/deploy/license/bj-license-fw.service +++ /dev/null @@ -1,7 +0,0 @@ -[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 diff --git a/deploy/license/bj-license-fw.timer b/deploy/license/bj-license-fw.timer deleted file mode 100644 index 9e4010a..0000000 --- a/deploy/license/bj-license-fw.timer +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -Description=Bridge&Join — статус защиты сервера лицензий раз в 30 секунд (и восстановление правил после перезагрузки) - -[Timer] -OnBootSec=30s -OnUnitActiveSec=30s -AccuracySec=5s -Unit=bj-license-fw.service - -[Install] -WantedBy=timers.target diff --git a/deploy/license/bj-license-server.service b/deploy/license/bj-license-server.service deleted file mode 100644 index b1996c8..0000000 --- a/deploy/license/bj-license-server.service +++ /dev/null @@ -1,22 +0,0 @@ -[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 diff --git a/deploy/license/fail2ban/filter.d/bj-license-admin.conf b/deploy/license/fail2ban/filter.d/bj-license-admin.conf deleted file mode 100644 index 753c7eb..0000000 --- a/deploy/license/fail2ban/filter.d/bj-license-admin.conf +++ /dev/null @@ -1,6 +0,0 @@ -# fail2ban: перебор паролей кабинета сервера лицензий bj -# (строки пишет bj-license-server в журнал systemd; IP клиента берётся из X-Real-IP nginx). -[Definition] -failregex = ^.*login failed: user=".*" ip=$ -ignoreregex = -journalmatch = _SYSTEMD_UNIT=bj-license-server.service diff --git a/deploy/license/fail2ban/filter.d/bj-license-api.conf b/deploy/license/fail2ban/filter.d/bj-license-api.conf deleted file mode 100644 index f81dd15..0000000 --- a/deploy/license/fail2ban/filter.d/bj-license-api.conf +++ /dev/null @@ -1,5 +0,0 @@ -# fail2ban: перебор кодов активации и токенов API сервера лицензий bj. -[Definition] -failregex = ^.*api denied: (unknown_code|bad_token) ip=$ -ignoreregex = -journalmatch = _SYSTEMD_UNIT=bj-license-server.service diff --git a/deploy/license/fail2ban/jail.d/bj-license.conf b/deploy/license/fail2ban/jail.d/bj-license.conf deleted file mode 100644 index 6f27224..0000000 --- a/deploy/license/fail2ban/jail.d/bj-license.conf +++ /dev/null @@ -1,30 +0,0 @@ -# Сервер лицензий 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 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 api - -[sshd] -enabled = true -maxretry = 5 -findtime = 10m -bantime = 1h diff --git a/docs/license-server-api.md b/docs/license-server-api.md index 33f911d..e8f1a8c 100644 --- a/docs/license-server-api.md +++ b/docs/license-server-api.md @@ -1,5 +1,8 @@ # Сервер лицензирования и обновлений bj — контракт API +> Сервер лицензий живёт в отдельном репозитории **zuevav/lic_Bridge-and-Join-s** (наша инфраструктура, клиентам не поставляется). Здесь — контракт API для клиентской части bj (`internal/lkgateway/licenseclient.go`). Формат токена общий: `internal/license`, тест `TestTokenCompat` одинаков в обоих репозиториях. + + Сервер: **https://lic.zetit.ru** (ВМ 46.173.19.220, Ubuntu 26.04). Адрес зашит в bj (`internal/lkgateway`, константа), в интерфейсе не меняется. С этого же сервера bj получает обновления. diff --git a/go.mod b/go.mod index 131acc1..9881920 100644 --- a/go.mod +++ b/go.mod @@ -11,8 +11,6 @@ require ( 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 diff --git a/go.sum b/go.sum index bc36cbd..3499083 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDP 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/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= +github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= 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= @@ -37,6 +39,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -45,6 +49,18 @@ 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/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= 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= @@ -94,5 +110,3 @@ 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= diff --git a/internal/license/compat_test.go b/internal/license/compat_test.go new file mode 100644 index 0000000..8439646 --- /dev/null +++ b/internal/license/compat_test.go @@ -0,0 +1,43 @@ +package license + +// Совместимость формата лицензии между bj (проверяет токены) и сервером лицензий +// lic_Bridge-and-Join-s (выпускает их). Пакет лежит копией в обоих репозиториях; +// этот тест с одинаковым ожидаемым токеном есть в обоих — если формат разойдётся, +// упадёт тот, где пакет изменили. Подпись Ed25519 детерминирована. + +import ( + "crypto/ed25519" + "testing" + "time" +) + +const compatToken = "eyJzY2hlbWEiOjEsImlkIjoiMTExMTExMTEtMjIyMi0zMzMzLTQ0NDQtNTU1NTU1NTU1NTU1IiwidGVuYW50Ijoi0J7QntCeIMKr0J/RgNC+0LLQtdGA0LrQsMK7IiwicHJvZHVjdCI6ImJqLXNlcnZlciIsInBsYW4iOiJwcm8iLCJpc3N1ZWRfYXQiOiIyMDI2LTA5LTAxVDAwOjAwOjAwWiIsImV4cGlyZXNfYXQiOiIyMDI2LTExLTAxVDAwOjAwOjAwWiIsImZlYXR1cmVzIjpbInVwZGF0ZXMiLCJucHIiXSwibWF4X25vZGVzIjoyLCJpbnN0YWxsX2lkIjoiYWFhYWFhYWEtYmJiYi1jY2NjLWRkZGQtZWVlZWVlZWVlZWVlIiwiY29udHJhY3RfdW50aWwiOiIyMDI3LTA5LTAxVDAwOjAwOjAwWiJ9.VIKhe7NZxwvTQfCNCNMwrADvlWd/izmzZrE2h1rkwliOBHQd5rGU00/v/V8uArkw4YShc5NW4BLY35wIPQ3GDg==.main" + +func TestTokenCompat(t *testing.T) { + seed := make([]byte, ed25519.SeedSize) + for i := range seed { + seed[i] = byte(i + 1) + } + priv := ed25519.NewKeyFromSeed(seed) + l := &License{ + ID: "11111111-2222-3333-4444-555555555555", Tenant: "ООО «Проверка»", Product: "bj-server", Plan: PlanPro, + IssuedAt: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), ExpiresAt: time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC), + Features: []string{"updates", "npr"}, MaxNodes: 2, + InstallID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", ContractUntil: time.Date(2027, 9, 1, 0, 0, 0, 0, time.UTC), + } + tok, err := Sign(l, priv, "main") + if err != nil { + t.Fatal(err) + } + if got := tok.Encode(); got != compatToken { + t.Fatalf("формат токена изменился — синхронизируйте internal/license в bj и сервере лицензий\nполучено: %s", got) + } + back, err := DecodeToken(compatToken) + if err != nil { + t.Fatal(err) + } + v, err := Verify(back, priv.Public().(ed25519.PublicKey)) + if err != nil || v.InstallID != l.InstallID || !v.ContractUntil.Equal(l.ContractUntil) || v.Tenant != l.Tenant { + t.Fatalf("проверка токена: %v %+v", err, v) + } +}