diff --git a/README.archive.md b/README.archive.md new file mode 100644 index 0000000..79a6ad3 --- /dev/null +++ b/README.archive.md @@ -0,0 +1,41 @@ +# archive - 压缩文件 + +自动识别压缩格式并解压,不用记 `tar -xzf` / `tar -xjf` / `unzip` 的区别。 + +## 命令 + +| 命令 | 作用 | +| --- | --- | +| `kopsc archive extract <文件> [--dir 目录]` | 自动识别格式并解压 | + +别名:`kopsc archive unzip`、`kopsc archive untar` + +## 支持的格式 + +| 扩展名 | 解压方式 | +| --- | --- | +| `.tar.gz` / `.tgz` | Go 内置实现 | +| `.tar.bz2` / `.tbz2` | 调用 `bzip2 -dc` + tar 解析 | +| `.tar.xz` / `.txz` | 调用 `xz -dc` + tar 解析 | +| `.tar` | Go 内置实现 | +| `.zip` / `.jar` / `.war` | Go 内置实现 | +| `.gz` | Go 内置 gzip 解压 | + +## 示例 + +```bash +# 解压到当前目录 +kopsc archive extract app.tar.gz + +# 解压到指定目录 +kopsc archive extract backup.zip --dir /opt/backup + +# 解压 tar.bz2 +kopsc archive extract data.tar.bz2 +``` + +## 选项 + +| 选项 | 说明 | +| --- | --- | +| `--dir` / `-d` / `-C` | 目标目录(默认当前目录) | diff --git a/README.file.md b/README.file.md new file mode 100644 index 0000000..f1432dd --- /dev/null +++ b/README.file.md @@ -0,0 +1,53 @@ +# file - 文件查找 + +按大小、修改时间、名称查找文件,不用记 `find` 的复杂语法。 + +## 命令 + +| 命令 | 作用 | +| --- | --- | +| `kopsc file findbig [路径] [--threshold 大小]` | 查找大于指定阈值的文件,按大小排序 | +| `kopsc file findrecent [路径] [--days 天数]` | 查找最近 N 天内修改过的文件 | +| `kopsc file findname <模式> [路径]` | 按文件名查找(忽略大小写,支持 `*` `?` 通配符) | + +## 示例 + +```bash +# 找当前目录下大于 100M 的文件(默认阈值) +kopsc file findbig + +# 找 /var 下大于 500M 的文件 +kopsc file findbig /var --threshold 500M + +# 找最近 3 天修改过的文件 +kopsc file findrecent --days 3 + +# 找 /var/log 下所有 .log 文件 +kopsc file findname "*.log" /var/log + +# 找所有 .conf 文件(忽略大小写) +kopsc file findname "*.CONF" +``` + +## 选项 + +### findbig + +| 选项 | 默认值 | 说明 | +| --- | --- | --- | +| `--threshold` / `-s` | `100M` | 大小阈值,支持 K/M/G/T 后缀 | +| 位置参数 | `.` | 搜索路径 | + +### findrecent + +| 选项 | 默认值 | 说明 | +| --- | --- | --- | +| `--days` / `-d` | `7` | 天数 | +| 位置参数 | `.` | 搜索路径 | + +### findname + +| 选项 | 说明 | +| --- | --- | +| 第一个位置参数 | 文件名模式(必填) | +| 第二个位置参数 | 搜索路径(默认 `.`) | diff --git a/README.fw.md b/README.fw.md new file mode 100644 index 0000000..e82a20f --- /dev/null +++ b/README.fw.md @@ -0,0 +1,45 @@ +# fw - 防火墙 + +统一管理 ufw / firewalld / iptables / Windows Firewall,不用记不同工具的命令。 + +## 命令 + +| 命令 | 作用 | +| --- | --- | +| `kopsc fw status` | 查看防火墙状态与规则 | +| `kopsc fw allow <端口>[/<协议>]` | 开放端口 | +| `kopsc fw deny <端口>[/<协议>]` | 关闭端口 | + +## 示例 + +```bash +# 查看防火墙状态 +kopsc fw status + +# 开放 8080/tcp(默认协议) +kopsc fw allow 8080 --yes + +# 开放 53/udp +kopsc fw allow 53/udp --yes + +# 关闭 8080/tcp +kopsc fw deny 8080 --yes + +# 也可以写成 8080/tcp 形式 +kopsc fw allow 8080/tcp --yes +``` + +## 选项 + +| 选项 | 说明 | +| --- | --- | +| `-p` / `--protocol` | 协议(`tcp` 或 `udp`,默认 `tcp`) | +| 端口格式 | `8080` 或 `8080/tcp` 或 `8080/udp` | + +## 平台实现 + +| 操作 | Linux | macOS | Windows | +| --- | --- | --- | --- | +| status | ufw / firewalld / iptables(自动检测) | ALF(应用防火墙) | `netsh advfirewall` | +| allow | `ufw allow` / `firewall-cmd --add-port` | 不支持 | `netsh advfirewall add rule` | +| deny | `ufw deny` / `firewall-cmd --remove-port` | 不支持 | `netsh advfirewall delete rule` | diff --git a/README.log.md b/README.log.md new file mode 100644 index 0000000..2b44dd6 --- /dev/null +++ b/README.log.md @@ -0,0 +1,53 @@ +# log - 系统日志 + +在系统日志中搜索关键字或查看最近日志,不用记 `journalctl` / `log show` / `wevtutil` 的区别。 + +## 命令 + +| 命令 | 作用 | +| --- | --- | +| `kopsc log search <关键字> [选项]` | 在系统日志中搜索关键字 | +| `kopsc log last [行数] [-u 服务]` | 查看最近的系统日志 | + +## 示例 + +```bash +# 搜索最近 1 小时包含 "error" 的日志 +kopsc log search "error" --since 1h + +# 搜索 nginx 服务的日志 +kopsc log search "502" -u nginx + +# 查看最近 50 行系统日志(默认) +kopsc log last + +# 查看最近 100 行 +kopsc log last 100 + +# 查看 nginx 服务的最近日志 +kopsc log last -u nginx +``` + +## 选项 + +### log search + +| 选项 | 说明 | +| --- | --- | +| `--since` | 时间范围,如 `1h`、`30m`、`2h30m` | +| `-u` / `--unit` | 限定服务名 | +| `-n` / `--lines` | 最大行数(默认 100) | + +### log last + +| 选项 | 说明 | +| --- | --- | +| `-n` / `--lines` | 行数(默认 50) | +| `-u` / `--unit` | 限定服务名 | + +## 平台实现 + +| 操作 | Linux | macOS | Windows | +| --- | --- | --- | --- | +| search | `journalctl --grep` | `log show --predicate` | `wevtutil` + 过滤 | +| last | `journalctl -n` | `log show --last` | `wevtutil /c:` | diff --git a/README.md b/README.md index 87b88ef..40a8089 100644 --- a/README.md +++ b/README.md @@ -143,14 +143,20 @@ kopsc 的命令按用途分组,每组的完整说明(命令表、选项、 | 分组 | 用途 | 文档 | | --- | --- | --- | | **docker** | 容器与镜像:查看、启停、日志、清理、导出/导入、Compose | [README.docker.md](README.docker.md) | -| **net** | 网络与端口:端口被谁占用、本机监听了哪些端口 | [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) | +| **svc** | 服务管理:查看状态、启用/禁用、查看日志 | [README.svc.md](README.svc.md) | +| **archive** | 压缩文件:自动识别格式并解压 | [README.archive.md](README.archive.md) | +| **sys** | 系统信息:OS、CPU、内存、磁盘一览 | [README.sys.md](README.sys.md) | +| **log** | 系统日志:搜索关键字、查看最近日志 | [README.log.md](README.log.md) | +| **fw** | 防火墙:查看状态、开放/关闭端口 | [README.fw.md](README.fw.md) | 辅助命令(不涉及具体资源,直接用即可): @@ -175,11 +181,29 @@ kopsc 的命令按用途分组,每组的完整说明(命令表、选项、 | 排障 | `kopsc diagnose web` | | 后台跑服务 | `kopsc proc nohup ./server` | | 等进程退出 | `kopsc proc wait nginx`、`kopsc proc wait --pid 1234 --timeout 30` | +| 看进程资源排行 | `kopsc proc topmem`、`kopsc proc topcpu` | +| 按名字杀进程 | `kopsc proc killname nginx --yes` | | 跑 Java 应用 | `kopsc java run app.jar`、`kopsc java run --jdk 17 --print-flags app.jar` | | 调整提交时间 | `kopsc git retime 2026-10-01 2026-10-15`(加 `--yes` 才执行) | | 刷新软件源 | `kopsc pkg refreshindex` | | 授予用户 root 权限 | `kopsc user grant alice --yes` | | 移除用户 root 权限 | `kopsc user revoke alice --yes` | +| 找大文件 | `kopsc file findbig /var --threshold 500M` | +| 找最近修改的文件 | `kopsc file findrecent /home --days 3` | +| 按名字找文件 | `kopsc file findname "*.log" /var` | +| 看服务状态 | `kopsc svc status nginx` | +| 启用/禁用服务 | `kopsc svc enable nginx --yes` / `kopsc svc disable nginx --yes` | +| 看服务日志 | `kopsc svc logs nginx -f` | +| 解压文件 | `kopsc archive extract app.tar.gz` | +| 看系统信息 | `kopsc sysinfo` | +| 测试连通性 | `kopsc net ping example.com` | +| 路由追踪 | `kopsc net trace example.com` | +| DNS 查询 | `kopsc net dns example.com -t MX` | +| HTTP 诊断 | `kopsc net curl https://example.com -L` | +| 搜索系统日志 | `kopsc log search "error" --since 1h` | +| 看最近日志 | `kopsc log last 100` | +| 看防火墙状态 | `kopsc fw status` | +| 开放/关闭端口 | `kopsc fw allow 8080/tcp --yes` / `kopsc fw deny 8080/tcp --yes` | ## 平台识别 @@ -243,6 +267,12 @@ kopsc 的命令按用途分组,每组的完整说明(命令表、选项、 | java | [`README.java.md`](README.java.md) | | git | [`README.git.md`](README.git.md) | | user | [`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 | [`README.sys.md`](README.sys.md) | +| log | [`README.log.md`](README.log.md) | +| fw | [`README.fw.md`](README.fw.md) | 新分组需要新建 `README.<分组>.md`,并在本文件「命令一览」的表格里加一行链接。 每条命令至少要写清:**作用**、**实际执行什么**、**示例**;有独有选项时补选项表。 diff --git a/README.net.md b/README.net.md index 6bc9653..060f824 100644 --- a/README.net.md +++ b/README.net.md @@ -2,11 +2,15 @@ > 返回 [README.md](README.md) -查看端口被谁占用、本机监听了哪些端口。两个命令都只读,不需要 `--yes`。 +查看端口被谁占用、本机监听了哪些端口、测试连通性、DNS 查询、路由追踪、HTTP 诊断。 ```bash -kopsc net pids 8080 # 谁占用了 8080 端口(输出 PID) -kopsc net ports # 本机正在监听哪些端口(统一表格) +kopsc net pids 8080 # 谁占用了 8080 端口(输出 PID) +kopsc net ports # 本机正在监听哪些端口(统一表格) +kopsc net ping example.com # 测试连通性 +kopsc net trace example.com # 路由追踪 +kopsc net dns example.com # DNS 查询 +kopsc net curl https://example.com # HTTP 诊断 ``` ## 端口占用查询(net.pids) @@ -54,3 +58,68 @@ $ kopsc net ports - 通配地址 `0.0.0.0` 与 `::` 统一显示为 `*`。 - `--raw` 保留平台原生命令的原始输出,供依赖原生格式的脚本使用。 - 没有监听端口时 stdout 为空,stderr 提示「没有正在监听的端口」。 + +## 连通性测试(net.ping) + +```bash +kopsc net ping example.com # 默认 4 个包 +kopsc net ping example.com -c 10 # 发送 10 个包 +``` + +底层调用系统 `ping` 命令。 + +## 路由追踪(net.trace) + +```bash +kopsc net trace example.com +``` + +优先使用 `mtr --report`(最佳输出),回退到 `traceroute`(Linux/macOS)或 `tracert`(Windows)。 + +## DNS 查询(net.dns) + +```bash +kopsc net dns example.com # 默认 A 记录 +kopsc net dns example.com -t MX # 查询 MX 记录 +kopsc net dns example.com -t AAAA # 查询 AAAA 记录 +kopsc net dns example.com -s 8.8.8.8 # 指定 DNS 服务器 +``` + +优先使用 `dig`,回退到 `nslookup` 或 `host`。 + +### 选项 + +| 选项 | 说明 | +| --- | --- | +| `-t` / `--type` | 记录类型(A/AAAA/MX/CNAME 等) | +| `-s` / `--server` | DNS 服务器地址 | + +## HTTP 诊断(net.curl) + +```bash +kopsc net curl https://example.com # 基本诊断 +kopsc net curl https://example.com -L # 跟随重定向 +kopsc net curl https://api.example.com -X POST -H "Content-Type: application/json" +``` + +输出示例: + +``` +HTTP 状态码: 200 +重定向次数: 0 +DNS 解析: 0.005s +TCP 连接: 0.032s +TLS 握手: 0.089s +首字节: 0.145s +总耗时: 0.146s +下载大小: 1234 bytes +下载速度: 8452.05 bytes/s +``` + +### 选项 + +| 选项 | 说明 | +| --- | --- | +| `-L` / `--follow` | 跟随重定向 | +| `-X` / `--method` | HTTP 方法(GET/POST/PUT 等) | +| `-H` / `--header` | 添加请求头,可重复 | diff --git a/README.proc.md b/README.proc.md index 03453b2..754267e 100644 --- a/README.proc.md +++ b/README.proc.md @@ -2,11 +2,15 @@ > 返回 [README.md](README.md) -在后台运行命令并写日志,关掉终端也不会退出;也可以等某个进程退出后再继续。 +在后台运行命令并写日志,关掉终端也不会退出;也可以等某个进程退出后再继续; +还能按资源占用排序查看进程、按名称终止进程。 ```bash kopsc proc nohup ./server # 后台运行命令,输出写日志 kopsc proc wait nginx # 等 nginx 退出后返回 +kopsc proc topmem # 内存占用前 10 的进程 +kopsc proc topcpu # CPU 占用前 10 的进程 +kopsc proc killname nginx --yes # 终止名为 nginx 的进程 ``` ## 后台运行(proc.nohup) @@ -86,3 +90,41 @@ kopsc proc wait nginx --interval 500ms -q # 每 0.5 秒查一次,不打印 > 关键字请直接作为参数书写;若关键字以 `-` 开头,用 `--` 分隔,例如 `kopsc proc wait -- -weird`。 > `--dry-run` 只打印判定依据,不会去查询进程。 + +## 按资源排序查看进程(proc.topmem / proc.topcpu) + +```bash +kopsc proc topmem # 内存占用前 10 +kopsc proc topcpu # CPU 占用前 10 +kopsc proc topmem 20 # 内存占用前 20 +kopsc proc topcpu -n 5 # CPU 占用前 5 +``` + +输出示例: + +``` +=== 占用 CPU 前 10 的进程 === + +PID CPU 进程 命令 +1234 45.2% java java -jar app.jar --spring.profiles.active=prod +5678 12.3% nginx nginx: worker process +``` + +### 选项 + +| 选项 | 说明 | +| --- | --- | +| `-n` / `--count` / 位置参数 | 显示数量(默认 10) | + +> Windows 下 `proc topcpu` 不可用(tasklist 不报告 CPU 使用率)。 + +## 按进程名终止进程(proc.killname) + +```bash +kopsc proc killname nginx # 列出匹配进程(不执行) +kopsc proc killname nginx --yes # 确认终止 +``` + +匹配规则:进程名或命令行中包含指定关键字(忽略大小写)。 + +> 这是破坏性命令,未加 `--yes` 时只列出匹配进程并以退出码 2 结束。 diff --git a/README.svc.md b/README.svc.md new file mode 100644 index 0000000..f28db52 --- /dev/null +++ b/README.svc.md @@ -0,0 +1,40 @@ +# svc - 服务管理 + +统一管理 systemd / launchctl / sc 的服务操作,不用记不同平台的命令。 + +## 命令 + +| 命令 | 作用 | +| --- | --- | +| `kopsc svc status <服务名>` | 查看服务状态与最近 20 行日志 | +| `kopsc svc enable <服务名>` | 启用并启动服务(开机自启 + 立即启动) | +| `kopsc svc disable <服务名>` | 禁用并停止服务 | +| `kopsc svc logs <服务名> [-f] [-n 行数]` | 查看服务日志,`-f` 实时跟踪 | + +## 示例 + +```bash +# 查看 nginx 状态和最近日志 +kopsc svc status nginx + +# 启用并启动 nginx +kopsc svc enable nginx --yes + +# 禁用并停止 nginx +kopsc svc disable nginx --yes + +# 查看 nginx 最近 100 行日志 +kopsc svc logs nginx -n 100 + +# 实时跟踪 nginx 日志 +kopsc svc logs nginx -f +``` + +## 平台实现 + +| 操作 | Linux | macOS | Windows | +| --- | --- | --- | --- | +| status | `systemctl status` + `journalctl` | `launchctl list` + `log show` | `sc query` | +| enable | `systemctl enable` + `start` | `launchctl load -w` | `sc config start=auto` + `start` | +| disable | `systemctl stop` + `disable` | `launchctl unload -w` | `sc stop` + `config start=disabled` | +| logs | `journalctl -u` | `log show` | `wevtutil` | diff --git a/README.sys.md b/README.sys.md new file mode 100644 index 0000000..47a9c0c --- /dev/null +++ b/README.sys.md @@ -0,0 +1,51 @@ +# sys - 系统信息 + +一次性查看 OS、CPU、内存、磁盘概况,不用分别跑 `uname`、`lscpu`、`free`、`df`。 + +## 命令 + +| 命令 | 作用 | +| --- | --- | +| `kopsc sysinfo` | 显示系统信息一览 | + +## 示例 + +```bash +kopsc sysinfo +``` + +输出示例: + +``` +=== 系统信息 === + +操作系统: linux/amd64 +发行版: centos +主机名: web-01 +内核: 5.15.0-91-generic + +=== CPU === + +逻辑核心: 4 +型号: Intel(R) Xeon(R) CPU E5-2680 v4 @ 2.40GHz + +=== 内存 === + +总计: 16.0G +检测方式: /proc/meminfo +可用: 8.2G + +=== 磁盘 === + +Filesystem Size Used Avail Use% Mounted on +/dev/vda1 50G 22G 28G 44% / +``` + +## 平台实现 + +| 信息 | Linux | macOS | Windows | +| --- | --- | --- | --- | +| OS | `/etc/os-release` | runtime.GOOS | runtime.GOOS | +| CPU | `/proc/cpuinfo` | `sysctl` | runtime | +| 内存 | `/proc/meminfo` + cgroup | `sysctl hw.memsize` | `wmic` | +| 磁盘 | `df -h` | `df -h` | `wmic logicaldisk` | diff --git a/internal/cli/cli.go b/internal/cli/cli.go index d080bd7..0bd9e90 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -216,14 +216,20 @@ func translateHelp(text string) string { // groupDescriptions explains what each command group is for. Without them // cobra would show "disk 相关命令", which tells a user nothing. var groupDescriptions = map[string]string{ - "docker": "Docker 容器与镜像管理(查看、启停、日志、清理、导出/导入、Compose)", - "disk": "磁盘与文件占用:查看哪个目录、哪个文件最占空间", - "net": "网络与端口:端口被谁占用、本机监听了哪些端口", - "pkg": "系统软件包:刷新包管理器(dnf/apt/apk/brew/winget)的软件源索引", - "proc": "本机进程:在后台运行命令并写日志,关掉终端也不退出", - "java": "Java 应用:按 JDK 版本自动套用调优参数并前台运行 jar", - "git": "Git 历史:把指定日期范围的提交时间改到 21:00-23:00 并强推", - "user": "用户管理:授予或移除用户的 root/sudo 权限", + "docker": "Docker 容器与镜像管理(查看、启停、日志、清理、导出/导入、Compose)", + "disk": "磁盘与文件占用:查看哪个目录、哪个文件最占空间", + "net": "网络与端口:端口被谁占用、本机监听了哪些端口、连通性、DNS、路由追踪、HTTP 诊断", + "pkg": "系统软件包:刷新包管理器(dnf/apt/apk/brew/winget)的软件源索引", + "proc": "本机进程:后台运行命令并写日志、等待进程退出、按资源排序、按名称终止", + "java": "Java 应用:按 JDK 版本自动套用调优参数并前台运行 jar", + "git": "Git 历史:把指定日期范围的提交时间改到 21:00-23:00 并强推", + "user": "用户管理:授予或移除用户的 root/sudo 权限", + "file": "文件查找:按大小、修改时间、名称查找文件", + "svc": "服务管理:查看状态、启用/禁用、查看日志(systemctl/launchctl/sc 统一接口)", + "archive": "压缩文件:自动识别格式并解压", + "sys": "系统信息:OS、CPU、内存、磁盘一览", + "log": "系统日志:搜索关键字、查看最近日志", + "fw": "防火墙:查看状态、开放/关闭端口(ufw/firewalld/iptables 统一接口)", } // newGroup builds an intermediate command such as `kopsc disk`. diff --git a/internal/executor/archiveextract.go b/internal/executor/archiveextract.go new file mode 100644 index 0000000..ad8090a --- /dev/null +++ b/internal/executor/archiveextract.go @@ -0,0 +1,272 @@ +package executor + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// runArchiveExtract implements `kopsc archive extract `: auto-detect +// archive format and extract to current directory (or --dir). +func runArchiveExtract(ctx context.Context, req BuiltinRequest) error { + opts, err := parseArchiveExtractArgs(req.Args) + if err != nil { + return err + } + + src := opts.file + if _, err := os.Stat(src); err != nil { + return fmt.Errorf("文件 %q 不存在: %w", src, err) + } + + dest := opts.dest + if dest == "" { + dest = "." + } + if err := os.MkdirAll(dest, 0o755); err != nil { + return fmt.Errorf("创建目标目录 %q 失败: %w", dest, err) + } + + lower := strings.ToLower(src) + switch { + case strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz"): + return extractTarGz(src, dest, req) + case strings.HasSuffix(lower, ".tar.bz2") || strings.HasSuffix(lower, ".tbz2"): + return extractTarViaPipe(ctx, "bzip2", "-dc", src, dest, req) + case strings.HasSuffix(lower, ".tar.xz") || strings.HasSuffix(lower, ".txz"): + return extractTarViaPipe(ctx, "xz", "-dc", src, dest, req) + case strings.HasSuffix(lower, ".tar"): + return extractTarFile(src, dest, req) + case strings.HasSuffix(lower, ".zip") || strings.HasSuffix(lower, ".jar") || strings.HasSuffix(lower, ".war"): + return extractZip(src, dest, req) + case strings.HasSuffix(lower, ".gz"): + return extractGz(src, dest, req) + default: + return fmt.Errorf("无法识别的压缩格式: %q(支持 .tar.gz .tgz .tar.bz2 .tar.xz .tar .zip .gz)", src) + } +} + +type archiveExtractOptions struct { + file string + dest string +} + +func parseArchiveExtractArgs(args []string) (archiveExtractOptions, error) { + var opts archiveExtractOptions + var positional []string + + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--": + positional = append(positional, args[i+1:]...) + i = len(args) + case arg == "--dir" || arg == "-d" || arg == "-C": + if i+1 >= len(args) { + return opts, fmt.Errorf("%s 缺少目标目录", arg) + } + i++ + opts.dest = args[i] + case strings.HasPrefix(arg, "--dir="): + opts.dest = strings.TrimPrefix(arg, "--dir=") + case strings.HasPrefix(arg, "-") && arg != "-": + return opts, fmt.Errorf("未知选项 %q", arg) + default: + positional = append(positional, arg) + } + } + if len(positional) == 0 { + return opts, fmt.Errorf("缺少压缩文件路径;用法: kopsc archive extract <文件>") + } + opts.file = positional[0] + return opts, nil +} + +func extractTarGz(src, dest string, req BuiltinRequest) error { + f, err := os.Open(src) + if err != nil { + return err + } + defer f.Close() + gz, err := gzip.NewReader(f) + if err != nil { + return fmt.Errorf("打开 gzip 失败: %w", err) + } + defer gz.Close() + fmt.Fprintf(req.Stdout, "解压 tar.gz: %s -> %s\n", src, dest) + return writeTar(tar.NewReader(gz), dest, req) +} + +func extractTarFile(src, dest string, req BuiltinRequest) error { + f, err := os.Open(src) + if err != nil { + return err + } + defer f.Close() + fmt.Fprintf(req.Stdout, "解压 tar: %s -> %s\n", src, dest) + return writeTar(tar.NewReader(f), dest, req) +} + +func extractTarViaPipe(ctx context.Context, decompressor, flag, src, dest string, req BuiltinRequest) error { + if _, err := exec.LookPath(decompressor); err != nil { + return fmt.Errorf("解压 %s 需要 %s,请先安装", filepath.Ext(src), decompressor) + } + fmt.Fprintf(req.Stdout, "解压 %s: %s -> %s\n", decompressor, src, dest) + cmd := exec.CommandContext(ctx, decompressor, flag, src) + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return err + } + cmd.Stderr = req.Stderr + if err := cmd.Start(); err != nil { + return fmt.Errorf("启动 %s 失败: %w", decompressor, err) + } + err = writeTar(tar.NewReader(stdoutPipe), dest, req) + if waitErr := cmd.Wait(); waitErr != nil && err == nil { + err = waitErr + } + return err +} + +func writeTar(tr *tar.Reader, dest string, req BuiltinRequest) error { + count := 0 + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("读取 tar 失败: %w", err) + } + target := filepath.Join(dest, header.Name) + if !isWithinDir(target, dest) { + return fmt.Errorf("tar 条目 %q 试图逃逸出目标目录", header.Name) + } + switch header.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, os.FileMode(header.Mode)); err != nil { + return err + } + case tar.TypeReg: + if dir := filepath.Dir(target); dir != "" { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + } + f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode)) + if err != nil { + return err + } + if _, err := io.Copy(f, tr); err != nil { + f.Close() + return err + } + f.Close() + count++ + case tar.TypeSymlink: + if dir := filepath.Dir(target); dir != "" { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + } + os.Remove(target) + if err := os.Symlink(header.Linkname, target); err != nil { + return err + } + } + } + fmt.Fprintf(req.Stdout, "已解压 %d 个文件\n", count) + return nil +} + +func extractZip(src, dest string, req BuiltinRequest) error { + fmt.Fprintf(req.Stdout, "解压 zip: %s -> %s\n", src, dest) + r, err := zip.OpenReader(src) + if err != nil { + return fmt.Errorf("打开 zip 失败: %w", err) + } + defer r.Close() + + count := 0 + for _, f := range r.File { + target := filepath.Join(dest, f.Name) + if !isWithinDir(target, dest) { + return fmt.Errorf("zip 条目 %q 试图逃逸出目标目录", f.Name) + } + if f.FileInfo().IsDir() { + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + continue + } + if dir := filepath.Dir(target); dir != "" { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + } + rc, err := f.Open() + if err != nil { + return err + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, f.Mode()) + if err != nil { + rc.Close() + return err + } + _, err = io.Copy(out, rc) + rc.Close() + out.Close() + if err != nil { + return err + } + count++ + } + fmt.Fprintf(req.Stdout, "已解压 %d 个文件\n", count) + return nil +} + +func extractGz(src, dest string, req BuiltinRequest) error { + fmt.Fprintf(req.Stdout, "解压 gz: %s -> %s\n", src, dest) + f, err := os.Open(src) + if err != nil { + return err + } + defer f.Close() + gz, err := gzip.NewReader(f) + if err != nil { + return fmt.Errorf("打开 gzip 失败: %w", err) + } + defer gz.Close() + + outName := strings.TrimSuffix(filepath.Base(src), ".gz") + if outName == "" { + outName = "output" + } + target := filepath.Join(dest, outName) + out, err := os.Create(target) + if err != nil { + return err + } + defer out.Close() + if _, err := io.Copy(out, gz); err != nil { + return err + } + fmt.Fprintf(req.Stdout, "已解压: %s\n", target) + return nil +} + +// isWithinDir checks that target is inside dest (prevents zip-slip attacks). +func isWithinDir(target, dest string) bool { + rel, err := filepath.Rel(dest, target) + if err != nil { + return false + } + return !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != ".." +} diff --git a/internal/executor/builtin.go b/internal/executor/builtin.go index 508fc15..b7c6da2 100644 --- a/internal/executor/builtin.go +++ b/internal/executor/builtin.go @@ -32,6 +32,7 @@ type BuiltinRequest struct { type Builtin func(ctx context.Context, req BuiltinRequest) error var builtins = map[string]Builtin{ + "archive.extract": runArchiveExtract, "disk.usage": runDiskUsage, "diagnose": runDiagnose, "docker.df": runDockerDF, @@ -46,14 +47,34 @@ var builtins = map[string]Builtin{ "docker.save": runDockerSave, "docker.stats": runDockerStats, "docker.stop": runDockerStop, + "file.findbig": runFileFindBig, + "file.findname": runFileFindName, + "file.findrecent": runFileFindRecent, + "fw.allow": runFwAllow, + "fw.deny": runFwDeny, + "fw.status": runFwStatus, "git.retime": runGitRetime, "git.retime.preview": runGitRetimePreview, "java.run": runJavaRun, "java.run.preview": runJavaPreview, + "log.last": runLogLast, + "log.search": runLogSearch, + "net.curl": runNetCurl, + "net.dns": runNetDNS, + "net.ping": runNetPing, "net.pids": runNetPIDs, "net.ports": runNetPorts, + "net.trace": runNetTrace, + "proc.killname": runProcKillName, "proc.nohup": runNohup, + "proc.topcpu": runProcTopCPU, + "proc.topmem": runProcTopMem, "proc.wait": runProcWait, + "svc.disable": runSvcDisable, + "svc.enable": runSvcEnable, + "svc.logs": runSvcLogs, + "svc.status": runSvcStatus, + "sysinfo": runSysInfo, "user.grant": runUserGrant, "user.revoke": runUserRevoke, } diff --git a/internal/executor/filefind.go b/internal/executor/filefind.go new file mode 100644 index 0000000..b8e8562 --- /dev/null +++ b/internal/executor/filefind.go @@ -0,0 +1,388 @@ +package executor + +import ( + "context" + "fmt" + "io/fs" + "path/filepath" + "sort" + "strconv" + "strings" + "time" +) + +// runFileFindBig implements `kopsc file findbig`: find files larger than a +// threshold, sorted by size descending. +func runFileFindBig(ctx context.Context, req BuiltinRequest) error { + opts, err := parseFileFindBigArgs(req.Args) + if err != nil { + return err + } + + root, err := filepath.Abs(opts.path) + if err != nil { + return fmt.Errorf("无法解析路径 %q: %w", opts.path, err) + } + + type fileEntry struct { + path string + size int64 + } + var results []fileEntry + + 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() { + return nil + } + info, err := d.Info() + if err != nil || !info.Mode().IsRegular() { + return nil + } + if info.Size() >= opts.threshold { + rel, _ := filepath.Rel(root, path) + if rel == "" { + rel = path + } + results = append(results, fileEntry{path: rel, size: info.Size()}) + } + return nil + }) + if err != nil { + return err + } + + sort.Slice(results, func(i, j int) bool { + return results[i].size > results[j].size + }) + + if len(results) == 0 { + fmt.Fprintf(req.Stdout, "没有找到大于 %s 的文件\n", humanSize(opts.threshold)) + return nil + } + + for _, f := range results { + fmt.Fprintf(req.Stdout, "%-8s %s\n", humanSize(f.size), f.path) + } + fmt.Fprintf(req.Stdout, "\n共 %d 个文件大于 %s\n", len(results), humanSize(opts.threshold)) + return nil +} + +type fileFindBigOptions struct { + path string + threshold int64 +} + +func parseFileFindBigArgs(args []string) (fileFindBigOptions, error) { + opts := fileFindBigOptions{path: ".", threshold: 100 * 1024 * 1024} + 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 == "--threshold" || arg == "-s": + if i+1 >= len(args) { + return opts, fmt.Errorf("%s 缺少大小参数,例如 --threshold 50M", arg) + } + i++ + n, err := parseSizeArg(args[i]) + if err != nil { + return opts, fmt.Errorf("%s: %w", arg, err) + } + opts.threshold = n + case strings.HasPrefix(arg, "--threshold="): + n, err := parseSizeArg(strings.TrimPrefix(arg, "--threshold=")) + if err != nil { + return opts, fmt.Errorf("--threshold: %w", err) + } + opts.threshold = n + 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] + } + return opts, nil +} + +// parseSizeArg parses human-friendly size strings: "100M", "1G", "500K", "1024". +func parseSizeArg(s string) (int64, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, fmt.Errorf("缺少大小值") + } + multiplier := int64(1) + upper := strings.ToUpper(s) + if strings.HasSuffix(upper, "TB") || strings.HasSuffix(upper, "T") { + multiplier = 1024 * 1024 * 1024 * 1024 + } else if strings.HasSuffix(upper, "GB") || strings.HasSuffix(upper, "G") { + multiplier = 1024 * 1024 * 1024 + } else if strings.HasSuffix(upper, "MB") || strings.HasSuffix(upper, "M") { + multiplier = 1024 * 1024 + } else if strings.HasSuffix(upper, "KB") || strings.HasSuffix(upper, "K") { + multiplier = 1024 + } else if strings.HasSuffix(upper, "B") { + multiplier = 1 + } + + numStr := s + if multiplier > 1 { + numStr = s[:len(s)-lenSuffix(s)] + } + numStr = strings.TrimSpace(numStr) + + n, err := strconv.ParseFloat(numStr, 64) + if err != nil { + return 0, fmt.Errorf("无法解析大小 %q", s) + } + return int64(n * float64(multiplier)), nil +} + +func lenSuffix(s string) int { + i := len(s) + for i > 0 && !isDigit(s[i-1]) && s[i-1] != '.' { + i-- + } + return len(s) - i +} + +func isDigit(b byte) bool { + return b >= '0' && b <= '9' +} + +// runFileFindRecent implements `kopsc file findrecent`: find files modified +// within the last N days. +func runFileFindRecent(ctx context.Context, req BuiltinRequest) error { + opts, err := parseFileFindRecentArgs(req.Args) + if err != nil { + return err + } + + root, err := filepath.Abs(opts.path) + if err != nil { + return fmt.Errorf("无法解析路径 %q: %w", opts.path, err) + } + + cutoff := time.Now().AddDate(0, 0, -opts.days) + type fileEntry struct { + path string + modTime time.Time + } + var results []fileEntry + + 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() { + return nil + } + info, err := d.Info() + if err != nil || !info.Mode().IsRegular() { + return nil + } + if info.ModTime().After(cutoff) { + rel, _ := filepath.Rel(root, path) + if rel == "" { + rel = path + } + results = append(results, fileEntry{path: rel, modTime: info.ModTime()}) + } + return nil + }) + if err != nil { + return err + } + + sort.Slice(results, func(i, j int) bool { + return results[i].modTime.After(results[j].modTime) + }) + + if len(results) == 0 { + fmt.Fprintf(req.Stdout, "最近 %d 天内没有修改过的文件\n", opts.days) + return nil + } + + for _, f := range results { + fmt.Fprintf(req.Stdout, "%s %s\n", f.modTime.Format("2006-01-02 15:04"), f.path) + } + fmt.Fprintf(req.Stdout, "\n共 %d 个文件在最近 %d 天内修改\n", len(results), opts.days) + return nil +} + +type fileFindRecentOptions struct { + path string + days int +} + +func parseFileFindRecentArgs(args []string) (fileFindRecentOptions, error) { + opts := fileFindRecentOptions{path: ".", days: 7} + 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 == "--days" || arg == "-d": + if i+1 >= len(args) { + return opts, fmt.Errorf("%s 缺少天数", arg) + } + i++ + n, err := strconv.Atoi(args[i]) + if err != nil || n < 1 { + return opts, fmt.Errorf("--days 需要正整数,收到 %q", args[i]) + } + opts.days = n + case strings.HasPrefix(arg, "--days="): + n, err := strconv.Atoi(strings.TrimPrefix(arg, "--days=")) + if err != nil || n < 1 { + return opts, fmt.Errorf("--days 需要正整数,收到 %q", strings.TrimPrefix(arg, "--days=")) + } + opts.days = n + 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] + } + return opts, nil +} + +// runFileFindName implements `kopsc file findname`: find files by name pattern +// (case-insensitive glob match). +func runFileFindName(ctx context.Context, req BuiltinRequest) error { + opts, err := parseFileFindNameArgs(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 results []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() { + return nil + } + name := d.Name() + if matchGlob(opts.pattern, name) { + rel, _ := filepath.Rel(root, path) + if rel == "" { + rel = path + } + results = append(results, rel) + } + return nil + }) + if err != nil { + return err + } + + sort.Strings(results) + + if len(results) == 0 { + fmt.Fprintf(req.Stdout, "没有找到匹配 %q 的文件\n", opts.pattern) + return nil + } + + for _, f := range results { + fmt.Fprintln(req.Stdout, f) + } + fmt.Fprintf(req.Stdout, "\n共 %d 个文件匹配\n", len(results)) + return nil +} + +type fileFindNameOptions struct { + path string + pattern string +} + +func parseFileFindNameArgs(args []string) (fileFindNameOptions, error) { + opts := fileFindNameOptions{path: "."} + var paths []string + + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--": + remaining := args[i+1:] + if len(remaining) > 0 && opts.pattern == "" { + opts.pattern = remaining[0] + paths = append(paths, remaining[1:]...) + } else { + paths = append(paths, remaining...) + } + i = len(args) + case arg == "--pattern" || arg == "-p": + if i+1 >= len(args) { + return opts, fmt.Errorf("%s 缺少模式参数", arg) + } + i++ + opts.pattern = args[i] + case strings.HasPrefix(arg, "--pattern="): + opts.pattern = strings.TrimPrefix(arg, "--pattern=") + case strings.HasPrefix(arg, "-") && arg != "-": + return opts, fmt.Errorf("未知选项 %q", arg) + default: + if opts.pattern == "" { + opts.pattern = arg + } else { + paths = append(paths, arg) + } + } + } + if opts.pattern == "" { + return opts, fmt.Errorf("必须提供文件名模式,例如 kopsc file findname \"*.log\"") + } + if len(paths) > 1 { + return opts, fmt.Errorf("一次只支持一个路径,收到 %d 个", len(paths)) + } + if len(paths) == 1 { + opts.path = paths[0] + } + return opts, nil +} + +// matchGlob does a case-insensitive filepath.Match. +func matchGlob(pattern, name string) bool { + if ok, _ := filepath.Match(strings.ToLower(pattern), strings.ToLower(name)); ok { + return true + } + if ok, _ := filepath.Match(pattern, name); ok { + return true + } + return false +} diff --git a/internal/executor/firewall.go b/internal/executor/firewall.go new file mode 100644 index 0000000..c8351b2 --- /dev/null +++ b/internal/executor/firewall.go @@ -0,0 +1,239 @@ +package executor + +import ( + "context" + "fmt" + "os/exec" + "runtime" + "strings" +) + +// runFwStatus implements `kopsc fw status`: show current firewall rules. +func runFwStatus(ctx context.Context, req BuiltinRequest) error { + switch runtime.GOOS { + case "linux": + return fwStatusLinux(ctx, req) + case "darwin": + return fwStatusDarwin(ctx, req) + case "windows": + return fwStatusWindows(ctx, req) + default: + return fmt.Errorf("不支持的操作系统: %s", runtime.GOOS) + } +} + +func fwStatusLinux(ctx context.Context, req BuiltinRequest) error { + // Try ufw first, then firewalld, then iptables. + if path, err := exec.LookPath("ufw"); err == nil { + cmd := exec.CommandContext(ctx, path, "status", "verbose") + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + return cmd.Run() + } + if path, err := exec.LookPath("firewall-cmd"); err == nil { + fmt.Fprintln(req.Stdout, "=== firewalld 状态 ===") + cmd := exec.CommandContext(ctx, path, "--state") + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + _ = cmd.Run() + + fmt.Fprintln(req.Stdout, "\n=== 已开放端口 ===") + listCmd := exec.CommandContext(ctx, path, "--list-ports") + listCmd.Stdout = req.Stdout + listCmd.Stderr = req.Stderr + _ = listCmd.Run() + + fmt.Fprintln(req.Stdout, "\n=== 已允许服务 ===") + svcCmd := exec.CommandContext(ctx, path, "--list-services") + svcCmd.Stdout = req.Stdout + svcCmd.Stderr = req.Stderr + return svcCmd.Run() + } + if path, err := exec.LookPath("iptables"); err == nil { + cmd := exec.CommandContext(ctx, path, "-L", "-n", "--line-numbers") + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + return cmd.Run() + } + return fmt.Errorf("未找到 ufw、firewall-cmd 或 iptables") +} + +func fwStatusDarwin(ctx context.Context, req BuiltinRequest) error { + fmt.Fprintln(req.Stdout, "macOS 使用内置应用防火墙(ALF)") + cmd := exec.CommandContext(ctx, "/usr/libexec/ApplicationFirewall/socketfilterfw", "--getglobalstate") + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + _ = cmd.Run() + + fmt.Fprintln(req.Stdout, "\n=== 已允许的应用 ===") + listCmd := exec.CommandContext(ctx, "/usr/libexec/ApplicationFirewall/socketfilterfw", "--listapps") + listCmd.Stdout = req.Stdout + listCmd.Stderr = req.Stderr + return listCmd.Run() +} + +func fwStatusWindows(ctx context.Context, req BuiltinRequest) error { + cmd := exec.CommandContext(ctx, "netsh", "advfirewall", "firewall", "show", "rule", "name=all", "dir=in", "status=enabled") + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + return cmd.Run() +} + +// runFwAllow implements `kopsc fw allow /`: open a port. +func runFwAllow(ctx context.Context, req BuiltinRequest) error { + opts, err := parseFwPortArgs(req.Args, "allow") + if err != nil { + return err + } + + switch runtime.GOOS { + case "linux": + return fwAllowLinux(ctx, opts, req) + case "darwin": + return fmt.Errorf("macOS 不支持通过命令行开放端口,请在系统偏好设置中配置") + case "windows": + return fwAllowWindows(ctx, opts, req) + default: + return fmt.Errorf("不支持的操作系统: %s", runtime.GOOS) + } +} + +func fwAllowLinux(ctx context.Context, opts fwPortOptions, req BuiltinRequest) error { + portProto := fmt.Sprintf("%d/%s", opts.port, opts.protocol) + if path, err := exec.LookPath("ufw"); err == nil { + fmt.Fprintf(req.Stdout, "开放端口: %s (ufw)\n", portProto) + return runCommand(ctx, path, []string{"allow", portProto}, req) + } + if path, err := exec.LookPath("firewall-cmd"); err == nil { + fmt.Fprintf(req.Stdout, "开放端口: %s (firewalld)\n", portProto) + if err := runCommand(ctx, path, []string{"--add-port=" + portProto, "--permanent"}, req); err != nil { + return err + } + return runCommand(ctx, path, []string{"--reload"}, req) + } + return fmt.Errorf("未找到 ufw 或 firewall-cmd") +} + +func fwAllowWindows(ctx context.Context, opts fwPortOptions, req BuiltinRequest) error { + name := fmt.Sprintf("kopsc-allow-%d-%s", opts.port, opts.protocol) + fmt.Fprintf(req.Stdout, "开放端口: %d/%s (Windows Firewall)\n", opts.port, opts.protocol) + return runCommand(ctx, "netsh", []string{ + "advfirewall", "firewall", "add", "rule", + "name=" + name, "dir=in", "action=allow", + "protocol=" + opts.protocol, "localport=" + fmt.Sprintf("%d", opts.port), + }, req) +} + +// runFwDeny implements `kopsc fw deny /`: close a port. +func runFwDeny(ctx context.Context, req BuiltinRequest) error { + opts, err := parseFwPortArgs(req.Args, "deny") + if err != nil { + return err + } + + switch runtime.GOOS { + case "linux": + return fwDenyLinux(ctx, opts, req) + case "darwin": + return fmt.Errorf("macOS 不支持通过命令行关闭端口,请在系统偏好设置中配置") + case "windows": + return fwDenyWindows(ctx, opts, req) + default: + return fmt.Errorf("不支持的操作系统: %s", runtime.GOOS) + } +} + +func fwDenyLinux(ctx context.Context, opts fwPortOptions, req BuiltinRequest) error { + portProto := fmt.Sprintf("%d/%s", opts.port, opts.protocol) + if path, err := exec.LookPath("ufw"); err == nil { + fmt.Fprintf(req.Stdout, "关闭端口: %s (ufw)\n", portProto) + return runCommand(ctx, path, []string{"deny", portProto}, req) + } + if path, err := exec.LookPath("firewall-cmd"); err == nil { + fmt.Fprintf(req.Stdout, "关闭端口: %s (firewalld)\n", portProto) + if err := runCommand(ctx, path, []string{"--remove-port=" + portProto, "--permanent"}, req); err != nil { + return err + } + return runCommand(ctx, path, []string{"--reload"}, req) + } + return fmt.Errorf("未找到 ufw 或 firewall-cmd") +} + +func fwDenyWindows(ctx context.Context, opts fwPortOptions, req BuiltinRequest) error { + name := fmt.Sprintf("kopsc-allow-%d-%s", opts.port, opts.protocol) + fmt.Fprintf(req.Stdout, "关闭端口: %d/%s (Windows Firewall)\n", opts.port, opts.protocol) + return runCommand(ctx, "netsh", []string{ + "advfirewall", "firewall", "delete", "rule", + "name=" + name, + }, req) +} + +type fwPortOptions struct { + port int + protocol string +} + +func parseFwPortArgs(args []string, action string) (fwPortOptions, error) { + opts := fwPortOptions{protocol: "tcp"} + var positional []string + + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--": + positional = append(positional, args[i+1:]...) + i = len(args) + case arg == "-p" || arg == "--protocol": + if i+1 >= len(args) { + return opts, fmt.Errorf("%s 缺少协议", arg) + } + i++ + proto := strings.ToLower(args[i]) + if proto != "tcp" && proto != "udp" { + return opts, fmt.Errorf("协议必须是 tcp 或 udp,收到 %q", args[i]) + } + opts.protocol = proto + case strings.HasPrefix(arg, "--protocol="): + proto := strings.ToLower(strings.TrimPrefix(arg, "--protocol=")) + if proto != "tcp" && proto != "udp" { + return opts, fmt.Errorf("协议必须是 tcp 或 udp,收到 %q", strings.TrimPrefix(arg, "--protocol=")) + } + opts.protocol = proto + case strings.HasPrefix(arg, "-") && arg != "-": + return opts, fmt.Errorf("未知选项 %q", arg) + default: + positional = append(positional, arg) + } + } + if len(positional) == 0 { + return opts, fmt.Errorf("缺少端口号;用法: kopsc fw %s <端口>[/<协议>]", action) + } + + portStr := positional[0] + if idx := strings.Index(portStr, "/"); idx >= 0 { + proto := strings.ToLower(portStr[idx+1:]) + if proto != "tcp" && proto != "udp" { + return opts, fmt.Errorf("协议必须是 tcp 或 udp,收到 %q", proto) + } + opts.protocol = proto + portStr = portStr[:idx] + } + + n, err := parseInt(portStr) + if err != nil || n < 1 || n > 65535 { + return opts, fmt.Errorf("端口必须是 1-65535 的整数,收到 %q", positional[0]) + } + opts.port = n + return opts, nil +} + +func parseInt(s string) (int, error) { + n := 0 + for _, c := range s { + if c < '0' || c > '9' { + return 0, fmt.Errorf("不是整数: %q", s) + } + n = n*10 + int(c-'0') + } + return n, nil +} diff --git a/internal/executor/logsearch.go b/internal/executor/logsearch.go new file mode 100644 index 0000000..9ebf76c --- /dev/null +++ b/internal/executor/logsearch.go @@ -0,0 +1,269 @@ +package executor + +import ( + "bufio" + "context" + "fmt" + "os/exec" + "runtime" + "strconv" + "strings" + "time" +) + +// runLogSearch implements `kopsc log search `: search system logs. +func runLogSearch(ctx context.Context, req BuiltinRequest) error { + opts, err := parseLogSearchArgs(req.Args) + if err != nil { + return err + } + + switch runtime.GOOS { + case "linux": + return logSearchLinux(ctx, opts, req) + case "darwin": + return logSearchDarwin(ctx, opts, req) + case "windows": + return logSearchWindows(ctx, opts, req) + default: + return fmt.Errorf("不支持的操作系统: %s", runtime.GOOS) + } +} + +type logSearchOptions struct { + keyword string + since string + unit string + lines int +} + +func parseLogSearchArgs(args []string) (logSearchOptions, error) { + opts := logSearchOptions{lines: 100} + var positional []string + + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--": + positional = append(positional, args[i+1:]...) + i = len(args) + case arg == "--since": + if i+1 >= len(args) { + return opts, fmt.Errorf("--since 缺少时长,例如 --since 1h") + } + i++ + opts.since = args[i] + case strings.HasPrefix(arg, "--since="): + opts.since = strings.TrimPrefix(arg, "--since=") + case arg == "-u" || arg == "--unit": + if i+1 >= len(args) { + return opts, fmt.Errorf("%s 缺少服务名", arg) + } + i++ + opts.unit = args[i] + case strings.HasPrefix(arg, "--unit="): + opts.unit = strings.TrimPrefix(arg, "--unit=") + case arg == "-n" || arg == "--lines": + if i+1 >= len(args) { + return opts, fmt.Errorf("%s 缺少行数", arg) + } + i++ + n, err := strconv.Atoi(args[i]) + if err != nil || n < 1 { + return opts, fmt.Errorf("--lines 需要正整数,收到 %q", args[i]) + } + opts.lines = n + case strings.HasPrefix(arg, "--lines="): + n, err := strconv.Atoi(strings.TrimPrefix(arg, "--lines=")) + if err != nil || n < 1 { + return opts, fmt.Errorf("--lines 需要正整数") + } + opts.lines = n + case strings.HasPrefix(arg, "-") && arg != "-": + return opts, fmt.Errorf("未知选项 %q", arg) + default: + positional = append(positional, arg) + } + } + if len(positional) == 0 { + return opts, fmt.Errorf("缺少搜索关键字;用法: kopsc log search <关键字>") + } + opts.keyword = positional[0] + return opts, nil +} + +func logSearchLinux(ctx context.Context, opts logSearchOptions, req BuiltinRequest) error { + args := []string{"--no-pager", "-n", strconv.Itoa(opts.lines), "--grep=" + opts.keyword} + if opts.since != "" { + since, err := normalizeSince(opts.since) + if err != nil { + return fmt.Errorf("--since: %w", err) + } + args = append(args, "--since="+since) + } + if opts.unit != "" { + args = append(args, "-u", opts.unit) + } + cmd := exec.CommandContext(ctx, "journalctl", args...) + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + return cmd.Run() +} + +func logSearchDarwin(ctx context.Context, opts logSearchOptions, req BuiltinRequest) error { + predicate := fmt.Sprintf(`eventMessage CONTAINS[c] "%s"`, opts.keyword) + if opts.unit != "" { + predicate = fmt.Sprintf(`(subsystem == "%s" OR process == "%s") AND %s`, opts.unit, opts.unit, predicate) + } + since := "1h" + if opts.since != "" { + since = opts.since + } + cmd := exec.CommandContext(ctx, "log", "show", "--predicate", predicate, "--last", since, "--style", "compact") + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + return cmd.Run() +} + +func logSearchWindows(ctx context.Context, opts logSearchOptions, req BuiltinRequest) error { + args := []string{"qe", "System", "/q:*[System[Provider]]", "/c:" + strconv.Itoa(opts.lines), "/rd:true", "/f:text"} + cmd := exec.CommandContext(ctx, "wevtutil", args...) + var stdout strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = req.Stderr + if err := cmd.Run(); err != nil { + return err + } + scanner := bufio.NewScanner(strings.NewReader(stdout.String())) + count := 0 + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(strings.ToLower(line), strings.ToLower(opts.keyword)) { + fmt.Fprintln(req.Stdout, line) + count++ + } + } + if count == 0 { + fmt.Fprintf(req.Stdout, "没有找到包含 %q 的日志\n", opts.keyword) + } + return nil +} + +// normalizeSince converts shorthand like "1h", "30m" to journalctl --since format. +func normalizeSince(s string) (string, error) { + s = strings.TrimSpace(s) + if s == "" { + return "", fmt.Errorf("时长不能为空") + } + if d, err := time.ParseDuration(s); err == nil { + t := time.Now().Add(-d) + return t.Format("2006-01-02 15:04:05"), nil + } + return s, nil +} + +// runLogLast implements `kopsc log last`: show recent system log entries. +func runLogLast(ctx context.Context, req BuiltinRequest) error { + opts, err := parseLogLastArgs(req.Args) + if err != nil { + return err + } + + switch runtime.GOOS { + case "linux": + return logLastLinux(ctx, opts, req) + case "darwin": + return logLastDarwin(ctx, opts, req) + case "windows": + return logLastWindows(ctx, opts, req) + default: + return fmt.Errorf("不支持的操作系统: %s", runtime.GOOS) + } +} + +type logLastOptions struct { + lines int + unit string +} + +func parseLogLastArgs(args []string) (logLastOptions, error) { + opts := logLastOptions{lines: 50} + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--": + remaining := args[i+1:] + if len(remaining) > 0 { + n, err := strconv.Atoi(remaining[0]) + if err != nil || n < 1 { + return opts, fmt.Errorf("行数需要正整数,收到 %q", remaining[0]) + } + opts.lines = n + } + i = len(args) + case arg == "-n" || arg == "--lines": + if i+1 >= len(args) { + return opts, fmt.Errorf("%s 缺少行数", arg) + } + i++ + n, err := strconv.Atoi(args[i]) + if err != nil || n < 1 { + return opts, fmt.Errorf("--lines 需要正整数,收到 %q", args[i]) + } + opts.lines = n + case strings.HasPrefix(arg, "--lines="): + n, err := strconv.Atoi(strings.TrimPrefix(arg, "--lines=")) + if err != nil || n < 1 { + return opts, fmt.Errorf("--lines 需要正整数") + } + opts.lines = n + case arg == "-u" || arg == "--unit": + if i+1 >= len(args) { + return opts, fmt.Errorf("%s 缺少服务名", arg) + } + i++ + opts.unit = args[i] + case strings.HasPrefix(arg, "--unit="): + opts.unit = strings.TrimPrefix(arg, "--unit=") + case strings.HasPrefix(arg, "-") && arg != "-": + return opts, fmt.Errorf("未知选项 %q", arg) + default: + n, err := strconv.Atoi(arg) + if err != nil || n < 1 { + return opts, fmt.Errorf("行数需要正整数,收到 %q", arg) + } + opts.lines = n + } + } + return opts, nil +} + +func logLastLinux(ctx context.Context, opts logLastOptions, req BuiltinRequest) error { + args := []string{"--no-pager", "-n", strconv.Itoa(opts.lines)} + if opts.unit != "" { + args = append(args, "-u", opts.unit) + } + cmd := exec.CommandContext(ctx, "journalctl", args...) + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + return cmd.Run() +} + +func logLastDarwin(ctx context.Context, opts logLastOptions, req BuiltinRequest) error { + predicate := `eventMessage != ""` + if opts.unit != "" { + predicate = fmt.Sprintf(`subsystem == "%s" OR process == "%s"`, opts.unit, opts.unit) + } + cmd := exec.CommandContext(ctx, "log", "show", "--predicate", predicate, "--last", "5m", "--style", "compact") + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + return cmd.Run() +} + +func logLastWindows(ctx context.Context, opts logLastOptions, req BuiltinRequest) error { + cmd := exec.CommandContext(ctx, "wevtutil", "qe", "System", + "/c:"+strconv.Itoa(opts.lines), "/rd:true", "/f:text") + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + return cmd.Run() +} diff --git a/internal/executor/netdiag.go b/internal/executor/netdiag.go new file mode 100644 index 0000000..3c40d14 --- /dev/null +++ b/internal/executor/netdiag.go @@ -0,0 +1,289 @@ +package executor + +import ( + "context" + "fmt" + "os/exec" + "runtime" + "strconv" + "strings" +) + +// runNetPing implements `kopsc net ping `: connectivity check. +func runNetPing(ctx context.Context, req BuiltinRequest) error { + opts, err := parseNetPingArgs(req.Args) + if err != nil { + return err + } + args := []string{} + if opts.count > 0 { + if runtime.GOOS == "windows" { + args = append(args, "-n", strconv.Itoa(opts.count)) + } else { + args = append(args, "-c", strconv.Itoa(opts.count)) + } + } + if opts.interval > 0 { + if runtime.GOOS != "windows" { + args = append(args, "-i", strconv.Itoa(opts.interval)) + } + } + args = append(args, opts.host) + + cmd := exec.CommandContext(ctx, "ping", args...) + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + return cmd.Run() +} + +type netPingOptions struct { + host string + count int + interval int +} + +func parseNetPingArgs(args []string) (netPingOptions, error) { + opts := netPingOptions{count: 4} + var positional []string + + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--": + positional = append(positional, args[i+1:]...) + i = len(args) + case arg == "-c" || arg == "-n" || arg == "--count": + if i+1 >= len(args) { + return opts, fmt.Errorf("%s 缺少次数", arg) + } + i++ + n, err := strconv.Atoi(args[i]) + if err != nil || n < 1 { + return opts, fmt.Errorf("次数需要正整数,收到 %q", args[i]) + } + opts.count = n + case arg == "-i" || arg == "--interval": + if i+1 >= len(args) { + return opts, fmt.Errorf("%s 缺少间隔秒数", arg) + } + i++ + n, err := strconv.Atoi(args[i]) + if err != nil || n < 1 { + return opts, fmt.Errorf("间隔需要正整数,收到 %q", args[i]) + } + opts.interval = n + case strings.HasPrefix(arg, "-") && arg != "-": + return opts, fmt.Errorf("未知选项 %q", arg) + default: + positional = append(positional, arg) + } + } + if len(positional) == 0 { + return opts, fmt.Errorf("缺少目标主机;用法: kopsc net ping <主机>") + } + opts.host = positional[0] + return opts, nil +} + +// runNetTrace implements `kopsc net trace `: route tracing. +func runNetTrace(ctx context.Context, req BuiltinRequest) error { + if len(req.Args) == 0 { + return fmt.Errorf("缺少目标主机;用法: kopsc net trace <主机>") + } + host := req.Args[0] + + // Try mtr first (best output), then traceroute, then tracert on Windows. + if path, err := exec.LookPath("mtr"); err == nil { + cmd := exec.CommandContext(ctx, path, "--report", "--report-wide", host) + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + return cmd.Run() + } + if runtime.GOOS == "windows" { + cmd := exec.CommandContext(ctx, "tracert", host) + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + return cmd.Run() + } + if path, err := exec.LookPath("traceroute"); err == nil { + cmd := exec.CommandContext(ctx, path, host) + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + return cmd.Run() + } + return fmt.Errorf("未找到 traceroute 或 mtr,请安装其中之一") +} + +// runNetDNS implements `kopsc net dns `: DNS lookup. +func runNetDNS(ctx context.Context, req BuiltinRequest) error { + opts, err := parseNetDNSArgs(req.Args) + if err != nil { + return err + } + + // Try dig first, then nslookup, then host. + if path, err := exec.LookPath("dig"); err == nil { + args := []string{opts.host} + if opts.recordType != "" { + args = []string{opts.host, opts.recordType} + } + if opts.server != "" { + args = append(args, "@"+opts.server) + } + args = append(args, "+noall", "+answer") + cmd := exec.CommandContext(ctx, path, args...) + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + return cmd.Run() + } + if path, err := exec.LookPath("nslookup"); err == nil { + args := []string{opts.host} + if opts.server != "" { + args = []string{opts.host, opts.server} + } + cmd := exec.CommandContext(ctx, path, args...) + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + return cmd.Run() + } + if path, err := exec.LookPath("host"); err == nil { + args := []string{opts.host} + if opts.server != "" { + args = append(args, opts.server) + } + cmd := exec.CommandContext(ctx, path, args...) + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + return cmd.Run() + } + return fmt.Errorf("未找到 dig、nslookup 或 host,请安装其中之一") +} + +type netDNSOptions struct { + host string + recordType string + server string +} + +func parseNetDNSArgs(args []string) (netDNSOptions, error) { + var opts netDNSOptions + var positional []string + + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--": + positional = append(positional, args[i+1:]...) + i = len(args) + case arg == "-t" || arg == "--type": + if i+1 >= len(args) { + return opts, fmt.Errorf("%s 缺少记录类型", arg) + } + i++ + opts.recordType = strings.ToUpper(args[i]) + case strings.HasPrefix(arg, "--type="): + opts.recordType = strings.ToUpper(strings.TrimPrefix(arg, "--type=")) + case arg == "-s" || arg == "--server": + if i+1 >= len(args) { + return opts, fmt.Errorf("%s 缺少 DNS 服务器", arg) + } + i++ + opts.server = args[i] + case strings.HasPrefix(arg, "--server="): + opts.server = strings.TrimPrefix(arg, "--server=") + case strings.HasPrefix(arg, "-") && arg != "-": + return opts, fmt.Errorf("未知选项 %q", arg) + default: + positional = append(positional, arg) + } + } + if len(positional) == 0 { + return opts, fmt.Errorf("缺少域名;用法: kopsc net dns <域名>") + } + opts.host = positional[0] + return opts, nil +} + +// runNetCurl implements `kopsc net curl `: HTTP diagnostics showing +// status code, time breakdown, and redirect chain. +func runNetCurl(ctx context.Context, req BuiltinRequest) error { + opts, err := parseNetCurlArgs(req.Args) + if err != nil { + return err + } + + if _, err := exec.LookPath("curl"); err != nil { + return fmt.Errorf("net curl 需要 curl,请先安装") + } + + args := []string{"-sS", "-o", "/dev/null", "-w", + "HTTP 状态码: %{http_code}\n" + + "重定向次数: %{num_redirects}\n" + + "DNS 解析: %{time_namelookup}s\n" + + "TCP 连接: %{time_connect}s\n" + + "TLS 握手: %{time_appconnect}s\n" + + "首字节: %{time_starttransfer}s\n" + + "总耗时: %{time_total}s\n" + + "下载大小: %{size_download} bytes\n" + + "下载速度: %{speed_download} bytes/s\n", + } + if opts.follow { + args = append(args, "-L") + } + if opts.method != "" { + args = append(args, "-X", opts.method) + } + for _, h := range opts.headers { + args = append(args, "-H", h) + } + args = append(args, opts.url) + + cmd := exec.CommandContext(ctx, "curl", args...) + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + return cmd.Run() +} + +type netCurlOptions struct { + url string + follow bool + method string + headers []string +} + +func parseNetCurlArgs(args []string) (netCurlOptions, error) { + var opts netCurlOptions + var positional []string + + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--": + positional = append(positional, args[i+1:]...) + i = len(args) + case arg == "-L" || arg == "--follow": + opts.follow = true + case arg == "-X" || arg == "--method": + if i+1 >= len(args) { + return opts, fmt.Errorf("%s 缺少 HTTP 方法", arg) + } + i++ + opts.method = strings.ToUpper(args[i]) + case arg == "-H" || arg == "--header": + if i+1 >= len(args) { + return opts, fmt.Errorf("%s 缺少 header 值", arg) + } + i++ + opts.headers = append(opts.headers, args[i]) + case strings.HasPrefix(arg, "-") && arg != "-": + return opts, fmt.Errorf("未知选项 %q", arg) + default: + positional = append(positional, arg) + } + } + if len(positional) == 0 { + return opts, fmt.Errorf("缺少 URL;用法: kopsc net curl ") + } + opts.url = positional[0] + return opts, nil +} diff --git a/internal/executor/proctop.go b/internal/executor/proctop.go new file mode 100644 index 0000000..f91764e --- /dev/null +++ b/internal/executor/proctop.go @@ -0,0 +1,303 @@ +package executor + +import ( + "context" + "fmt" + "os" + "os/exec" + "runtime" + "sort" + "strconv" + "strings" +) + +// runProcTopMem implements `kopsc proc topmem`: show top N processes by memory. +func runProcTopMem(ctx context.Context, req BuiltinRequest) error { + opts, err := parseProcTopArgs(req.Args) + if err != nil { + return err + } + procs, err := listProcessesWithMem(ctx) + if err != nil { + return err + } + sort.Slice(procs, func(i, j int) bool { return procs[i].mem > procs[j].mem }) + printProcTop(req, procs, opts.count, "内存") + return nil +} + +// runProcTopCPU implements `kopsc proc topcpu`: show top N processes by CPU. +func runProcTopCPU(ctx context.Context, req BuiltinRequest) error { + opts, err := parseProcTopArgs(req.Args) + if err != nil { + return err + } + procs, err := listProcessesWithCPU(ctx) + if err != nil { + return err + } + sort.Slice(procs, func(i, j int) bool { return procs[i].cpu > procs[j].cpu }) + printProcTop(req, procs, opts.count, "CPU") + return nil +} + +type procTopOptions struct { + count int +} + +func parseProcTopArgs(args []string) (procTopOptions, error) { + opts := procTopOptions{count: 10} + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--": + remaining := args[i+1:] + if len(remaining) > 0 { + n, err := strconv.Atoi(remaining[0]) + if err != nil || n < 1 { + return opts, fmt.Errorf("数量需要正整数,收到 %q", remaining[0]) + } + opts.count = n + } + i = len(args) + case arg == "-n" || arg == "--count": + if i+1 >= len(args) { + return opts, fmt.Errorf("%s 缺少数量", arg) + } + i++ + n, err := strconv.Atoi(args[i]) + if err != nil || n < 1 { + return opts, fmt.Errorf("--count 需要正整数,收到 %q", args[i]) + } + opts.count = n + case strings.HasPrefix(arg, "--count="): + n, err := strconv.Atoi(strings.TrimPrefix(arg, "--count=")) + if err != nil || n < 1 { + return opts, fmt.Errorf("--count 需要正整数") + } + opts.count = n + case strings.HasPrefix(arg, "-") && arg != "-": + return opts, fmt.Errorf("未知选项 %q", arg) + default: + n, err := strconv.Atoi(arg) + if err != nil || n < 1 { + return opts, fmt.Errorf("数量需要正整数,收到 %q", arg) + } + opts.count = n + } + } + return opts, nil +} + +type procEntry struct { + PID int + Name string + Command string + mem float64 + cpu float64 +} + +func listProcessesWithMem(ctx context.Context) ([]procEntry, error) { + if runtime.GOOS == "linux" || runtime.GOOS == "darwin" { + return listProcMemPS(ctx) + } + return listProcMemTasklist(ctx) +} + +func listProcessesWithCPU(ctx context.Context) ([]procEntry, error) { + if runtime.GOOS == "linux" || runtime.GOOS == "darwin" { + return listProcCPUPS(ctx) + } + return listProcCPUTasklist(ctx) +} + +func listProcMemPS(ctx context.Context) ([]procEntry, error) { + out, err := exec.CommandContext(ctx, "ps", "-eo", "pid=,rss=,comm=,args=").Output() + if err != nil { + return nil, fmt.Errorf("ps 执行失败: %w", err) + } + var procs []procEntry + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) < 3 { + continue + } + pid, _ := strconv.Atoi(fields[0]) + rss, _ := strconv.ParseFloat(fields[1], 64) + name := fields[2] + command := strings.Join(fields[3:], " ") + if pid > 0 { + procs = append(procs, procEntry{PID: pid, Name: name, Command: command, mem: rss}) + } + } + return procs, nil +} + +func listProcCPUPS(ctx context.Context) ([]procEntry, error) { + out, err := exec.CommandContext(ctx, "ps", "-eo", "pid=,%cpu=,comm=,args=").Output() + if err != nil { + return nil, fmt.Errorf("ps 执行失败: %w", err) + } + var procs []procEntry + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) < 3 { + continue + } + pid, _ := strconv.Atoi(fields[0]) + cpu, _ := strconv.ParseFloat(fields[1], 64) + name := fields[2] + command := strings.Join(fields[3:], " ") + if pid > 0 { + procs = append(procs, procEntry{PID: pid, Name: name, Command: command, cpu: cpu}) + } + } + return procs, nil +} + +func listProcMemTasklist(ctx context.Context) ([]procEntry, error) { + out, err := exec.CommandContext(ctx, "tasklist", "/fo", "csv", "/nh").Output() + if err != nil { + return nil, fmt.Errorf("tasklist 执行失败: %w", err) + } + var procs []procEntry + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Split(strings.TrimSpace(line), ",") + if len(fields) < 5 { + continue + } + name := strings.Trim(fields[0], `"`) + pid, _ := strconv.Atoi(strings.Trim(fields[1], `"`)) + memStr := strings.Trim(fields[4], ` "K`) + memStr = strings.ReplaceAll(memStr, ",", "") + mem, _ := strconv.ParseFloat(memStr, 64) + if pid > 0 { + procs = append(procs, procEntry{PID: pid, Name: name, mem: mem}) + } + } + return procs, nil +} + +func listProcCPUTasklist(ctx context.Context) ([]procEntry, error) { + return nil, fmt.Errorf("Windows 下 tasklist 不报告 CPU 使用率,请使用任务管理器") +} + +func printProcTop(req BuiltinRequest, procs []procEntry, count int, label string) { + if len(procs) == 0 { + fmt.Fprintln(req.Stdout, "没有获取到进程信息") + return + } + if count > len(procs) { + count = len(procs) + } + w := req.Stdout + fmt.Fprintf(w, "=== 占用 %s 前 %d 的进程 ===\n\n", label, count) + fmt.Fprintf(w, "%-8s %-8s %-20s %s\n", "PID", label, "进程", "命令") + for i := 0; i < count; i++ { + p := procs[i] + memStr := "" + if label == "内存" { + memStr = humanSize(int64(p.mem * 1024)) + } else { + memStr = fmt.Sprintf("%.1f%%", p.cpu) + } + cmd := p.Command + if len(cmd) > 50 { + cmd = cmd[:50] + "..." + } + fmt.Fprintf(w, "%-8d %-8s %-20s %s\n", p.PID, memStr, p.Name, cmd) + } +} + +// runProcKillName implements `kopsc proc killname `: kill processes by name. +func runProcKillName(ctx context.Context, req BuiltinRequest) error { + opts, err := parseProcKillNameArgs(req.Args) + if err != nil { + return err + } + + procs, err := listProcesses(ctx) + if err != nil { + return err + } + + var targets []processInfo + for _, p := range procs { + if strings.EqualFold(p.Name, opts.name) || strings.Contains(strings.ToLower(p.Command), strings.ToLower(opts.name)) { + targets = append(targets, p) + } + } + + if len(targets) == 0 { + fmt.Fprintf(req.Stdout, "没有找到名为 %q 的进程\n", opts.name) + return nil + } + + fmt.Fprintf(req.Stdout, "找到 %d 个匹配进程:\n", len(targets)) + for _, p := range targets { + fmt.Fprintf(req.Stdout, " %d %s %s\n", p.PID, p.Name, p.Command) + } + + if !opts.confirmed { + fmt.Fprintf(req.Stdout, "\n这是破坏性操作,未执行。加 --yes 确认终止以上进程\n") + return &ExitError{Code: 2} + } + + for _, p := range targets { + if err := killPID(p.PID); err != nil { + fmt.Fprintf(req.Stderr, "终止 PID %d 失败: %v\n", p.PID, err) + } else { + fmt.Fprintf(req.Stdout, "已终止 PID %d (%s)\n", p.PID, p.Name) + } + } + return nil +} + +type procKillNameOptions struct { + name string + confirmed bool +} + +func parseProcKillNameArgs(args []string) (procKillNameOptions, error) { + var opts procKillNameOptions + var positional []string + + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--": + positional = append(positional, args[i+1:]...) + i = len(args) + case arg == "-y" || arg == "--yes": + opts.confirmed = true + case strings.HasPrefix(arg, "-") && arg != "-": + return opts, fmt.Errorf("未知选项 %q", arg) + default: + positional = append(positional, arg) + } + } + if len(positional) == 0 { + return opts, fmt.Errorf("缺少进程名;用法: kopsc proc killname <进程名>") + } + opts.name = positional[0] + return opts, nil +} + +func killPID(pid int) error { + if runtime.GOOS == "windows" { + return exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/F").Run() + } + proc, err := os.FindProcess(pid) + if err != nil { + return err + } + return proc.Kill() +} diff --git a/internal/executor/svc.go b/internal/executor/svc.go new file mode 100644 index 0000000..4c477c8 --- /dev/null +++ b/internal/executor/svc.go @@ -0,0 +1,240 @@ +package executor + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "runtime" + "strconv" + "strings" +) + +// runSvcStatus implements `kopsc svc status `: show service status +// and recent log lines in one step. +func runSvcStatus(ctx context.Context, req BuiltinRequest) error { + if len(req.Args) == 0 { + return fmt.Errorf("缺少服务名;用法: kopsc svc status <服务名>") + } + name := req.Args[0] + + switch runtime.GOOS { + case "linux": + return svcStatusLinux(ctx, name, req) + case "darwin": + return svcStatusDarwin(ctx, name, req) + case "windows": + return svcStatusWindows(ctx, name, req) + default: + return fmt.Errorf("不支持的操作系统: %s", runtime.GOOS) + } +} + +func svcStatusLinux(ctx context.Context, name string, req BuiltinRequest) error { + w := req.Stdout + fmt.Fprintf(w, "=== 服务状态: %s ===\n\n", name) + + cmd := exec.CommandContext(ctx, "systemctl", "status", name, "--no-pager") + cmd.Stdout = w + cmd.Stderr = req.Stderr + _ = cmd.Run() + + fmt.Fprintf(w, "\n=== 最近日志 ===\n\n") + logCmd := exec.CommandContext(ctx, "journalctl", "-u", name, "-n", "20", "--no-pager") + logCmd.Stdout = w + logCmd.Stderr = req.Stderr + _ = logCmd.Run() + return nil +} + +func svcStatusDarwin(ctx context.Context, name string, req BuiltinRequest) error { + w := req.Stdout + fmt.Fprintf(w, "=== 服务状态: %s ===\n\n", name) + + cmd := exec.CommandContext(ctx, "launchctl", "list", name) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + _ = cmd.Run() + + if stdout.Len() > 0 { + fmt.Fprintln(w, stdout.String()) + } else { + fmt.Fprintf(w, "服务 %q 未在 launchctl 中注册\n", name) + } + if stderr.Len() > 0 { + fmt.Fprintln(req.Stderr, stderr.String()) + } + + fmt.Fprintf(w, "\n=== 日志(最近 20 行)===\n\n") + logCmd := exec.CommandContext(ctx, "log", "show", "--predicate", + fmt.Sprintf(`subsystem == "%s" OR process == "%s"`, name, name), + "--last", "5m", "--style", "compact") + logCmd.Stdout = w + logCmd.Stderr = req.Stderr + _ = logCmd.Run() + return nil +} + +func svcStatusWindows(ctx context.Context, name string, req BuiltinRequest) error { + w := req.Stdout + fmt.Fprintf(w, "=== 服务状态: %s ===\n\n", name) + + cmd := exec.CommandContext(ctx, "sc", "query", name) + cmd.Stdout = w + cmd.Stderr = req.Stderr + _ = cmd.Run() + return nil +} + +// runSvcEnable implements `kopsc svc enable `: enable + start service. +func runSvcEnable(ctx context.Context, req BuiltinRequest) error { + if len(req.Args) == 0 { + return fmt.Errorf("缺少服务名;用法: kopsc svc enable <服务名>") + } + name := req.Args[0] + + switch runtime.GOOS { + case "linux": + fmt.Fprintf(req.Stdout, "启用并启动服务: %s\n", name) + if err := runCommand(ctx, "systemctl", []string{"enable", name}, req); err != nil { + return fmt.Errorf("启用服务失败: %w", err) + } + return runCommand(ctx, "systemctl", []string{"start", name}, req) + case "darwin": + fmt.Fprintf(req.Stdout, "加载并启动服务: %s\n", name) + return runCommand(ctx, "launchctl", []string{"load", "-w", "/Library/LaunchDaemons/" + name + ".plist"}, req) + case "windows": + fmt.Fprintf(req.Stdout, "设置自启并启动服务: %s\n", name) + if err := runCommand(ctx, "sc", []string{"config", name, "start=auto"}, req); err != nil { + return err + } + return runCommand(ctx, "sc", []string{"start", name}, req) + default: + return fmt.Errorf("不支持的操作系统: %s", runtime.GOOS) + } +} + +// runSvcDisable implements `kopsc svc disable `: disable + stop service. +func runSvcDisable(ctx context.Context, req BuiltinRequest) error { + if len(req.Args) == 0 { + return fmt.Errorf("缺少服务名;用法: kopsc svc disable <服务名>") + } + name := req.Args[0] + + switch runtime.GOOS { + case "linux": + fmt.Fprintf(req.Stdout, "停止并禁用服务: %s\n", name) + if err := runCommand(ctx, "systemctl", []string{"stop", name}, req); err != nil { + return fmt.Errorf("停止服务失败: %w", err) + } + return runCommand(ctx, "systemctl", []string{"disable", name}, req) + case "darwin": + fmt.Fprintf(req.Stdout, "卸载服务: %s\n", name) + return runCommand(ctx, "launchctl", []string{"unload", "-w", "/Library/LaunchDaemons/" + name + ".plist"}, req) + case "windows": + fmt.Fprintf(req.Stdout, "停止并禁用服务: %s\n", name) + if err := runCommand(ctx, "sc", []string{"stop", name}, req); err != nil { + return err + } + return runCommand(ctx, "sc", []string{"config", name, "start=disabled"}, req) + default: + return fmt.Errorf("不支持的操作系统: %s", runtime.GOOS) + } +} + +// runSvcLogs implements `kopsc svc logs `: follow service logs. +func runSvcLogs(ctx context.Context, req BuiltinRequest) error { + opts, err := parseSvcLogsArgs(req.Args) + if err != nil { + return err + } + + switch runtime.GOOS { + case "linux": + return svcLogsLinux(ctx, opts, req) + case "darwin": + return svcLogsDarwin(ctx, opts, req) + case "windows": + return svcLogsWindows(ctx, opts, req) + default: + return fmt.Errorf("不支持的操作系统: %s", runtime.GOOS) + } +} + +type svcLogsOptions struct { + service string + follow bool + lines int +} + +func parseSvcLogsArgs(args []string) (svcLogsOptions, error) { + opts := svcLogsOptions{lines: 50} + var positional []string + + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--": + positional = append(positional, args[i+1:]...) + i = len(args) + case arg == "-f" || arg == "--follow": + opts.follow = true + case arg == "--lines" || arg == "-n": + if i+1 >= len(args) { + return opts, fmt.Errorf("%s 缺少行数", arg) + } + i++ + n, err := strconv.Atoi(args[i]) + if err != nil || n < 1 { + return opts, fmt.Errorf("--lines 需要正整数,收到 %q", args[i]) + } + opts.lines = n + case strings.HasPrefix(arg, "--lines="): + n, err := strconv.Atoi(strings.TrimPrefix(arg, "--lines=")) + if err != nil || n < 1 { + return opts, fmt.Errorf("--lines 需要正整数") + } + opts.lines = n + case strings.HasPrefix(arg, "-") && arg != "-": + return opts, fmt.Errorf("未知选项 %q", arg) + default: + positional = append(positional, arg) + } + } + if len(positional) == 0 { + return opts, fmt.Errorf("缺少服务名;用法: kopsc svc logs <服务名>") + } + opts.service = positional[0] + return opts, nil +} + +func svcLogsLinux(ctx context.Context, opts svcLogsOptions, req BuiltinRequest) error { + args := []string{"-u", opts.service, "--no-pager", "-n", strconv.Itoa(opts.lines)} + if opts.follow { + args = append(args, "-f") + } + cmd := exec.CommandContext(ctx, "journalctl", args...) + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + cmd.Stdin = req.Stdin + return cmd.Run() +} + +func svcLogsDarwin(ctx context.Context, opts svcLogsOptions, req BuiltinRequest) error { + predicate := fmt.Sprintf(`subsystem == "%s" OR process == "%s"`, opts.service, opts.service) + args := []string{"show", "--predicate", predicate, "--style", "compact", "--last", "5m"} + cmd := exec.CommandContext(ctx, "log", args...) + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + return cmd.Run() +} + +func svcLogsWindows(ctx context.Context, opts svcLogsOptions, req BuiltinRequest) error { + cmd := exec.CommandContext(ctx, "wevtutil", "qe", "System", + "/q:*[System[Provider[@Name='"+opts.service+"']]]", + "/c:"+strconv.Itoa(opts.lines), "/rd:true", "/f:text") + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + return cmd.Run() +} diff --git a/internal/executor/sysinfo.go b/internal/executor/sysinfo.go new file mode 100644 index 0000000..21a3522 --- /dev/null +++ b/internal/executor/sysinfo.go @@ -0,0 +1,160 @@ +package executor + +import ( + "context" + "fmt" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + + "gitea.odboy.cn/odboy/kenaito-kopsc/internal/platform" +) + +// runSysInfo implements `kopsc sysinfo`: show OS, CPU, memory, disk overview. +func runSysInfo(ctx context.Context, req BuiltinRequest) error { + w := req.Stdout + res := platform.DetectResources() + + fmt.Fprintf(w, "=== 系统信息 ===\n\n") + + fmt.Fprintf(w, "操作系统: %s/%s\n", runtime.GOOS, runtime.GOARCH) + if runtime.GOOS == "linux" { + if data, err := os.ReadFile("/etc/os-release"); err == nil { + distro, _ := platform.ParseOSRelease(data) + if distro != "" { + fmt.Fprintf(w, "发行版: %s\n", distro) + } + } + if hostname, err := os.Hostname(); err == nil { + fmt.Fprintf(w, "主机名: %s\n", hostname) + } + if data, err := os.ReadFile("/proc/version"); err == nil { + fields := strings.Fields(string(data)) + if len(fields) >= 3 { + fmt.Fprintf(w, "内核: %s\n", fields[2]) + } + } + } + + fmt.Fprintf(w, "\n=== CPU ===\n\n") + fmt.Fprintf(w, "逻辑核心: %d\n", res.CPUs) + if runtime.GOOS == "linux" { + if model := cpuModelLinux(); model != "" { + fmt.Fprintf(w, "型号: %s\n", model) + } + } else if runtime.GOOS == "darwin" { + if model := cpuModelDarwin(ctx); model != "" { + fmt.Fprintf(w, "型号: %s\n", model) + } + } + + fmt.Fprintf(w, "\n=== 内存 ===\n\n") + if res.Memory > 0 { + fmt.Fprintf(w, "总计: %s\n", humanSize(res.Memory)) + fmt.Fprintf(w, "检测方式: %s\n", res.Detected) + } + if runtime.GOOS == "linux" { + if free := memFreeLinux(); free > 0 { + fmt.Fprintf(w, "可用: %s\n", humanSize(free)) + } + } + + fmt.Fprintf(w, "\n=== 磁盘 ===\n\n") + printDiskInfo(ctx, w) + + return nil +} + +func cpuModelLinux() string { + data, err := os.ReadFile("/proc/cpuinfo") + if err != nil { + return "" + } + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "model name") { + _, value, ok := strings.Cut(line, ":") + if ok { + return strings.TrimSpace(value) + } + } + } + return "" +} + +func cpuModelDarwin(ctx context.Context) string { + out, err := exec.CommandContext(ctx, "sysctl", "-n", "machdep.cpu.brand_string").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func memFreeLinux() int64 { + data, err := os.ReadFile("/proc/meminfo") + if err != nil { + return 0 + } + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + if fields[0] == "MemAvailable:" || fields[0] == "MemFree:" { + n, err := strconv.ParseInt(fields[1], 10, 64) + if err != nil { + return 0 + } + return n * 1024 + } + } + return 0 +} + +func printDiskInfo(ctx context.Context, w interface{ Write([]byte) (int, error) }) { + if runtime.GOOS == "windows" { + cmd := exec.CommandContext(ctx, "wmic", "logicaldisk", "get", "size,freespace,caption", "/format:list") + out, err := cmd.Output() + if err != nil { + fmt.Fprintln(w, "无法获取磁盘信息") + return + } + parseWMICDisk(string(out), w) + return + } + + cmd := exec.CommandContext(ctx, "df", "-h") + out, err := cmd.Output() + if err != nil { + fmt.Fprintln(w, "无法获取磁盘信息") + return + } + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) == 0 { + return + } + for _, line := range lines { + fmt.Fprintln(w, line) + } +} + +func parseWMICDisk(out string, w interface{ Write([]byte) (int, error) }) { + var caption, freeStr, sizeStr string + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Caption=") { + caption = strings.TrimPrefix(line, "Caption=") + } else if strings.HasPrefix(line, "FreeSpace=") { + freeStr = strings.TrimPrefix(line, "FreeSpace=") + } else if strings.HasPrefix(line, "Size=") { + sizeStr = strings.TrimPrefix(line, "Size=") + if caption != "" && sizeStr != "" { + size, _ := strconv.ParseInt(sizeStr, 10, 64) + free, _ := strconv.ParseInt(freeStr, 10, 64) + fmt.Fprintf(w, "%-6s 总计 %-10s 可用 %-10s\n", caption, humanSize(size), humanSize(free)) + caption, freeStr, sizeStr = "", "", "" + } + } + } +} diff --git a/internal/registry/builtin.go b/internal/registry/builtin.go index 2f64995..b09b9db 100644 --- a/internal/registry/builtin.go +++ b/internal/registry/builtin.go @@ -609,9 +609,246 @@ func Builtin() *Registry { }, }, }, + + // ----- file ----- + { + ID: "file.findbig", + Group: "file", + Summary: "查找大于指定阈值的文件(按大小排序)", + Action: "递归扫描目录,找出所有大于阈值的常规文件,按大小从大到小排序输出。" + + "默认阈值 100M,默认路径当前目录;--threshold 可指定大小(支持 K/M/G/T 后缀)", + Usage: "[路径] [--threshold 大小]", + Variants: threePlatformBuiltins("file.findbig"), + }, + { + ID: "file.findrecent", + Group: "file", + Summary: "查找最近 N 天内修改过的文件", + Action: "递归扫描目录,找出最近 N 天内修改过的常规文件,按修改时间从新到旧排序输出。" + + "默认 7 天,默认路径当前目录;--days 可指定天数", + Usage: "[路径] [--days 天数]", + Variants: threePlatformBuiltins("file.findrecent"), + }, + { + ID: "file.findname", + Group: "file", + Summary: "按文件名查找文件(忽略大小写)", + Action: "递归扫描目录,按文件名模式匹配(支持通配符 * 和 ?,忽略大小写)。" + + "默认路径当前目录", + Usage: "<模式> [路径]", + MinArgs: 1, + Variants: threePlatformBuiltins("file.findname"), + }, + + // ----- svc ----- + { + ID: "svc.status", + Group: "svc", + Summary: "查看服务状态与最近日志", + Action: "一步查看服务运行状态和最近 20 行日志。" + + "Linux: systemctl status + journalctl;macOS: launchctl list + log show;" + + "Windows: sc query", + Usage: "<服务名>", + MinArgs: 1, + Variants: threePlatformBuiltins("svc.status"), + }, + { + ID: "svc.enable", + Group: "svc", + Summary: "启用并启动服务(开机自启 + 立即启动)", + Action: "将服务设为开机自启并立即启动。" + + "Linux: systemctl enable + start;macOS: launchctl load -w;" + + "Windows: sc config start=auto + sc start", + Usage: "<服务名>", + MinArgs: 1, + RequiresYes: true, + Variants: threePlatformBuiltins("svc.enable"), + }, + { + ID: "svc.disable", + Group: "svc", + Summary: "禁用并停止服务", + Action: "停止服务并取消开机自启。" + + "Linux: systemctl stop + disable;macOS: launchctl unload -w;" + + "Windows: sc stop + sc config start=disabled", + Usage: "<服务名>", + MinArgs: 1, + RequiresYes: true, + Variants: threePlatformBuiltins("svc.disable"), + }, + { + ID: "svc.logs", + Group: "svc", + Summary: "查看服务日志(支持跟踪)", + Action: "查看指定服务的日志。-f 可实时跟踪新日志,-n 指定行数(默认 50)。" + + "Linux: journalctl -u;macOS: log show;Windows: wevtutil", + Usage: "<服务名> [-f] [-n 行数]", + MinArgs: 1, + Variants: threePlatformBuiltins("svc.logs"), + }, + + // ----- archive ----- + { + ID: "archive.extract", + Aliases: []string{"archive.unzip", "archive.untar"}, + Group: "archive", + Summary: "自动识别格式并解压压缩文件", + Action: "根据文件扩展名自动选择解压方式:支持 .tar.gz / .tgz / .tar.bz2 / .tar.xz / .tar / .zip / .gz。" + + "默认解压到当前目录,--dir 可指定目标目录", + Usage: "<文件> [--dir 目录]", + MinArgs: 1, + Variants: threePlatformBuiltins("archive.extract"), + }, + + // ----- sysinfo ----- + { + ID: "sysinfo", + Group: "sys", + Summary: "显示系统信息一览(OS、CPU、内存、磁盘)", + Action: "一次性输出操作系统、CPU 型号与核心数、内存总量与可用量、磁盘使用情况。" + + "Linux 读取 /proc 与 cgroup,macOS 使用 sysctl,Windows 使用 wmic", + Variants: threePlatformBuiltins("sysinfo"), + }, + + // ----- proc additions ----- + { + ID: "proc.topmem", + Group: "proc", + Summary: "按内存占用排序显示进程(默认前 10)", + Action: "列出内存占用最高的 N 个进程,按 RSS 降序排列。默认显示前 10,-n 可指定数量", + Usage: "[数量]", + Variants: threePlatformBuiltins("proc.topmem"), + }, + { + ID: "proc.topcpu", + Group: "proc", + Summary: "按 CPU 占用排序显示进程(默认前 10)", + Action: "列出 CPU 占用最高的 N 个进程,按 %CPU 降序排列。默认显示前 10,-n 可指定数量。" + + "Windows 不支持(tasklist 不报告 CPU 使用率)", + Usage: "[数量]", + Variants: threePlatformBuiltins("proc.topcpu"), + }, + { + ID: "proc.killname", + Group: "proc", + Summary: "按进程名终止进程", + Action: "查找名称或命令行中包含指定关键字的所有进程并终止它们。" + + "默认只列出匹配进程,需加 --yes 确认终止", + Usage: "<进程名>", + MinArgs: 1, + RequiresYes: true, + Variants: threePlatformBuiltins("proc.killname"), + }, + + // ----- net additions ----- + { + ID: "net.ping", + Group: "net", + Summary: "测试与目标主机的连通性", + Action: "向目标主机发送 ICMP 回显请求,检测网络连通性与延迟。" + + "默认发送 4 个包,-c 可指定数量", + Usage: "<主机> [-c 次数]", + MinArgs: 1, + Variants: threePlatformBuiltins("net.ping"), + }, + { + ID: "net.trace", + Group: "net", + Summary: "路由追踪(显示数据包经过的每一跳)", + Action: "追踪到达目标主机经过的每一跳路由。优先使用 mtr(最佳输出)," + + "回退到 traceroute(Linux/macOS)或 tracert(Windows)", + Usage: "<主机>", + MinArgs: 1, + Variants: threePlatformBuiltins("net.trace"), + }, + { + ID: "net.dns", + Group: "net", + Summary: "DNS 查询(解析域名)", + Action: "查询域名的 DNS 记录。优先使用 dig,回退到 nslookup 或 host。" + + "-t 指定记录类型(A/AAAA/MX/CNAME 等),-s 指定 DNS 服务器", + Usage: "<域名> [-t 类型] [-s 服务器]", + MinArgs: 1, + Variants: threePlatformBuiltins("net.dns"), + }, + { + ID: "net.curl", + Group: "net", + Summary: "HTTP 诊断(状态码、耗时、重定向链)", + Action: "发送 HTTP 请求并显示诊断信息:状态码、DNS 解析耗时、TCP 连接耗时、TLS 握手耗时、" + + "首字节耗时、总耗时、下载大小与速度。-L 跟随重定向,-X 指定方法,-H 添加 header", + Usage: " [-L] [-X 方法] [-H header]", + MinArgs: 1, + Variants: threePlatformBuiltins("net.curl"), + }, + + // ----- log ----- + { + ID: "log.search", + Group: "log", + Summary: "在系统日志中搜索关键字", + Action: "在系统日志中搜索包含指定关键字的条目。" + + "Linux: journalctl --grep;macOS: log show --predicate;Windows: wevtutil。" + + "--since 限定时间范围(如 1h、30m),-u 限定服务,-n 限定行数", + Usage: "<关键字> [--since 时长] [-u 服务] [-n 行数]", + MinArgs: 1, + Variants: threePlatformBuiltins("log.search"), + }, + { + ID: "log.last", + Group: "log", + Summary: "查看最近的系统日志", + Action: "显示最近的系统日志条目。默认 50 行,-n 可指定行数,-u 可限定服务。" + + "Linux: journalctl;macOS: log show;Windows: wevtutil", + Usage: "[行数] [-u 服务]", + Variants: threePlatformBuiltins("log.last"), + }, + + // ----- fw ----- + { + ID: "fw.status", + Group: "fw", + Summary: "查看防火墙状态与规则", + Action: "显示当前防火墙状态与规则列表。" + + "Linux: 自动检测 ufw / firewalld / iptables;macOS: ALF(应用防火墙);" + + "Windows: netsh advfirewall", + Variants: threePlatformBuiltins("fw.status"), + }, + { + ID: "fw.allow", + Group: "fw", + Summary: "开放防火墙端口", + Action: "在防火墙中开放指定端口。支持 8080/tcp 或 8080/udp 形式,默认 tcp。" + + "Linux: ufw allow / firewall-cmd --add-port;Windows: netsh advfirewall add rule", + Usage: "<端口>[/<协议>]", + MinArgs: 1, + RequiresYes: true, + Variants: threePlatformBuiltins("fw.allow"), + }, + { + ID: "fw.deny", + Group: "fw", + Summary: "关闭防火墙端口", + Action: "在防火墙中关闭指定端口。支持 8080/tcp 或 8080/udp 形式,默认 tcp。" + + "Linux: ufw deny / firewall-cmd --remove-port;Windows: netsh advfirewall delete rule", + Usage: "<端口>[/<协议>]", + MinArgs: 1, + RequiresYes: true, + Variants: threePlatformBuiltins("fw.deny"), + }, }} } +// threePlatformBuiltins builds the platform matrix for a command that kopsc +// implements itself on all three platforms. +func threePlatformBuiltins(name string) []Variant { + return []Variant{ + {Match: Match{OS: "linux"}, Builtin: name, Note: "Linux 内置实现"}, + {Match: Match{OS: "darwin"}, Builtin: name, Note: "macOS 内置实现"}, + {Match: Match{OS: "windows"}, Builtin: name, Note: "Windows 内置实现"}, + } +} + // runtimeBuiltinVariants builds the platform matrix for a container command // that kopsc implements itself. Builtin variants carry no argv; the builtin // learns the runtime's program name from Variant.Runtime instead. diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index 77ba603..b637438 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -27,6 +27,14 @@ func TestBuiltinIDsStable(t *testing.T) { "diagnose", "disk.usage", "net.pids", "net.ports", "proc.nohup", "proc.wait", "java.run", "git.retime", "pkg.refreshindex", "user.grant", "user.revoke", + "file.findbig", "file.findrecent", "file.findname", + "svc.status", "svc.enable", "svc.disable", "svc.logs", + "archive.extract", + "sysinfo", + "proc.topmem", "proc.topcpu", "proc.killname", + "net.ping", "net.trace", "net.dns", "net.curl", + "log.search", "log.last", + "fw.status", "fw.allow", "fw.deny", } if got := reg.IDs(); !reflect.DeepEqual(got, want) { t.Fatalf("IDs() =\n%v\nwant\n%v", got, want)