- kopsc net pids <端口> 等价 lsof -ti:<端口>,输出 PID 升序去重、每行一个 - Linux 回退链 lsof -> ss -> /proc;macOS 使用 lsof;Windows 解析 netstat -ano - 端口 1-65535 校验,无占用时无输出并正常退出 - README 新增 net.pids 小节,修正过时的匹配示例、-n 例外说明与目录结构
334 lines
7.9 KiB
Go
334 lines
7.9 KiB
Go
package executor
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"regexp"
|
||
"runtime"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
// pidPattern extracts "pid=1234" from ss output.
|
||
var pidPattern = regexp.MustCompile(`pid=(\d+)`)
|
||
|
||
// runNetPIDs implements `lsof -ti:<port>`: print the PID of every process
|
||
// holding the given port, one per line.
|
||
//
|
||
// The kopsc command and its output are identical on every platform; only the
|
||
// backend differs:
|
||
// - Linux: lsof -> ss -> /proc (first available wins)
|
||
// - macOS: lsof (the system ships it; no /proc fallback)
|
||
// - Windows: netstat -ano
|
||
func runNetPIDs(ctx context.Context, req BuiltinRequest) error {
|
||
port, err := parsePortArgs(req.Args)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if runtime.GOOS == "windows" {
|
||
return netPIDsWindows(ctx, port, req)
|
||
}
|
||
return netPIDsUnix(ctx, port, req)
|
||
}
|
||
|
||
func parsePortArgs(args []string) (int, error) {
|
||
var value string
|
||
for i := 0; i < len(args); i++ {
|
||
arg := args[i]
|
||
switch {
|
||
case arg == "--port":
|
||
if i+1 >= len(args) {
|
||
return 0, fmt.Errorf("--port 缺少端口号")
|
||
}
|
||
i++
|
||
value = args[i]
|
||
case strings.HasPrefix(arg, "--port="):
|
||
value = strings.TrimPrefix(arg, "--port=")
|
||
case strings.HasPrefix(arg, "-") && arg != "-":
|
||
return 0, fmt.Errorf("未知选项 %q", arg)
|
||
default:
|
||
if value != "" {
|
||
return 0, fmt.Errorf("一次只支持一个端口,收到多个参数")
|
||
}
|
||
value = arg
|
||
}
|
||
}
|
||
if value == "" {
|
||
return 0, fmt.Errorf("必须提供端口,例如 kopsc net pids 8080")
|
||
}
|
||
|
||
port, err := strconv.Atoi(value)
|
||
if err != nil || port < 1 || port > 65535 {
|
||
return 0, fmt.Errorf("端口必须是 1-65535 的整数,收到 %q", value)
|
||
}
|
||
return port, nil
|
||
}
|
||
|
||
func netPIDsUnix(ctx context.Context, port int, req BuiltinRequest) error {
|
||
pids, err := unixPIDs(ctx, port)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
writePIDs(req.Stdout, pids)
|
||
return nil
|
||
}
|
||
|
||
// unixPIDs tries lsof, then ss, then /proc on Linux.
|
||
func unixPIDs(ctx context.Context, port int) ([]string, error) {
|
||
var lsofErr error
|
||
if path, err := exec.LookPath("lsof"); err == nil {
|
||
pids, matched, err := lsofPIDs(ctx, path, port)
|
||
if err == nil {
|
||
if !matched {
|
||
return nil, nil
|
||
}
|
||
return pids, nil
|
||
}
|
||
lsofErr = err
|
||
}
|
||
|
||
var ssErr error
|
||
if path, err := exec.LookPath("ss"); err == nil {
|
||
pids, err := ssPIDs(ctx, path, port)
|
||
if err == nil {
|
||
return pids, nil
|
||
}
|
||
ssErr = err
|
||
}
|
||
|
||
if runtime.GOOS == "linux" {
|
||
if pids, err := procPIDs(port, "/proc"); err == nil {
|
||
return pids, nil
|
||
} else if lsofErr == nil && ssErr == nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
switch {
|
||
case lsofErr != nil:
|
||
return nil, lsofErr
|
||
case ssErr != nil:
|
||
return nil, ssErr
|
||
}
|
||
return nil, fmt.Errorf("未找到 lsof 或 ss(macOS 自带 lsof;Linux 请安装 lsof 或 iproute2)")
|
||
}
|
||
|
||
func lsofPIDs(ctx context.Context, path string, port int) ([]string, bool, error) {
|
||
cmd := exec.CommandContext(ctx, path, "-ti:"+strconv.Itoa(port))
|
||
var stdout, stderr bytes.Buffer
|
||
cmd.Stdout = &stdout
|
||
cmd.Stderr = &stderr
|
||
|
||
err := cmd.Run()
|
||
if err != nil {
|
||
var exitErr *exec.ExitError
|
||
// lsof exits 1 with no output when nothing holds the port.
|
||
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 && stdout.Len() == 0 {
|
||
return nil, false, nil
|
||
}
|
||
message := strings.TrimSpace(stderr.String())
|
||
if message == "" {
|
||
message = err.Error()
|
||
}
|
||
return nil, false, fmt.Errorf("lsof -ti:%d 失败: %s", port, message)
|
||
}
|
||
return normalizePIDs(strings.Fields(stdout.String())), true, nil
|
||
}
|
||
|
||
func ssPIDs(ctx context.Context, path string, port int) ([]string, error) {
|
||
cmd := exec.CommandContext(ctx, path, "-H", "-t", "-u", "-n", "-p")
|
||
var stdout, stderr bytes.Buffer
|
||
cmd.Stdout = &stdout
|
||
cmd.Stderr = &stderr
|
||
|
||
if err := cmd.Run(); err != nil {
|
||
message := strings.TrimSpace(stderr.String())
|
||
if message == "" {
|
||
message = err.Error()
|
||
}
|
||
return nil, fmt.Errorf("ss -t -u -n -p 失败: %s", message)
|
||
}
|
||
return parseSSPIDs(stdout.String(), port), nil
|
||
}
|
||
|
||
// parseSSPIDs extracts PIDs for a local port from `ss -t -u -n -p` output.
|
||
func parseSSPIDs(output string, port int) []string {
|
||
suffix := ":" + strconv.Itoa(port)
|
||
var pids []string
|
||
|
||
for _, line := range strings.Split(output, "\n") {
|
||
fields := strings.Fields(line)
|
||
if len(fields) < 5 {
|
||
continue
|
||
}
|
||
// Netid State Recv-Q Send-Q Local:Port Peer:Port Process
|
||
if !strings.HasSuffix(fields[4], suffix) {
|
||
continue
|
||
}
|
||
match := pidPattern.FindStringSubmatch(line)
|
||
if len(match) == 2 {
|
||
pids = append(pids, match[1])
|
||
}
|
||
}
|
||
return normalizePIDs(pids)
|
||
}
|
||
|
||
// procPIDs derives PIDs from /proc without external tools (Linux only).
|
||
func procPIDs(port int, procRoot string) ([]string, error) {
|
||
inodes := make(map[string]bool)
|
||
|
||
for _, name := range []string{"net/tcp", "net/tcp6", "net/udp", "net/udp6"} {
|
||
data, err := os.ReadFile(filepath.Join(procRoot, filepath.FromSlash(name)))
|
||
if err != nil {
|
||
continue
|
||
}
|
||
lines := strings.Split(string(data), "\n")
|
||
if len(lines) > 0 {
|
||
lines = lines[1:] // header
|
||
}
|
||
for _, line := range lines {
|
||
fields := strings.Fields(line)
|
||
if len(fields) < 10 {
|
||
continue
|
||
}
|
||
local := fields[1]
|
||
idx := strings.LastIndex(local, ":")
|
||
if idx < 0 {
|
||
continue
|
||
}
|
||
hexPort, err := strconv.ParseInt(local[idx+1:], 16, 32)
|
||
if err != nil || int(hexPort) != port {
|
||
continue
|
||
}
|
||
inodes[fields[9]] = true // inode column
|
||
}
|
||
}
|
||
|
||
if len(inodes) == 0 {
|
||
return nil, nil
|
||
}
|
||
|
||
entries, err := os.ReadDir(procRoot)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取 %s 失败: %w", procRoot, err)
|
||
}
|
||
|
||
var pids []string
|
||
for _, entry := range entries {
|
||
if !entry.IsDir() {
|
||
continue
|
||
}
|
||
pid := entry.Name()
|
||
if _, err := strconv.Atoi(pid); err != nil {
|
||
continue
|
||
}
|
||
|
||
fdDir := filepath.Join(procRoot, pid, "fd")
|
||
fds, err := os.ReadDir(fdDir)
|
||
if err != nil {
|
||
continue // permission denied for other users
|
||
}
|
||
for _, fd := range fds {
|
||
link, err := os.Readlink(filepath.Join(fdDir, fd.Name()))
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if !strings.HasPrefix(link, "socket:[") {
|
||
continue
|
||
}
|
||
inode := strings.TrimSuffix(strings.TrimPrefix(link, "socket:["), "]")
|
||
if inodes[inode] {
|
||
pids = append(pids, pid)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return normalizePIDs(pids), nil
|
||
}
|
||
|
||
func netPIDsWindows(ctx context.Context, port int, req BuiltinRequest) error {
|
||
cmd := exec.CommandContext(ctx, "netstat", "-ano")
|
||
var stdout, stderr bytes.Buffer
|
||
cmd.Stdout = &stdout
|
||
cmd.Stderr = &stderr
|
||
|
||
if err := cmd.Run(); err != nil {
|
||
message := strings.TrimSpace(stderr.String())
|
||
if message == "" {
|
||
message = err.Error()
|
||
}
|
||
return fmt.Errorf("netstat -ano 失败: %s", message)
|
||
}
|
||
|
||
writePIDs(req.Stdout, parseNetstatPIDs(stdout.String(), port))
|
||
return nil
|
||
}
|
||
|
||
// parseNetstatPIDs extracts unique owning PIDs for a local port from
|
||
// `netstat -ano` output.
|
||
func parseNetstatPIDs(output string, port int) []string {
|
||
suffix := ":" + strconv.Itoa(port)
|
||
var pids []string
|
||
|
||
for _, line := range strings.Split(output, "\n") {
|
||
fields := strings.Fields(line)
|
||
if len(fields) < 2 {
|
||
continue
|
||
}
|
||
proto := strings.ToUpper(fields[0])
|
||
if proto != "TCP" && proto != "UDP" {
|
||
continue
|
||
}
|
||
if !strings.HasSuffix(fields[1], suffix) {
|
||
continue
|
||
}
|
||
|
||
pid := fields[len(fields)-1]
|
||
if _, err := strconv.Atoi(pid); err != nil {
|
||
continue
|
||
}
|
||
pids = append(pids, pid)
|
||
}
|
||
return normalizePIDs(pids)
|
||
}
|
||
|
||
// normalizePIDs de-duplicates and sorts PIDs numerically so every platform
|
||
// prints the same order.
|
||
func normalizePIDs(pids []string) []string {
|
||
seen := make(map[string]bool)
|
||
out := make([]string, 0, len(pids))
|
||
for _, pid := range pids {
|
||
pid = strings.TrimSpace(pid)
|
||
if pid == "" {
|
||
continue
|
||
}
|
||
if _, err := strconv.Atoi(pid); err != nil {
|
||
continue
|
||
}
|
||
if seen[pid] {
|
||
continue
|
||
}
|
||
seen[pid] = true
|
||
out = append(out, pid)
|
||
}
|
||
sort.Slice(out, func(i, j int) bool {
|
||
a, _ := strconv.Atoi(out[i])
|
||
b, _ := strconv.Atoi(out[j])
|
||
return a < b
|
||
})
|
||
return out
|
||
}
|
||
|
||
func writePIDs(w interface{ Write([]byte) (int, error) }, pids []string) {
|
||
for _, pid := range pids {
|
||
fmt.Fprintln(w, pid)
|
||
}
|
||
}
|