diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md old mode 100755 new mode 100644 index 502ec01..9e2c031 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -48,6 +48,7 @@ auspex [флаг] [аргументы] ├── -w → runWebCheck(sid) [WAF, build tag] ├── -fd → runFDCheck() ├── -dt → runDockerTUI() + ├── -fw → runFirewallTUI() ├── -v → версия (+ вариант сборки: full/generic) └── -h → usage ``` @@ -357,6 +358,37 @@ Truncate — единственное действие, которое осво --- +### `firewall.go` + `firewall_tui.go` — управление ufw / nftables / iptables + +**Точка входа:** `runFirewallTUI()` + +**Автоопределение бэкенда** (`fwDetectBackend`), по приоритету: +1. `ufw` — если `ufw status` сообщает `active` +2. `nftables` — если `nft list ruleset` возвращает непустой вывод +3. `iptables` — fallback + +**Унифицированный интерфейс** `fwBackend` (`Name`, `ListRules`, `Rule`, `PortForward`, `RateLimit`, `DeleteRule`) реализован тремя структурами (`fwUFWBackend`, `fwNFTBackend`, `fwIPTBackend`) — TUI-слой работает с интерфейсом, не зная деталей конкретного бэкенда. + +**Тегирование управляемых правил:** каждое новое правило получает уникальный комментарий `auspex-XXXXXXXX` (`fwNewTag`). После применения модуль ищет это правило в свежем списке — так он получает handle (nftables) / номер строки (iptables) / номер (ufw) для последующего удаления, не полагаясь на порядок вставки. **Удаление через `x` разрешено только для правил с этим тегом** — сторонние/системные правила видны в списке, но не редактируются. + +**Защита от самоблокировки (ключевая часть дизайна):** после применения любого нового правила запускается обратный отсчёт `cfgFWRollbackSeconds` (по умолчанию 25 сек, `FW_ROLLBACK_SECONDS`). Если пользователь не подтвердил (`y`) — правило автоматически откатывается через тот же `undo()`, что вернула соответствующая функция интерфейса. Работает одинаково для всех трёх бэкендов, поскольку `undo` — часть возвращаемого значения каждого метода интерфейса, а не отдельная логика в TUI. + +**Dry-run (`D`):** переключаемый режим — экран подтверждения показывает точную команду, но `apply()` не вызывается. Рекомендуется включать при первом знакомстве с инструментом на новом сервере — команды для nftables/iptables не тестировались на живой системе в песочнице, где писался этот код. + +**Формы (wizard):** пошаговый ввод через один переиспользуемый `textinput.Model` — за раз один вопрос с валидацией (`fwValidatePort`, `fwValidateProto`, `fwValidateSource`, `fwValidateRequiredIP`, `fwValidateIntDefault`), в конце — экран предпросмотра команды перед реальным применением. + +**Специфика по действиям:** + +| Действие | ufw | nftables | iptables | +|---|---|---|---| +| Allow/Deny порт+прото/подсеть | `ufw allow/deny ...` | своя таблица `inet auspex`, чейн `input` (создаётся лениво) | `-I INPUT 1 ... -j ACCEPT/DROP` | +| Port forward / NAT | raw `iptables -t nat` (ufw не даёт CLI для DNAT) — **не персистентно через конфиг ufw**, нужно вручную дописать в `/etc/ufw/before.rules` | своя таблица `ip auspex_nat`, чейн `prerouting` (nat hook) | `-t nat -I PREROUTING` + `-I FORWARD` | +| Rate limit (защита от brute-force) | нативный `ufw limit` (фиксированный порог ~6/30 сек, кастомные значения игнорируются) | `limit rate over N/minute drop`, вставляется в начало чейна (порядок важен!) | классическая пара правил на модуле `recent` (`--set` / `--update --hitcount`) | + +**Персистентность** (`fwPersistenceHint`) — модуль НЕ пишет системные конфиги автоматически: ufw сохраняет правила сам, для nftables/iptables пользователю показывается подсказка (`netfilter-persistent save` / ручное добавление в `/etc/nftables.conf`). + +--- + ### `webcheck.go` — проверка доступности сайтов **Точка входа:** `runWebCheck(sidArg string)` @@ -540,6 +572,7 @@ primary (cfgPrimaryDBHost) → ping OK? → использовать | dockercheck | `dc` | | dockerview | `dv` | | fdcheck | `fd` | +| firewall | `fw` | | email | `email` | | mattermost | `mm` | | webcheck | `wc` | @@ -555,4 +588,4 @@ primary (cfgPrimaryDBHost) → ping OK? → использовать `buildinfo_waf.go` / `buildinfo_generic.go` по той же схеме задают константу `buildVariant` (`"full"` / `"generic"`), которую показывают баннер меню и `auspex -v`. -`lib/pq` используется только в трёх WAF-тегированных файлах — в generic-сборке он просто не компилируется (в `go.mod` остаётся объявленным, это нормально для двух конфигураций одного модуля; не стоит гонять `go mod tidy` без тега `waf`, иначе он может решить, что зависимость лишняя). \ No newline at end of file +`lib/pq` используется только в трёх WAF-тегированных файлах — в generic-сборке он просто не компилируется (в `go.mod` остаётся объявленным, это нормально для двух конфигураций одного модуля; не стоит гонять `go mod tidy` без тега `waf`, иначе он может решить, что зависимость лишняя). diff --git a/README.md b/README.md index 88b9b9a..9cb40da 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ | `-w` | Проверка доступности сайтов клиента через lynx **[WAF]** | | `-fd` | Поиск удалённых-но-открытых файловых дескрипторов (recover/reopen/truncate/restart/kill) | | `-dt` | Docker Live — интерактивный TUI (аналог lazydocker): логи, шелл, stop/start/restart/remove | +| `-fw` | Firewall — TUI для ufw/nftables/iptables: просмотр правил + формы (allow/deny, forward, rate-limit) с dry-run и автооткатом | Модули **[WAF]** требуют БД `waf_info` и/или структуру каталогов PT AF и доступны только в сборке `full` (см. [Сборка](#сборка)). @@ -33,6 +34,8 @@ auspex/ ├── servicecheck.go # -sv: проверка служб systemd ├── dockercheck.go # -d: проверка Docker контейнеров (снапшот + diff) ├── dockerview.go # -dt: Docker Live TUI (аналог lazydocker) +├── firewall.go # -fw: бэкенды ufw/nftables/iptables (detect/list/apply/undo) +├── firewall_tui.go # -fw: TUI-слой (формы, review, rollback-таймер) ├── portsview.go # -ss: TUI просмотр занятых портов ├── fdcheck.go # -fd: удалённые-но-открытые файловые дескрипторы ├── portcheck.go # -p: проверка доступности портов [WAF, build tag] @@ -89,6 +92,7 @@ sudo cp auspex /usr/local/bin/auspex ./auspex -w # Проверка веб доступности ./auspex -fd # Поиск удалённых-но-открытых файловых дескрипторов ./auspex -dt # Docker Live TUI (аналог lazydocker) +./auspex -fw # Firewall TUI (ufw/nftables/iptables) ./auspex -h # Справка ./auspex -v # Версия ``` @@ -164,6 +168,7 @@ sudo nano /etc/auspex/auspex.env | `SERVICE_STATE_FILE_PATH` | State-файл servicecheck | `/tmp/auspex_service_state.json` | | `DOCKER_STATE_FILE_PATH` | State-файл dockercheck | `/tmp/auspex_docker_state.json` | | `FD_RECOVER_DIR` | Куда сохраняются снимки восстановленных файлов (`-fd`) | `/var/tmp/auspex-recovered` | +| `FW_ROLLBACK_SECONDS` | Время до автоотката неподтверждённого правила (`-fw`) | `25` | #### Пороги сертификатов @@ -283,4 +288,4 @@ CERT_INFO_DAYS=30 ## Подробнее -См. [ARCHITECTURE.md](ARCHITECTURE.md). \ No newline at end of file +См. [ARCHITECTURE.md](ARCHITECTURE.md). diff --git a/config.go b/config.go old mode 100755 new mode 100644 index 0e3a3cf..466af97 --- a/config.go +++ b/config.go @@ -81,6 +81,9 @@ var ( cfgFDRecoverDir = "/var/tmp/auspex-recovered" ) +// --- Firewall (-fw) --- +var cfgFWRollbackSeconds = 25 + // --- Параметры проверки портов --- var ( cfgCheckTimeoutSec = 10 @@ -227,6 +230,11 @@ func applyConfig(env map[string]string) { if v, ok := env["FD_RECOVER_DIR"]; ok && v != "" { cfgFDRecoverDir = v } + if v, ok := env["FW_ROLLBACK_SECONDS"]; ok && v != "" { + if sec, err := strconv.Atoi(v); err == nil && sec > 0 { + cfgFWRollbackSeconds = sec + } + } // Thresholds if v, ok := env["CERT_CRITICAL_DAYS"]; ok && v != "" { @@ -402,4 +410,4 @@ func printConfigStatus() { } fmt.Println() -} \ No newline at end of file +} diff --git a/firewall.go b/firewall.go new file mode 100644 index 0000000..a2d5463 --- /dev/null +++ b/firewall.go @@ -0,0 +1,645 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "os/exec" + "regexp" + "strconv" + "strings" +) + +// ============================================================================= +// firewall — просмотр и упрощённое управление ufw / nftables / iptables +// ============================================================================= +// +// Backend автоопределяется (ufw, если активен → nftables, если есть непустой +// ruleset → iptables как fallback). Каждое новое правило получает уникальный +// тег-комментарий "auspex-XXXXXXXX", по которому модуль находит его в списке +// после применения (для undo) и разрешает удаление ТОЛЬКО управляемых им +// правил — системные/сторонние правила показываются, но не редактируются. +// +// ГЛАВНАЯ ЗАЩИТА: любое новое правило после применения ждёт подтверждения +// (клавиша 'y') в течение cfgFWRollbackSeconds секунд. Если не подтвердить — +// правило автоматически откатывается. Это защищает от случайной блокировки +// себя же (например по SSH) при удалённой работе. +// +// ВАЖНО: команды для nftables/iptables написаны по документированному +// синтаксису, но не протестированы на живой системе в песочнице — используйте +// dry-run (клавиша 'D') при первом знакомстве с инструментом на новом сервере. +// ============================================================================= + +// ── Общие типы ─────────────────────────────────────────────────────────────── + +type fwRule struct { + Backend string + Chain string // INPUT, PREROUTING(nat), inet/auspex/input, ... + ID string // line number (iptables/ufw) или handle (nft) + Action string // ACCEPT/DROP/DNAT/LIMIT... + Proto string + Port string + Source string + Destination string + Comment string // "auspex-xxxxxxxx", если правило наше + Kind string // "rule" | "forward" | "ratelimit" | "" (не наше) + Managed bool + Extra string +} + +// fwBackend — унифицированный интерфейс для ufw/nftables/iptables. +type fwBackend interface { + Name() string + ListRules() ([]fwRule, error) + // Rule — разрешить/запретить трафик. action: "allow" | "deny". + // port=="" — правило на весь источник (все порты); source=="" — отовсюду. + Rule(action, proto, port, source string) (apply func() error, undo func() error, err error) + PortForward(proto, extPort, dstIP, dstPort string) (apply func() error, undo func() error, err error) + RateLimit(proto, port string, hitCount, seconds int) (apply func() error, undo func() error, err error) + DeleteRule(r fwRule) error +} + +func fwNewTag() string { + b := make([]byte, 4) + _, _ = rand.Read(b) + return "auspex-" + hex.EncodeToString(b) +} + +func fwExec(name string, args ...string) error { + out, err := exec.Command(name, args...).CombinedOutput() + if err != nil { + return fmt.Errorf("%s %s: %v\n%s", name, strings.Join(args, " "), err, strings.TrimSpace(string(out))) + } + return nil +} + +func fwExecOut(name string, args ...string) (string, error) { + out, err := exec.Command(name, args...).CombinedOutput() + return string(out), err +} + +// fwCmdPreview — человекочитаемая команда для dry-run / экрана подтверждения. +func fwCmdPreview(name string, args ...string) string { + return name + " " + strings.Join(args, " ") +} + +// ── Автоопределение бэкенда ──────────────────────────────────────────────── + +func fwDetectBackend() (fwBackend, error) { + if _, err := exec.LookPath("ufw"); err == nil { + out, _ := fwExecOut("ufw", "status") + if strings.Contains(out, "Status: active") { + return fwUFWBackend{}, nil + } + } + if _, err := exec.LookPath("nft"); err == nil { + out, err := fwExecOut("nft", "list", "ruleset") + if err == nil && strings.TrimSpace(out) != "" { + return fwNFTBackend{}, nil + } + } + if _, err := exec.LookPath("iptables"); err == nil { + return fwIPTBackend{}, nil + } + return nil, fmt.Errorf("не найден ни ufw (активный), ни nftables (непустой ruleset), ни iptables") +} + +func fwPersistenceHint(backendName string) string { + switch backendName { + case "ufw": + return "ufw сохраняет правила автоматически (/etc/ufw/*.rules)." + case "nftables": + return "nftables НЕ сохраняет правила между рестартами автоматически — добавьте вручную в /etc/nftables.conf (например 'nft list ruleset >> /etc/nftables.conf' и проверьте файл руками)." + case "iptables": + return "iptables НЕ сохраняет правила автоматически — выполните 'netfilter-persistent save' (Debian/Ubuntu) или аналог для вашего дистрибутива." + default: + return "" + } +} + +// ============================================================================= +// UFW backend +// ============================================================================= + +type fwUFWBackend struct{} + +func (b fwUFWBackend) Name() string { return "ufw" } + +var fwUFWLineRe = regexp.MustCompile(`^\[\s*(\d+)\]\s+(.+?)\s{2,}(ALLOW|DENY|REJECT|LIMIT)\s+(IN|OUT)\s{2,}(.+)$`) + +func (b fwUFWBackend) ListRules() ([]fwRule, error) { + out, err := fwExecOut("ufw", "status", "numbered") + if err != nil { + return nil, fmt.Errorf("ufw status numbered: %v\n%s", err, out) + } + var rules []fwRule + for _, line := range strings.Split(out, "\n") { + line = strings.TrimRight(line, " \r") + m := fwUFWLineRe.FindStringSubmatch(line) + if m == nil { + continue + } + to := strings.TrimSpace(m[2]) + proto := "" + port := to + if idx := strings.LastIndex(to, "/"); idx != -1 { + proto = to[idx+1:] + port = to[:idx] + } + rules = append(rules, fwRule{ + Backend: "ufw", Chain: "ufw", ID: m[1], + Action: m[3], Proto: proto, Port: port, + Source: strings.TrimSpace(m[5]), Extra: line, + }) + } + return rules, nil +} + +// fwUFWNewestRule возвращает правило с максимальным номером — сразу после +// добавления ufw обычно дописывает новое правило в конец списка. +func (b fwUFWBackend) fwUFWNewestRule() (fwRule, error) { + rules, err := b.ListRules() + if err != nil { + return fwRule{}, err + } + if len(rules) == 0 { + return fwRule{}, fmt.Errorf("список правил ufw пуст после применения") + } + best := rules[0] + bestNum := 0 + for _, r := range rules { + if n, err := strconv.Atoi(r.ID); err == nil && n > bestNum { + bestNum = n + best = r + } + } + return best, nil +} + +func (b fwUFWBackend) Rule(action, proto, port, source string) (func() error, func() error, error) { + verb := "allow" + if action == "deny" { + verb = "deny" + } + var args []string + if source != "" { + args = []string{verb, "from", source} + if port != "" { + args = append(args, "to", "any", "port", port) + if proto != "" { + args = append(args, "proto", proto) + } + } + } else if port != "" { + spec := port + if proto != "" { + spec += "/" + proto + } + args = []string{verb, spec} + } else { + return nil, nil, fmt.Errorf("нужно указать порт и/или источник") + } + + apply := func() error { return fwExec("ufw", args...) } + undo := func() error { + r, err := b.fwUFWNewestRule() + if err != nil { + return err + } + return b.DeleteRule(r) + } + return apply, undo, nil +} + +func (b fwUFWBackend) PortForward(proto, extPort, dstIP, dstPort string) (func() error, func() error, error) { + // ufw не даёт нативного простого CLI для DNAT — работаем напрямую через + // iptables (ufw и так построен поверх него), но это НЕ персистентно через + // конфиг ufw — нужно вручную добавить в /etc/ufw/before.rules для сохранения. + tag := fwNewTag() + dnatArgs := []string{"-t", "nat", "-I", "PREROUTING", "1", "-p", proto, + "--dport", extPort, "-j", "DNAT", "--to-destination", dstIP + ":" + dstPort} + fwdArgs := []string{"-I", "FORWARD", "1", "-p", proto, "-d", dstIP, + "--dport", dstPort, "-j", "ACCEPT", "-m", "comment", "--comment", tag} + + apply := func() error { + if err := fwExec("iptables", dnatArgs...); err != nil { + return err + } + return fwExec("iptables", fwdArgs...) + } + undo := func() error { + undnat := []string{"-t", "nat", "-D", "PREROUTING", "-p", proto, + "--dport", extPort, "-j", "DNAT", "--to-destination", dstIP + ":" + dstPort} + _ = fwExec("iptables", undnat...) + unfwd := []string{"-D", "FORWARD", "-p", proto, "-d", dstIP, + "--dport", dstPort, "-j", "ACCEPT", "-m", "comment", "--comment", tag} + return fwExec("iptables", unfwd...) + } + return apply, undo, nil +} + +func (b fwUFWBackend) RateLimit(proto, port string, hitCount, seconds int) (func() error, func() error, error) { + // ufw имеет нативный 'limit' (примерно 6 попыток / 30 сек, пороги не кастомизируются + // через CLI) — используем его напрямую, кастомные hitCount/seconds здесь игнорируются. + spec := port + "/" + proto + apply := func() error { return fwExec("ufw", "limit", spec) } + undo := func() error { return fwExec("ufw", "delete", "limit", spec) } + return apply, undo, nil +} + +func (b fwUFWBackend) DeleteRule(r fwRule) error { + return fwExec("ufw", "--force", "delete", r.ID) +} + +// ============================================================================= +// nftables backend +// ============================================================================= + +const fwNFTTable = "auspex" +const fwNFTChain = "input" +const fwNFTNatTable = "auspex_nat" + +type fwNFTBackend struct{} + +func (b fwNFTBackend) Name() string { return "nftables" } + +func (b fwNFTBackend) ensureFilterChain() error { + if err := fwExec("nft", "add", "table", "inet", fwNFTTable); err != nil { + return err + } + return fwExec("nft", "add", "chain", "inet", fwNFTTable, fwNFTChain, + "{", "type", "filter", "hook", "input", "priority", "0", ";", "policy", "accept", ";", "}") +} + +func (b fwNFTBackend) ensureNatChain() error { + if err := fwExec("nft", "add", "table", "ip", fwNFTNatTable); err != nil { + return err + } + return fwExec("nft", "add", "chain", "ip", fwNFTNatTable, "prerouting", + "{", "type", "nat", "hook", "prerouting", "priority", "-100", ";", "}") +} + +var fwNFTRuleRe = regexp.MustCompile(`^\s*(.+?)\s*#\s*handle\s+(\d+)\s*$`) +var fwNFTCommentRe = regexp.MustCompile(`comment\s+"?(auspex-[0-9a-f]+)"?`) +var fwNFTPortRe = regexp.MustCompile(`dport\s+(\S+)`) +var fwNFTProtoRe = regexp.MustCompile(`^\s*(tcp|udp)\b`) +var fwNFTSaddrRe = regexp.MustCompile(`ip6?\s+saddr\s+(\S+)`) + +func fwNFTParseLines(out, chainLabel string) []fwRule { + var rules []fwRule + for _, line := range strings.Split(out, "\n") { + m := fwNFTRuleRe.FindStringSubmatch(line) + if m == nil { + continue + } + body, handle := m[1], m[2] + action := "accept" + switch { + case strings.Contains(body, "drop"): + action = "drop" + case strings.Contains(body, "dnat"): + action = "dnat" + case strings.Contains(body, "reject"): + action = "reject" + } + kind := "rule" + if strings.Contains(body, "dnat") { + kind = "forward" + } else if strings.Contains(body, "limit rate") { + kind = "ratelimit" + } + proto := "" + if pm := fwNFTProtoRe.FindStringSubmatch(body); pm != nil { + proto = pm[1] + } + port := "" + if pm := fwNFTPortRe.FindStringSubmatch(body); pm != nil { + port = pm[1] + } + source := "" + if sm := fwNFTSaddrRe.FindStringSubmatch(body); sm != nil { + source = sm[1] + } + comment := "" + managed := false + if cm := fwNFTCommentRe.FindStringSubmatch(body); cm != nil { + comment = cm[1] + managed = true + } + rules = append(rules, fwRule{ + Backend: "nftables", Chain: chainLabel, ID: handle, + Action: action, Proto: proto, Port: port, Source: source, + Comment: comment, Kind: kind, Managed: managed, Extra: strings.TrimSpace(body), + }) + } + return rules +} + +func (b fwNFTBackend) ListRules() ([]fwRule, error) { + var rules []fwRule + if out, err := fwExecOut("nft", "-a", "list", "table", "inet", fwNFTTable); err == nil { + rules = append(rules, fwNFTParseLines(out, "inet/"+fwNFTTable+"/"+fwNFTChain)...) + } + if out, err := fwExecOut("nft", "-a", "list", "table", "ip", fwNFTNatTable); err == nil { + rules = append(rules, fwNFTParseLines(out, "ip/"+fwNFTNatTable+"/prerouting")...) + } + return rules, nil +} + +func (b fwNFTBackend) findByTag(tag string) (fwRule, error) { + rules, err := b.ListRules() + if err != nil { + return fwRule{}, err + } + for _, r := range rules { + if r.Comment == tag { + return r, nil + } + } + return fwRule{}, fmt.Errorf("правило с тегом %s не найдено после применения (проверьте синтаксис nft вручную)", tag) +} + +func (b fwNFTBackend) Rule(action, proto, port, source string) (func() error, func() error, error) { + if err := b.ensureFilterChain(); err != nil { + return nil, nil, fmt.Errorf("не удалось подготовить таблицу/цепочку auspex: %w", err) + } + tag := fwNewTag() + verb := "accept" + if action == "deny" { + verb = "drop" + } + + args := []string{"add", "rule", "inet", fwNFTTable, fwNFTChain} + if proto != "" { + args = append(args, proto) + } + if port != "" { + args = append(args, "dport", port) + } + if source != "" { + fam := "ip" + if strings.Contains(source, ":") { + fam = "ip6" + } + args = append(args, fam, "saddr", source) + } + args = append(args, verb, "comment", tag) + + apply := func() error { return fwExec("nft", args...) } + undo := func() error { + r, err := b.findByTag(tag) + if err != nil { + return err + } + return b.DeleteRule(r) + } + return apply, undo, nil +} + +func (b fwNFTBackend) PortForward(proto, extPort, dstIP, dstPort string) (func() error, func() error, error) { + if err := b.ensureNatChain(); err != nil { + return nil, nil, fmt.Errorf("не удалось подготовить nat-таблицу auspex: %w", err) + } + tag := fwNewTag() + args := []string{"add", "rule", "ip", fwNFTNatTable, "prerouting", + proto, "dport", extPort, "dnat", "to", dstIP + ":" + dstPort, "comment", tag} + + apply := func() error { return fwExec("nft", args...) } + undo := func() error { + r, err := b.findByTag(tag) + if err != nil { + return err + } + return b.DeleteRule(r) + } + return apply, undo, nil +} + +func (b fwNFTBackend) RateLimit(proto, port string, hitCount, seconds int) (func() error, func() error, error) { + if err := b.ensureFilterChain(); err != nil { + return nil, nil, fmt.Errorf("не удалось подготовить таблицу/цепочку auspex: %w", err) + } + tag := fwNewTag() + perMinute := hitCount + if seconds > 0 && seconds != 60 { + perMinute = hitCount * 60 / seconds + if perMinute < 1 { + perMinute = 1 + } + } + // Вставляем В НАЧАЛО цепочки (insert без позиции = начало) — порядок важен: + // правило лимита должно идти РАНЬШЕ любого общего accept для этого порта, + // иначе более ранний accept "перехватит" пакет первым. + args := []string{"insert", "rule", "inet", fwNFTTable, fwNFTChain, + proto, "dport", port, "ct", "state", "new", + "limit", "rate", "over", fmt.Sprintf("%d/minute", perMinute), + "drop", "comment", tag} + + apply := func() error { return fwExec("nft", args...) } + undo := func() error { + r, err := b.findByTag(tag) + if err != nil { + return err + } + return b.DeleteRule(r) + } + return apply, undo, nil +} + +func (b fwNFTBackend) DeleteRule(r fwRule) error { + parts := strings.SplitN(r.Chain, "/", 3) + if len(parts) != 3 { + return fmt.Errorf("некорректная цепочка nft: %s", r.Chain) + } + family, table, chain := parts[0], parts[1], parts[2] + return fwExec("nft", "delete", "rule", family, table, chain, "handle", r.ID) +} + +// ============================================================================= +// iptables backend +// ============================================================================= + +type fwIPTBackend struct{} + +func (b fwIPTBackend) Name() string { return "iptables" } + +var fwIPTDptRe = regexp.MustCompile(`dpts?:(\S+)`) +var fwIPTCommentRe = regexp.MustCompile(`/\*\s*(auspex-[0-9a-f]+)\s*\*/`) + +func fwIPTParseChain(out, chainLabel string) []fwRule { + var rules []fwRule + for _, line := range strings.Split(out, "\n") { + fields := strings.Fields(strings.TrimSpace(line)) + if len(fields) < 10 { + continue + } + if _, err := strconv.Atoi(fields[0]); err != nil { + continue // не строка данных (заголовок/Chain ...) + } + num, target, proto := fields[0], fields[3], fields[4] + source, destination := fields[8], fields[9] + extra := strings.Join(fields[10:], " ") + + port := "" + if m := fwIPTDptRe.FindStringSubmatch(extra); m != nil { + port = m[1] + } + comment := "" + managed := false + if m := fwIPTCommentRe.FindStringSubmatch(extra); m != nil { + comment = m[1] + managed = true + } + kind := "rule" + if strings.Contains(chainLabel, "nat") { + kind = "forward" + } else if strings.Contains(extra, "recent") { + kind = "ratelimit" + } + + rules = append(rules, fwRule{ + Backend: "iptables", Chain: chainLabel, ID: num, + Action: target, Proto: proto, Port: port, + Source: source, Destination: destination, + Comment: comment, Kind: kind, Managed: managed, Extra: extra, + }) + } + return rules +} + +func (b fwIPTBackend) ListRules() ([]fwRule, error) { + var rules []fwRule + out, err := fwExecOut("iptables", "-L", "INPUT", "-n", "-v", "--line-numbers") + if err != nil { + return nil, fmt.Errorf("iptables -L INPUT: %v\n%s", err, out) + } + rules = append(rules, fwIPTParseChain(out, "INPUT")...) + + if natOut, err := fwExecOut("iptables", "-t", "nat", "-L", "PREROUTING", "-n", "-v", "--line-numbers"); err == nil { + rules = append(rules, fwIPTParseChain(natOut, "PREROUTING(nat)")...) + } + return rules, nil +} + +func (b fwIPTBackend) findByTag(tag, chain string) (fwRule, error) { + rules, err := b.ListRules() + if err != nil { + return fwRule{}, err + } + for _, r := range rules { + if r.Comment == tag && strings.HasPrefix(r.Chain, chain) { + return r, nil + } + } + return fwRule{}, fmt.Errorf("правило с тегом %s не найдено после применения", tag) +} + +func (b fwIPTBackend) Rule(action, proto, port, source string) (func() error, func() error, error) { + target := "ACCEPT" + if action == "deny" { + target = "DROP" + } + tag := fwNewTag() + args := []string{"-I", "INPUT", "1"} + if proto != "" { + args = append(args, "-p", proto) + } + if port != "" { + args = append(args, "--dport", port) + } + if source != "" { + args = append(args, "-s", source) + } + args = append(args, "-j", target, "-m", "comment", "--comment", tag) + + apply := func() error { return fwExec("iptables", args...) } + undo := func() error { + r, err := b.findByTag(tag, "INPUT") + if err != nil { + return err + } + return b.DeleteRule(r) + } + return apply, undo, nil +} + +func (b fwIPTBackend) PortForward(proto, extPort, dstIP, dstPort string) (func() error, func() error, error) { + tag := fwNewTag() + dnatArgs := []string{"-t", "nat", "-I", "PREROUTING", "1", "-p", proto, + "--dport", extPort, "-j", "DNAT", "--to-destination", dstIP + ":" + dstPort, + "-m", "comment", "--comment", tag} + fwdArgs := []string{"-I", "FORWARD", "1", "-p", proto, "-d", dstIP, + "--dport", dstPort, "-j", "ACCEPT", "-m", "comment", "--comment", tag} + + apply := func() error { + if err := fwExec("iptables", dnatArgs...); err != nil { + return err + } + return fwExec("iptables", fwdArgs...) + } + undo := func() error { + // FORWARD-цепочка не входит в ListRules() (там только INPUT и nat/PREROUTING), + // поэтому удаляем оба правила напрямую по полной спецификации, без поиска по тегу. + nat, errNat := b.findByTag(tag, "PREROUTING") + if errNat == nil { + _ = b.DeleteRule(nat) + } else { + undnat := []string{"-t", "nat", "-D", "PREROUTING", "-p", proto, + "--dport", extPort, "-j", "DNAT", "--to-destination", dstIP + ":" + dstPort, + "-m", "comment", "--comment", tag} + _ = fwExec("iptables", undnat...) + } + return fwExec("iptables", "-D", "FORWARD", "-p", proto, "-d", dstIP, + "--dport", dstPort, "-j", "ACCEPT", "-m", "comment", "--comment", tag) + } + return apply, undo, nil +} + +func (b fwIPTBackend) RateLimit(proto, port string, hitCount, seconds int) (func() error, func() error, error) { + tag := fwNewTag() + name := "AUSPEX_" + port + setArgs := []string{"-I", "INPUT", "1", "-p", proto, "--dport", port, + "-m", "conntrack", "--ctstate", "NEW", + "-m", "recent", "--set", "--name", name, + "-m", "comment", "--comment", tag} + dropArgs := []string{"-I", "INPUT", "1", "-p", proto, "--dport", port, + "-m", "conntrack", "--ctstate", "NEW", + "-m", "recent", "--update", "--seconds", strconv.Itoa(seconds), + "--hitcount", strconv.Itoa(hitCount), "--name", name, + "-j", "DROP", "-m", "comment", "--comment", tag} + + apply := func() error { + // dropArgs добавляем первым (-I ... 1), затем setArgs тоже в позицию 1 — + // в итоге set оказывается ПЕРЕД drop, именно такой порядок нужен модулю recent. + if err := fwExec("iptables", dropArgs...); err != nil { + return err + } + return fwExec("iptables", setArgs...) + } + undo := func() error { + rules, err := b.ListRules() + if err != nil { + return err + } + var lastErr error + for _, r := range rules { + if r.Comment == tag && r.Chain == "INPUT" { + if err := b.DeleteRule(r); err != nil { + lastErr = err + } + } + } + return lastErr + } + return apply, undo, nil +} + +func (b fwIPTBackend) DeleteRule(r fwRule) error { + chain := r.Chain + table := "filter" + if strings.Contains(chain, "(nat)") { + chain = strings.TrimSuffix(chain, "(nat)") + table = "nat" + } + return fwExec("iptables", "-t", table, "-D", chain, r.ID) +} diff --git a/firewall_tui.go b/firewall_tui.go new file mode 100644 index 0000000..f2b1f23 --- /dev/null +++ b/firewall_tui.go @@ -0,0 +1,711 @@ +package main + +import ( + "fmt" + "net" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// ============================================================================= +// firewall_tui — интерактивный слой поверх firewall.go +// ============================================================================= + +// ── Стили (отдельный namespace fw*, чтобы не путать с dv*/pv* из других TUI) ── + +var ( + fwBaseStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#6c7086")). + Padding(1, 2) + + fwLogoStyle = lipgloss.NewStyle().Bold(true). + Foreground(lipgloss.Color("#89b4fa")).Padding(0, 1) + + fwHelpStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6c7086")).Padding(0, 1) + + fwStatusStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#a6e3a1")).Bold(true) + + fwErrorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#f38ba8")).Bold(true) + + fwWarnStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#f9e2af")).Bold(true) + + fwInputStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#89b4fa")).Padding(0, 1) + + fwReviewStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#f9e2af")).Padding(1, 3) + + fwRollbackStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#f38ba8")).Padding(1, 3) +) + +// ── Формы (wizard) ─────────────────────────────────────────────────────────── + +type fwPrompt struct { + Label string + Default string + Validate func(string) (string, error) +} + +func fwValidatePort(s string) (string, error) { + if s == "" { + return "", nil + } + if !regexp.MustCompile(`^\d{1,5}(:\d{1,5})?$`).MatchString(s) { + return "", fmt.Errorf("порт должен быть числом (8080) или диапазоном (3000:3010)") + } + return s, nil +} + +func fwValidateRequiredPort(s string) (string, error) { + if strings.TrimSpace(s) == "" { + return "", fmt.Errorf("порт обязателен") + } + return fwValidatePort(s) +} + +func fwValidateProto(s string) (string, error) { + s = strings.ToLower(strings.TrimSpace(s)) + if s == "" { + s = "tcp" + } + if s != "tcp" && s != "udp" { + return "", fmt.Errorf("протокол должен быть tcp или udp") + } + return s, nil +} + +func fwValidateSource(s string) (string, error) { + s = strings.TrimSpace(s) + if s == "" { + return "", nil + } + if strings.Contains(s, "/") { + if _, _, err := net.ParseCIDR(s); err != nil { + return "", fmt.Errorf("некорректный CIDR: %v", err) + } + return s, nil + } + if net.ParseIP(s) == nil { + return "", fmt.Errorf("некорректный IP-адрес") + } + return s, nil +} + +func fwValidateRequiredIP(s string) (string, error) { + s = strings.TrimSpace(s) + if net.ParseIP(s) == nil { + return "", fmt.Errorf("некорректный IP-адрес") + } + return s, nil +} + +func fwValidateIntDefault(def int) func(string) (string, error) { + return func(s string) (string, error) { + s = strings.TrimSpace(s) + if s == "" { + return strconv.Itoa(def), nil + } + n, err := strconv.Atoi(s) + if err != nil || n <= 0 { + return "", fmt.Errorf("нужно положительное целое число") + } + return s, nil + } +} + +func fwPromptsFor(kind string) []fwPrompt { + switch kind { + case "allow", "deny": + return []fwPrompt{ + {Label: "Порт (Enter = все порты)", Validate: fwValidatePort}, + {Label: "Протокол (tcp/udp)", Default: "tcp", Validate: fwValidateProto}, + {Label: "Источник IP/CIDR (Enter = отовсюду)", Validate: fwValidateSource}, + } + case "forward": + return []fwPrompt{ + {Label: "Внешний порт", Validate: fwValidateRequiredPort}, + {Label: "Протокол (tcp/udp)", Default: "tcp", Validate: fwValidateProto}, + {Label: "IP назначения (внутренний хост/контейнер)", Validate: fwValidateRequiredIP}, + {Label: "Внутренний порт (Enter = как внешний)", Validate: fwValidatePort}, + } + case "ratelimit": + return []fwPrompt{ + {Label: "Порт (по умолчанию 22 — SSH)", Default: "22", Validate: fwValidateRequiredPort}, + {Label: "Протокол (tcp/udp)", Default: "tcp", Validate: fwValidateProto}, + {Label: "Макс. новых соединений", Default: "6", Validate: fwValidateIntDefault(6)}, + {Label: "За период, сек", Default: "30", Validate: fwValidateIntDefault(30)}, + } + } + return nil +} + +// fwBuildPreview — человекочитаемая команда для экрана подтверждения/dry-run. +// Дублирует логику построения аргументов из firewall.go специально для показа — +// реального выполнения здесь нет, это только предпросмотр. +func fwBuildPreview(backendName, kind, action string, v []string) []string { + switch kind { + case "allow", "deny": + port, proto, source := v[0], v[1], v[2] + switch backendName { + case "ufw": + verb := "allow" + if action == "deny" { + verb = "deny" + } + if source != "" { + s := "ufw " + verb + " from " + source + if port != "" { + s += " to any port " + port + " proto " + proto + } + return []string{s} + } + spec := port + if proto != "" && port != "" { + spec += "/" + proto + } + return []string{"ufw " + verb + " " + spec} + case "nftables": + verb := "accept" + if action == "deny" { + verb = "drop" + } + s := "nft add rule inet auspex input" + if proto != "" { + s += " " + proto + } + if port != "" { + s += " dport " + port + } + if source != "" { + s += " ip saddr " + source + } + return []string{s + " " + verb + " comment auspex-xxxxxxxx"} + default: // iptables + target := "ACCEPT" + if action == "deny" { + target = "DROP" + } + s := "iptables -I INPUT 1" + if proto != "" { + s += " -p " + proto + } + if port != "" { + s += " --dport " + port + } + if source != "" { + s += " -s " + source + } + return []string{s + " -j " + target + ` -m comment --comment auspex-xxxxxxxx`} + } + + case "forward": + extPort, proto, dstIP, dstPort := v[0], v[1], v[2], v[3] + if dstPort == "" { + dstPort = extPort + } + switch backendName { + case "nftables": + return []string{ + fmt.Sprintf("nft add rule ip auspex_nat prerouting %s dport %s dnat to %s:%s", proto, extPort, dstIP, dstPort), + } + default: // ufw и iptables оба идут через raw iptables nat + return []string{ + fmt.Sprintf("iptables -t nat -I PREROUTING 1 -p %s --dport %s -j DNAT --to-destination %s:%s", proto, extPort, dstIP, dstPort), + fmt.Sprintf("iptables -I FORWARD 1 -p %s -d %s --dport %s -j ACCEPT", proto, dstIP, dstPort), + } + } + + case "ratelimit": + port, proto, hit, sec := v[0], v[1], v[2], v[3] + switch backendName { + case "ufw": + return []string{fmt.Sprintf("ufw limit %s/%s (примерно 6/30 сек, свои %s/%s сек игнорируются нативным ufw limit)", port, proto, hit, sec)} + case "nftables": + return []string{fmt.Sprintf("nft insert rule inet auspex input %s dport %s ct state new limit rate over N/minute drop (N пересчитан из %s попыток / %s сек)", proto, port, hit, sec)} + default: + return []string{ + fmt.Sprintf("iptables -I INPUT 1 -p %s --dport %s -m recent --set --name AUSPEX_%s", proto, port, port), + fmt.Sprintf("iptables -I INPUT 1 -p %s --dport %s -m recent --update --seconds %s --hitcount %s --name AUSPEX_%s -j DROP", proto, port, sec, hit, port), + } + } + } + return nil +} + +// ── Модель ─────────────────────────────────────────────────────────────────── + +type fwRulesMsg struct { + rules []fwRule + err error +} + +type fwApplyDoneMsg struct { + err error + undo func() error + label string +} + +type fwUndoDoneMsg struct { + err error + auto bool + label string +} + +type fwTickMsg struct{} + +type fwModel struct { + backend fwBackend + + table table.Model + rules []fwRule + err error + status string + width, height int + loading bool + dryRun bool + + wizardActive bool + wizardKind string + wizardStep int + prompts []fwPrompt + values []string + textInput textinput.Model + + reviewing bool + reviewLines []string + pendingApply func() error + pendingUndo func() error + pendingLabel string + + rollbackActive bool + rollbackDeadline time.Time + rollbackUndo func() error + rollbackLabel string + + confirmDelete bool + deleteTarget fwRule +} + +func runFirewallTUI() error { + backend, err := fwDetectBackend() + if err != nil { + return err + } + + fmt.Printf("%s🔥 Backend: %s%s\n", cCyan, backend.Name(), cReset) + fmt.Printf("%sℹ️ %s%s\n\n", cYellow, fwPersistenceHint(backend.Name()), cReset) + + columns := []table.Column{ + {Title: "★", Width: 2}, + {Title: "Chain", Width: 22}, + {Title: "ID", Width: 6}, + {Title: "Действие", Width: 10}, + {Title: "Proto", Width: 6}, + {Title: "Порт", Width: 10}, + {Title: "Источник", Width: 18}, + {Title: "Комментарий", Width: 18}, + } + t := table.New(table.WithColumns(columns), table.WithFocused(true), table.WithHeight(14)) + s := table.DefaultStyles() + s.Header = s.Header.BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("#6c7086")).BorderBottom(true).Bold(true) + s.Selected = s.Selected.Foreground(lipgloss.Color("#cdd6f4")). + Background(lipgloss.Color("#313244")).Bold(false) + t.SetStyles(s) + + ti := textinput.New() + ti.CharLimit = 100 + ti.Width = 40 + + m := fwModel{backend: backend, table: t, textInput: ti} + + if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil { + return fmt.Errorf("ошибка запуска TUI: %w", err) + } + return nil +} + +func (m fwModel) Init() tea.Cmd { + return tea.Batch(fwLoadCmd(m.backend), textinput.Blink) +} + +func fwLoadCmd(b fwBackend) tea.Cmd { + return func() tea.Msg { + rules, err := b.ListRules() + return fwRulesMsg{rules: rules, err: err} + } +} + +func fwTick() tea.Cmd { + return tea.Tick(time.Second, func(time.Time) tea.Msg { return fwTickMsg{} }) +} + +func (m fwModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + // ── Обратный отсчёт отката ──────────────────────────────────────────── + if m.rollbackActive { + switch msg := msg.(type) { + case fwTickMsg: + if time.Now().After(m.rollbackDeadline) { + undo := m.rollbackUndo + label := m.rollbackLabel + m.rollbackActive = false + return m, func() tea.Msg { + err := undo() + return fwUndoDoneMsg{err: err, auto: true, label: label} + } + } + return m, fwTick() + case tea.KeyMsg: + switch msg.String() { + case "y", "Y", "enter": + m.rollbackActive = false + m.status = "✅ Подтверждено, правило остаётся: " + m.rollbackLabel + return m, fwLoadCmd(m.backend) + case "n", "N": + undo := m.rollbackUndo + label := m.rollbackLabel + m.rollbackActive = false + return m, func() tea.Msg { + err := undo() + return fwUndoDoneMsg{err: err, auto: false, label: label} + } + } + } + return m, nil + } + + // ── Диалог удаления существующего управляемого правила ──────────────── + if m.confirmDelete { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.String() { + case "y", "Y", "enter": + target := m.deleteTarget + m.confirmDelete = false + return m, func() tea.Msg { + err := m.backend.DeleteRule(target) + return fwApplyDoneMsg{err: err, label: "удаление правила"} + } + case "n", "N", "esc": + m.confirmDelete = false + m.status = "Отменено" + } + } + return m, nil + } + + // ── Экран подтверждения перед применением ───────────────────────────── + if m.reviewing { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.String() { + case "y", "Y", "enter": + m.reviewing = false + if m.dryRun { + m.status = "[DRY-RUN] Команда не выполнена (см. превью выше)" + return m, nil + } + apply, undo, label := m.pendingApply, m.pendingUndo, m.pendingLabel + m.status = "⏳ Применяю..." + return m, func() tea.Msg { + err := apply() + return fwApplyDoneMsg{err: err, undo: undo, label: label} + } + case "n", "N", "esc": + m.reviewing = false + m.status = "Отменено" + } + } + return m, nil + } + + // ── Мастер ввода (wizard) ────────────────────────────────────────────── + if m.wizardActive { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.String() { + case "esc": + m.wizardActive = false + m.status = "Отменено" + return m, nil + case "enter": + raw := m.textInput.Value() + p := m.prompts[m.wizardStep] + val, err := p.Validate(raw) + if err != nil { + if raw == "" && p.Default != "" { + val, err = p.Validate(p.Default) + } + } + if err != nil { + m.status = "❌ " + err.Error() + return m, nil + } + if val == "" && p.Default != "" { + val = p.Default + } + m.values = append(m.values, val) + m.wizardStep++ + m.status = "" + if m.wizardStep >= len(m.prompts) { + m.wizardActive = false + m.reviewing = true + m.reviewLines = fwBuildPreview(m.backend.Name(), m.wizardKind, m.wizardKind, m.values) + m.pendingApply, m.pendingUndo, _ = fwBuildAction(m.backend, m.wizardKind, m.values) + m.pendingLabel = m.wizardKind + " " + strings.Join(m.values, " / ") + return m, nil + } + m.textInput.SetValue("") + m.textInput.Placeholder = m.prompts[m.wizardStep].Default + return m, textinput.Blink + } + } + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "r": + m.loading = true + m.status = "Обновляю..." + return m, fwLoadCmd(m.backend) + case "D": + m.dryRun = !m.dryRun + if m.dryRun { + m.status = "🧪 Dry-run включён: команды будут только показаны" + } else { + m.status = "Dry-run выключен" + } + case "a": + m.fwStartWizard("allow") + return m, textinput.Blink + case "b": + m.fwStartWizard("deny") + return m, textinput.Blink + case "f": + m.fwStartWizard("forward") + return m, textinput.Blink + case "l": + m.fwStartWizard("ratelimit") + return m, textinput.Blink + case "x": + if r, ok := m.fwSelected(); ok { + if !r.Managed { + m.status = "❌ Нельзя удалить не-auspex правило через этот интерфейс" + } else { + m.confirmDelete = true + m.deleteTarget = r + } + } + } + + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + avail := m.height - 7 + fwBaseStyle = fwBaseStyle.Width(m.width - 2).Height(avail) + m.table.SetWidth(m.width - 4) + th := avail - 2 + if th < 2 { + th = 2 + } + m.table.SetHeight(th) + + case fwRulesMsg: + m.loading = false + if msg.err != nil { + m.err = msg.err + } else { + m.err = nil + m.rules = msg.rules + sort.Slice(m.rules, func(i, j int) bool { return m.rules[i].Chain < m.rules[j].Chain }) + m.fwUpdateTable() + if m.status == "Обновляю..." { + m.status = "Обновлено" + } + } + + case fwApplyDoneMsg: + if msg.err != nil { + m.status = "❌ Ошибка: " + msg.err.Error() + return m, fwLoadCmd(m.backend) + } + if msg.undo != nil { + m.rollbackActive = true + m.rollbackDeadline = time.Now().Add(time.Duration(cfgFWRollbackSeconds) * time.Second) + m.rollbackUndo = msg.undo + m.rollbackLabel = msg.label + return m, tea.Batch(fwTick(), fwLoadCmd(m.backend)) + } + m.status = "✅ Готово: " + msg.label + return m, fwLoadCmd(m.backend) + + case fwUndoDoneMsg: + if msg.err != nil { + m.status = "❌ Не удалось откатить '" + msg.label + "': " + msg.err.Error() + } else if msg.auto { + m.status = "⏱ Не подтверждено вовремя — правило отменено автоматически: " + msg.label + } else { + m.status = "↩️ Отменено пользователем: " + msg.label + } + return m, fwLoadCmd(m.backend) + + case error: + m.err = msg + m.loading = false + } + + m.table, cmd = m.table.Update(msg) + return m, cmd +} + +func (m *fwModel) fwStartWizard(kind string) { + m.wizardActive = true + m.wizardKind = kind + m.wizardStep = 0 + m.values = nil + m.prompts = fwPromptsFor(kind) + m.textInput.SetValue("") + m.textInput.Placeholder = m.prompts[0].Default + m.textInput.Focus() + m.status = "" +} + +func (m *fwModel) fwSelected() (fwRule, bool) { + idx := m.table.Cursor() + if idx < 0 || idx >= len(m.rules) { + return fwRule{}, false + } + return m.rules[idx], true +} + +func (m *fwModel) fwUpdateTable() { + rows := []table.Row{} + for _, r := range m.rules { + star := "" + if r.Managed { + star = "★" + } + rows = append(rows, table.Row{star, r.Chain, r.ID, r.Action, r.Proto, r.Port, r.Source, r.Comment}) + } + m.table.SetRows(rows) +} + +// fwBuildAction — строит apply/undo для выбранного kind по значениям wizard'а. +func fwBuildAction(b fwBackend, kind string, v []string) (func() error, func() error, error) { + switch kind { + case "allow": + return b.Rule("allow", v[1], v[0], v[2]) + case "deny": + return b.Rule("deny", v[1], v[0], v[2]) + case "forward": + dstPort := v[3] + if dstPort == "" { + dstPort = v[0] + } + return b.PortForward(v[1], v[0], v[2], dstPort) + case "ratelimit": + hit, _ := strconv.Atoi(v[2]) + sec, _ := strconv.Atoi(v[3]) + return b.RateLimit(v[1], v[0], hit, sec) + } + return nil, nil, fmt.Errorf("неизвестный тип действия: %s", kind) +} + +// ── Отрисовка ──────────────────────────────────────────────────────────────── + +func (m fwModel) View() string { + if m.err != nil { + return fmt.Sprintf("Ошибка: %v\nНажмите 'q' для выхода", m.err) + } + + if m.rollbackActive { + remain := int(time.Until(m.rollbackDeadline).Seconds()) + 1 + body := fwWarnStyle.Render(fmt.Sprintf("⏳ Правило применено: %s", m.rollbackLabel)) + + fmt.Sprintf("\n\nЕсли всё ещё есть доступ (например по SSH) — подтвердите.\nАвтоматический откат через %d сек, если не подтвердить.\n\n", remain) + + fwHelpStyle.Render("y/Enter: Подтвердить и оставить • n: Откатить сейчас") + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, fwRollbackStyle.Render(body)) + } + + if m.confirmDelete { + body := fwWarnStyle.Render("⚠️ Удалить правило?") + fmt.Sprintf( + "\n\nChain: %s\nID: %s Действие: %s Порт: %s/%s Источник: %s\n\n", + m.deleteTarget.Chain, m.deleteTarget.ID, m.deleteTarget.Action, + m.deleteTarget.Port, m.deleteTarget.Proto, m.deleteTarget.Source) + + fwHelpStyle.Render("y: Да • n/Esc: Отмена") + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, fwReviewStyle.Render(body)) + } + + if m.reviewing { + var b strings.Builder + b.WriteString(fwWarnStyle.Render("Проверьте команду перед применением:") + "\n\n") + for _, l := range m.reviewLines { + b.WriteString(" " + l + "\n") + } + if m.dryRun { + b.WriteString("\n" + fwHelpStyle.Render("[DRY-RUN включён — реально выполнено не будет]") + "\n") + } + b.WriteString("\n" + fwHelpStyle.Render("y/Enter: Применить • n/Esc: Отмена")) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, fwReviewStyle.Render(b.String())) + } + + if m.wizardActive { + p := m.prompts[m.wizardStep] + title := fmt.Sprintf("Шаг %d/%d — %s", m.wizardStep+1, len(m.prompts), p.Label) + if p.Default != "" { + title += fmt.Sprintf(" [%s]", p.Default) + } + box := fwInputStyle.Render(title + "\n\n" + m.textInput.View()) + help := fwHelpStyle.Render("Enter: Далее • Esc: Отмена") + errLine := "" + if strings.HasPrefix(m.status, "❌") { + errLine = "\n" + fwErrorStyle.Render(m.status) + } + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box+"\n"+help+errLine) + } + + logo := fwLogoStyle.Render(fmt.Sprintf("🔥 Auspex — Firewall (%s)", m.backend.Name())) + if m.loading { + logo += " " + fwWarnStyle.Render("●") + } + if m.dryRun { + logo += " " + fwHelpStyle.Render("[DRY-RUN]") + } + + tableView := fwBaseStyle.Render(m.table.View()) + controls := "↑/↓: Навигация • a: Allow • b: Deny/Block • f: Port-forward • l: Rate-limit • " + + "x: Удалить (★ только) • r: Refresh • D: Dry-run • q: Выход" + + status := m.status + rendered := fwHelpStyle.Render(controls) + if status != "" { + style := fwStatusStyle + if strings.HasPrefix(status, "❌") { + style = fwErrorStyle + } else if strings.HasPrefix(status, "⏱") || strings.HasPrefix(status, "🧪") { + style = fwWarnStyle + } + rendered = style.Render(status) + " • " + rendered + } + + return fmt.Sprintf("%s\n%s\n%s", logo, tableView, rendered) +} diff --git a/main.go b/main.go index ef7e48c..9fd2f07 100644 --- a/main.go +++ b/main.go @@ -117,6 +117,12 @@ func runByFlag(flag string, extraArgs []string) { os.Exit(1) } + case "-fw": + if err := runFirewallTUI(); err != nil { + fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) + os.Exit(1) + } + case "-v", "--version": fmt.Printf("auspex v%s (%s)\n", appVersion, buildVariant) @@ -132,7 +138,80 @@ func runByFlag(flag string, extraArgs []string) { func showMenu() { printBanner() + reader := bufio.NewReader(os.Stdin) + for { + printMenuOptions() + fmt.Printf("%s>>> %s", cGreen, cReset) + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(input) + + switch input { + case "1", "-ss", "ss": + if err := runPortsView(); err != nil { + fmt.Fprintf(os.Stderr, "%sОшибка: %v%s\n", cRed, err, cReset) + } + case "2", "-c", "c": + if err := runCertCheck(); err != nil { + fmt.Fprintf(os.Stderr, "%sОшибка: %v%s\n", cRed, err, cReset) + } + case "3", "-p", "p": + if err := runPortCheck(); err != nil { + fmt.Fprintf(os.Stderr, "%sОшибка: %v%s\n", cRed, err, cReset) + } + case "4", "-m", "m": + fmt.Printf(" Имя клиента (Enter = все): ") + clientInput, _ := reader.ReadString('\n') + clientInput = strings.TrimSpace(clientInput) + if err := runMatchCheck(clientInput); err != nil { + fmt.Fprintf(os.Stderr, "%sОшибка: %v%s\n", cRed, err, cReset) + } + case "5", "-l", "l": + if err := runLogCheck(); err != nil { + fmt.Fprintf(os.Stderr, "%sОшибка: %v%s\n", cRed, err, cReset) + } + case "6", "-sv", "sv": + if err := runServiceCheck(); err != nil { + fmt.Fprintf(os.Stderr, "%sОшибка: %v%s\n", cRed, err, cReset) + } + case "7", "-dc", "-d", "dc", "d": + if err := runDockerCheck(); err != nil { + fmt.Fprintf(os.Stderr, "%sОшибка: %v%s\n", cRed, err, cReset) + } + case "8", "-w", "w": + fmt.Printf(" SID: ") + sidInput, _ := reader.ReadString('\n') + sidInput = strings.TrimSpace(sidInput) + if err := runWebCheck(sidInput); err != nil { + fmt.Fprintf(os.Stderr, "%sОшибка: %v%s\n", cRed, err, cReset) + } + case "9", "-fd", "fd": + if err := runFDCheck(); err != nil { + fmt.Fprintf(os.Stderr, "%sОшибка: %v%s\n", cRed, err, cReset) + } + case "10", "-dt", "dt": + if err := runDockerTUI(); err != nil { + fmt.Fprintf(os.Stderr, "%sОшибка: %v%s\n", cRed, err, cReset) + } + case "11", "-fw", "fw": + if err := runFirewallTUI(); err != nil { + fmt.Fprintf(os.Stderr, "%sОшибка: %v%s\n", cRed, err, cReset) + } + case "0", "q", "quit", "exit": + fmt.Println("Выход.") + return + default: + fmt.Printf("%sНеизвестная команда. Введите 1-11 или 0 для выхода.%s\n", cRed, cReset) + } + + fmt.Println() // разделитель перед повторным показом меню + } +} + +// printMenuOptions выводит нумерованный список утилит и подсказку по флагам. +// Вызывается заново после каждого действия — успешного или с ошибкой, +// программа не завершается, а возвращается к выбору. +func printMenuOptions() { fmt.Printf("%sВыберите утилиту:%s\n\n", cBlue, cReset) fmt.Printf(" %s1%s) %s-ss%s Просмотр занятых портов (TUI интерфейс)\n", cGreen, cReset, cCyan, cReset) fmt.Printf(" %s2%s) %s-c %s Проверка SSL сертификатов\n", cGreen, cReset, cCyan, cReset) @@ -144,81 +223,10 @@ func showMenu() { fmt.Printf(" %s8%s) %s-w %s Проверка доступности сайтов (lynx) [sid] [WAF]\n", cGreen, cReset, cCyan, cReset) fmt.Printf(" %s9%s) %s-fd%s Удалённые-но-открытые файловые дескрипторы\n", cGreen, cReset, cCyan, cReset) fmt.Printf(" %s10%s) %s-dt%s Docker Live — TUI (аналог lazydocker)\n", cGreen, cReset, cCyan, cReset) + fmt.Printf(" %s11%s) %s-fw%s Firewall — TUI (ufw/nftables/iptables)\n", cGreen, cReset, cCyan, cReset) fmt.Printf(" %s0%s) Выход\n\n", cGreen, cReset) fmt.Printf("%sДля быстрого вызова: auspex <ключ>%s\n", cYellow, cReset) fmt.Printf("%sПример: auspex -ss | auspex -c | auspex -p | auspex -m [клиент] | auspex -l | auspex -sv | auspex -dc | auspex -w %s\n\n", cYellow, cReset) - - reader := bufio.NewReader(os.Stdin) - - for { - fmt.Printf("%s>>> %s", cGreen, cReset) - input, _ := reader.ReadString('\n') - input = strings.TrimSpace(input) - - switch input { - case "1", "-ss", "ss": - if err := runPortsView(); err != nil { - fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) - } - return - case "2", "-c", "c": - if err := runCertCheck(); err != nil { - fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) - } - return - case "3", "-p", "p": - if err := runPortCheck(); err != nil { - fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) - } - return - case "4", "-m", "m": - fmt.Printf(" Имя клиента (Enter = все): ") - clientInput, _ := reader.ReadString('\n') - clientInput = strings.TrimSpace(clientInput) - if err := runMatchCheck(clientInput); err != nil { - fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) - } - return - case "5", "-l", "l": - if err := runLogCheck(); err != nil { - fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) - } - return - case "6", "-sv", "sv": - if err := runServiceCheck(); err != nil { - fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) - } - return - case "7", "-dc", "-d", "dc", "d": - if err := runDockerCheck(); err != nil { - fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) - } - return - case "8", "-w", "w": - fmt.Printf(" SID: ") - sidInput, _ := reader.ReadString('\n') - sidInput = strings.TrimSpace(sidInput) - if err := runWebCheck(sidInput); err != nil { - fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) - } - return - case "9", "-fd", "fd": - if err := runFDCheck(); err != nil { - fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) - } - return - case "10", "-dt", "dt": - if err := runDockerTUI(); err != nil { - fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) - } - return - case "0", "q", "quit", "exit": - fmt.Println("Выход.") - return - default: - fmt.Printf("%sНеизвестная команда. Введите 1-8 или 0 для выхода.%s\n", cRed, cReset) - } - } } func printBanner() { @@ -251,6 +259,7 @@ func printUsage() { fmt.Printf(" %-12s Проверка доступности сайтов через lynx [sid] [WAF]\n", "-w") fmt.Printf(" %-12s Удалённые-но-открытые файловые дескрипторы (recover/reopen/restart/kill)\n", "-fd") fmt.Printf(" %-12s Docker Live — интерактивный TUI (аналог lazydocker)\n", "-dt") + fmt.Printf(" %-12s Firewall — интерактивный TUI (ufw/nftables/iptables, dry-run 'D')\n", "-fw") fmt.Printf(" %-12s Показать версию\n", "-v") fmt.Printf(" %-12s Показать эту справку\n\n", "-h") fmt.Printf("Без аргументов запускается интерактивное меню.\n") @@ -259,4 +268,4 @@ func printUsage() { " они требуют БД waf_info / структуры PT AF. Соберите с 'go build -tags waf .',\n"+ " чтобы получить полную (full) сборку.%s\n", cYellow, cReset) } -} +} \ No newline at end of file