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

918 lines
33 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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+".", "")
}