Files
Bridge-and-Join-s/cmd/bj-license-server/access.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

513 lines
19 KiB
Go

package main
// Доступ к кабинету: свой адрес вместо /admin, вход только из белого списка администраторов и
// второй фактор (TOTP, RFC 6238).
//
// Маршруты кабинета внутри остаются /admin/…: adminGate переписывает внешний адрес (State.AdminPath)
// во внутренний, на прямые обращения к /admin при своём адресе отвечает 404 и подменяет Location в
// ответах. Ссылки в шаблонах строятся функцией {{adm}}. При AdminAllowOnly кабинет и страница входа
// видны только адресам белого списка администраторов, остальным — 404. API /v1 не затрагивается.
//
// По SSH: bj-license-server admin-info — адрес и настройки доступа; fw-apply -off — аварийно открыть
// кабинет всем и выключить оборону; admin-config — изменить доступ при остановленном сервере.
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"encoding/base32"
"encoding/binary"
"flag"
"fmt"
"html/template"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"rsc.io/qr"
)
const (
adminDefault = "/admin"
adminOpenFile = "admin-open" // создаёт fw-apply -off: кабинет открыт всем до повторного включения ограничения
totpIssuer = "BJ License (lic.zetit.ru)"
publicHost = "lic.zetit.ru"
)
var adminSlugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{7,63}$`)
// adminBase — внешний адрес кабинета: "/admin" или "/<AdminPath>".
func (srv *server) adminBase() string {
base := adminDefault
srv.st.read(func(s *State) {
if s.AdminPath != "" {
base = "/" + s.AdminPath
}
})
return base
}
func (srv *server) adminOpen() bool {
_, err := os.Stat(filepath.Join(srv.dataDir(), adminOpenFile))
return err == nil
}
// adminIPBlocked — кабинет ограничен белым списком, а адрес в него не входит.
func (srv *server) adminIPBlocked(ip string) bool {
blocked := false
srv.st.read(func(s *State) { blocked = s.AdminAllowOnly && !s.allowCovers(ip, false) })
return blocked && !srv.adminOpen()
}
// locRewriter — Location: /admin/… в ответах кабинета заменяется текущим адресом кабинета.
type locRewriter struct {
http.ResponseWriter
srv *server
done bool
}
func (lw *locRewriter) fix() {
if lw.done {
return
}
lw.done = true
h := lw.Header()
if l := h.Get("Location"); l == adminDefault || strings.HasPrefix(l, adminDefault+"/") || strings.HasPrefix(l, adminDefault+"?") {
h.Set("Location", lw.srv.adminBase()+strings.TrimPrefix(l, adminDefault))
}
}
func (lw *locRewriter) WriteHeader(code int) {
lw.fix()
lw.ResponseWriter.WriteHeader(code)
}
func (lw *locRewriter) Write(b []byte) (int, error) {
lw.fix()
return lw.ResponseWriter.Write(b)
}
// adminGate — внешний адрес кабинета, 404 на /admin при своём адресе, ограничение белым списком.
func (srv *server) adminGate(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base, p := srv.adminBase(), r.URL.Path
under := func(prefix string) bool { return p == prefix || strings.HasPrefix(p, prefix+"/") }
switch {
case base != adminDefault && under(adminDefault):
http.NotFound(w, r)
return
case base != adminDefault && under(base):
r2 := r.Clone(r.Context())
r2.URL.Path = adminDefault + strings.TrimPrefix(p, base)
r2.URL.RawPath = ""
r = r2
case !under(adminDefault):
h.ServeHTTP(w, r) // API, healthz, главная
return
}
if srv.adminIPBlocked(clientIP(r)) {
http.NotFound(w, r)
return
}
h.ServeHTTP(&locRewriter{ResponseWriter: w, srv: srv}, r)
})
}
func (srv *server) setSessionCookie(w http.ResponseWriter, r *http.Request, token string) {
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: token, Path: srv.adminBase(), HttpOnly: true, Secure: isHTTPS(r),
SameSite: http.SameSiteStrictMode, MaxAge: int(sessionMax.Seconds())})
}
// ---- TOTP ----
var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)
func newTOTPSecret() string {
b := make([]byte, 20)
_, _ = rand.Read(b)
return b32.EncodeToString(b)
}
func totpAt(secret string, counter int64) (string, bool) {
key, err := b32.DecodeString(strings.ToUpper(secret))
if err != nil {
return "", false
}
var msg [8]byte
binary.BigEndian.PutUint64(msg[:], uint64(counter))
m := hmac.New(sha1.New, key)
m.Write(msg[:])
sum := m.Sum(nil)
off := sum[len(sum)-1] & 0x0f
v := (binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff) % 1000000
return fmt.Sprintf("%06d", v), true
}
// totpCheck — код текущего, предыдущего или следующего 30-секундного шага, ещё не использованный
// (шаг больше last). Возвращает шаг, чтобы запомнить его и не принять тот же код повторно.
func totpCheck(secret, code string, last int64, now time.Time) (int64, bool) {
code = strings.ReplaceAll(strings.TrimSpace(code), " ", "")
if len(code) != 6 {
return 0, false
}
step := now.Unix() / 30
for d := int64(-1); d <= 1; d++ {
c := step + d
if c <= last {
continue
}
if want, ok := totpAt(secret, c); ok && hmac.Equal([]byte(want), []byte(code)) {
return c, true
}
}
return 0, false
}
func totpURI(login, secret string) string {
return "otpauth://totp/" + url.PathEscape(totpIssuer+":"+login) + "?secret=" + secret +
"&issuer=" + url.QueryEscape(totpIssuer) + "&algorithm=SHA1&digits=6&period=30"
}
// qrSVG — QR-код строкой SVG (с полем в 4 модуля).
func qrSVG(text string) template.HTML {
c, err := qr.Encode(text, qr.M)
if err != nil {
return ""
}
n := c.Size + 8
var b strings.Builder
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" width="220" height="220" shape-rendering="crispEdges" role="img" aria-label="QR-код для приложения"><rect width="%d" height="%d" fill="#fff"/><path fill="#000" d="`, n, n, n, n)
for y := 0; y < c.Size; y++ {
for x := 0; x < c.Size; x++ {
if c.Black(x, y) {
fmt.Fprintf(&b, "M%d %dh1v1h-1z", x+4, y+4)
}
}
}
b.WriteString(`"/></svg>`)
return template.HTML(b.String())
}
func groupBy4(s string) string {
var parts []string
for i := 0; i < len(s); i += 4 {
parts = append(parts, s[i:min(i+4, len(s))])
}
return strings.Join(parts, " ")
}
// ---- страницы ----
type totpPageData struct {
Enabled bool
SecretGrouped string
URI template.URL
QR template.HTML
}
// meTOTPPage — GET /admin/me/2fa: подключение или отключение второго фактора.
func (srv *server) meTOTPPage(w http.ResponseWriter, r *http.Request, sess *session) {
var d totpPageData
srv.st.read(func(s *State) {
if u := s.user(sess.Login); u != nil {
d.Enabled = u.TOTPSecret != ""
}
})
if !d.Enabled {
srv.sessions.mu.Lock()
if sess.PendingTOTP == "" {
sess.PendingTOTP = newTOTPSecret()
}
secret := sess.PendingTOTP
srv.sessions.mu.Unlock()
uri := totpURI(sess.Login, secret)
d.SecretGrouped, d.URI, d.QR = groupBy4(secret), template.URL(uri), qrSVG(uri)
}
w.Header().Set("Cache-Control", "no-store")
srv.render(w, r, sess, "totp", "Второй фактор", "users", d)
}
func (srv *server) meTOTPEnable(w http.ResponseWriter, r *http.Request, sess *session) {
srv.sessions.mu.Lock()
secret := sess.PendingTOTP
srv.sessions.mu.Unlock()
if secret == "" {
back(w, r, "/admin/me/2fa", "", "обновите страницу и добавьте ключ в приложение заново")
return
}
step, ok := totpCheck(secret, r.PostFormValue("code"), 0, time.Now())
if !ok {
back(w, r, "/admin/me/2fa", "", "код не подошёл: проверьте, что время на телефоне точное, и введите текущий код")
return
}
err := srv.st.write(func(s *State) error {
u := s.user(sess.Login)
if u == nil {
return fmt.Errorf("администратор не найден")
}
u.TOTPSecret, u.TOTPLast = secret, step
s.addEvent("totp_on", "", "", sess.Login, clientIP(r), "подключён второй фактор входа")
return nil
})
if err != nil {
back(w, r, "/admin/me/2fa", "", err.Error())
return
}
srv.sessions.mu.Lock()
sess.PendingTOTP = ""
srv.sessions.mu.Unlock()
back(w, r, "/admin/me/2fa", "Второй фактор подключён: при входе теперь нужен код из приложения.", "")
}
func (srv *server) meTOTPDisable(w http.ResponseWriter, r *http.Request, sess *session) {
var bad string
err := srv.st.write(func(s *State) error {
u := s.user(sess.Login)
if u == nil || u.TOTPSecret == "" {
bad = "второй фактор не подключён"
return nil
}
if _, ok := totpCheck(u.TOTPSecret, r.PostFormValue("code"), 0, time.Now()); !ok {
bad = "код не подошёл"
return nil
}
u.TOTPSecret, u.TOTPLast = "", 0
s.addEvent("totp_off", "", "", sess.Login, clientIP(r), "второй фактор входа отключён")
return nil
})
if err != nil || bad != "" {
back(w, r, "/admin/me/2fa", "", pickStr(bad, fmt.Sprint(err)))
return
}
back(w, r, "/admin/me/2fa", "Второй фактор отключён.", "")
}
// userTOTPReset — POST /admin/users/{login}/2fa-reset: сброс второго фактора другого администратора.
func (srv *server) userTOTPReset(w http.ResponseWriter, r *http.Request, sess *session) {
login := r.PathValue("login")
if strings.EqualFold(login, sess.Login) {
back(w, r, "/admin/users", "", "свой второй фактор отключается на странице «Мой второй фактор» с кодом из приложения")
return
}
err := srv.st.write(func(s *State) error {
u := s.user(login)
if u == nil {
return fmt.Errorf("администратор не найден")
}
u.TOTPSecret, u.TOTPLast = "", 0
s.addEvent("totp_reset", "", "", sess.Login, clientIP(r), "сброшен второй фактор администратора "+u.Login)
return nil
})
if err != nil {
back(w, r, "/admin/users", "", err.Error())
return
}
srv.sessions.dropUser(login, "")
back(w, r, "/admin/users", "Второй фактор "+login+" сброшен, его сессии завершены. При следующем входе хватит пароля.", "")
}
// securityAccess — POST /admin/security/access: адрес кабинета и ограничение белым списком.
func (srv *server) securityAccess(w http.ResponseWriter, r *http.Request, sess *session) {
op, myIP := r.PostFormValue("op"), clientIP(r)
oldBase := srv.adminBase()
var bad, okMsg string
err := srv.st.write(func(s *State) error {
switch op {
case "path", "path-random", "path-default":
slug := strings.ToLower(strings.Trim(strings.TrimSpace(r.PostFormValue("path")), "/"))
switch {
case op == "path-random":
slug = "k" + randHex(12)
case op == "path-default" || slug == "admin":
slug = ""
case !adminSlugRe.MatchString(slug):
bad = "адрес: от 8 до 64 символов — латинские строчные буквы, цифры и дефис"
return nil
}
if slug == s.AdminPath {
bad = "адрес не изменился"
return nil
}
s.AdminPath = slug
shown := adminDefault
if slug != "" {
shown = "/" + slug
}
s.addEvent("admin_path", "", "", sess.Login, myIP, "адрес кабинета изменён на "+shown)
okMsg = "Новый адрес кабинета: https://" + publicHost + shown + "/ — сохраните его в закладки. Прежний адрес больше не открывается."
case "allow-only-on":
if n, ok := normalizeNet(myIP); ok && !s.allowCovers(myIP, false) {
s.AllowIPs = append(s.AllowIPs, &AllowIP{Net: n, Comment: "добавлен автоматически при ограничении кабинета", AddedBy: sess.Login, AddedAt: time.Now()})
s.addEvent("fw_allow_add", "", "", sess.Login, myIP, "IP "+n+" добавлен в белый список (ограничение кабинета)")
}
s.AdminAllowOnly = true
s.addEvent("admin_allow_only_on", "", "", sess.Login, myIP, "кабинет доступен только из белого списка администраторов")
_ = os.Remove(filepath.Join(srv.dataDir(), adminOpenFile))
okMsg = "Кабинет открывается только с адресов белого списка, остальным — «не найдено»."
case "allow-only-off":
s.AdminAllowOnly = false
s.addEvent("admin_allow_only_off", "", "", sess.Login, myIP, "ограничение кабинета белым списком снято")
okMsg = "Ограничение снято: страница входа доступна с любого адреса."
default:
bad = "неизвестная команда"
}
return nil
})
if err != nil || bad != "" {
back(w, r, "/admin/security", "", pickStr(bad, fmt.Sprint(err)))
return
}
if newBase := srv.adminBase(); newBase != oldBase {
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: oldBase, HttpOnly: true, Secure: isHTTPS(r), MaxAge: -1})
srv.setSessionCookie(w, r, sess.Token)
}
back(w, r, "/admin/security", okMsg, "")
}
// ---- консоль ----
type multiFlag []string
func (m *multiFlag) String() string { return strings.Join(*m, ",") }
func (m *multiFlag) Set(v string) error { *m = append(*m, v); return nil }
// adminConfig — `bj-license-server admin-config`: доступ к кабинету из консоли (сервер остановлен).
func adminConfig(args []string) {
fs := flag.NewFlagSet("admin-config", flag.ExitOnError)
data := fs.String("data", "/var/lib/bj-license/state.json", "файл состояния")
var allow multiFlag
fs.Var(&allow, "allow", "добавить в белый список администраторов: IP[=комментарий] (флаг можно повторять)")
path := fs.String("path", "", "адрес кабинета: свой (8–64 символа a-z, 0-9, дефис), random — сгенерировать, admin — стандартный")
only := fs.String("allow-only", "", "кабинет только из белого списка: on | off")
reset := fs.String("reset-2fa", "", "сбросить второй фактор администратора (логин)")
zkFlag := fs.String("zetit-key", "", "вход через ЗЕТИТ Ключ: off — выключить (аварийно)")
_ = fs.Parse(args)
st, err := openStore(*data)
if err != nil {
log.Fatal(err)
}
err = st.write(func(s *State) error {
now := time.Now()
for _, a := range allow {
ipPart, comment, _ := strings.Cut(a, "=")
n, ok := normalizeNet(ipPart)
if !ok {
return fmt.Errorf("неверный адрес %q", ipPart)
}
exists := false
for _, x := range s.AllowIPs {
exists = exists || x.Net == n
}
if !exists {
s.AllowIPs = append(s.AllowIPs, &AllowIP{Net: n, Comment: strings.TrimSpace(comment), AddedBy: "консоль", AddedAt: now})
s.addEvent("fw_allow_add", "", "", "консоль", "", "IP "+n+" добавлен в белый список")
}
}
switch p := strings.ToLower(strings.Trim(*path, "/")); {
case p == "":
case p == "admin":
s.AdminPath = ""
s.addEvent("admin_path", "", "", "консоль", "", "адрес кабинета: стандартный /admin")
case p == "random":
s.AdminPath = "k" + randHex(12)
s.addEvent("admin_path", "", "", "консоль", "", "адрес кабинета изменён")
case adminSlugRe.MatchString(p):
s.AdminPath = p
s.addEvent("admin_path", "", "", "консоль", "", "адрес кабинета изменён")
default:
return fmt.Errorf("адрес: 8–64 символа, латинские строчные буквы, цифры и дефис")
}
switch *only {
case "":
case "on":
if len(s.AllowIPs) == 0 {
return fmt.Errorf("белый список пуст — ограничение закроет кабинет всем")
}
s.AdminAllowOnly = true
s.addEvent("admin_allow_only_on", "", "", "консоль", "", "кабинет доступен только из белого списка администраторов")
case "off":
s.AdminAllowOnly = false
s.addEvent("admin_allow_only_off", "", "", "консоль", "", "ограничение кабинета белым списком снято")
default:
return fmt.Errorf("-allow-only: on или off")
}
switch *zkFlag {
case "":
case "off":
s.ZetitKey.Enabled = false
s.addEvent("zetit_key", "", "", "консоль", "", "вход через ЗЕТИТ Ключ выключен")
default:
return fmt.Errorf("-zetit-key: только off")
}
if *reset != "" {
u := s.user(*reset)
if u == nil {
return fmt.Errorf("администратор %s не найден", *reset)
}
u.TOTPSecret, u.TOTPLast = "", 0
s.addEvent("totp_reset", "", "", "консоль", "", "сброшен второй фактор администратора "+u.Login)
}
return nil
})
if err != nil {
log.Fatal(err)
}
if *only == "on" {
_ = os.Remove(filepath.Join(filepath.Dir(*data), adminOpenFile))
}
printAccess(st)
}
// adminInfo — `bj-license-server admin-info`: адрес и настройки доступа (только чтение).
func adminInfo(args []string) {
fs := flag.NewFlagSet("admin-info", flag.ExitOnError)
data := fs.String("data", "/var/lib/bj-license/state.json", "файл состояния")
_ = fs.Parse(args)
st, err := openStore(*data)
if err != nil {
log.Fatal(err)
}
printAccess(st)
}
func printAccess(st *store) {
_, openErr := os.Stat(filepath.Join(filepath.Dir(st.path), adminOpenFile))
st.read(func(s *State) {
path := adminDefault
if s.AdminPath != "" {
path = "/" + s.AdminPath
}
fmt.Printf("Адрес кабинета: https://%s%s/\n", publicHost, path)
switch {
case s.AdminAllowOnly && openErr == nil:
fmt.Println("Только из белого списка: включено, но кабинет открыт аварийно (fw-apply -off)")
case s.AdminAllowOnly:
fmt.Println("Только из белого списка: включено")
default:
fmt.Println("Только из белого списка: выключено")
}
fmt.Println("Белый список администраторов:")
for _, a := range s.AllowIPs {
fmt.Printf(" %-20s %s\n", a.Net, a.Comment)
}
if s.ZetitKey.Enabled {
fmt.Println("Вход через ЗЕТИТ Ключ: включён (" + pickStr(s.ZetitKey.URL, zetitKeyDefaultURL) + ")")
} else {
fmt.Println("Вход через ЗЕТИТ Ключ: выключен")
}
fmt.Println("Администраторы (второй фактор):")
for _, u := range s.Users {
state := "не подключён"
if u.TOTPSecret != "" {
state = "подключён"
}
fmt.Printf(" %-24s TOTP %s, e-mail %s\n", u.Login, state, pickStr(u.Email, "не указан"))
}
})
}