feat: 新增 sys.uptime/net.iface/net.conn/net.speed/proc.tree/file.empty/user.info 命令

This commit is contained in:
2026-11-03 20:05:10 +08:00
parent 0cf0dd61b9
commit aeeef892ba
16 changed files with 978 additions and 8 deletions
+7
View File
@@ -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,
}
+135
View File
@@ -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
}
+125
View File
@@ -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
}
+50
View File
@@ -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
}
+168
View File
@@ -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
}
+51
View File
@@ -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()
}
+133
View File
@@ -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)
}
+78
View File
@@ -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
}