Files
kenaito-kopsc/internal/executor/netports.go
T
odboy 5d4631c053 fix: net ports 表格按显示宽度对齐,修正中文表头导致的错位
text/tabwriter 按字节数计算列宽,中文表头每个字 3 字节但只占 2 列,
导致表头下方的数据列整体错位。改为自行计算终端显示宽度(CJK 算 2 列)
补空格,行尾不留空白;监听地址 0.0.0.0/:: 统一显示为 *。
2026-11-01 08:39:37 +08:00

358 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package executor
import (
"context"
"fmt"
"os/exec"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
)
// listeningPort is one listening socket with its owner, normalised across
// platforms so the table looks the same everywhere.
type listeningPort struct {
Proto string // TCP or UDP
Address string // local address as reported, e.g. "0.0.0.0", "*", "[::]"
Port int
PID string // empty when the platform does not expose the owner
Process string // empty when unknown
}
// listenCommand describes how to list listening sockets on this platform.
type listenCommand struct {
program string
args []string
parse func([]byte) []listeningPort
}
// runNetPorts implements `kopsc net ports [--raw]`.
//
// By default the listeners are printed as one table with the same columns on
// every platform; --raw keeps the platform command's own output for scripts
// that parse it.
func runNetPorts(ctx context.Context, req BuiltinRequest) error {
raw := false
for _, arg := range req.Args {
switch arg {
case "--raw":
raw = true
case "--":
default:
if strings.HasPrefix(arg, "-") {
return fmt.Errorf("未知选项 %q(支持 --raw)", arg)
}
}
}
if raw {
spec := listenCommandFor(runtime.GOOS)
cmd := exec.CommandContext(ctx, spec.program, spec.args...)
cmd.Stdout = req.Stdout
cmd.Stderr = req.Stderr
if err := cmd.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return nil // lsof: nothing is listening
}
return fmt.Errorf("%s 执行失败: %w", spec.program, err)
}
return nil
}
ports, err := listListeningPorts(ctx, runtime.GOOS)
if err != nil {
return err
}
renderListeningPorts(ports, req)
return nil
}
// renderListeningPorts writes the table, sorted by port.
//
// text/tabwriter measures in bytes, which misaligns the CJK headings (each
// character is three bytes wide but only two columns on screen), so the
// columns are padded by display width instead.
func renderListeningPorts(ports []listeningPort, req BuiltinRequest) {
if len(ports) == 0 {
if req.Stderr != nil {
fmt.Fprintln(req.Stderr, "没有正在监听的端口")
}
return
}
rows := [][]string{{"端口", "协议", "地址", "PID", "进程"}}
for _, p := range ports {
pid, process := p.PID, p.Process
if pid == "" {
pid = "-"
}
if process == "" {
process = "-"
}
rows = append(rows, []string{
strconv.Itoa(p.Port), p.Proto, displayAddress(p.Address), pid, process,
})
}
// The last column is not padded, so lines end without trailing spaces.
widths := make([]int, len(rows[0]))
for _, row := range rows {
for i, cell := range row {
if w := displayWidth(cell); w > widths[i] {
widths[i] = w
}
}
}
// Two spaces between columns, as the rest of kopsc's tables use.
const gap = 2
var b strings.Builder
for _, row := range rows {
b.Reset()
for i, cell := range row {
b.WriteString(cell)
if i < len(row)-1 {
b.WriteString(strings.Repeat(" ", widths[i]-displayWidth(cell)+gap))
}
}
fmt.Fprintln(req.Stdout, strings.TrimRight(b.String(), " "))
}
}
// displayAddress shows a listening address the way ss/netstat print it: a
// wildcard bind is "*", an IPv6 wildcard is "::".
func displayAddress(addr string) string {
switch addr {
case "", "0.0.0.0", "::":
return "*"
}
return addr
}
// displayWidth counts the columns a string occupies in a terminal: two for
// wide (CJK) runes, one otherwise. Combining marks are ignored.
func displayWidth(s string) int {
width := 0
for _, r := range s {
width += runeWidth(r)
}
return width
}
// runeWidth is the usual wcwidth approximation: East Asian wide characters
// take two columns, everything else one.
func runeWidth(r rune) int {
switch {
case r == 0:
return 0
case r < 32:
return 0
case r < 0x1100:
return 1
case r <= 0x115F, // Hangul Jamo
r >= 0x2E80 && r <= 0xA4CF, // CJK radicals, Kana, CJK ideographs
r >= 0xAC00 && r <= 0xD7A3, // Hangul syllables
r >= 0xF900 && r <= 0xFAFF, // CJK compatibility ideographs
r >= 0xFE30 && r <= 0xFE6F, // CJK compatibility forms
r >= 0xFF00 && r <= 0xFF60, // fullwidth forms
r >= 0xFFE0 && r <= 0xFFE6,
r >= 0x1F300 && r <= 0x1F64F, // emoji
r >= 0x20000 && r <= 0x3FFFD:
return 2
}
return 1
}
// listenCommandFor picks the platform's command.
func listenCommandFor(goos string) listenCommand {
switch goos {
case "linux":
return listenCommand{program: "ss", args: []string{"-tulnp"}, parse: parseSSListeners}
case "windows":
return listenCommand{program: "netstat", args: []string{"-ano"}, parse: parseNetstatListeners}
default:
// macOS: lsof is part of the base system and prints the owner name.
return listenCommand{program: "lsof", args: []string{"-iTCP", "-sTCP:LISTEN", "-nP"}, parse: parseLsofListeners}
}
}
// listListeningPorts runs the platform command and parses its output.
func listListeningPorts(ctx context.Context, goos string) ([]listeningPort, error) {
spec := listenCommandFor(goos)
cmd := exec.CommandContext(ctx, spec.program, spec.args...)
out, err := cmd.Output()
if err != nil {
// lsof exits 1 with no output when nothing is listening.
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 && len(out) == 0 {
return nil, nil
}
return nil, fmt.Errorf("%s 执行失败: %w", spec.program, err)
}
return spec.parse(out), nil
}
// ssProcessPattern extracts the process name from `ss -p` output such as
// users:(("nginx",pid=1234,fd=6)).
var ssProcessPattern = regexp.MustCompile(`\("([^"]+)"`)
// parseSSListeners parses `ss -tulnp`:
//
// Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
// udp UNCONN 0 0 0.0.0.0:68 0.0.0.0:* users:(("dhclient",pid=812,fd=6))
// tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=901,fd=3))
func parseSSListeners(out []byte) []listeningPort {
var ports []listeningPort
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) < 5 {
continue
}
proto := strings.ToUpper(fields[0])
if proto != "TCP" && proto != "UDP" {
continue
}
// ss lists every socket; only listeners (TCP LISTEN, any bound UDP)
// are interesting, so established connections are skipped.
if !isListeningState(proto, fields[1]) {
continue
}
addr, port, ok := splitHostPort(fields[4])
if !ok {
continue
}
entry := listeningPort{Proto: proto, Address: addr, Port: port}
if m := ssProcessPattern.FindStringSubmatch(line); len(m) == 2 {
entry.Process = m[1]
}
if m := pidPattern.FindStringSubmatch(line); len(m) == 2 {
entry.PID = m[1]
}
ports = append(ports, entry)
}
return dedupeListeners(ports)
}
// isListeningState reports whether ss's State column means "reachable
// inbound": TCP needs LISTEN, while UDP has no handshake and a bound socket
// shows as UNCONN. Established connections are not listeners.
func isListeningState(proto, state string) bool {
switch strings.ToUpper(proto) {
case "TCP":
return strings.EqualFold(state, "LISTEN")
case "UDP":
return strings.EqualFold(state, "UNCONN")
}
return false
}
// lsofListenPattern matches the tail of a lsof row:
//
// NAME: TCP *:22 (LISTEN)
var lsofListenPattern = regexp.MustCompile(`(TCP|UDP)\s+(\S+)\s+\(LISTEN\)`)
// parseLsofListeners parses `lsof -iTCP -sTCP:LISTEN -nP`:
//
// COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
// sshd 901 root 3u IPv4 ... 0t0 TCP *:22 (LISTEN)
func parseLsofListeners(out []byte) []listeningPort {
var ports []listeningPort
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) < 2 || fields[0] == "COMMAND" {
continue
}
m := lsofListenPattern.FindStringSubmatch(line)
if len(m) != 3 {
continue
}
addr, port, ok := splitHostPort(m[2])
if !ok {
continue
}
ports = append(ports, listeningPort{
Proto: strings.ToUpper(m[1]),
Address: addr,
Port: port,
PID: fields[1],
Process: fields[0],
})
}
return dedupeListeners(ports)
}
// parseNetstatListeners parses `netstat -ano` on Windows:
//
// Proto Local Address Foreign Address State PID
// TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 1234
// UDP 0.0.0.0:53 *:* 5678
func parseNetstatListeners(out []byte) []listeningPort {
var ports []listeningPort
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
proto := strings.ToUpper(fields[0])
if proto != "TCP" && proto != "UDP" {
continue
}
// TCP rows carry a state column; UDP rows do not. Only listening TCP
// sockets are interesting, while every bound UDP socket is.
if proto == "TCP" && (len(fields) < 4 || !strings.EqualFold(fields[3], "LISTENING")) {
continue
}
addr, port, ok := splitHostPort(fields[1])
if !ok {
continue
}
pid := fields[len(fields)-1]
if pid == "*" {
pid = ""
}
ports = append(ports, listeningPort{Proto: proto, Address: addr, Port: port, PID: pid})
}
return dedupeListeners(ports)
}
// splitHostPort splits "addr:port" from right, so IPv6 forms such as
// [::]:22 and *:7000 work. The brackets around IPv6 addresses are dropped.
func splitHostPort(s string) (string, int, bool) {
i := strings.LastIndexByte(s, ':')
if i < 0 {
return "", 0, false
}
port, err := strconv.Atoi(s[i+1:])
if err != nil || port < 0 || port > 65535 {
return "", 0, false
}
host := strings.TrimSuffix(strings.TrimPrefix(s[:i], "["), "]")
return host, port, true
}
// dedupeListeners drops rows repeating the same socket (lsof lists the same
// port once per address family) and sorts by port, then protocol.
func dedupeListeners(ports []listeningPort) []listeningPort {
seen := map[string]bool{}
out := ports[:0]
for _, p := range ports {
key := fmt.Sprintf("%s|%s|%d|%s", p.Proto, p.Address, p.Port, p.PID)
if seen[key] {
continue
}
seen[key] = true
out = append(out, p)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Port != out[j].Port {
return out[i].Port < out[j].Port
}
if out[i].Proto != out[j].Proto {
return out[i].Proto < out[j].Proto
}
return out[i].Address < out[j].Address
})
return out
}