diff --git a/README.net.md b/README.net.md index 5dab4aa..c6ecd68 100644 --- a/README.net.md +++ b/README.net.md @@ -2,7 +2,7 @@ > 返回 [README.md](README.md) -查看端口被谁占用、本机监听了哪些端口、测试连通性、DNS 查询、路由追踪、HTTP 诊断、网卡信息、连接统计、网速测试。 +查看端口被谁占用、本机监听了哪些端口、测试连通性、DNS 查询、路由追踪、HTTP 诊断、网卡信息、连接统计、网速测试、启动 HTTP 文件服务器。 ```bash kopsc net pids 8080 # 谁占用了 8080 端口(输出 PID) @@ -14,6 +14,7 @@ kopsc net curl https://example.com # HTTP 诊断 kopsc net iface # 查看网卡和 IP 地址 kopsc net conn # 查看 TCP 连接状态统计 kopsc net speed # 简单网速测试 +kopsc net http # 启动 HTTP 文件服务器 ``` ## 端口占用查询(net.pids) @@ -186,3 +187,45 @@ kopsc net speed --upload # 只测上传 | `--size` / `-s` | 测试文件大小(默认 10M,支持 K/M/G 后缀) | | `--download` / `-d` | 只测下载 | | `--upload` / `-u` | 只测上传 | + +## HTTP 文件服务器(net.http) + +```bash +kopsc net http # 当前目录起 HTTP 服务,端口 8000 +kopsc net http -p 9000 # 指定端口 +kopsc net http --dir /var/www # 指定目录 +kopsc net http --upload # 启用上传功能 +``` + +快速启动一个 HTTP 文件服务器,方便局域网内共享文件。纯 Go 实现,不依赖外部工具。 + +输出示例: + +``` +=== HTTP 文件服务器 === + +目录: /Users/odboy/projects +地址: + http://localhost:8000 + http://192.168.1.100:8000 + +按 Ctrl+C 停止 +``` + +### 选项 + +| 选项 | 说明 | +| --- | --- | +| `--dir` / `-d` | 服务目录(默认当前目录) | +| `--port` / `-p` | 端口号(默认 8000) | +| `--upload` / `-u` | 启用上传功能(POST /upload) | + +### 上传功能 + +启用 `--upload` 后,可通过 POST 请求上传文件: + +```bash +curl -F "file=@document.pdf" http://localhost:8000/upload +``` + +上传的文件会保存到服务目录,同名文件自动添加后缀避免覆盖。 diff --git a/internal/executor/nethttp.go b/internal/executor/nethttp.go new file mode 100644 index 0000000..024dc47 --- /dev/null +++ b/internal/executor/nethttp.go @@ -0,0 +1,281 @@ +package executor + +import ( + "context" + "fmt" + "io" + "mime" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// runNetHTTP implements `kopsc net http`: start a simple HTTP file server. +func runNetHTTP(ctx context.Context, req BuiltinRequest) error { + opts, err := parseNetHTTPArgs(req.Args) + if err != nil { + return err + } + + dir := opts.dir + if dir == "" { + dir = "." + } + + absDir, err := filepath.Abs(dir) + if err != nil { + return fmt.Errorf("无法解析路径 %q: %w", dir, err) + } + + if info, err := os.Stat(absDir); err != nil { + return fmt.Errorf("目录 %q 不存在: %w", dir, err) + } else if !info.IsDir() { + return fmt.Errorf("%q 不是目录", dir) + } + + port := opts.port + addr := fmt.Sprintf(":%d", port) + + // Check if port is available + listener, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("端口 %d 已被占用: %w", port, err) + } + + // Get local IP addresses + ips := getLocalIPs() + + fmt.Fprintf(req.Stdout, "=== HTTP 文件服务器 ===\n\n") + fmt.Fprintf(req.Stdout, "目录: %s\n", absDir) + fmt.Fprintf(req.Stdout, "地址:\n") + fmt.Fprintf(req.Stdout, " http://localhost:%d\n", port) + for _, ip := range ips { + fmt.Fprintf(req.Stdout, " http://%s:%d\n", ip, port) + } + if opts.upload { + fmt.Fprintf(req.Stdout, "\n上传功能已启用\n") + fmt.Fprintf(req.Stdout, "POST /upload 可上传文件\n") + } + fmt.Fprintf(req.Stdout, "\n按 Ctrl+C 停止\n") + + mux := http.NewServeMux() + + if opts.upload { + mux.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "请使用 POST 方法上传", http.StatusMethodNotAllowed) + return + } + handleUpload(w, r, absDir, req) + }) + } + + // File server with directory listing + fileServer := http.FileServer(http.Dir(absDir)) + mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Log request + fmt.Fprintf(req.Stdout, "[%s] %s %s\n", time.Now().Format("15:04:05"), r.Method, r.URL.Path) + fileServer.ServeHTTP(w, r) + })) + + server := &http.Server{ + Handler: mux, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + } + + // Handle context cancellation + go func() { + <-ctx.Done() + server.Shutdown(context.Background()) + }() + + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + return err + } + return nil +} + +type netHTTPOptions struct { + dir string + port int + upload bool +} + +func parseNetHTTPArgs(args []string) (netHTTPOptions, error) { + opts := netHTTPOptions{ + port: 8000, // default port + } + + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--": + // Ignore everything after -- + return opts, nil + case arg == "--dir" || arg == "-d": + if i+1 >= len(args) { + return opts, fmt.Errorf("%s 缺少目录参数", arg) + } + i++ + opts.dir = args[i] + case strings.HasPrefix(arg, "--dir="): + opts.dir = strings.TrimPrefix(arg, "--dir=") + case arg == "--port" || arg == "-p": + if i+1 >= len(args) { + return opts, fmt.Errorf("%s 缺少端口参数", arg) + } + i++ + port, err := strconv.Atoi(args[i]) + if err != nil || port < 1 || port > 65535 { + return opts, fmt.Errorf("无效的端口号: %q", args[i]) + } + opts.port = port + case strings.HasPrefix(arg, "--port="): + portStr := strings.TrimPrefix(arg, "--port=") + port, err := strconv.Atoi(portStr) + if err != nil || port < 1 || port > 65535 { + return opts, fmt.Errorf("无效的端口号: %q", portStr) + } + opts.port = port + case arg == "--upload" || arg == "-u": + opts.upload = true + case strings.HasPrefix(arg, "-") && arg != "-": + return opts, fmt.Errorf("未知选项 %q", arg) + default: + // Treat as directory + opts.dir = arg + } + } + + return opts, nil +} + +func getLocalIPs() []string { + var ips []string + + ifaces, err := net.Interfaces() + if err != nil { + return ips + } + + for _, iface := range ifaces { + // Skip down interfaces and loopback + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue + } + + addrs, err := iface.Addrs() + if err != nil { + continue + } + + for _, addr := range addrs { + var ip net.IP + switch v := addr.(type) { + case *net.IPNet: + ip = v.IP + case *net.IPAddr: + ip = v.IP + } + + if ip == nil || ip.IsLoopback() { + continue + } + + // Only IPv4 for simplicity + if ip.To4() != nil { + ips = append(ips, ip.String()) + } + } + } + + return ips +} + +func handleUpload(w http.ResponseWriter, r *http.Request, dir string, req BuiltinRequest) { + // Parse multipart form (max 100MB) + if err := r.ParseMultipartForm(100 << 20); err != nil { + http.Error(w, fmt.Sprintf("解析表单失败: %v", err), http.StatusBadRequest) + return + } + + files := r.MultipartForm.File["file"] + if len(files) == 0 { + http.Error(w, "没有选择文件", http.StatusBadRequest) + return + } + + uploaded := 0 + for _, fileHeader := range files { + file, err := fileHeader.Open() + if err != nil { + http.Error(w, fmt.Sprintf("打开文件失败: %v", err), http.StatusInternalServerError) + return + } + defer file.Close() + + // Sanitize filename + filename := filepath.Base(fileHeader.Filename) + destPath := filepath.Join(dir, filename) + + // Check if file exists + if _, err := os.Stat(destPath); err == nil { + // Add suffix to avoid overwrite + ext := filepath.Ext(filename) + base := strings.TrimSuffix(filename, ext) + for i := 1; ; i++ { + newName := fmt.Sprintf("%s_%d%s", base, i, ext) + destPath = filepath.Join(dir, newName) + if _, err := os.Stat(destPath); os.IsNotExist(err) { + break + } + } + } + + dest, err := os.Create(destPath) + if err != nil { + http.Error(w, fmt.Sprintf("创建文件失败: %v", err), http.StatusInternalServerError) + return + } + + if _, err := io.Copy(dest, file); err != nil { + dest.Close() + http.Error(w, fmt.Sprintf("写入文件失败: %v", err), http.StatusInternalServerError) + return + } + dest.Close() + + fmt.Fprintf(req.Stdout, "[上传] %s -> %s\n", fileHeader.Filename, destPath) + uploaded++ + } + + // Set content type based on Accept header + accept := r.Header.Get("Accept") + if strings.Contains(accept, "application/json") { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"success": true, "uploaded": %d}`, uploaded) + } else { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, "

上传成功

已上传 %d 个文件

返回", uploaded) + } +} + +// detectContentType tries to detect the MIME type of a file +func detectContentType(path string) string { + ext := filepath.Ext(path) + if ext == "" { + return "application/octet-stream" + } + + mimeType := mime.TypeByExtension(ext) + if mimeType != "" { + return mimeType + } + + return "application/octet-stream" +}