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" или "/". 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, ``) 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, "не указан")) } }) }