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", "Ваш пароль изменён", "") }