diff --git a/README.file.md b/README.file.md index f1432dd..c8057d4 100644 --- a/README.file.md +++ b/README.file.md @@ -1,6 +1,6 @@ # file - 文件查找 -按大小、修改时间、名称查找文件,不用记 `find` 的复杂语法。 +按大小、修改时间、名称查找文件,查找空文件和空目录,不用记 `find` 的复杂语法。 ## 命令 @@ -9,6 +9,7 @@ | `kopsc file findbig [路径] [--threshold 大小]` | 查找大于指定阈值的文件,按大小排序 | | `kopsc file findrecent [路径] [--days 天数]` | 查找最近 N 天内修改过的文件 | | `kopsc file findname <模式> [路径]` | 按文件名查找(忽略大小写,支持 `*` `?` 通配符) | +| `kopsc file empty [路径]` | 查找空文件和空目录 | ## 示例 @@ -51,3 +52,25 @@ kopsc file findname "*.CONF" | --- | --- | | 第一个位置参数 | 文件名模式(必填) | | 第二个位置参数 | 搜索路径(默认 `.`) | + +### empty + +```bash +# 查找当前目录下的空文件和空目录 +kopsc file empty + +# 查找指定目录 +kopsc file empty /var/log + +# 只查找空文件 +kopsc file empty --files-only + +# 只查找空目录 +kopsc file empty --dirs-only +``` + +| 选项 | 说明 | +| --- | --- | +| `--files-only` | 只查找空文件 | +| `--dirs-only` | 只查找空目录 | +| 位置参数 | 搜索路径(默认 `.`) | diff --git a/README.md b/README.md index 2aa62ed..e1a7141 100644 --- a/README.md +++ b/README.md @@ -143,18 +143,18 @@ kopsc 的命令按用途分组,每组的完整说明(命令表、选项、 | 分组 | 用途 | 文档 | | --- | --- | --- | | **docker** | 容器与镜像:查看、启停、日志、清理、导出/导入、Compose | [README.docker.md](README.docker.md) | -| **net** | 网络与端口:端口被谁占用、监听端口、ping、DNS、路由追踪、HTTP 诊断 | [README.net.md](README.net.md) | +| **net** | 网络与端口:端口被谁占用、监听端口、ping、DNS、路由追踪、HTTP 诊断、网卡信息、连接统计、网速测试 | [README.net.md](README.net.md) | | **disk** | 磁盘与文件占用:哪个目录、哪个文件最占空间 | [README.disk.md](README.disk.md) | | **diagnose** | 容器 / Pod 状态根因诊断(自动识别 Docker 与 K8s) | [README.diagnose.md](README.diagnose.md) | -| **proc** | 本机进程:后台运行、等待退出、按资源排序、按名称终止 | [README.proc.md](README.proc.md) | +| **proc** | 本机进程:后台运行、等待退出、按资源排序、按名称终止、进程树 | [README.proc.md](README.proc.md) | | **java** | Java 应用:按 JDK 版本套用调优参数并运行 jar | [README.java.md](README.java.md) | | **git** | Git 历史:按日期范围调整提交时间并强推 | [README.git.md](README.git.md) | | **pkg** | 系统软件包:刷新包管理器索引 | [README.pkg.md](README.pkg.md) | -| **user** | 用户管理:授予或移除用户的 root/sudo 权限 | [README.user.md](README.user.md) | -| **file** | 文件查找:按大小、修改时间、名称查找文件 | [README.file.md](README.file.md) | +| **user** | 用户管理:授予或移除用户的 root/sudo 权限、查看用户信息 | [README.user.md](README.user.md) | +| **file** | 文件查找:按大小、修改时间、名称查找文件、查找空文件/空目录 | [README.file.md](README.file.md) | | **svc** | 服务管理:查看状态、启用/禁用、查看日志 | [README.svc.md](README.svc.md) | | **archive** | 压缩文件:自动识别格式并解压 | [README.archive.md](README.archive.md) | -| **sys** | 系统信息:OS、CPU、内存、磁盘一览 | [README.sys.md](README.sys.md) | +| **sys** | 系统信息:OS、CPU、内存、磁盘一览、运行时间 | [README.sys.md](README.sys.md) | | **log** | 系统日志:搜索关键字、查看最近日志 | [README.log.md](README.log.md) | | **fw** | 防火墙:查看状态、开放/关闭端口 | [README.fw.md](README.fw.md) | @@ -205,6 +205,13 @@ kopsc 的命令按用途分组,每组的完整说明(命令表、选项、 | 看最近日志 | `kopsc log last 100` | | 看防火墙状态 | `kopsc fw status` | | 开放/关闭端口 | `kopsc fw allow 8080/tcp --yes` / `kopsc fw deny 8080/tcp --yes` | +| 看系统运行时间 | `kopsc sys uptime` | +| 看网卡和 IP | `kopsc net iface` | +| 看 TCP 连接统计 | `kopsc net conn` | +| 测网速 | `kopsc net speed` | +| 看进程树 | `kopsc proc tree` | +| 找空文件/空目录 | `kopsc file empty /var` | +| 看用户信息 | `kopsc user info` | ## 平台识别 diff --git a/README.net.md b/README.net.md index 060f824..5dab4aa 100644 --- a/README.net.md +++ b/README.net.md @@ -2,7 +2,7 @@ > 返回 [README.md](README.md) -查看端口被谁占用、本机监听了哪些端口、测试连通性、DNS 查询、路由追踪、HTTP 诊断。 +查看端口被谁占用、本机监听了哪些端口、测试连通性、DNS 查询、路由追踪、HTTP 诊断、网卡信息、连接统计、网速测试。 ```bash kopsc net pids 8080 # 谁占用了 8080 端口(输出 PID) @@ -11,6 +11,9 @@ kopsc net ping example.com # 测试连通性 kopsc net trace example.com # 路由追踪 kopsc net dns example.com # DNS 查询 kopsc net curl https://example.com # HTTP 诊断 +kopsc net iface # 查看网卡和 IP 地址 +kopsc net conn # 查看 TCP 连接状态统计 +kopsc net speed # 简单网速测试 ``` ## 端口占用查询(net.pids) @@ -123,3 +126,63 @@ TLS 握手: 0.089s | `-L` / `--follow` | 跟随重定向 | | `-X` / `--method` | HTTP 方法(GET/POST/PUT 等) | | `-H` / `--header` | 添加请求头,可重复 | + +## 网卡信息(net.iface) + +```bash +kopsc net iface +``` + +列出所有非回环网卡的信息:名称、状态(UP/DOWN)、MAC 地址、IPv4/IPv6 地址、MTU。 + +纯 Go 实现,跨平台一致,不依赖外部命令。 + +## 连接统计(net.conn) + +```bash +kopsc net conn +``` + +统计各状态的 TCP 连接数量,输出示例: + +``` +=== TCP 连接统计 (共 108 个) === + +ESTABLISHED 61 +LISTEN 32 +TIME_WAIT 14 +CLOSE_WAIT 1 +``` + +Linux 优先使用 `ss -tan`(更快),回退到 `netstat -tan`;macOS/Windows 使用 `netstat -tan`。 + +## 网速测试(net.speed) + +```bash +kopsc net speed # 默认测试 10MB +kopsc net speed --size 50M # 测试 50MB +kopsc net speed --download # 只测下载 +kopsc net speed --upload # 只测上传 +``` + +从 Cloudflare 下载测试文件测量下载速度,向 httpbin.org 上传数据测量上传速度。 + +输出示例: + +``` +=== 网络速度测试 === + +测试下载速度... +下载速度: 12.5M/s + +测试上传速度... +上传速度: 3.2M/s +``` + +### 选项 + +| 选项 | 说明 | +| --- | --- | +| `--size` / `-s` | 测试文件大小(默认 10M,支持 K/M/G 后缀) | +| `--download` / `-d` | 只测下载 | +| `--upload` / `-u` | 只测上传 | diff --git a/README.proc.md b/README.proc.md index 754267e..edcb8aa 100644 --- a/README.proc.md +++ b/README.proc.md @@ -3,7 +3,7 @@ > 返回 [README.md](README.md) 在后台运行命令并写日志,关掉终端也不会退出;也可以等某个进程退出后再继续; -还能按资源占用排序查看进程、按名称终止进程。 +还能按资源占用排序查看进程、按名称终止进程、查看进程树。 ```bash kopsc proc nohup ./server # 后台运行命令,输出写日志 @@ -11,6 +11,7 @@ kopsc proc wait nginx # 等 nginx 退出后返回 kopsc proc topmem # 内存占用前 10 的进程 kopsc proc topcpu # CPU 占用前 10 的进程 kopsc proc killname nginx --yes # 终止名为 nginx 的进程 +kopsc proc tree # 查看进程树 ``` ## 后台运行(proc.nohup) @@ -128,3 +129,13 @@ kopsc proc killname nginx --yes # 确认终止 匹配规则:进程名或命令行中包含指定关键字(忽略大小写)。 > 这是破坏性命令,未加 `--yes` 时只列出匹配进程并以退出码 2 结束。 + +## 进程树(proc.tree) + +```bash +kopsc proc tree +``` + +以树形结构显示进程的父子关系。 + +Linux/macOS 优先使用 `pstree -p`(最佳输出),回退到 `ps -ejH`(Linux)或 `ps -eo pid,ppid,comm`(macOS);Windows 使用 `tasklist /tree`。 diff --git a/README.sys.md b/README.sys.md index 47a9c0c..ed0909f 100644 --- a/README.sys.md +++ b/README.sys.md @@ -7,9 +7,12 @@ | 命令 | 作用 | | --- | --- | | `kopsc sysinfo` | 显示系统信息一览 | +| `kopsc sys uptime` | 查看系统运行时间和启动时间 | ## 示例 +### sysinfo + ```bash kopsc sysinfo ``` @@ -41,6 +44,20 @@ Filesystem Size Used Avail Use% Mounted on /dev/vda1 50G 22G 28G 44% / ``` +### sys uptime + +```bash +kopsc sys uptime +``` + +输出示例: + +``` +运行时间: 15 天 3 小时 42 分钟 +启动时间: 2026-10-19 08:30:15 +负载均值: 0.15 0.22 0.18 (1/5/15分钟) +``` + ## 平台实现 | 信息 | Linux | macOS | Windows | @@ -49,3 +66,4 @@ Filesystem Size Used Avail Use% Mounted on | CPU | `/proc/cpuinfo` | `sysctl` | runtime | | 内存 | `/proc/meminfo` + cgroup | `sysctl hw.memsize` | `wmic` | | 磁盘 | `df -h` | `df -h` | `wmic logicaldisk` | +| 运行时间 | `/proc/uptime` | `sysctl kern.boottime` | `wmic os get LastBootUpTime` | diff --git a/README.user.md b/README.user.md index 2ac24f5..41714ea 100644 --- a/README.user.md +++ b/README.user.md @@ -8,6 +8,7 @@ | --- | --- | --- | | `kopsc user grant <用户名>` | 授予用户 root/sudo 权限 | Linux: `usermod -aG wheel/sudo <用户名>`
macOS: `dseditgroup -o edit -t user -a <用户名> admin`
Windows: `net localgroup Administrators <用户名> /add` | | `kopsc user revoke <用户名>` | 移除用户的 root/sudo 权限 | Linux: `gpasswd -d <用户名> wheel/sudo`
macOS: `dseditgroup -o edit -t user -d <用户名> admin`
Windows: `net localgroup Administrators <用户名> /delete` | +| `kopsc user info [用户名]` | 查看用户详细信息 | Linux/macOS: `id` + `/etc/passwd` + `groups`
Windows: `net user` | ## 用法 @@ -129,3 +130,27 @@ done **原因**:当前用户没有 sudo/管理员权限 **解决**:使用已有管理员权限的账户执行,或临时使用 `sudo` 前缀 + +## 查看用户信息(user.info) + +```bash +# 查看当前用户信息 +kopsc user info + +# 查看指定用户信息 +kopsc user info alice +``` + +输出示例: + +``` +用户: uid=501(alice) gid=20(staff) groups=20(staff),80(admin),204(_developer) +UID: 501 +GID: 20 +描述: Alice Wang +家目录: /Users/alice +Shell: /bin/zsh +组: staff admin _developer +``` + +显示用户的 UID、GID、所属组、描述信息、家目录、默认 Shell 等。不指定用户名时显示当前用户信息。 diff --git a/internal/executor/builtin.go b/internal/executor/builtin.go index b7c6da2..2183bfa 100644 --- a/internal/executor/builtin.go +++ b/internal/executor/builtin.go @@ -47,6 +47,7 @@ var builtins = map[string]Builtin{ "docker.save": runDockerSave, "docker.stats": runDockerStats, "docker.stop": runDockerStop, + "file.empty": runFileEmpty, "file.findbig": runFileFindBig, "file.findname": runFileFindName, "file.findrecent": runFileFindRecent, @@ -59,23 +60,29 @@ var builtins = map[string]Builtin{ "java.run.preview": runJavaPreview, "log.last": runLogLast, "log.search": runLogSearch, + "net.conn": runNetConn, "net.curl": runNetCurl, "net.dns": runNetDNS, + "net.iface": runNetIface, "net.ping": runNetPing, "net.pids": runNetPIDs, "net.ports": runNetPorts, + "net.speed": runNetSpeed, "net.trace": runNetTrace, "proc.killname": runProcKillName, "proc.nohup": runNohup, "proc.topcpu": runProcTopCPU, "proc.topmem": runProcTopMem, + "proc.tree": runProcTree, "proc.wait": runProcWait, "svc.disable": runSvcDisable, "svc.enable": runSvcEnable, "svc.logs": runSvcLogs, "svc.status": runSvcStatus, + "sys.uptime": runSysUptime, "sysinfo": runSysInfo, "user.grant": runUserGrant, + "user.info": runUserInfo, "user.revoke": runUserRevoke, } diff --git a/internal/executor/fileempty.go b/internal/executor/fileempty.go new file mode 100644 index 0000000..a9a87a2 --- /dev/null +++ b/internal/executor/fileempty.go @@ -0,0 +1,135 @@ +package executor + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" +) + +// runFileEmpty implements `kopsc file empty`: find empty files and directories. +func runFileEmpty(ctx context.Context, req BuiltinRequest) error { + opts, err := parseFileEmptyArgs(req.Args) + if err != nil { + return err + } + + root, err := filepath.Abs(opts.path) + if err != nil { + return fmt.Errorf("无法解析路径 %q: %w", opts.path, err) + } + + var emptyFiles []string + var emptyDirs []string + + err = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return nil + } + if err := ctx.Err(); err != nil { + return err + } + + if d.IsDir() { + // Check if directory is empty + entries, err := os.ReadDir(path) + if err != nil { + return nil + } + if len(entries) == 0 && path != root { + rel, _ := filepath.Rel(root, path) + if rel == "" { + rel = path + } + emptyDirs = append(emptyDirs, rel) + } + return nil + } + + // Check if file is empty + info, err := d.Info() + if err != nil { + return nil + } + if info.Size() == 0 { + rel, _ := filepath.Rel(root, path) + if rel == "" { + rel = path + } + emptyFiles = append(emptyFiles, rel) + } + return nil + }) + if err != nil { + return err + } + + sort.Strings(emptyFiles) + sort.Strings(emptyDirs) + + if !opts.dirsOnly { + if len(emptyFiles) == 0 { + fmt.Fprintln(req.Stdout, "没有空文件") + } else { + fmt.Fprintf(req.Stdout, "=== 空文件 (%d 个) ===\n", len(emptyFiles)) + for _, f := range emptyFiles { + fmt.Fprintln(req.Stdout, f) + } + fmt.Fprintln(req.Stdout) + } + } + + if !opts.filesOnly { + if len(emptyDirs) == 0 { + fmt.Fprintln(req.Stdout, "没有空目录") + } else { + fmt.Fprintf(req.Stdout, "=== 空目录 (%d 个) ===\n", len(emptyDirs)) + for _, d := range emptyDirs { + fmt.Fprintln(req.Stdout, d) + } + } + } + + return nil +} + +type fileEmptyOptions struct { + path string + filesOnly bool + dirsOnly bool +} + +func parseFileEmptyArgs(args []string) (fileEmptyOptions, error) { + opts := fileEmptyOptions{path: "."} + var paths []string + + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--": + paths = append(paths, args[i+1:]...) + i = len(args) + case arg == "--files-only": + opts.filesOnly = true + case arg == "--dirs-only": + opts.dirsOnly = true + case strings.HasPrefix(arg, "-") && arg != "-": + return opts, fmt.Errorf("未知选项 %q", arg) + default: + paths = append(paths, arg) + } + } + if len(paths) > 1 { + return opts, fmt.Errorf("一次只支持一个路径,收到 %d 个", len(paths)) + } + if len(paths) == 1 { + opts.path = paths[0] + } + if opts.filesOnly && opts.dirsOnly { + return opts, fmt.Errorf("--files-only 和 --dirs-only 不能同时使用") + } + return opts, nil +} diff --git a/internal/executor/netconn.go b/internal/executor/netconn.go new file mode 100644 index 0000000..9cdd5ba --- /dev/null +++ b/internal/executor/netconn.go @@ -0,0 +1,125 @@ +package executor + +import ( + "bufio" + "context" + "fmt" + "os/exec" + "runtime" + "sort" + "strings" +) + +// runNetConn implements `kopsc net conn`: show network connection statistics. +func runNetConn(ctx context.Context, req BuiltinRequest) error { + switch runtime.GOOS { + case "linux": + return netConnLinux(ctx, req) + case "darwin": + return netConnDarwin(ctx, req) + case "windows": + return netConnWindows(ctx, req) + default: + return fmt.Errorf("不支持的操作系统: %s", runtime.GOOS) + } +} + +func netConnLinux(ctx context.Context, req BuiltinRequest) error { + // Try ss first (faster) + if path, err := exec.LookPath("ss"); err == nil { + return netConnSS(ctx, path, req) + } + // Fallback to netstat + return netConnNetstat(ctx, req) +} + +func netConnSS(ctx context.Context, path string, req BuiltinRequest) error { + out, err := exec.CommandContext(ctx, path, "-tan").Output() + if err != nil { + return fmt.Errorf("ss 执行失败: %w", err) + } + return parseNetstatOutput(string(out), req) +} + +func netConnNetstat(ctx context.Context, req BuiltinRequest) error { + out, err := exec.CommandContext(ctx, "netstat", "-tan").Output() + if err != nil { + return fmt.Errorf("netstat 执行失败: %w", err) + } + return parseNetstatOutput(string(out), req) +} + +func netConnDarwin(ctx context.Context, req BuiltinRequest) error { + out, err := exec.CommandContext(ctx, "netstat", "-tan").Output() + if err != nil { + return fmt.Errorf("netstat 执行失败: %w", err) + } + return parseNetstatOutput(string(out), req) +} + +func netConnWindows(ctx context.Context, req BuiltinRequest) error { + out, err := exec.CommandContext(ctx, "netstat", "-an").Output() + if err != nil { + return fmt.Errorf("netstat 执行失败: %w", err) + } + return parseNetstatOutput(string(out), req) +} + +func parseNetstatOutput(output string, req BuiltinRequest) error { + stats := make(map[string]int) + total := 0 + + scanner := bufio.NewScanner(strings.NewReader(output)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) < 4 { + continue + } + + // Detect state column (usually last for TCP) + state := fields[len(fields)-1] + if isConnectionState(state) { + stats[state]++ + total++ + } + } + + if total == 0 { + fmt.Fprintln(req.Stdout, "没有活动的 TCP 连接") + return nil + } + + fmt.Fprintf(req.Stdout, "=== TCP 连接统计 (共 %d 个) ===\n\n", total) + + // Sort by count descending + type stateCount struct { + state string + count int + } + var sorted []stateCount + for state, count := range stats { + sorted = append(sorted, stateCount{state, count}) + } + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].count > sorted[j].count + }) + + for _, sc := range sorted { + fmt.Fprintf(req.Stdout, "%-15s %d\n", sc.state, sc.count) + } + + return nil +} + +func isConnectionState(s string) bool { + switch s { + case "ESTABLISHED", "SYN_SENT", "SYN_RECV", "FIN_WAIT1", "FIN_WAIT2", + "TIME_WAIT", "CLOSE", "CLOSE_WAIT", "LAST_ACK", "LISTEN", "CLOSING": + return true + } + return false +} diff --git a/internal/executor/netiface.go b/internal/executor/netiface.go new file mode 100644 index 0000000..4aacdeb --- /dev/null +++ b/internal/executor/netiface.go @@ -0,0 +1,50 @@ +package executor + +import ( + "context" + "fmt" + "net" +) + +// runNetIface implements `kopsc net iface`: show network interfaces and IPs. +func runNetIface(ctx context.Context, req BuiltinRequest) error { + ifaces, err := net.Interfaces() + if err != nil { + return fmt.Errorf("获取网卡列表失败: %w", err) + } + + for _, iface := range ifaces { + // Skip loopback if not requested + if iface.Flags&net.FlagLoopback != 0 { + continue + } + + // Status + status := "DOWN" + if iface.Flags&net.FlagUp != 0 { + status = "UP" + } + + fmt.Fprintf(req.Stdout, "%s: <%s> mtu %d\n", iface.Name, status, iface.MTU) + + // MAC address + if len(iface.HardwareAddr) > 0 { + fmt.Fprintf(req.Stdout, " MAC: %s\n", iface.HardwareAddr.String()) + } + + // IP addresses + addrs, err := iface.Addrs() + if err != nil { + continue + } + for _, addr := range addrs { + fmt.Fprintf(req.Stdout, " IP: %s\n", addr.String()) + } + + // Flags + fmt.Fprintf(req.Stdout, " 标志: %s\n", iface.Flags.String()) + fmt.Fprintln(req.Stdout) + } + + return nil +} diff --git a/internal/executor/netspeed.go b/internal/executor/netspeed.go new file mode 100644 index 0000000..6818fee --- /dev/null +++ b/internal/executor/netspeed.go @@ -0,0 +1,168 @@ +package executor + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// runNetSpeed implements `kopsc net speed`: simple network speed test. +func runNetSpeed(ctx context.Context, req BuiltinRequest) error { + opts, err := parseNetSpeedArgs(req.Args) + if err != nil { + return err + } + + fmt.Fprint(req.Stdout, "=== 网络速度测试 ===\n") + + // Download test + if !opts.uploadOnly { + fmt.Fprintln(req.Stdout, "测试下载速度...") + downloadSpeed, err := testDownload(ctx, opts.size) + if err != nil { + fmt.Fprintf(req.Stderr, "下载测试失败: %v\n", err) + } else { + fmt.Fprintf(req.Stdout, "下载速度: %s/s\n", humanSize(downloadSpeed)) + } + fmt.Fprintln(req.Stdout) + } + + // Upload test + if !opts.downloadOnly { + fmt.Fprintln(req.Stdout, "测试上传速度...") + uploadSpeed, err := testUpload(ctx, opts.size) + if err != nil { + fmt.Fprintf(req.Stderr, "上传测试失败: %v\n", err) + } else { + fmt.Fprintf(req.Stdout, "上传速度: %s/s\n", humanSize(uploadSpeed)) + } + } + + return nil +} + +type netSpeedOptions struct { + size int64 + downloadOnly bool + uploadOnly bool +} + +func parseNetSpeedArgs(args []string) (netSpeedOptions, error) { + opts := netSpeedOptions{size: 10 * 1024 * 1024} // 10MB default + + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--": + remaining := args[i+1:] + if len(remaining) > 0 { + n, err := parseSizeArg(remaining[0]) + if err != nil { + return opts, fmt.Errorf("大小参数无效: %w", err) + } + opts.size = n + } + i = len(args) + case arg == "--size" || arg == "-s": + if i+1 >= len(args) { + return opts, fmt.Errorf("%s 缺少大小参数", arg) + } + i++ + n, err := parseSizeArg(args[i]) + if err != nil { + return opts, fmt.Errorf("--size: %w", err) + } + opts.size = n + case strings.HasPrefix(arg, "--size="): + n, err := parseSizeArg(strings.TrimPrefix(arg, "--size=")) + if err != nil { + return opts, fmt.Errorf("--size: %w", err) + } + opts.size = n + case arg == "--download" || arg == "-d": + opts.downloadOnly = true + case arg == "--upload" || arg == "-u": + opts.uploadOnly = true + default: + return opts, fmt.Errorf("未知选项 %q", arg) + } + } + + if opts.downloadOnly && opts.uploadOnly { + return opts, fmt.Errorf("--download 和 --upload 不能同时使用") + } + + return opts, nil +} + +func testDownload(ctx context.Context, size int64) (int64, error) { + // Use Cloudflare's speed test endpoint + url := fmt.Sprintf("https://speed.cloudflare.com/__down?bytes=%d", size) + + client := &http.Client{Timeout: 30 * time.Second} + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return 0, err + } + + start := time.Now() + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + n, err := io.Copy(io.Discard, resp.Body) + if err != nil { + return 0, err + } + + elapsed := time.Since(start).Seconds() + if elapsed == 0 { + elapsed = 0.001 + } + return int64(float64(n) / elapsed), nil +} + +func testUpload(ctx context.Context, size int64) (int64, error) { + // Use httpbin.org for upload test + url := "https://httpbin.org/post" + + // Create a reader that generates data on the fly + data := make([]byte, size) + for i := range data { + data[i] = 'A' + } + + client := &http.Client{Timeout: 30 * time.Second} + req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(data))) + if err != nil { + return 0, err + } + req.Header.Set("Content-Type", "application/octet-stream") + + start := time.Now() + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + io.Copy(io.Discard, resp.Body) + + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + elapsed := time.Since(start).Seconds() + if elapsed == 0 { + elapsed = 0.001 + } + return int64(float64(size) / elapsed), nil +} diff --git a/internal/executor/proctree.go b/internal/executor/proctree.go new file mode 100644 index 0000000..d929673 --- /dev/null +++ b/internal/executor/proctree.go @@ -0,0 +1,51 @@ +package executor + +import ( + "context" + "fmt" + "os/exec" + "runtime" +) +// runProcTree implements `kopsc proc tree`: show process tree. +func runProcTree(ctx context.Context, req BuiltinRequest) error { + switch runtime.GOOS { + case "linux", "darwin": + return procTreeUnix(ctx, req) + case "windows": + return procTreeWindows(ctx, req) + default: + return fmt.Errorf("不支持的操作系统: %s", runtime.GOOS) + } +} + +func procTreeUnix(ctx context.Context, req BuiltinRequest) error { + // Try pstree first (best output) + if path, err := exec.LookPath("pstree"); err == nil { + cmd := exec.CommandContext(ctx, path, "-p") + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + return cmd.Run() + } + + // Fallback: use ps with PPID and format a simple tree + // macOS doesn't support -H, so we use a different approach + if runtime.GOOS == "darwin" { + cmd := exec.CommandContext(ctx, "ps", "-eo", "pid,ppid,comm") + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + return cmd.Run() + } + + // Linux fallback: ps with hierarchical output + cmd := exec.CommandContext(ctx, "ps", "-ejH") + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + return cmd.Run() +} + +func procTreeWindows(ctx context.Context, req BuiltinRequest) error { + cmd := exec.CommandContext(ctx, "tasklist", "/tree") + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + return cmd.Run() +} diff --git a/internal/executor/sysuptime.go b/internal/executor/sysuptime.go new file mode 100644 index 0000000..34c05fd --- /dev/null +++ b/internal/executor/sysuptime.go @@ -0,0 +1,133 @@ +package executor + +import ( + "context" + "fmt" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "time" +) + +// runSysUptime implements `kopsc sys uptime`: show system uptime. +func runSysUptime(ctx context.Context, req BuiltinRequest) error { + switch runtime.GOOS { + case "linux": + return sysUptimeLinux(req) + case "darwin": + return sysUptimeDarwin(ctx, req) + case "windows": + return sysUptimeWindows(ctx, req) + default: + return fmt.Errorf("不支持的操作系统: %s", runtime.GOOS) + } +} + +func sysUptimeLinux(req BuiltinRequest) error { + data, err := os.ReadFile("/proc/uptime") + if err != nil { + return fmt.Errorf("读取 /proc/uptime 失败: %w", err) + } + fields := strings.Fields(string(data)) + if len(fields) < 1 { + return fmt.Errorf("/proc/uptime 格式异常") + } + seconds, err := strconv.ParseFloat(fields[0], 64) + if err != nil { + return fmt.Errorf("解析 uptime 失败: %w", err) + } + + bootTime := time.Now().Add(-time.Duration(seconds) * time.Second) + fmt.Fprintf(req.Stdout, "运行时间: %s\n", formatDuration(time.Duration(seconds)*time.Second)) + fmt.Fprintf(req.Stdout, "启动时间: %s\n", bootTime.Format("2006-01-02 15:04:05")) + + // Load average + if len(fields) >= 2 { + idle, _ := strconv.ParseFloat(fields[1], 64) + fmt.Fprintf(req.Stdout, "空闲时间: %s\n", formatDuration(time.Duration(idle)*time.Second)) + } + + // Load average from /proc/loadavg + if loadavg, err := os.ReadFile("/proc/loadavg"); err == nil { + parts := strings.Fields(string(loadavg)) + if len(parts) >= 3 { + fmt.Fprintf(req.Stdout, "负载均值: %s %s %s (1/5/15分钟)\n", parts[0], parts[1], parts[2]) + } + } + return nil +} + +func sysUptimeDarwin(ctx context.Context, req BuiltinRequest) error { + out, err := exec.CommandContext(ctx, "sysctl", "-n", "kern.boottime").Output() + if err != nil { + return fmt.Errorf("获取启动时间失败: %w", err) + } + // Output: { sec = 1234567890, usec = 123456 } Mon Jan 01 00:00:00 2024 + line := string(out) + secStart := strings.Index(line, "sec = ") + if secStart < 0 { + return fmt.Errorf("无法解析 kern.boottime: %s", line) + } + secStr := strings.TrimSpace(line[secStart+6:]) + if idx := strings.Index(secStr, ","); idx >= 0 { + secStr = secStr[:idx] + } + sec, err := strconv.ParseInt(secStr, 10, 64) + if err != nil { + return fmt.Errorf("解析启动时间失败: %w", err) + } + + bootTime := time.Unix(sec, 0) + uptime := time.Since(bootTime) + fmt.Fprintf(req.Stdout, "运行时间: %s\n", formatDuration(uptime)) + fmt.Fprintf(req.Stdout, "启动时间: %s\n", bootTime.Format("2006-01-02 15:04:05")) + + // Load average + if out, err := exec.CommandContext(ctx, "sysctl", "-n", "vm.loadavg").Output(); err == nil { + fmt.Fprintf(req.Stdout, "负载均值: %s\n", strings.TrimSpace(string(out))) + } + return nil +} + +func sysUptimeWindows(ctx context.Context, req BuiltinRequest) error { + // Use wmic to get boot time + out, err := exec.CommandContext(ctx, "wmic", "os", "get", "LastBootUpTime", "/value").Output() + if err != nil { + return fmt.Errorf("获取启动时间失败: %w", err) + } + line := strings.TrimSpace(string(out)) + // LastBootUpTime=20240101000000.000000+480 + if !strings.HasPrefix(line, "LastBootUpTime=") { + return fmt.Errorf("无法解析 wmic 输出: %s", line) + } + timeStr := strings.TrimPrefix(line, "LastBootUpTime=") + // Parse: 20240101000000.000000+480 + if len(timeStr) < 14 { + return fmt.Errorf("时间格式异常: %s", timeStr) + } + bootTime, err := time.Parse("20060102150405", timeStr[:14]) + if err != nil { + return fmt.Errorf("解析时间失败: %w", err) + } + uptime := time.Since(bootTime) + fmt.Fprintf(req.Stdout, "运行时间: %s\n", formatDuration(uptime)) + fmt.Fprintf(req.Stdout, "启动时间: %s\n", bootTime.Format("2006-01-02 15:04:05")) + return nil +} + +// formatDuration formats a duration into a human-readable string. +func formatDuration(d time.Duration) string { + days := int(d.Hours()) / 24 + hours := int(d.Hours()) % 24 + minutes := int(d.Minutes()) % 60 + + if days > 0 { + return fmt.Sprintf("%d 天 %d 小时 %d 分钟", days, hours, minutes) + } + if hours > 0 { + return fmt.Sprintf("%d 小时 %d 分钟", hours, minutes) + } + return fmt.Sprintf("%d 分钟", minutes) +} diff --git a/internal/executor/userinfo.go b/internal/executor/userinfo.go new file mode 100644 index 0000000..c111164 --- /dev/null +++ b/internal/executor/userinfo.go @@ -0,0 +1,78 @@ +package executor + +import ( + "context" + "fmt" + "os" + "os/exec" + "os/user" + "runtime" + "strings" +) + +// runUserInfo implements `kopsc user info`: show user details. +func runUserInfo(ctx context.Context, req BuiltinRequest) error { + var username string + if len(req.Args) == 0 { + // Current user + u, err := user.Current() + if err != nil { + return fmt.Errorf("获取当前用户失败: %w", err) + } + username = u.Username + } else { + username = req.Args[0] + } + + switch runtime.GOOS { + case "linux", "darwin": + return userInfoUnix(ctx, username, req) + case "windows": + return userInfoWindows(ctx, username, req) + default: + return fmt.Errorf("不支持的操作系统: %s", runtime.GOOS) + } +} + +func userInfoUnix(ctx context.Context, username string, req BuiltinRequest) error { + // Use id command for comprehensive info + cmd := exec.CommandContext(ctx, "id", username) + out, err := cmd.Output() + if err != nil { + return fmt.Errorf("用户 %q 不存在或 id 命令失败: %w", username, err) + } + fmt.Fprintf(req.Stdout, "用户: %s\n", strings.TrimSpace(string(out))) + + // Get home directory and shell from /etc/passwd + if data, err := os.ReadFile("/etc/passwd"); err == nil { + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Split(line, ":") + if len(fields) >= 7 && fields[0] == username { + fmt.Fprintf(req.Stdout, "UID: %s\n", fields[2]) + fmt.Fprintf(req.Stdout, "GID: %s\n", fields[3]) + fmt.Fprintf(req.Stdout, "描述: %s\n", fields[4]) + fmt.Fprintf(req.Stdout, "家目录: %s\n", fields[5]) + fmt.Fprintf(req.Stdout, "Shell: %s\n", fields[6]) + break + } + } + } + + // Groups + groupsCmd := exec.CommandContext(ctx, "groups", username) + if groupsOut, err := groupsCmd.Output(); err == nil { + fmt.Fprintf(req.Stdout, "组: %s\n", strings.TrimSpace(string(groupsOut))) + } + + return nil +} + +func userInfoWindows(ctx context.Context, username string, req BuiltinRequest) error { + cmd := exec.CommandContext(ctx, "net", "user", username) + out, err := cmd.Output() + if err != nil { + return fmt.Errorf("用户 %q 不存在或 net user 失败: %w", username, err) + } + fmt.Fprintln(req.Stdout, string(out)) + return nil +} diff --git a/internal/registry/builtin.go b/internal/registry/builtin.go index b09b9db..a152d1c 100644 --- a/internal/registry/builtin.go +++ b/internal/registry/builtin.go @@ -836,6 +836,77 @@ func Builtin() *Registry { RequiresYes: true, Variants: threePlatformBuiltins("fw.deny"), }, + + // ----- sys additions ----- + { + ID: "sys.uptime", + Group: "sys", + Summary: "查看系统运行时间和启动时间", + Action: "显示系统已运行多长时间、启动时间、负载均值。" + + "Linux: /proc/uptime + /proc/loadavg;macOS: sysctl kern.boottime;" + + "Windows: wmic os get LastBootUpTime", + Variants: threePlatformBuiltins("sys.uptime"), + }, + + // ----- net additions ----- + { + ID: "net.iface", + Group: "net", + Summary: "查看网卡信息和 IP 地址", + Action: "列出所有非回环网卡的信息:名称、状态、MAC 地址、IPv4/IPv6 地址、MTU。" + + "纯 Go 实现,跨平台一致", + Variants: threePlatformBuiltins("net.iface"), + }, + { + ID: "net.conn", + Group: "net", + Summary: "查看 TCP 连接状态统计", + Action: "统计各状态的 TCP 连接数量(ESTABLISHED、TIME_WAIT、CLOSE_WAIT 等)。" + + "Linux: ss -s 或 netstat;macOS: netstat;Windows: netstat", + Variants: threePlatformBuiltins("net.conn"), + }, + { + ID: "net.speed", + Group: "net", + Summary: "简单网速测试(下载/上传)", + Action: "从 Cloudflare 下载测试文件测量下载速度,向 httpbin.org 上传数据测量上传速度。" + + "默认测试 10MB,--size 可指定大小,--download 或 --upload 可单独测试", + Usage: "[--size 大小] [--download] [--upload]", + Variants: threePlatformBuiltins("net.speed"), + }, + + // ----- proc additions ----- + { + ID: "proc.tree", + Group: "proc", + Summary: "查看进程树(父子关系)", + Action: "以树形结构显示进程的父子关系。" + + "Linux/macOS: pstree -p 或 ps -ejH;Windows: tasklist /tree", + Variants: threePlatformBuiltins("proc.tree"), + }, + + // ----- file additions ----- + { + ID: "file.empty", + Group: "file", + Summary: "查找空文件和空目录", + Action: "递归扫描目录,找出大小为 0 的文件和没有任何内容的目录。" + + "--files-only 只查找空文件,--dirs-only 只查找空目录", + Usage: "[路径] [--files-only] [--dirs-only]", + Variants: threePlatformBuiltins("file.empty"), + }, + + // ----- user additions ----- + { + ID: "user.info", + Group: "user", + Summary: "查看用户详细信息", + Action: "显示用户的 UID、GID、组、家目录、Shell 等信息。" + + "不指定用户名时显示当前用户。" + + "Linux/macOS: id + /etc/passwd + groups;Windows: net user", + Usage: "[用户名]", + Variants: threePlatformBuiltins("user.info"), + }, }} } diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index b637438..bfd9972 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -35,6 +35,11 @@ func TestBuiltinIDsStable(t *testing.T) { "net.ping", "net.trace", "net.dns", "net.curl", "log.search", "log.last", "fw.status", "fw.allow", "fw.deny", + "sys.uptime", + "net.iface", "net.conn", "net.speed", + "proc.tree", + "file.empty", + "user.info", } if got := reg.IDs(); !reflect.DeepEqual(got, want) { t.Fatalf("IDs() =\n%v\nwant\n%v", got, want)