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