refactor: сервер лицензий вынесен в репозиторий lic_Bridge-and-Join-s
cmd/bj-license-server, cmd/bj-license и deploy/license перенесены в zuevav/lic_Bridge-and-Join-s (наша инфраструктура, клиентам не поставляется). Здесь остаются клиентская часть лицензирования, формат токена internal/license и контракт API в docs. TestTokenCompat — одинаковый тест формата токена в обоих репозиториях. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,512 +0,0 @@
|
||||
package main
|
||||
|
||||
// Доступ к кабинету: свой адрес вместо /admin, вход только из белого списка администраторов и
|
||||
// второй фактор (TOTP, RFC 6238).
|
||||
//
|
||||
// Маршруты кабинета внутри остаются /admin/…: adminGate переписывает внешний адрес (State.AdminPath)
|
||||
// во внутренний, на прямые обращения к /admin при своём адресе отвечает 404 и подменяет Location в
|
||||
// ответах. Ссылки в шаблонах строятся функцией {{adm}}. При AdminAllowOnly кабинет и страница входа
|
||||
// видны только адресам белого списка администраторов, остальным — 404. API /v1 не затрагивается.
|
||||
//
|
||||
// По SSH: bj-license-server admin-info — адрес и настройки доступа; fw-apply -off — аварийно открыть
|
||||
// кабинет всем и выключить оборону; admin-config — изменить доступ при остановленном сервере.
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"encoding/base32"
|
||||
"encoding/binary"
|
||||
"flag"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rsc.io/qr"
|
||||
)
|
||||
|
||||
const (
|
||||
adminDefault = "/admin"
|
||||
adminOpenFile = "admin-open" // создаёт fw-apply -off: кабинет открыт всем до повторного включения ограничения
|
||||
totpIssuer = "BJ License (lic.zetit.ru)"
|
||||
publicHost = "lic.zetit.ru"
|
||||
)
|
||||
|
||||
var adminSlugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{7,63}$`)
|
||||
|
||||
// adminBase — внешний адрес кабинета: "/admin" или "/<AdminPath>".
|
||||
func (srv *server) adminBase() string {
|
||||
base := adminDefault
|
||||
srv.st.read(func(s *State) {
|
||||
if s.AdminPath != "" {
|
||||
base = "/" + s.AdminPath
|
||||
}
|
||||
})
|
||||
return base
|
||||
}
|
||||
|
||||
func (srv *server) adminOpen() bool {
|
||||
_, err := os.Stat(filepath.Join(srv.dataDir(), adminOpenFile))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// adminIPBlocked — кабинет ограничен белым списком, а адрес в него не входит.
|
||||
func (srv *server) adminIPBlocked(ip string) bool {
|
||||
blocked := false
|
||||
srv.st.read(func(s *State) { blocked = s.AdminAllowOnly && !s.allowCovers(ip, false) })
|
||||
return blocked && !srv.adminOpen()
|
||||
}
|
||||
|
||||
// locRewriter — Location: /admin/… в ответах кабинета заменяется текущим адресом кабинета.
|
||||
type locRewriter struct {
|
||||
http.ResponseWriter
|
||||
srv *server
|
||||
done bool
|
||||
}
|
||||
|
||||
func (lw *locRewriter) fix() {
|
||||
if lw.done {
|
||||
return
|
||||
}
|
||||
lw.done = true
|
||||
h := lw.Header()
|
||||
if l := h.Get("Location"); l == adminDefault || strings.HasPrefix(l, adminDefault+"/") || strings.HasPrefix(l, adminDefault+"?") {
|
||||
h.Set("Location", lw.srv.adminBase()+strings.TrimPrefix(l, adminDefault))
|
||||
}
|
||||
}
|
||||
|
||||
func (lw *locRewriter) WriteHeader(code int) {
|
||||
lw.fix()
|
||||
lw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (lw *locRewriter) Write(b []byte) (int, error) {
|
||||
lw.fix()
|
||||
return lw.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// adminGate — внешний адрес кабинета, 404 на /admin при своём адресе, ограничение белым списком.
|
||||
func (srv *server) adminGate(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
base, p := srv.adminBase(), r.URL.Path
|
||||
under := func(prefix string) bool { return p == prefix || strings.HasPrefix(p, prefix+"/") }
|
||||
switch {
|
||||
case base != adminDefault && under(adminDefault):
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
case base != adminDefault && under(base):
|
||||
r2 := r.Clone(r.Context())
|
||||
r2.URL.Path = adminDefault + strings.TrimPrefix(p, base)
|
||||
r2.URL.RawPath = ""
|
||||
r = r2
|
||||
case !under(adminDefault):
|
||||
h.ServeHTTP(w, r) // API, healthz, главная
|
||||
return
|
||||
}
|
||||
if srv.adminIPBlocked(clientIP(r)) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
h.ServeHTTP(&locRewriter{ResponseWriter: w, srv: srv}, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (srv *server) setSessionCookie(w http.ResponseWriter, r *http.Request, token string) {
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: token, Path: srv.adminBase(), HttpOnly: true, Secure: isHTTPS(r),
|
||||
SameSite: http.SameSiteStrictMode, MaxAge: int(sessionMax.Seconds())})
|
||||
}
|
||||
|
||||
// ---- TOTP ----
|
||||
|
||||
var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)
|
||||
|
||||
func newTOTPSecret() string {
|
||||
b := make([]byte, 20)
|
||||
_, _ = rand.Read(b)
|
||||
return b32.EncodeToString(b)
|
||||
}
|
||||
|
||||
func totpAt(secret string, counter int64) (string, bool) {
|
||||
key, err := b32.DecodeString(strings.ToUpper(secret))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
var msg [8]byte
|
||||
binary.BigEndian.PutUint64(msg[:], uint64(counter))
|
||||
m := hmac.New(sha1.New, key)
|
||||
m.Write(msg[:])
|
||||
sum := m.Sum(nil)
|
||||
off := sum[len(sum)-1] & 0x0f
|
||||
v := (binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff) % 1000000
|
||||
return fmt.Sprintf("%06d", v), true
|
||||
}
|
||||
|
||||
// totpCheck — код текущего, предыдущего или следующего 30-секундного шага, ещё не использованный
|
||||
// (шаг больше last). Возвращает шаг, чтобы запомнить его и не принять тот же код повторно.
|
||||
func totpCheck(secret, code string, last int64, now time.Time) (int64, bool) {
|
||||
code = strings.ReplaceAll(strings.TrimSpace(code), " ", "")
|
||||
if len(code) != 6 {
|
||||
return 0, false
|
||||
}
|
||||
step := now.Unix() / 30
|
||||
for d := int64(-1); d <= 1; d++ {
|
||||
c := step + d
|
||||
if c <= last {
|
||||
continue
|
||||
}
|
||||
if want, ok := totpAt(secret, c); ok && hmac.Equal([]byte(want), []byte(code)) {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func totpURI(login, secret string) string {
|
||||
return "otpauth://totp/" + url.PathEscape(totpIssuer+":"+login) + "?secret=" + secret +
|
||||
"&issuer=" + url.QueryEscape(totpIssuer) + "&algorithm=SHA1&digits=6&period=30"
|
||||
}
|
||||
|
||||
// qrSVG — QR-код строкой SVG (с полем в 4 модуля).
|
||||
func qrSVG(text string) template.HTML {
|
||||
c, err := qr.Encode(text, qr.M)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
n := c.Size + 8
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" width="220" height="220" shape-rendering="crispEdges" role="img" aria-label="QR-код для приложения"><rect width="%d" height="%d" fill="#fff"/><path fill="#000" d="`, n, n, n, n)
|
||||
for y := 0; y < c.Size; y++ {
|
||||
for x := 0; x < c.Size; x++ {
|
||||
if c.Black(x, y) {
|
||||
fmt.Fprintf(&b, "M%d %dh1v1h-1z", x+4, y+4)
|
||||
}
|
||||
}
|
||||
}
|
||||
b.WriteString(`"/></svg>`)
|
||||
return template.HTML(b.String())
|
||||
}
|
||||
|
||||
func groupBy4(s string) string {
|
||||
var parts []string
|
||||
for i := 0; i < len(s); i += 4 {
|
||||
parts = append(parts, s[i:min(i+4, len(s))])
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// ---- страницы ----
|
||||
|
||||
type totpPageData struct {
|
||||
Enabled bool
|
||||
SecretGrouped string
|
||||
URI template.URL
|
||||
QR template.HTML
|
||||
}
|
||||
|
||||
// meTOTPPage — GET /admin/me/2fa: подключение или отключение второго фактора.
|
||||
func (srv *server) meTOTPPage(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||
var d totpPageData
|
||||
srv.st.read(func(s *State) {
|
||||
if u := s.user(sess.Login); u != nil {
|
||||
d.Enabled = u.TOTPSecret != ""
|
||||
}
|
||||
})
|
||||
if !d.Enabled {
|
||||
srv.sessions.mu.Lock()
|
||||
if sess.PendingTOTP == "" {
|
||||
sess.PendingTOTP = newTOTPSecret()
|
||||
}
|
||||
secret := sess.PendingTOTP
|
||||
srv.sessions.mu.Unlock()
|
||||
uri := totpURI(sess.Login, secret)
|
||||
d.SecretGrouped, d.URI, d.QR = groupBy4(secret), template.URL(uri), qrSVG(uri)
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
srv.render(w, r, sess, "totp", "Второй фактор", "users", d)
|
||||
}
|
||||
|
||||
func (srv *server) meTOTPEnable(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||
srv.sessions.mu.Lock()
|
||||
secret := sess.PendingTOTP
|
||||
srv.sessions.mu.Unlock()
|
||||
if secret == "" {
|
||||
back(w, r, "/admin/me/2fa", "", "обновите страницу и добавьте ключ в приложение заново")
|
||||
return
|
||||
}
|
||||
step, ok := totpCheck(secret, r.PostFormValue("code"), 0, time.Now())
|
||||
if !ok {
|
||||
back(w, r, "/admin/me/2fa", "", "код не подошёл: проверьте, что время на телефоне точное, и введите текущий код")
|
||||
return
|
||||
}
|
||||
err := srv.st.write(func(s *State) error {
|
||||
u := s.user(sess.Login)
|
||||
if u == nil {
|
||||
return fmt.Errorf("администратор не найден")
|
||||
}
|
||||
u.TOTPSecret, u.TOTPLast = secret, step
|
||||
s.addEvent("totp_on", "", "", sess.Login, clientIP(r), "подключён второй фактор входа")
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
back(w, r, "/admin/me/2fa", "", err.Error())
|
||||
return
|
||||
}
|
||||
srv.sessions.mu.Lock()
|
||||
sess.PendingTOTP = ""
|
||||
srv.sessions.mu.Unlock()
|
||||
back(w, r, "/admin/me/2fa", "Второй фактор подключён: при входе теперь нужен код из приложения.", "")
|
||||
}
|
||||
|
||||
func (srv *server) meTOTPDisable(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||
var bad string
|
||||
err := srv.st.write(func(s *State) error {
|
||||
u := s.user(sess.Login)
|
||||
if u == nil || u.TOTPSecret == "" {
|
||||
bad = "второй фактор не подключён"
|
||||
return nil
|
||||
}
|
||||
if _, ok := totpCheck(u.TOTPSecret, r.PostFormValue("code"), 0, time.Now()); !ok {
|
||||
bad = "код не подошёл"
|
||||
return nil
|
||||
}
|
||||
u.TOTPSecret, u.TOTPLast = "", 0
|
||||
s.addEvent("totp_off", "", "", sess.Login, clientIP(r), "второй фактор входа отключён")
|
||||
return nil
|
||||
})
|
||||
if err != nil || bad != "" {
|
||||
back(w, r, "/admin/me/2fa", "", pickStr(bad, fmt.Sprint(err)))
|
||||
return
|
||||
}
|
||||
back(w, r, "/admin/me/2fa", "Второй фактор отключён.", "")
|
||||
}
|
||||
|
||||
// userTOTPReset — POST /admin/users/{login}/2fa-reset: сброс второго фактора другого администратора.
|
||||
func (srv *server) userTOTPReset(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||
login := r.PathValue("login")
|
||||
if strings.EqualFold(login, sess.Login) {
|
||||
back(w, r, "/admin/users", "", "свой второй фактор отключается на странице «Мой второй фактор» с кодом из приложения")
|
||||
return
|
||||
}
|
||||
err := srv.st.write(func(s *State) error {
|
||||
u := s.user(login)
|
||||
if u == nil {
|
||||
return fmt.Errorf("администратор не найден")
|
||||
}
|
||||
u.TOTPSecret, u.TOTPLast = "", 0
|
||||
s.addEvent("totp_reset", "", "", sess.Login, clientIP(r), "сброшен второй фактор администратора "+u.Login)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
back(w, r, "/admin/users", "", err.Error())
|
||||
return
|
||||
}
|
||||
srv.sessions.dropUser(login, "")
|
||||
back(w, r, "/admin/users", "Второй фактор "+login+" сброшен, его сессии завершены. При следующем входе хватит пароля.", "")
|
||||
}
|
||||
|
||||
// securityAccess — POST /admin/security/access: адрес кабинета и ограничение белым списком.
|
||||
func (srv *server) securityAccess(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||
op, myIP := r.PostFormValue("op"), clientIP(r)
|
||||
oldBase := srv.adminBase()
|
||||
var bad, okMsg string
|
||||
err := srv.st.write(func(s *State) error {
|
||||
switch op {
|
||||
case "path", "path-random", "path-default":
|
||||
slug := strings.ToLower(strings.Trim(strings.TrimSpace(r.PostFormValue("path")), "/"))
|
||||
switch {
|
||||
case op == "path-random":
|
||||
slug = "k" + randHex(12)
|
||||
case op == "path-default" || slug == "admin":
|
||||
slug = ""
|
||||
case !adminSlugRe.MatchString(slug):
|
||||
bad = "адрес: от 8 до 64 символов — латинские строчные буквы, цифры и дефис"
|
||||
return nil
|
||||
}
|
||||
if slug == s.AdminPath {
|
||||
bad = "адрес не изменился"
|
||||
return nil
|
||||
}
|
||||
s.AdminPath = slug
|
||||
shown := adminDefault
|
||||
if slug != "" {
|
||||
shown = "/" + slug
|
||||
}
|
||||
s.addEvent("admin_path", "", "", sess.Login, myIP, "адрес кабинета изменён на "+shown)
|
||||
okMsg = "Новый адрес кабинета: https://" + publicHost + shown + "/ — сохраните его в закладки. Прежний адрес больше не открывается."
|
||||
case "allow-only-on":
|
||||
if n, ok := normalizeNet(myIP); ok && !s.allowCovers(myIP, false) {
|
||||
s.AllowIPs = append(s.AllowIPs, &AllowIP{Net: n, Comment: "добавлен автоматически при ограничении кабинета", AddedBy: sess.Login, AddedAt: time.Now()})
|
||||
s.addEvent("fw_allow_add", "", "", sess.Login, myIP, "IP "+n+" добавлен в белый список (ограничение кабинета)")
|
||||
}
|
||||
s.AdminAllowOnly = true
|
||||
s.addEvent("admin_allow_only_on", "", "", sess.Login, myIP, "кабинет доступен только из белого списка администраторов")
|
||||
_ = os.Remove(filepath.Join(srv.dataDir(), adminOpenFile))
|
||||
okMsg = "Кабинет открывается только с адресов белого списка, остальным — «не найдено»."
|
||||
case "allow-only-off":
|
||||
s.AdminAllowOnly = false
|
||||
s.addEvent("admin_allow_only_off", "", "", sess.Login, myIP, "ограничение кабинета белым списком снято")
|
||||
okMsg = "Ограничение снято: страница входа доступна с любого адреса."
|
||||
default:
|
||||
bad = "неизвестная команда"
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil || bad != "" {
|
||||
back(w, r, "/admin/security", "", pickStr(bad, fmt.Sprint(err)))
|
||||
return
|
||||
}
|
||||
if newBase := srv.adminBase(); newBase != oldBase {
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: oldBase, HttpOnly: true, Secure: isHTTPS(r), MaxAge: -1})
|
||||
srv.setSessionCookie(w, r, sess.Token)
|
||||
}
|
||||
back(w, r, "/admin/security", okMsg, "")
|
||||
}
|
||||
|
||||
// ---- консоль ----
|
||||
|
||||
type multiFlag []string
|
||||
|
||||
func (m *multiFlag) String() string { return strings.Join(*m, ",") }
|
||||
func (m *multiFlag) Set(v string) error { *m = append(*m, v); return nil }
|
||||
|
||||
// adminConfig — `bj-license-server admin-config`: доступ к кабинету из консоли (сервер остановлен).
|
||||
func adminConfig(args []string) {
|
||||
fs := flag.NewFlagSet("admin-config", flag.ExitOnError)
|
||||
data := fs.String("data", "/var/lib/bj-license/state.json", "файл состояния")
|
||||
var allow multiFlag
|
||||
fs.Var(&allow, "allow", "добавить в белый список администраторов: IP[=комментарий] (флаг можно повторять)")
|
||||
path := fs.String("path", "", "адрес кабинета: свой (8–64 символа a-z, 0-9, дефис), random — сгенерировать, admin — стандартный")
|
||||
only := fs.String("allow-only", "", "кабинет только из белого списка: on | off")
|
||||
reset := fs.String("reset-2fa", "", "сбросить второй фактор администратора (логин)")
|
||||
zkFlag := fs.String("zetit-key", "", "вход через ЗЕТИТ Ключ: off — выключить (аварийно)")
|
||||
_ = fs.Parse(args)
|
||||
st, err := openStore(*data)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
err = st.write(func(s *State) error {
|
||||
now := time.Now()
|
||||
for _, a := range allow {
|
||||
ipPart, comment, _ := strings.Cut(a, "=")
|
||||
n, ok := normalizeNet(ipPart)
|
||||
if !ok {
|
||||
return fmt.Errorf("неверный адрес %q", ipPart)
|
||||
}
|
||||
exists := false
|
||||
for _, x := range s.AllowIPs {
|
||||
exists = exists || x.Net == n
|
||||
}
|
||||
if !exists {
|
||||
s.AllowIPs = append(s.AllowIPs, &AllowIP{Net: n, Comment: strings.TrimSpace(comment), AddedBy: "консоль", AddedAt: now})
|
||||
s.addEvent("fw_allow_add", "", "", "консоль", "", "IP "+n+" добавлен в белый список")
|
||||
}
|
||||
}
|
||||
switch p := strings.ToLower(strings.Trim(*path, "/")); {
|
||||
case p == "":
|
||||
case p == "admin":
|
||||
s.AdminPath = ""
|
||||
s.addEvent("admin_path", "", "", "консоль", "", "адрес кабинета: стандартный /admin")
|
||||
case p == "random":
|
||||
s.AdminPath = "k" + randHex(12)
|
||||
s.addEvent("admin_path", "", "", "консоль", "", "адрес кабинета изменён")
|
||||
case adminSlugRe.MatchString(p):
|
||||
s.AdminPath = p
|
||||
s.addEvent("admin_path", "", "", "консоль", "", "адрес кабинета изменён")
|
||||
default:
|
||||
return fmt.Errorf("адрес: 8–64 символа, латинские строчные буквы, цифры и дефис")
|
||||
}
|
||||
switch *only {
|
||||
case "":
|
||||
case "on":
|
||||
if len(s.AllowIPs) == 0 {
|
||||
return fmt.Errorf("белый список пуст — ограничение закроет кабинет всем")
|
||||
}
|
||||
s.AdminAllowOnly = true
|
||||
s.addEvent("admin_allow_only_on", "", "", "консоль", "", "кабинет доступен только из белого списка администраторов")
|
||||
case "off":
|
||||
s.AdminAllowOnly = false
|
||||
s.addEvent("admin_allow_only_off", "", "", "консоль", "", "ограничение кабинета белым списком снято")
|
||||
default:
|
||||
return fmt.Errorf("-allow-only: on или off")
|
||||
}
|
||||
switch *zkFlag {
|
||||
case "":
|
||||
case "off":
|
||||
s.ZetitKey.Enabled = false
|
||||
s.addEvent("zetit_key", "", "", "консоль", "", "вход через ЗЕТИТ Ключ выключен")
|
||||
default:
|
||||
return fmt.Errorf("-zetit-key: только off")
|
||||
}
|
||||
if *reset != "" {
|
||||
u := s.user(*reset)
|
||||
if u == nil {
|
||||
return fmt.Errorf("администратор %s не найден", *reset)
|
||||
}
|
||||
u.TOTPSecret, u.TOTPLast = "", 0
|
||||
s.addEvent("totp_reset", "", "", "консоль", "", "сброшен второй фактор администратора "+u.Login)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if *only == "on" {
|
||||
_ = os.Remove(filepath.Join(filepath.Dir(*data), adminOpenFile))
|
||||
}
|
||||
printAccess(st)
|
||||
}
|
||||
|
||||
// adminInfo — `bj-license-server admin-info`: адрес и настройки доступа (только чтение).
|
||||
func adminInfo(args []string) {
|
||||
fs := flag.NewFlagSet("admin-info", flag.ExitOnError)
|
||||
data := fs.String("data", "/var/lib/bj-license/state.json", "файл состояния")
|
||||
_ = fs.Parse(args)
|
||||
st, err := openStore(*data)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
printAccess(st)
|
||||
}
|
||||
|
||||
func printAccess(st *store) {
|
||||
_, openErr := os.Stat(filepath.Join(filepath.Dir(st.path), adminOpenFile))
|
||||
st.read(func(s *State) {
|
||||
path := adminDefault
|
||||
if s.AdminPath != "" {
|
||||
path = "/" + s.AdminPath
|
||||
}
|
||||
fmt.Printf("Адрес кабинета: https://%s%s/\n", publicHost, path)
|
||||
switch {
|
||||
case s.AdminAllowOnly && openErr == nil:
|
||||
fmt.Println("Только из белого списка: включено, но кабинет открыт аварийно (fw-apply -off)")
|
||||
case s.AdminAllowOnly:
|
||||
fmt.Println("Только из белого списка: включено")
|
||||
default:
|
||||
fmt.Println("Только из белого списка: выключено")
|
||||
}
|
||||
fmt.Println("Белый список администраторов:")
|
||||
for _, a := range s.AllowIPs {
|
||||
fmt.Printf(" %-20s %s\n", a.Net, a.Comment)
|
||||
}
|
||||
if s.ZetitKey.Enabled {
|
||||
fmt.Println("Вход через ЗЕТИТ Ключ: включён (" + pickStr(s.ZetitKey.URL, zetitKeyDefaultURL) + ")")
|
||||
} else {
|
||||
fmt.Println("Вход через ЗЕТИТ Ключ: выключен")
|
||||
}
|
||||
fmt.Println("Администраторы (второй фактор):")
|
||||
for _, u := range s.Users {
|
||||
state := "не подключён"
|
||||
if u.TOTPSecret != "" {
|
||||
state = "подключён"
|
||||
}
|
||||
fmt.Printf(" %-24s TOTP %s, e-mail %s\n", u.Login, state, pickStr(u.Email, "не указан"))
|
||||
}
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,291 +0,0 @@
|
||||
package main
|
||||
|
||||
// API для установок bj: активация по коду и продление токена (docs/license-server-api.md).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license"
|
||||
)
|
||||
|
||||
const (
|
||||
tokenTTL = 60 * 24 * time.Hour
|
||||
renewAfterSec = 86400
|
||||
)
|
||||
|
||||
type apiRequest struct {
|
||||
Code string `json:"code"`
|
||||
Token string `json:"token"`
|
||||
InstallID string `json:"install_id"`
|
||||
Machine string `json:"machine"`
|
||||
Hostname string `json:"hostname"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type apiResponse struct {
|
||||
Token string `json:"token"`
|
||||
LicenseID string `json:"license_id"`
|
||||
Tenant string `json:"tenant"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
ContractUntil time.Time `json:"contract_until"`
|
||||
RenewAfterSec int `json:"renew_after_sec"`
|
||||
}
|
||||
|
||||
type apiError struct {
|
||||
Status int `json:"-"`
|
||||
Code string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func apiErr(status int, code, msg string) *apiError {
|
||||
return &apiError{Status: status, Code: code, Message: msg}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func clip(s string, n int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if r := []rune(s); len(r) > n {
|
||||
return string(r[:n])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (srv *server) decodeAPI(w http.ResponseWriter, r *http.Request) (*apiRequest, bool) {
|
||||
if !srv.apiRate.allow(clientIP(r)) {
|
||||
writeJSON(w, http.StatusTooManyRequests, apiErr(429, "rate_limited", "слишком много запросов, повторите позже"))
|
||||
return nil, false
|
||||
}
|
||||
var in apiRequest
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&in); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, apiErr(400, "bad_request", "тело запроса — JSON по docs/license-server-api.md"))
|
||||
return nil, false
|
||||
}
|
||||
in.InstallID = strings.ToLower(strings.TrimSpace(in.InstallID))
|
||||
if !uuidRe.MatchString(in.InstallID) {
|
||||
writeJSON(w, http.StatusBadRequest, apiErr(400, "bad_request", "install_id: ожидается UUID"))
|
||||
return nil, false
|
||||
}
|
||||
in.Machine, in.Hostname, in.Version = clip(in.Machine, 128), clip(in.Hostname, 128), clip(in.Version, 40)
|
||||
return &in, true
|
||||
}
|
||||
|
||||
// issueToken — подписанная лицензия под установку: срок токена 60 дней, не дольше договора.
|
||||
func (srv *server) issueToken(s *State, lic *License, installID string, now time.Time) (string, time.Time, error) {
|
||||
exp := now.Add(tokenTTL)
|
||||
if !lic.ContractUntil.IsZero() && lic.ContractUntil.Before(exp) {
|
||||
exp = lic.ContractUntil
|
||||
}
|
||||
tenant := ""
|
||||
if t := s.tenant(lic.TenantID); t != nil {
|
||||
tenant = t.Name
|
||||
}
|
||||
l := &license.License{
|
||||
Schema: license.CurrentSchema, ID: lic.ID, Tenant: tenant, Product: "bj-server",
|
||||
Plan: license.Plan(lic.Plan), IssuedAt: now.UTC(), ExpiresAt: exp.UTC(),
|
||||
Features: lic.Features, MaxNodes: lic.MaxNodes, InstallID: installID, ContractUntil: lic.ContractUntil.UTC(),
|
||||
}
|
||||
tok, err := license.Sign(l, srv.priv, srv.keyID)
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
return tok.Encode(), exp, nil
|
||||
}
|
||||
|
||||
func (srv *server) answer(s *State, lic *License, token string, exp time.Time) *apiResponse {
|
||||
tenant := ""
|
||||
if t := s.tenant(lic.TenantID); t != nil {
|
||||
tenant = t.Name
|
||||
}
|
||||
return &apiResponse{Token: token, LicenseID: lic.ID, Tenant: tenant, ExpiresAt: exp.UTC(), ContractUntil: lic.ContractUntil.UTC(), RenewAfterSec: renewAfterSec}
|
||||
}
|
||||
|
||||
// touchInstall — сведения об установке из запроса; смена machine-id — предупреждение в журнал.
|
||||
func touchInstall(s *State, inst *Install, in *apiRequest, ip string, now time.Time) {
|
||||
if in.Machine != "" && inst.Machine != "" && in.Machine != inst.Machine {
|
||||
s.addEvent("machine_changed", inst.LicenseID, inst.InstallID, "api", ip,
|
||||
fmt.Sprintf("сменился machine-id установки (%s → %s): возможно, копия сервера", short(inst.Machine), short(in.Machine)))
|
||||
}
|
||||
if in.Machine != "" {
|
||||
inst.Machine = in.Machine
|
||||
}
|
||||
if in.Hostname != "" {
|
||||
inst.Hostname = in.Hostname
|
||||
}
|
||||
if in.Version != "" {
|
||||
inst.Version = in.Version
|
||||
}
|
||||
inst.IP, inst.LastSeen = ip, now
|
||||
}
|
||||
|
||||
// trustInstallIP — после успешной активации или продления адрес установки попадает в доверенные fail2ban.
|
||||
func trustInstallIP(s *State, inst *Install, ip string, now time.Time) {
|
||||
if s.trustIP(inst, ip, now) {
|
||||
s.addEvent("trust_ip", inst.LicenseID, inst.InstallID, "api", ip, "IP "+ip+" добавлен в доверенные fail2ban")
|
||||
}
|
||||
}
|
||||
|
||||
func hostNote(in *apiRequest) string {
|
||||
if in.Hostname == "" {
|
||||
return ""
|
||||
}
|
||||
return " (" + in.Hostname + ")"
|
||||
}
|
||||
|
||||
// POST /v1/activate
|
||||
func (srv *server) apiActivate(w http.ResponseWriter, r *http.Request) {
|
||||
in, ok := srv.decodeAPI(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ip := clientIP(r)
|
||||
code := normalizeCode(in.Code)
|
||||
var resp *apiResponse
|
||||
var fail *apiError
|
||||
err := srv.st.write(func(s *State) error {
|
||||
now := time.Now()
|
||||
var lic *License
|
||||
if code != "" {
|
||||
lic = s.licenseByCode(code)
|
||||
}
|
||||
if lic == nil {
|
||||
fail = apiErr(http.StatusNotFound, "unknown_code", "код активации не найден")
|
||||
log.Printf("api denied: unknown_code ip=%s", ip) // строку ловит fail2ban
|
||||
s.addEvent("activate_denied", "", in.InstallID, "api", ip, "неизвестный код активации"+hostNote(in))
|
||||
return nil
|
||||
}
|
||||
inst := s.install(in.InstallID)
|
||||
switch {
|
||||
case lic.Revoked():
|
||||
fail = apiErr(http.StatusForbidden, "revoked", "лицензия отозвана")
|
||||
case lic.ContractExpired(now):
|
||||
fail = apiErr(http.StatusForbidden, "contract_expired", "срок договора истёк "+lic.ContractUntil.In(msk).Format("02.01.2006"))
|
||||
case inst != nil && inst.Blocked:
|
||||
fail = apiErr(http.StatusForbidden, "install_blocked", "установка заблокирована: "+inst.BlockReason)
|
||||
case (inst == nil || inst.LicenseID != lic.ID) && lic.MaxNodes > 0 && s.activeInstalls(lic.ID) >= lic.MaxNodes:
|
||||
fail = apiErr(http.StatusConflict, "max_nodes", fmt.Sprintf("исчерпан лимит установок по лицензии (%d)", lic.MaxNodes))
|
||||
}
|
||||
if fail != nil {
|
||||
s.addEvent("activate_denied", lic.ID, in.InstallID, "api", ip, fail.Message+hostNote(in))
|
||||
return nil
|
||||
}
|
||||
text := "активирована новая установка" + hostNote(in)
|
||||
switch {
|
||||
case inst == nil:
|
||||
inst = &Install{InstallID: in.InstallID, FirstSeen: now}
|
||||
s.Installs = append(s.Installs, inst)
|
||||
case inst.LicenseID != lic.ID:
|
||||
text = "установка переведена на эту лицензию с " + short(inst.LicenseID) + hostNote(in)
|
||||
default:
|
||||
text = "повторная активация установки" + hostNote(in)
|
||||
}
|
||||
inst.LicenseID = lic.ID
|
||||
touchInstall(s, inst, in, ip, now)
|
||||
trustInstallIP(s, inst, ip, now)
|
||||
token, exp, err := srv.issueToken(s, lic, inst.InstallID, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inst.TokenExpires = exp
|
||||
s.addEvent("activate", lic.ID, inst.InstallID, "api", ip, text+", токен до "+exp.In(msk).Format("02.01.2006"))
|
||||
resp = srv.answer(s, lic, token, exp)
|
||||
return nil
|
||||
})
|
||||
srv.finishAPI(w, resp, fail, err)
|
||||
}
|
||||
|
||||
// POST /v1/renew
|
||||
func (srv *server) apiRenew(w http.ResponseWriter, r *http.Request) {
|
||||
in, ok := srv.decodeAPI(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ip := clientIP(r)
|
||||
tok, err := license.DecodeToken(in.Token)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusUnauthorized, apiErr(401, "bad_token", "токен не разобран"))
|
||||
return
|
||||
}
|
||||
l, err := license.Verify(tok, srv.pub)
|
||||
if err != nil {
|
||||
log.Printf("api denied: bad_token ip=%s", ip) // строку ловит fail2ban
|
||||
writeJSON(w, http.StatusUnauthorized, apiErr(401, "bad_token", "подпись токена недействительна"))
|
||||
return
|
||||
}
|
||||
if l.InstallID == "" || !strings.EqualFold(l.InstallID, in.InstallID) {
|
||||
writeJSON(w, http.StatusForbidden, apiErr(403, "install_mismatch", "токен выдан другой установке"))
|
||||
return
|
||||
}
|
||||
var resp *apiResponse
|
||||
var fail *apiError
|
||||
err = srv.st.write(func(s *State) error {
|
||||
now := time.Now()
|
||||
lic := s.license(l.ID)
|
||||
if lic == nil {
|
||||
fail = apiErr(http.StatusUnauthorized, "bad_token", "лицензия из токена не найдена")
|
||||
return nil
|
||||
}
|
||||
inst := s.install(in.InstallID)
|
||||
switch {
|
||||
case inst == nil || inst.LicenseID != lic.ID:
|
||||
fail = apiErr(http.StatusNotFound, "unknown_install", "установка не зарегистрирована по этой лицензии — активируйте кодом")
|
||||
case lic.Revoked():
|
||||
fail = apiErr(http.StatusForbidden, "revoked", "лицензия отозвана")
|
||||
case lic.ContractExpired(now):
|
||||
fail = apiErr(http.StatusForbidden, "contract_expired", "срок договора истёк "+lic.ContractUntil.In(msk).Format("02.01.2006"))
|
||||
case inst.Blocked:
|
||||
fail = apiErr(http.StatusForbidden, "install_blocked", "установка заблокирована: "+inst.BlockReason)
|
||||
}
|
||||
if fail != nil {
|
||||
s.addEvent("renew_denied", lic.ID, in.InstallID, "api", ip, fail.Message+hostNote(in))
|
||||
return nil
|
||||
}
|
||||
touchInstall(s, inst, in, ip, now)
|
||||
trustInstallIP(s, inst, ip, now)
|
||||
token, exp, err := srv.issueToken(s, lic, inst.InstallID, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inst.TokenExpires = exp
|
||||
s.addEvent("renew", lic.ID, inst.InstallID, "api", ip, fmt.Sprintf("продление%s, версия %s, токен до %s", hostNote(in), pickStr(in.Version, "—"), exp.In(msk).Format("02.01.2006")))
|
||||
resp = srv.answer(s, lic, token, exp)
|
||||
return nil
|
||||
})
|
||||
srv.finishAPI(w, resp, fail, err)
|
||||
}
|
||||
|
||||
func (srv *server) finishAPI(w http.ResponseWriter, resp *apiResponse, fail *apiError, err error) {
|
||||
switch {
|
||||
case err != nil:
|
||||
writeJSON(w, http.StatusInternalServerError, apiErr(500, "internal", "внутренняя ошибка сервера лицензий"))
|
||||
case fail != nil:
|
||||
writeJSON(w, fail.Status, fail)
|
||||
default:
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
}
|
||||
|
||||
func pickStr(v, def string) string {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func short(id string) string {
|
||||
if len(id) > 8 {
|
||||
return id[:8]
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -1,917 +0,0 @@
|
||||
package main
|
||||
|
||||
// Защита сервера лицензий из кабинета: fail2ban и «режим обороны».
|
||||
//
|
||||
// Кабинет работает непривилегированно (пользователь bj-license) и только пишет желаемое
|
||||
// состояние рядом с state.json: белый список администраторов (allow-ips.txt) и режим с
|
||||
// разовыми командами fail2ban (fw-control.json). Применяет их root-помощник
|
||||
// `bj-license-server fw-apply` — systemd запускает его при изменении fw-control.json
|
||||
// (bj-license-fw.path) и раз в 30 секунд (bj-license-fw.timer); фактическое состояние
|
||||
// он пишет в fw-status.json, его и показывает кабинет.
|
||||
//
|
||||
// Режим обороны: на порты 80/443 пускаются только адреса из белого списка (ipset
|
||||
// bj-allow / bj-allow6) — доверенные IP установок (trusted-ips.txt) и адреса
|
||||
// администраторов (allow-ips.txt). SSH не затрагивается. Аварийное выключение по SSH:
|
||||
//
|
||||
// /opt/bj-license/bj-license-server fw-apply -off
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
fwControlFile = "fw-control.json"
|
||||
fwStatusFile = "fw-status.json"
|
||||
fwAllowFile = "allow-ips.txt"
|
||||
fwOffFile = "fw-off" // создаёт fw-apply -off; удаляет кабинет при повторном включении обороны
|
||||
fwSetV4 = "bj-allow"
|
||||
fwSetV6 = "bj-allow6"
|
||||
fwRuleComment = "bj-license-lockdown"
|
||||
fwStatusStale = 2 * time.Minute
|
||||
)
|
||||
|
||||
// fwJails — jail fail2ban, которыми управляет кабинет.
|
||||
var fwJails = []string{"bj-license-api", "bj-license-admin", "sshd"}
|
||||
|
||||
// AllowIP — адрес или сеть администратора в белом списке.
|
||||
type AllowIP struct {
|
||||
Net string `json:"net"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
AddedBy string `json:"added_by,omitempty"`
|
||||
AddedAt time.Time `json:"added_at"`
|
||||
}
|
||||
|
||||
// FirewallState — желаемое состояние защиты: пишет кабинет, применяет fw-apply.
|
||||
type FirewallState struct {
|
||||
Mode string `json:"mode,omitempty"` // "" — обычный, lockdown — оборона
|
||||
Until time.Time `json:"until,omitempty"`
|
||||
UpdatedBy string `json:"updated_by,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
Actions []fwAction `json:"actions,omitempty"`
|
||||
// Параметры блокировок jail (секунды; bantime -1 — навсегда). Пусто — значения из jail.d/bj-license.conf.
|
||||
Jails map[string]fwJailConf `json:"jails,omitempty"`
|
||||
// Блокировка входа в кабинет самим приложением (до fail2ban): попыток и срок.
|
||||
LoginAttempts int `json:"login_attempts,omitempty"`
|
||||
LoginLockSec int64 `json:"login_lock_sec,omitempty"`
|
||||
}
|
||||
|
||||
// fwJailConf — параметры блокировки jail.
|
||||
type fwJailConf struct {
|
||||
BanTime int64 `json:"bantime"`
|
||||
FindTime int64 `json:"findtime"`
|
||||
MaxRetry int `json:"maxretry"`
|
||||
}
|
||||
|
||||
// fwDefaultJail — значения из deploy/license/fail2ban/jail.d/bj-license.conf.
|
||||
func fwDefaultJail(name string) fwJailConf {
|
||||
if name == "sshd" {
|
||||
return fwJailConf{BanTime: 3600, FindTime: 600, MaxRetry: 5}
|
||||
}
|
||||
return fwJailConf{BanTime: 3600, FindTime: 600, MaxRetry: 10}
|
||||
}
|
||||
|
||||
type fwPreset struct {
|
||||
Sec int64
|
||||
Label string
|
||||
}
|
||||
|
||||
var (
|
||||
fwBanPresets = []fwPreset{{900, "15 минут"}, {3600, "1 час"}, {7200, "2 часа"}, {21600, "6 часов"}, {43200, "12 часов"},
|
||||
{86400, "1 день"}, {259200, "3 дня"}, {604800, "1 неделя"}, {2592000, "1 месяц"}, {7776000, "3 месяца"},
|
||||
{31536000, "1 год"}, {-1, "навсегда"}}
|
||||
fwFindPresets = []fwPreset{{600, "10 минут"}, {3600, "1 час"}, {21600, "6 часов"}, {86400, "1 день"}, {604800, "1 неделя"}}
|
||||
fwRetryPresets = []int{3, 5, 10, 20}
|
||||
)
|
||||
|
||||
// durRu — срок по-русски: из пресетов или в секундах.
|
||||
func durRu(sec int64) string {
|
||||
for _, p := range fwBanPresets {
|
||||
if p.Sec == sec {
|
||||
return p.Label
|
||||
}
|
||||
}
|
||||
for _, p := range fwFindPresets {
|
||||
if p.Sec == sec {
|
||||
return p.Label
|
||||
}
|
||||
}
|
||||
return strconv.FormatInt(sec, 10) + " с"
|
||||
}
|
||||
|
||||
func fwPresetOK(list []fwPreset, sec int64) bool {
|
||||
for _, p := range list {
|
||||
if p.Sec == sec {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// loginLimitOf — ограничение входа в кабинет: попыток и срок (по умолчанию 8 за 15 минут).
|
||||
func loginLimitOf(s *State) (int, time.Duration) {
|
||||
n, sec := s.Firewall.LoginAttempts, s.Firewall.LoginLockSec
|
||||
if n <= 0 {
|
||||
n = 8
|
||||
}
|
||||
if sec <= 0 {
|
||||
sec = 900
|
||||
}
|
||||
return n, time.Duration(sec) * time.Second
|
||||
}
|
||||
|
||||
// fwAction — разовая команда fail2ban из кабинета.
|
||||
type fwAction struct {
|
||||
ID string `json:"id"`
|
||||
Op string `json:"op"` // ban | unban
|
||||
Jail string `json:"jail"`
|
||||
IP string `json:"ip"`
|
||||
At time.Time `json:"at"`
|
||||
By string `json:"by,omitempty"`
|
||||
}
|
||||
|
||||
type fwJailStatus struct {
|
||||
Name string `json:"name"`
|
||||
BanTime int64 `json:"bantime"`
|
||||
FindTime int64 `json:"findtime"`
|
||||
MaxRetry int `json:"maxretry"`
|
||||
CurrentlyFailed int `json:"currently_failed"`
|
||||
TotalFailed int `json:"total_failed"`
|
||||
CurrentlyBanned int `json:"currently_banned"`
|
||||
TotalBanned int `json:"total_banned"`
|
||||
Banned []string `json:"banned"`
|
||||
}
|
||||
|
||||
// fwStatus — фактическое состояние, пишет fw-apply.
|
||||
type fwStatus struct {
|
||||
At time.Time `json:"at"`
|
||||
Lockdown bool `json:"lockdown"` // правило обороны действует
|
||||
Emergency bool `json:"emergency"` // оборона выключена аварийно (fw-apply -off)
|
||||
Until time.Time `json:"until,omitempty"`
|
||||
AllowCount int `json:"allow_count"`
|
||||
Jails []fwJailStatus `json:"jails"`
|
||||
Done []string `json:"done,omitempty"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
// normalizeNet — IP или сеть в каноническом виде; слишком широкие сети не принимаются.
|
||||
func normalizeNet(s string) (string, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if ip := net.ParseIP(s); ip != nil {
|
||||
return ip.String(), true
|
||||
}
|
||||
_, n, err := net.ParseCIDR(s)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
ones, bits := n.Mask.Size()
|
||||
if (bits == 32 && ones < 16) || (bits == 128 && ones < 32) {
|
||||
return "", false
|
||||
}
|
||||
return n.String(), true
|
||||
}
|
||||
|
||||
// netContains — адрес ip попадает в IP или сеть entry.
|
||||
func netContains(entry, ip string) bool {
|
||||
p := net.ParseIP(strings.TrimSpace(ip))
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
if e := net.ParseIP(entry); e != nil {
|
||||
return e.Equal(p)
|
||||
}
|
||||
_, n, err := net.ParseCIDR(entry)
|
||||
return err == nil && n.Contains(p)
|
||||
}
|
||||
|
||||
// allowCovers — адрес в белом списке администраторов (includeTrusted — или среди доверенных установок).
|
||||
func (s *State) allowCovers(ip string, includeTrusted bool) bool {
|
||||
for _, a := range s.AllowIPs {
|
||||
if netContains(a.Net, ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if includeTrusted {
|
||||
for _, t := range s.trustedIPs() {
|
||||
if netContains(t, ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// writeIfChanged — атомарная запись файла, только если содержимое изменилось.
|
||||
func writeIfChanged(path string, data []byte, perm os.FileMode) error {
|
||||
if old, err := os.ReadFile(path); err == nil && bytes.Equal(old, data) {
|
||||
return nil
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, perm); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// writeFirewallLocked — allow-ips.txt и fw-control.json для fw-apply; вызывать под блокировкой.
|
||||
func (st *store) writeFirewallLocked() error {
|
||||
dir := filepath.Dir(st.path)
|
||||
body := "# Белый список администраторов (IP или сеть). Файл пишет bj-license-server.\n"
|
||||
for _, a := range st.s.AllowIPs {
|
||||
body += a.Net + "\n"
|
||||
}
|
||||
if err := writeIfChanged(filepath.Join(dir, fwAllowFile), []byte(body), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := json.MarshalIndent(st.s.Firewall, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeIfChanged(filepath.Join(dir, fwControlFile), b, 0o600)
|
||||
}
|
||||
|
||||
func fwReadStatus(dir string) fwStatus {
|
||||
var st fwStatus
|
||||
if b, err := os.ReadFile(filepath.Join(dir, fwStatusFile)); err == nil {
|
||||
_ = json.Unmarshal(b, &st)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// ---- root-помощник ----
|
||||
|
||||
// fwApply — `bj-license-server fw-apply [-dir …] [-off]`: применить желаемое состояние (от root).
|
||||
func fwApply(args []string) {
|
||||
fs := flag.NewFlagSet("fw-apply", flag.ExitOnError)
|
||||
dir := fs.String("dir", "/var/lib/bj-license", "каталог данных сервера лицензий")
|
||||
off := fs.Bool("off", false, "аварийно выключить режим обороны (до повторного включения в кабинете)")
|
||||
_ = fs.Parse(args)
|
||||
if os.Geteuid() != 0 {
|
||||
log.Fatal("fw-apply запускается от root")
|
||||
}
|
||||
if *off {
|
||||
if err := os.WriteFile(filepath.Join(*dir, fwOffFile), []byte(time.Now().Format(time.RFC3339)+"\n"), 0o644); err != nil {
|
||||
log.Fatalf("аварийное выключение: %v", err)
|
||||
}
|
||||
chownLikeDir(*dir, filepath.Join(*dir, fwOffFile))
|
||||
// Заодно открыть кабинет всем адресам, если он ограничен белым списком (access.go).
|
||||
if err := os.WriteFile(filepath.Join(*dir, adminOpenFile), []byte(time.Now().Format(time.RFC3339)+"\n"), 0o644); err == nil {
|
||||
chownLikeDir(*dir, filepath.Join(*dir, adminOpenFile))
|
||||
}
|
||||
}
|
||||
st := fwRun(*dir)
|
||||
if err := fwWriteStatus(*dir, st); err != nil {
|
||||
log.Printf("fw-status.json: %v", err)
|
||||
}
|
||||
for _, e := range st.Errors {
|
||||
log.Print(e)
|
||||
}
|
||||
if *off {
|
||||
fmt.Println("Режим обороны выключен аварийно, кабинет открыт для всех адресов. Включить снова — в кабинете, раздел «Защита».")
|
||||
fmt.Println("Адрес кабинета: bj-license-server admin-info")
|
||||
}
|
||||
}
|
||||
|
||||
func fwRun(dir string) fwStatus {
|
||||
now := time.Now()
|
||||
st := fwStatus{At: now}
|
||||
prev := fwReadStatus(dir)
|
||||
var ctl FirewallState
|
||||
if b, err := os.ReadFile(filepath.Join(dir, fwControlFile)); err == nil {
|
||||
if err := json.Unmarshal(b, &ctl); err != nil {
|
||||
st.Errors = append(st.Errors, "fw-control.json: "+err.Error())
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, fwOffFile)); err == nil {
|
||||
st.Emergency = true
|
||||
}
|
||||
want := ctl.Mode == "lockdown" && !st.Emergency && (ctl.Until.IsZero() || now.Before(ctl.Until))
|
||||
|
||||
v4, v6 := fwReadAllow(dir)
|
||||
st.AllowCount = len(v4) + len(v6)
|
||||
if err := fwSyncSet(fwSetV4, "inet", v4); err != nil {
|
||||
st.Errors = append(st.Errors, err.Error())
|
||||
want = false
|
||||
}
|
||||
if err := fwSyncSet(fwSetV6, "inet6", v6); err != nil {
|
||||
st.Errors = append(st.Errors, err.Error())
|
||||
want = false
|
||||
}
|
||||
if want && st.AllowCount == 0 {
|
||||
st.Errors = append(st.Errors, "белый список пуст — режим обороны не включён, иначе доступ закроется всем")
|
||||
want = false
|
||||
}
|
||||
ok4 := fwRule("iptables", fwSetV4, want, &st.Errors)
|
||||
ok6 := fwRule("ip6tables", fwSetV6, want, &st.Errors)
|
||||
st.Lockdown = want && ok4 && ok6
|
||||
if st.Lockdown {
|
||||
st.Until = ctl.Until
|
||||
}
|
||||
|
||||
if err := fwApplyJailConf(ctl.Jails); err != nil {
|
||||
st.Errors = append(st.Errors, err.Error())
|
||||
}
|
||||
|
||||
done := map[string]bool{}
|
||||
for _, id := range prev.Done {
|
||||
done[id] = true
|
||||
}
|
||||
for _, a := range ctl.Actions {
|
||||
if !done[a.ID] {
|
||||
if err := fwRunAction(a); err != nil {
|
||||
st.Errors = append(st.Errors, err.Error())
|
||||
}
|
||||
done[a.ID] = true
|
||||
}
|
||||
st.Done = append(st.Done, a.ID) // только команды, ещё лежащие в fw-control.json
|
||||
}
|
||||
|
||||
for _, j := range fwJails {
|
||||
js, err := fwJail(j)
|
||||
if err != nil {
|
||||
st.Errors = append(st.Errors, err.Error())
|
||||
continue
|
||||
}
|
||||
st.Jails = append(st.Jails, js)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// fwReadAllow — белый список для ipset: доверенные установки и администраторы.
|
||||
func fwReadAllow(dir string) (v4, v6 []string) {
|
||||
seen := map[string]bool{}
|
||||
for _, name := range []string{trustedFile, fwAllowFile} {
|
||||
b, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
n, ok := normalizeNet(line)
|
||||
if !ok || seen[n] {
|
||||
continue
|
||||
}
|
||||
seen[n] = true
|
||||
if strings.Contains(n, ":") {
|
||||
v6 = append(v6, n)
|
||||
} else {
|
||||
v4 = append(v4, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
return v4, v6
|
||||
}
|
||||
|
||||
// fwSyncSet — содержимое ipset заменяется атомарно (временный набор + swap).
|
||||
func fwSyncSet(name, family string, nets []string) error {
|
||||
tmp := name + "-tmp"
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "create %s hash:net family %s -exist\n", name, family)
|
||||
fmt.Fprintf(&b, "create %s hash:net family %s -exist\nflush %s\n", tmp, family, tmp)
|
||||
for _, n := range nets {
|
||||
fmt.Fprintf(&b, "add %s %s -exist\n", tmp, n)
|
||||
}
|
||||
fmt.Fprintf(&b, "swap %s %s\ndestroy %s\n", tmp, name, tmp)
|
||||
cmd := exec.Command("ipset", "restore")
|
||||
cmd.Stdin = strings.NewReader(b.String())
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("ipset %s: %v: %s", name, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fwRuleSpec — правило обороны: 80/443 только из белого списка.
|
||||
func fwRuleSpec(set string) []string {
|
||||
return []string{"-p", "tcp", "-m", "multiport", "--dports", "80,443", "-m", "set", "!", "--match-set", set, "src",
|
||||
"-m", "comment", "--comment", fwRuleComment, "-j", "DROP"}
|
||||
}
|
||||
|
||||
// fwRule — привести наличие правила к want; true — правило в нужном состоянии.
|
||||
func fwRule(bin, set string, want bool, errs *[]string) bool {
|
||||
has := func() bool {
|
||||
return exec.Command(bin, append([]string{"-w", "-C", "INPUT"}, fwRuleSpec(set)...)...).Run() == nil
|
||||
}
|
||||
if want && !has() {
|
||||
if out, err := exec.Command(bin, append([]string{"-w", "-I", "INPUT", "1"}, fwRuleSpec(set)...)...).CombinedOutput(); err != nil {
|
||||
*errs = append(*errs, fmt.Sprintf("%s: включение обороны: %v: %s", bin, err, strings.TrimSpace(string(out))))
|
||||
return false
|
||||
}
|
||||
}
|
||||
for i := 0; !want && has() && i < 10; i++ {
|
||||
if out, err := exec.Command(bin, append([]string{"-w", "-D", "INPUT"}, fwRuleSpec(set)...)...).CombinedOutput(); err != nil {
|
||||
*errs = append(*errs, fmt.Sprintf("%s: выключение обороны: %v: %s", bin, err, strings.TrimSpace(string(out))))
|
||||
return false
|
||||
}
|
||||
}
|
||||
return want == has()
|
||||
}
|
||||
|
||||
func fwRunAction(a fwAction) error {
|
||||
ip := net.ParseIP(a.IP)
|
||||
known := false
|
||||
for _, j := range fwJails {
|
||||
known = known || j == a.Jail
|
||||
}
|
||||
if ip == nil || !known || (a.Op != "ban" && a.Op != "unban") {
|
||||
return fmt.Errorf("команда %s %s %s отклонена: неверные параметры", a.Op, a.Jail, a.IP)
|
||||
}
|
||||
out, err := exec.Command("fail2ban-client", "set", a.Jail, a.Op+"ip", ip.String()).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("fail2ban %s %s %s: %v: %s", a.Jail, a.Op, ip, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
fwJailOverride = "/etc/fail2ban/jail.d/zz-bj-license-ui.local"
|
||||
fwServerDefault = "/etc/fail2ban/fail2ban.d/zz-bj-license-ui.local"
|
||||
)
|
||||
|
||||
// fwApplyJailConf — параметры блокировок из кабинета в override fail2ban; при изменении — reload.
|
||||
func fwApplyJailConf(conf map[string]fwJailConf) error {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Параметры блокировок из кабинета сервера лицензий (раздел «Защита»). Файл пишет bj-license-server fw-apply.\n")
|
||||
var maxBan int64 = 86400
|
||||
forever := false
|
||||
for _, j := range fwJails {
|
||||
c, ok := conf[j]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if c.BanTime == -1 {
|
||||
forever = true
|
||||
} else if c.BanTime > maxBan {
|
||||
maxBan = c.BanTime
|
||||
}
|
||||
fmt.Fprintf(&b, "\n[%s]\nbantime = %d\nfindtime = %d\nmaxretry = %d\n", j, c.BanTime, c.FindTime, c.MaxRetry)
|
||||
}
|
||||
old, _ := os.ReadFile(fwJailOverride)
|
||||
if string(old) == b.String() {
|
||||
return nil
|
||||
}
|
||||
if err := os.WriteFile(fwJailOverride, []byte(b.String()), 0o644); err != nil {
|
||||
return fmt.Errorf("параметры блокировок: %w", err)
|
||||
}
|
||||
// Долгие баны должны пережить перезапуск fail2ban: база банов хранится дольше самого долгого срока.
|
||||
purge := maxBan + 86400
|
||||
if forever && purge < 5*31536000 {
|
||||
purge = 5 * 31536000
|
||||
}
|
||||
_ = os.WriteFile(fwServerDefault, []byte(fmt.Sprintf("# Файл пишет bj-license-server fw-apply.\n[Definition]\ndbpurgeage = %d\n", purge)), 0o644)
|
||||
if out, err := exec.Command("fail2ban-client", "reload").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("fail2ban reload: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
_ = exec.Command("fail2ban-client", "set", "dbpurgeage", strconv.FormatInt(purge, 10)).Run()
|
||||
return nil
|
||||
}
|
||||
|
||||
var fwNumRe = regexp.MustCompile(`(Currently failed|Total failed|Currently banned|Total banned):\s*(\d+)`)
|
||||
|
||||
func fwJail(name string) (fwJailStatus, error) {
|
||||
js := fwJailStatus{Name: name}
|
||||
out, err := exec.Command("fail2ban-client", "status", name).CombinedOutput()
|
||||
if err != nil {
|
||||
return js, fmt.Errorf("fail2ban status %s: %v: %s", name, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
text := string(out)
|
||||
for _, m := range fwNumRe.FindAllStringSubmatch(text, -1) {
|
||||
n, _ := strconv.Atoi(m[2])
|
||||
switch m[1] {
|
||||
case "Currently failed":
|
||||
js.CurrentlyFailed = n
|
||||
case "Total failed":
|
||||
js.TotalFailed = n
|
||||
case "Currently banned":
|
||||
js.CurrentlyBanned = n
|
||||
case "Total banned":
|
||||
js.TotalBanned = n
|
||||
}
|
||||
}
|
||||
if i := strings.Index(text, "Banned IP list:"); i >= 0 {
|
||||
line, _, _ := strings.Cut(text[i+len("Banned IP list:"):], "\n")
|
||||
js.Banned = strings.Fields(line)
|
||||
}
|
||||
get := func(opt string) int64 {
|
||||
out, err := exec.Command("fail2ban-client", "get", name, opt).Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64)
|
||||
return n
|
||||
}
|
||||
js.BanTime, js.FindTime, js.MaxRetry = get("bantime"), get("findtime"), int(get("maxretry"))
|
||||
return js, nil
|
||||
}
|
||||
|
||||
func chownLikeDir(dir, path string) {
|
||||
if fi, err := os.Stat(dir); err == nil {
|
||||
if s, ok := fi.Sys().(*syscall.Stat_t); ok {
|
||||
_ = os.Chown(path, int(s.Uid), int(s.Gid))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fwWriteStatus(dir string, st fwStatus) error {
|
||||
b, err := json.MarshalIndent(st, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := filepath.Join(dir, fwStatusFile+".tmp")
|
||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
chownLikeDir(dir, tmp)
|
||||
return os.Rename(tmp, filepath.Join(dir, fwStatusFile))
|
||||
}
|
||||
|
||||
// fwIgnore — `bj-license-server fw-ignore [-dir …] <ip> api|admin` для ignorecommand fail2ban:
|
||||
// код 0 — адрес не блокировать. api — доверенные установки и белый список, admin — только белый список.
|
||||
func fwIgnore(args []string) {
|
||||
fs := flag.NewFlagSet("fw-ignore", flag.ExitOnError)
|
||||
dir := fs.String("dir", "/var/lib/bj-license", "каталог данных сервера лицензий")
|
||||
_ = fs.Parse(args)
|
||||
ip, kind := fs.Arg(0), fs.Arg(1)
|
||||
if net.ParseIP(ip) == nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
files := []string{fwAllowFile}
|
||||
if kind != "admin" {
|
||||
files = append(files, trustedFile)
|
||||
}
|
||||
for _, name := range files {
|
||||
b, err := os.ReadFile(filepath.Join(*dir, name))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" && !strings.HasPrefix(line, "#") && netContains(line, ip) {
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// ---- кабинет: раздел «Защита» ----
|
||||
|
||||
type securityData struct {
|
||||
Status fwStatus
|
||||
StatusFresh bool
|
||||
Wanted FirewallState
|
||||
WantedActive bool
|
||||
Allow []AllowIP
|
||||
TrustedCount int
|
||||
Pending []fwAction
|
||||
MyIP string
|
||||
MyIPCovered bool
|
||||
Jails []string
|
||||
// Доступ к кабинету.
|
||||
Host string
|
||||
AdminBase string
|
||||
AdminDefault bool
|
||||
AdminAllowOnly bool
|
||||
AdminOpen bool
|
||||
TwoFA, Admins int
|
||||
ZKEnabled bool
|
||||
ZKHasKey bool
|
||||
ZKURL string
|
||||
ZKWithEmail int
|
||||
Limits []fwLimitRow
|
||||
BanPresets []fwPreset
|
||||
FindPresets []fwPreset
|
||||
RetryPresets []int
|
||||
LoginTries int
|
||||
LoginLockSec int64
|
||||
}
|
||||
|
||||
// fwLimitRow — строка карточки «Параметры блокировок».
|
||||
type fwLimitRow struct {
|
||||
Jail, Title string
|
||||
Conf fwJailConf
|
||||
Effective string // действующие значения в fail2ban
|
||||
HasEffective bool
|
||||
BanOpts []fwPreset
|
||||
FindOpts []fwPreset
|
||||
RetryOpts []int
|
||||
}
|
||||
|
||||
// withPreset — варианты выбора с текущим значением, даже если его нет среди стандартных.
|
||||
func withPreset(list []fwPreset, sec int64) []fwPreset {
|
||||
if fwPresetOK(list, sec) {
|
||||
return list
|
||||
}
|
||||
return append(append([]fwPreset{}, list...), fwPreset{sec, durRu(sec)})
|
||||
}
|
||||
|
||||
func withRetry(list []int, n int) []int {
|
||||
for _, v := range list {
|
||||
if v == n {
|
||||
return list
|
||||
}
|
||||
}
|
||||
return append(append([]int{}, list...), n)
|
||||
}
|
||||
|
||||
func (srv *server) dataDir() string { return filepath.Dir(srv.st.path) }
|
||||
|
||||
// fwPrune — убрать из желаемого состояния команды, которые помощник уже выполнил.
|
||||
func fwPrune(s *State, st fwStatus) {
|
||||
done := map[string]bool{}
|
||||
for _, id := range st.Done {
|
||||
done[id] = true
|
||||
}
|
||||
var keep []fwAction
|
||||
for _, a := range s.Firewall.Actions {
|
||||
if !done[a.ID] {
|
||||
keep = append(keep, a)
|
||||
}
|
||||
}
|
||||
s.Firewall.Actions = keep
|
||||
}
|
||||
|
||||
// fwWait — подождать, пока помощник применит изменения (до 5 секунд).
|
||||
func (srv *server) fwWait(since time.Time) {
|
||||
for i := 0; i < 20; i++ {
|
||||
if fwReadStatus(srv.dataDir()).At.After(since) {
|
||||
return
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *server) securityPage(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||
st := fwReadStatus(srv.dataDir())
|
||||
d := securityData{Status: st, StatusFresh: !st.At.IsZero() && time.Since(st.At) < fwStatusStale, MyIP: clientIP(r), Jails: fwJails}
|
||||
srv.st.read(func(s *State) {
|
||||
d.Wanted = s.Firewall
|
||||
d.WantedActive = s.Firewall.Mode == "lockdown" && (s.Firewall.Until.IsZero() || time.Now().Before(s.Firewall.Until))
|
||||
for _, a := range s.AllowIPs {
|
||||
d.Allow = append(d.Allow, *a)
|
||||
}
|
||||
d.TrustedCount = len(s.trustedIPs())
|
||||
done := map[string]bool{}
|
||||
for _, id := range st.Done {
|
||||
done[id] = true
|
||||
}
|
||||
for _, a := range s.Firewall.Actions {
|
||||
if !done[a.ID] {
|
||||
d.Pending = append(d.Pending, a)
|
||||
}
|
||||
}
|
||||
d.MyIPCovered = s.allowCovers(d.MyIP, false)
|
||||
d.AdminAllowOnly, d.Admins = s.AdminAllowOnly, len(s.Users)
|
||||
d.ZKEnabled, d.ZKHasKey, d.ZKURL = s.ZetitKey.Enabled, s.ZetitKey.AppKey != "", pickStr(s.ZetitKey.URL, zetitKeyDefaultURL)
|
||||
for _, u := range s.Users {
|
||||
if u.Email != "" {
|
||||
d.ZKWithEmail++
|
||||
}
|
||||
}
|
||||
for _, u := range s.Users {
|
||||
if u.TOTPSecret != "" {
|
||||
d.TwoFA++
|
||||
}
|
||||
}
|
||||
n, lock := loginLimitOf(s)
|
||||
d.LoginTries, d.LoginLockSec = n, int64(lock/time.Second)
|
||||
for _, j := range fwJails {
|
||||
c, configured := s.Firewall.Jails[j]
|
||||
if !configured {
|
||||
c = fwDefaultJail(j)
|
||||
}
|
||||
row := fwLimitRow{Jail: j, Title: fwJailTitle(j)}
|
||||
for _, js := range st.Jails {
|
||||
if js.Name == j && js.MaxRetry > 0 {
|
||||
row.HasEffective = true
|
||||
row.Effective = fmt.Sprintf("сейчас: бан %s, %d попыток за %s", durRu(js.BanTime), js.MaxRetry, durRu(js.FindTime))
|
||||
if !configured { // ещё не настраивали в кабинете — показываем то, что действует
|
||||
c = fwJailConf{BanTime: js.BanTime, FindTime: js.FindTime, MaxRetry: js.MaxRetry}
|
||||
}
|
||||
}
|
||||
}
|
||||
row.Conf = c
|
||||
row.BanOpts, row.FindOpts, row.RetryOpts = withPreset(fwBanPresets, c.BanTime), withPreset(fwFindPresets, c.FindTime), withRetry(fwRetryPresets, c.MaxRetry)
|
||||
d.Limits = append(d.Limits, row)
|
||||
}
|
||||
})
|
||||
d.BanPresets, d.FindPresets, d.RetryPresets = fwBanPresets, fwFindPresets, fwRetryPresets
|
||||
d.Host, d.AdminBase, d.AdminOpen = publicHost, srv.adminBase(), srv.adminOpen()
|
||||
d.AdminDefault = d.AdminBase == adminDefault
|
||||
srv.render(w, r, sess, "security", "Защита", "security", d)
|
||||
}
|
||||
|
||||
func fwJailTitle(j string) string {
|
||||
switch j {
|
||||
case "bj-license-api":
|
||||
return "Активация и продление (API)"
|
||||
case "bj-license-admin":
|
||||
return "Вход в кабинет (fail2ban)"
|
||||
case "sshd":
|
||||
return "SSH"
|
||||
}
|
||||
return j
|
||||
}
|
||||
|
||||
// securityLimits — POST /admin/security/limits: сроки и пороги блокировок.
|
||||
func (srv *server) securityLimits(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||
conf := map[string]fwJailConf{}
|
||||
var changes []string
|
||||
for _, j := range fwJails {
|
||||
ban, e1 := strconv.ParseInt(r.PostFormValue("ban_"+j), 10, 64)
|
||||
find, e2 := strconv.ParseInt(r.PostFormValue("find_"+j), 10, 64)
|
||||
retry, e3 := strconv.Atoi(r.PostFormValue("retry_" + j))
|
||||
if e1 != nil || e2 != nil || e3 != nil || (ban != -1 && (ban < 60 || ban > 5*31536000)) || find < 60 || find > 31536000 || retry < 1 || retry > 100 {
|
||||
back(w, r, "/admin/security", "", "неверные параметры для "+fwJailTitle(j))
|
||||
return
|
||||
}
|
||||
conf[j] = fwJailConf{BanTime: ban, FindTime: find, MaxRetry: retry}
|
||||
changes = append(changes, fmt.Sprintf("%s: бан %s, %d попыток за %s", fwJailTitle(j), durRu(ban), retry, durRu(find)))
|
||||
}
|
||||
tries, e1 := strconv.Atoi(r.PostFormValue("login_tries"))
|
||||
lock, e2 := strconv.ParseInt(r.PostFormValue("login_lock"), 10, 64)
|
||||
if e1 != nil || e2 != nil || tries < 3 || tries > 100 || lock <= 0 || !fwPresetOK(fwBanPresets, lock) {
|
||||
back(w, r, "/admin/security", "", "неверные параметры блокировки входа в кабинет")
|
||||
return
|
||||
}
|
||||
changes = append(changes, fmt.Sprintf("вход в кабинет (приложение): %d попыток, блокировка %s", tries, durRu(lock)))
|
||||
start := time.Now()
|
||||
err := srv.st.write(func(s *State) error {
|
||||
s.Firewall.Jails, s.Firewall.LoginAttempts, s.Firewall.LoginLockSec = conf, tries, lock
|
||||
s.Firewall.UpdatedBy, s.Firewall.UpdatedAt = sess.Login, start
|
||||
s.addEvent("fw_limits", "", "", sess.Login, clientIP(r), "параметры блокировок: "+strings.Join(changes, "; "))
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
back(w, r, "/admin/security", "", err.Error())
|
||||
return
|
||||
}
|
||||
srv.fails.set(tries, time.Duration(lock)*time.Second)
|
||||
srv.fwWait(start)
|
||||
back(w, r, "/admin/security", "Параметры блокировок сохранены. fail2ban перечитывает настройки, действующие значения обновятся в течение 30 секунд.", "")
|
||||
}
|
||||
|
||||
// securityMode — POST /admin/security/mode: включить (op=on, hours) или выключить (op=off) оборону.
|
||||
func (srv *server) securityMode(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||
op, myIP := r.PostFormValue("op"), clientIP(r)
|
||||
start := time.Now()
|
||||
status := fwReadStatus(srv.dataDir())
|
||||
var bad string
|
||||
err := srv.st.write(func(s *State) error {
|
||||
fwPrune(s, status)
|
||||
switch op {
|
||||
case "on":
|
||||
hours, _ := strconv.Atoi(r.PostFormValue("hours"))
|
||||
if hours < 0 || hours > 24*30 {
|
||||
bad = "неверный срок"
|
||||
return nil
|
||||
}
|
||||
if n, ok := normalizeNet(myIP); ok && !s.allowCovers(myIP, false) {
|
||||
s.AllowIPs = append(s.AllowIPs, &AllowIP{Net: n, Comment: "добавлен автоматически при включении обороны", AddedBy: sess.Login, AddedAt: start})
|
||||
s.addEvent("fw_allow_add", "", "", sess.Login, myIP, "IP "+n+" добавлен в белый список (включение обороны)")
|
||||
}
|
||||
s.Firewall.Mode, s.Firewall.Until = "lockdown", time.Time{}
|
||||
text := "включён режим обороны: на 80/443 только белый список"
|
||||
if hours > 0 {
|
||||
s.Firewall.Until = start.Add(time.Duration(hours) * time.Hour)
|
||||
text += ", до " + s.Firewall.Until.In(msk).Format("02.01.2006 15:04")
|
||||
}
|
||||
s.addEvent("fw_lockdown_on", "", "", sess.Login, myIP, text)
|
||||
_ = os.Remove(filepath.Join(srv.dataDir(), fwOffFile))
|
||||
case "off":
|
||||
s.Firewall.Mode, s.Firewall.Until = "", time.Time{}
|
||||
s.addEvent("fw_lockdown_off", "", "", sess.Login, myIP, "режим обороны выключен")
|
||||
default:
|
||||
bad = "неизвестная команда"
|
||||
return nil
|
||||
}
|
||||
s.Firewall.UpdatedBy, s.Firewall.UpdatedAt = sess.Login, start
|
||||
return nil
|
||||
})
|
||||
if err != nil || bad != "" {
|
||||
back(w, r, "/admin/security", "", pickStr(bad, fmt.Sprint(err)))
|
||||
return
|
||||
}
|
||||
srv.fwWait(start)
|
||||
if op == "on" {
|
||||
back(w, r, "/admin/security", "Режим обороны включается: доступ к сайту и API — только из белого списка.", "")
|
||||
return
|
||||
}
|
||||
back(w, r, "/admin/security", "Режим обороны выключен: доступ открыт, fail2ban работает как обычно.", "")
|
||||
}
|
||||
|
||||
// securityAllow — POST /admin/security/allow: добавить (op=add) или убрать (op=remove) адрес белого списка.
|
||||
func (srv *server) securityAllow(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||
op, myIP := r.PostFormValue("op"), clientIP(r)
|
||||
start := time.Now()
|
||||
var bad, okMsg string
|
||||
err := srv.st.write(func(s *State) error {
|
||||
switch op {
|
||||
case "add":
|
||||
n, ok := normalizeNet(r.PostFormValue("net"))
|
||||
if !ok {
|
||||
bad = "укажите IP или сеть (IPv4 не шире /16, IPv6 не шире /32)"
|
||||
return nil
|
||||
}
|
||||
for _, a := range s.AllowIPs {
|
||||
if a.Net == n {
|
||||
bad = n + " уже в белом списке"
|
||||
return nil
|
||||
}
|
||||
}
|
||||
s.AllowIPs = append(s.AllowIPs, &AllowIP{Net: n, Comment: clip(r.PostFormValue("comment"), 120), AddedBy: sess.Login, AddedAt: start})
|
||||
s.addEvent("fw_allow_add", "", "", sess.Login, myIP, "IP "+n+" добавлен в белый список")
|
||||
okMsg = n + " добавлен в белый список"
|
||||
case "remove":
|
||||
n := strings.TrimSpace(r.PostFormValue("net"))
|
||||
var keep []*AllowIP
|
||||
for _, a := range s.AllowIPs {
|
||||
if a.Net != n {
|
||||
keep = append(keep, a)
|
||||
}
|
||||
}
|
||||
if len(keep) == len(s.AllowIPs) {
|
||||
bad = "адрес не найден в белом списке"
|
||||
return nil
|
||||
}
|
||||
lockdown := s.Firewall.Mode == "lockdown" && (s.Firewall.Until.IsZero() || start.Before(s.Firewall.Until))
|
||||
if (lockdown || s.AdminAllowOnly) && netContains(n, myIP) {
|
||||
rest := &State{AllowIPs: keep}
|
||||
if !rest.allowCovers(myIP, false) {
|
||||
bad = n + " покрывает ваш адрес " + myIP + ", а кабинет сейчас ограничен белым списком: после удаления доступ закроется. Сначала снимите ограничение или выключите оборону."
|
||||
return nil
|
||||
}
|
||||
}
|
||||
s.AllowIPs = keep
|
||||
s.addEvent("fw_allow_remove", "", "", sess.Login, myIP, "IP "+n+" убран из белого списка")
|
||||
okMsg = n + " убран из белого списка"
|
||||
default:
|
||||
bad = "неизвестная команда"
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil || bad != "" {
|
||||
back(w, r, "/admin/security", "", pickStr(bad, fmt.Sprint(err)))
|
||||
return
|
||||
}
|
||||
srv.fwWait(start)
|
||||
back(w, r, "/admin/security", okMsg, "")
|
||||
}
|
||||
|
||||
// securityBan — POST /admin/security/ban: заблокировать (op=ban) или разблокировать (op=unban) IP в jail.
|
||||
func (srv *server) securityBan(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||
op, jail, myIP := r.PostFormValue("op"), r.PostFormValue("jail"), clientIP(r)
|
||||
ip := net.ParseIP(strings.TrimSpace(r.PostFormValue("ip")))
|
||||
known := false
|
||||
for _, j := range fwJails {
|
||||
known = known || j == jail
|
||||
}
|
||||
switch {
|
||||
case ip == nil:
|
||||
back(w, r, "/admin/security", "", "укажите IP-адрес")
|
||||
return
|
||||
case !known || (op != "ban" && op != "unban"):
|
||||
back(w, r, "/admin/security", "", "неверная команда")
|
||||
return
|
||||
case op == "ban" && ip.String() == net.ParseIP(myIP).String():
|
||||
back(w, r, "/admin/security", "", "это ваш текущий адрес — блокировать его нельзя")
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
status := fwReadStatus(srv.dataDir())
|
||||
err := srv.st.write(func(s *State) error {
|
||||
fwPrune(s, status)
|
||||
s.Firewall.Actions = append(s.Firewall.Actions, fwAction{ID: randHex(8), Op: op, Jail: jail, IP: ip.String(), At: start, By: sess.Login})
|
||||
verb := "заблокирован"
|
||||
if op == "unban" {
|
||||
verb = "разблокирован"
|
||||
}
|
||||
s.addEvent("fw_"+op, "", "", sess.Login, myIP, "IP "+ip.String()+" "+verb+" вручную в "+jail)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
back(w, r, "/admin/security", "", err.Error())
|
||||
return
|
||||
}
|
||||
srv.fwWait(start)
|
||||
if op == "ban" {
|
||||
back(w, r, "/admin/security", "IP "+ip.String()+" заблокирован в "+jail+" (на срок bantime jail).", "")
|
||||
return
|
||||
}
|
||||
back(w, r, "/admin/security", "IP "+ip.String()+" разблокирован в "+jail+".", "")
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
// Command bj-license-server — сервер лицензирования Bridge&Join (https://lic.zetit.ru).
|
||||
//
|
||||
// Выдаёт и продлевает лицензии установкам bj по коду активации (контракт —
|
||||
// docs/license-server-api.md) и даёт веб-кабинет администратора: клиенты, лицензии,
|
||||
// установки, журнал, администраторы. Вход в кабинет — логин и пароль.
|
||||
//
|
||||
// Хранилище — один JSON-файл с атомарной записью и ежедневными копиями рядом: клиентов
|
||||
// десятки, записей немного, отдельная СУБД не нужна.
|
||||
//
|
||||
// bj-license-server serve -addr 127.0.0.1:8091 -data /var/lib/bj-license/state.json -key /etc/bj-license/license.priv
|
||||
// bj-license-server user-set -data /var/lib/bj-license/state.json -login admin
|
||||
// (пароль из stdin; пусто — сгенерировать; сервер должен быть остановлен)
|
||||
// bj-license-server fw-apply -dir /var/lib/bj-license [-off] (root, из systemd: защита, см. firewall.go)
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/ed25519"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
}
|
||||
switch os.Args[1] {
|
||||
case "serve":
|
||||
serve(os.Args[2:])
|
||||
case "user-set":
|
||||
userSet(os.Args[2:])
|
||||
case "fw-apply":
|
||||
fwApply(os.Args[2:])
|
||||
case "fw-ignore":
|
||||
fwIgnore(os.Args[2:])
|
||||
case "admin-info":
|
||||
adminInfo(os.Args[2:])
|
||||
case "admin-config":
|
||||
adminConfig(os.Args[2:])
|
||||
default:
|
||||
usage()
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, "bj-license-server serve [-addr 127.0.0.1:8091] [-data state.json] [-key license.priv] [-keyid main]")
|
||||
fmt.Fprintln(os.Stderr, "bj-license-server user-set -login admin [-name Имя] [-data state.json] (пароль из stdin)")
|
||||
fmt.Fprintln(os.Stderr, "bj-license-server fw-apply [-dir /var/lib/bj-license] [-off] (root: применить защиту; -off — аварийно выключить оборону)")
|
||||
fmt.Fprintln(os.Stderr, "bj-license-server fw-ignore [-dir /var/lib/bj-license] <ip> api|admin (ignorecommand fail2ban)")
|
||||
fmt.Fprintln(os.Stderr, "bj-license-server admin-info [-data state.json] (адрес кабинета, белый список, второй фактор)")
|
||||
fmt.Fprintln(os.Stderr, "bj-license-server admin-config [-data state.json] [-allow IP=комментарий ...] [-path свой|random|admin] [-allow-only on|off] [-reset-2fa логин] (сервер должен быть остановлен)")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
func serve(args []string) {
|
||||
fs := flag.NewFlagSet("serve", flag.ExitOnError)
|
||||
addr := fs.String("addr", "127.0.0.1:8091", "адрес прослушивания (снаружи — через nginx)")
|
||||
data := fs.String("data", "/var/lib/bj-license/state.json", "файл состояния")
|
||||
keyPath := fs.String("key", "/etc/bj-license/license.priv", "закрытый ключ подписи лицензий (base64 seed Ed25519)")
|
||||
keyID := fs.String("keyid", "main", "идентификатор ключа в токене")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
priv, err := license.LoadPrivateKey(*keyPath)
|
||||
if err != nil {
|
||||
log.Fatalf("ключ подписи: %v", err)
|
||||
}
|
||||
st, err := openStore(*data)
|
||||
if err != nil {
|
||||
log.Fatalf("хранилище: %v", err)
|
||||
}
|
||||
srv := &server{
|
||||
st: st,
|
||||
priv: priv,
|
||||
pub: priv.Public().(ed25519.PublicKey),
|
||||
keyID: *keyID,
|
||||
sessions: newSessions(),
|
||||
fails: newLimiter(8, 15*time.Minute),
|
||||
apiRate: newLimiter(60, time.Minute),
|
||||
}
|
||||
st.read(func(s *State) { srv.fails.set(loginLimitOf(s)) })
|
||||
if err := srv.loadTemplates(); err != nil {
|
||||
log.Fatalf("шаблоны: %v", err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
srv.routes(mux)
|
||||
go st.backupLoop()
|
||||
|
||||
st.read(func(s *State) {
|
||||
log.Printf("bj-license-server: слушаю %s; клиентов %d, лицензий %d, установок %d, администраторов %d",
|
||||
*addr, len(s.Tenants), len(s.Licenses), len(s.Installs), len(s.Users))
|
||||
if len(s.Users) == 0 {
|
||||
log.Printf("bj-license-server: администраторов нет — создайте: bj-license-server user-set -login admin")
|
||||
}
|
||||
})
|
||||
hs := &http.Server{
|
||||
Addr: *addr,
|
||||
Handler: securityHeaders(srv.adminGate(mux)),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
}
|
||||
log.Fatal(hs.ListenAndServe())
|
||||
}
|
||||
|
||||
func userSet(args []string) {
|
||||
fs := flag.NewFlagSet("user-set", flag.ExitOnError)
|
||||
data := fs.String("data", "/var/lib/bj-license/state.json", "файл состояния")
|
||||
login := fs.String("login", "", "логин администратора")
|
||||
name := fs.String("name", "", "имя (для журнала)")
|
||||
_ = fs.Parse(args)
|
||||
if !loginRe.MatchString(*login) {
|
||||
log.Fatal("логин: латиница, цифры, точка, дефис, подчёркивание")
|
||||
}
|
||||
st, err := openStore(*data)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
pw, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
pw = strings.TrimRight(pw, "\r\n")
|
||||
generated := false
|
||||
if pw == "" {
|
||||
pw, generated = randomPassword(16), true
|
||||
}
|
||||
if len(pw) < 10 {
|
||||
log.Fatal("пароль короче 10 символов")
|
||||
}
|
||||
if err := st.write(func(s *State) error {
|
||||
return s.setUserPassword(*login, *name, pw, "cli")
|
||||
}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if generated {
|
||||
fmt.Printf("Администратор %s: пароль %s\n", *login, pw)
|
||||
} else {
|
||||
fmt.Printf("Администратор %s: пароль задан\n", *login)
|
||||
}
|
||||
}
|
||||
@@ -1,460 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Признаки лицензии, которые понимает bj (internal/lkgateway/licensecheck.go).
|
||||
var knownFeatures = []struct{ Key, Title string }{
|
||||
{"updates", "Обновления"},
|
||||
{"web-cabinet", "Личный кабинет клиента (bj-lk)"},
|
||||
{"quik-mnp", "QUIK: неторговые поручения (M2M)"},
|
||||
{"quik-export", "QUIK: экспорт биржевой информации"},
|
||||
{"npr", "Риск-монитор НПР / УДС"},
|
||||
}
|
||||
|
||||
var knownPlans = []struct{ Key, Title string }{
|
||||
{"enterprise", "Enterprise"},
|
||||
{"pro", "Pro"},
|
||||
{"free", "Free"},
|
||||
}
|
||||
|
||||
const maxEvents = 20000
|
||||
|
||||
var (
|
||||
loginRe = regexp.MustCompile(`^[A-Za-z0-9_.\-]{2,40}$`)
|
||||
uuidRe = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
|
||||
innRe = regexp.MustCompile(`^(\d{10}|\d{12})$`)
|
||||
)
|
||||
|
||||
type User struct {
|
||||
Login string `json:"login"`
|
||||
Name string `json:"name,omitempty"`
|
||||
PassHash string `json:"pass_hash"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastLogin time.Time `json:"last_login,omitempty"`
|
||||
// Второй фактор (TOTP): секрет base32 и последний принятый шаг — один код не принимается дважды.
|
||||
TOTPSecret string `json:"totp_secret,omitempty"`
|
||||
TOTPLast int64 `json:"totp_last,omitempty"`
|
||||
HasTOTP bool `json:"-"` // для шаблонов: секрет в представления не отдаётся
|
||||
Email string `json:"email,omitempty"` // учётная запись в «ЗЕТИТ Ключ» (zetitkey.go)
|
||||
}
|
||||
|
||||
type Tenant struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
INN string `json:"inn,omitempty"`
|
||||
Contact string `json:"contact,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type License struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Plan string `json:"plan"`
|
||||
Features []string `json:"features,omitempty"`
|
||||
MaxNodes int `json:"max_nodes"`
|
||||
ContractUntil time.Time `json:"contract_until"`
|
||||
Code string `json:"code"`
|
||||
Note string `json:"note,omitempty"`
|
||||
RevokedAt time.Time `json:"revoked_at,omitempty"`
|
||||
RevokeReason string `json:"revoke_reason,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CreatedBy string `json:"created_by,omitempty"`
|
||||
}
|
||||
|
||||
func (l License) Revoked() bool { return !l.RevokedAt.IsZero() }
|
||||
|
||||
func (l License) ContractExpired(now time.Time) bool {
|
||||
return !l.ContractUntil.IsZero() && now.After(l.ContractUntil)
|
||||
}
|
||||
|
||||
func (l License) HasFeature(f string) bool {
|
||||
for _, x := range l.Features {
|
||||
if x == f {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type Install struct {
|
||||
InstallID string `json:"install_id"`
|
||||
LicenseID string `json:"license_id"`
|
||||
Machine string `json:"machine,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
TokenExpires time.Time `json:"token_expires"`
|
||||
Blocked bool `json:"blocked,omitempty"`
|
||||
BlockReason string `json:"block_reason,omitempty"`
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
At time.Time `json:"at"`
|
||||
Kind string `json:"kind"`
|
||||
LicenseID string `json:"license_id,omitempty"`
|
||||
InstallID string `json:"install_id,omitempty"`
|
||||
Actor string `json:"actor,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// TrustedIP — адрес установки, которая успешно активировала или продлила лицензию. fail2ban
|
||||
// не блокирует такие адреса за ошибки API (jail bj-license-api, ignorecommand по trusted-ips.txt);
|
||||
// на вход в кабинет доверие не распространяется.
|
||||
type TrustedIP struct {
|
||||
IP string `json:"ip"`
|
||||
LicenseID string `json:"license_id"`
|
||||
InstallID string `json:"install_id"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
AddedAt time.Time `json:"added_at"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
}
|
||||
|
||||
type State struct {
|
||||
Users []*User `json:"users"`
|
||||
Tenants []*Tenant `json:"tenants"`
|
||||
Licenses []*License `json:"licenses"`
|
||||
Installs []*Install `json:"installs"`
|
||||
Events []*Event `json:"events"`
|
||||
Trusted []*TrustedIP `json:"trusted,omitempty"`
|
||||
// Защита (раздел «Защита»): белый список администраторов и желаемый режим.
|
||||
AllowIPs []*AllowIP `json:"allow_ips,omitempty"`
|
||||
Firewall FirewallState `json:"firewall"`
|
||||
// Доступ к кабинету (access.go): свой адрес вместо /admin и вход только из белого списка.
|
||||
AdminPath string `json:"admin_path,omitempty"`
|
||||
AdminAllowOnly bool `json:"admin_allow_only,omitempty"`
|
||||
// Второй фактор через «ЗЕТИТ Ключ» (zetitkey.go).
|
||||
ZetitKey ZetitKeyConf `json:"zetit_key,omitempty"`
|
||||
}
|
||||
|
||||
// trustedFile — список доверенных IP установок для fail2ban и режима обороны.
|
||||
const trustedFile = "trusted-ips.txt"
|
||||
|
||||
type store struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
s State
|
||||
}
|
||||
|
||||
func openStore(path string) (*store, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st := &store{path: path}
|
||||
b, err := os.ReadFile(path)
|
||||
switch {
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
return st, nil
|
||||
case err != nil:
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(b, &st.s); err != nil {
|
||||
return nil, fmt.Errorf("разбор %s: %w", path, err)
|
||||
}
|
||||
if err := st.writeTrustedLocked(); err != nil {
|
||||
log.Printf("доверенные IP: %v", err)
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// trustedPath — список доверенных IP для fail2ban, рядом с файлом состояния.
|
||||
func (st *store) trustedPath() string {
|
||||
return filepath.Join(filepath.Dir(st.path), trustedFile)
|
||||
}
|
||||
|
||||
// writeTrustedLocked — переписывает trusted-ips.txt (по адресу в строке) и файлы защиты; вызывать под блокировкой.
|
||||
func (st *store) writeTrustedLocked() error {
|
||||
body := "# Доверенные IP установок bj (успешная активация или продление лицензии). Файл пишет bj-license-server.\n"
|
||||
for _, ip := range st.s.trustedIPs() {
|
||||
body += ip + "\n"
|
||||
}
|
||||
if err := writeIfChanged(st.trustedPath(), []byte(body), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return st.writeFirewallLocked()
|
||||
}
|
||||
|
||||
func (st *store) saveLocked() error {
|
||||
b, err := json.MarshalIndent(&st.s, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := st.path + ".tmp"
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := f.Write(b); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, st.path); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := st.writeTrustedLocked(); err != nil {
|
||||
log.Printf("доверенные IP: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// write — изменение состояния под блокировкой; сохраняется, если fn не вернула ошибку.
|
||||
func (st *store) write(fn func(s *State) error) error {
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
if err := fn(&st.s); err != nil {
|
||||
return err
|
||||
}
|
||||
return st.saveLocked()
|
||||
}
|
||||
|
||||
func (st *store) read(fn func(s *State)) {
|
||||
st.mu.RLock()
|
||||
defer st.mu.RUnlock()
|
||||
fn(&st.s)
|
||||
}
|
||||
|
||||
// backupLoop — раз в сутки копия state.json рядом (state.json.YYYYMMDD), хранится 30 копий.
|
||||
func (st *store) backupLoop() {
|
||||
for {
|
||||
st.mu.RLock()
|
||||
b, err := json.Marshal(&st.s)
|
||||
st.mu.RUnlock()
|
||||
if err == nil {
|
||||
name := st.path + "." + time.Now().Format("20060102")
|
||||
if _, e := os.Stat(name); errors.Is(e, os.ErrNotExist) {
|
||||
if e := os.WriteFile(name, b, 0o600); e != nil {
|
||||
log.Printf("резервная копия: %v", e)
|
||||
}
|
||||
}
|
||||
old, _ := filepath.Glob(st.path + ".2*")
|
||||
sort.Strings(old)
|
||||
for len(old) > 30 {
|
||||
_ = os.Remove(old[0])
|
||||
old = old[1:]
|
||||
}
|
||||
}
|
||||
time.Sleep(6 * time.Hour)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- выборки и изменения (вызывать внутри read/write) ----
|
||||
|
||||
// trustIP — адрес установки в доверенные после успешной активации или продления.
|
||||
// Возвращает true, если для этой установки адрес новый.
|
||||
func (s *State) trustIP(inst *Install, ip string, now time.Time) bool {
|
||||
p := net.ParseIP(strings.TrimSpace(ip))
|
||||
if p == nil || p.IsLoopback() || p.IsUnspecified() {
|
||||
return false
|
||||
}
|
||||
ip = p.String()
|
||||
for _, t := range s.Trusted {
|
||||
if t.IP == ip && t.InstallID == inst.InstallID {
|
||||
t.LicenseID, t.Hostname, t.LastSeen = inst.LicenseID, inst.Hostname, now
|
||||
return false
|
||||
}
|
||||
}
|
||||
s.Trusted = append(s.Trusted, &TrustedIP{IP: ip, LicenseID: inst.LicenseID, InstallID: inst.InstallID,
|
||||
Hostname: inst.Hostname, AddedAt: now, LastSeen: now})
|
||||
return true
|
||||
}
|
||||
|
||||
// untrust — убирает доверенные адреса, подходящие под условие; возвращает убранные.
|
||||
func (s *State) untrust(match func(t *TrustedIP) bool) []*TrustedIP {
|
||||
var keep, gone []*TrustedIP
|
||||
for _, t := range s.Trusted {
|
||||
if match(t) {
|
||||
gone = append(gone, t)
|
||||
} else {
|
||||
keep = append(keep, t)
|
||||
}
|
||||
}
|
||||
s.Trusted = keep
|
||||
return gone
|
||||
}
|
||||
|
||||
// trustedIPs — адреса для fail2ban: без отозванных лицензий и заблокированных установок.
|
||||
func (s *State) trustedIPs() []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, t := range s.Trusted {
|
||||
if l := s.license(t.LicenseID); l == nil || l.Revoked() {
|
||||
continue
|
||||
}
|
||||
if i := s.install(t.InstallID); i == nil || i.Blocked {
|
||||
continue
|
||||
}
|
||||
if !seen[t.IP] {
|
||||
seen[t.IP] = true
|
||||
out = append(out, t.IP)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *State) user(login string) *User {
|
||||
for _, u := range s.Users {
|
||||
if strings.EqualFold(u.Login, login) {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) tenant(id string) *Tenant {
|
||||
for _, t := range s.Tenants {
|
||||
if t.ID == id {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) license(id string) *License {
|
||||
for _, l := range s.Licenses {
|
||||
if l.ID == id {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) licenseByCode(code string) *License {
|
||||
for _, l := range s.Licenses {
|
||||
if l.Code == code {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) install(id string) *Install {
|
||||
for _, i := range s.Installs {
|
||||
if strings.EqualFold(i.InstallID, id) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) installsOf(licenseID string) []*Install {
|
||||
var out []*Install
|
||||
for _, i := range s.Installs {
|
||||
if i.LicenseID == licenseID {
|
||||
out = append(out, i)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(a, b int) bool { return out[a].LastSeen.After(out[b].LastSeen) })
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *State) activeInstalls(licenseID string) int {
|
||||
n := 0
|
||||
for _, i := range s.Installs {
|
||||
if i.LicenseID == licenseID && !i.Blocked {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *State) addEvent(kind, licenseID, installID, actor, ip, text string) {
|
||||
s.Events = append(s.Events, &Event{At: time.Now(), Kind: kind, LicenseID: licenseID, InstallID: installID, Actor: actor, IP: ip, Text: text})
|
||||
if len(s.Events) > maxEvents {
|
||||
s.Events = append([]*Event(nil), s.Events[len(s.Events)-maxEvents:]...)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *State) setUserPassword(login, name, password, actor string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u := s.user(login)
|
||||
if u == nil {
|
||||
u = &User{Login: login, Name: name, CreatedAt: time.Now()}
|
||||
s.Users = append(s.Users, u)
|
||||
s.addEvent("user_add", "", "", actor, "", "добавлен администратор "+login)
|
||||
} else {
|
||||
s.addEvent("user_password", "", "", actor, "", "сменён пароль администратора "+login)
|
||||
}
|
||||
if name != "" {
|
||||
u.Name = name
|
||||
}
|
||||
u.PassHash = string(hash)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- идентификаторы ----
|
||||
|
||||
func newID() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
b[6] = b[6]&0x0f | 0x40
|
||||
b[8] = b[8]&0x3f | 0x80
|
||||
h := hex.EncodeToString(b)
|
||||
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:]
|
||||
}
|
||||
|
||||
const codeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // без 0/O и 1/I
|
||||
|
||||
func randomFrom(alphabet string, n int) string {
|
||||
var sb strings.Builder
|
||||
max := big.NewInt(int64(len(alphabet)))
|
||||
for i := 0; i < n; i++ {
|
||||
k, _ := rand.Int(rand.Reader, max)
|
||||
sb.WriteByte(alphabet[k.Int64()])
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// newActivationCode — BJ-XXXX-XXXX-XXXX (60 бит случайности).
|
||||
func newActivationCode() string {
|
||||
c := randomFrom(codeAlphabet, 12)
|
||||
return "BJ-" + c[0:4] + "-" + c[4:8] + "-" + c[8:12]
|
||||
}
|
||||
|
||||
// normalizeCode — код в каноническом виде: верхний регистр, дефисы по местам.
|
||||
func normalizeCode(s string) string {
|
||||
s = strings.ToUpper(strings.NewReplacer("-", "", " ", "").Replace(strings.TrimSpace(s)))
|
||||
if len(s) == 14 && strings.HasPrefix(s, "BJ") {
|
||||
s = s[2:]
|
||||
}
|
||||
if len(s) != 12 {
|
||||
return ""
|
||||
}
|
||||
return "BJ-" + s[0:4] + "-" + s[4:8] + "-" + s[8:12]
|
||||
}
|
||||
|
||||
func randomPassword(n int) string {
|
||||
return randomFrom("abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789", n)
|
||||
}
|
||||
@@ -1,484 +0,0 @@
|
||||
{{define "style"}}<style>
|
||||
:root{--bg:#f4f5f9;--card:#fff;--card2:#f8f9fc;--text:#1a1d26;--muted:#6b7280;--accent:#3b4fd8;--accent2:#e8562a;--border:#e3e5ee;--ok:#15803d;--warn:#b45309;--bad:#b91c1c;--okbg:#e8f6ee;--warnbg:#fdf3e2;--badbg:#fdecec}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#131620;--card:#1c202d;--card2:#232838;--text:#e6e8f0;--muted:#9aa0b3;--accent:#8190ff;--border:#2d3244;--ok:#4ade80;--warn:#fbbf24;--bad:#f87171;--okbg:#16301f;--warnbg:#33280f;--badbg:#3a1a1a}}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
||||
a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
|
||||
header{background:var(--card);border-bottom:1px solid var(--border);padding:0 20px;display:flex;align-items:center;gap:24px;flex-wrap:wrap;min-height:56px}
|
||||
.brand{display:flex;align-items:center;gap:9px;font-weight:700;font-size:16px;white-space:nowrap;color:var(--text)}
|
||||
.brand em{font-style:normal;color:var(--accent)}.brand small{font-weight:500;color:var(--muted);font-size:13px}
|
||||
nav{display:flex;gap:4px;flex-wrap:wrap;flex:1}
|
||||
nav a{padding:7px 11px;border-radius:8px;color:var(--muted);font-weight:500}
|
||||
nav a.on{background:var(--card2);color:var(--text)}nav a:hover{text-decoration:none;color:var(--text)}
|
||||
.me{display:flex;align-items:center;gap:10px;color:var(--muted);font-size:13px}
|
||||
main{max-width:1180px;margin:0 auto;padding:22px 20px 60px}
|
||||
h1{font-size:22px;margin:0 0 16px}h2{font-size:16px;margin:0 0 12px}
|
||||
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin-bottom:16px}
|
||||
.grid{display:grid;gap:16px;grid-template-columns:repeat(auto-fit,minmax(240px,1fr))}
|
||||
.stats{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));margin-bottom:16px}
|
||||
.stat{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:14px 16px}
|
||||
.stat b{display:block;font-size:26px;line-height:1.2}.stat span{color:var(--muted);font-size:13px}
|
||||
.table-wrap{overflow-x:auto}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th{text-align:left;font-size:12px;font-weight:600;color:var(--muted);padding:8px 10px;border-bottom:1px solid var(--border);white-space:nowrap}
|
||||
td{padding:9px 10px;border-bottom:1px solid var(--border);vertical-align:top}
|
||||
tr:last-child td{border-bottom:0}
|
||||
.muted{color:var(--muted)}.small{font-size:12px}
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px;font-weight:600;white-space:nowrap}
|
||||
.badge.ok{background:var(--okbg);color:var(--ok)}.badge.warn{background:var(--warnbg);color:var(--warn)}.badge.bad{background:var(--badbg);color:var(--bad)}
|
||||
.chip{display:inline-block;padding:1px 7px;margin:1px 2px 1px 0;border:1px solid var(--border);border-radius:6px;font-size:12px;background:var(--card2)}
|
||||
.flash{padding:10px 14px;border-radius:10px;margin-bottom:16px;font-weight:500}
|
||||
.flash.ok{background:var(--okbg);color:var(--ok)}.flash.err{background:var(--badbg);color:var(--bad)}
|
||||
form.f{display:grid;gap:12px}
|
||||
label{display:block;font-size:12px;color:var(--muted);margin-bottom:4px}
|
||||
input[type=text],input[type=password],input[type=number],input[type=date],select,textarea{width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--text);font:inherit}
|
||||
textarea{min-height:64px;resize:vertical}
|
||||
input:focus,select:focus,textarea:focus{outline:2px solid var(--accent);outline-offset:1px;border-color:transparent}
|
||||
.row2{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(200px,1fr))}
|
||||
.checks{display:flex;flex-wrap:wrap;gap:6px 16px}.checks label{display:flex;gap:6px;align-items:center;font-size:14px;color:var(--text);margin:0}
|
||||
.btn{display:inline-block;padding:8px 14px;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--text);font:inherit;font-weight:600;cursor:pointer}
|
||||
.btn:hover{background:var(--card2);text-decoration:none}
|
||||
.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}.btn.primary:hover{filter:brightness(1.07)}
|
||||
.btn.danger{color:var(--bad)}.btn.sm{padding:4px 9px;font-size:12px}
|
||||
.actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
||||
.code{font:600 20px/1.3 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.06em;padding:10px 14px;background:var(--card2);border:1px dashed var(--border);border-radius:10px;display:inline-block;user-select:all}
|
||||
.token{font:12px/1.4 ui-monospace,monospace;word-break:break-all;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:10px;user-select:all}
|
||||
.kv{display:grid;grid-template-columns:minmax(140px,max-content) 1fr;gap:6px 16px}.kv dt{color:var(--muted)}.kv dd{margin:0}
|
||||
details summary{cursor:pointer;font-weight:600}
|
||||
.mono{font-family:ui-monospace,monospace;font-size:12px}
|
||||
.none::before{content:"\2014";color:var(--muted)} /* пустое значение */
|
||||
@media (max-width:640px){header{padding:8px 16px;gap:8px}main{padding:16px}.kv{grid-template-columns:1fr}}
|
||||
</style>{{end}}
|
||||
|
||||
{{define "logo"}}<svg width="24" height="24" viewBox="0 0 26 26" fill="none" aria-hidden="true"><path d="M5.5 16 C 9 8.5, 17 8.5, 20.5 16" stroke="#3b4fd8" stroke-width="1.9" stroke-linecap="round"/><circle cx="5.5" cy="16" r="3.1" fill="#3b4fd8"/><circle cx="20.5" cy="16" r="3.1" fill="#e8562a"/></svg>{{end}}
|
||||
|
||||
{{define "head"}}<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · лицензии Bridge&Join</title>{{template "style"}}</head><body>
|
||||
<header>
|
||||
<a class="brand" href="{{adm}}/">{{template "logo"}}<span>Bridge<em>&Join</em></span><small>лицензии</small></a>
|
||||
<nav>
|
||||
<a href="{{adm}}/" {{if eq .Nav "dash"}}class="on"{{end}}>Обзор</a>
|
||||
<a href="{{adm}}/tenants" {{if eq .Nav "tenants"}}class="on"{{end}}>Клиенты</a>
|
||||
<a href="{{adm}}/licenses" {{if eq .Nav "licenses"}}class="on"{{end}}>Лицензии</a>
|
||||
<a href="{{adm}}/installs" {{if eq .Nav "installs"}}class="on"{{end}}>Установки</a>
|
||||
<a href="{{adm}}/security" {{if eq .Nav "security"}}class="on"{{end}}>Защита</a>
|
||||
<a href="{{adm}}/events" {{if eq .Nav "events"}}class="on"{{end}}>Журнал</a>
|
||||
<a href="{{adm}}/users" {{if eq .Nav "users"}}class="on"{{end}}>Администраторы</a>
|
||||
</nav>
|
||||
<div class="me">{{if .Me}}<span>{{if .Me.Name}}{{.Me.Name}}{{else}}{{.Me.Login}}{{end}}</span>
|
||||
<form method="post" action="{{adm}}/logout" style="margin:0"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="btn sm" type="submit">Выйти</button></form>{{end}}</div>
|
||||
</header>
|
||||
<main>
|
||||
{{if .OK}}<div class="flash ok">{{.OK}}</div>{{end}}
|
||||
{{if .Err}}<div class="flash err">{{.Err}}</div>{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "foot"}}</main></body></html>{{end}}
|
||||
|
||||
{{define "login"}}<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Вход · лицензии Bridge&Join</title>{{template "style"}}</head>
|
||||
<body style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:16px">
|
||||
<div class="card" style="width:100%;max-width:380px;padding:28px 30px">
|
||||
<div class="brand" style="font-size:20px;margin-bottom:4px">{{template "logo"}}<span>Bridge<em>&Join</em></span></div>
|
||||
<p class="muted" style="margin:0 0 18px;font-size:13px">Сервер лицензирования: вход администратора</p>
|
||||
<form class="f" method="post" action="{{adm}}/login">
|
||||
<input type="hidden" name="next" value="{{.Data.Next}}">
|
||||
<div><label for="login">Логин</label><input id="login" type="text" name="login" value="{{.Data.Login}}" autocomplete="username" autofocus required></div>
|
||||
<div><label for="password">Пароль</label><input id="password" type="password" name="password" autocomplete="current-password" required></div>
|
||||
<div><label for="code">Код из приложения <span class="muted" style="font-weight:400">(если подключён второй фактор)</span></label><input id="code" type="text" name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6"></div>
|
||||
<button class="btn primary" type="submit" style="width:100%;padding:10px">Войти</button>
|
||||
{{if .Err}}<div class="flash err" style="margin:0">{{.Err}}</div>{{end}}
|
||||
</form>
|
||||
</div></body></html>{{end}}
|
||||
|
||||
{{define "login2fa"}}<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Подтверждение входа · лицензии Bridge&Join</title>{{template "style"}}</head>
|
||||
<body style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:16px">
|
||||
<div class="card" style="width:100%;max-width:400px;padding:28px 30px">
|
||||
<div class="brand" style="font-size:20px;margin-bottom:4px">{{template "logo"}}<span>Bridge<em>&Join</em></span></div>
|
||||
<h2 style="margin:10px 0 6px">Подтвердите вход</h2>
|
||||
<p class="muted" style="margin:0 0 6px;font-size:13px" id="zk-msg">Запрос отправлен в приложение «ЗЕТИТ Ключ» ({{.Data.Email}}). Подтвердите вход на телефоне, и страница продолжит сама.</p>
|
||||
<p style="font-size:30px;font-weight:700;margin:4px 0 14px" id="zk-count">{{.Data.Left}}</p>
|
||||
<form id="zk-finish" method="post" action="{{adm}}/login/2fa/finish"><input type="hidden" name="t" value="{{.Data.Token}}"></form>
|
||||
<form class="f" method="post" action="{{adm}}/login/2fa/code">
|
||||
<input type="hidden" name="t" value="{{.Data.Token}}">
|
||||
<div><label for="code">Или введите код из приложения</label><input id="code" type="text" name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required></div>
|
||||
<button class="btn primary" type="submit" style="width:100%;padding:10px">Войти с кодом</button>
|
||||
{{if .Err}}<div class="flash err" style="margin:0">{{.Err}}</div>{{end}}
|
||||
</form>
|
||||
<p class="small" style="margin:14px 0 0"><a href="{{adm}}/login">Отмена</a></p>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var token = {{.Data.Token}}, left = {{.Data.Left}}, base = {{adm}}, done = false;
|
||||
var cnt = document.getElementById('zk-count'), msg = document.getElementById('zk-msg');
|
||||
function expired(){ msg.textContent = 'Время подтверждения истекло. Введите код из приложения или войдите заново.'; }
|
||||
var tick = setInterval(function(){
|
||||
left = Math.max(0, left - 1); cnt.textContent = left;
|
||||
if (left === 0) { clearInterval(tick); if (!done) expired(); }
|
||||
}, 1000);
|
||||
function poll(){
|
||||
if (done || left <= 0) return;
|
||||
fetch(base + '/login/2fa/status?t=' + encodeURIComponent(token), {credentials: 'same-origin', cache: 'no-store'})
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(j){
|
||||
if (j.status === 'approved' || j.status === 'denied') {
|
||||
done = true;
|
||||
msg.textContent = j.status === 'approved' ? 'Вход подтверждён.' : 'Вход отклонён в приложении.';
|
||||
document.getElementById('zk-finish').submit();
|
||||
return;
|
||||
}
|
||||
if (j.status === 'expired') { done = true; expired(); return; }
|
||||
setTimeout(poll, 2500);
|
||||
})
|
||||
.catch(function(){ setTimeout(poll, 4000); });
|
||||
}
|
||||
if (left > 0) setTimeout(poll, 2000); else expired();
|
||||
})();
|
||||
</script></body></html>{{end}}
|
||||
|
||||
{{define "evtable"}}<div class="table-wrap"><table>
|
||||
<thead><tr><th>Когда</th><th>Событие</th><th>Клиент</th><th>Кто</th><th>IP</th></tr></thead><tbody>
|
||||
{{range .}}<tr>
|
||||
<td class="small" style="white-space:nowrap">{{dt .At}}</td>
|
||||
<td>{{.Text}}{{if .InstallID}} <span class="mono muted">{{short .InstallID}}</span>{{end}}</td>
|
||||
<td>{{if .LicenseID}}<a href="{{adm}}/licenses/{{.LicenseID}}">{{if .TenantName}}{{.TenantName}}{{else}}{{short .LicenseID}}{{end}}</a>{{else}}<span class="none"></span>{{end}}</td>
|
||||
<td class="small">{{.Actor}}</td><td class="small mono">{{.IP}}</td>
|
||||
</tr>{{else}}<tr><td colspan="5" class="muted">Событий пока нет</td></tr>{{end}}
|
||||
</tbody></table></div>{{end}}
|
||||
|
||||
{{define "dashboard"}}{{template "head" .}}
|
||||
<h1>Обзор</h1>
|
||||
<div class="stats">
|
||||
<div class="stat"><b>{{.Data.Tenants}}</b><span>клиентов</span></div>
|
||||
<div class="stat"><b>{{.Data.Active}}</b><span>действующих лицензий</span></div>
|
||||
<div class="stat"><b>{{.Data.Revoked}}</b><span>отозвано</span></div>
|
||||
<div class="stat"><b>{{.Data.Installs}}</b><span>установок всего</span></div>
|
||||
<div class="stat"><b>{{.Data.Online}}</b><span>на связи за 48 ч</span></div>
|
||||
</div>
|
||||
{{if .Data.Expiring}}<div class="card"><h2>Требуют внимания</h2><div class="table-wrap"><table>
|
||||
<thead><tr><th>Клиент</th><th>План</th><th>Договор до</th><th>Статус</th></tr></thead><tbody>
|
||||
{{range .Data.Expiring}}<tr><td><a href="{{adm}}/licenses/{{.ID}}">{{.TenantName}}</a></td><td>{{plan .Plan}}</td><td>{{d .ContractUntil}}</td><td><span class="badge {{.StatusClass}}">{{.Status}}</span></td></tr>{{end}}
|
||||
</tbody></table></div></div>{{end}}
|
||||
<div class="card"><h2>Последние события</h2>{{template "evtable" .Data.Events}}<p class="small" style="margin:10px 0 0"><a href="{{adm}}/events">Весь журнал →</a></p></div>
|
||||
{{template "foot" .}}{{end}}
|
||||
|
||||
{{define "tenants"}}{{template "head" .}}
|
||||
<h1>Клиенты</h1>
|
||||
<div class="card"><div class="table-wrap"><table>
|
||||
<thead><tr><th>Клиент</th><th>ИНН</th><th>Контакт</th><th>Лицензий</th><th>Установок</th><th>Добавлен</th></tr></thead><tbody>
|
||||
{{range .Data}}<tr><td><a href="{{adm}}/tenants/{{.ID}}"><b>{{.Name}}</b></a></td><td class="mono">{{.INN}}</td><td class="small">{{.Contact}}</td><td>{{.Licenses}}</td><td>{{.Installs}}</td><td class="small">{{d .CreatedAt}}</td></tr>
|
||||
{{else}}<tr><td colspan="6" class="muted">Клиентов пока нет. Добавьте первого ниже.</td></tr>{{end}}
|
||||
</tbody></table></div></div>
|
||||
<div class="card"><h2>Новый клиент</h2>
|
||||
<form class="f" method="post" action="{{adm}}/tenants"><input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div class="row2">
|
||||
<div><label>Название</label><input type="text" name="name" required placeholder="АО «ИК Фонтвьель»"></div>
|
||||
<div><label>ИНН</label><input type="text" name="inn" inputmode="numeric" pattern="\d{10}|\d{12}" placeholder="10 или 12 цифр"></div>
|
||||
</div>
|
||||
<div><label>Контакт</label><input type="text" name="contact" placeholder="ответственный, почта, телефон"></div>
|
||||
<div><label>Примечание</label><textarea name="note"></textarea></div>
|
||||
<div><button class="btn primary" type="submit">Добавить клиента</button></div>
|
||||
</form></div>
|
||||
{{template "foot" .}}{{end}}
|
||||
|
||||
{{define "tenant"}}{{template "head" .}}
|
||||
<p class="small" style="margin:0 0 6px"><a href="{{adm}}/tenants">← Клиенты</a></p>
|
||||
<h1>{{.Data.Tenant.Name}}</h1>
|
||||
<div class="card"><div class="actions" style="justify-content:space-between;margin-bottom:10px"><h2 style="margin:0">Лицензии</h2><a class="btn primary" href="{{adm}}/licenses?tenant={{.Data.Tenant.ID}}#new">Выпустить лицензию</a></div>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Лицензия</th><th>План</th><th>Установок</th><th>Договор до</th><th>Статус</th></tr></thead><tbody>
|
||||
{{range .Data.Licenses}}<tr><td><a class="mono" href="{{adm}}/licenses/{{.ID}}">{{.Code}}</a></td><td>{{plan .Plan}}</td><td>{{.Active}} из {{if .MaxNodes}}{{.MaxNodes}}{{else}}∞{{end}}</td><td>{{d .ContractUntil}}</td><td><span class="badge {{.StatusClass}}">{{.Status}}</span></td></tr>
|
||||
{{else}}<tr><td colspan="5" class="muted">Лицензий нет</td></tr>{{end}}
|
||||
</tbody></table></div></div>
|
||||
<div class="card"><h2>Реквизиты</h2>
|
||||
<form class="f" method="post" action="{{adm}}/tenants/{{.Data.Tenant.ID}}"><input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div class="row2">
|
||||
<div><label>Название</label><input type="text" name="name" value="{{.Data.Tenant.Name}}" required></div>
|
||||
<div><label>ИНН</label><input type="text" name="inn" value="{{.Data.Tenant.INN}}" inputmode="numeric" pattern="\d{10}|\d{12}"></div>
|
||||
</div>
|
||||
<div><label>Контакт</label><input type="text" name="contact" value="{{.Data.Tenant.Contact}}"></div>
|
||||
<div><label>Примечание</label><textarea name="note">{{.Data.Tenant.Note}}</textarea></div>
|
||||
<div class="actions"><button class="btn primary" type="submit">Сохранить</button><span class="muted small">добавлен {{dt .Data.Tenant.CreatedAt}}</span></div>
|
||||
</form></div>
|
||||
{{template "foot" .}}{{end}}
|
||||
|
||||
{{define "terms"}}
|
||||
<div class="row2">
|
||||
<div><label>План</label><select name="plan">{{$cur := .Plan}}{{range $.Plans}}<option value="{{.Key}}" {{if eq .Key $cur}}selected{{end}}>{{.Title}}</option>{{end}}</select></div>
|
||||
<div><label>Установок (0 = без лимита)</label><input type="number" name="max_nodes" min="0" max="1000" value="{{.MaxNodes}}"></div>
|
||||
<div><label>Договор действует до</label><input type="date" name="contract_until" value="{{dateInput .ContractUntil}}" required></div>
|
||||
</div>
|
||||
<div><label>Модули</label><div class="checks">{{$f := .Features}}{{range $.All}}<label><input type="checkbox" name="features" value="{{.Key}}" {{if has $f .Key}}checked{{end}}> {{.Title}}</label>{{end}}</div></div>
|
||||
<div><label>Примечание (видно только здесь)</label><textarea name="note">{{.Note}}</textarea></div>
|
||||
{{end}}
|
||||
|
||||
{{define "licenses"}}{{template "head" .}}
|
||||
<h1>Лицензии</h1>
|
||||
<div class="card"><div class="table-wrap"><table>
|
||||
<thead><tr><th>Клиент</th><th>Код активации</th><th>План</th><th>Модули</th><th>Установок</th><th>Договор до</th><th>Статус</th></tr></thead><tbody>
|
||||
{{range .Data.Rows}}<tr>
|
||||
<td><a href="{{adm}}/licenses/{{.ID}}"><b>{{.TenantName}}</b></a></td><td class="mono">{{.Code}}</td><td>{{plan .Plan}}</td>
|
||||
<td>{{range .Features}}<span class="chip">{{feature .}}</span>{{else}}<span class="none"></span>{{end}}</td>
|
||||
<td>{{.Active}} из {{if .MaxNodes}}{{.MaxNodes}}{{else}}∞{{end}}</td><td>{{d .ContractUntil}}</td><td><span class="badge {{.StatusClass}}">{{.Status}}</span></td>
|
||||
</tr>{{else}}<tr><td colspan="7" class="muted">Лицензий пока нет.</td></tr>{{end}}
|
||||
</tbody></table></div></div>
|
||||
<div class="card" id="new"><h2>Выпустить лицензию</h2>
|
||||
{{if .Data.Tenants}}
|
||||
<form class="f" method="post" action="{{adm}}/licenses"><input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div><label>Клиент</label><select name="tenant_id" required>{{$sel := .Data.Tenant}}{{range .Data.Tenants}}<option value="{{.ID}}" {{if eq .ID $sel}}selected{{end}}>{{.Name}}{{if .INN}} · ИНН {{.INN}}{{end}}</option>{{end}}</select></div>
|
||||
{{template "terms" (dict2 .Plans .Features)}}
|
||||
<div><button class="btn primary" type="submit">Выпустить и получить код активации</button></div>
|
||||
</form>
|
||||
{{else}}<p class="muted">Сначала <a href="{{adm}}/tenants">добавьте клиента</a>.</p>{{end}}
|
||||
</div>
|
||||
{{template "foot" .}}{{end}}
|
||||
|
||||
{{define "license"}}{{template "head" .}}{{$row := .Data.Row}}{{$csrf := .CSRF}}
|
||||
<p class="small" style="margin:0 0 6px"><a href="{{adm}}/licenses">← Лицензии</a> · <a href="{{adm}}/tenants/{{$row.TenantID}}">{{$row.TenantName}}</a></p>
|
||||
<h1>{{$row.TenantName}} <span class="badge {{$row.StatusClass}}" style="vertical-align:middle">{{$row.Status}}</span></h1>
|
||||
|
||||
{{if .Data.OfflineToken}}<div class="card" style="border-color:var(--accent)"><h2>Офлайн-ключ выпущен</h2>
|
||||
<p class="muted small" style="margin-top:0">Ключ показывается один раз. Вставьте его в bj: Настройка → Лицензия. Действует до {{d $row.ContractUntil}} без связи с сервером; отзыв на него не распространяется.</p>
|
||||
<div class="token">{{.Data.OfflineToken}}</div></div>{{end}}
|
||||
|
||||
<div class="grid">
|
||||
<div class="card"><h2>Код активации</h2>
|
||||
<div class="code">{{$row.Code}}</div>
|
||||
<p class="muted small">Клиент вводит код в bj (Настройка → Лицензия → «Активировать»). Одним кодом активируется до {{if $row.MaxNodes}}{{$row.MaxNodes}}{{else}}любого числа{{end}} установок; сейчас активных {{$row.Active}}.</p>
|
||||
<form method="post" action="{{adm}}/licenses/{{$row.ID}}/code" onsubmit="return confirm('Выпустить новый код? Прежний перестанет действовать; активированные установки продолжат работать.')">
|
||||
<input type="hidden" name="csrf" value="{{$csrf}}"><button class="btn sm" type="submit">Выпустить новый код</button></form>
|
||||
</div>
|
||||
<div class="card"><h2>Сводка</h2><dl class="kv">
|
||||
<dt>План</dt><dd>{{plan $row.Plan}}</dd>
|
||||
<dt>Модули</dt><dd>{{range $row.Features}}<span class="chip">{{feature .}}</span>{{else}}<span class="none"></span>{{end}}</dd>
|
||||
<dt>Договор до</dt><dd>{{d $row.ContractUntil}}{{if not $row.Revoked}} <span class="muted small">(осталось {{daysLeft $row.ContractUntil}} дн.)</span>{{end}}</dd>
|
||||
<dt>Выпущена</dt><dd>{{dt $row.CreatedAt}}{{if $row.CreatedBy}} · {{$row.CreatedBy}}{{end}}</dd>
|
||||
<dt>ID</dt><dd class="mono">{{$row.ID}}</dd>
|
||||
{{if $row.Revoked}}<dt>Отозвана</dt><dd>{{dt $row.RevokedAt}}: {{$row.RevokeReason}}</dd>{{end}}
|
||||
</dl></div>
|
||||
</div>
|
||||
|
||||
<div class="card"><h2>Установки</h2><div class="table-wrap"><table>
|
||||
<thead><tr><th>Сервер</th><th>Версия</th><th>IP</th><th>Первая активация</th><th>Последнее продление</th><th>Токен до</th><th></th></tr></thead><tbody>
|
||||
{{range .Data.Installs}}<tr>
|
||||
<td><b>{{if .Hostname}}{{.Hostname}}{{else}}<span class="none"></span>{{end}}</b><div class="mono muted">{{.InstallID}}</div>{{if .Blocked}}<span class="badge bad">заблокирована</span> <span class="small">{{.BlockReason}}</span>{{else if .Stale}}<span class="badge warn">нет связи</span>{{end}}</td>
|
||||
<td class="small">{{.Version}}</td><td class="small mono">{{.IP}}</td><td class="small">{{dt .FirstSeen}}</td>
|
||||
<td class="small">{{dt .LastSeen}}<div class="muted">{{ago .LastSeen}}</div></td><td class="small">{{d .TokenExpires}}</td>
|
||||
<td>{{if .Blocked}}<form method="post" action="{{adm}}/licenses/{{$row.ID}}/installs/{{.InstallID}}/unblock"><input type="hidden" name="csrf" value="{{$csrf}}"><button class="btn sm" type="submit">Разблокировать</button></form>
|
||||
{{else}}<details><summary class="small">Заблокировать</summary><form class="f" method="post" action="{{adm}}/licenses/{{$row.ID}}/installs/{{.InstallID}}/block" style="margin-top:6px;min-width:200px"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="text" name="reason" placeholder="причина" required><button class="btn sm danger" type="submit">Заблокировать</button></form></details>{{end}}</td>
|
||||
</tr>{{else}}<tr><td colspan="7" class="muted">Установок ещё нет: они появятся после активации кодом.</td></tr>{{end}}
|
||||
</tbody></table></div></div>
|
||||
|
||||
<div class="card"><h2>Условия</h2>
|
||||
<form class="f" method="post" action="{{adm}}/licenses/{{$row.ID}}/edit"><input type="hidden" name="csrf" value="{{$csrf}}">
|
||||
{{template "terms" (dict3 .Plans .Features $row.License)}}
|
||||
<div class="actions"><button class="btn primary" type="submit">Сохранить условия</button><span class="muted small">установки получат их при следующем продлении</span></div>
|
||||
</form></div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card"><h2>Офлайн-ключ</h2>
|
||||
<p class="muted small" style="margin-top:0">Для закрытого контура без доступа к серверу: ключ без привязки к установке, действует до окончания договора. Отозвать его нельзя.</p>
|
||||
<form method="post" action="{{adm}}/licenses/{{$row.ID}}/offline" onsubmit="return confirm('Выпустить офлайн-ключ до {{d $row.ContractUntil}}? Отозвать его будет нельзя.')"><input type="hidden" name="csrf" value="{{$csrf}}"><button class="btn" type="submit" {{if ne $row.StatusClass "ok"}}{{if ne $row.StatusClass "warn"}}disabled{{end}}{{end}}>Выпустить офлайн-ключ</button></form>
|
||||
</div>
|
||||
<div class="card"><h2>{{if $row.Revoked}}Восстановление{{else}}Отзыв{{end}}</h2>
|
||||
{{if $row.Revoked}}
|
||||
<form method="post" action="{{adm}}/licenses/{{$row.ID}}/restore"><input type="hidden" name="csrf" value="{{$csrf}}"><button class="btn" type="submit">Восстановить лицензию</button></form>
|
||||
{{else}}
|
||||
<p class="muted small" style="margin-top:0">Продления прекращаются; выданные токены доживают свой срок (до 60 дней).</p>
|
||||
<form class="f" method="post" action="{{adm}}/licenses/{{$row.ID}}/revoke" onsubmit="return confirm('Отозвать лицензию?')"><input type="hidden" name="csrf" value="{{$csrf}}">
|
||||
<input type="text" name="reason" placeholder="причина отзыва" required><div><button class="btn danger" type="submit">Отозвать лицензию</button></div></form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card"><h2>Журнал лицензии</h2>{{template "evtable" .Data.Events}}</div>
|
||||
{{template "foot" .}}{{end}}
|
||||
|
||||
{{define "installs"}}{{template "head" .}}{{$csrf := .CSRF}}
|
||||
<h1>Установки</h1>
|
||||
<div class="card"><div class="table-wrap"><table>
|
||||
<thead><tr><th>Клиент</th><th>Сервер</th><th>Версия</th><th>IP</th><th>Последнее продление</th><th>Токен до</th><th>Статус</th></tr></thead><tbody>
|
||||
{{range .Data.Installs}}<tr>
|
||||
<td><a href="{{adm}}/licenses/{{.LicenseID}}">{{if .TenantName}}{{.TenantName}}{{else}}{{short .LicenseID}}{{end}}</a></td>
|
||||
<td><b>{{if .Hostname}}{{.Hostname}}{{else}}<span class="none"></span>{{end}}</b><div class="mono muted">{{short .InstallID}}</div></td>
|
||||
<td class="small">{{.Version}}</td><td class="small mono">{{.IP}}</td><td class="small">{{dt .LastSeen}}<div class="muted">{{ago .LastSeen}}</div></td><td class="small">{{d .TokenExpires}}</td>
|
||||
<td>{{if .Blocked}}<span class="badge bad">заблокирована</span>{{else if .Stale}}<span class="badge warn">нет связи</span>{{else}}<span class="badge ok">на связи</span>{{end}}</td>
|
||||
</tr>{{else}}<tr><td colspan="7" class="muted">Установок пока нет.</td></tr>{{end}}
|
||||
</tbody></table></div></div>
|
||||
<div class="card"><h2>Доверенные IP (fail2ban)</h2>
|
||||
<p class="muted small" style="margin:0 0 10px">Адрес попадает сюда после успешной активации или продления лицензии. fail2ban не блокирует его за ошибки API (неверный код, испорченный токен). На вход в кабинет и SSH доверие не распространяется. При отзыве лицензии или блокировке установки адрес исключается автоматически.</p>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>IP</th><th>Клиент</th><th>Сервер</th><th>Добавлен</th><th>Последнее обращение</th><th></th></tr></thead><tbody>
|
||||
{{range .Data.Trusted}}<tr>
|
||||
<td class="mono"><b>{{.IP}}</b></td>
|
||||
<td><a href="{{adm}}/licenses/{{.LicenseID}}">{{if .TenantName}}{{.TenantName}}{{else}}{{short .LicenseID}}{{end}}</a></td>
|
||||
<td class="small">{{if .Hostname}}{{.Hostname}}{{else}}<span class="none"></span>{{end}}<div class="mono muted">{{short .InstallID}}</div></td>
|
||||
<td class="small">{{dt .AddedAt}}</td><td class="small">{{dt .LastSeen}}<div class="muted">{{ago .LastSeen}}</div></td>
|
||||
<td><form method="post" action="{{adm}}/trusted/remove"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="ip" value="{{.IP}}"><input type="hidden" name="install" value="{{.InstallID}}"><button class="btn sm danger" type="submit">Исключить</button></form></td>
|
||||
</tr>{{else}}<tr><td colspan="6" class="muted">Доверенных адресов пока нет: они появятся после первой активации или продления.</td></tr>{{end}}
|
||||
</tbody></table></div></div>
|
||||
{{template "foot" .}}{{end}}
|
||||
|
||||
{{define "security"}}{{template "head" .}}{{$csrf := .CSRF}}{{$d := .Data}}
|
||||
<h1>Защита</h1>
|
||||
<div class="card"><h2>Доступ к кабинету</h2>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<h3 style="margin:0 0 6px;font-size:15px">Адрес кабинета</h3>
|
||||
<p style="margin:0 0 8px"><span class="mono"><b>https://{{$d.Host}}{{$d.AdminBase}}/</b></span> {{if $d.AdminDefault}}<span class="badge warn">стандартный</span>{{else}}<span class="badge ok">свой</span>{{end}}</p>
|
||||
<form method="post" action="{{adm}}/security/access" class="actions" onsubmit="return confirm('Сменить адрес кабинета? Прежний перестанет открываться.')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="path"><input name="path" placeholder="свой адрес, 8–64 символа: a-z, 0-9, дефис" pattern="[a-z0-9][a-z0-9\-]{7,63}" required style="max-width:280px"><button class="btn" type="submit">Сохранить</button></form>
|
||||
<div class="actions" style="margin-top:6px">
|
||||
<form method="post" action="{{adm}}/security/access" onsubmit="return confirm('Сгенерировать новый адрес кабинета? Прежний перестанет открываться.')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="path-random"><button class="btn" type="submit">Сгенерировать случайный</button></form>
|
||||
{{if not $d.AdminDefault}}<form method="post" action="{{adm}}/security/access" onsubmit="return confirm('Вернуть стандартный адрес /admin?')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="path-default"><button class="btn sm" type="submit">Вернуть /admin</button></form>{{end}}
|
||||
</div>
|
||||
<p class="muted small" style="margin:8px 0 0">При своём адресе /admin и главная страница отвечают «не найдено», и боты не находят форму входа. Сохраните адрес в закладки и не пересылайте. Узнать по SSH: <span class="mono">/opt/bj-license/bj-license-server admin-info</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 style="margin:0 0 6px;font-size:15px">Только из белого списка</h3>
|
||||
<p style="margin:0 0 8px">{{if and $d.AdminAllowOnly $d.AdminOpen}}<span class="badge warn">открыт аварийно по SSH</span> Ограничение включено, но сейчас не действует.{{else if $d.AdminAllowOnly}}<span class="badge ok">включено</span> Кабинет и страница входа видны только адресам белого списка администраторов, остальным отвечает «не найдено».{{else}}<span class="badge warn">выключено</span> Страница входа открывается с любого адреса.{{end}}</p>
|
||||
{{if and $d.AdminAllowOnly (not $d.AdminOpen)}}<form method="post" action="{{adm}}/security/access" onsubmit="return confirm('Снять ограничение? Страница входа станет доступна с любого адреса.')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="allow-only-off"><button class="btn" type="submit">Снять ограничение</button></form>
|
||||
{{else}}<form method="post" action="{{adm}}/security/access" onsubmit="return confirm('Открывать кабинет только с адресов белого списка?')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="allow-only-on"><button class="btn primary" type="submit">Включить ограничение</button></form>{{end}}
|
||||
<p class="muted small" style="margin:8px 0 0">API активации и продления это не затрагивает. Ваш адрес <span class="mono">{{$d.MyIP}}</span> {{if $d.MyIPCovered}}в белом списке{{else}}добавится в белый список при включении{{end}}. Аварийно открыть кабинет по SSH: <span class="mono">/opt/bj-license/bj-license-server fw-apply -off</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 style="margin:0 0 6px;font-size:15px">Второй фактор</h3>
|
||||
<p style="margin:0 0 8px">{{if eq $d.TwoFA $d.Admins}}<span class="badge ok">у всех</span>{{else}}<span class="badge warn">{{$d.TwoFA}} из {{$d.Admins}}</span>{{end}} администраторов входят с кодом из приложения.</p>
|
||||
<div class="actions"><a class="btn" href="{{adm}}/me/2fa">Мой второй фактор</a><a class="btn" href="{{adm}}/users">Администраторы</a></div>
|
||||
<p class="muted small" style="margin:8px 0 0">Даже зная пароль, без телефона войти нельзя. Потерянный телефон: второй фактор сбрасывает другой администратор или по SSH <span class="mono">admin-config -reset-2fa логин</span> при остановленном сервере.</p>
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
<div class="card"><h2>ЗЕТИТ Ключ</h2>
|
||||
<p style="margin:0 0 10px">{{if $d.ZKEnabled}}<span class="badge ok">включён</span> После пароля вход подтверждается push-уведомлением в приложении «ЗЕТИТ Ключ» или кодом из него. Действует для администраторов с указанным e-mail ({{$d.ZKWithEmail}} из {{$d.Admins}}).{{else}}<span class="badge warn">выключен</span> Второй фактор через приложение «ЗЕТИТ Ключ», как при входе в ZETIT Connect.{{end}}</p>
|
||||
<form class="f" method="post" action="{{adm}}/security/zetit-key" style="max-width:620px"><input type="hidden" name="csrf" value="{{$csrf}}">
|
||||
<div><label>Адрес API</label><input type="url" name="url" value="{{$d.ZKURL}}" placeholder="https://key.zetit.ru/api/v1/connect-auth"></div>
|
||||
<div><label>Ключ приложения (X-Connect-Key)</label><input type="password" name="app_key" autocomplete="off" placeholder="{{if $d.ZKHasKey}}задан, оставьте пустым, чтобы не менять{{else}}панель ЗЕТИТ Ключ → Система → Внешние приложения → Добавить{{end}}"></div>
|
||||
<label style="display:flex;gap:8px;align-items:center;font-weight:600"><input type="checkbox" name="enabled" value="1" {{if $d.ZKEnabled}}checked{{end}}> Требовать подтверждение в ЗЕТИТ Ключ при входе</label>
|
||||
<div class="actions"><button class="btn primary" type="submit" name="op" value="save">Сохранить</button><button class="btn" type="submit" name="op" value="test">Проверить мой e-mail</button></div>
|
||||
</form>
|
||||
<p class="muted small" style="margin:10px 0 0">E-mail администраторов задаётся на странице <a href="{{adm}}/users">«Администраторы»</a> и должен совпадать с учётной записью в ЗЕТИТ Ключ. Включить можно, только если ваш e-mail найден в Ключе с привязанным приложением. У администраторов без e-mail действует пароль и код приложения-аутентификатора, если он подключён. Если Ключ недоступен, вход с e-mail невозможен. Аварийно выключить по SSH при остановленном сервере: <span class="mono">bj-license-server admin-config -zetit-key off</span></p>
|
||||
</div>
|
||||
|
||||
{{if not $d.StatusFresh}}<div class="card"><span class="badge bad">Помощник защиты не отвечает</span>
|
||||
<p class="small" style="margin:8px 0 0">{{if $d.Status.At.IsZero}}Статуса ещё нет.{{else}}Последний отчёт {{dt $d.Status.At}} ({{ago $d.Status.At}}).{{end}} Команды из этого раздела не применяются. На сервере: <span class="mono">systemctl status bj-license-fw.timer bj-license-fw.path</span></p></div>{{end}}
|
||||
|
||||
<div class="card">
|
||||
<h2>Режим обороны</h2>
|
||||
<p>{{if $d.Status.Lockdown}}<span class="badge bad">Оборона включена</span> На сайт и API пускаются только адреса из белого списка ({{$d.Status.AllowCount}}){{if not $d.Status.Until.IsZero}}, автоматически выключится {{dt $d.Status.Until}}{{end}}.
|
||||
{{else if $d.Status.Emergency}}<span class="badge warn">Оборона выключена аварийно по SSH</span> Включить снова можно кнопкой ниже.
|
||||
{{else}}<span class="badge ok">Обычный режим</span> Доступ открыт всем, fail2ban блокирует адреса, которые перебирают пароли и коды.{{end}}</p>
|
||||
{{if $d.WantedActive}}
|
||||
<form method="post" action="{{adm}}/security/mode" class="actions" onsubmit="return confirm('Выключить режим обороны и открыть доступ всем?')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="off"><button class="btn primary" type="submit">Выключить оборону</button></form>
|
||||
{{else}}
|
||||
<form method="post" action="{{adm}}/security/mode" class="actions" onsubmit="return confirm('Включить режим обороны? Сайт и API станут недоступны всем, кроме белого списка.')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="on">
|
||||
<select name="hours"><option value="1">на 1 час</option><option value="6">на 6 часов</option><option value="24" selected>на 24 часа</option><option value="0">без срока</option></select>
|
||||
<button class="btn danger" type="submit">Включить оборону</button></form>
|
||||
{{end}}
|
||||
<p class="muted small" style="margin:10px 0 0">Для атаки на сервер: все, кроме белого списка, получают отказ на уровне файрвола (порты 80 и 443). В белом списке: доверенные IP установок ({{$d.TrustedCount}}, <a href="{{adm}}/installs">Установки</a>) и адреса администраторов ниже. Ваш адрес <span class="mono">{{$d.MyIP}}</span> {{if $d.MyIPCovered}}уже в белом списке{{else}}добавится в белый список автоматически при включении{{end}}. SSH не затрагивается. Пока оборона включена, Let's Encrypt не сможет продлить сертификат. Аварийно выключить по SSH: <span class="mono">/opt/bj-license/bj-license-server fw-apply -off</span></p>
|
||||
{{if $d.Status.Errors}}<div style="margin-top:10px">{{range $d.Status.Errors}}<div class="badge bad" style="display:block;margin-top:4px;white-space:normal">{{.}}</div>{{end}}</div>{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card"><h2>Белый список администраторов</h2>
|
||||
<p class="muted small" style="margin:0 0 10px">Адреса офиса и администраторов. Они не блокируются fail2ban (ни за вход в кабинет, ни за API) и пропускаются в режиме обороны.</p>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>IP или сеть</th><th>Комментарий</th><th>Добавил</th><th></th></tr></thead><tbody>
|
||||
{{range $d.Allow}}<tr><td class="mono"><b>{{.Net}}</b></td><td class="small">{{.Comment}}</td><td class="small">{{.AddedBy}}<div class="muted">{{dt .AddedAt}}</div></td>
|
||||
<td><form method="post" action="{{adm}}/security/allow"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="remove"><input type="hidden" name="net" value="{{.Net}}"><button class="btn sm danger" type="submit">Убрать</button></form></td></tr>
|
||||
{{else}}<tr><td colspan="4" class="muted">Пока пусто.</td></tr>{{end}}
|
||||
</tbody></table></div>
|
||||
<form method="post" action="{{adm}}/security/allow" class="actions" style="margin-top:10px"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="add">
|
||||
<input name="net" placeholder="IP или сеть, например 203.0.113.10 или 203.0.113.0/24" required style="max-width:340px"><input name="comment" placeholder="комментарий" style="max-width:260px"><button class="btn" type="submit">Добавить</button></form>
|
||||
</div>
|
||||
|
||||
<div class="card"><h2>Параметры блокировок</h2>
|
||||
<p class="muted small" style="margin:0 0 10px">Сколько неудачных попыток за период приводят к блокировке адреса и на какой срок. Долгие блокировки сохраняются и после перезапуска fail2ban. Белый список и доверенные установки не блокируются.</p>
|
||||
<form method="post" action="{{adm}}/security/limits"><input type="hidden" name="csrf" value="{{$csrf}}">
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Что защищаем</th><th>Попыток</th><th>За период</th><th>Блокировать на</th></tr></thead><tbody>
|
||||
{{range $d.Limits}}{{$row := .}}<tr><td><b>{{.Title}}</b>{{if .HasEffective}}<div class="muted small">{{.Effective}}</div>{{end}}</td>
|
||||
<td><select name="retry_{{.Jail}}">{{range .RetryOpts}}<option value="{{.}}" {{if eq . $row.Conf.MaxRetry}}selected{{end}}>{{.}}</option>{{end}}</select></td>
|
||||
<td><select name="find_{{.Jail}}">{{range .FindOpts}}<option value="{{.Sec}}" {{if eq .Sec $row.Conf.FindTime}}selected{{end}}>{{.Label}}</option>{{end}}</select></td>
|
||||
<td><select name="ban_{{.Jail}}">{{range .BanOpts}}<option value="{{.Sec}}" {{if eq .Sec $row.Conf.BanTime}}selected{{end}}>{{.Label}}</option>{{end}}</select></td></tr>
|
||||
{{end}}
|
||||
<tr><td><b>Вход в кабинет (приложение)</b><div class="muted small">срабатывает раньше fail2ban: после N неудачных попыток форма входа отказывает этому адресу и логину</div></td>
|
||||
<td><select name="login_tries">{{range $d.RetryPresets}}<option value="{{.}}" {{if eq . $d.LoginTries}}selected{{end}}>{{.}}</option>{{end}}{{if and (ne $d.LoginTries 3) (ne $d.LoginTries 5) (ne $d.LoginTries 10) (ne $d.LoginTries 20)}}<option value="{{$d.LoginTries}}" selected>{{$d.LoginTries}}</option>{{end}}</select></td>
|
||||
<td class="muted small">за срок блокировки</td>
|
||||
<td><select name="login_lock">{{range $d.BanPresets}}{{if ne .Sec -1}}<option value="{{.Sec}}" {{if eq .Sec $d.LoginLockSec}}selected{{end}}>{{.Label}}</option>{{end}}{{end}}</select></td></tr>
|
||||
</tbody></table></div>
|
||||
<div class="actions" style="margin-top:10px"><button class="btn primary" type="submit">Сохранить параметры</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card"><h2>fail2ban</h2>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Jail</th><th>Ошибок сейчас / всего</th><th>Заблокировано сейчас / всего</th><th>Заблокированные адреса</th></tr></thead><tbody>
|
||||
{{range $d.Status.Jails}}{{$jail := .Name}}<tr><td><b>{{.Name}}</b><div class="muted small">{{if eq .Name "bj-license-api"}}коды активации и токены{{else if eq .Name "bj-license-admin"}}вход в кабинет{{else if eq .Name "sshd"}}SSH{{end}}</div></td>
|
||||
<td>{{.CurrentlyFailed}} / {{.TotalFailed}}</td><td>{{.CurrentlyBanned}} / {{.TotalBanned}}</td>
|
||||
<td>{{range .Banned}}<form method="post" action="{{adm}}/security/ban" class="actions" style="margin:0 0 4px"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="unban"><input type="hidden" name="jail" value="{{$jail}}"><input type="hidden" name="ip" value="{{.}}"><span class="mono">{{.}}</span><button class="btn sm" type="submit">Разблокировать</button></form>{{else}}<span class="muted">нет</span>{{end}}</td></tr>
|
||||
{{else}}<tr><td colspan="4" class="muted">Данных пока нет.</td></tr>{{end}}
|
||||
</tbody></table></div>
|
||||
{{if $d.Pending}}<p class="small" style="margin:10px 0 0"><b>Ожидают применения:</b> {{range $d.Pending}}<span class="mono">{{.Op}} {{.IP}} ({{.Jail}})</span> {{end}}</p>{{end}}
|
||||
<form method="post" action="{{adm}}/security/ban" class="actions" style="margin-top:12px"><input type="hidden" name="csrf" value="{{$csrf}}">
|
||||
<input name="ip" placeholder="IP-адрес" required style="max-width:220px">
|
||||
<select name="jail">{{range $d.Jails}}<option value="{{.}}">{{.}}</option>{{end}}</select>
|
||||
<select name="op"><option value="ban">Заблокировать</option><option value="unban">Разблокировать</option></select>
|
||||
<button class="btn" type="submit">Выполнить</button></form>
|
||||
<p class="muted small" style="margin:8px 0 0">Ручная блокировка действует срок блокировки jail из «Параметров блокировок». Статус обновляется раз в 30 секунд{{if not $d.Status.At.IsZero}}, последний раз {{dt $d.Status.At}}{{end}}.</p>
|
||||
</div>
|
||||
{{template "foot" .}}{{end}}
|
||||
|
||||
{{define "totp"}}{{template "head" .}}{{$csrf := .CSRF}}{{$d := .Data}}
|
||||
<h1>Второй фактор входа</h1>
|
||||
<div class="card">
|
||||
{{if $d.Enabled}}
|
||||
<p><span class="badge ok">Подключён</span> При входе в кабинет, кроме пароля, нужен шестизначный код из приложения.</p>
|
||||
<form class="f" method="post" action="{{adm}}/me/2fa/disable" style="max-width:360px"><input type="hidden" name="csrf" value="{{$csrf}}">
|
||||
<div><label>Код из приложения, чтобы отключить</label><input name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required></div>
|
||||
<div><button class="btn danger" type="submit">Отключить второй фактор</button></div></form>
|
||||
{{else}}
|
||||
<p style="margin-top:0">Установите на телефон приложение для одноразовых кодов: Яндекс Ключ, Google Authenticator, Microsoft Authenticator или любое с поддержкой TOTP.</p>
|
||||
<div class="grid">
|
||||
<div><h2>1. Добавьте ключ в приложение</h2>
|
||||
{{if $d.QR}}<div style="background:#fff;display:inline-block;padding:8px;border-radius:8px">{{$d.QR}}</div>{{end}}
|
||||
<p class="small">Отсканируйте QR-код или введите ключ вручную: <span class="mono"><b>{{$d.SecretGrouped}}</b></span>. Если открыли кабинет на телефоне, <a href="{{$d.URI}}">добавить в приложение</a>.</p></div>
|
||||
<div><h2>2. Подтвердите кодом</h2>
|
||||
<form class="f" method="post" action="{{adm}}/me/2fa/enable"><input type="hidden" name="csrf" value="{{$csrf}}">
|
||||
<div><label>Шестизначный код из приложения</label><input name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required autofocus></div>
|
||||
<div><button class="btn primary" type="submit">Подключить</button></div></form>
|
||||
<p class="muted small">Ключ показывается только на этой странице. Если телефон потеряется, второй фактор сбросит другой администратор на странице «Администраторы».</p></div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{template "foot" .}}{{end}}
|
||||
|
||||
{{define "events"}}{{template "head" .}}
|
||||
<h1>Журнал</h1>
|
||||
<div class="card">{{template "evtable" .Data}}<p class="muted small" style="margin:10px 0 0">Последние 500 событий.</p></div>
|
||||
{{template "foot" .}}{{end}}
|
||||
|
||||
{{define "users"}}{{template "head" .}}{{$csrf := .CSRF}}{{$me := .Me}}
|
||||
<h1>Администраторы</h1>
|
||||
<div class="card"><div class="table-wrap"><table>
|
||||
<thead><tr><th>Логин</th><th>Имя</th><th>E-mail (ЗЕТИТ Ключ)</th><th>Последний вход</th><th>Статус</th><th>Второй фактор</th><th>Сменить пароль</th><th></th></tr></thead><tbody>
|
||||
{{range .Data}}<tr>
|
||||
<td><b>{{.Login}}</b></td><td>{{.Name}}</td><td><form method="post" action="{{adm}}/users/{{.Login}}/email" class="actions" style="margin:0"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="email" name="email" value="{{.Email}}" placeholder="e-mail в ЗЕТИТ Ключ" style="max-width:220px"><button class="btn sm" type="submit">Сохранить</button></form></td><td class="small">{{dt .LastLogin}}</td>
|
||||
<td>{{if .Disabled}}<span class="badge bad">отключён</span>{{else}}<span class="badge ok">активен</span>{{end}}</td>
|
||||
<td>{{if .HasTOTP}}<span class="badge ok">подключён</span>{{if ne .Login $me.Login}}<form method="post" action="{{adm}}/users/{{.Login}}/2fa-reset" style="margin:4px 0 0" onsubmit="return confirm('Сбросить второй фактор администратора?')"><input type="hidden" name="csrf" value="{{$csrf}}"><button class="btn sm" type="submit">Сбросить</button></form>{{end}}{{else}}<span class="badge warn">нет</span>{{end}}{{if eq .Login $me.Login}}<div><a class="small" href="{{adm}}/me/2fa">{{if .HasTOTP}}управлять{{else}}подключить{{end}}</a></div>{{end}}</td>
|
||||
<td><form class="actions" method="post" action="{{adm}}/users/{{.Login}}/password"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="password" name="password" placeholder="новый пароль, от 10 символов" minlength="10" required autocomplete="new-password" style="max-width:230px"><button class="btn sm" type="submit">Задать</button></form></td>
|
||||
<td>{{if ne .Login $me.Login}}<form method="post" action="{{adm}}/users/{{.Login}}/toggle"><input type="hidden" name="csrf" value="{{$csrf}}"><button class="btn sm {{if not .Disabled}}danger{{end}}" type="submit">{{if .Disabled}}Включить{{else}}Отключить{{end}}</button></form>{{end}}</td>
|
||||
</tr>{{end}}
|
||||
</tbody></table></div></div>
|
||||
<div class="grid">
|
||||
<div class="card"><h2>Новый администратор</h2>
|
||||
<form class="f" method="post" action="{{adm}}/users"><input type="hidden" name="csrf" value="{{$csrf}}">
|
||||
<div class="row2"><div><label>Логин</label><input type="text" name="login" required pattern="[A-Za-z0-9_.\-]{2,40}"></div><div><label>Имя</label><input type="text" name="name"></div></div>
|
||||
<div><label>E-mail в ЗЕТИТ Ключ <span class="muted" style="font-weight:400">(для подтверждения входа в приложении)</span></label><input type="email" name="email"></div>
|
||||
<div><label>Пароль (от 10 символов)</label><input type="password" name="password" minlength="10" required autocomplete="new-password"></div>
|
||||
<div><button class="btn primary" type="submit">Добавить</button></div>
|
||||
</form></div>
|
||||
<div class="card"><h2>Мой пароль</h2>
|
||||
<form class="f" method="post" action="{{adm}}/me/password"><input type="hidden" name="csrf" value="{{$csrf}}">
|
||||
<div><label>Текущий пароль</label><input type="password" name="current" required autocomplete="current-password"></div>
|
||||
<div class="row2"><div><label>Новый пароль</label><input type="password" name="password" minlength="10" required autocomplete="new-password"></div><div><label>Повтор</label><input type="password" name="password2" minlength="10" required autocomplete="new-password"></div></div>
|
||||
<div><button class="btn primary" type="submit">Сменить пароль</button></div>
|
||||
</form></div>
|
||||
</div>
|
||||
{{template "foot" .}}{{end}}
|
||||
@@ -1,389 +0,0 @@
|
||||
package main
|
||||
|
||||
// Второй фактор через «ЗЕТИТ Ключ» — так же, как вход в ZETIT Connect. После пароля сервер лицензий
|
||||
// просит Ключ подтвердить вход: push-уведомление на телефон администратора или код из приложения.
|
||||
// Статус опрашивает только сервер; вход разрешается лишь при approved (push) или success (код).
|
||||
// API: Zetit_key/docs/EXTERNAL_APP_2FA.md, способ 1 (приложение с X-Connect-Key).
|
||||
//
|
||||
// Действует для администраторов с указанным e-mail, когда интеграция включена в разделе «Защита».
|
||||
// Остальные входят по паролю и, если подключён, по коду приложения-аутентификатора (access.go).
|
||||
// Аварийно выключить по SSH: bj-license-server admin-config -zetit-key off (сервер остановлен).
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
zetitKeyDefaultURL = "https://key.zetit.ru/api/v1/connect-auth"
|
||||
zkPendingTTL = 5 * time.Minute // push живёт 60 секунд, код из приложения можно ввести и позже
|
||||
)
|
||||
|
||||
// ZetitKeyConf — настройки интеграции с «ЗЕТИТ Ключ».
|
||||
type ZetitKeyConf struct {
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
URL string `json:"url,omitempty"` // по умолчанию https://key.zetit.ru/api/v1/connect-auth
|
||||
AppKey string `json:"app_key,omitempty"` // X-Connect-Key приложения из панели Ключа
|
||||
}
|
||||
|
||||
func (c ZetitKeyConf) active() bool { return c.Enabled && c.AppKey != "" }
|
||||
|
||||
var zkClient = &http.Client{Timeout: 8 * time.Second}
|
||||
|
||||
func zkCall(c ZetitKeyConf, method, path string, body, out any) error {
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, strings.TrimRight(pickStr(c.URL, zetitKeyDefaultURL), "/")+path, rd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("X-Connect-Key", c.AppKey)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := zkClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("сервер недоступен: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("ответ %d: %s", resp.StatusCode, clip(string(data), 200))
|
||||
}
|
||||
if out != nil {
|
||||
if err := json.Unmarshal(data, out); err != nil {
|
||||
return fmt.Errorf("ответ не разобран: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type zkLookupResult struct {
|
||||
Exists bool `json:"exists"`
|
||||
Has2FA bool `json:"has_2fa"`
|
||||
FullName string `json:"full_name"`
|
||||
}
|
||||
|
||||
type zkVerifyResult struct {
|
||||
RequestID string `json:"request_id"`
|
||||
Status string `json:"status"`
|
||||
NeedsSetup bool `json:"needs_setup"`
|
||||
}
|
||||
|
||||
func zkLookup(c ZetitKeyConf, email string) (zkLookupResult, error) {
|
||||
var r zkLookupResult
|
||||
return r, zkCall(c, http.MethodGet, "/lookup?email="+url.QueryEscape(email), nil, &r)
|
||||
}
|
||||
|
||||
func zkStart(c ZetitKeyConf, email, ip string) (zkVerifyResult, error) {
|
||||
var r zkVerifyResult
|
||||
return r, zkCall(c, http.MethodPost, "/verify", map[string]string{"email": email, "source_ip": ip}, &r)
|
||||
}
|
||||
|
||||
func zkStatus(c ZetitKeyConf, requestID string) (string, error) {
|
||||
var r zkVerifyResult
|
||||
err := zkCall(c, http.MethodGet, "/verify/"+url.PathEscape(requestID), nil, &r)
|
||||
return r.Status, err
|
||||
}
|
||||
|
||||
func zkCode(c ZetitKeyConf, email, code string) (bool, error) {
|
||||
var r struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
err := zkCall(c, http.MethodPost, "/verify-totp", map[string]string{"email": email, "code": code}, &r)
|
||||
return r.Success, err
|
||||
}
|
||||
|
||||
// ---- вход с подтверждением ----
|
||||
|
||||
// pendingLogin — пароль проверен, ждём подтверждения в Ключе.
|
||||
type pendingLogin struct {
|
||||
Token, Login, Email, IP, RequestID, Next string
|
||||
Created time.Time
|
||||
}
|
||||
|
||||
type pendingLogins struct {
|
||||
mu sync.Mutex
|
||||
m map[string]*pendingLogin
|
||||
}
|
||||
|
||||
var zkPending = &pendingLogins{m: map[string]*pendingLogin{}}
|
||||
|
||||
func (p *pendingLogins) put(pl *pendingLogin) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for k, v := range p.m {
|
||||
if time.Since(v.Created) > zkPendingTTL {
|
||||
delete(p.m, k)
|
||||
}
|
||||
}
|
||||
p.m[pl.Token] = pl
|
||||
}
|
||||
|
||||
// get — ожидающий вход по токену с того же адреса.
|
||||
func (p *pendingLogins) get(token, ip string) *pendingLogin {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
pl := p.m[token]
|
||||
if pl == nil || pl.IP != ip || time.Since(pl.Created) > zkPendingTTL {
|
||||
return nil
|
||||
}
|
||||
return pl
|
||||
}
|
||||
|
||||
func (p *pendingLogins) drop(token string) {
|
||||
p.mu.Lock()
|
||||
delete(p.m, token)
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (srv *server) zetitKey() ZetitKeyConf {
|
||||
var c ZetitKeyConf
|
||||
srv.st.read(func(s *State) { c = s.ZetitKey })
|
||||
return c
|
||||
}
|
||||
|
||||
// finishLogin — второй фактор пройден (или не требуется): сессия и переход в кабинет.
|
||||
func (srv *server) finishLogin(w http.ResponseWriter, r *http.Request, login, ip, next string, totpStep int64, how string) {
|
||||
srv.fails.reset(ip + "|" + strings.ToLower(login))
|
||||
sess := srv.sessions.create(login)
|
||||
_ = srv.st.write(func(s *State) error {
|
||||
if u := s.user(login); u != nil {
|
||||
u.LastLogin = time.Now()
|
||||
sess.Login = u.Login
|
||||
if totpStep > 0 {
|
||||
u.TOTPLast = totpStep
|
||||
}
|
||||
}
|
||||
s.addEvent("login", "", "", login, ip, "вход в кабинет"+how)
|
||||
return nil
|
||||
})
|
||||
srv.setSessionCookie(w, r, sess.Token)
|
||||
http.Redirect(w, r, next, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (srv *server) renderLoginErr(w http.ResponseWriter, login, next, msg string, status int) {
|
||||
p := page{Title: "Вход", Err: msg, Data: map[string]string{"Next": next, "Login": login}}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = srv.tpl.ExecuteTemplate(w, "login", p)
|
||||
}
|
||||
|
||||
// loginReject — отказ на втором факторе: счётчик неудач, строка для fail2ban, журнал.
|
||||
func (srv *server) loginReject(w http.ResponseWriter, login, ip, next, eventText, msg string) {
|
||||
srv.fails.add(ip + "|" + strings.ToLower(login))
|
||||
srv.fails.add(ip)
|
||||
log.Printf("login failed: user=%q ip=%s", login, ip) // строку ловит fail2ban (deploy/license/fail2ban)
|
||||
time.Sleep(600 * time.Millisecond)
|
||||
_ = srv.st.write(func(s *State) error {
|
||||
s.addEvent("login_failed", "", "", login, ip, eventText)
|
||||
return nil
|
||||
})
|
||||
srv.renderLoginErr(w, login, next, msg, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
func (srv *server) renderLogin2FA(w http.ResponseWriter, pl *pendingLogin, errMsg string) {
|
||||
left := 60 - int(time.Since(pl.Created).Seconds())
|
||||
if left < 0 {
|
||||
left = 0
|
||||
}
|
||||
p := page{Title: "Подтверждение входа", Err: errMsg, Data: map[string]any{"Token": pl.Token, "Email": pl.Email, "Left": left}}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = srv.tpl.ExecuteTemplate(w, "login2fa", p)
|
||||
}
|
||||
|
||||
// login2FAStatus — GET /admin/login/2fa/status?t=…: статус push, опрос идёт с сервера.
|
||||
func (srv *server) login2FAStatus(w http.ResponseWriter, r *http.Request) {
|
||||
pl := zkPending.get(r.URL.Query().Get("t"), clientIP(r))
|
||||
status := "expired"
|
||||
if pl != nil && time.Since(pl.Created) <= 70*time.Second {
|
||||
if st, err := zkStatus(srv.zetitKey(), pl.RequestID); err == nil {
|
||||
status = st
|
||||
} else {
|
||||
status = "pending" // сбой связи — страница спросит ещё раз
|
||||
}
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": status})
|
||||
}
|
||||
|
||||
// login2FAFinish — POST /admin/login/2fa/finish: страница увидела ответ, сервер перепроверяет его сам.
|
||||
func (srv *server) login2FAFinish(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
ip := clientIP(r)
|
||||
pl := zkPending.get(r.PostFormValue("t"), ip)
|
||||
if pl == nil {
|
||||
srv.renderLoginErr(w, "", "/admin/", "Время подтверждения истекло, войдите заново.", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
st, err := zkStatus(srv.zetitKey(), pl.RequestID)
|
||||
switch {
|
||||
case err != nil:
|
||||
srv.renderLogin2FA(w, pl, "ЗЕТИТ Ключ не ответил: "+err.Error()+". Введите код из приложения.")
|
||||
case st == "approved":
|
||||
zkPending.drop(pl.Token)
|
||||
srv.finishLogin(w, r, pl.Login, ip, pl.Next, 0, " (ЗЕТИТ Ключ, push)")
|
||||
case st == "denied":
|
||||
zkPending.drop(pl.Token)
|
||||
srv.loginReject(w, pl.Login, ip, pl.Next, "вход отклонён в ЗЕТИТ Ключ", "Вход отклонён в приложении «ЗЕТИТ Ключ».")
|
||||
case st == "expired":
|
||||
srv.renderLogin2FA(w, pl, "Время подтверждения истекло. Введите код из приложения или войдите заново.")
|
||||
default:
|
||||
srv.renderLogin2FA(w, pl, "Вход ещё не подтверждён в приложении.")
|
||||
}
|
||||
}
|
||||
|
||||
// login2FACode — POST /admin/login/2fa/code: код из приложения «ЗЕТИТ Ключ».
|
||||
func (srv *server) login2FACode(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
ip := clientIP(r)
|
||||
pl := zkPending.get(r.PostFormValue("t"), ip)
|
||||
if pl == nil {
|
||||
srv.renderLoginErr(w, "", "/admin/", "Время подтверждения истекло, войдите заново.", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if srv.fails.blocked(ip+"|"+strings.ToLower(pl.Login)) || srv.fails.blocked(ip) {
|
||||
zkPending.drop(pl.Token)
|
||||
srv.renderLoginErr(w, pl.Login, pl.Next, "Слишком много неудачных попыток. Вход заблокирован на "+durRu(int64(srv.fails.window()/time.Second))+".", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
ok, err := zkCode(srv.zetitKey(), pl.Email, strings.TrimSpace(r.PostFormValue("code")))
|
||||
switch {
|
||||
case err != nil:
|
||||
srv.renderLogin2FA(w, pl, "Не удалось проверить код: "+err.Error())
|
||||
case ok:
|
||||
zkPending.drop(pl.Token)
|
||||
srv.finishLogin(w, r, pl.Login, ip, pl.Next, 0, " (ЗЕТИТ Ключ, код)")
|
||||
default:
|
||||
srv.fails.add(ip + "|" + strings.ToLower(pl.Login))
|
||||
srv.fails.add(ip)
|
||||
log.Printf("login failed: user=%q ip=%s", pl.Login, ip)
|
||||
_ = srv.st.write(func(s *State) error {
|
||||
s.addEvent("login_failed", "", "", pl.Login, ip, "неверный код ЗЕТИТ Ключ")
|
||||
return nil
|
||||
})
|
||||
srv.renderLogin2FA(w, pl, "Код неверен или уже использован.")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- настройки ----
|
||||
|
||||
func validEmail(e string) bool {
|
||||
at := strings.Index(e, "@")
|
||||
return len(e) <= 120 && at > 0 && at < len(e)-3 && strings.Contains(e[at:], ".") && !strings.ContainsAny(e, " <>\"'")
|
||||
}
|
||||
|
||||
// userEmail — POST /admin/users/{login}/email: e-mail администратора в «ЗЕТИТ Ключ».
|
||||
func (srv *server) userEmail(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||
login, email := r.PathValue("login"), strings.TrimSpace(r.PostFormValue("email"))
|
||||
if email != "" && !validEmail(email) {
|
||||
back(w, r, "/admin/users", "", "неверный e-mail")
|
||||
return
|
||||
}
|
||||
var bad string
|
||||
err := srv.st.write(func(s *State) error {
|
||||
u := s.user(login)
|
||||
if u == nil {
|
||||
return fmt.Errorf("администратор не найден")
|
||||
}
|
||||
if strings.EqualFold(u.Login, sess.Login) && email == "" && s.ZetitKey.active() {
|
||||
bad = "вход через ЗЕТИТ Ключ включён: без e-mail вы войдёте только по паролю. Сначала выключите интеграцию в «Защите»."
|
||||
return nil
|
||||
}
|
||||
u.Email = email
|
||||
s.addEvent("user_email", "", "", sess.Login, clientIP(r), "e-mail администратора "+u.Login+": "+pickStr(email, "удалён"))
|
||||
return nil
|
||||
})
|
||||
if err != nil || bad != "" {
|
||||
back(w, r, "/admin/users", "", pickStr(bad, fmt.Sprint(err)))
|
||||
return
|
||||
}
|
||||
back(w, r, "/admin/users", "E-mail "+login+" сохранён", "")
|
||||
}
|
||||
|
||||
// securityZetitKey — POST /admin/security/zetit-key: адрес API, ключ приложения, включение, проверка.
|
||||
func (srv *server) securityZetitKey(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||
op := r.PostFormValue("op")
|
||||
var conf ZetitKeyConf
|
||||
var myEmail string
|
||||
srv.st.read(func(s *State) {
|
||||
conf = s.ZetitKey
|
||||
if u := s.user(sess.Login); u != nil {
|
||||
myEmail = u.Email
|
||||
}
|
||||
})
|
||||
if u := strings.TrimSpace(r.PostFormValue("url")); u != "" {
|
||||
pu, err := url.Parse(u)
|
||||
if err != nil || pu.Scheme != "https" || pu.Host == "" {
|
||||
back(w, r, "/admin/security", "", "адрес API ЗЕТИТ Ключ: https://…")
|
||||
return
|
||||
}
|
||||
conf.URL = strings.TrimRight(u, "/")
|
||||
}
|
||||
if k := strings.TrimSpace(r.PostFormValue("app_key")); k != "" {
|
||||
conf.AppKey = k
|
||||
}
|
||||
check := func() (string, bool) {
|
||||
switch {
|
||||
case conf.AppKey == "":
|
||||
return "не задан ключ приложения", false
|
||||
case myEmail == "":
|
||||
return "укажите свой e-mail на странице «Администраторы»", false
|
||||
}
|
||||
lk, err := zkLookup(conf, myEmail)
|
||||
switch {
|
||||
case err != nil:
|
||||
return "ЗЕТИТ Ключ: " + err.Error(), false
|
||||
case !lk.Exists:
|
||||
return myEmail + " не найден в ЗЕТИТ Ключ", false
|
||||
case !lk.Has2FA:
|
||||
return myEmail + " найден в ЗЕТИТ Ключ, но приложение не привязано", false
|
||||
}
|
||||
return "ЗЕТИТ Ключ отвечает: " + myEmail + pickStr(" ("+lk.FullName+")", "") + " найден, приложение привязано", true
|
||||
}
|
||||
msg, ok := "", true
|
||||
if op == "test" || r.PostFormValue("enabled") == "1" {
|
||||
msg, ok = check()
|
||||
}
|
||||
switch {
|
||||
case op == "test":
|
||||
case r.PostFormValue("enabled") == "1" && !ok:
|
||||
back(w, r, "/admin/security", "", "Не включено: "+msg)
|
||||
return
|
||||
default:
|
||||
conf.Enabled = r.PostFormValue("enabled") == "1"
|
||||
}
|
||||
err := srv.st.write(func(s *State) error {
|
||||
s.ZetitKey = conf
|
||||
state := "выключен"
|
||||
if conf.Enabled {
|
||||
state = "включён"
|
||||
}
|
||||
s.addEvent("zetit_key", "", "", sess.Login, clientIP(r), "настройки ЗЕТИТ Ключ сохранены, вход через Ключ "+state)
|
||||
return nil
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
back(w, r, "/admin/security", "", err.Error())
|
||||
case op == "test" && !ok:
|
||||
back(w, r, "/admin/security", "", "Проверка: "+msg)
|
||||
case op == "test":
|
||||
back(w, r, "/admin/security", "Проверка: "+msg, "")
|
||||
case conf.Enabled:
|
||||
back(w, r, "/admin/security", "Вход через ЗЕТИТ Ключ включён. "+msg+".", "")
|
||||
default:
|
||||
back(w, r, "/admin/security", "Настройки ЗЕТИТ Ключ сохранены, вход через Ключ выключен.", "")
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
// Command bj-license — инструмент издателя: генерация ключей подписи,
|
||||
// выпуск годовых лицензий и проверка.
|
||||
//
|
||||
// bj-license keygen -out ./keys/license
|
||||
// bj-license issue -tenant "ООО Ромашка" -plan pro -days 365 \
|
||||
// -features updates,web-cabinet -key ./keys/license.priv -keyid main
|
||||
// bj-license verify -key-file license.key -pub ./keys/license.pub
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license"
|
||||
)
|
||||
|
||||
// newUUID — UUID v4 без внешних зависимостей.
|
||||
func newUUID() string {
|
||||
var b [16]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
b[6] = (b[6] & 0x0f) | 0x40 // версия 4
|
||||
b[8] = (b[8] & 0x3f) | 0x80 // вариант
|
||||
h := hex.EncodeToString(b[:])
|
||||
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32]
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
}
|
||||
switch os.Args[1] {
|
||||
case "keygen":
|
||||
keygen(os.Args[2:])
|
||||
case "issue":
|
||||
issue(os.Args[2:])
|
||||
case "verify":
|
||||
verify(os.Args[2:])
|
||||
default:
|
||||
usage()
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, "bj-license keygen -out <prefix>")
|
||||
fmt.Fprintln(os.Stderr, "bj-license issue -tenant <name> -plan free|pro|enterprise -days <n> -features a,b -key <priv> [-keyid id] [-max-nodes n] [-note txt]")
|
||||
fmt.Fprintln(os.Stderr, "bj-license verify -key-file <license.key> -pub <pubkey.pub>")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
func keygen(args []string) {
|
||||
out := "license"
|
||||
for i := 0; i < len(args)-1; i++ {
|
||||
if args[i] == "-out" {
|
||||
out = args[i+1]
|
||||
}
|
||||
}
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
fatal("keygen: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(out+".priv", []byte(base64.StdEncoding.EncodeToString(priv.Seed())+"\n"), 0o600); err != nil {
|
||||
fatal("write priv: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(out+".pub", []byte(base64.StdEncoding.EncodeToString(pub)+"\n"), 0o644); err != nil {
|
||||
fatal("write pub: %v", err)
|
||||
}
|
||||
fmt.Printf("Приватный ключ лицензий: %s.priv (СЕКРЕТ)\n", out)
|
||||
fmt.Printf("Публичный ключ (зашить в bj-server):\n %s\n", base64.StdEncoding.EncodeToString(pub))
|
||||
}
|
||||
|
||||
func issue(args []string) {
|
||||
a := parseArgs(args)
|
||||
tenant := a["tenant"]
|
||||
keyPath := a["key"]
|
||||
if tenant == "" || keyPath == "" {
|
||||
fatal("issue: требуются -tenant и -key")
|
||||
}
|
||||
plan := license.Plan(orDefault(a["plan"], "pro"))
|
||||
days := atoiDefault(a["days"], 365)
|
||||
keyID := orDefault(a["keyid"], "main")
|
||||
|
||||
priv, err := license.LoadPrivateKey(keyPath)
|
||||
if err != nil {
|
||||
fatal("load key: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
var feats []string
|
||||
if a["features"] != "" {
|
||||
feats = strings.Split(a["features"], ",")
|
||||
}
|
||||
l := &license.License{
|
||||
Schema: license.CurrentSchema,
|
||||
ID: newUUID(),
|
||||
Tenant: tenant,
|
||||
Product: "bj-server",
|
||||
Plan: plan,
|
||||
IssuedAt: now,
|
||||
ExpiresAt: now.AddDate(0, 0, days),
|
||||
Features: feats,
|
||||
MaxNodes: atoiDefault(a["max-nodes"], 0),
|
||||
Note: a["note"],
|
||||
}
|
||||
tok, err := license.Sign(l, priv, keyID)
|
||||
if err != nil {
|
||||
fatal("sign: %v", err)
|
||||
}
|
||||
fmt.Printf("Лицензия выпущена: tenant=%q plan=%s до %s (%d дней)\n",
|
||||
tenant, plan, l.ExpiresAt.Format("02.01.2006"), days)
|
||||
fmt.Printf("ID: %s\n", l.ID)
|
||||
fmt.Println("Ключ для клиента (вставить в bj-server → Лицензия):")
|
||||
fmt.Println(tok.Encode())
|
||||
}
|
||||
|
||||
func verify(args []string) {
|
||||
a := parseArgs(args)
|
||||
if a["key-file"] == "" || a["pub"] == "" {
|
||||
fatal("verify: требуются -key-file и -pub")
|
||||
}
|
||||
raw, err := os.ReadFile(a["key-file"])
|
||||
if err != nil {
|
||||
fatal("read key-file: %v", err)
|
||||
}
|
||||
pubB, err := os.ReadFile(a["pub"])
|
||||
if err != nil {
|
||||
fatal("read pub: %v", err)
|
||||
}
|
||||
pub, err := license.ParsePublicKey(strings.TrimSpace(string(pubB)))
|
||||
if err != nil {
|
||||
fatal("pub: %v", err)
|
||||
}
|
||||
tok, err := license.DecodeToken(string(raw))
|
||||
if err != nil {
|
||||
fatal("decode: %v", err)
|
||||
}
|
||||
l, err := license.Verify(tok, pub)
|
||||
if err != nil {
|
||||
fatal("verify: %v", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
fmt.Printf("Подпись валидна. tenant=%q plan=%s\n", l.Tenant, l.Plan)
|
||||
fmt.Printf("Действует: %s — %s (осталось %d дней)\n",
|
||||
l.IssuedAt.Format("02.01.2006"), l.ExpiresAt.Format("02.01.2006"), l.DaysLeft(now))
|
||||
if err := l.Valid(now); err != nil {
|
||||
fmt.Printf("СТАТУС: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("СТАТУС: активна, обновления %v\n", l.AllowsUpdates())
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func parseArgs(args []string) map[string]string {
|
||||
m := map[string]string{}
|
||||
for i := 0; i < len(args); i++ {
|
||||
if strings.HasPrefix(args[i], "-") && i+1 < len(args) {
|
||||
m[strings.TrimPrefix(args[i], "-")] = args[i+1]
|
||||
i++
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func orDefault(s, def string) string {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func atoiDefault(s string, def int) int {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
var n int
|
||||
_, err := fmt.Sscanf(s, "%d", &n)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func fatal(format string, a ...any) {
|
||||
fmt.Fprintf(os.Stderr, "bj-license: "+format+"\n", a...)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
# Лицензирование Bridge-and-Join-s (#19)
|
||||
|
||||
Годовые лицензии с офлайн-проверкой по подписи Ed25519 + опциональный
|
||||
онлайн-отзыв.
|
||||
|
||||
## Компоненты
|
||||
|
||||
- `internal/license` — формат лицензии + подпись/проверка (offline).
|
||||
- `cmd/bj-license` — издательский CLI: keygen, issue, verify.
|
||||
- `cmd/bj-license-server` — онлайн-реестр отзывов (revocation).
|
||||
- Интеграция в bj-server: `internal/lkgateway/licensecheck.go` — проверка
|
||||
лицензии + гейт обновлений; UI раздел «Лицензия».
|
||||
|
||||
## Модель
|
||||
|
||||
Лицензия — самодостаточный подписанный токен: `payload.signature.keyid`.
|
||||
bj-server проверяет подпись зашитым публичным ключом и срок **офлайн** —
|
||||
работает без связи с сервером. Online-сервер нужен только для отзыва.
|
||||
|
||||
**Гейт обновлений:** если лицензирование включено (есть публичный ключ),
|
||||
авто-обновление (#20) выполняется только при валидной лицензии с фичей
|
||||
`updates`. Без лицензирования (публичный ключ не зашит) — открытый режим,
|
||||
гейты не действуют (бесплатная редакция / разработка).
|
||||
|
||||
## Издателю
|
||||
|
||||
```bash
|
||||
# 1. Ключи лицензий (однократно; приватный — в секрете!)
|
||||
bj-license keygen -out ./keys/license
|
||||
# публичный base64 — зашить в bj-server (DefaultLicensePublicKey)
|
||||
|
||||
# 2. Выпустить годовую лицензию клиенту
|
||||
bj-license issue -tenant "ООО Ромашка" -plan pro -days 365 \
|
||||
-features updates,web-cabinet -key ./keys/license.priv -keyid main
|
||||
# → выводит ключ payload.signature.keyid — отдать клиенту
|
||||
|
||||
# 3. Проверить
|
||||
bj-license verify -key-file license.key -pub ./keys/license.pub
|
||||
```
|
||||
|
||||
Планы: `free` (без фич), `pro` (перечисленные features), `enterprise`
|
||||
(всё включено). Фичи: `updates`, `web-cabinet`, …
|
||||
|
||||
## Клиенту (on-prem bj-server)
|
||||
|
||||
Админ → Настройка → **Лицензия** → вставить ключ → «Активировать».
|
||||
Проверка офлайн; статус (организация, план, срок, обновления) виден сразу.
|
||||
|
||||
## Онлайн-отзыв (опционально)
|
||||
|
||||
```bash
|
||||
bj-license-server --addr :8091 --revoked /var/lib/bj-license/revoked.json
|
||||
```
|
||||
|
||||
`revoked.json` — JSON-массив отозванных license ID:
|
||||
|
||||
```json
|
||||
["28db4973-fde8-434c-b102-e83623eede2c"]
|
||||
```
|
||||
|
||||
`GET /v1/check?id=<id>` → `{"revoked":true|false}`. Перечитывается раз в
|
||||
минуту. В проде заменить файл на PostgreSQL + admin API выпуска/отзыва.
|
||||
|
||||
## Зашивка публичного ключа в релиз
|
||||
|
||||
```bash
|
||||
go build -ldflags "\
|
||||
-X .../lkgateway.DefaultLicensePublicKey=<base64-pub> \
|
||||
-X .../lkgateway.DefaultUpdatePublicKey=<base64-pub-артефактории> \
|
||||
-X .../lkgateway.BuildVersion=1.0.0" -o bj-server ./cmd/bj-server/
|
||||
```
|
||||
@@ -1,11 +0,0 @@
|
||||
[Unit]
|
||||
Description=Bridge&Join — команды защиты из кабинета сервера лицензий
|
||||
|
||||
[Path]
|
||||
PathChanged=/var/lib/bj-license/fw-control.json
|
||||
PathChanged=/var/lib/bj-license/allow-ips.txt
|
||||
PathChanged=/var/lib/bj-license/trusted-ips.txt
|
||||
Unit=bj-license-fw.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,7 +0,0 @@
|
||||
[Unit]
|
||||
Description=Bridge&Join — применение защиты сервера лицензий (белый список, режим обороны, команды fail2ban)
|
||||
After=fail2ban.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/opt/bj-license/bj-license-server fw-apply -dir /var/lib/bj-license
|
||||
@@ -1,11 +0,0 @@
|
||||
[Unit]
|
||||
Description=Bridge&Join — статус защиты сервера лицензий раз в 30 секунд (и восстановление правил после перезагрузки)
|
||||
|
||||
[Timer]
|
||||
OnBootSec=30s
|
||||
OnUnitActiveSec=30s
|
||||
AccuracySec=5s
|
||||
Unit=bj-license-fw.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -1,22 +0,0 @@
|
||||
[Unit]
|
||||
Description=Bridge&Join — сервер лицензирования (lic.zetit.ru)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=bj-license
|
||||
Group=bj-license
|
||||
ExecStart=/opt/bj-license/bj-license-server serve -addr 127.0.0.1:8091 -data /var/lib/bj-license/state.json -key /etc/bj-license/license.priv -keyid main
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ReadWritePaths=/var/lib/bj-license
|
||||
ReadOnlyPaths=/etc/bj-license
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,6 +0,0 @@
|
||||
# fail2ban: перебор паролей кабинета сервера лицензий bj
|
||||
# (строки пишет bj-license-server в журнал systemd; IP клиента берётся из X-Real-IP nginx).
|
||||
[Definition]
|
||||
failregex = ^.*login failed: user=".*" ip=<HOST>$
|
||||
ignoreregex =
|
||||
journalmatch = _SYSTEMD_UNIT=bj-license-server.service
|
||||
@@ -1,5 +0,0 @@
|
||||
# fail2ban: перебор кодов активации и токенов API сервера лицензий bj.
|
||||
[Definition]
|
||||
failregex = ^.*api denied: (unknown_code|bad_token) ip=<HOST>$
|
||||
ignoreregex =
|
||||
journalmatch = _SYSTEMD_UNIT=bj-license-server.service
|
||||
@@ -1,30 +0,0 @@
|
||||
# Сервер лицензий lic.zetit.ru: блокировка IP на уровне файрвола.
|
||||
|
||||
# Вход в кабинет: не блокируется только белый список администраторов (раздел «Защита»).
|
||||
[bj-license-admin]
|
||||
enabled = true
|
||||
backend = systemd
|
||||
filter = bj-license-admin
|
||||
port = http,https
|
||||
maxretry = 10
|
||||
findtime = 10m
|
||||
bantime = 1h
|
||||
ignorecommand = /opt/bj-license/bj-license-server fw-ignore -dir /var/lib/bj-license <ip> admin
|
||||
|
||||
# API активации и продления: не блокируются адреса установок, успешно активировавших или продливших
|
||||
# лицензию (trusted-ips.txt), и белый список администраторов (allow-ips.txt).
|
||||
[bj-license-api]
|
||||
enabled = true
|
||||
backend = systemd
|
||||
filter = bj-license-api
|
||||
port = http,https
|
||||
maxretry = 10
|
||||
findtime = 10m
|
||||
bantime = 1h
|
||||
ignorecommand = /opt/bj-license/bj-license-server fw-ignore -dir /var/lib/bj-license <ip> api
|
||||
|
||||
[sshd]
|
||||
enabled = true
|
||||
maxretry = 5
|
||||
findtime = 10m
|
||||
bantime = 1h
|
||||
@@ -1,5 +1,8 @@
|
||||
# Сервер лицензирования и обновлений bj — контракт API
|
||||
|
||||
> Сервер лицензий живёт в отдельном репозитории **zuevav/lic_Bridge-and-Join-s** (наша инфраструктура, клиентам не поставляется). Здесь — контракт API для клиентской части bj (`internal/lkgateway/licenseclient.go`). Формат токена общий: `internal/license`, тест `TestTokenCompat` одинаков в обоих репозиториях.
|
||||
|
||||
|
||||
Сервер: **https://lic.zetit.ru** (ВМ 46.173.19.220, Ubuntu 26.04). Адрес зашит в bj
|
||||
(`internal/lkgateway`, константа), в интерфейсе не меняется. С этого же сервера bj
|
||||
получает обновления.
|
||||
|
||||
@@ -11,8 +11,6 @@ require (
|
||||
google.golang.org/protobuf v1.36.11
|
||||
)
|
||||
|
||||
require rsc.io/qr v0.2.0 // indirect
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
|
||||
|
||||
@@ -12,6 +12,8 @@ github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDP
|
||||
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -37,6 +39,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -45,6 +49,18 @@ github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
|
||||
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/microsoft/go-mssqldb v1.10.0 h1:pHEt+Qz6YFPWqREq10mqSE524QQo+/QremwTCQht7TY=
|
||||
@@ -94,5 +110,3 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package license
|
||||
|
||||
// Совместимость формата лицензии между bj (проверяет токены) и сервером лицензий
|
||||
// lic_Bridge-and-Join-s (выпускает их). Пакет лежит копией в обоих репозиториях;
|
||||
// этот тест с одинаковым ожидаемым токеном есть в обоих — если формат разойдётся,
|
||||
// упадёт тот, где пакет изменили. Подпись Ed25519 детерминирована.
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const compatToken = "eyJzY2hlbWEiOjEsImlkIjoiMTExMTExMTEtMjIyMi0zMzMzLTQ0NDQtNTU1NTU1NTU1NTU1IiwidGVuYW50Ijoi0J7QntCeIMKr0J/RgNC+0LLQtdGA0LrQsMK7IiwicHJvZHVjdCI6ImJqLXNlcnZlciIsInBsYW4iOiJwcm8iLCJpc3N1ZWRfYXQiOiIyMDI2LTA5LTAxVDAwOjAwOjAwWiIsImV4cGlyZXNfYXQiOiIyMDI2LTExLTAxVDAwOjAwOjAwWiIsImZlYXR1cmVzIjpbInVwZGF0ZXMiLCJucHIiXSwibWF4X25vZGVzIjoyLCJpbnN0YWxsX2lkIjoiYWFhYWFhYWEtYmJiYi1jY2NjLWRkZGQtZWVlZWVlZWVlZWVlIiwiY29udHJhY3RfdW50aWwiOiIyMDI3LTA5LTAxVDAwOjAwOjAwWiJ9.VIKhe7NZxwvTQfCNCNMwrADvlWd/izmzZrE2h1rkwliOBHQd5rGU00/v/V8uArkw4YShc5NW4BLY35wIPQ3GDg==.main"
|
||||
|
||||
func TestTokenCompat(t *testing.T) {
|
||||
seed := make([]byte, ed25519.SeedSize)
|
||||
for i := range seed {
|
||||
seed[i] = byte(i + 1)
|
||||
}
|
||||
priv := ed25519.NewKeyFromSeed(seed)
|
||||
l := &License{
|
||||
ID: "11111111-2222-3333-4444-555555555555", Tenant: "ООО «Проверка»", Product: "bj-server", Plan: PlanPro,
|
||||
IssuedAt: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), ExpiresAt: time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC),
|
||||
Features: []string{"updates", "npr"}, MaxNodes: 2,
|
||||
InstallID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", ContractUntil: time.Date(2027, 9, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
tok, err := Sign(l, priv, "main")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := tok.Encode(); got != compatToken {
|
||||
t.Fatalf("формат токена изменился — синхронизируйте internal/license в bj и сервере лицензий\nполучено: %s", got)
|
||||
}
|
||||
back, err := DecodeToken(compatToken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, err := Verify(back, priv.Public().(ed25519.PublicKey))
|
||||
if err != nil || v.InstallID != l.InstallID || !v.ContractUntil.Equal(l.ContractUntil) || v.Tenant != l.Tenant {
|
||||
t.Fatalf("проверка токена: %v %+v", err, v)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user