feat: 新增 net.http HTTP 文件服务器命令

This commit is contained in:
2026-11-03 22:52:00 +08:00
parent 815fe26bba
commit 3f2a4eb6ca
2 changed files with 325 additions and 1 deletions
+44 -1
View File
@@ -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
```
上传的文件会保存到服务目录,同名文件自动添加后缀避免覆盖。
+281
View File
@@ -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, "<html><body><h2>上传成功</h2><p>已上传 %d 个文件</p><a href='/'>返回</a></body></html>", 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"
}