package main import ( "fmt" "os" "os/exec" "sort" "strconv" "strings" "sync" "time" "github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) // ── Типы ───────────────────────────────────────────────────────────────────── type pvSortMode int const ( pvSortByPort pvSortMode = iota pvSortByProcess pvSortByPID ) type pvPortEntry struct { Port, Protocol, PID, Process, State, Address string } type pvLoadedMsg struct { entries []pvPortEntry err error } type pvDetailsMsg struct { content string err error } type pvAutoTickMsg struct{} type pvModel struct { table table.Model textInput textinput.Model entries []pvPortEntry filteredEntries []pvPortEntry err error status string width, height int isFiltering bool showDetails bool detailsContent string sortMode pvSortMode loading bool loadingDetails bool autoRefresh bool lastRefresh time.Time cache *pvCache } type pvCache struct { mu sync.RWMutex store map[string]pvCacheEntry } type pvCacheEntry struct { details string timestamp time.Time } func newPvCache() *pvCache { return &pvCache{store: make(map[string]pvCacheEntry)} } func (c *pvCache) get(pid string) (string, bool) { c.mu.RLock() defer c.mu.RUnlock() e, ok := c.store[pid] if !ok || time.Since(e.timestamp) > 30*time.Second { return "", false } return e.details, true } func (c *pvCache) set(pid, details string) { c.mu.Lock() defer c.mu.Unlock() c.store[pid] = pvCacheEntry{details: details, timestamp: time.Now()} } func (c *pvCache) clear() { c.mu.Lock() defer c.mu.Unlock() c.store = make(map[string]pvCacheEntry) } // ── Стили ──────────────────────────────────────────────────────────────────── var ( pvCommonPorts = map[string]string{ "21": "FTP", "22": "SSH", "23": "Telnet", "25": "SMTP", "53": "DNS", "80": "HTTP", "110": "POP3", "143": "IMAP", "443": "HTTPS", "3306": "MySQL", "5432": "PostgreSQL", "6379": "Redis", "8080": "HTTP-Alt", "27017": "MongoDB", } pvBaseStyle = lipgloss.NewStyle(). BorderStyle(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("#6c7086")). Padding(1, 2) pvLogoStyle = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#89b4fa")). Padding(0, 1) pvHelpStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#6c7086")). Padding(0, 1) pvStatusStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#a6e3a1")). Bold(true) pvInputStyle = lipgloss.NewStyle(). BorderStyle(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("#89b4fa")). Padding(0, 1) pvDetailsStyle = lipgloss.NewStyle(). BorderStyle(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("#89b4fa")). Padding(1, 2). Width(80) pvDetailsTitleStyle = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#89b4fa")). Padding(0, 0, 1, 0) pvLoadingStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#f9e2af")). Bold(true) ) // ── Точка входа ────────────────────────────────────────────────────────────── func runPortsView() error { columns := []table.Column{ {Title: "Port", Width: 8}, {Title: "Proto", Width: 6}, {Title: "State", Width: 12}, {Title: "PID", Width: 8}, {Title: "Address", Width: 20}, {Title: "Process", Width: 20}, } t := table.New( table.WithColumns(columns), table.WithFocused(true), table.WithHeight(10), ) 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.Placeholder = "Search ports, processes, pids..." ti.CharLimit = 156 ti.Width = 40 m := pvModel{ table: t, textInput: ti, cache: newPvCache(), autoRefresh: false, } if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil { return fmt.Errorf("ошибка запуска TUI: %w", err) } return nil } // ── Tea Model ──────────────────────────────────────────────────────────────── func (m pvModel) Init() tea.Cmd { return tea.Batch(pvLoadAsync, textinput.Blink, pvTickAuto) } func pvTickAuto() tea.Msg { time.Sleep(5 * time.Second) return pvAutoTickMsg{} } func pvLoadAsync() tea.Msg { entries, err := pvGetPorts() return pvLoadedMsg{entries: entries, err: err} } func pvLoadDetails(pid, port, proto, addr, state, proc string, cache *pvCache) tea.Cmd { return func() tea.Msg { if cached, ok := cache.get(pid); ok { content := cached if pid != "-" { content = fmt.Sprintf("Port: %s/%s\nPID: %s\nAddress: %s\nState: %s\nProcess: %s\n\n%s", port, proto, pid, addr, state, proc, cached) } return pvDetailsMsg{content: content} } details, err := pvGetProcessDetails(pid) if err != nil { return pvDetailsMsg{err: err} } cache.set(pid, details) content := details if pid != "-" { content = fmt.Sprintf("Port: %s/%s\nPID: %s\nAddress: %s\nState: %s\nProcess: %s\n\n%s", port, proto, pid, addr, state, proc, details) } return pvDetailsMsg{content: content} } } func (m pvModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd if _, ok := msg.(pvAutoTickMsg); ok { var cmds []tea.Cmd if m.autoRefresh && !m.loading && time.Since(m.lastRefresh) > 5*time.Second { cmds = append(cmds, pvLoadAsync) } cmds = append(cmds, pvTickAuto) return m, tea.Batch(cmds...) } if m.isFiltering { switch msg := msg.(type) { case tea.KeyMsg: if msg.String() == "enter" || msg.String() == "esc" { m.isFiltering = false m.table.Focus() return m, nil } } m.textInput, cmd = m.textInput.Update(msg) m.pvFilter() return m, cmd } if m.showDetails { switch msg := msg.(type) { case tea.KeyMsg: if msg.String() == "esc" || msg.String() == "q" || msg.String() == "enter" { m.showDetails = false m.loadingDetails = false return m, nil } case pvDetailsMsg: m.loadingDetails = false if msg.err != nil { m.detailsContent = fmt.Sprintf("Error: %v", msg.err) } else { m.detailsContent = msg.content } return m, nil } return m, nil } switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "q", "ctrl+c": return m, tea.Quit case "/": m.isFiltering = true m.textInput.Focus() m.textInput.SetValue("") return m, textinput.Blink case "r": if !m.loading { m.status = "Refreshing..." m.loading = true m.cache.clear() return m, pvLoadAsync } case "a": m.autoRefresh = !m.autoRefresh if m.autoRefresh { m.status = "Auto-refresh ON" } else { m.status = "Auto-refresh OFF" } case "s": switch m.sortMode { case pvSortByPort: m.sortMode = pvSortByProcess case pvSortByProcess: m.sortMode = pvSortByPID default: m.sortMode = pvSortByPort } m.pvSort() m.pvUpdateColumns() m.pvUpdateTable() case "enter": if len(m.filteredEntries) > 0 && !m.loadingDetails { idx := m.table.Cursor() if idx >= 0 && idx < len(m.filteredEntries) { t := m.filteredEntries[idx] m.showDetails = true m.loadingDetails = true m.detailsContent = "Loading..." return m, pvLoadDetails(t.PID, t.Port, t.Protocol, t.Address, t.State, t.Process, m.cache) } } case "k": if len(m.filteredEntries) > 0 { idx := m.table.Cursor() if idx >= 0 && idx < len(m.filteredEntries) { t := m.filteredEntries[idx] if t.PID == "-" { if os.Geteuid() == 0 { m.status = "Cannot kill system process" } else { m.status = "Run as sudo to kill this process" } return m, nil } err := pvKillProcess(t.PID) if err != nil { m.status = fmt.Sprintf("Error killing %s: %v", t.PID, err) } else { m.status = fmt.Sprintf("Killed %s (%s)", t.Process, t.PID) m.cache.clear() return m, pvLoadAsync } } } } case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height avail := m.height - 7 pvBaseStyle = pvBaseStyle.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 pvLoadedMsg: m.loading = false m.lastRefresh = time.Now() if msg.err != nil { m.err = msg.err } else { m.entries = msg.entries m.pvSort() m.pvFilter() m.pvUpdateColumns() m.err = nil if m.status == "Refreshing..." { m.status = "Refreshed" } } case error: m.err = msg m.loading = false } m.table, cmd = m.table.Update(msg) return m, cmd } func (m *pvModel) pvSort() { sort.Slice(m.entries, func(i, j int) bool { switch m.sortMode { case pvSortByProcess: return strings.ToLower(m.entries[i].Process) < strings.ToLower(m.entries[j].Process) case pvSortByPID: if m.entries[i].PID == "-" { return false } if m.entries[j].PID == "-" { return true } p1, _ := strconv.Atoi(m.entries[i].PID) p2, _ := strconv.Atoi(m.entries[j].PID) return p1 < p2 default: p1, e1 := strconv.Atoi(m.entries[i].Port) p2, e2 := strconv.Atoi(m.entries[j].Port) if e1 == nil && e2 == nil { if p1 == p2 { return m.entries[i].Protocol < m.entries[j].Protocol } return p1 < p2 } return m.entries[i].Port < m.entries[j].Port } }) m.pvFilter() } func (m *pvModel) pvUpdateColumns() { cols := []table.Column{ {Title: "Port", Width: 8}, {Title: "Proto", Width: 6}, {Title: "State", Width: 12}, {Title: "PID", Width: 8}, {Title: "Address", Width: 20}, {Title: "Process", Width: 20}, } arrow := " ▼" switch m.sortMode { case pvSortByPort: cols[0].Title += arrow case pvSortByPID: cols[3].Title += arrow case pvSortByProcess: cols[5].Title += arrow } m.table.SetColumns(cols) } func (m *pvModel) pvFilter() { q := strings.ToLower(m.textInput.Value()) m.filteredEntries = nil for _, e := range m.entries { if q == "" || strings.Contains(strings.ToLower(e.Process), q) || strings.Contains(e.Port, q) || strings.Contains(e.PID, q) || strings.Contains(strings.ToLower(e.State), q) || strings.Contains(strings.ToLower(e.Address), q) { m.filteredEntries = append(m.filteredEntries, e) } } m.pvUpdateTable() } func (m *pvModel) pvUpdateTable() { rows := []table.Row{} for _, e := range m.filteredEntries { icon := "○" if strings.Contains(e.State, "LISTEN") { icon = "●" } else if strings.Contains(e.State, "ESTAB") { icon = "↔" } rows = append(rows, table.Row{e.Port, e.Protocol, icon + " " + e.State, e.PID, e.Address, e.Process}) } m.table.SetRows(rows) } func (m pvModel) View() string { if m.err != nil { return fmt.Sprintf("Error: %v\nPress 'q' to quit", m.err) } if m.showDetails { content := pvDetailsTitleStyle.Render("Connection Details") + "\n" if m.loadingDetails { content += pvLoadingStyle.Render("Loading...") + "\n" } else { content += m.detailsContent } content += "\n\n" + pvHelpStyle.Render("Press Esc/Enter to close") box := pvDetailsStyle.Render(content) return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box) } logo := pvLogoStyle.Render("⚡ Auspex — Ports View") if m.loading { logo += " " + pvLoadingStyle.Render("●") } tableView := pvBaseStyle.Render(m.table.View()) controls := "↑/↓: Navigate • /: Filter • Enter: Details • k: Kill • r: Refresh • s: Sort • a: Auto-refresh • q: Quit" if m.isFiltering { controls = "Type to search • Esc/Enter: Done" inputView := pvInputStyle.Render(m.textInput.View()) return fmt.Sprintf("%s\n%s\n%s\n%s", logo, tableView, inputView, pvHelpStyle.Render(controls)) } status := m.status if status != "" { controls = pvStatusStyle.Render(status) + " • " + controls } if m.autoRefresh { controls += " " + pvLoadingStyle.Render("[AUTO]") } return fmt.Sprintf("%s\n%s\n%s", logo, tableView, pvHelpStyle.Render(controls)) } // ── Системные функции ──────────────────────────────────────────────────────── func pvGetPorts() ([]pvPortEntry, error) { cmd := exec.Command("ss", "-tulnp") output, err := cmd.Output() if err != nil { return nil, fmt.Errorf("failed to run ss: %v", err) } lines := strings.Split(string(output), "\n") entries := make([]pvPortEntry, 0, len(lines)) for i, line := range lines { if line == "" || i == 0 { continue } fields := strings.Fields(line) if len(fields) < 5 || fields[0] == "Netid" { continue } entries = append(entries, pvParseSSLine(fields)) } return entries, nil } func pvParseSSLine(fields []string) pvPortEntry { proto := fields[0] state := fields[1] localAddr := fields[4] address := localAddr port := "" if idx := strings.LastIndex(localAddr, ":"); idx != -1 { port = localAddr[idx+1:] address = localAddr[:idx] } if address == "*" || address == "0.0.0.0" || address == "[::]" { address = "All Interfaces" } pid := "" process := "" for _, f := range fields { if strings.Contains(f, "users:((") { content := strings.TrimPrefix(f, "users:((") content = strings.TrimSuffix(content, "))") content = strings.TrimSuffix(content, ")") parts := strings.Split(content, ",") for _, p := range parts { if strings.HasPrefix(p, "\"") { process = strings.Trim(p, "\"") } if strings.HasPrefix(p, "pid=") { pid = strings.TrimPrefix(p, "pid=") } } } } if pid == "" { pid = "-" suffix := "(requires sudo)" if os.Geteuid() == 0 { suffix = "(system)" } if svc, ok := pvCommonPorts[port]; ok { process = fmt.Sprintf("%s %s", svc, suffix) } else { process = suffix } } return pvPortEntry{Port: port, Protocol: proto, PID: pid, Process: process, State: state, Address: address} } func pvKillProcess(pid string) error { pidInt, err := strconv.Atoi(pid) if err != nil { return err } proc, err := os.FindProcess(pidInt) if err != nil { return err } return proc.Kill() } func pvGetProcessDetails(pid string) (string, error) { if pid == "-" { if os.Geteuid() == 0 { return "System process (no detailed information available).", nil } return "Process details require sudo privileges.", nil } cmd := exec.Command("ps", "-p", pid, "-o", "user,lstart,cmd", "--no-headers") output, err := cmd.Output() if err != nil { return "", fmt.Errorf("failed to get details: %v", err) } return strings.TrimSpace(string(output)), nil }