Added firewall settings

This commit is contained in:
Magnus Root 2026-07-27 16:00:24 +03:00
parent d74a323cdb
commit 1f2d554d6a
6 changed files with 1487 additions and 76 deletions

33
ARCHITECTURE.md Executable file → Normal file
View file

@ -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` |

View file

@ -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` |
#### Пороги сертификатов

8
config.go Executable file → Normal file
View file

@ -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 != "" {

645
firewall.go Normal file
View file

@ -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)
}

711
firewall_tui.go Normal file
View file

@ -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)
}

153
main.go
View file

@ -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 <sid>%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")