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>
This commit is contained in:
zuevav
2026-09-15 19:41:11 +03:00
parent 586ffb3a31
commit 86aa452ed6
19 changed files with 4659 additions and 99 deletions
+917
View File
@@ -0,0 +1,917 @@
package main
// Защита сервера лицензий из кабинета: fail2ban и «режим обороны».
//
// Кабинет работает непривилегированно (пользователь bj-license) и только пишет желаемое
// состояние рядом с state.json: белый список администраторов (allow-ips.txt) и режим с
// разовыми командами fail2ban (fw-control.json). Применяет их root-помощник
// `bj-license-server fw-apply` — systemd запускает его при изменении fw-control.json
// (bj-license-fw.path) и раз в 30 секунд (bj-license-fw.timer); фактическое состояние
// он пишет в fw-status.json, его и показывает кабинет.
//
// Режим обороны: на порты 80/443 пускаются только адреса из белого списка (ipset
// bj-allow / bj-allow6) — доверенные IP установок (trusted-ips.txt) и адреса
// администраторов (allow-ips.txt). SSH не затрагивается. Аварийное выключение по SSH:
//
// /opt/bj-license/bj-license-server fw-apply -off
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"syscall"
"time"
)
const (
fwControlFile = "fw-control.json"
fwStatusFile = "fw-status.json"
fwAllowFile = "allow-ips.txt"
fwOffFile = "fw-off" // создаёт fw-apply -off; удаляет кабинет при повторном включении обороны
fwSetV4 = "bj-allow"
fwSetV6 = "bj-allow6"
fwRuleComment = "bj-license-lockdown"
fwStatusStale = 2 * time.Minute
)
// fwJails — jail fail2ban, которыми управляет кабинет.
var fwJails = []string{"bj-license-api", "bj-license-admin", "sshd"}
// AllowIP — адрес или сеть администратора в белом списке.
type AllowIP struct {
Net string `json:"net"`
Comment string `json:"comment,omitempty"`
AddedBy string `json:"added_by,omitempty"`
AddedAt time.Time `json:"added_at"`
}
// FirewallState — желаемое состояние защиты: пишет кабинет, применяет fw-apply.
type FirewallState struct {
Mode string `json:"mode,omitempty"` // "" — обычный, lockdown — оборона
Until time.Time `json:"until,omitempty"`
UpdatedBy string `json:"updated_by,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
Actions []fwAction `json:"actions,omitempty"`
// Параметры блокировок jail (секунды; bantime -1 — навсегда). Пусто — значения из jail.d/bj-license.conf.
Jails map[string]fwJailConf `json:"jails,omitempty"`
// Блокировка входа в кабинет самим приложением (до fail2ban): попыток и срок.
LoginAttempts int `json:"login_attempts,omitempty"`
LoginLockSec int64 `json:"login_lock_sec,omitempty"`
}
// fwJailConf — параметры блокировки jail.
type fwJailConf struct {
BanTime int64 `json:"bantime"`
FindTime int64 `json:"findtime"`
MaxRetry int `json:"maxretry"`
}
// fwDefaultJail — значения из deploy/license/fail2ban/jail.d/bj-license.conf.
func fwDefaultJail(name string) fwJailConf {
if name == "sshd" {
return fwJailConf{BanTime: 3600, FindTime: 600, MaxRetry: 5}
}
return fwJailConf{BanTime: 3600, FindTime: 600, MaxRetry: 10}
}
type fwPreset struct {
Sec int64
Label string
}
var (
fwBanPresets = []fwPreset{{900, "15 минут"}, {3600, "1 час"}, {7200, "2 часа"}, {21600, "6 часов"}, {43200, "12 часов"},
{86400, "1 день"}, {259200, "3 дня"}, {604800, "1 неделя"}, {2592000, "1 месяц"}, {7776000, "3 месяца"},
{31536000, "1 год"}, {-1, "навсегда"}}
fwFindPresets = []fwPreset{{600, "10 минут"}, {3600, "1 час"}, {21600, "6 часов"}, {86400, "1 день"}, {604800, "1 неделя"}}
fwRetryPresets = []int{3, 5, 10, 20}
)
// durRu — срок по-русски: из пресетов или в секундах.
func durRu(sec int64) string {
for _, p := range fwBanPresets {
if p.Sec == sec {
return p.Label
}
}
for _, p := range fwFindPresets {
if p.Sec == sec {
return p.Label
}
}
return strconv.FormatInt(sec, 10) + " с"
}
func fwPresetOK(list []fwPreset, sec int64) bool {
for _, p := range list {
if p.Sec == sec {
return true
}
}
return false
}
// loginLimitOf — ограничение входа в кабинет: попыток и срок (по умолчанию 8 за 15 минут).
func loginLimitOf(s *State) (int, time.Duration) {
n, sec := s.Firewall.LoginAttempts, s.Firewall.LoginLockSec
if n <= 0 {
n = 8
}
if sec <= 0 {
sec = 900
}
return n, time.Duration(sec) * time.Second
}
// fwAction — разовая команда fail2ban из кабинета.
type fwAction struct {
ID string `json:"id"`
Op string `json:"op"` // ban | unban
Jail string `json:"jail"`
IP string `json:"ip"`
At time.Time `json:"at"`
By string `json:"by,omitempty"`
}
type fwJailStatus struct {
Name string `json:"name"`
BanTime int64 `json:"bantime"`
FindTime int64 `json:"findtime"`
MaxRetry int `json:"maxretry"`
CurrentlyFailed int `json:"currently_failed"`
TotalFailed int `json:"total_failed"`
CurrentlyBanned int `json:"currently_banned"`
TotalBanned int `json:"total_banned"`
Banned []string `json:"banned"`
}
// fwStatus — фактическое состояние, пишет fw-apply.
type fwStatus struct {
At time.Time `json:"at"`
Lockdown bool `json:"lockdown"` // правило обороны действует
Emergency bool `json:"emergency"` // оборона выключена аварийно (fw-apply -off)
Until time.Time `json:"until,omitempty"`
AllowCount int `json:"allow_count"`
Jails []fwJailStatus `json:"jails"`
Done []string `json:"done,omitempty"`
Errors []string `json:"errors,omitempty"`
}
// normalizeNet — IP или сеть в каноническом виде; слишком широкие сети не принимаются.
func normalizeNet(s string) (string, bool) {
s = strings.TrimSpace(s)
if ip := net.ParseIP(s); ip != nil {
return ip.String(), true
}
_, n, err := net.ParseCIDR(s)
if err != nil {
return "", false
}
ones, bits := n.Mask.Size()
if (bits == 32 && ones < 16) || (bits == 128 && ones < 32) {
return "", false
}
return n.String(), true
}
// netContains — адрес ip попадает в IP или сеть entry.
func netContains(entry, ip string) bool {
p := net.ParseIP(strings.TrimSpace(ip))
if p == nil {
return false
}
if e := net.ParseIP(entry); e != nil {
return e.Equal(p)
}
_, n, err := net.ParseCIDR(entry)
return err == nil && n.Contains(p)
}
// allowCovers — адрес в белом списке администраторов (includeTrusted — или среди доверенных установок).
func (s *State) allowCovers(ip string, includeTrusted bool) bool {
for _, a := range s.AllowIPs {
if netContains(a.Net, ip) {
return true
}
}
if includeTrusted {
for _, t := range s.trustedIPs() {
if netContains(t, ip) {
return true
}
}
}
return false
}
// writeIfChanged — атомарная запись файла, только если содержимое изменилось.
func writeIfChanged(path string, data []byte, perm os.FileMode) error {
if old, err := os.ReadFile(path); err == nil && bytes.Equal(old, data) {
return nil
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, perm); err != nil {
return err
}
return os.Rename(tmp, path)
}
// writeFirewallLocked — allow-ips.txt и fw-control.json для fw-apply; вызывать под блокировкой.
func (st *store) writeFirewallLocked() error {
dir := filepath.Dir(st.path)
body := "# Белый список администраторов (IP или сеть). Файл пишет bj-license-server.\n"
for _, a := range st.s.AllowIPs {
body += a.Net + "\n"
}
if err := writeIfChanged(filepath.Join(dir, fwAllowFile), []byte(body), 0o600); err != nil {
return err
}
b, err := json.MarshalIndent(st.s.Firewall, "", " ")
if err != nil {
return err
}
return writeIfChanged(filepath.Join(dir, fwControlFile), b, 0o600)
}
func fwReadStatus(dir string) fwStatus {
var st fwStatus
if b, err := os.ReadFile(filepath.Join(dir, fwStatusFile)); err == nil {
_ = json.Unmarshal(b, &st)
}
return st
}
// ---- root-помощник ----
// fwApply — `bj-license-server fw-apply [-dir …] [-off]`: применить желаемое состояние (от root).
func fwApply(args []string) {
fs := flag.NewFlagSet("fw-apply", flag.ExitOnError)
dir := fs.String("dir", "/var/lib/bj-license", "каталог данных сервера лицензий")
off := fs.Bool("off", false, "аварийно выключить режим обороны (до повторного включения в кабинете)")
_ = fs.Parse(args)
if os.Geteuid() != 0 {
log.Fatal("fw-apply запускается от root")
}
if *off {
if err := os.WriteFile(filepath.Join(*dir, fwOffFile), []byte(time.Now().Format(time.RFC3339)+"\n"), 0o644); err != nil {
log.Fatalf("аварийное выключение: %v", err)
}
chownLikeDir(*dir, filepath.Join(*dir, fwOffFile))
// Заодно открыть кабинет всем адресам, если он ограничен белым списком (access.go).
if err := os.WriteFile(filepath.Join(*dir, adminOpenFile), []byte(time.Now().Format(time.RFC3339)+"\n"), 0o644); err == nil {
chownLikeDir(*dir, filepath.Join(*dir, adminOpenFile))
}
}
st := fwRun(*dir)
if err := fwWriteStatus(*dir, st); err != nil {
log.Printf("fw-status.json: %v", err)
}
for _, e := range st.Errors {
log.Print(e)
}
if *off {
fmt.Println("Режим обороны выключен аварийно, кабинет открыт для всех адресов. Включить снова — в кабинете, раздел «Защита».")
fmt.Println("Адрес кабинета: bj-license-server admin-info")
}
}
func fwRun(dir string) fwStatus {
now := time.Now()
st := fwStatus{At: now}
prev := fwReadStatus(dir)
var ctl FirewallState
if b, err := os.ReadFile(filepath.Join(dir, fwControlFile)); err == nil {
if err := json.Unmarshal(b, &ctl); err != nil {
st.Errors = append(st.Errors, "fw-control.json: "+err.Error())
}
}
if _, err := os.Stat(filepath.Join(dir, fwOffFile)); err == nil {
st.Emergency = true
}
want := ctl.Mode == "lockdown" && !st.Emergency && (ctl.Until.IsZero() || now.Before(ctl.Until))
v4, v6 := fwReadAllow(dir)
st.AllowCount = len(v4) + len(v6)
if err := fwSyncSet(fwSetV4, "inet", v4); err != nil {
st.Errors = append(st.Errors, err.Error())
want = false
}
if err := fwSyncSet(fwSetV6, "inet6", v6); err != nil {
st.Errors = append(st.Errors, err.Error())
want = false
}
if want && st.AllowCount == 0 {
st.Errors = append(st.Errors, "белый список пуст — режим обороны не включён, иначе доступ закроется всем")
want = false
}
ok4 := fwRule("iptables", fwSetV4, want, &st.Errors)
ok6 := fwRule("ip6tables", fwSetV6, want, &st.Errors)
st.Lockdown = want && ok4 && ok6
if st.Lockdown {
st.Until = ctl.Until
}
if err := fwApplyJailConf(ctl.Jails); err != nil {
st.Errors = append(st.Errors, err.Error())
}
done := map[string]bool{}
for _, id := range prev.Done {
done[id] = true
}
for _, a := range ctl.Actions {
if !done[a.ID] {
if err := fwRunAction(a); err != nil {
st.Errors = append(st.Errors, err.Error())
}
done[a.ID] = true
}
st.Done = append(st.Done, a.ID) // только команды, ещё лежащие в fw-control.json
}
for _, j := range fwJails {
js, err := fwJail(j)
if err != nil {
st.Errors = append(st.Errors, err.Error())
continue
}
st.Jails = append(st.Jails, js)
}
return st
}
// fwReadAllow — белый список для ipset: доверенные установки и администраторы.
func fwReadAllow(dir string) (v4, v6 []string) {
seen := map[string]bool{}
for _, name := range []string{trustedFile, fwAllowFile} {
b, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
continue
}
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
n, ok := normalizeNet(line)
if !ok || seen[n] {
continue
}
seen[n] = true
if strings.Contains(n, ":") {
v6 = append(v6, n)
} else {
v4 = append(v4, n)
}
}
}
return v4, v6
}
// fwSyncSet — содержимое ipset заменяется атомарно (временный набор + swap).
func fwSyncSet(name, family string, nets []string) error {
tmp := name + "-tmp"
var b strings.Builder
fmt.Fprintf(&b, "create %s hash:net family %s -exist\n", name, family)
fmt.Fprintf(&b, "create %s hash:net family %s -exist\nflush %s\n", tmp, family, tmp)
for _, n := range nets {
fmt.Fprintf(&b, "add %s %s -exist\n", tmp, n)
}
fmt.Fprintf(&b, "swap %s %s\ndestroy %s\n", tmp, name, tmp)
cmd := exec.Command("ipset", "restore")
cmd.Stdin = strings.NewReader(b.String())
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("ipset %s: %v: %s", name, err, strings.TrimSpace(string(out)))
}
return nil
}
// fwRuleSpec — правило обороны: 80/443 только из белого списка.
func fwRuleSpec(set string) []string {
return []string{"-p", "tcp", "-m", "multiport", "--dports", "80,443", "-m", "set", "!", "--match-set", set, "src",
"-m", "comment", "--comment", fwRuleComment, "-j", "DROP"}
}
// fwRule — привести наличие правила к want; true — правило в нужном состоянии.
func fwRule(bin, set string, want bool, errs *[]string) bool {
has := func() bool {
return exec.Command(bin, append([]string{"-w", "-C", "INPUT"}, fwRuleSpec(set)...)...).Run() == nil
}
if want && !has() {
if out, err := exec.Command(bin, append([]string{"-w", "-I", "INPUT", "1"}, fwRuleSpec(set)...)...).CombinedOutput(); err != nil {
*errs = append(*errs, fmt.Sprintf("%s: включение обороны: %v: %s", bin, err, strings.TrimSpace(string(out))))
return false
}
}
for i := 0; !want && has() && i < 10; i++ {
if out, err := exec.Command(bin, append([]string{"-w", "-D", "INPUT"}, fwRuleSpec(set)...)...).CombinedOutput(); err != nil {
*errs = append(*errs, fmt.Sprintf("%s: выключение обороны: %v: %s", bin, err, strings.TrimSpace(string(out))))
return false
}
}
return want == has()
}
func fwRunAction(a fwAction) error {
ip := net.ParseIP(a.IP)
known := false
for _, j := range fwJails {
known = known || j == a.Jail
}
if ip == nil || !known || (a.Op != "ban" && a.Op != "unban") {
return fmt.Errorf("команда %s %s %s отклонена: неверные параметры", a.Op, a.Jail, a.IP)
}
out, err := exec.Command("fail2ban-client", "set", a.Jail, a.Op+"ip", ip.String()).CombinedOutput()
if err != nil {
return fmt.Errorf("fail2ban %s %s %s: %v: %s", a.Jail, a.Op, ip, err, strings.TrimSpace(string(out)))
}
return nil
}
const (
fwJailOverride = "/etc/fail2ban/jail.d/zz-bj-license-ui.local"
fwServerDefault = "/etc/fail2ban/fail2ban.d/zz-bj-license-ui.local"
)
// fwApplyJailConf — параметры блокировок из кабинета в override fail2ban; при изменении — reload.
func fwApplyJailConf(conf map[string]fwJailConf) error {
var b strings.Builder
b.WriteString("# Параметры блокировок из кабинета сервера лицензий (раздел «Защита»). Файл пишет bj-license-server fw-apply.\n")
var maxBan int64 = 86400
forever := false
for _, j := range fwJails {
c, ok := conf[j]
if !ok {
continue
}
if c.BanTime == -1 {
forever = true
} else if c.BanTime > maxBan {
maxBan = c.BanTime
}
fmt.Fprintf(&b, "\n[%s]\nbantime = %d\nfindtime = %d\nmaxretry = %d\n", j, c.BanTime, c.FindTime, c.MaxRetry)
}
old, _ := os.ReadFile(fwJailOverride)
if string(old) == b.String() {
return nil
}
if err := os.WriteFile(fwJailOverride, []byte(b.String()), 0o644); err != nil {
return fmt.Errorf("параметры блокировок: %w", err)
}
// Долгие баны должны пережить перезапуск fail2ban: база банов хранится дольше самого долгого срока.
purge := maxBan + 86400
if forever && purge < 5*31536000 {
purge = 5 * 31536000
}
_ = os.WriteFile(fwServerDefault, []byte(fmt.Sprintf("# Файл пишет bj-license-server fw-apply.\n[Definition]\ndbpurgeage = %d\n", purge)), 0o644)
if out, err := exec.Command("fail2ban-client", "reload").CombinedOutput(); err != nil {
return fmt.Errorf("fail2ban reload: %v: %s", err, strings.TrimSpace(string(out)))
}
_ = exec.Command("fail2ban-client", "set", "dbpurgeage", strconv.FormatInt(purge, 10)).Run()
return nil
}
var fwNumRe = regexp.MustCompile(`(Currently failed|Total failed|Currently banned|Total banned):\s*(\d+)`)
func fwJail(name string) (fwJailStatus, error) {
js := fwJailStatus{Name: name}
out, err := exec.Command("fail2ban-client", "status", name).CombinedOutput()
if err != nil {
return js, fmt.Errorf("fail2ban status %s: %v: %s", name, err, strings.TrimSpace(string(out)))
}
text := string(out)
for _, m := range fwNumRe.FindAllStringSubmatch(text, -1) {
n, _ := strconv.Atoi(m[2])
switch m[1] {
case "Currently failed":
js.CurrentlyFailed = n
case "Total failed":
js.TotalFailed = n
case "Currently banned":
js.CurrentlyBanned = n
case "Total banned":
js.TotalBanned = n
}
}
if i := strings.Index(text, "Banned IP list:"); i >= 0 {
line, _, _ := strings.Cut(text[i+len("Banned IP list:"):], "\n")
js.Banned = strings.Fields(line)
}
get := func(opt string) int64 {
out, err := exec.Command("fail2ban-client", "get", name, opt).Output()
if err != nil {
return 0
}
n, _ := strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64)
return n
}
js.BanTime, js.FindTime, js.MaxRetry = get("bantime"), get("findtime"), int(get("maxretry"))
return js, nil
}
func chownLikeDir(dir, path string) {
if fi, err := os.Stat(dir); err == nil {
if s, ok := fi.Sys().(*syscall.Stat_t); ok {
_ = os.Chown(path, int(s.Uid), int(s.Gid))
}
}
}
func fwWriteStatus(dir string, st fwStatus) error {
b, err := json.MarshalIndent(st, "", " ")
if err != nil {
return err
}
tmp := filepath.Join(dir, fwStatusFile+".tmp")
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
chownLikeDir(dir, tmp)
return os.Rename(tmp, filepath.Join(dir, fwStatusFile))
}
// fwIgnore — `bj-license-server fw-ignore [-dir …] <ip> api|admin` для ignorecommand fail2ban:
// код 0 — адрес не блокировать. api — доверенные установки и белый список, admin — только белый список.
func fwIgnore(args []string) {
fs := flag.NewFlagSet("fw-ignore", flag.ExitOnError)
dir := fs.String("dir", "/var/lib/bj-license", "каталог данных сервера лицензий")
_ = fs.Parse(args)
ip, kind := fs.Arg(0), fs.Arg(1)
if net.ParseIP(ip) == nil {
os.Exit(1)
}
files := []string{fwAllowFile}
if kind != "admin" {
files = append(files, trustedFile)
}
for _, name := range files {
b, err := os.ReadFile(filepath.Join(*dir, name))
if err != nil {
continue
}
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "#") && netContains(line, ip) {
os.Exit(0)
}
}
}
os.Exit(1)
}
// ---- кабинет: раздел «Защита» ----
type securityData struct {
Status fwStatus
StatusFresh bool
Wanted FirewallState
WantedActive bool
Allow []AllowIP
TrustedCount int
Pending []fwAction
MyIP string
MyIPCovered bool
Jails []string
// Доступ к кабинету.
Host string
AdminBase string
AdminDefault bool
AdminAllowOnly bool
AdminOpen bool
TwoFA, Admins int
ZKEnabled bool
ZKHasKey bool
ZKURL string
ZKWithEmail int
Limits []fwLimitRow
BanPresets []fwPreset
FindPresets []fwPreset
RetryPresets []int
LoginTries int
LoginLockSec int64
}
// fwLimitRow — строка карточки «Параметры блокировок».
type fwLimitRow struct {
Jail, Title string
Conf fwJailConf
Effective string // действующие значения в fail2ban
HasEffective bool
BanOpts []fwPreset
FindOpts []fwPreset
RetryOpts []int
}
// withPreset — варианты выбора с текущим значением, даже если его нет среди стандартных.
func withPreset(list []fwPreset, sec int64) []fwPreset {
if fwPresetOK(list, sec) {
return list
}
return append(append([]fwPreset{}, list...), fwPreset{sec, durRu(sec)})
}
func withRetry(list []int, n int) []int {
for _, v := range list {
if v == n {
return list
}
}
return append(append([]int{}, list...), n)
}
func (srv *server) dataDir() string { return filepath.Dir(srv.st.path) }
// fwPrune — убрать из желаемого состояния команды, которые помощник уже выполнил.
func fwPrune(s *State, st fwStatus) {
done := map[string]bool{}
for _, id := range st.Done {
done[id] = true
}
var keep []fwAction
for _, a := range s.Firewall.Actions {
if !done[a.ID] {
keep = append(keep, a)
}
}
s.Firewall.Actions = keep
}
// fwWait — подождать, пока помощник применит изменения (до 5 секунд).
func (srv *server) fwWait(since time.Time) {
for i := 0; i < 20; i++ {
if fwReadStatus(srv.dataDir()).At.After(since) {
return
}
time.Sleep(250 * time.Millisecond)
}
}
func (srv *server) securityPage(w http.ResponseWriter, r *http.Request, sess *session) {
st := fwReadStatus(srv.dataDir())
d := securityData{Status: st, StatusFresh: !st.At.IsZero() && time.Since(st.At) < fwStatusStale, MyIP: clientIP(r), Jails: fwJails}
srv.st.read(func(s *State) {
d.Wanted = s.Firewall
d.WantedActive = s.Firewall.Mode == "lockdown" && (s.Firewall.Until.IsZero() || time.Now().Before(s.Firewall.Until))
for _, a := range s.AllowIPs {
d.Allow = append(d.Allow, *a)
}
d.TrustedCount = len(s.trustedIPs())
done := map[string]bool{}
for _, id := range st.Done {
done[id] = true
}
for _, a := range s.Firewall.Actions {
if !done[a.ID] {
d.Pending = append(d.Pending, a)
}
}
d.MyIPCovered = s.allowCovers(d.MyIP, false)
d.AdminAllowOnly, d.Admins = s.AdminAllowOnly, len(s.Users)
d.ZKEnabled, d.ZKHasKey, d.ZKURL = s.ZetitKey.Enabled, s.ZetitKey.AppKey != "", pickStr(s.ZetitKey.URL, zetitKeyDefaultURL)
for _, u := range s.Users {
if u.Email != "" {
d.ZKWithEmail++
}
}
for _, u := range s.Users {
if u.TOTPSecret != "" {
d.TwoFA++
}
}
n, lock := loginLimitOf(s)
d.LoginTries, d.LoginLockSec = n, int64(lock/time.Second)
for _, j := range fwJails {
c, configured := s.Firewall.Jails[j]
if !configured {
c = fwDefaultJail(j)
}
row := fwLimitRow{Jail: j, Title: fwJailTitle(j)}
for _, js := range st.Jails {
if js.Name == j && js.MaxRetry > 0 {
row.HasEffective = true
row.Effective = fmt.Sprintf("сейчас: бан %s, %d попыток за %s", durRu(js.BanTime), js.MaxRetry, durRu(js.FindTime))
if !configured { // ещё не настраивали в кабинете — показываем то, что действует
c = fwJailConf{BanTime: js.BanTime, FindTime: js.FindTime, MaxRetry: js.MaxRetry}
}
}
}
row.Conf = c
row.BanOpts, row.FindOpts, row.RetryOpts = withPreset(fwBanPresets, c.BanTime), withPreset(fwFindPresets, c.FindTime), withRetry(fwRetryPresets, c.MaxRetry)
d.Limits = append(d.Limits, row)
}
})
d.BanPresets, d.FindPresets, d.RetryPresets = fwBanPresets, fwFindPresets, fwRetryPresets
d.Host, d.AdminBase, d.AdminOpen = publicHost, srv.adminBase(), srv.adminOpen()
d.AdminDefault = d.AdminBase == adminDefault
srv.render(w, r, sess, "security", "Защита", "security", d)
}
func fwJailTitle(j string) string {
switch j {
case "bj-license-api":
return "Активация и продление (API)"
case "bj-license-admin":
return "Вход в кабинет (fail2ban)"
case "sshd":
return "SSH"
}
return j
}
// securityLimits — POST /admin/security/limits: сроки и пороги блокировок.
func (srv *server) securityLimits(w http.ResponseWriter, r *http.Request, sess *session) {
conf := map[string]fwJailConf{}
var changes []string
for _, j := range fwJails {
ban, e1 := strconv.ParseInt(r.PostFormValue("ban_"+j), 10, 64)
find, e2 := strconv.ParseInt(r.PostFormValue("find_"+j), 10, 64)
retry, e3 := strconv.Atoi(r.PostFormValue("retry_" + j))
if e1 != nil || e2 != nil || e3 != nil || (ban != -1 && (ban < 60 || ban > 5*31536000)) || find < 60 || find > 31536000 || retry < 1 || retry > 100 {
back(w, r, "/admin/security", "", "неверные параметры для "+fwJailTitle(j))
return
}
conf[j] = fwJailConf{BanTime: ban, FindTime: find, MaxRetry: retry}
changes = append(changes, fmt.Sprintf("%s: бан %s, %d попыток за %s", fwJailTitle(j), durRu(ban), retry, durRu(find)))
}
tries, e1 := strconv.Atoi(r.PostFormValue("login_tries"))
lock, e2 := strconv.ParseInt(r.PostFormValue("login_lock"), 10, 64)
if e1 != nil || e2 != nil || tries < 3 || tries > 100 || lock <= 0 || !fwPresetOK(fwBanPresets, lock) {
back(w, r, "/admin/security", "", "неверные параметры блокировки входа в кабинет")
return
}
changes = append(changes, fmt.Sprintf("вход в кабинет (приложение): %d попыток, блокировка %s", tries, durRu(lock)))
start := time.Now()
err := srv.st.write(func(s *State) error {
s.Firewall.Jails, s.Firewall.LoginAttempts, s.Firewall.LoginLockSec = conf, tries, lock
s.Firewall.UpdatedBy, s.Firewall.UpdatedAt = sess.Login, start
s.addEvent("fw_limits", "", "", sess.Login, clientIP(r), "параметры блокировок: "+strings.Join(changes, "; "))
return nil
})
if err != nil {
back(w, r, "/admin/security", "", err.Error())
return
}
srv.fails.set(tries, time.Duration(lock)*time.Second)
srv.fwWait(start)
back(w, r, "/admin/security", "Параметры блокировок сохранены. fail2ban перечитывает настройки, действующие значения обновятся в течение 30 секунд.", "")
}
// securityMode — POST /admin/security/mode: включить (op=on, hours) или выключить (op=off) оборону.
func (srv *server) securityMode(w http.ResponseWriter, r *http.Request, sess *session) {
op, myIP := r.PostFormValue("op"), clientIP(r)
start := time.Now()
status := fwReadStatus(srv.dataDir())
var bad string
err := srv.st.write(func(s *State) error {
fwPrune(s, status)
switch op {
case "on":
hours, _ := strconv.Atoi(r.PostFormValue("hours"))
if hours < 0 || hours > 24*30 {
bad = "неверный срок"
return nil
}
if n, ok := normalizeNet(myIP); ok && !s.allowCovers(myIP, false) {
s.AllowIPs = append(s.AllowIPs, &AllowIP{Net: n, Comment: "добавлен автоматически при включении обороны", AddedBy: sess.Login, AddedAt: start})
s.addEvent("fw_allow_add", "", "", sess.Login, myIP, "IP "+n+" добавлен в белый список (включение обороны)")
}
s.Firewall.Mode, s.Firewall.Until = "lockdown", time.Time{}
text := "включён режим обороны: на 80/443 только белый список"
if hours > 0 {
s.Firewall.Until = start.Add(time.Duration(hours) * time.Hour)
text += ", до " + s.Firewall.Until.In(msk).Format("02.01.2006 15:04")
}
s.addEvent("fw_lockdown_on", "", "", sess.Login, myIP, text)
_ = os.Remove(filepath.Join(srv.dataDir(), fwOffFile))
case "off":
s.Firewall.Mode, s.Firewall.Until = "", time.Time{}
s.addEvent("fw_lockdown_off", "", "", sess.Login, myIP, "режим обороны выключен")
default:
bad = "неизвестная команда"
return nil
}
s.Firewall.UpdatedBy, s.Firewall.UpdatedAt = sess.Login, start
return nil
})
if err != nil || bad != "" {
back(w, r, "/admin/security", "", pickStr(bad, fmt.Sprint(err)))
return
}
srv.fwWait(start)
if op == "on" {
back(w, r, "/admin/security", "Режим обороны включается: доступ к сайту и API — только из белого списка.", "")
return
}
back(w, r, "/admin/security", "Режим обороны выключен: доступ открыт, fail2ban работает как обычно.", "")
}
// securityAllow — POST /admin/security/allow: добавить (op=add) или убрать (op=remove) адрес белого списка.
func (srv *server) securityAllow(w http.ResponseWriter, r *http.Request, sess *session) {
op, myIP := r.PostFormValue("op"), clientIP(r)
start := time.Now()
var bad, okMsg string
err := srv.st.write(func(s *State) error {
switch op {
case "add":
n, ok := normalizeNet(r.PostFormValue("net"))
if !ok {
bad = "укажите IP или сеть (IPv4 не шире /16, IPv6 не шире /32)"
return nil
}
for _, a := range s.AllowIPs {
if a.Net == n {
bad = n + " уже в белом списке"
return nil
}
}
s.AllowIPs = append(s.AllowIPs, &AllowIP{Net: n, Comment: clip(r.PostFormValue("comment"), 120), AddedBy: sess.Login, AddedAt: start})
s.addEvent("fw_allow_add", "", "", sess.Login, myIP, "IP "+n+" добавлен в белый список")
okMsg = n + " добавлен в белый список"
case "remove":
n := strings.TrimSpace(r.PostFormValue("net"))
var keep []*AllowIP
for _, a := range s.AllowIPs {
if a.Net != n {
keep = append(keep, a)
}
}
if len(keep) == len(s.AllowIPs) {
bad = "адрес не найден в белом списке"
return nil
}
lockdown := s.Firewall.Mode == "lockdown" && (s.Firewall.Until.IsZero() || start.Before(s.Firewall.Until))
if (lockdown || s.AdminAllowOnly) && netContains(n, myIP) {
rest := &State{AllowIPs: keep}
if !rest.allowCovers(myIP, false) {
bad = n + " покрывает ваш адрес " + myIP + ", а кабинет сейчас ограничен белым списком: после удаления доступ закроется. Сначала снимите ограничение или выключите оборону."
return nil
}
}
s.AllowIPs = keep
s.addEvent("fw_allow_remove", "", "", sess.Login, myIP, "IP "+n+" убран из белого списка")
okMsg = n + " убран из белого списка"
default:
bad = "неизвестная команда"
}
return nil
})
if err != nil || bad != "" {
back(w, r, "/admin/security", "", pickStr(bad, fmt.Sprint(err)))
return
}
srv.fwWait(start)
back(w, r, "/admin/security", okMsg, "")
}
// securityBan — POST /admin/security/ban: заблокировать (op=ban) или разблокировать (op=unban) IP в jail.
func (srv *server) securityBan(w http.ResponseWriter, r *http.Request, sess *session) {
op, jail, myIP := r.PostFormValue("op"), r.PostFormValue("jail"), clientIP(r)
ip := net.ParseIP(strings.TrimSpace(r.PostFormValue("ip")))
known := false
for _, j := range fwJails {
known = known || j == jail
}
switch {
case ip == nil:
back(w, r, "/admin/security", "", "укажите IP-адрес")
return
case !known || (op != "ban" && op != "unban"):
back(w, r, "/admin/security", "", "неверная команда")
return
case op == "ban" && ip.String() == net.ParseIP(myIP).String():
back(w, r, "/admin/security", "", "это ваш текущий адрес — блокировать его нельзя")
return
}
start := time.Now()
status := fwReadStatus(srv.dataDir())
err := srv.st.write(func(s *State) error {
fwPrune(s, status)
s.Firewall.Actions = append(s.Firewall.Actions, fwAction{ID: randHex(8), Op: op, Jail: jail, IP: ip.String(), At: start, By: sess.Login})
verb := "заблокирован"
if op == "unban" {
verb = "разблокирован"
}
s.addEvent("fw_"+op, "", "", sess.Login, myIP, "IP "+ip.String()+" "+verb+" вручную в "+jail)
return nil
})
if err != nil {
back(w, r, "/admin/security", "", err.Error())
return
}
srv.fwWait(start)
if op == "ban" {
back(w, r, "/admin/security", "IP "+ip.String()+" заблокирован в "+jail+" (на срок bantime jail).", "")
return
}
back(w, r, "/admin/security", "IP "+ip.String()+" разблокирован в "+jail+".", "")
}