Added firewall settings
This commit is contained in:
parent
d74a323cdb
commit
1f2d554d6a
6 changed files with 1487 additions and 76 deletions
35
ARCHITECTURE.md
Executable file → Normal file
35
ARCHITECTURE.md
Executable file → Normal file
|
|
@ -48,6 +48,7 @@ auspex [флаг] [аргументы]
|
||||||
├── -w → runWebCheck(sid) [WAF, build tag]
|
├── -w → runWebCheck(sid) [WAF, build tag]
|
||||||
├── -fd → runFDCheck()
|
├── -fd → runFDCheck()
|
||||||
├── -dt → runDockerTUI()
|
├── -dt → runDockerTUI()
|
||||||
|
├── -fw → runFirewallTUI()
|
||||||
├── -v → версия (+ вариант сборки: full/generic)
|
├── -v → версия (+ вариант сборки: full/generic)
|
||||||
└── -h → usage
|
└── -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` — проверка доступности сайтов
|
### `webcheck.go` — проверка доступности сайтов
|
||||||
|
|
||||||
**Точка входа:** `runWebCheck(sidArg string)`
|
**Точка входа:** `runWebCheck(sidArg string)`
|
||||||
|
|
@ -540,6 +572,7 @@ primary (cfgPrimaryDBHost) → ping OK? → использовать
|
||||||
| dockercheck | `dc` |
|
| dockercheck | `dc` |
|
||||||
| dockerview | `dv` |
|
| dockerview | `dv` |
|
||||||
| fdcheck | `fd` |
|
| fdcheck | `fd` |
|
||||||
|
| firewall | `fw` |
|
||||||
| email | `email` |
|
| email | `email` |
|
||||||
| mattermost | `mm` |
|
| mattermost | `mm` |
|
||||||
| webcheck | `wc` |
|
| webcheck | `wc` |
|
||||||
|
|
@ -555,4 +588,4 @@ primary (cfgPrimaryDBHost) → ping OK? → использовать
|
||||||
|
|
||||||
`buildinfo_waf.go` / `buildinfo_generic.go` по той же схеме задают константу `buildVariant` (`"full"` / `"generic"`), которую показывают баннер меню и `auspex -v`.
|
`buildinfo_waf.go` / `buildinfo_generic.go` по той же схеме задают константу `buildVariant` (`"full"` / `"generic"`), которую показывают баннер меню и `auspex -v`.
|
||||||
|
|
||||||
`lib/pq` используется только в трёх WAF-тегированных файлах — в generic-сборке он просто не компилируется (в `go.mod` остаётся объявленным, это нормально для двух конфигураций одного модуля; не стоит гонять `go mod tidy` без тега `waf`, иначе он может решить, что зависимость лишняя).
|
`lib/pq` используется только в трёх WAF-тегированных файлах — в generic-сборке он просто не компилируется (в `go.mod` остаётся объявленным, это нормально для двух конфигураций одного модуля; не стоит гонять `go mod tidy` без тега `waf`, иначе он может решить, что зависимость лишняя).
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
| `-w` | Проверка доступности сайтов клиента через lynx **[WAF]** |
|
| `-w` | Проверка доступности сайтов клиента через lynx **[WAF]** |
|
||||||
| `-fd` | Поиск удалённых-но-открытых файловых дескрипторов (recover/reopen/truncate/restart/kill) |
|
| `-fd` | Поиск удалённых-но-открытых файловых дескрипторов (recover/reopen/truncate/restart/kill) |
|
||||||
| `-dt` | Docker Live — интерактивный TUI (аналог lazydocker): логи, шелл, stop/start/restart/remove |
|
| `-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 и доступны
|
Модули **[WAF]** требуют БД `waf_info` и/или структуру каталогов PT AF и доступны
|
||||||
только в сборке `full` (см. [Сборка](#сборка)).
|
только в сборке `full` (см. [Сборка](#сборка)).
|
||||||
|
|
@ -33,6 +34,8 @@ auspex/
|
||||||
├── servicecheck.go # -sv: проверка служб systemd
|
├── servicecheck.go # -sv: проверка служб systemd
|
||||||
├── dockercheck.go # -d: проверка Docker контейнеров (снапшот + diff)
|
├── dockercheck.go # -d: проверка Docker контейнеров (снапшот + diff)
|
||||||
├── dockerview.go # -dt: Docker Live TUI (аналог lazydocker)
|
├── 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 просмотр занятых портов
|
├── portsview.go # -ss: TUI просмотр занятых портов
|
||||||
├── fdcheck.go # -fd: удалённые-но-открытые файловые дескрипторы
|
├── fdcheck.go # -fd: удалённые-но-открытые файловые дескрипторы
|
||||||
├── portcheck.go # -p: проверка доступности портов [WAF, build tag]
|
├── portcheck.go # -p: проверка доступности портов [WAF, build tag]
|
||||||
|
|
@ -89,6 +92,7 @@ sudo cp auspex /usr/local/bin/auspex
|
||||||
./auspex -w # Проверка веб доступности
|
./auspex -w # Проверка веб доступности
|
||||||
./auspex -fd # Поиск удалённых-но-открытых файловых дескрипторов
|
./auspex -fd # Поиск удалённых-но-открытых файловых дескрипторов
|
||||||
./auspex -dt # Docker Live TUI (аналог lazydocker)
|
./auspex -dt # Docker Live TUI (аналог lazydocker)
|
||||||
|
./auspex -fw # Firewall TUI (ufw/nftables/iptables)
|
||||||
./auspex -h # Справка
|
./auspex -h # Справка
|
||||||
./auspex -v # Версия
|
./auspex -v # Версия
|
||||||
```
|
```
|
||||||
|
|
@ -164,6 +168,7 @@ sudo nano /etc/auspex/auspex.env
|
||||||
| `SERVICE_STATE_FILE_PATH` | State-файл servicecheck | `/tmp/auspex_service_state.json` |
|
| `SERVICE_STATE_FILE_PATH` | State-файл servicecheck | `/tmp/auspex_service_state.json` |
|
||||||
| `DOCKER_STATE_FILE_PATH` | State-файл dockercheck | `/tmp/auspex_docker_state.json` |
|
| `DOCKER_STATE_FILE_PATH` | State-файл dockercheck | `/tmp/auspex_docker_state.json` |
|
||||||
| `FD_RECOVER_DIR` | Куда сохраняются снимки восстановленных файлов (`-fd`) | `/var/tmp/auspex-recovered` |
|
| `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).
|
См. [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||||
|
|
|
||||||
10
config.go
Executable file → Normal file
10
config.go
Executable file → Normal file
|
|
@ -81,6 +81,9 @@ var (
|
||||||
cfgFDRecoverDir = "/var/tmp/auspex-recovered"
|
cfgFDRecoverDir = "/var/tmp/auspex-recovered"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// --- Firewall (-fw) ---
|
||||||
|
var cfgFWRollbackSeconds = 25
|
||||||
|
|
||||||
// --- Параметры проверки портов ---
|
// --- Параметры проверки портов ---
|
||||||
var (
|
var (
|
||||||
cfgCheckTimeoutSec = 10
|
cfgCheckTimeoutSec = 10
|
||||||
|
|
@ -227,6 +230,11 @@ func applyConfig(env map[string]string) {
|
||||||
if v, ok := env["FD_RECOVER_DIR"]; ok && v != "" {
|
if v, ok := env["FD_RECOVER_DIR"]; ok && v != "" {
|
||||||
cfgFDRecoverDir = 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
|
// Thresholds
|
||||||
if v, ok := env["CERT_CRITICAL_DAYS"]; ok && v != "" {
|
if v, ok := env["CERT_CRITICAL_DAYS"]; ok && v != "" {
|
||||||
|
|
@ -402,4 +410,4 @@ func printConfigStatus() {
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
645
firewall.go
Normal file
645
firewall.go
Normal 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
711
firewall_tui.go
Normal 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)
|
||||||
|
}
|
||||||
155
main.go
155
main.go
|
|
@ -117,6 +117,12 @@ func runByFlag(flag string, extraArgs []string) {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "-fw":
|
||||||
|
if err := runFirewallTUI(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
case "-v", "--version":
|
case "-v", "--version":
|
||||||
fmt.Printf("auspex v%s (%s)\n", appVersion, buildVariant)
|
fmt.Printf("auspex v%s (%s)\n", appVersion, buildVariant)
|
||||||
|
|
||||||
|
|
@ -132,7 +138,80 @@ func runByFlag(flag string, extraArgs []string) {
|
||||||
|
|
||||||
func showMenu() {
|
func showMenu() {
|
||||||
printBanner()
|
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("%sВыберите утилиту:%s\n\n", cBlue, cReset)
|
||||||
fmt.Printf(" %s1%s) %s-ss%s Просмотр занятых портов (TUI интерфейс)\n", cGreen, cReset, cCyan, 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)
|
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(" %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(" %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(" %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(" %s0%s) Выход\n\n", cGreen, cReset)
|
||||||
fmt.Printf("%sДля быстрого вызова: auspex <ключ>%s\n", cYellow, 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)
|
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() {
|
func printBanner() {
|
||||||
|
|
@ -251,6 +259,7 @@ func printUsage() {
|
||||||
fmt.Printf(" %-12s Проверка доступности сайтов через lynx [sid] [WAF]\n", "-w")
|
fmt.Printf(" %-12s Проверка доступности сайтов через lynx [sid] [WAF]\n", "-w")
|
||||||
fmt.Printf(" %-12s Удалённые-но-открытые файловые дескрипторы (recover/reopen/restart/kill)\n", "-fd")
|
fmt.Printf(" %-12s Удалённые-но-открытые файловые дескрипторы (recover/reopen/restart/kill)\n", "-fd")
|
||||||
fmt.Printf(" %-12s Docker Live — интерактивный TUI (аналог lazydocker)\n", "-dt")
|
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", "-v")
|
||||||
fmt.Printf(" %-12s Показать эту справку\n\n", "-h")
|
fmt.Printf(" %-12s Показать эту справку\n\n", "-h")
|
||||||
fmt.Printf("Без аргументов запускается интерактивное меню.\n")
|
fmt.Printf("Без аргументов запускается интерактивное меню.\n")
|
||||||
|
|
@ -259,4 +268,4 @@ func printUsage() {
|
||||||
" они требуют БД waf_info / структуры PT AF. Соберите с 'go build -tags waf .',\n"+
|
" они требуют БД waf_info / структуры PT AF. Соберите с 'go build -tags waf .',\n"+
|
||||||
" чтобы получить полную (full) сборку.%s\n", cYellow, cReset)
|
" чтобы получить полную (full) сборку.%s\n", cYellow, cReset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue