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:
@@ -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+".", "")
|
||||
}
|
||||
Reference in New Issue
Block a user