feat(license): сервер лицензий lic.zetit.ru — кабинет, онлайн-активация и защита

- bj-license-server: активация по коду и продление токенов (API /v1), кабинет
  администратора (клиенты, лицензии, установки, журнал, администраторы)
- защита: доверенные IP установок, jail fail2ban api/admin, раздел «Защита»
  с режимом обороны, белым списком и сроками блокировок (root-помощник fw-apply)
- доступ к кабинету: свой адрес вместо /admin, вход только из белого списка,
  второй фактор — TOTP или подтверждение в «ЗЕТИТ Ключ» (push/код)
- deploy/license: unit-файлы, fail2ban, path/timer помощника; контракт API в docs

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-09-15 19:41:11 +03:00
parent 586ffb3a31
commit 86aa452ed6
19 changed files with 4659 additions and 99 deletions
+389
View File
@@ -0,0 +1,389 @@
package main
// Второй фактор через «ЗЕТИТ Ключ» — так же, как вход в ZETIT Connect. После пароля сервер лицензий
// просит Ключ подтвердить вход: push-уведомление на телефон администратора или код из приложения.
// Статус опрашивает только сервер; вход разрешается лишь при approved (push) или success (код).
// API: Zetit_key/docs/EXTERNAL_APP_2FA.md, способ 1 (приложение с X-Connect-Key).
//
// Действует для администраторов с указанным e-mail, когда интеграция включена в разделе «Защита».
// Остальные входят по паролю и, если подключён, по коду приложения-аутентификатора (access.go).
// Аварийно выключить по SSH: bj-license-server admin-config -zetit-key off (сервер остановлен).
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
const (
zetitKeyDefaultURL = "https://key.zetit.ru/api/v1/connect-auth"
zkPendingTTL = 5 * time.Minute // push живёт 60 секунд, код из приложения можно ввести и позже
)
// ZetitKeyConf — настройки интеграции с «ЗЕТИТ Ключ».
type ZetitKeyConf struct {
Enabled bool `json:"enabled,omitempty"`
URL string `json:"url,omitempty"` // по умолчанию https://key.zetit.ru/api/v1/connect-auth
AppKey string `json:"app_key,omitempty"` // X-Connect-Key приложения из панели Ключа
}
func (c ZetitKeyConf) active() bool { return c.Enabled && c.AppKey != "" }
var zkClient = &http.Client{Timeout: 8 * time.Second}
func zkCall(c ZetitKeyConf, method, path string, body, out any) error {
var rd io.Reader
if body != nil {
b, _ := json.Marshal(body)
rd = bytes.NewReader(b)
}
req, err := http.NewRequest(method, strings.TrimRight(pickStr(c.URL, zetitKeyDefaultURL), "/")+path, rd)
if err != nil {
return err
}
req.Header.Set("X-Connect-Key", c.AppKey)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := zkClient.Do(req)
if err != nil {
return fmt.Errorf("сервер недоступен: %v", err)
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("ответ %d: %s", resp.StatusCode, clip(string(data), 200))
}
if out != nil {
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("ответ не разобран: %v", err)
}
}
return nil
}
type zkLookupResult struct {
Exists bool `json:"exists"`
Has2FA bool `json:"has_2fa"`
FullName string `json:"full_name"`
}
type zkVerifyResult struct {
RequestID string `json:"request_id"`
Status string `json:"status"`
NeedsSetup bool `json:"needs_setup"`
}
func zkLookup(c ZetitKeyConf, email string) (zkLookupResult, error) {
var r zkLookupResult
return r, zkCall(c, http.MethodGet, "/lookup?email="+url.QueryEscape(email), nil, &r)
}
func zkStart(c ZetitKeyConf, email, ip string) (zkVerifyResult, error) {
var r zkVerifyResult
return r, zkCall(c, http.MethodPost, "/verify", map[string]string{"email": email, "source_ip": ip}, &r)
}
func zkStatus(c ZetitKeyConf, requestID string) (string, error) {
var r zkVerifyResult
err := zkCall(c, http.MethodGet, "/verify/"+url.PathEscape(requestID), nil, &r)
return r.Status, err
}
func zkCode(c ZetitKeyConf, email, code string) (bool, error) {
var r struct {
Success bool `json:"success"`
}
err := zkCall(c, http.MethodPost, "/verify-totp", map[string]string{"email": email, "code": code}, &r)
return r.Success, err
}
// ---- вход с подтверждением ----
// pendingLogin — пароль проверен, ждём подтверждения в Ключе.
type pendingLogin struct {
Token, Login, Email, IP, RequestID, Next string
Created time.Time
}
type pendingLogins struct {
mu sync.Mutex
m map[string]*pendingLogin
}
var zkPending = &pendingLogins{m: map[string]*pendingLogin{}}
func (p *pendingLogins) put(pl *pendingLogin) {
p.mu.Lock()
defer p.mu.Unlock()
for k, v := range p.m {
if time.Since(v.Created) > zkPendingTTL {
delete(p.m, k)
}
}
p.m[pl.Token] = pl
}
// get — ожидающий вход по токену с того же адреса.
func (p *pendingLogins) get(token, ip string) *pendingLogin {
p.mu.Lock()
defer p.mu.Unlock()
pl := p.m[token]
if pl == nil || pl.IP != ip || time.Since(pl.Created) > zkPendingTTL {
return nil
}
return pl
}
func (p *pendingLogins) drop(token string) {
p.mu.Lock()
delete(p.m, token)
p.mu.Unlock()
}
func (srv *server) zetitKey() ZetitKeyConf {
var c ZetitKeyConf
srv.st.read(func(s *State) { c = s.ZetitKey })
return c
}
// finishLogin — второй фактор пройден (или не требуется): сессия и переход в кабинет.
func (srv *server) finishLogin(w http.ResponseWriter, r *http.Request, login, ip, next string, totpStep int64, how string) {
srv.fails.reset(ip + "|" + strings.ToLower(login))
sess := srv.sessions.create(login)
_ = srv.st.write(func(s *State) error {
if u := s.user(login); u != nil {
u.LastLogin = time.Now()
sess.Login = u.Login
if totpStep > 0 {
u.TOTPLast = totpStep
}
}
s.addEvent("login", "", "", login, ip, "вход в кабинет"+how)
return nil
})
srv.setSessionCookie(w, r, sess.Token)
http.Redirect(w, r, next, http.StatusSeeOther)
}
func (srv *server) renderLoginErr(w http.ResponseWriter, login, next, msg string, status int) {
p := page{Title: "Вход", Err: msg, Data: map[string]string{"Next": next, "Login": login}}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_ = srv.tpl.ExecuteTemplate(w, "login", p)
}
// loginReject — отказ на втором факторе: счётчик неудач, строка для fail2ban, журнал.
func (srv *server) loginReject(w http.ResponseWriter, login, ip, next, eventText, msg string) {
srv.fails.add(ip + "|" + strings.ToLower(login))
srv.fails.add(ip)
log.Printf("login failed: user=%q ip=%s", login, ip) // строку ловит fail2ban (deploy/license/fail2ban)
time.Sleep(600 * time.Millisecond)
_ = srv.st.write(func(s *State) error {
s.addEvent("login_failed", "", "", login, ip, eventText)
return nil
})
srv.renderLoginErr(w, login, next, msg, http.StatusUnauthorized)
}
func (srv *server) renderLogin2FA(w http.ResponseWriter, pl *pendingLogin, errMsg string) {
left := 60 - int(time.Since(pl.Created).Seconds())
if left < 0 {
left = 0
}
p := page{Title: "Подтверждение входа", Err: errMsg, Data: map[string]any{"Token": pl.Token, "Email": pl.Email, "Left": left}}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = srv.tpl.ExecuteTemplate(w, "login2fa", p)
}
// login2FAStatus — GET /admin/login/2fa/status?t=…: статус push, опрос идёт с сервера.
func (srv *server) login2FAStatus(w http.ResponseWriter, r *http.Request) {
pl := zkPending.get(r.URL.Query().Get("t"), clientIP(r))
status := "expired"
if pl != nil && time.Since(pl.Created) <= 70*time.Second {
if st, err := zkStatus(srv.zetitKey(), pl.RequestID); err == nil {
status = st
} else {
status = "pending" // сбой связи — страница спросит ещё раз
}
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, map[string]string{"status": status})
}
// login2FAFinish — POST /admin/login/2fa/finish: страница увидела ответ, сервер перепроверяет его сам.
func (srv *server) login2FAFinish(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
ip := clientIP(r)
pl := zkPending.get(r.PostFormValue("t"), ip)
if pl == nil {
srv.renderLoginErr(w, "", "/admin/", "Время подтверждения истекло, войдите заново.", http.StatusUnauthorized)
return
}
st, err := zkStatus(srv.zetitKey(), pl.RequestID)
switch {
case err != nil:
srv.renderLogin2FA(w, pl, "ЗЕТИТ Ключ не ответил: "+err.Error()+". Введите код из приложения.")
case st == "approved":
zkPending.drop(pl.Token)
srv.finishLogin(w, r, pl.Login, ip, pl.Next, 0, " (ЗЕТИТ Ключ, push)")
case st == "denied":
zkPending.drop(pl.Token)
srv.loginReject(w, pl.Login, ip, pl.Next, "вход отклонён в ЗЕТИТ Ключ", "Вход отклонён в приложении «ЗЕТИТ Ключ».")
case st == "expired":
srv.renderLogin2FA(w, pl, "Время подтверждения истекло. Введите код из приложения или войдите заново.")
default:
srv.renderLogin2FA(w, pl, "Вход ещё не подтверждён в приложении.")
}
}
// login2FACode — POST /admin/login/2fa/code: код из приложения «ЗЕТИТ Ключ».
func (srv *server) login2FACode(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
ip := clientIP(r)
pl := zkPending.get(r.PostFormValue("t"), ip)
if pl == nil {
srv.renderLoginErr(w, "", "/admin/", "Время подтверждения истекло, войдите заново.", http.StatusUnauthorized)
return
}
if srv.fails.blocked(ip+"|"+strings.ToLower(pl.Login)) || srv.fails.blocked(ip) {
zkPending.drop(pl.Token)
srv.renderLoginErr(w, pl.Login, pl.Next, "Слишком много неудачных попыток. Вход заблокирован на "+durRu(int64(srv.fails.window()/time.Second))+".", http.StatusTooManyRequests)
return
}
ok, err := zkCode(srv.zetitKey(), pl.Email, strings.TrimSpace(r.PostFormValue("code")))
switch {
case err != nil:
srv.renderLogin2FA(w, pl, "Не удалось проверить код: "+err.Error())
case ok:
zkPending.drop(pl.Token)
srv.finishLogin(w, r, pl.Login, ip, pl.Next, 0, " (ЗЕТИТ Ключ, код)")
default:
srv.fails.add(ip + "|" + strings.ToLower(pl.Login))
srv.fails.add(ip)
log.Printf("login failed: user=%q ip=%s", pl.Login, ip)
_ = srv.st.write(func(s *State) error {
s.addEvent("login_failed", "", "", pl.Login, ip, "неверный код ЗЕТИТ Ключ")
return nil
})
srv.renderLogin2FA(w, pl, "Код неверен или уже использован.")
}
}
// ---- настройки ----
func validEmail(e string) bool {
at := strings.Index(e, "@")
return len(e) <= 120 && at > 0 && at < len(e)-3 && strings.Contains(e[at:], ".") && !strings.ContainsAny(e, " <>\"'")
}
// userEmail — POST /admin/users/{login}/email: e-mail администратора в «ЗЕТИТ Ключ».
func (srv *server) userEmail(w http.ResponseWriter, r *http.Request, sess *session) {
login, email := r.PathValue("login"), strings.TrimSpace(r.PostFormValue("email"))
if email != "" && !validEmail(email) {
back(w, r, "/admin/users", "", "неверный e-mail")
return
}
var bad string
err := srv.st.write(func(s *State) error {
u := s.user(login)
if u == nil {
return fmt.Errorf("администратор не найден")
}
if strings.EqualFold(u.Login, sess.Login) && email == "" && s.ZetitKey.active() {
bad = "вход через ЗЕТИТ Ключ включён: без e-mail вы войдёте только по паролю. Сначала выключите интеграцию в «Защите»."
return nil
}
u.Email = email
s.addEvent("user_email", "", "", sess.Login, clientIP(r), "e-mail администратора "+u.Login+": "+pickStr(email, "удалён"))
return nil
})
if err != nil || bad != "" {
back(w, r, "/admin/users", "", pickStr(bad, fmt.Sprint(err)))
return
}
back(w, r, "/admin/users", "E-mail "+login+" сохранён", "")
}
// securityZetitKey — POST /admin/security/zetit-key: адрес API, ключ приложения, включение, проверка.
func (srv *server) securityZetitKey(w http.ResponseWriter, r *http.Request, sess *session) {
op := r.PostFormValue("op")
var conf ZetitKeyConf
var myEmail string
srv.st.read(func(s *State) {
conf = s.ZetitKey
if u := s.user(sess.Login); u != nil {
myEmail = u.Email
}
})
if u := strings.TrimSpace(r.PostFormValue("url")); u != "" {
pu, err := url.Parse(u)
if err != nil || pu.Scheme != "https" || pu.Host == "" {
back(w, r, "/admin/security", "", "адрес API ЗЕТИТ Ключ: https://…")
return
}
conf.URL = strings.TrimRight(u, "/")
}
if k := strings.TrimSpace(r.PostFormValue("app_key")); k != "" {
conf.AppKey = k
}
check := func() (string, bool) {
switch {
case conf.AppKey == "":
return "не задан ключ приложения", false
case myEmail == "":
return "укажите свой e-mail на странице «Администраторы»", false
}
lk, err := zkLookup(conf, myEmail)
switch {
case err != nil:
return "ЗЕТИТ Ключ: " + err.Error(), false
case !lk.Exists:
return myEmail + " не найден в ЗЕТИТ Ключ", false
case !lk.Has2FA:
return myEmail + " найден в ЗЕТИТ Ключ, но приложение не привязано", false
}
return "ЗЕТИТ Ключ отвечает: " + myEmail + pickStr(" ("+lk.FullName+")", "") + " найден, приложение привязано", true
}
msg, ok := "", true
if op == "test" || r.PostFormValue("enabled") == "1" {
msg, ok = check()
}
switch {
case op == "test":
case r.PostFormValue("enabled") == "1" && !ok:
back(w, r, "/admin/security", "", "Не включено: "+msg)
return
default:
conf.Enabled = r.PostFormValue("enabled") == "1"
}
err := srv.st.write(func(s *State) error {
s.ZetitKey = conf
state := "выключен"
if conf.Enabled {
state = "включён"
}
s.addEvent("zetit_key", "", "", sess.Login, clientIP(r), "настройки ЗЕТИТ Ключ сохранены, вход через Ключ "+state)
return nil
})
switch {
case err != nil:
back(w, r, "/admin/security", "", err.Error())
case op == "test" && !ok:
back(w, r, "/admin/security", "", "Проверка: "+msg)
case op == "test":
back(w, r, "/admin/security", "Проверка: "+msg, "")
case conf.Enabled:
back(w, r, "/admin/security", "Вход через ЗЕТИТ Ключ включён. "+msg+".", "")
default:
back(w, r, "/admin/security", "Настройки ЗЕТИТ Ключ сохранены, вход через Ключ выключен.", "")
}
}