From 86aa452ed643ee5601eab4fa28eb5e56d211add6 Mon Sep 17 00:00:00 2001 From: zuevav Date: Tue, 15 Sep 2026 19:41:11 +0300 Subject: [PATCH] =?UTF-8?q?feat(license):=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=20=D0=BB=D0=B8=D1=86=D0=B5=D0=BD=D0=B7=D0=B8=D0=B9=20lic?= =?UTF-8?q?.zetit.ru=20=E2=80=94=20=D0=BA=D0=B0=D0=B1=D0=B8=D0=BD=D0=B5?= =?UTF-8?q?=D1=82,=20=D0=BE=D0=BD=D0=BB=D0=B0=D0=B9=D0=BD-=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D0=B8=D0=B2=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B8=20=D0=B7?= =?UTF-8?q?=D0=B0=D1=89=D0=B8=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bj-license-server: активация по коду и продление токенов (API /v1), кабинет администратора (клиенты, лицензии, установки, журнал, администраторы) - защита: доверенные IP установок, jail fail2ban api/admin, раздел «Защита» с режимом обороны, белым списком и сроками блокировок (root-помощник fw-apply) - доступ к кабинету: свой адрес вместо /admin, вход только из белого списка, второй фактор — TOTP или подтверждение в «ЗЕТИТ Ключ» (push/код) - deploy/license: unit-файлы, fail2ban, path/timer помощника; контракт API в docs Co-Authored-By: Claude Opus 5 (1M context) --- cmd/bj-license-server/access.go | 512 +++++++ cmd/bj-license-server/admin.go | 1235 +++++++++++++++++ cmd/bj-license-server/api.go | 291 ++++ cmd/bj-license-server/firewall.go | 917 ++++++++++++ cmd/bj-license-server/main.go | 210 +-- cmd/bj-license-server/store.go | 460 ++++++ cmd/bj-license-server/web/pages.html | 484 +++++++ cmd/bj-license-server/zetitkey.go | 389 ++++++ deploy/license/bj-license-fw.path | 11 + deploy/license/bj-license-fw.service | 7 + deploy/license/bj-license-fw.timer | 11 + deploy/license/bj-license-server.service | 22 + .../fail2ban/filter.d/bj-license-admin.conf | 6 + .../fail2ban/filter.d/bj-license-api.conf | 5 + .../license/fail2ban/jail.d/bj-license.conf | 30 + docs/license-server-api.md | 82 ++ go.mod | 22 +- go.sum | 58 +- internal/license/license.go | 6 + 19 files changed, 4659 insertions(+), 99 deletions(-) create mode 100644 cmd/bj-license-server/access.go create mode 100644 cmd/bj-license-server/admin.go create mode 100644 cmd/bj-license-server/api.go create mode 100644 cmd/bj-license-server/firewall.go create mode 100644 cmd/bj-license-server/store.go create mode 100644 cmd/bj-license-server/web/pages.html create mode 100644 cmd/bj-license-server/zetitkey.go create mode 100644 deploy/license/bj-license-fw.path create mode 100644 deploy/license/bj-license-fw.service create mode 100644 deploy/license/bj-license-fw.timer create mode 100644 deploy/license/bj-license-server.service create mode 100644 deploy/license/fail2ban/filter.d/bj-license-admin.conf create mode 100644 deploy/license/fail2ban/filter.d/bj-license-api.conf create mode 100644 deploy/license/fail2ban/jail.d/bj-license.conf create mode 100644 docs/license-server-api.md diff --git a/cmd/bj-license-server/access.go b/cmd/bj-license-server/access.go new file mode 100644 index 0000000..6291628 --- /dev/null +++ b/cmd/bj-license-server/access.go @@ -0,0 +1,512 @@ +package main + +// Доступ к кабинету: свой адрес вместо /admin, вход только из белого списка администраторов и +// второй фактор (TOTP, RFC 6238). +// +// Маршруты кабинета внутри остаются /admin/…: adminGate переписывает внешний адрес (State.AdminPath) +// во внутренний, на прямые обращения к /admin при своём адресе отвечает 404 и подменяет Location в +// ответах. Ссылки в шаблонах строятся функцией {{adm}}. При AdminAllowOnly кабинет и страница входа +// видны только адресам белого списка администраторов, остальным — 404. API /v1 не затрагивается. +// +// По SSH: bj-license-server admin-info — адрес и настройки доступа; fw-apply -off — аварийно открыть +// кабинет всем и выключить оборону; admin-config — изменить доступ при остановленном сервере. + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha1" + "encoding/base32" + "encoding/binary" + "flag" + "fmt" + "html/template" + "log" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "rsc.io/qr" +) + +const ( + adminDefault = "/admin" + adminOpenFile = "admin-open" // создаёт fw-apply -off: кабинет открыт всем до повторного включения ограничения + totpIssuer = "BJ License (lic.zetit.ru)" + publicHost = "lic.zetit.ru" +) + +var adminSlugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{7,63}$`) + +// adminBase — внешний адрес кабинета: "/admin" или "/". +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 new file mode 100644 index 0000000..ce27e4d --- /dev/null +++ b/cmd/bj-license-server/admin.go @@ -0,0 +1,1235 @@ +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 new file mode 100644 index 0000000..7afb7ad --- /dev/null +++ b/cmd/bj-license-server/api.go @@ -0,0 +1,291 @@ +package main + +// API для установок bj: активация по коду и продление токена (docs/license-server-api.md). + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license" +) + +const ( + tokenTTL = 60 * 24 * time.Hour + renewAfterSec = 86400 +) + +type apiRequest struct { + Code string `json:"code"` + Token string `json:"token"` + InstallID string `json:"install_id"` + Machine string `json:"machine"` + Hostname string `json:"hostname"` + Version string `json:"version"` +} + +type apiResponse struct { + Token string `json:"token"` + LicenseID string `json:"license_id"` + Tenant string `json:"tenant"` + ExpiresAt time.Time `json:"expires_at"` + ContractUntil time.Time `json:"contract_until"` + RenewAfterSec int `json:"renew_after_sec"` +} + +type apiError struct { + Status int `json:"-"` + Code string `json:"error"` + Message string `json:"message"` +} + +func apiErr(status int, code, msg string) *apiError { + return &apiError{Status: status, Code: code, Message: msg} +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func clip(s string, n int) string { + s = strings.TrimSpace(s) + if r := []rune(s); len(r) > n { + return string(r[:n]) + } + return s +} + +func (srv *server) decodeAPI(w http.ResponseWriter, r *http.Request) (*apiRequest, bool) { + if !srv.apiRate.allow(clientIP(r)) { + writeJSON(w, http.StatusTooManyRequests, apiErr(429, "rate_limited", "слишком много запросов, повторите позже")) + return nil, false + } + var in apiRequest + if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&in); err != nil { + writeJSON(w, http.StatusBadRequest, apiErr(400, "bad_request", "тело запроса — JSON по docs/license-server-api.md")) + return nil, false + } + in.InstallID = strings.ToLower(strings.TrimSpace(in.InstallID)) + if !uuidRe.MatchString(in.InstallID) { + writeJSON(w, http.StatusBadRequest, apiErr(400, "bad_request", "install_id: ожидается UUID")) + return nil, false + } + in.Machine, in.Hostname, in.Version = clip(in.Machine, 128), clip(in.Hostname, 128), clip(in.Version, 40) + return &in, true +} + +// issueToken — подписанная лицензия под установку: срок токена 60 дней, не дольше договора. +func (srv *server) issueToken(s *State, lic *License, installID string, now time.Time) (string, time.Time, error) { + exp := now.Add(tokenTTL) + if !lic.ContractUntil.IsZero() && lic.ContractUntil.Before(exp) { + exp = lic.ContractUntil + } + tenant := "" + if t := s.tenant(lic.TenantID); t != nil { + tenant = t.Name + } + l := &license.License{ + Schema: license.CurrentSchema, ID: lic.ID, Tenant: tenant, Product: "bj-server", + Plan: license.Plan(lic.Plan), IssuedAt: now.UTC(), ExpiresAt: exp.UTC(), + Features: lic.Features, MaxNodes: lic.MaxNodes, InstallID: installID, ContractUntil: lic.ContractUntil.UTC(), + } + tok, err := license.Sign(l, srv.priv, srv.keyID) + if err != nil { + return "", time.Time{}, err + } + return tok.Encode(), exp, nil +} + +func (srv *server) answer(s *State, lic *License, token string, exp time.Time) *apiResponse { + tenant := "" + if t := s.tenant(lic.TenantID); t != nil { + tenant = t.Name + } + return &apiResponse{Token: token, LicenseID: lic.ID, Tenant: tenant, ExpiresAt: exp.UTC(), ContractUntil: lic.ContractUntil.UTC(), RenewAfterSec: renewAfterSec} +} + +// touchInstall — сведения об установке из запроса; смена machine-id — предупреждение в журнал. +func touchInstall(s *State, inst *Install, in *apiRequest, ip string, now time.Time) { + if in.Machine != "" && inst.Machine != "" && in.Machine != inst.Machine { + s.addEvent("machine_changed", inst.LicenseID, inst.InstallID, "api", ip, + fmt.Sprintf("сменился machine-id установки (%s → %s): возможно, копия сервера", short(inst.Machine), short(in.Machine))) + } + if in.Machine != "" { + inst.Machine = in.Machine + } + if in.Hostname != "" { + inst.Hostname = in.Hostname + } + if in.Version != "" { + inst.Version = in.Version + } + inst.IP, inst.LastSeen = ip, now +} + +// trustInstallIP — после успешной активации или продления адрес установки попадает в доверенные fail2ban. +func trustInstallIP(s *State, inst *Install, ip string, now time.Time) { + if s.trustIP(inst, ip, now) { + s.addEvent("trust_ip", inst.LicenseID, inst.InstallID, "api", ip, "IP "+ip+" добавлен в доверенные fail2ban") + } +} + +func hostNote(in *apiRequest) string { + if in.Hostname == "" { + return "" + } + return " (" + in.Hostname + ")" +} + +// POST /v1/activate +func (srv *server) apiActivate(w http.ResponseWriter, r *http.Request) { + in, ok := srv.decodeAPI(w, r) + if !ok { + return + } + ip := clientIP(r) + code := normalizeCode(in.Code) + var resp *apiResponse + var fail *apiError + err := srv.st.write(func(s *State) error { + now := time.Now() + var lic *License + if code != "" { + lic = s.licenseByCode(code) + } + if lic == nil { + fail = apiErr(http.StatusNotFound, "unknown_code", "код активации не найден") + log.Printf("api denied: unknown_code ip=%s", ip) // строку ловит fail2ban + s.addEvent("activate_denied", "", in.InstallID, "api", ip, "неизвестный код активации"+hostNote(in)) + return nil + } + inst := s.install(in.InstallID) + switch { + case lic.Revoked(): + fail = apiErr(http.StatusForbidden, "revoked", "лицензия отозвана") + case lic.ContractExpired(now): + fail = apiErr(http.StatusForbidden, "contract_expired", "срок договора истёк "+lic.ContractUntil.In(msk).Format("02.01.2006")) + case inst != nil && inst.Blocked: + fail = apiErr(http.StatusForbidden, "install_blocked", "установка заблокирована: "+inst.BlockReason) + case (inst == nil || inst.LicenseID != lic.ID) && lic.MaxNodes > 0 && s.activeInstalls(lic.ID) >= lic.MaxNodes: + fail = apiErr(http.StatusConflict, "max_nodes", fmt.Sprintf("исчерпан лимит установок по лицензии (%d)", lic.MaxNodes)) + } + if fail != nil { + s.addEvent("activate_denied", lic.ID, in.InstallID, "api", ip, fail.Message+hostNote(in)) + return nil + } + text := "активирована новая установка" + hostNote(in) + switch { + case inst == nil: + inst = &Install{InstallID: in.InstallID, FirstSeen: now} + s.Installs = append(s.Installs, inst) + case inst.LicenseID != lic.ID: + text = "установка переведена на эту лицензию с " + short(inst.LicenseID) + hostNote(in) + default: + text = "повторная активация установки" + hostNote(in) + } + inst.LicenseID = lic.ID + touchInstall(s, inst, in, ip, now) + trustInstallIP(s, inst, ip, now) + token, exp, err := srv.issueToken(s, lic, inst.InstallID, now) + if err != nil { + return err + } + inst.TokenExpires = exp + s.addEvent("activate", lic.ID, inst.InstallID, "api", ip, text+", токен до "+exp.In(msk).Format("02.01.2006")) + resp = srv.answer(s, lic, token, exp) + return nil + }) + srv.finishAPI(w, resp, fail, err) +} + +// POST /v1/renew +func (srv *server) apiRenew(w http.ResponseWriter, r *http.Request) { + in, ok := srv.decodeAPI(w, r) + if !ok { + return + } + ip := clientIP(r) + tok, err := license.DecodeToken(in.Token) + if err != nil { + writeJSON(w, http.StatusUnauthorized, apiErr(401, "bad_token", "токен не разобран")) + return + } + l, err := license.Verify(tok, srv.pub) + if err != nil { + log.Printf("api denied: bad_token ip=%s", ip) // строку ловит fail2ban + writeJSON(w, http.StatusUnauthorized, apiErr(401, "bad_token", "подпись токена недействительна")) + return + } + if l.InstallID == "" || !strings.EqualFold(l.InstallID, in.InstallID) { + writeJSON(w, http.StatusForbidden, apiErr(403, "install_mismatch", "токен выдан другой установке")) + return + } + var resp *apiResponse + var fail *apiError + err = srv.st.write(func(s *State) error { + now := time.Now() + lic := s.license(l.ID) + if lic == nil { + fail = apiErr(http.StatusUnauthorized, "bad_token", "лицензия из токена не найдена") + return nil + } + inst := s.install(in.InstallID) + switch { + case inst == nil || inst.LicenseID != lic.ID: + fail = apiErr(http.StatusNotFound, "unknown_install", "установка не зарегистрирована по этой лицензии — активируйте кодом") + case lic.Revoked(): + fail = apiErr(http.StatusForbidden, "revoked", "лицензия отозвана") + case lic.ContractExpired(now): + fail = apiErr(http.StatusForbidden, "contract_expired", "срок договора истёк "+lic.ContractUntil.In(msk).Format("02.01.2006")) + case inst.Blocked: + fail = apiErr(http.StatusForbidden, "install_blocked", "установка заблокирована: "+inst.BlockReason) + } + if fail != nil { + s.addEvent("renew_denied", lic.ID, in.InstallID, "api", ip, fail.Message+hostNote(in)) + return nil + } + touchInstall(s, inst, in, ip, now) + trustInstallIP(s, inst, ip, now) + token, exp, err := srv.issueToken(s, lic, inst.InstallID, now) + if err != nil { + return err + } + inst.TokenExpires = exp + s.addEvent("renew", lic.ID, inst.InstallID, "api", ip, fmt.Sprintf("продление%s, версия %s, токен до %s", hostNote(in), pickStr(in.Version, "—"), exp.In(msk).Format("02.01.2006"))) + resp = srv.answer(s, lic, token, exp) + return nil + }) + srv.finishAPI(w, resp, fail, err) +} + +func (srv *server) finishAPI(w http.ResponseWriter, resp *apiResponse, fail *apiError, err error) { + switch { + case err != nil: + writeJSON(w, http.StatusInternalServerError, apiErr(500, "internal", "внутренняя ошибка сервера лицензий")) + case fail != nil: + writeJSON(w, fail.Status, fail) + default: + writeJSON(w, http.StatusOK, resp) + } +} + +func pickStr(v, def string) string { + if strings.TrimSpace(v) == "" { + return def + } + return v +} + +func short(id string) string { + if len(id) > 8 { + return id[:8] + } + return id +} diff --git a/cmd/bj-license-server/firewall.go b/cmd/bj-license-server/firewall.go new file mode 100644 index 0000000..805a64b --- /dev/null +++ b/cmd/bj-license-server/firewall.go @@ -0,0 +1,917 @@ +package main + +// Защита сервера лицензий из кабинета: fail2ban и «режим обороны». +// +// Кабинет работает непривилегированно (пользователь bj-license) и только пишет желаемое +// состояние рядом с state.json: белый список администраторов (allow-ips.txt) и режим с +// разовыми командами fail2ban (fw-control.json). Применяет их root-помощник +// `bj-license-server fw-apply` — systemd запускает его при изменении fw-control.json +// (bj-license-fw.path) и раз в 30 секунд (bj-license-fw.timer); фактическое состояние +// он пишет в fw-status.json, его и показывает кабинет. +// +// Режим обороны: на порты 80/443 пускаются только адреса из белого списка (ipset +// bj-allow / bj-allow6) — доверенные IP установок (trusted-ips.txt) и адреса +// администраторов (allow-ips.txt). SSH не затрагивается. Аварийное выключение по SSH: +// +// /opt/bj-license/bj-license-server fw-apply -off + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "log" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "syscall" + "time" +) + +const ( + fwControlFile = "fw-control.json" + fwStatusFile = "fw-status.json" + fwAllowFile = "allow-ips.txt" + fwOffFile = "fw-off" // создаёт fw-apply -off; удаляет кабинет при повторном включении обороны + fwSetV4 = "bj-allow" + fwSetV6 = "bj-allow6" + fwRuleComment = "bj-license-lockdown" + fwStatusStale = 2 * time.Minute +) + +// fwJails — jail fail2ban, которыми управляет кабинет. +var fwJails = []string{"bj-license-api", "bj-license-admin", "sshd"} + +// AllowIP — адрес или сеть администратора в белом списке. +type AllowIP struct { + Net string `json:"net"` + Comment string `json:"comment,omitempty"` + AddedBy string `json:"added_by,omitempty"` + AddedAt time.Time `json:"added_at"` +} + +// FirewallState — желаемое состояние защиты: пишет кабинет, применяет fw-apply. +type FirewallState struct { + Mode string `json:"mode,omitempty"` // "" — обычный, lockdown — оборона + Until time.Time `json:"until,omitempty"` + UpdatedBy string `json:"updated_by,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + Actions []fwAction `json:"actions,omitempty"` + // Параметры блокировок jail (секунды; bantime -1 — навсегда). Пусто — значения из jail.d/bj-license.conf. + Jails map[string]fwJailConf `json:"jails,omitempty"` + // Блокировка входа в кабинет самим приложением (до fail2ban): попыток и срок. + LoginAttempts int `json:"login_attempts,omitempty"` + LoginLockSec int64 `json:"login_lock_sec,omitempty"` +} + +// fwJailConf — параметры блокировки jail. +type fwJailConf struct { + BanTime int64 `json:"bantime"` + FindTime int64 `json:"findtime"` + MaxRetry int `json:"maxretry"` +} + +// fwDefaultJail — значения из deploy/license/fail2ban/jail.d/bj-license.conf. +func fwDefaultJail(name string) fwJailConf { + if name == "sshd" { + return fwJailConf{BanTime: 3600, FindTime: 600, MaxRetry: 5} + } + return fwJailConf{BanTime: 3600, FindTime: 600, MaxRetry: 10} +} + +type fwPreset struct { + Sec int64 + Label string +} + +var ( + fwBanPresets = []fwPreset{{900, "15 минут"}, {3600, "1 час"}, {7200, "2 часа"}, {21600, "6 часов"}, {43200, "12 часов"}, + {86400, "1 день"}, {259200, "3 дня"}, {604800, "1 неделя"}, {2592000, "1 месяц"}, {7776000, "3 месяца"}, + {31536000, "1 год"}, {-1, "навсегда"}} + fwFindPresets = []fwPreset{{600, "10 минут"}, {3600, "1 час"}, {21600, "6 часов"}, {86400, "1 день"}, {604800, "1 неделя"}} + fwRetryPresets = []int{3, 5, 10, 20} +) + +// durRu — срок по-русски: из пресетов или в секундах. +func durRu(sec int64) string { + for _, p := range fwBanPresets { + if p.Sec == sec { + return p.Label + } + } + for _, p := range fwFindPresets { + if p.Sec == sec { + return p.Label + } + } + return strconv.FormatInt(sec, 10) + " с" +} + +func fwPresetOK(list []fwPreset, sec int64) bool { + for _, p := range list { + if p.Sec == sec { + return true + } + } + return false +} + +// loginLimitOf — ограничение входа в кабинет: попыток и срок (по умолчанию 8 за 15 минут). +func loginLimitOf(s *State) (int, time.Duration) { + n, sec := s.Firewall.LoginAttempts, s.Firewall.LoginLockSec + if n <= 0 { + n = 8 + } + if sec <= 0 { + sec = 900 + } + return n, time.Duration(sec) * time.Second +} + +// fwAction — разовая команда fail2ban из кабинета. +type fwAction struct { + ID string `json:"id"` + Op string `json:"op"` // ban | unban + Jail string `json:"jail"` + IP string `json:"ip"` + At time.Time `json:"at"` + By string `json:"by,omitempty"` +} + +type fwJailStatus struct { + Name string `json:"name"` + BanTime int64 `json:"bantime"` + FindTime int64 `json:"findtime"` + MaxRetry int `json:"maxretry"` + CurrentlyFailed int `json:"currently_failed"` + TotalFailed int `json:"total_failed"` + CurrentlyBanned int `json:"currently_banned"` + TotalBanned int `json:"total_banned"` + Banned []string `json:"banned"` +} + +// fwStatus — фактическое состояние, пишет fw-apply. +type fwStatus struct { + At time.Time `json:"at"` + Lockdown bool `json:"lockdown"` // правило обороны действует + Emergency bool `json:"emergency"` // оборона выключена аварийно (fw-apply -off) + Until time.Time `json:"until,omitempty"` + AllowCount int `json:"allow_count"` + Jails []fwJailStatus `json:"jails"` + Done []string `json:"done,omitempty"` + Errors []string `json:"errors,omitempty"` +} + +// normalizeNet — IP или сеть в каноническом виде; слишком широкие сети не принимаются. +func normalizeNet(s string) (string, bool) { + s = strings.TrimSpace(s) + if ip := net.ParseIP(s); ip != nil { + return ip.String(), true + } + _, n, err := net.ParseCIDR(s) + if err != nil { + return "", false + } + ones, bits := n.Mask.Size() + if (bits == 32 && ones < 16) || (bits == 128 && ones < 32) { + return "", false + } + return n.String(), true +} + +// netContains — адрес ip попадает в IP или сеть entry. +func netContains(entry, ip string) bool { + p := net.ParseIP(strings.TrimSpace(ip)) + if p == nil { + return false + } + if e := net.ParseIP(entry); e != nil { + return e.Equal(p) + } + _, n, err := net.ParseCIDR(entry) + return err == nil && n.Contains(p) +} + +// allowCovers — адрес в белом списке администраторов (includeTrusted — или среди доверенных установок). +func (s *State) allowCovers(ip string, includeTrusted bool) bool { + for _, a := range s.AllowIPs { + if netContains(a.Net, ip) { + return true + } + } + if includeTrusted { + for _, t := range s.trustedIPs() { + if netContains(t, ip) { + return true + } + } + } + return false +} + +// writeIfChanged — атомарная запись файла, только если содержимое изменилось. +func writeIfChanged(path string, data []byte, perm os.FileMode) error { + if old, err := os.ReadFile(path); err == nil && bytes.Equal(old, data) { + return nil + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, perm); err != nil { + return err + } + return os.Rename(tmp, path) +} + +// writeFirewallLocked — allow-ips.txt и fw-control.json для fw-apply; вызывать под блокировкой. +func (st *store) writeFirewallLocked() error { + dir := filepath.Dir(st.path) + body := "# Белый список администраторов (IP или сеть). Файл пишет bj-license-server.\n" + for _, a := range st.s.AllowIPs { + body += a.Net + "\n" + } + if err := writeIfChanged(filepath.Join(dir, fwAllowFile), []byte(body), 0o600); err != nil { + return err + } + b, err := json.MarshalIndent(st.s.Firewall, "", " ") + if err != nil { + return err + } + return writeIfChanged(filepath.Join(dir, fwControlFile), b, 0o600) +} + +func fwReadStatus(dir string) fwStatus { + var st fwStatus + if b, err := os.ReadFile(filepath.Join(dir, fwStatusFile)); err == nil { + _ = json.Unmarshal(b, &st) + } + return st +} + +// ---- root-помощник ---- + +// fwApply — `bj-license-server fw-apply [-dir …] [-off]`: применить желаемое состояние (от root). +func fwApply(args []string) { + fs := flag.NewFlagSet("fw-apply", flag.ExitOnError) + dir := fs.String("dir", "/var/lib/bj-license", "каталог данных сервера лицензий") + off := fs.Bool("off", false, "аварийно выключить режим обороны (до повторного включения в кабинете)") + _ = fs.Parse(args) + if os.Geteuid() != 0 { + log.Fatal("fw-apply запускается от root") + } + if *off { + if err := os.WriteFile(filepath.Join(*dir, fwOffFile), []byte(time.Now().Format(time.RFC3339)+"\n"), 0o644); err != nil { + log.Fatalf("аварийное выключение: %v", err) + } + chownLikeDir(*dir, filepath.Join(*dir, fwOffFile)) + // Заодно открыть кабинет всем адресам, если он ограничен белым списком (access.go). + if err := os.WriteFile(filepath.Join(*dir, adminOpenFile), []byte(time.Now().Format(time.RFC3339)+"\n"), 0o644); err == nil { + chownLikeDir(*dir, filepath.Join(*dir, adminOpenFile)) + } + } + st := fwRun(*dir) + if err := fwWriteStatus(*dir, st); err != nil { + log.Printf("fw-status.json: %v", err) + } + for _, e := range st.Errors { + log.Print(e) + } + if *off { + fmt.Println("Режим обороны выключен аварийно, кабинет открыт для всех адресов. Включить снова — в кабинете, раздел «Защита».") + fmt.Println("Адрес кабинета: bj-license-server admin-info") + } +} + +func fwRun(dir string) fwStatus { + now := time.Now() + st := fwStatus{At: now} + prev := fwReadStatus(dir) + var ctl FirewallState + if b, err := os.ReadFile(filepath.Join(dir, fwControlFile)); err == nil { + if err := json.Unmarshal(b, &ctl); err != nil { + st.Errors = append(st.Errors, "fw-control.json: "+err.Error()) + } + } + if _, err := os.Stat(filepath.Join(dir, fwOffFile)); err == nil { + st.Emergency = true + } + want := ctl.Mode == "lockdown" && !st.Emergency && (ctl.Until.IsZero() || now.Before(ctl.Until)) + + v4, v6 := fwReadAllow(dir) + st.AllowCount = len(v4) + len(v6) + if err := fwSyncSet(fwSetV4, "inet", v4); err != nil { + st.Errors = append(st.Errors, err.Error()) + want = false + } + if err := fwSyncSet(fwSetV6, "inet6", v6); err != nil { + st.Errors = append(st.Errors, err.Error()) + want = false + } + if want && st.AllowCount == 0 { + st.Errors = append(st.Errors, "белый список пуст — режим обороны не включён, иначе доступ закроется всем") + want = false + } + ok4 := fwRule("iptables", fwSetV4, want, &st.Errors) + ok6 := fwRule("ip6tables", fwSetV6, want, &st.Errors) + st.Lockdown = want && ok4 && ok6 + if st.Lockdown { + st.Until = ctl.Until + } + + if err := fwApplyJailConf(ctl.Jails); err != nil { + st.Errors = append(st.Errors, err.Error()) + } + + done := map[string]bool{} + for _, id := range prev.Done { + done[id] = true + } + for _, a := range ctl.Actions { + if !done[a.ID] { + if err := fwRunAction(a); err != nil { + st.Errors = append(st.Errors, err.Error()) + } + done[a.ID] = true + } + st.Done = append(st.Done, a.ID) // только команды, ещё лежащие в fw-control.json + } + + for _, j := range fwJails { + js, err := fwJail(j) + if err != nil { + st.Errors = append(st.Errors, err.Error()) + continue + } + st.Jails = append(st.Jails, js) + } + return st +} + +// fwReadAllow — белый список для ipset: доверенные установки и администраторы. +func fwReadAllow(dir string) (v4, v6 []string) { + seen := map[string]bool{} + for _, name := range []string{trustedFile, fwAllowFile} { + b, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + continue + } + for _, line := range strings.Split(string(b), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + n, ok := normalizeNet(line) + if !ok || seen[n] { + continue + } + seen[n] = true + if strings.Contains(n, ":") { + v6 = append(v6, n) + } else { + v4 = append(v4, n) + } + } + } + return v4, v6 +} + +// fwSyncSet — содержимое ipset заменяется атомарно (временный набор + swap). +func fwSyncSet(name, family string, nets []string) error { + tmp := name + "-tmp" + var b strings.Builder + fmt.Fprintf(&b, "create %s hash:net family %s -exist\n", name, family) + fmt.Fprintf(&b, "create %s hash:net family %s -exist\nflush %s\n", tmp, family, tmp) + for _, n := range nets { + fmt.Fprintf(&b, "add %s %s -exist\n", tmp, n) + } + fmt.Fprintf(&b, "swap %s %s\ndestroy %s\n", tmp, name, tmp) + cmd := exec.Command("ipset", "restore") + cmd.Stdin = strings.NewReader(b.String()) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("ipset %s: %v: %s", name, err, strings.TrimSpace(string(out))) + } + return nil +} + +// fwRuleSpec — правило обороны: 80/443 только из белого списка. +func fwRuleSpec(set string) []string { + return []string{"-p", "tcp", "-m", "multiport", "--dports", "80,443", "-m", "set", "!", "--match-set", set, "src", + "-m", "comment", "--comment", fwRuleComment, "-j", "DROP"} +} + +// fwRule — привести наличие правила к want; true — правило в нужном состоянии. +func fwRule(bin, set string, want bool, errs *[]string) bool { + has := func() bool { + return exec.Command(bin, append([]string{"-w", "-C", "INPUT"}, fwRuleSpec(set)...)...).Run() == nil + } + if want && !has() { + if out, err := exec.Command(bin, append([]string{"-w", "-I", "INPUT", "1"}, fwRuleSpec(set)...)...).CombinedOutput(); err != nil { + *errs = append(*errs, fmt.Sprintf("%s: включение обороны: %v: %s", bin, err, strings.TrimSpace(string(out)))) + return false + } + } + for i := 0; !want && has() && i < 10; i++ { + if out, err := exec.Command(bin, append([]string{"-w", "-D", "INPUT"}, fwRuleSpec(set)...)...).CombinedOutput(); err != nil { + *errs = append(*errs, fmt.Sprintf("%s: выключение обороны: %v: %s", bin, err, strings.TrimSpace(string(out)))) + return false + } + } + return want == has() +} + +func fwRunAction(a fwAction) error { + ip := net.ParseIP(a.IP) + known := false + for _, j := range fwJails { + known = known || j == a.Jail + } + if ip == nil || !known || (a.Op != "ban" && a.Op != "unban") { + return fmt.Errorf("команда %s %s %s отклонена: неверные параметры", a.Op, a.Jail, a.IP) + } + out, err := exec.Command("fail2ban-client", "set", a.Jail, a.Op+"ip", ip.String()).CombinedOutput() + if err != nil { + return fmt.Errorf("fail2ban %s %s %s: %v: %s", a.Jail, a.Op, ip, err, strings.TrimSpace(string(out))) + } + return nil +} + +const ( + fwJailOverride = "/etc/fail2ban/jail.d/zz-bj-license-ui.local" + fwServerDefault = "/etc/fail2ban/fail2ban.d/zz-bj-license-ui.local" +) + +// fwApplyJailConf — параметры блокировок из кабинета в override fail2ban; при изменении — reload. +func fwApplyJailConf(conf map[string]fwJailConf) error { + var b strings.Builder + b.WriteString("# Параметры блокировок из кабинета сервера лицензий (раздел «Защита»). Файл пишет bj-license-server fw-apply.\n") + var maxBan int64 = 86400 + forever := false + for _, j := range fwJails { + c, ok := conf[j] + if !ok { + continue + } + if c.BanTime == -1 { + forever = true + } else if c.BanTime > maxBan { + maxBan = c.BanTime + } + fmt.Fprintf(&b, "\n[%s]\nbantime = %d\nfindtime = %d\nmaxretry = %d\n", j, c.BanTime, c.FindTime, c.MaxRetry) + } + old, _ := os.ReadFile(fwJailOverride) + if string(old) == b.String() { + return nil + } + if err := os.WriteFile(fwJailOverride, []byte(b.String()), 0o644); err != nil { + return fmt.Errorf("параметры блокировок: %w", err) + } + // Долгие баны должны пережить перезапуск fail2ban: база банов хранится дольше самого долгого срока. + purge := maxBan + 86400 + if forever && purge < 5*31536000 { + purge = 5 * 31536000 + } + _ = os.WriteFile(fwServerDefault, []byte(fmt.Sprintf("# Файл пишет bj-license-server fw-apply.\n[Definition]\ndbpurgeage = %d\n", purge)), 0o644) + if out, err := exec.Command("fail2ban-client", "reload").CombinedOutput(); err != nil { + return fmt.Errorf("fail2ban reload: %v: %s", err, strings.TrimSpace(string(out))) + } + _ = exec.Command("fail2ban-client", "set", "dbpurgeage", strconv.FormatInt(purge, 10)).Run() + return nil +} + +var fwNumRe = regexp.MustCompile(`(Currently failed|Total failed|Currently banned|Total banned):\s*(\d+)`) + +func fwJail(name string) (fwJailStatus, error) { + js := fwJailStatus{Name: name} + out, err := exec.Command("fail2ban-client", "status", name).CombinedOutput() + if err != nil { + return js, fmt.Errorf("fail2ban status %s: %v: %s", name, err, strings.TrimSpace(string(out))) + } + text := string(out) + for _, m := range fwNumRe.FindAllStringSubmatch(text, -1) { + n, _ := strconv.Atoi(m[2]) + switch m[1] { + case "Currently failed": + js.CurrentlyFailed = n + case "Total failed": + js.TotalFailed = n + case "Currently banned": + js.CurrentlyBanned = n + case "Total banned": + js.TotalBanned = n + } + } + if i := strings.Index(text, "Banned IP list:"); i >= 0 { + line, _, _ := strings.Cut(text[i+len("Banned IP list:"):], "\n") + js.Banned = strings.Fields(line) + } + get := func(opt string) int64 { + out, err := exec.Command("fail2ban-client", "get", name, opt).Output() + if err != nil { + return 0 + } + n, _ := strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64) + return n + } + js.BanTime, js.FindTime, js.MaxRetry = get("bantime"), get("findtime"), int(get("maxretry")) + return js, nil +} + +func chownLikeDir(dir, path string) { + if fi, err := os.Stat(dir); err == nil { + if s, ok := fi.Sys().(*syscall.Stat_t); ok { + _ = os.Chown(path, int(s.Uid), int(s.Gid)) + } + } +} + +func fwWriteStatus(dir string, st fwStatus) error { + b, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + tmp := filepath.Join(dir, fwStatusFile+".tmp") + if err := os.WriteFile(tmp, b, 0o600); err != nil { + return err + } + chownLikeDir(dir, tmp) + return os.Rename(tmp, filepath.Join(dir, fwStatusFile)) +} + +// fwIgnore — `bj-license-server fw-ignore [-dir …] 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 index fb96b9b..2eeacbd 100644 --- a/cmd/bj-license-server/main.go +++ b/cmd/bj-license-server/main.go @@ -1,96 +1,144 @@ -// Command bj-license-server — онлайн-сервис учёта и отзыва лицензий. +// Command bj-license-server — сервер лицензирования Bridge&Join (https://lic.zetit.ru). // -// Базовая модель лицензирования офлайновая: bj-server проверяет подпись и -// срок сам. Этот сервер нужен для: -// - реестра выданных лицензий (учёт); -// - ОТЗЫВА (revocation) до окончания срока; -// - проверки клиентом «не отозвана ли» (опциональный online-чек). +// Выдаёт и продлевает лицензии установкам bj по коду активации (контракт — +// docs/license-server-api.md) и даёт веб-кабинет администратора: клиенты, лицензии, +// установки, журнал, администраторы. Вход в кабинет — логин и пароль. // -// Хранилище — JSON-файл со списком отозванных ID (для каркаса; в проде — -// PostgreSQL). API: +// Хранилище — один JSON-файл с атомарной записью и ежедневными копиями рядом: клиентов +// десятки, записей немного, отдельная СУБД не нужна. // -// GET /v1/check?id= → {"revoked":bool} -// GET /healthz -// -// Управление отзывом — правкой файла revoked.json (или будущим admin API). +// bj-license-server serve -addr 127.0.0.1:8091 -data /var/lib/bj-license/state.json -key /etc/bj-license/license.priv +// bj-license-server user-set -data /var/lib/bj-license/state.json -login admin +// (пароль из stdin; пусто — сгенерировать; сервер должен быть остановлен) +// bj-license-server fw-apply -dir /var/lib/bj-license [-off] (root, из systemd: защита, см. firewall.go) package main import ( - "encoding/json" + "bufio" + "crypto/ed25519" "flag" + "fmt" "log" "net/http" "os" - "sync" + "strings" "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license" ) -type store struct { - mu sync.RWMutex - path string - revoked map[string]bool -} - -func newStore(path string) *store { - s := &store{path: path, revoked: map[string]bool{}} - s.load() - return s -} - -func (s *store) load() { - s.mu.Lock() - defer s.mu.Unlock() - b, err := os.ReadFile(s.path) - if err != nil { - return // файла нет — пустой список - } - var ids []string - if err := json.Unmarshal(b, &ids); err != nil { - log.Printf("license-server: разбор %s: %v", s.path, err) - return - } - s.revoked = map[string]bool{} - for _, id := range ids { - s.revoked[id] = true - } - log.Printf("license-server: загружено отозванных лицензий: %d", len(s.revoked)) -} - -func (s *store) isRevoked(id string) bool { - s.mu.RLock() - defer s.mu.RUnlock() - return s.revoked[id] -} - func main() { - addr := flag.String("addr", ":8091", "адрес прослушивания") - file := flag.String("revoked", "./revoked.json", "JSON-файл со списком отозванных license ID") - flag.Parse() - - st := newStore(*file) - - // Перечитываем файл отзывов раз в минуту (горячее применение). - go func() { - t := time.NewTicker(time.Minute) - defer t.Stop() - for range t.C { - st.load() - } - }() - - mux := http.NewServeMux() - mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }) - mux.HandleFunc("/v1/check", func(w http.ResponseWriter, r *http.Request) { - id := r.URL.Query().Get("id") - if id == "" { - http.Error(w, "id required", http.StatusBadRequest) - return - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]bool{"revoked": st.isRevoked(id)}) - }) - - log.Printf("license-server: слушаю %s, отзывы из %s", *addr, *file) - srv := &http.Server{Addr: *addr, Handler: mux, ReadHeaderTimeout: 10 * time.Second} - log.Fatal(srv.ListenAndServe()) + 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 new file mode 100644 index 0000000..5b34246 --- /dev/null +++ b/cmd/bj-license-server/store.go @@ -0,0 +1,460 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log" + "math/big" + "net" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "time" + + "golang.org/x/crypto/bcrypt" +) + +// Признаки лицензии, которые понимает bj (internal/lkgateway/licensecheck.go). +var knownFeatures = []struct{ Key, Title string }{ + {"updates", "Обновления"}, + {"web-cabinet", "Личный кабинет клиента (bj-lk)"}, + {"quik-mnp", "QUIK: неторговые поручения (M2M)"}, + {"quik-export", "QUIK: экспорт биржевой информации"}, + {"npr", "Риск-монитор НПР / УДС"}, +} + +var knownPlans = []struct{ Key, Title string }{ + {"enterprise", "Enterprise"}, + {"pro", "Pro"}, + {"free", "Free"}, +} + +const maxEvents = 20000 + +var ( + loginRe = regexp.MustCompile(`^[A-Za-z0-9_.\-]{2,40}$`) + uuidRe = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) + innRe = regexp.MustCompile(`^(\d{10}|\d{12})$`) +) + +type User struct { + Login string `json:"login"` + Name string `json:"name,omitempty"` + PassHash string `json:"pass_hash"` + Disabled bool `json:"disabled,omitempty"` + CreatedAt time.Time `json:"created_at"` + LastLogin time.Time `json:"last_login,omitempty"` + // Второй фактор (TOTP): секрет base32 и последний принятый шаг — один код не принимается дважды. + TOTPSecret string `json:"totp_secret,omitempty"` + TOTPLast int64 `json:"totp_last,omitempty"` + HasTOTP bool `json:"-"` // для шаблонов: секрет в представления не отдаётся + Email string `json:"email,omitempty"` // учётная запись в «ЗЕТИТ Ключ» (zetitkey.go) +} + +type Tenant struct { + ID string `json:"id"` + Name string `json:"name"` + INN string `json:"inn,omitempty"` + Contact string `json:"contact,omitempty"` + Note string `json:"note,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type License struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + Plan string `json:"plan"` + Features []string `json:"features,omitempty"` + MaxNodes int `json:"max_nodes"` + ContractUntil time.Time `json:"contract_until"` + Code string `json:"code"` + Note string `json:"note,omitempty"` + RevokedAt time.Time `json:"revoked_at,omitempty"` + RevokeReason string `json:"revoke_reason,omitempty"` + CreatedAt time.Time `json:"created_at"` + CreatedBy string `json:"created_by,omitempty"` +} + +func (l License) Revoked() bool { return !l.RevokedAt.IsZero() } + +func (l License) ContractExpired(now time.Time) bool { + return !l.ContractUntil.IsZero() && now.After(l.ContractUntil) +} + +func (l License) HasFeature(f string) bool { + for _, x := range l.Features { + if x == f { + return true + } + } + return false +} + +type Install struct { + InstallID string `json:"install_id"` + LicenseID string `json:"license_id"` + Machine string `json:"machine,omitempty"` + Hostname string `json:"hostname,omitempty"` + Version string `json:"version,omitempty"` + IP string `json:"ip,omitempty"` + FirstSeen time.Time `json:"first_seen"` + LastSeen time.Time `json:"last_seen"` + TokenExpires time.Time `json:"token_expires"` + Blocked bool `json:"blocked,omitempty"` + BlockReason string `json:"block_reason,omitempty"` +} + +type Event struct { + At time.Time `json:"at"` + Kind string `json:"kind"` + LicenseID string `json:"license_id,omitempty"` + InstallID string `json:"install_id,omitempty"` + Actor string `json:"actor,omitempty"` + IP string `json:"ip,omitempty"` + Text string `json:"text"` +} + +// TrustedIP — адрес установки, которая успешно активировала или продлила лицензию. fail2ban +// не блокирует такие адреса за ошибки API (jail bj-license-api, ignorecommand по trusted-ips.txt); +// на вход в кабинет доверие не распространяется. +type TrustedIP struct { + IP string `json:"ip"` + LicenseID string `json:"license_id"` + InstallID string `json:"install_id"` + Hostname string `json:"hostname,omitempty"` + AddedAt time.Time `json:"added_at"` + LastSeen time.Time `json:"last_seen"` +} + +type State struct { + Users []*User `json:"users"` + Tenants []*Tenant `json:"tenants"` + Licenses []*License `json:"licenses"` + Installs []*Install `json:"installs"` + Events []*Event `json:"events"` + Trusted []*TrustedIP `json:"trusted,omitempty"` + // Защита (раздел «Защита»): белый список администраторов и желаемый режим. + AllowIPs []*AllowIP `json:"allow_ips,omitempty"` + Firewall FirewallState `json:"firewall"` + // Доступ к кабинету (access.go): свой адрес вместо /admin и вход только из белого списка. + AdminPath string `json:"admin_path,omitempty"` + AdminAllowOnly bool `json:"admin_allow_only,omitempty"` + // Второй фактор через «ЗЕТИТ Ключ» (zetitkey.go). + ZetitKey ZetitKeyConf `json:"zetit_key,omitempty"` +} + +// trustedFile — список доверенных IP установок для fail2ban и режима обороны. +const trustedFile = "trusted-ips.txt" + +type store struct { + mu sync.RWMutex + path string + s State +} + +func openStore(path string) (*store, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, err + } + st := &store{path: path} + b, err := os.ReadFile(path) + switch { + case errors.Is(err, os.ErrNotExist): + return st, nil + case err != nil: + return nil, err + } + if err := json.Unmarshal(b, &st.s); err != nil { + return nil, fmt.Errorf("разбор %s: %w", path, err) + } + if err := st.writeTrustedLocked(); err != nil { + log.Printf("доверенные IP: %v", err) + } + return st, nil +} + +// trustedPath — список доверенных IP для fail2ban, рядом с файлом состояния. +func (st *store) trustedPath() string { + return filepath.Join(filepath.Dir(st.path), trustedFile) +} + +// writeTrustedLocked — переписывает trusted-ips.txt (по адресу в строке) и файлы защиты; вызывать под блокировкой. +func (st *store) writeTrustedLocked() error { + body := "# Доверенные IP установок bj (успешная активация или продление лицензии). Файл пишет bj-license-server.\n" + for _, ip := range st.s.trustedIPs() { + body += ip + "\n" + } + if err := writeIfChanged(st.trustedPath(), []byte(body), 0o600); err != nil { + return err + } + return st.writeFirewallLocked() +} + +func (st *store) saveLocked() error { + b, err := json.MarshalIndent(&st.s, "", " ") + if err != nil { + return err + } + tmp := st.path + ".tmp" + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return err + } + if _, err := f.Write(b); err != nil { + f.Close() + return err + } + if err := f.Sync(); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + if err := os.Rename(tmp, st.path); err != nil { + return err + } + if err := st.writeTrustedLocked(); err != nil { + log.Printf("доверенные IP: %v", err) + } + return nil +} + +// write — изменение состояния под блокировкой; сохраняется, если fn не вернула ошибку. +func (st *store) write(fn func(s *State) error) error { + st.mu.Lock() + defer st.mu.Unlock() + if err := fn(&st.s); err != nil { + return err + } + return st.saveLocked() +} + +func (st *store) read(fn func(s *State)) { + st.mu.RLock() + defer st.mu.RUnlock() + fn(&st.s) +} + +// backupLoop — раз в сутки копия state.json рядом (state.json.YYYYMMDD), хранится 30 копий. +func (st *store) backupLoop() { + for { + st.mu.RLock() + b, err := json.Marshal(&st.s) + st.mu.RUnlock() + if err == nil { + name := st.path + "." + time.Now().Format("20060102") + if _, e := os.Stat(name); errors.Is(e, os.ErrNotExist) { + if e := os.WriteFile(name, b, 0o600); e != nil { + log.Printf("резервная копия: %v", e) + } + } + old, _ := filepath.Glob(st.path + ".2*") + sort.Strings(old) + for len(old) > 30 { + _ = os.Remove(old[0]) + old = old[1:] + } + } + time.Sleep(6 * time.Hour) + } +} + +// ---- выборки и изменения (вызывать внутри read/write) ---- + +// trustIP — адрес установки в доверенные после успешной активации или продления. +// Возвращает true, если для этой установки адрес новый. +func (s *State) trustIP(inst *Install, ip string, now time.Time) bool { + p := net.ParseIP(strings.TrimSpace(ip)) + if p == nil || p.IsLoopback() || p.IsUnspecified() { + return false + } + ip = p.String() + for _, t := range s.Trusted { + if t.IP == ip && t.InstallID == inst.InstallID { + t.LicenseID, t.Hostname, t.LastSeen = inst.LicenseID, inst.Hostname, now + return false + } + } + s.Trusted = append(s.Trusted, &TrustedIP{IP: ip, LicenseID: inst.LicenseID, InstallID: inst.InstallID, + Hostname: inst.Hostname, AddedAt: now, LastSeen: now}) + return true +} + +// untrust — убирает доверенные адреса, подходящие под условие; возвращает убранные. +func (s *State) untrust(match func(t *TrustedIP) bool) []*TrustedIP { + var keep, gone []*TrustedIP + for _, t := range s.Trusted { + if match(t) { + gone = append(gone, t) + } else { + keep = append(keep, t) + } + } + s.Trusted = keep + return gone +} + +// trustedIPs — адреса для fail2ban: без отозванных лицензий и заблокированных установок. +func (s *State) trustedIPs() []string { + seen := map[string]bool{} + var out []string + for _, t := range s.Trusted { + if l := s.license(t.LicenseID); l == nil || l.Revoked() { + continue + } + if i := s.install(t.InstallID); i == nil || i.Blocked { + continue + } + if !seen[t.IP] { + seen[t.IP] = true + out = append(out, t.IP) + } + } + sort.Strings(out) + return out +} + +func (s *State) user(login string) *User { + for _, u := range s.Users { + if strings.EqualFold(u.Login, login) { + return u + } + } + return nil +} + +func (s *State) tenant(id string) *Tenant { + for _, t := range s.Tenants { + if t.ID == id { + return t + } + } + return nil +} + +func (s *State) license(id string) *License { + for _, l := range s.Licenses { + if l.ID == id { + return l + } + } + return nil +} + +func (s *State) licenseByCode(code string) *License { + for _, l := range s.Licenses { + if l.Code == code { + return l + } + } + return nil +} + +func (s *State) install(id string) *Install { + for _, i := range s.Installs { + if strings.EqualFold(i.InstallID, id) { + return i + } + } + return nil +} + +func (s *State) installsOf(licenseID string) []*Install { + var out []*Install + for _, i := range s.Installs { + if i.LicenseID == licenseID { + out = append(out, i) + } + } + sort.Slice(out, func(a, b int) bool { return out[a].LastSeen.After(out[b].LastSeen) }) + return out +} + +func (s *State) activeInstalls(licenseID string) int { + n := 0 + for _, i := range s.Installs { + if i.LicenseID == licenseID && !i.Blocked { + n++ + } + } + return n +} + +func (s *State) addEvent(kind, licenseID, installID, actor, ip, text string) { + s.Events = append(s.Events, &Event{At: time.Now(), Kind: kind, LicenseID: licenseID, InstallID: installID, Actor: actor, IP: ip, Text: text}) + if len(s.Events) > maxEvents { + s.Events = append([]*Event(nil), s.Events[len(s.Events)-maxEvents:]...) + } +} + +func (s *State) setUserPassword(login, name, password, actor string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(password), 12) + if err != nil { + return err + } + u := s.user(login) + if u == nil { + u = &User{Login: login, Name: name, CreatedAt: time.Now()} + s.Users = append(s.Users, u) + s.addEvent("user_add", "", "", actor, "", "добавлен администратор "+login) + } else { + s.addEvent("user_password", "", "", actor, "", "сменён пароль администратора "+login) + } + if name != "" { + u.Name = name + } + u.PassHash = string(hash) + return nil +} + +// ---- идентификаторы ---- + +func newID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + b[6] = b[6]&0x0f | 0x40 + b[8] = b[8]&0x3f | 0x80 + h := hex.EncodeToString(b) + return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:] +} + +const codeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // без 0/O и 1/I + +func randomFrom(alphabet string, n int) string { + var sb strings.Builder + max := big.NewInt(int64(len(alphabet))) + for i := 0; i < n; i++ { + k, _ := rand.Int(rand.Reader, max) + sb.WriteByte(alphabet[k.Int64()]) + } + return sb.String() +} + +// newActivationCode — BJ-XXXX-XXXX-XXXX (60 бит случайности). +func newActivationCode() string { + c := randomFrom(codeAlphabet, 12) + return "BJ-" + c[0:4] + "-" + c[4:8] + "-" + c[8:12] +} + +// normalizeCode — код в каноническом виде: верхний регистр, дефисы по местам. +func normalizeCode(s string) string { + s = strings.ToUpper(strings.NewReplacer("-", "", " ", "").Replace(strings.TrimSpace(s))) + if len(s) == 14 && strings.HasPrefix(s, "BJ") { + s = s[2:] + } + if len(s) != 12 { + return "" + } + return "BJ-" + s[0:4] + "-" + s[4:8] + "-" + s[8:12] +} + +func randomPassword(n int) string { + return randomFrom("abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789", n) +} diff --git a/cmd/bj-license-server/web/pages.html b/cmd/bj-license-server/web/pages.html new file mode 100644 index 0000000..5a85561 --- /dev/null +++ b/cmd/bj-license-server/web/pages.html @@ -0,0 +1,484 @@ +{{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 new file mode 100644 index 0000000..c343742 --- /dev/null +++ b/cmd/bj-license-server/zetitkey.go @@ -0,0 +1,389 @@ +package main + +// Второй фактор через «ЗЕТИТ Ключ» — так же, как вход в ZETIT Connect. После пароля сервер лицензий +// просит Ключ подтвердить вход: push-уведомление на телефон администратора или код из приложения. +// Статус опрашивает только сервер; вход разрешается лишь при approved (push) или success (код). +// API: Zetit_key/docs/EXTERNAL_APP_2FA.md, способ 1 (приложение с X-Connect-Key). +// +// Действует для администраторов с указанным e-mail, когда интеграция включена в разделе «Защита». +// Остальные входят по паролю и, если подключён, по коду приложения-аутентификатора (access.go). +// Аварийно выключить по SSH: bj-license-server admin-config -zetit-key off (сервер остановлен). + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +const ( + zetitKeyDefaultURL = "https://key.zetit.ru/api/v1/connect-auth" + zkPendingTTL = 5 * time.Minute // push живёт 60 секунд, код из приложения можно ввести и позже +) + +// ZetitKeyConf — настройки интеграции с «ЗЕТИТ Ключ». +type ZetitKeyConf struct { + Enabled bool `json:"enabled,omitempty"` + URL string `json:"url,omitempty"` // по умолчанию https://key.zetit.ru/api/v1/connect-auth + AppKey string `json:"app_key,omitempty"` // X-Connect-Key приложения из панели Ключа +} + +func (c ZetitKeyConf) active() bool { return c.Enabled && c.AppKey != "" } + +var zkClient = &http.Client{Timeout: 8 * time.Second} + +func zkCall(c ZetitKeyConf, method, path string, body, out any) error { + var rd io.Reader + if body != nil { + b, _ := json.Marshal(body) + rd = bytes.NewReader(b) + } + req, err := http.NewRequest(method, strings.TrimRight(pickStr(c.URL, zetitKeyDefaultURL), "/")+path, rd) + if err != nil { + return err + } + req.Header.Set("X-Connect-Key", c.AppKey) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := zkClient.Do(req) + if err != nil { + return fmt.Errorf("сервер недоступен: %v", err) + } + defer resp.Body.Close() + data, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("ответ %d: %s", resp.StatusCode, clip(string(data), 200)) + } + if out != nil { + if err := json.Unmarshal(data, out); err != nil { + return fmt.Errorf("ответ не разобран: %v", err) + } + } + return nil +} + +type zkLookupResult struct { + Exists bool `json:"exists"` + Has2FA bool `json:"has_2fa"` + FullName string `json:"full_name"` +} + +type zkVerifyResult struct { + RequestID string `json:"request_id"` + Status string `json:"status"` + NeedsSetup bool `json:"needs_setup"` +} + +func zkLookup(c ZetitKeyConf, email string) (zkLookupResult, error) { + var r zkLookupResult + return r, zkCall(c, http.MethodGet, "/lookup?email="+url.QueryEscape(email), nil, &r) +} + +func zkStart(c ZetitKeyConf, email, ip string) (zkVerifyResult, error) { + var r zkVerifyResult + return r, zkCall(c, http.MethodPost, "/verify", map[string]string{"email": email, "source_ip": ip}, &r) +} + +func zkStatus(c ZetitKeyConf, requestID string) (string, error) { + var r zkVerifyResult + err := zkCall(c, http.MethodGet, "/verify/"+url.PathEscape(requestID), nil, &r) + return r.Status, err +} + +func zkCode(c ZetitKeyConf, email, code string) (bool, error) { + var r struct { + Success bool `json:"success"` + } + err := zkCall(c, http.MethodPost, "/verify-totp", map[string]string{"email": email, "code": code}, &r) + return r.Success, err +} + +// ---- вход с подтверждением ---- + +// pendingLogin — пароль проверен, ждём подтверждения в Ключе. +type pendingLogin struct { + Token, Login, Email, IP, RequestID, Next string + Created time.Time +} + +type pendingLogins struct { + mu sync.Mutex + m map[string]*pendingLogin +} + +var zkPending = &pendingLogins{m: map[string]*pendingLogin{}} + +func (p *pendingLogins) put(pl *pendingLogin) { + p.mu.Lock() + defer p.mu.Unlock() + for k, v := range p.m { + if time.Since(v.Created) > zkPendingTTL { + delete(p.m, k) + } + } + p.m[pl.Token] = pl +} + +// get — ожидающий вход по токену с того же адреса. +func (p *pendingLogins) get(token, ip string) *pendingLogin { + p.mu.Lock() + defer p.mu.Unlock() + pl := p.m[token] + if pl == nil || pl.IP != ip || time.Since(pl.Created) > zkPendingTTL { + return nil + } + return pl +} + +func (p *pendingLogins) drop(token string) { + p.mu.Lock() + delete(p.m, token) + p.mu.Unlock() +} + +func (srv *server) zetitKey() ZetitKeyConf { + var c ZetitKeyConf + srv.st.read(func(s *State) { c = s.ZetitKey }) + return c +} + +// finishLogin — второй фактор пройден (или не требуется): сессия и переход в кабинет. +func (srv *server) finishLogin(w http.ResponseWriter, r *http.Request, login, ip, next string, totpStep int64, how string) { + srv.fails.reset(ip + "|" + strings.ToLower(login)) + sess := srv.sessions.create(login) + _ = srv.st.write(func(s *State) error { + if u := s.user(login); u != nil { + u.LastLogin = time.Now() + sess.Login = u.Login + if totpStep > 0 { + u.TOTPLast = totpStep + } + } + s.addEvent("login", "", "", login, ip, "вход в кабинет"+how) + return nil + }) + srv.setSessionCookie(w, r, sess.Token) + http.Redirect(w, r, next, http.StatusSeeOther) +} + +func (srv *server) renderLoginErr(w http.ResponseWriter, login, next, msg string, status int) { + p := page{Title: "Вход", Err: msg, Data: map[string]string{"Next": next, "Login": login}} + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + _ = srv.tpl.ExecuteTemplate(w, "login", p) +} + +// loginReject — отказ на втором факторе: счётчик неудач, строка для fail2ban, журнал. +func (srv *server) loginReject(w http.ResponseWriter, login, ip, next, eventText, msg string) { + srv.fails.add(ip + "|" + strings.ToLower(login)) + srv.fails.add(ip) + log.Printf("login failed: user=%q ip=%s", login, ip) // строку ловит fail2ban (deploy/license/fail2ban) + time.Sleep(600 * time.Millisecond) + _ = srv.st.write(func(s *State) error { + s.addEvent("login_failed", "", "", login, ip, eventText) + return nil + }) + srv.renderLoginErr(w, login, next, msg, http.StatusUnauthorized) +} + +func (srv *server) renderLogin2FA(w http.ResponseWriter, pl *pendingLogin, errMsg string) { + left := 60 - int(time.Since(pl.Created).Seconds()) + if left < 0 { + left = 0 + } + p := page{Title: "Подтверждение входа", Err: errMsg, Data: map[string]any{"Token": pl.Token, "Email": pl.Email, "Left": left}} + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _ = srv.tpl.ExecuteTemplate(w, "login2fa", p) +} + +// login2FAStatus — GET /admin/login/2fa/status?t=…: статус push, опрос идёт с сервера. +func (srv *server) login2FAStatus(w http.ResponseWriter, r *http.Request) { + pl := zkPending.get(r.URL.Query().Get("t"), clientIP(r)) + status := "expired" + if pl != nil && time.Since(pl.Created) <= 70*time.Second { + if st, err := zkStatus(srv.zetitKey(), pl.RequestID); err == nil { + status = st + } else { + status = "pending" // сбой связи — страница спросит ещё раз + } + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, map[string]string{"status": status}) +} + +// login2FAFinish — POST /admin/login/2fa/finish: страница увидела ответ, сервер перепроверяет его сам. +func (srv *server) login2FAFinish(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + ip := clientIP(r) + pl := zkPending.get(r.PostFormValue("t"), ip) + if pl == nil { + srv.renderLoginErr(w, "", "/admin/", "Время подтверждения истекло, войдите заново.", http.StatusUnauthorized) + return + } + st, err := zkStatus(srv.zetitKey(), pl.RequestID) + switch { + case err != nil: + srv.renderLogin2FA(w, pl, "ЗЕТИТ Ключ не ответил: "+err.Error()+". Введите код из приложения.") + case st == "approved": + zkPending.drop(pl.Token) + srv.finishLogin(w, r, pl.Login, ip, pl.Next, 0, " (ЗЕТИТ Ключ, push)") + case st == "denied": + zkPending.drop(pl.Token) + srv.loginReject(w, pl.Login, ip, pl.Next, "вход отклонён в ЗЕТИТ Ключ", "Вход отклонён в приложении «ЗЕТИТ Ключ».") + case st == "expired": + srv.renderLogin2FA(w, pl, "Время подтверждения истекло. Введите код из приложения или войдите заново.") + default: + srv.renderLogin2FA(w, pl, "Вход ещё не подтверждён в приложении.") + } +} + +// login2FACode — POST /admin/login/2fa/code: код из приложения «ЗЕТИТ Ключ». +func (srv *server) login2FACode(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + ip := clientIP(r) + pl := zkPending.get(r.PostFormValue("t"), ip) + if pl == nil { + srv.renderLoginErr(w, "", "/admin/", "Время подтверждения истекло, войдите заново.", http.StatusUnauthorized) + return + } + if srv.fails.blocked(ip+"|"+strings.ToLower(pl.Login)) || srv.fails.blocked(ip) { + zkPending.drop(pl.Token) + srv.renderLoginErr(w, pl.Login, pl.Next, "Слишком много неудачных попыток. Вход заблокирован на "+durRu(int64(srv.fails.window()/time.Second))+".", http.StatusTooManyRequests) + return + } + ok, err := zkCode(srv.zetitKey(), pl.Email, strings.TrimSpace(r.PostFormValue("code"))) + switch { + case err != nil: + srv.renderLogin2FA(w, pl, "Не удалось проверить код: "+err.Error()) + case ok: + zkPending.drop(pl.Token) + srv.finishLogin(w, r, pl.Login, ip, pl.Next, 0, " (ЗЕТИТ Ключ, код)") + default: + srv.fails.add(ip + "|" + strings.ToLower(pl.Login)) + srv.fails.add(ip) + log.Printf("login failed: user=%q ip=%s", pl.Login, ip) + _ = srv.st.write(func(s *State) error { + s.addEvent("login_failed", "", "", pl.Login, ip, "неверный код ЗЕТИТ Ключ") + return nil + }) + srv.renderLogin2FA(w, pl, "Код неверен или уже использован.") + } +} + +// ---- настройки ---- + +func validEmail(e string) bool { + at := strings.Index(e, "@") + return len(e) <= 120 && at > 0 && at < len(e)-3 && strings.Contains(e[at:], ".") && !strings.ContainsAny(e, " <>\"'") +} + +// userEmail — POST /admin/users/{login}/email: e-mail администратора в «ЗЕТИТ Ключ». +func (srv *server) userEmail(w http.ResponseWriter, r *http.Request, sess *session) { + login, email := r.PathValue("login"), strings.TrimSpace(r.PostFormValue("email")) + if email != "" && !validEmail(email) { + back(w, r, "/admin/users", "", "неверный e-mail") + return + } + var bad string + err := srv.st.write(func(s *State) error { + u := s.user(login) + if u == nil { + return fmt.Errorf("администратор не найден") + } + if strings.EqualFold(u.Login, sess.Login) && email == "" && s.ZetitKey.active() { + bad = "вход через ЗЕТИТ Ключ включён: без e-mail вы войдёте только по паролю. Сначала выключите интеграцию в «Защите»." + return nil + } + u.Email = email + s.addEvent("user_email", "", "", sess.Login, clientIP(r), "e-mail администратора "+u.Login+": "+pickStr(email, "удалён")) + return nil + }) + if err != nil || bad != "" { + back(w, r, "/admin/users", "", pickStr(bad, fmt.Sprint(err))) + return + } + back(w, r, "/admin/users", "E-mail "+login+" сохранён", "") +} + +// securityZetitKey — POST /admin/security/zetit-key: адрес API, ключ приложения, включение, проверка. +func (srv *server) securityZetitKey(w http.ResponseWriter, r *http.Request, sess *session) { + op := r.PostFormValue("op") + var conf ZetitKeyConf + var myEmail string + srv.st.read(func(s *State) { + conf = s.ZetitKey + if u := s.user(sess.Login); u != nil { + myEmail = u.Email + } + }) + if u := strings.TrimSpace(r.PostFormValue("url")); u != "" { + pu, err := url.Parse(u) + if err != nil || pu.Scheme != "https" || pu.Host == "" { + back(w, r, "/admin/security", "", "адрес API ЗЕТИТ Ключ: https://…") + return + } + conf.URL = strings.TrimRight(u, "/") + } + if k := strings.TrimSpace(r.PostFormValue("app_key")); k != "" { + conf.AppKey = k + } + check := func() (string, bool) { + switch { + case conf.AppKey == "": + return "не задан ключ приложения", false + case myEmail == "": + return "укажите свой e-mail на странице «Администраторы»", false + } + lk, err := zkLookup(conf, myEmail) + switch { + case err != nil: + return "ЗЕТИТ Ключ: " + err.Error(), false + case !lk.Exists: + return myEmail + " не найден в ЗЕТИТ Ключ", false + case !lk.Has2FA: + return myEmail + " найден в ЗЕТИТ Ключ, но приложение не привязано", false + } + return "ЗЕТИТ Ключ отвечает: " + myEmail + pickStr(" ("+lk.FullName+")", "") + " найден, приложение привязано", true + } + msg, ok := "", true + if op == "test" || r.PostFormValue("enabled") == "1" { + msg, ok = check() + } + switch { + case op == "test": + case r.PostFormValue("enabled") == "1" && !ok: + back(w, r, "/admin/security", "", "Не включено: "+msg) + return + default: + conf.Enabled = r.PostFormValue("enabled") == "1" + } + err := srv.st.write(func(s *State) error { + s.ZetitKey = conf + state := "выключен" + if conf.Enabled { + state = "включён" + } + s.addEvent("zetit_key", "", "", sess.Login, clientIP(r), "настройки ЗЕТИТ Ключ сохранены, вход через Ключ "+state) + return nil + }) + switch { + case err != nil: + back(w, r, "/admin/security", "", err.Error()) + case op == "test" && !ok: + back(w, r, "/admin/security", "", "Проверка: "+msg) + case op == "test": + back(w, r, "/admin/security", "Проверка: "+msg, "") + case conf.Enabled: + back(w, r, "/admin/security", "Вход через ЗЕТИТ Ключ включён. "+msg+".", "") + default: + back(w, r, "/admin/security", "Настройки ЗЕТИТ Ключ сохранены, вход через Ключ выключен.", "") + } +} diff --git a/deploy/license/bj-license-fw.path b/deploy/license/bj-license-fw.path new file mode 100644 index 0000000..f0ee7e8 --- /dev/null +++ b/deploy/license/bj-license-fw.path @@ -0,0 +1,11 @@ +[Unit] +Description=Bridge&Join — команды защиты из кабинета сервера лицензий + +[Path] +PathChanged=/var/lib/bj-license/fw-control.json +PathChanged=/var/lib/bj-license/allow-ips.txt +PathChanged=/var/lib/bj-license/trusted-ips.txt +Unit=bj-license-fw.service + +[Install] +WantedBy=multi-user.target diff --git a/deploy/license/bj-license-fw.service b/deploy/license/bj-license-fw.service new file mode 100644 index 0000000..752779f --- /dev/null +++ b/deploy/license/bj-license-fw.service @@ -0,0 +1,7 @@ +[Unit] +Description=Bridge&Join — применение защиты сервера лицензий (белый список, режим обороны, команды fail2ban) +After=fail2ban.service + +[Service] +Type=oneshot +ExecStart=/opt/bj-license/bj-license-server fw-apply -dir /var/lib/bj-license diff --git a/deploy/license/bj-license-fw.timer b/deploy/license/bj-license-fw.timer new file mode 100644 index 0000000..9e4010a --- /dev/null +++ b/deploy/license/bj-license-fw.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Bridge&Join — статус защиты сервера лицензий раз в 30 секунд (и восстановление правил после перезагрузки) + +[Timer] +OnBootSec=30s +OnUnitActiveSec=30s +AccuracySec=5s +Unit=bj-license-fw.service + +[Install] +WantedBy=timers.target diff --git a/deploy/license/bj-license-server.service b/deploy/license/bj-license-server.service new file mode 100644 index 0000000..b1996c8 --- /dev/null +++ b/deploy/license/bj-license-server.service @@ -0,0 +1,22 @@ +[Unit] +Description=Bridge&Join — сервер лицензирования (lic.zetit.ru) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=bj-license +Group=bj-license +ExecStart=/opt/bj-license/bj-license-server serve -addr 127.0.0.1:8091 -data /var/lib/bj-license/state.json -key /etc/bj-license/license.priv -keyid main +Restart=on-failure +RestartSec=5 + +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +ReadWritePaths=/var/lib/bj-license +ReadOnlyPaths=/etc/bj-license + +[Install] +WantedBy=multi-user.target diff --git a/deploy/license/fail2ban/filter.d/bj-license-admin.conf b/deploy/license/fail2ban/filter.d/bj-license-admin.conf new file mode 100644 index 0000000..753c7eb --- /dev/null +++ b/deploy/license/fail2ban/filter.d/bj-license-admin.conf @@ -0,0 +1,6 @@ +# 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 new file mode 100644 index 0000000..f81dd15 --- /dev/null +++ b/deploy/license/fail2ban/filter.d/bj-license-api.conf @@ -0,0 +1,5 @@ +# 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 new file mode 100644 index 0000000..6f27224 --- /dev/null +++ b/deploy/license/fail2ban/jail.d/bj-license.conf @@ -0,0 +1,30 @@ +# Сервер лицензий lic.zetit.ru: блокировка IP на уровне файрвола. + +# Вход в кабинет: не блокируется только белый список администраторов (раздел «Защита»). +[bj-license-admin] +enabled = true +backend = systemd +filter = bj-license-admin +port = http,https +maxretry = 10 +findtime = 10m +bantime = 1h +ignorecommand = /opt/bj-license/bj-license-server fw-ignore -dir /var/lib/bj-license 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 new file mode 100644 index 0000000..33f911d --- /dev/null +++ b/docs/license-server-api.md @@ -0,0 +1,82 @@ +# Сервер лицензирования и обновлений bj — контракт API + +Сервер: **https://lic.zetit.ru** (ВМ 46.173.19.220, Ubuntu 26.04). Адрес зашит в bj +(`internal/lkgateway`, константа), в интерфейсе не меняется. С этого же сервера bj +получает обновления. + +## Модель + +- Лицензия — прежний токен `internal/license`: подписанный Ed25519 JSON `License`, + строка `base64(payload).base64(sig).keyid`. Ключ подписи — `~/keys/license.priv` + (keyid `main`); открытый ключ зашит в bj. +- Клиенту выдаётся **код активации** (`BJ-XXXX-XXXX-XXXX`), один на лицензию; им + активируется до `max_nodes` установок (0 — без лимита). +- При активации сервер выдаёт токен **на 60 дней** (`expires_at`), но не дольше + `contract_until`; в токене `install_id` установки и `contract_until` договора. +- bj продлевает токен **раз в сутки**. Отзыв = сервер больше не продлевает; уже + выданный токен доживает свой срок (запас на недоступность сервера). +- Офлайн-ключи (`bj-license issue`, без `install_id`) продолжают работать — для + закрытых контуров без интернета. + +## Идентификатор установки + +`install_id` — UUID v4, создаётся bj при первом запуске и хранится в +`/var/lib/bj/.bj/setup.json` (`license.install_id`). Вместе с ним bj передаёт +`machine` — sha256(`/etc/machine-id`) в hex (для учёта и обнаружения клонов, не для +отказа). + +## Запросы + +Все тела — JSON, `Content-Type: application/json`. Ошибки — HTTP 4xx/5xx и +`{"error": "<код>", "message": "<текст по-русски>"}`. + +### POST /v1/activate + +```json +{"code": "BJ-7K3M-Q9XP-2HDA", "install_id": "…uuid…", "machine": "…sha256…", + "hostname": "bj-server-01", "version": "0.1.0"} +``` + +200: +```json +{"token": "", "license_id": "…uuid…", "tenant": "ИК Фонтвьель", + "expires_at": "2026-11-14T10:00:00Z", "contract_until": "2027-09-14T00:00:00Z", + "renew_after_sec": 86400} +``` + +Коды ошибок: `bad_request`, `unknown_code`, `revoked`, `contract_expired`, +`max_nodes` (лимит установок исчерпан). Повторная активация той же `install_id` — +не новая установка, просто новый токен. + +### POST /v1/renew + +```json +{"token": "<текущий токен>", "install_id": "…", "machine": "…", "hostname": "…", "version": "…"} +``` + +Сервер проверяет подпись токена (срок **не** проверяет — продлить можно и +истёкший), совпадение `install_id` с токеном, что установка известна и лицензия не +отозвана. 200 — тот же формат, что у activate. Ошибки: `bad_token`, +`install_mismatch`, `unknown_install`, `revoked`, `contract_expired`. +Каждый успешный renew обновляет `last_seen`, `version`, `hostname`, IP установки. + +### Обновления + +- `GET /v1/{channel}/manifest.json` — подписанный манифест (`internal/release`), + без авторизации. +- `GET /v1/{channel}/files/{file}` — только с заголовком + `Authorization: License `: подпись верна, срок не истёк, лицензия не + отозвана, в признаках есть `updates` (или план `enterprise`). Иначе 401/403 с + JSON-ошибкой. Ключ подписи манифестов отдельный от лицензионного. + +### GET /healthz + +`ok`. + +## Кабинет администратора + +`https://lic.zetit.ru/admin/` — вход по логину и паролю (bcrypt), только для нас: +клиенты (название, ИНН), лицензии (план, признаки, max_nodes, contract_until, код +активации, отзыв/восстановление), установки (install_id, hostname, версия, IP, +последнее продление, срок токена), журнал событий, выпуск офлайн-ключа для +закрытого контура. diff --git a/go.mod b/go.mod index ced9b4a..131acc1 100644 --- a/go.mod +++ b/go.mod @@ -1,21 +1,31 @@ module git.zetit.ru/zuevav/Bridge-and-Join-s -go 1.25.0 +go 1.25.7 require ( github.com/jackc/pgx/v5 v5.7.4 - golang.org/x/text v0.34.0 + github.com/microsoft/go-mssqldb v1.10.0 + golang.org/x/crypto v0.54.0 + golang.org/x/text v0.40.0 google.golang.org/grpc v1.81.1 google.golang.org/protobuf v1.36.11 ) +require rsc.io/qr v0.2.0 // indirect + require ( + github.com/Azure/go-ntlmssp v0.1.1 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect + github.com/go-ldap/ldap/v3 v3.4.14 + github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect + github.com/golang-sql/sqlexp v0.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/net v0.51.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.42.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect ) diff --git a/go.sum b/go.sum index 3c68fd8..bc36cbd 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,36 @@ +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= +github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw= +github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ= +github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs= +github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -21,13 +45,21 @@ github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/microsoft/go-mssqldb v1.10.0 h1:pHEt+Qz6YFPWqREq10mqSE524QQo+/QremwTCQht7TY= +github.com/microsoft/go-mssqldb v1.10.0/go.mod h1:mnG7lGa9iYJbzJqGCXyuQCegStKMr3kogDLD6+bmggg= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= @@ -40,16 +72,16 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= @@ -62,3 +94,5 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/internal/license/license.go b/internal/license/license.go index c9c00f2..13820b2 100644 --- a/internal/license/license.go +++ b/internal/license/license.go @@ -47,6 +47,12 @@ type License struct { Features []string `json:"features,omitempty"` // "updates","web-cabinet",... MaxNodes int `json:"max_nodes,omitempty"` // лимит инсталляций (0 = без лимита) Note string `json:"note,omitempty"` + + // Онлайн-лицензирование (lic.zetit.ru, docs/license-server-api.md): токен выдаётся + // сервером на короткий срок (ExpiresAt) под конкретную установку и продлевается, + // пока действует договор (ContractUntil). У офлайн-ключей оба поля пустые. + InstallID string `json:"install_id,omitempty"` + ContractUntil time.Time `json:"contract_until,omitempty"` } // Token — лицензия + подпись. Именно это вводит клиент (одна base64-строка