86aa452ed6
- 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>
1236 lines
40 KiB
Go
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", "Ваш пароль изменён", "")
|
|
}
|