diff --git a/README.md b/README.md index 2879275..91b4798 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ $ kopsc docker clearsafe --dry-run 将执行: docker system prune -f 平台: linux/amd64, distro=centos, runtimes=docker 方式: 直接执行 -命中: 变体 #1(5 分)CentOS 上的 Docker 运行时 -依据: 匹配 os=linux, distro=centos (+3), runtime=docker (+2) +命中: 变体 #1(2 分)Linux 上的 Docker 运行时 +依据: 匹配 os=linux, runtime=docker (+2) ``` 在 CentOS 上它执行 `docker system prune -f`;在装有 Podman 的 CentOS 上自动改走 @@ -66,6 +66,9 @@ kopsc diagnose api-0 -n prod # K8s:本命令里 -n 表示 namespace kopsc diagnose api-0 --kind k8s --context staging kopsc diagnose web --lines 200 --no-events +kopsc net pids 8080 # 等价 lsof -ti:8080,输出占用端口的 PID +kopsc net ports # 列出监听中的端口 + kopsc disk usage /var # 磁盘占用排行(du -x --max-depth=1 -h | sort -hr 的跨平台版) kopsc disk usage --path /var # 同上,用命名参数指定路径 kopsc disk usage /var --dry-run # 预览(内置命令同样支持) @@ -103,7 +106,7 @@ $ kopsc disk usage /var | 参数 | 行为 | | --- | --- | -| `-n`, `--dry-run`, `--dry-run=true` | 置为预览模式,不执行 | +| `-n`, `--dry-run`, `--dry-run=true` | 置为预览模式,不执行。**例外**:`kopsc diagnose` 中 `-n` 表示 namespace,预览请用 `--dry-run` | | `--dry-run=false` | 关闭预览模式 | | `-y`, `--yes` | 确认执行破坏性命令;未提供时只打印影响范围并以退出码 2 结束 | | `-h`, `--help` | 打印该命令的帮助 | @@ -172,11 +175,34 @@ $ kopsc disk usage /var Kubernetes 的 CrashLoopBackOff、ImagePullBackOff/ErrImagePull、CreateContainerConfigError、OOMKilled、 Pending(结合 FailedScheduling/FailedMount 事件)、Evicted 等。 +### 端口占用查询(net.pids) + +`kopsc net pids <端口>` 等价 `lsof -ti:<端口>`:输出占用该端口的进程 PID,每行一个、升序、去重;没有进程占用时无输出并正常退出。 + +```bash +kopsc net pids 8080 +kopsc net pids --port 8080 +kopsc net pids 8080 --dry-run +``` + +底层按平台自动选择,但对外的命令与输出保持一致: + +| 平台 | 依次尝试的底层来源 | +| --- | --- | +| Linux | `lsof -ti:` → `ss -H -t -u -n -p`(解析 `pid=`) → 读 `/proc/net/*` 与 `/proc/*/fd`(纯内置,零外部依赖) | +| macOS | `lsof -ti:`(系统自带,无 `/proc` 回退) | +| Windows | `netstat -ano`(解析本地地址与 PID) | + +- 端口必须是 1–65535;非法端口直接报错,不执行任何外部命令。 +- `lsof` 无匹配时退出码为 1 且无输出,kopsc 视为“没有进程占用”,返回 0。 +- 只读命令,不需要 `--yes`。 + ### 其它 | 命令 | 说明 | 平台变体 | | --- | --- | --- | | `diagnose` | 容器/Pod 状态根因诊断(自动识别 Docker 与 Kubernetes) | Linux/macOS/Windows 均使用 kopsc 内置实现 | +| `net.pids` | 查找占用指定端口的进程 PID(等价 `lsof -ti:<端口>`) | Linux `lsof` → `ss` → `/proc`;macOS `lsof`;Windows `netstat -ano`。三平台命令与输出一致(PID 升序、每行一个) | | `disk.usage` | 目录占用排行(等价 `du -x --max-depth=1 -h <路径> \| sort -hr`) | Linux/macOS/Windows 均使用 kopsc 内置实现,保证输出一致 | | `net.ports` | 列出监听中的端口 | Linux `ss -tulnp`、macOS `lsof -iTCP -sTCP:LISTEN -nP`、Windows `netstat -ano` | | `pkg.refreshindex` | 刷新包管理器索引 | centos `dnf makecache`、ID_LIKE rhel/fedora `dnf makecache`、ID_LIKE debian/ubuntu `apt-get update`、alpine `apk update`、macOS `brew update`、Windows `winget source update` | @@ -203,10 +229,10 @@ Pending(结合 FailedScheduling/FailedMount 事件)、Evicted 等。 例如 CentOS 上同时装了 docker 与 podman: ```text -✓ #0 5 分 匹配 os=linux, distro=centos (+3), runtime=podman (+2) -> podman system prune -f -✓ #1 5 分 匹配 os=linux, distro=centos (+3), runtime=docker (+2) -> docker system prune -f -✓ #2 2 分 匹配 os=linux, runtime=podman (+2) -> podman system prune -f -✗ #4 0 分 os=darwin 不匹配(当前 linux) -> docker system prune -f +✓ #0 2 分 匹配 os=linux, runtime=podman (+2) -> podman system prune -f +✓ #1 2 分 匹配 os=linux, runtime=docker (+2) -> docker system prune -f +✗ #2 0 分 os=darwin 不匹配(当前 linux) -> docker system prune -f +✗ #3 0 分 os=windows 不匹配(当前 linux) -> docker system prune -f ``` ## 新增命令或平台变体 @@ -244,7 +270,8 @@ cmd/kopsc 入口,注入版本号 internal/platform 环境探测(可注入,便于测试) internal/registry 命令模型、校验、内置注册表 internal/resolve 变体打分与选择 -internal/executor 进程执行、dry-run、退出码透传 +internal/executor 进程执行、内置动作、dry-run、退出码透传 +internal/diagnose Docker/Podman 与 Kubernetes 的状态根因诊断(内置动作) internal/cli cobra 命令树与辅助命令 scripts/build 跨平台构建脚本(go run ./scripts/build) ``` diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 82a8038..b0c7bc3 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -460,6 +460,29 @@ func TestDiagnoseRequiresTarget(t *testing.T) { } } +func TestNetPidsDryRun(t *testing.T) { + app, stdout, stderr := testApp(centos("docker")) + + if code := app.Execute([]string{"net", "pids", "8080", "--dry-run"}); code != 0 { + t.Fatalf("exit code = %d, stderr=%s", code, stderr.String()) + } + out := stdout.String() + if !strings.Contains(out, "(内置) net.pids 8080") { + t.Errorf("dry-run output missing builtin plan:\n%s", out) + } +} + +func TestNetPidsRequiresPort(t *testing.T) { + app, _, stderr := testApp(centos("docker")) + + if code := app.Execute([]string{"net", "pids"}); code != 1 { + t.Fatalf("exit code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "缺少参数") { + t.Errorf("stderr = %s", stderr.String()) + } +} + // TestCLIHelperProcess is re-executed as a child to verify exit-code passthrough. func TestCLIHelperProcess(t *testing.T) { if os.Getenv("KOPSC_CLI_HELPER") != "1" { diff --git a/internal/executor/builtin.go b/internal/executor/builtin.go index f65c7e1..fd28a51 100644 --- a/internal/executor/builtin.go +++ b/internal/executor/builtin.go @@ -24,6 +24,7 @@ type Builtin func(ctx context.Context, req BuiltinRequest) error var builtins = map[string]Builtin{ "disk.usage": runDiskUsage, "diagnose": runDiagnose, + "net.pids": runNetPIDs, } // runDiagnose bridges the registry builtin to the diagnose package. diff --git a/internal/executor/netpids.go b/internal/executor/netpids.go new file mode 100644 index 0000000..d40f879 --- /dev/null +++ b/internal/executor/netpids.go @@ -0,0 +1,333 @@ +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:`: 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) + } +} diff --git a/internal/executor/netpids_test.go b/internal/executor/netpids_test.go new file mode 100644 index 0000000..7a3640c --- /dev/null +++ b/internal/executor/netpids_test.go @@ -0,0 +1,188 @@ +package executor + +import ( + "bytes" + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestParsePortArgs(t *testing.T) { + cases := []struct { + name string + args []string + want int + wantErr string + }{ + {"positional", []string{"8080"}, 8080, ""}, + {"port flag", []string{"--port", "443"}, 443, ""}, + {"port flag equals", []string{"--port=443"}, 443, ""}, + {"missing", nil, 0, "必须提供端口"}, + {"zero", []string{"0"}, 0, "1-65535"}, + {"too large", []string{"65536"}, 0, "1-65535"}, + {"not a number", []string{"abc"}, 0, "1-65535"}, + {"two ports", []string{"80", "81"}, 0, "一次只支持一个端口"}, + {"unknown option", []string{"--wat"}, 0, "未知选项"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + port, err := parsePortArgs(tc.args) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err = %v, want %q", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if port != tc.want { + t.Errorf("port = %d, want %d", port, tc.want) + } + }) + } +} + +func TestParseNetstatPIDs(t *testing.T) { + output := `Active Connections + + Proto Local Address Foreign Address State PID + TCP 0.0.0.0:8080 0.0.0.0:0 LISTENING 1234 + TCP [::]:8080 [::]:0 LISTENING 1234 + TCP 127.0.0.1:8080 127.0.0.1:51515 ESTABLISHED 5678 + TCP 0.0.0.0:8081 0.0.0.0:0 LISTENING 9999 + UDP 0.0.0.0:8080 *:* 4321 +` + + got := parseNetstatPIDs(output, 8080) + want := []string{"1234", "4321", "5678"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("pids = %v, want %v", got, want) + } + + if got := parseNetstatPIDs(output, 9999); len(got) != 0 { + t.Errorf("port 9999 should have no match, got %v", got) + } +} + +func TestNetPIDsUnixWithFakeLsof(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("lsof fixture is not executable on Windows") + } + + dir := t.TempDir() + script := filepath.Join(dir, "lsof") + if err := os.WriteFile(script, []byte("#!/bin/sh\necho 111\necho 222\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + var out bytes.Buffer + if err := runNetPIDs(context.Background(), BuiltinRequest{Args: []string{"8080"}, Stdout: &out}); err != nil { + t.Fatalf("runNetPIDs error: %v", err) + } + if out.String() != "111\n222\n" { + t.Fatalf("output = %q", out.String()) + } +} + +func TestNetPIDsUnixNoMatchIsNotAnError(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("lsof fixture is not executable on Windows") + } + + dir := t.TempDir() + script := filepath.Join(dir, "lsof") + if err := os.WriteFile(script, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + var out bytes.Buffer + if err := runNetPIDs(context.Background(), BuiltinRequest{Args: []string{"8080"}, Stdout: &out}); err != nil { + t.Fatalf("no-match should not fail: %v", err) + } + if out.Len() != 0 { + t.Fatalf("expected no output, got %q", out.String()) + } +} + +func TestNetPIDsRejectsInvalidPortBeforeRunning(t *testing.T) { + var out bytes.Buffer + err := runNetPIDs(context.Background(), BuiltinRequest{Args: []string{"70000"}, Stdout: &out}) + if err == nil || !strings.Contains(err.Error(), "1-65535") { + t.Fatalf("err = %v", err) + } +} + +func TestParseSSPIDs(t *testing.T) { + output := `tcp ESTAB 0 0 127.0.0.1:8080 127.0.0.1:51515 users:(("app",pid=4321,fd=6)) +udp UNCONN 0 0 0.0.0.0:8080 0.0.0.0:* users:(("dns",pid=123,fd=5)) +tcp LISTEN 0 128 0.0.0.0:8081 0.0.0.0:* users:(("other",pid=9999,fd=3))` + + got := parseSSPIDs(output, 8080) + want := []string{"123", "4321"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("pids = %v, want %v", got, want) + } +} + +func TestNetPIDsUnixFallsBackToSS(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell fixtures are not executable on Windows") + } + + dir := t.TempDir() + body := "#!/bin/sh\n" + + "echo 'tcp ESTAB 0 0 127.0.0.1:8080 127.0.0.1:51515 users:((\"app\",pid=4321,fd=6))'\n" + + "echo 'tcp LISTEN 0 128 0.0.0.0:8081 0.0.0.0:* users:((\"other\",pid=9999,fd=3))'\n" + if err := os.WriteFile(filepath.Join(dir, "ss"), []byte(body), 0o755); err != nil { + t.Fatal(err) + } + // Only the fixture directory: there is no lsof, so ss must be used. + t.Setenv("PATH", dir) + + var out bytes.Buffer + if err := runNetPIDs(context.Background(), BuiltinRequest{Args: []string{"8080"}, Stdout: &out}); err != nil { + t.Fatalf("runNetPIDs error: %v", err) + } + if out.String() != "4321\n" { + t.Fatalf("output = %q, want %q", out.String(), "4321\n") + } +} + +func TestProcPIDs(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("procPIDs is Linux-only") + } + + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "net"), 0o755); err != nil { + t.Fatal(err) + } + tcp := " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n" + + " 0: 0100007F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 12345 1\n" + if err := os.WriteFile(filepath.Join(root, "net", "tcp"), []byte(tcp), 0o644); err != nil { + t.Fatal(err) + } + + fdDir := filepath.Join(root, "4242", "fd") + if err := os.MkdirAll(fdDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink("socket:[12345]", filepath.Join(fdDir, "3")); err != nil { + t.Fatal(err) + } + + got, err := procPIDs(8080, root) // 0x1F90 == 8080 + if err != nil { + t.Fatalf("procPIDs error: %v", err) + } + if strings.Join(got, ",") != "4242" { + t.Fatalf("pids = %v, want [4242]", got) + } +} diff --git a/internal/registry/builtin.go b/internal/registry/builtin.go index 7bf04d4..8d83644 100644 --- a/internal/registry/builtin.go +++ b/internal/registry/builtin.go @@ -325,17 +325,41 @@ func Builtin() *Registry { }, }, }, + { + ID: "net.pids", + Group: "net", + Summary: "查找占用指定端口的进程 PID", + Action: "等价 lsof -ti:<端口>:每行输出一个占用该端口的进程 PID。Linux/macOS 调用 lsof,Windows 解析 netstat -ano;无进程占用时无输出", + Usage: "<端口>", + MinArgs: 1, + Variants: []Variant{ + { + Match: Match{OS: "linux"}, + Builtin: "net.pids", + Note: "Linux 使用 lsof -ti", + }, + { + Match: Match{OS: "darwin"}, + Builtin: "net.pids", + Note: "macOS 使用 lsof -ti", + }, + { + Match: Match{OS: "windows"}, + Builtin: "net.pids", + Note: "Windows 解析 netstat -ano", + }, + }, + }, { ID: "net.ports", Group: "net", Summary: "列出本机正在监听的 TCP/UDP 端口", Action: "以只读方式列出监听中的套接字及持有进程", - Variants: []Variant{ - { - Match: Match{OS: "linux"}, - Argv: []string{"ss", "-tulnp"}, - Note: "Linux 使用 iproute2 的 ss", - }, + Variants: []Variant{{ + Match: Match{OS: "linux"}, + Argv: []string{"ss", "-tulnp"}, + Note: "Linux 使用 iproute2 的 ss", + }, { Match: Match{OS: "darwin"}, Argv: []string{"lsof", "-iTCP", "-sTCP:LISTEN", "-nP"}, diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index 9fbbe2b..35b7c61 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -25,7 +25,7 @@ func TestBuiltinIDsStable(t *testing.T) { "docker.prunebuilder", "docker.prunevolumes", "docker.clearall", "docker.compose.up", "docker.compose.down", "docker.compose.ps", "docker.compose.logs", "diagnose", - "disk.usage", "net.ports", "pkg.refreshindex", + "disk.usage", "net.pids", "net.ports", "pkg.refreshindex", } if got := reg.IDs(); !reflect.DeepEqual(got, want) { t.Fatalf("IDs() =\n%v\nwant\n%v", got, want)