auspex/portsview.go
2026-02-24 10:54:38 +03:00

620 lines
15 KiB
Go

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
}