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 …] 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+".", "") }