711 lines
22 KiB
Go
711 lines
22 KiB
Go
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)
|
||
}
|