620 lines
15 KiB
Go
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
|
|
}
|