auspex/firewall_tui.go

711 lines
22 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
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)
}