Files
Bridge-and-Join-s/cmd/bj-license-server/admin.go
T
zuevav 86aa452ed6 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>
2026-09-15 19:41:11 +03:00

1236 lines
40 KiB
Go

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