feat: 新增 net pids 端口占用查询并补齐 README
- kopsc net pids <端口> 等价 lsof -ti:<端口>,输出 PID 升序去重、每行一个 - Linux 回退链 lsof -> ss -> /proc;macOS 使用 lsof;Windows 解析 netstat -ano - 端口 1-65535 校验,无占用时无输出并正常退出 - README 新增 net.pids 小节,修正过时的匹配示例、-n 例外说明与目录结构
This commit is contained in:
@@ -8,8 +8,8 @@ $ kopsc docker clearsafe --dry-run
|
|||||||
将执行: docker system prune -f
|
将执行: docker system prune -f
|
||||||
平台: linux/amd64, distro=centos, runtimes=docker
|
平台: linux/amd64, distro=centos, runtimes=docker
|
||||||
方式: 直接执行
|
方式: 直接执行
|
||||||
命中: 变体 #1(5 分)CentOS 上的 Docker 运行时
|
命中: 变体 #1(2 分)Linux 上的 Docker 运行时
|
||||||
依据: 匹配 os=linux, distro=centos (+3), runtime=docker (+2)
|
依据: 匹配 os=linux, runtime=docker (+2)
|
||||||
```
|
```
|
||||||
|
|
||||||
在 CentOS 上它执行 `docker system prune -f`;在装有 Podman 的 CentOS 上自动改走
|
在 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 api-0 --kind k8s --context staging
|
||||||
kopsc diagnose web --lines 200 --no-events
|
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 /var # 磁盘占用排行(du -x --max-depth=1 -h | sort -hr 的跨平台版)
|
||||||
kopsc disk usage --path /var # 同上,用命名参数指定路径
|
kopsc disk usage --path /var # 同上,用命名参数指定路径
|
||||||
kopsc disk usage /var --dry-run # 预览(内置命令同样支持)
|
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` | 关闭预览模式 |
|
| `--dry-run=false` | 关闭预览模式 |
|
||||||
| `-y`, `--yes` | 确认执行破坏性命令;未提供时只打印影响范围并以退出码 2 结束 |
|
| `-y`, `--yes` | 确认执行破坏性命令;未提供时只打印影响范围并以退出码 2 结束 |
|
||||||
| `-h`, `--help` | 打印该命令的帮助 |
|
| `-h`, `--help` | 打印该命令的帮助 |
|
||||||
@@ -172,11 +175,34 @@ $ kopsc disk usage /var
|
|||||||
Kubernetes 的 CrashLoopBackOff、ImagePullBackOff/ErrImagePull、CreateContainerConfigError、OOMKilled、
|
Kubernetes 的 CrashLoopBackOff、ImagePullBackOff/ErrImagePull、CreateContainerConfigError、OOMKilled、
|
||||||
Pending(结合 FailedScheduling/FailedMount 事件)、Evicted 等。
|
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:<port>` → `ss -H -t -u -n -p`(解析 `pid=`) → 读 `/proc/net/*` 与 `/proc/*/fd`(纯内置,零外部依赖) |
|
||||||
|
| macOS | `lsof -ti:<port>`(系统自带,无 `/proc` 回退) |
|
||||||
|
| Windows | `netstat -ano`(解析本地地址与 PID) |
|
||||||
|
|
||||||
|
- 端口必须是 1–65535;非法端口直接报错,不执行任何外部命令。
|
||||||
|
- `lsof` 无匹配时退出码为 1 且无输出,kopsc 视为“没有进程占用”,返回 0。
|
||||||
|
- 只读命令,不需要 `--yes`。
|
||||||
|
|
||||||
### 其它
|
### 其它
|
||||||
|
|
||||||
| 命令 | 说明 | 平台变体 |
|
| 命令 | 说明 | 平台变体 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `diagnose` | 容器/Pod 状态根因诊断(自动识别 Docker 与 Kubernetes) | Linux/macOS/Windows 均使用 kopsc 内置实现 |
|
| `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 内置实现,保证输出一致 |
|
| `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` |
|
| `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` |
|
| `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:
|
例如 CentOS 上同时装了 docker 与 podman:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
✓ #0 5 分 匹配 os=linux, distro=centos (+3), runtime=podman (+2) -> podman system prune -f
|
✓ #0 2 分 匹配 os=linux, runtime=podman (+2) -> podman system prune -f
|
||||||
✓ #1 5 分 匹配 os=linux, distro=centos (+3), runtime=docker (+2) -> docker system prune -f
|
✓ #1 2 分 匹配 os=linux, runtime=docker (+2) -> docker system prune -f
|
||||||
✓ #2 2 分 匹配 os=linux, runtime=podman (+2) -> podman system prune -f
|
✗ #2 0 分 os=darwin 不匹配(当前 linux) -> docker system prune -f
|
||||||
✗ #4 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/platform 环境探测(可注入,便于测试)
|
||||||
internal/registry 命令模型、校验、内置注册表
|
internal/registry 命令模型、校验、内置注册表
|
||||||
internal/resolve 变体打分与选择
|
internal/resolve 变体打分与选择
|
||||||
internal/executor 进程执行、dry-run、退出码透传
|
internal/executor 进程执行、内置动作、dry-run、退出码透传
|
||||||
|
internal/diagnose Docker/Podman 与 Kubernetes 的状态根因诊断(内置动作)
|
||||||
internal/cli cobra 命令树与辅助命令
|
internal/cli cobra 命令树与辅助命令
|
||||||
scripts/build 跨平台构建脚本(go run ./scripts/build)
|
scripts/build 跨平台构建脚本(go run ./scripts/build)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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.
|
// TestCLIHelperProcess is re-executed as a child to verify exit-code passthrough.
|
||||||
func TestCLIHelperProcess(t *testing.T) {
|
func TestCLIHelperProcess(t *testing.T) {
|
||||||
if os.Getenv("KOPSC_CLI_HELPER") != "1" {
|
if os.Getenv("KOPSC_CLI_HELPER") != "1" {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type Builtin func(ctx context.Context, req BuiltinRequest) error
|
|||||||
var builtins = map[string]Builtin{
|
var builtins = map[string]Builtin{
|
||||||
"disk.usage": runDiskUsage,
|
"disk.usage": runDiskUsage,
|
||||||
"diagnose": runDiagnose,
|
"diagnose": runDiagnose,
|
||||||
|
"net.pids": runNetPIDs,
|
||||||
}
|
}
|
||||||
|
|
||||||
// runDiagnose bridges the registry builtin to the diagnose package.
|
// runDiagnose bridges the registry builtin to the diagnose package.
|
||||||
|
|||||||
@@ -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:<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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
ID: "net.ports",
|
||||||
Group: "net",
|
Group: "net",
|
||||||
Summary: "列出本机正在监听的 TCP/UDP 端口",
|
Summary: "列出本机正在监听的 TCP/UDP 端口",
|
||||||
Action: "以只读方式列出监听中的套接字及持有进程",
|
Action: "以只读方式列出监听中的套接字及持有进程",
|
||||||
Variants: []Variant{
|
Variants: []Variant{{
|
||||||
{
|
Match: Match{OS: "linux"},
|
||||||
Match: Match{OS: "linux"},
|
Argv: []string{"ss", "-tulnp"},
|
||||||
Argv: []string{"ss", "-tulnp"},
|
Note: "Linux 使用 iproute2 的 ss",
|
||||||
Note: "Linux 使用 iproute2 的 ss",
|
},
|
||||||
},
|
|
||||||
{
|
{
|
||||||
Match: Match{OS: "darwin"},
|
Match: Match{OS: "darwin"},
|
||||||
Argv: []string{"lsof", "-iTCP", "-sTCP:LISTEN", "-nP"},
|
Argv: []string{"lsof", "-iTCP", "-sTCP:LISTEN", "-nP"},
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func TestBuiltinIDsStable(t *testing.T) {
|
|||||||
"docker.prunebuilder", "docker.prunevolumes", "docker.clearall",
|
"docker.prunebuilder", "docker.prunevolumes", "docker.clearall",
|
||||||
"docker.compose.up", "docker.compose.down", "docker.compose.ps", "docker.compose.logs",
|
"docker.compose.up", "docker.compose.down", "docker.compose.ps", "docker.compose.logs",
|
||||||
"diagnose",
|
"diagnose",
|
||||||
"disk.usage", "net.ports", "pkg.refreshindex",
|
"disk.usage", "net.pids", "net.ports", "pkg.refreshindex",
|
||||||
}
|
}
|
||||||
if got := reg.IDs(); !reflect.DeepEqual(got, want) {
|
if got := reg.IDs(); !reflect.DeepEqual(got, want) {
|
||||||
t.Fatalf("IDs() =\n%v\nwant\n%v", got, want)
|
t.Fatalf("IDs() =\n%v\nwant\n%v", got, want)
|
||||||
|
|||||||
Reference in New Issue
Block a user