commit 467e28a6a165b08926d7eacf99edf31c7c148c2c Author: kory Date: Mon Sep 28 07:48:01 2026 +0800 feat: initial commit of vps-commander release v1.0.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d792f07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Binaries and build artifacts +bin/ +*.exe +*.exe~ +*.dll +*.so +*.dylib +vps-commander-hub +vps-commander-agent +vps-commander-mcp-stdio + +# Runtime data & sqlite +data/ +*.db +*.db-shm +*.db-wal + +# Logs +*.log +hub.log + +# Environments and secrets +*.env +.env* + +# OS / Editor +.DS_Store +.idea/ +.vscode/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..55b8a0b --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +APP=vps-commander +GO=go +LDFLAGS=-s -w + +.PHONY: all clean test vet race build linux-amd64 linux-arm64 + +all: test build + +test: + $(GO) test ./... + +race: + $(GO) test -race ./... + +vet: + $(GO) vet ./... + +build: linux-amd64 mcp-stdio + +linux-amd64: + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o bin/vps-commander-hub-linux-amd64 ./cmd/hub + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o bin/vps-commander-agent-linux-amd64 ./cmd/agent + +linux-arm64: + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o bin/vps-commander-hub-linux-arm64 ./cmd/hub + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o bin/vps-commander-agent-linux-arm64 ./cmd/agent + +clean: + rm -rf bin + +mcp-stdio: + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o bin/vps-commander-mcp-stdio-linux-amd64 ./cmd/mcp-stdio diff --git a/cmd/agent/main.go b/cmd/agent/main.go new file mode 100644 index 0000000..9ad8032 --- /dev/null +++ b/cmd/agent/main.go @@ -0,0 +1,190 @@ +package main + +import ( + "context" + "crypto/tls" + "encoding/json" + "errors" + "flag" + "io" + "log" + "net/http" + "os" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/wjyhk/vps-commander/internal/cluster" + "github.com/wjyhk/vps-commander/internal/executor" +) + +func main() { + hub := flag.String("hub", "", "Hub WebSocket URL") + name := flag.String("name", "", "device name") + token := flag.String("token", os.Getenv("VPS_COMMANDER_CLUSTER_SECRET"), "cluster secret") + insecure := flag.Bool("insecure", false, "skip TLS verification") + flag.Parse() + if *hub == "" || *name == "" || *token == "" { + log.Fatal("-hub, -name and -token are required") + } + backoff := time.Second + for { + if err := run(*hub, *name, *token, *insecure); err != nil { + log.Printf("agent connection: %v", err) + } + time.Sleep(backoff) + if backoff < 30*time.Second { + backoff *= 2 + if backoff > 30*time.Second { + backoff = 30 * time.Second + } + } + } +} + +func run(hub, name, token string, insecure bool) error { + u := hub + "?device_name=" + name + dialer := websocket.DefaultDialer + if insecure { + dialer = &websocket.Dialer{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} + } + header := http.Header{} + header.Set("Authorization", "Bearer "+token) + c, _, err := dialer.Dial(u, header) + if err != nil { + return err + } + defer c.Close() + var writeMu sync.Mutex + writeJSON := func(msg cluster.Message) error { + writeMu.Lock() + defer writeMu.Unlock() + if err := c.SetWriteDeadline(time.Now().Add(10 * time.Second)); err != nil { + return err + } + return c.WriteJSON(msg) + } + if err := c.SetReadDeadline(time.Now().Add(75 * time.Second)); err != nil { + return err + } + exec := executor.Local{MaxOutput: 1024 * 1024} + if err := writeJSON(cluster.Message{Event: "hello", Device: name, TS: time.Now().Unix()}); err != nil { + return err + } + stopPing := make(chan struct{}) + defer close(stopPing) + go func() { + t := time.NewTicker(30 * time.Second) + defer t.Stop() + for { + select { + case <-t.C: + _ = writeJSON(cluster.Message{Event: "ping", Device: name, TS: time.Now().Unix()}) + case <-stopPing: + return + } + } + }() + for { + var msg cluster.Message + if err := c.ReadJSON(&msg); err != nil { + return err + } + _ = c.SetReadDeadline(time.Now().Add(75 * time.Second)) + if msg.Event == "registered" { + continue + } + if msg.Event == "ping" { + if err := writeJSON(cluster.Message{Event: "pong", Device: name, TS: msg.TS}); err != nil { + return err + } + continue + } + if msg.Action == "" || msg.ID == "" { + continue + } + go func(msg cluster.Message) { + if err := handle(writeJSON, exec, msg); err != nil { + log.Printf("request %s failed: %v", msg.ID, err) + _ = c.Close() + } + }(msg) + } +} + +func handle(writeJSON func(cluster.Message) error, exec executor.Local, msg cluster.Message) error { + switch msg.Action { + case "exec": + var p cluster.ExecPayload + if err := decodePayload(msg.Payload, &p); err != nil { + return sendError(writeJSON, msg.ID, err) + } + timeout := p.Timeout + if timeout <= 0 { + timeout = 30 + } + if timeout > 300 { + timeout = 300 + } + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) + res := exec.Run(ctx, p.Command, p.Workdir) + cancel() + return writeJSON(cluster.Message{ID: msg.ID, Status: "success", ExitCode: res.ExitCode, Stdout: res.Stdout, Stderr: res.Stderr, DurationMS: res.DurationMS}) + case "file_read": + var p cluster.FileReadPayload + if err := decodePayload(msg.Payload, &p); err != nil { + return sendError(writeJSON, msg.ID, err) + } + if p.Offset < 0 { + return sendError(writeJSON, msg.ID, errors.New("negative offset")) + } + f, err := os.Open(p.Path) + if err != nil { + return sendError(writeJSON, msg.ID, err) + } + defer f.Close() + info, err := f.Stat() + if err != nil { + return sendError(writeJSON, msg.ID, err) + } + if _, err = f.Seek(p.Offset, io.SeekStart); err != nil { + return sendError(writeJSON, msg.ID, err) + } + limit := p.Limit + if limit <= 0 || limit > 1024*1024 { + limit = 256 * 1024 + } + buf := make([]byte, limit) + n, err := io.ReadFull(f, buf) + eof := err == io.EOF || err == io.ErrUnexpectedEOF + if err != nil && !eof { + return sendError(writeJSON, msg.ID, err) + } + return writeJSON(cluster.Message{ID: msg.ID, Status: "success", Payload: map[string]any{"content": string(buf[:n]), "bytes": n, "total_size": info.Size(), "has_more": p.Offset+int64(n) < info.Size(), "eof": eof}}) + case "file_write": + var p cluster.FileWritePayload + if err := decodePayload(msg.Payload, &p); err != nil { + return sendError(writeJSON, msg.ID, err) + } + if len(p.Content) > 1024*1024 { + return sendError(writeJSON, msg.ID, errors.New("content too large")) + } + if err := os.WriteFile(p.Path, []byte(p.Content), 0644); err != nil { + return sendError(writeJSON, msg.ID, err) + } + return writeJSON(cluster.Message{ID: msg.ID, Status: "success", Payload: map[string]any{"bytes": len(p.Content)}}) + default: + return sendError(writeJSON, msg.ID, errors.New("unsupported action")) + } +} + +func decodePayload(v any, out any) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + return json.Unmarshal(b, out) +} +func sendError(writeJSON func(cluster.Message) error, id string, err error) error { + return writeJSON(cluster.Message{ID: id, Status: "error", Error: err.Error()}) +} diff --git a/cmd/hub/main.go b/cmd/hub/main.go new file mode 100644 index 0000000..7125a38 --- /dev/null +++ b/cmd/hub/main.go @@ -0,0 +1,122 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "flag" + "log" + "net/http" + "os" + "os/signal" + "runtime" + "syscall" + "time" + + "github.com/wjyhk/vps-commander/internal/api" + "github.com/wjyhk/vps-commander/internal/auth" + "github.com/wjyhk/vps-commander/internal/cluster" + "github.com/wjyhk/vps-commander/internal/executor" + "github.com/wjyhk/vps-commander/internal/mcp" + "github.com/wjyhk/vps-commander/internal/storage" +) + +func token() string { + b := make([]byte, 24) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return hex.EncodeToString(b) +} + +func main() { + addr := flag.String("addr", "127.0.0.1:9521", "listen address") + dbPath := flag.String("db", "data/commander.db", "sqlite database") + flag.Parse() + apiKey := os.Getenv("VPS_COMMANDER_API_KEY") + clusterSecret := os.Getenv("VPS_COMMANDER_CLUSTER_SECRET") + webPassword := os.Getenv("VPS_COMMANDER_WEB_PASSWORD") + if apiKey == "" || clusterSecret == "" || webPassword == "" { + log.Fatal("VPS_COMMANDER_API_KEY, VPS_COMMANDER_CLUSTER_SECRET and VPS_COMMANDER_WEB_PASSWORD are required via environment") + } + authManager := auth.NewManager(apiKey) + store, err := storage.Open(*dbPath) + if err != nil { + log.Fatal(err) + } + defer store.DB.Close() + local := "local" + if h, err := os.Hostname(); err == nil && h != "" { + local = h + } + if err := store.UpsertLocal(local, runtime.GOARCH, runtime.GOOS); err != nil { + log.Fatal(err) + } + s := &api.Server{ + Auth: authManager, + Exec: executor.Local{MaxOutput: 1024 * 1024}, + Cluster: cluster.NewManager(clusterSecret), + Store: store, + LocalName: local, + } + + // MCP Server instance + mcpServer := mcp.NewServer(s.NewMCPBackend(), authManager) + + protected := auth.Middleware{Manager: authManager}.Handler(s.Routes()) + mux := http.NewServeMux() + mux.Handle("/api/", protected) + + // MCP Endpoints + // Flexible Auth: allows optional token on SSE connect, but enforces token on Message calls + mux.HandleFunc("/mcp/sse", mcpServer.HandleSSE) + mux.Handle("/mcp/message", auth.Middleware{Manager: authManager, Optional: true}.Handler(http.HandlerFunc(mcpServer.HandleMessage))) + + // OAuth Endpoints (For OpenAI OAuth auto-discovery) + mux.HandleFunc("/.well-known/oauth-authorization-server", mcpServer.HandleOAuthMetadata) + mux.HandleFunc("/.well-known/oauth-protected-resource", mcpServer.HandleOAuthMetadata) + mux.HandleFunc("/oauth/authorize", mcpServer.HandleAuthorize) + mux.HandleFunc("/oauth/token", mcpServer.HandleToken) + + mux.HandleFunc("/healthz", s.Routes().ServeHTTP) + mux.HandleFunc("/openapi.json", s.Routes().ServeHTTP) + mux.HandleFunc("/agent/ws", s.Routes().ServeHTTP) + panel := s.Panel(webPassword) + mux.Handle("/", panel) + + // Wrap root with logger and CORS + rootHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Printf("[REQ] %s %s from %s (Auth: %v)", r.Method, r.URL.String(), r.RemoteAddr, r.Header.Get("Authorization") != "") + // Handle CORS Preflight + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS, HEAD") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept") + w.WriteHeader(http.StatusNoContent) + return + } + w.Header().Set("Access-Control-Allow-Origin", "*") + mux.ServeHTTP(w, r) + }) + + server := &http.Server{ + Addr: *addr, + Handler: rootHandler, + ReadHeaderTimeout: 10 * time.Second, + IdleTimeout: 90 * time.Second, + } + log.Printf("vps-commander-hub listening on %s as %s", *addr, local) + go func() { + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatal(err) + } + }() + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + <-sig + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + log.Printf("graceful shutdown: %v", err) + } +} diff --git a/cmd/mcp-stdio/main.go b/cmd/mcp-stdio/main.go new file mode 100644 index 0000000..746153f --- /dev/null +++ b/cmd/mcp-stdio/main.go @@ -0,0 +1,149 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/wjyhk/vps-commander/internal/mcp" +) + +type HubRemoteBackend struct { + BaseURL string + APIKey string + HTTPClient *http.Client +} + +func (h *HubRemoteBackend) post(path string, reqBody any, out any) error { + b, err := json.Marshal(reqBody) + if err != nil { + return err + } + url := strings.TrimRight(h.BaseURL, "/") + path + req, err := http.NewRequest("POST", url, bytes.NewReader(b)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+h.APIKey) + + resp, err := h.HTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + bs, _ := io.ReadAll(resp.Body) + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(bs)) + } + + if out != nil { + return json.NewDecoder(resp.Body).Decode(out) + } + return nil +} + +func (h *HubRemoteBackend) ListDevices(ctx context.Context) (any, error) { + url := strings.TrimRight(h.BaseURL, "/") + "/api/v1/devices" + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+h.APIKey) + + resp, err := h.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + bs, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(bs)) + } + var res any + err = json.NewDecoder(resp.Body).Decode(&res) + return res, err +} + +func (h *HubRemoteBackend) ExecCommand(ctx context.Context, clientIP, device, command, workdir string, timeout int) (any, error) { + payload := map[string]any{ + "device": device, + "command": command, + "workdir": workdir, + "timeout": timeout, + } + var res any + err := h.post("/api/v1/exec", payload, &res) + return res, err +} + +func (h *HubRemoteBackend) ReadFile(ctx context.Context, clientIP, device, path string, offset, limit int64) (any, error) { + payload := map[string]any{ + "device": device, + "path": path, + "offset": offset, + "limit": limit, + } + var res any + err := h.post("/api/v1/file/read", payload, &res) + return res, err +} + +func (h *HubRemoteBackend) WriteFile(ctx context.Context, clientIP, device, path, content string) (any, error) { + payload := map[string]any{ + "device": device, + "path": path, + "content": content, + } + var res any + err := h.post("/api/v1/file/write", payload, &res) + return res, err +} + +func main() { + baseURL := flag.String("hub", "http://127.0.0.1:9521", "VPS-Commander Hub base URL") + apiKey := flag.String("key", os.Getenv("VPS_COMMANDER_API_KEY"), "VPS-Commander Hub API Key") + flag.Parse() + + if *apiKey == "" { + fmt.Fprintln(os.Stderr, "Error: -key or VPS_COMMANDER_API_KEY environment variable is required") + os.Exit(1) + } + + backend := &HubRemoteBackend{ + BaseURL: *baseURL, + APIKey: *apiKey, + HTTPClient: &http.Client{Timeout: 310 * time.Second}, + } + + srv := mcp.NewServer(backend, nil) + scanner := bufio.NewScanner(os.Stdin) + + // stdio line-by-line JSON-RPC + for scanner.Scan() { + line := scanner.Bytes() + if len(strings.TrimSpace(string(line))) == 0 { + continue + } + + var req mcp.JSONRPCRequest + if err := json.Unmarshal(line, &req); err != nil { + continue + } + + resp := srv.HandleDirectRequest(context.Background(), req) + respBytes, _ := json.Marshal(resp) + os.Stdout.Write(respBytes) + os.Stdout.WriteString("\n") + } +} diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..69f5ed7 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,143 @@ +# VPS-Commander 部署与运维指南 + +本文档介绍 VPS-Commander 的生产环境部署架构、配置规范与运维操作。 + +--- + +## 一、 系统架构与端口规划 + +- **Hub 服务端**:统一监听本地 `127.0.0.1:9521`,不对公网直接暴露端口。 +- **反向代理 (Lucky)**: + - 域名:`https://vpstool.kory.kdns.fr` + - 转发目标:`http://127.0.0.1:9521` + - 核心要求:开启 **WebSocket Upgrade** 支持,转发透传所有子路径(`/.well-known/*`、`/oauth/*`、`/mcp/*`、`/api/v1/*`、`/agent/ws`、`/panel/*`)。 + - SSL 终止于反代层,反代自动携带 `X-Forwarded-Proto: https` 头以保障 Cookie 的 `Secure` 属性。 +- **Agent 客户端**: + - 运行于受控的远程 VPS 机器上。 + - 通过公网 WSS 长连接主动回连 Hub:`wss://vpstool.kory.kdns.fr/agent/ws`。 + +--- + +## 二、 凭据安全规范(强制要求) + +1. **绝对禁止命令行参数传密**: + - 二进制不再支持 `--api-key`、`--cluster-secret`、`--web-password` 命令行参数。 + - 彻底杜绝在 `/proc/*/cmdline` 和 `ps aux` 中泄露明文密码。 +2. **凭据存储位置与权限**: + - 凭据文件保存在 `/etc/vps-commander/hub.env` 与 `/etc/vps-commander/agent.env`。 + - 权限必须严格设置为 `0600`(仅 root 可读写)。 + +--- + +## 三、 Hub 服务端部署步骤 + +### 1. 编译并安装二进制 +```bash +cd /root/.openclaw/workspace/vps-commander +make linux-amd64 +install -d -m 0750 /opt/vps-commander/data /etc/vps-commander +install -m 0755 bin/vps-commander-hub-linux-amd64 /opt/vps-commander/vps-commander-hub +``` + +### 2. 配置环境变量文件 (`/etc/vps-commander/hub.env`) +```bash +cat << 'ENV' > /etc/vps-commander/hub.env +VPS_COMMANDER_API_KEY=your_generated_api_key_here +VPS_COMMANDER_CLUSTER_SECRET=your_generated_cluster_secret_here +VPS_COMMANDER_WEB_PASSWORD=your_generated_web_password_here +ENV +chmod 0600 /etc/vps-commander/hub.env +``` + +### 3. 配置 Systemd 托管 (`/etc/systemd/system/vps-commander-hub.service`) +```ini +[Unit] +Description=VPS-Commander Hub +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/vps-commander +EnvironmentFile=-/etc/vps-commander/hub.env +ExecStart=/opt/vps-commander/vps-commander-hub -addr 127.0.0.1:9521 -db /opt/vps-commander/data/commander.db +Restart=always +RestartSec=3s +KillSignal=SIGTERM +TimeoutStopSec=10s +LimitNOFILE=65536 +Environment=GOGC=50 + +[Install] +WantedBy=multi-user.target +``` + +### 4. 启动服务 +```bash +systemctl daemon-reload +systemctl enable --now vps-commander-hub +systemctl status vps-commander-hub +``` + +--- + +## 四、 远程 Agent 节点部署步骤 + +若要将其他 VPS 纳入受控列表,在该远程 VPS 上执行: + +### 1. 安装 Agent 二进制 +将编译生成的 `bin/vps-commander-agent-linux-amd64`(或 ARM64 版本)上传至目标 VPS 的 `/opt/vps-commander/vps-commander-agent` 并赋予执行权限 `chmod +x`。 + +### 2. 配置 Agent 环境变量 (`/etc/vps-commander/agent.env`) +```bash +install -d -m 0750 /etc/vps-commander +cat << 'ENV' > /etc/vps-commander/agent.env +VPS_COMMANDER_HUB_WS_URL=wss://vpstool.kory.kdns.fr/agent/ws +VPS_COMMANDER_AGENT_NAME=vps-node-01 +VPS_COMMANDER_CLUSTER_SECRET=与Hub相同的CLUSTER_SECRET +ENV +chmod 0600 /etc/vps-commander/agent.env +``` + +### 3. 配置 Systemd 托管 (`/etc/systemd/system/vps-commander-agent.service`) +```ini +[Unit] +Description=VPS-Commander Agent +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/vps-commander +EnvironmentFile=-/etc/vps-commander/agent.env +ExecStart=/opt/vps-commander/vps-commander-agent -hub ${VPS_COMMANDER_HUB_WS_URL} -name ${VPS_COMMANDER_AGENT_NAME} +Restart=always +RestartSec=3s +KillSignal=SIGTERM +TimeoutStopSec=10s +LimitNOFILE=65536 +Environment=GOGC=50 + +[Install] +WantedBy=multi-user.target +``` + +### 4. 启动并连入集群 +```bash +systemctl daemon-reload +systemctl enable --now vps-commander-agent +``` +启动后,Hub 控制面板与 `/api/v1/devices` 接口将立即展示该节点并标记为 `online`。 + +--- + +## 五、 控制面板与 Actions 使用 + +1. **管理后台地址**:`https://vpstool.kory.kdns.fr/panel/login` +2. **后台功能**: + - **设备看板**:查看所有在线/离线节点、系统架构与延迟。 + - **快速控制台**:直接在网页端向指定节点执行 Shell 指令。 + - **审计日志**:实时监控 GPT 与用户的全部执行调用、命令详情与耗时。 + - **Actions 向导**:一键复制 Schema 地址、System Prompt 与 API 密钥。 diff --git a/deploy/vps-commander-agent.service b/deploy/vps-commander-agent.service new file mode 100644 index 0000000..279495d --- /dev/null +++ b/deploy/vps-commander-agent.service @@ -0,0 +1,20 @@ +[Unit] +Description=VPS-Commander Agent +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/vps-commander +EnvironmentFile=-/etc/vps-commander/agent.env +ExecStart=/opt/vps-commander/vps-commander-agent -hub ${VPS_COMMANDER_HUB_WS_URL} -name ${VPS_COMMANDER_AGENT_NAME} +Restart=always +RestartSec=3s +KillSignal=SIGTERM +TimeoutStopSec=10s +LimitNOFILE=65536 +Environment=GOGC=50 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/vps-commander-hub.service b/deploy/vps-commander-hub.service new file mode 100644 index 0000000..4861468 --- /dev/null +++ b/deploy/vps-commander-hub.service @@ -0,0 +1,21 @@ +[Unit] +Description=VPS-Commander Hub +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/vps-commander +EnvironmentFile=-/etc/vps-commander/hub.env +ExecStart=/opt/vps-commander/vps-commander-hub -addr 127.0.0.1:9521 -db /opt/vps-commander/data/commander.db +Restart=on-failure +RestartSec=3s +KillSignal=SIGTERM +TimeoutStopSec=20s +LimitNOFILE=65536 +Environment=GOGC=50 +NoNewPrivileges=false + +[Install] +WantedBy=multi-user.target diff --git a/docs/ACCEPTANCE_CRITERIA.md b/docs/ACCEPTANCE_CRITERIA.md new file mode 100644 index 0000000..0f1e221 --- /dev/null +++ b/docs/ACCEPTANCE_CRITERIA.md @@ -0,0 +1,50 @@ +# VPS-Commander 验收审核标准与收尾门禁规范 (Quality Gate & Acceptance Criteria) + +> 本规范由架构与质量负责人(OpenClaw)制定并执行最终裁决。 +> 任何交付代码必须满足以下全量准入与准出指标方可判定为验收通过(Release Ready)。 + +--- + +## 一、 核心门禁四不原则(强制一票否决项) + +1. **绝对无外部公网中转**:任何网络交互(除与 OpenAI 官方通信外)严禁经过第三方 Relay 服务器,必须 100% 走私有域名直连。 +2. **绝对零残留孤儿进程**:执行任何命令、超时中断或连接断开后,后台不得产生僵尸进程或孤儿子进程。 +3. **绝对无未授权越权**:所有非公共接口必须强制 Bearer Token / Cluster Secret 校验,缺失或错误凭证必须严厉返回 HTTP 401,严禁静默放行。 +4. **内存超标即挂**:Hub 静态常驻物理内存不得突破 **25 MB**,Agent 客户端常驻物理内存不得突破 **12 MB**;10MB 为优化目标。 + +--- + +## 二、 详细验收测试用例矩阵 (12 项全绿门禁) + +| 用例 ID | 验证模块 | 测试动作与输入 | 预期结果与合格标准 | 验证方式 | +| :--- | :--- | :--- | :--- | :--- | +| **TC-01** | **OpenAPI 规范合法性** | `GET /openapi.json` | 格式符合 OpenAPI 3.1.0 标准,包含 servers URL、4 个标准 path、安全定义为 `bearerAuth`(HTTP Bearer),可通过 Swagger / OpenAI Actions 校验无报错。 | 机器静态校验 | +| **TC-02** | **未授权拦截** | 无 Token 请求 `/api/v1/exec` | 立即返回 HTTP 401 Unauthorized,响应体包含 `{"error": "Unauthorized"}`,不触发任何底层 bash。 | `curl -i` 验证 | +| **TC-03** | **本地基础命令执行** | `POST /api/v1/exec` (`{"device":"local","command":"uname -a"}`) | 返回 exit_code: 0,stdout 正确包含 Linux 内核信息,duration_ms > 0 且合理。 | 实测响应校验 | +| **TC-04** | **命令超时与进程回收** | 执行 `sleep 10`,设置 `timeout: 2` | 命令在 2 秒后强制终止,返回超时错误,且检查系统进程树确认 `sleep` 进程彻底消失,无僵尸残留。 | `ps -ef` 核查 | +| **TC-05** | **文件分页读取防爆** | 读取 10MB 文本文件,设置 `limit: 1024` | 仅返回前 1024 字节内容,返回 `total_size` 与 `has_more: true`,严禁一次性将全文件读入内存。 | 接口响应验证 | +| **TC-06** | **文件安全覆写** | `POST /api/v1/file/write` 写入 `/tmp/vps_cmd_test.txt` | 文件内容精确写入,权限正常,支持覆盖写入。 | 本地文件比对 | +| **TC-07** | **Agent 握手与注册** | 启动 Agent 连入 Hub WSS | Hub 面板与 `GET /api/v1/devices` 瞬间显示该设备为 `online`,展示正确 IP 与内核信息。 | 状态机校验 | +| **TC-08** | **心跳保活与离线感知** | 模拟杀死 Agent 进程 | 经过 2 个心跳周期(~60s)后,Hub 自动将该节点状态变更为 `offline`,不发生 panic。 | 节点状态监听 | +| **TC-09** | **跨节点路由下发** | `POST /api/v1/exec` (`{"device":"hwsg","command":"uptime"}`) | Hub 正确通过 WebSocket 寻址到远程 Agent,Agent 执行并原路返回,耗时增加在 RTT 合理范围内。 | 跨机实测 | +| **TC-10** | **Web 管理面板嵌入** | 浏览器直接访问 `GET /` | 页面秒开,设备列表、ChatGPT 向导、审计日志正常渲染,无任何外部 CDN 404,无 node 构建产物遗留。 | 浏览器/Headless 验证 | +| **TC-11** | **审计日志完备性** | 执行一次正常命令与一次失败命令 | 查询 SQLite `audit_logs` 表,两条记录均完整存在(含 IP、设备、指令、退出码、耗时),字段无空缺。 | SQLite 查询比对 | +| **TC-12** | **内存基线压测** | 连续并发执行 100 次 `ps aux` | 压测结束后,Hub RSS 必须 ≤ 25MB;20MB 为优化目标。压测结束后应回落并稳定,无持续增长趋势。 | `ps -eo rss` 采样 | + +--- + +## 三、 严格收尾与交付规范 (Sign-off Checklist) + +GPT 完成开发提交代码后,由我执行以下严苛收尾工作: +1. **代码审计 (Code Review)**: + - 检查并发安全性(互斥锁 `sync.RWMutex` 是否合理、Channel 是否有阻塞泄漏风险)。 + - 检查外部输入 Shell 命令的执行上下文,是否存在注入或不可控行为。 +2. **构建与打包 (Clean Build)**: + - 生成标准的 `Makefile`,支持编译 Linux AMD64/ARM64 静态无依赖产物。 + - 清理所有中间编译缓存与临时测试文件。 +3. **部署托管与 Systemd 固化**: + - 为 Hub 编写规范的 `vps-commander-hub.service`。 + - Lucky 反代添加规则及 SSL 校验。 +4. **终验报告与归档**: + - 输出完整的《VPS-Commander 最终验收合格报告》。 + - 将完整配置参数与使用说明同步至长期记忆 `MEMORY.md`。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..51b3733 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,148 @@ +# VPS-Commander(自托管私有化多节点运维管家)架构与开发设计规范 + +## 1. 项目立项背景与目标 +### 1.1 背景与痛点 +- 当前市面方案(如 Desktop Commander)严重依赖公共第三方中继(SaaS Relay),存在严重的命令与数据泄漏风险、Token 明文暴露风险以及第三方额度/速率限制。 +- 现有纯开发网关(如 CodePier)采用项目目录沙箱隔离,无法进行系统级运维(如查看全局进程、管理 Lucky/Docker、修改全盘系统配置等),且不支持 ChatGPT 原生 Actions 插件生态。 + +### 1.2 核心目标 +- **100% 私有化部署**:数据仅在 OpenAI 官方服务器和用户私有 VPS 之间传输,零第三方中转。 +- **全权宿主运维 (Host-centric)**:提供全局 Shell、进程管理、文件读写能力。 +- **多节点别名调度**:通过单一 Hub 统一管理多台 VPS/客户端节点,支持在 ChatGPT 中通过自然语言昵称(如 "hwsg"、"wjyhk")随时切换目标节点。 +- **原生 ChatGPT 插件集成**:提供标准 OpenAPI 3.1 规范与 Actions 端点,在 ChatGPT 手机端/网页端呈现为原生交互卡片。 +- **超轻量 Go 架构**:Hub 常驻 RSS 目标 ≤ 20MB、硬上限 25MB;Agent 常驻 RSS 目标 ≤ 10MB、硬上限 12MB。 + +--- + +## 2. 总体架构设计 + +```text +[ 用户在 ChatGPT APP / Web 端发指令 ] + │ + ▼ (HTTPS POST + Bearer API Key) + [ 用户私有域名反代 (Lucky SSL) ] + │ + ▼ (反向代理至 127.0.0.1:28800) + ┌────────────────────────────────────────────────────────┐ + │ VPS-Commander Hub (中央管理网关) │ + │ │ + │ ├─ OpenAPI / Actions Engine (/openapi.json, /api/v1) │ + │ ├─ 鉴权与安全过滤中间件 (Auth Middleware) │ + │ ├─ 设备拓扑注册表 (Device Registry & State Machine) │ + │ ├─ 审计日志存储 (SQLite: audit.db) │ + │ └─ 路由分发器 (Router Dispatcher) │ + └───────────┬────────────────────────────────┬───────────┘ + │ │ + (本地进程直连: target="local") (安全 WebSocket 长连接 / mTLS) + │ │ + ▼ ▼ + [ 本机 Local Executor ] [ 远程 Agent (Node: hwsg) ] + - bash -c ... - Client Agent Daemon + - 文件/进程直接操作 - 接收 JSON-RPC 指令并执行 +``` + +--- + +## 3. 核心接口与协议规范 (OpenAPI 3.1) + +ChatGPT Actions 识别的标准接口集合: + +### 3.1 `/api/v1/devices` (GET) +- **说明**:获取当前所有在线/受管设备列表及健康状态。 +- **返回**:`[{"name": "wjyhk", "is_local": true, "status": "online"}, {"name": "hwsg", "status": "online"}]` + +### 3.2 `/api/v1/exec` (POST) +- **说明**:在指定节点执行 Shell 命令。 +- **请求体**: + ```json + { + "device": "hwsg", // 节点昵称,缺省默认 local / wjyhk + "command": "free -h", // 要执行的命令 + "workdir": "/root", // 可选工作目录 + "timeout": 30 // 超时时间(秒) + } + ``` +- **响应体**: + ```json + { + "device": "hwsg", + "exit_code": 0, + "stdout": "...", + "stderr": "", + "duration_ms": 120 + } + ``` + +### 3.3 `/api/v1/fs/read` (POST) +- **说明**:受控读取指定设备的文件内容。 +- **参数**:`device`, `path`, `offset_bytes`, `limit_bytes`(防止大文件撑爆模型上下文)。 + +### 3.4 `/api/v1/fs/write` (POST) +- **说明**:在指定设备创建或覆写文件。 +- **参数**:`device`, `path`, `content`。 + +--- + +## 4. Hub 与 Agent 节点通信规范 + +1. **Agent 注册与鉴权**: + - Agent 启动时通过 `wss://hub.domain.com/agent/ws` 发起长连接。 + - 握手使用 `Authorization: Bearer ` Header;Cluster Secret 不进入 URL。 + - 握手成功后,Hub 内存注册表更新该设备为 `online`,并启动双向 Heartbeat ping/pong(30s 周期)。 + +2. **指令下发 (JSON-RPC)**: + - Hub 收到 ChatGPT 请求 ➡️ 生成 `task_id` ➡️ 通过 WS 发送 `{"action": "exec", "task_id": "...", "command": "..."}`。 + - Agent 执行完毕后返回 `{"task_id": "...", "result": {...}}`。 + - Hub 审计日志落库后返回给 ChatGPT。 + +--- + +## 5. 项目工程结构与技术选型 + +- **开发语言**:Go 1.22+(高性能、低内存、单二进制交叉编译友好) +- **Web 框架**:Gin 或纯标准库 `net/http` + `gorilla/websocket` +- **存储**:SQLite (纯粹用于审计日志与持久化配置) + +``` +vps-commander/ +├── cmd/ +│ ├── hub/ # 中央控制网关主入口 +│ └── agent/ # 节点客户端主入口 +├── internal/ +│ ├── api/ # ChatGPT Actions HTTP 接口 +│ ├── auth/ # Bearer Token 与 Secret 鉴权 +│ ├── cluster/ # 节点连接池、心跳与 WebSocket 管理 +│ ├── executor/ # 本地/远程执行器抽象 +│ └── storage/ # 审计日志与设备配置持久化 +├── web/ # 极简设备状态面板(纯 HTML/CSS 嵌入二进制) +├── openapi/ # 供 ChatGPT 导入的 openapi.json +├── Makefile # 多架构编译 (linux/amd64, linux/arm64) +└── README.md # 部署与 ChatGPT 配置指南 +``` + +--- + +## 6. Hub Web 控制面板设计规范 + +### 6.1 定位与设计原则 +- **极简独立**:前端采用纯 HTML5 + TailwindCSS/原生CSS + Vanilla JS,不引入复杂 node 构建工具链,通过 Go 1.16+ `embed.FS` 直接打包进单一二进制产物。 +- **开箱即用**:零外部静态资源依赖,访问 `https://hub.yourdomain.com/` 即可直接加载。 +- **低资源消耗**:Web 面板由 Hub 服务原生托管,静态文件常驻开销基本为零。 + +### 6.2 核心功能模块 +1. **设备看板 (Device Overview)** + - 受管设备列表:别名(Alias)、当前状态(Online/Offline)、系统架构与内核、IP 地址、最后心跳(Heartbeat)。 + - 快速连接指引:点击“添加节点”,自动生成带 Token 的单行一键安装/运行命令(如 `curl -sL https://... | bash -s -- --hub=... --token=...`)。 + - 设备启停与踢出操作:支持在面板主动注销失效/弃用节点。 + +2. **ChatGPT Actions 配置与向导中心** + - 托管与展示 `/openapi.json` 规范预览。 + - 提供专属 API Key 生成、轮换与撤销功能。 + - 提供直接导入 ChatGPT Custom GPT 的一键复制 Schema 按钮与预设 System Prompt 建议。 + +3. **实时操作审计与日志 (Audit Log)** + - 记录来源(ChatGPT / Web 手动)、目标设备、动作(exec / file_read / file_write)、执行内容、执行耗时、退出码与结果摘要。 + - 支持按设备、关键字实时搜索过滤。 + +4. **Web 快速调试终端 (Quick Terminal / Console)** + - 提供快速测试输入框,选择目标设备后可直接输入命令测试执行并查看即时输出,无需打开 ChatGPT 即可验证节点联通性。 diff --git a/docs/DEV_SPEC.md b/docs/DEV_SPEC.md new file mode 100644 index 0000000..8594334 --- /dev/null +++ b/docs/DEV_SPEC.md @@ -0,0 +1,119 @@ +# VPS-Commander 详细技术规格与开发实施方案 (v1.1.0) + +## 一、 系统架构角色划分 + +整个系统划分为两个独立二进制产物(单一代码库仓库编译): +1. **`vps-commander-hub` (中央网关 + 控制面板)**: + - 部署在有公网域名或反代的主机上(当前主力机 `wjyhk`)。 + - 监听端口:`127.0.0.1:9521`(由 Lucky 负责 TLS 证书卸载与反代)。 + - 暴露端点: + - 面向 ChatGPT Actions:`/api/v1/*`(Bearer Token 鉴权) + - 面向 Agent 长连接:`/agent/ws`(Cluster Secret 握手) + - 面向用户极简管理面板:`/` 及 `/static/*`(Go embed 静态资源,支持密码登录/Cookie鉴权) + - 描述文件:`/openapi.json` + - 本身内置 Local Executor(直接代表本地主力机,设备名:`local` 或自定义主机名)。 +2. **`vps-commander-agent` (节点轻量客户端)**: + - 部署在其他受管节点(如 `hwsg` 等)。 + - 主动向 Hub 发起安全 WebSocket 连接(支持断线指数退避重连)。 + - 纯受控执行器,只响应 Hub 下发指令,不开放任何公网入站端口(彻底规避防火墙/NAT问题)。 + +--- + +## 二、 协议设计 (RPC over WebSocket) + +### 2.1 握手与注册 (Handshake) +Agent 连接 URL: `wss://hub.domain.com/agent/ws?device_name=hwsg` +- Hub 校验通过后: + - 发送握手成功包:`{"event": "registered", "device": "hwsg", "heartbeat_interval": 30}` + - 将该连接注入 `DeviceManager`,状态标为 `online`。 + +### 2.2 心跳 (Ping/Pong) +- 每 30 秒由 Hub 发送 `{"event": "ping", "ts": 1727049600}`。 +- Agent 回复 `{"event": "pong", "ts": 1727049600}`。连续 2 次无响应标记为 `offline`。 + +### 2.3 命令下发与响应 (Request-Response) +- Hub 下发: + ```json + { + "id": "task_6b7a8c9d", + "action": "exec", + "payload": { + "command": "free -h", + "workdir": "/root", + "timeout": 30 + } + } + ``` +- Agent 执行完返回: + ```json + { + "id": "task_6b7a8c9d", + "status": "success", + "exit_code": 0, + "stdout": "...", + "stderr": "", + "duration_ms": 45 + } + ``` + +--- + +## 三、 ChatGPT Actions (OpenAPI 3.1) 接口定义 + +完整的 `openapi.json` 规范定义了以下 4 个原子能力: + +1. **`list_devices`**: + - `GET /api/v1/devices` + - 返回受管设备名、IP、状态(online/offline)、最后心跳。 +2. **`exec_command`**: + - `POST /api/v1/exec` + - 参数:`device` (默认: "local"), `command`, `workdir`, `timeout`。 +3. **`read_file`**: + - `POST /api/v1/file/read` + - 参数:`device`, `path`, `offset`, `limit`。 +4. **`write_file`**: + - `POST /api/v1/file/write` + - 参数:`device`, `path`, `content`。 + +--- + +## 四、 Hub Web 控制面板功能规范 + +### 4.1 技术实现 +- 单二进制静态内嵌:基于 Go `embed.FS` 打包 HTML5 + TailwindCSS + Vanilla JS。 +- 零外部 npm/node 依赖,开箱即用。 + +### 4.2 功能矩阵 +1. **设备看板 (Devices Dashboard)**: + - 节点列表展示(名称、IP、状态、架构/系统版本、最后活跃时间)。 + - 一键生成 Agent 安装注册命令(带 Token 参数)。 + - 手动注销/踢出无效节点。 +2. **ChatGPT Actions 向导 (Actions Guide)**: + - 动态渲染 `openapi.json` 并提供一键复制。 + - API Key 凭据生成、轮换与撤销。 + - 预设 System Prompt 最佳实践一键复制。 +3. **安全审计追踪 (Audit Trails)**: + - 实时日志流水:时间戳、来源 IP、目标节点、操作类型、执行命令、执行耗时、退出状态。 + - 支持日志按设备名、时间区间筛选。 +4. **快速网页终端 (Quick Console)**: + - 应急测试控制台,免切 ChatGPT 直接在 Web 端选择节点跑指令验证联通性。 + +--- + +## 五、 存储与安全审计 (SQLite) + +- 数据库文件:`data/commander.db` +- 数据表结构: + 1. `devices`:记录设备名、备注、添加时间、密钥 Hash。 + 2. `audit_logs`: + - `id`, `timestamp`, `caller_ip`, `device`, `action`, `command`, `exit_code`, `duration_ms`, `error_msg` + - 确保在 ChatGPT 里执行的所有高危操作均可追溯。 + +--- + +## 六、 开发实施里程碑 (Milestones) + +- [ ] **M1: 基础骨架与单机执行**(完成本地 Local Executor + Gin API + Bearer 鉴权 + `/openapi.json`) +- [ ] **M2: Agent WebSocket 协议与多节点分发**(完成 Agent 注册、心跳保活、Hub 路由转发) +- [ ] **M3: 审计日志与嵌入式 Web 管理页**(完成前端内嵌、看板交互、命令历史审计与快速控制台) +- [ ] **M4: 本机 Lucky 证书反代与 ChatGPT 联调上线** diff --git a/docs/M4_FINAL_ACCEPTANCE_REPORT.md b/docs/M4_FINAL_ACCEPTANCE_REPORT.md new file mode 100644 index 0000000..faaeeef --- /dev/null +++ b/docs/M4_FINAL_ACCEPTANCE_REPORT.md @@ -0,0 +1,27 @@ +# VPS-Commander M4 最终验收报告(待人工签收) + +日期:2026-09-28 +状态:🟡 技术收尾完成,等待 Custom GPT Actions 人工签收 + +## 已完成 + +- Hub/Agent systemd 固化,Hub 监听 `127.0.0.1:9521`。 +- Lucky 正式 HTTPS:`https://vpstool.kory.kdns.fr`。 +- OpenAPI 3.1.0,4 个 Actions path,统一 `bearerAuth`。 +- Hub 敏感凭据仅允许通过环境变量注入,禁止 CLI 参数。 +- Cluster Secret 仅通过 WebSocket Authorization Header,不进入 URL。 +- 单一 `m4-live-agent` 正式实例通过 WSS 注册在线。 +- 本机执行、远程执行、文件读取、文件写入、超时回收、401 鉴权均完成真实回归。 +- SQLite 审计链路已确认记录请求。 +- 临时凭据文件已清理,生产环境凭据文件权限为 0600。 +- Hub RSS 当前约 14.5MB;Agent 经 `GOGC=50` 优化后约 10.5MB,均低于 25MB/12MB 硬上限;20MB/10MB 仍为优化目标。 + +## 尚未签署 + +用户仍需在 Custom GPT 中: +1. 导入 `https://vpstool.kory.kdns.fr/openapi.json`。 +2. 配置 Bearer API Key。 +3. 实测设备列表、本机 exec、远程 exec、文件读写和错误/超时。 +4. 完成后由用户人工确认 M4 通过。 + +在此之前,本报告不得视为 Release Ready / M4 Passed。 diff --git a/docs/MCP_INTEGRATION.md b/docs/MCP_INTEGRATION.md new file mode 100644 index 0000000..52e8330 --- /dev/null +++ b/docs/MCP_INTEGRATION.md @@ -0,0 +1,50 @@ +# VPS-Commander MCP (Model Context Protocol) 接入指南 + +## 一、概述 +为了应对 OpenAI Custom GPTs 退役及新一代 Plugins/Skills 规范转型,VPS-Commander 现已全面支持标准 **MCP (Model Context Protocol)** 协议。 + +- **零侵入**:原有 REST API、OpenAPI 3.1、Web 面板及 WebSocket 多节点连接池 100% 保持不变。 +- **纯 Go 原生**:零外部庞大框架依赖,常驻内存基线依然保持在极低水平。 +- **双模支持**:同时支持 **远程 SSE 模式** 与 **本地 stdio 模式**。 + +--- + +## 二、支持的 4 大 MCP Tools + +| Tool 名称 | 参数说明 | 描述 | +| :--- | :--- | :--- | +| `list_devices` | 无 | 列出当前所有已注册的受控 VPS 节点(如本机 `wjyhk` 及远程 `m4-live-agent` 等)及在线状态 | +| `exec_command` | `device` (可选), `command` (必填), `workdir` (可选), `timeout` (可选, 默认30) | 在指定受控节点执行任意 Shell 命令并返回退出码与输出 | +| `read_file` | `device` (可选), `path` (必填), `offset` (可选), `limit` (可选) | 读取指定受控节点的文件内容(支持安全分页读取) | +| `write_file` | `device` (可选), `path` (必填), `content` (必填) | 在指定受控节点创建或覆写文件 | + +--- + +## 三、接入方式 + +### 方式 1:远程 SSE 模式(适用于远程 Agent、OpenAI 新生态端点) + +Hub 原生提供 MCP SSE 端点(需带 Bearer Token 鉴权): +- **SSE 建立端点**:`GET https://vpstool.kory.kdns.fr/mcp/sse` +- **消息交互端点**:`POST https://vpstool.kory.kdns.fr/mcp/message?sessionId=` +- **请求头**:`Authorization: Bearer ` + +### 方式 2:本地 stdio 模式(适用于 Claude Desktop、Cursor、VS Code) + +直接使用编译好的极轻量 Go 二进制 `vps-commander-mcp-stdio-linux-amd64`。 + +#### 客户端配置示例 (`claude_desktop_config.json` 或 Cursor MCP 设置): + +```json +{ + "mcpServers": { + "vps-commander": { + "command": "/root/.openclaw/workspace/vps-commander/bin/vps-commander-mcp-stdio-linux-amd64", + "args": [ + "-hub", "https://vpstool.kory.kdns.fr", + "-key", "YOUR_API_KEY" + ] + } + } +} +``` diff --git a/docs/PROGRESS_TRACKING.md b/docs/PROGRESS_TRACKING.md new file mode 100644 index 0000000..bc31ccc --- /dev/null +++ b/docs/PROGRESS_TRACKING.md @@ -0,0 +1,113 @@ +# VPS-Commander 开发进度看板与交付清单 + +> 角色定位: +> - **开发实施**:外部 GPT +> - **架构监督、文档规范、验收审核与严格收尾**:OpenClaw (yk) + +--- + +## 阶段推进概览 (Status Board) + +| 阶段 | 模块 / 目标 | 负责人 | 状态 | 交付物检验 | +| :--- | :--- | :--- | :--- | :--- | +| **Phase 0** | 架构设计、接口契约与开发文档 | OpenClaw | 🟢 **已完成 (Done)** | `ARCHITECTURE.md`, `DEV_SPEC.md` | +| **Phase 1** | Hub 核心骨架、Local Executor 与 Actions 引擎 | GPT (开发) | 🟢 **验收通过 (Passed)** | Hub 本地执行、Bearer 鉴权、`/openapi.json` | +| **Phase 2** | Agent 通信协议与多节点分发 (WebSocket RPC) | GPT (开发) | 🟢 **验收通过 (Passed)** | 5项生产级缺陷彻底整改;并发竞态/强杀0 Panic/Header鉴权实测全绿 | +| **Phase 3** | 内嵌 Web 控制面板与 SQLite 审计模块 | GPT (开发) | 🟢 **验收通过 (Passed)** | CSS MIME、SQLite 并发审计丢失缺陷已修复;100 并发无损落盘、-race、浏览器 MIME 与会话权限复验全绿 | +| **Phase 4** | 独立审核、严苛回归测试与上线收尾 | OpenClaw | 🟡 **收尾整改中 (Hardening)** | 技术门禁基本全绿;待 Custom GPT Actions、Key 轮换与人工签收 | + +--- + +## 详细功能检查清单 (Feature Checklist for GPT) + +### 📌 Milestone 1: Hub 基础与单机 Actions (Phase 1) +- [x] **项目骨架构建**: + - [x] Go 模块初始化 (`go.mod`, module `github.com/wjyhk/vps-commander`) + - [x] 目录分层清晰(`cmd/hub`, `internal/api`, `internal/executor`, `internal/auth`, `internal/storage`) +- [x] **安全鉴权中间件**: + - [x] 校验 `Authorization: Bearer ` (ConstantTimeCompare) + - [x] 无效/缺失 Token 统一返回标准 HTTP 401 +- [x] **Local Executor (本地执行器)**: + - [x] 执行 Shell 命令(`exec.CommandContext` 绑定超时) + - [x] 捕获 `stdout`, `stderr`, `exit_code`, `duration_ms` + - [x] 文件分页安全读取(限制单次最大返回字节,防 OOM) + - [x] 文件安全写入/覆盖 +- [x] **OpenAPI 3.1 动态端点**: + - [x] `GET /openapi.json`:包含 schema 与 BearerAuth + - [x] `GET /api/v1/devices`:返回本地节点信息 + - [x] `POST /api/v1/exec`:执行本地命令 + - [x] `POST /api/v1/file/read`:读取本地文件 + - [x] `POST /api/v1/file/write`:写入本地文件 + +--- + +### 📌 Milestone 2: Agent WebSocket 协议与多节点路由 (Phase 2) + +> **2026-09-23 生产级 Code Review 驳回:** 原实现不得直接投入生产。整改项:① pending response channel 竞态导致 send on closed channel;② Cluster Secret 禁止出现在 WebSocket URL Query,改用 Authorization Bearer Header;③ Agent 指令处理改为并发 Goroutine,避免长任务阻塞读循环;④ Agent 增加主动 ping、ReadDeadline,防止半开连接僵尸;⑤ Hub/Agent WebSocket 写入统一设置 10s WriteDeadline;同时将 LastSeen 改为原子时间戳,消除竞态数据访问。Phase 3 在本项重新验收前保持阻塞。 +- [x] **Agent 二进制开发 (`cmd/agent`)**: + - [x] 读取配置文件或 CLI 参数(`--hub`, `--name`, `--token`) + - [x] 主动向 Hub 发起 WSS 连接(`wss:///agent/ws`) + - [x] Cluster Secret 通过 WebSocket Authorization Bearer Header 发送,不进入 URL Query + - [x] 指令处理采用独立 Goroutine,并由单一写锁保护并发响应 + - [x] 主动 30s ping + 75s ReadDeadline,防止半开连接永久阻塞 + - [x] WebSocket 写入统一 10s WriteDeadline + - [x] 指数退避断线重连(1s, 2s, 4s... 最大 30s) +- [x] **Hub 节点连接池 (`internal/cluster`)**: + - [x] WebSocket 握手鉴权(校验 Cluster Secret,凭据仅走 Authorization Bearer Header) + - [x] Pending RPC 响应使用 LoadAndDelete + 非阻塞投递,断线不再 close response channel + - [x] Hub ReadDeadline / WriteDeadline(75s / 10s) + - [x] LastSeen 使用 atomic.Int64,避免读写竞态 + - [x] 设备连接注册表(维护设备别名、WS 连接、心跳时间) + - [x] 定时双向 Ping/Pong(30s 周期,超时 2 次标记离线) +- [x] **RPC 路由与跨节点下发**: + - [x] 当 API 请求 `device == "local"` 或本机别名时 ➡️ 走 Local Executor + - [x] 当 API 请求 `device == ""` 时 ➡️ 查找连接池,封装 JSON-RPC 消息异步等待回调 + - [x] 设备不在线或找不到时,返回友好错误:“设备 `` 处于离线状态” + +--- + +### 📌 Milestone 3: 内嵌 Web 控制面板与审计存储 (Phase 3) + +> **2026-09-23 M3 门禁状态:🟢 验收通过 (Passed)。** 代码级修复了静态 CSS 后缀判断错误与 SQLite WAL 单写队列;经 100 并发突发请求压测,SQLite 审计日志 100% 无损落盘,Session 认证、MIME 判定、静态内嵌与看板全绿通过。 +- [x] **SQLite 存储 (`internal/storage`)**: + - [x] 自动初始化数据库 `commander.db`,开启 WAL 模式与 busy_timeout + - [x] 每次执行写入 `audit_logs`(记录触发者 IP、设备、指令、退出码、耗时) +- [x] **Web 控制面板 (`web/`)**: + - [x] 纯 HTML5 + 原生 CSS 单页面,使用 `embed.FS` 打包(零外部静态资源) + - [x] **看板 1**:节点在线状态卡片、系统基础信息 + - [x] **看板 2**:ChatGPT Actions 向导(一键复制 Schema、Prompt 模板、Key 轮换) + - [x] **看板 3**:审计日志流水与刷新查询 + - [x] **看板 4**:Web 快速调试执行器(选择设备并执行命令) + +--- + +### 📌 Milestone 4: 审核、收尾与交付 (Phase 4, 由 OpenClaw 严格把关) + +> **2026-09-23 M3 正式验收通过,Phase 4 已解锁。** 本阶段进入独立总装门禁;未经全部回归、内存基线、上线链路与最终文档验收,不得标记为完成。 +- [x] 代码静态分析与编译:\`go vet ./...\`、\`go test -race ./...\`、AMD64/ARM64 clean build 全通过 +- [x] 内存硬上限回归:Hub 当前约 14.5MB;Agent 经 `GOGC=50` 优化后约 10.5MB,均低于 25MB/12MB 硬上限;20MB/10MB 仍作为优化目标保留 +- [x] 12 项原子功能:TC-01~TC-07、TC-09、TC-11 已实测;TC-08 离线感知实测通过;TC-12 100 并发已实测且审计 100% 落盘;TC-10 页面/MIME 已在 M3 浏览器实测 +- [x] Lucky 反代与正式域名 HTTPS:vpstool.kory.kdns.fr 已实测反代至本机 127.0.0.1:9521;根路径 302 到 /panel/login,OpenAPI 3.1.0 经 HTTPS 实链可访问,WebSocket Agent 已通过该上游注册在线 +- [ ] ChatGPT Custom GPT 真实联调通过并归档 + +### M4 收尾整改记录(2026-09-28) +- [x] 修正文档与实现漂移:正式 Hub 监听端口统一为 `127.0.0.1:9521`;Agent 握手统一为 `Authorization: Bearer` Header,Cluster Secret 不进入 URL。 +- [x] Hub 禁止通过 CLI 参数接收 API Key、Cluster Secret、Web Password;三项敏感凭据现在仅从环境变量读取,避免进入 `/proc/*/cmdline`。 +- [x] 统一内存门禁口径:Hub 25MB、Agent 12MB 为硬上限;20MB/10MB 为优化目标。 +- [x] 清理遗留测试 Agent;正式 Hub/Agent 已由 systemd 单实例接管,并完成单实例远程 Agent 实联调。 +- [ ] Custom GPT Actions 真实调用、Key 轮换、审计闭环与最终人工签收。 + +### M4 2026-09-28 实联调证据 +- [x] Hub/Agent 已切换为 systemd 管理;Hub 只监听 `127.0.0.1:9521`。 +- [x] Hub 敏感凭据不再接受 CLI 参数,仅从 `EnvironmentFile` 环境变量读取;进程命令行已核验无凭据。 +- [x] Agent 单实例:仅保留 `m4-live-agent` 一个正式实例;通过 `wss://vpstool.kory.kdns.fr/agent/ws` 成功注册 online。 +- [x] VPS-Commander 实联调:本机 exec、远程 Agent exec、远程 file read、远程 file write、timeout=1s 均通过。 +- [x] 未授权 HTTP 请求返回 401;有效 Bearer 请求返回 200。 +- [x] 审计日志已记录上述 Actions 操作,最新抽查包含 `file_read` 与 timeout=124。 +- [x] 临时凭据文件 `/tmp/m4-prod.env` 已删除;`/etc/vps-commander/{hub,agent}.env` 权限均为 `0600`。 +- [ ] Custom GPT Actions UI 仍需用户侧完成正式 OpenAPI 导入、Bearer Key 配置及对话调用;未完成前不得签署 M4。 + +### M4 终验签收记录(2026-09-28) +- [x] 安全清理:已彻底清除 `openapi.json` 中冗余残留的 `apiKey` 定义,统一收敛为标准 `bearerAuth`。 +- [x] 二次回归验证通过:全链路 API 鉴权、受控节点列表获取及本地命令执行均现场实测 100% PASS。 +- [x] 服务状态确认:`vps-commander-hub` (PID: systemd) 运行稳定,二进制构建已同步更新。 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9a1b927 --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module github.com/wjyhk/vps-commander + +go 1.24.0 + +toolchain go1.24.4 + +require ( + github.com/gorilla/websocket v1.5.3 + modernc.org/sqlite v1.39.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sys v0.36.0 // indirect + modernc.org/libc v1.66.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..00871b4 --- /dev/null +++ b/go.sum @@ -0,0 +1,51 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= +modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= +modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4= +modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..76b7894 --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,319 @@ +package api + +import ( + "context" + "embed" + "encoding/json" + "io" + "net" + "net/http" + "os" + "runtime" + "strconv" + "strings" + "time" + + "github.com/wjyhk/vps-commander/internal/auth" + "github.com/wjyhk/vps-commander/internal/cluster" + "github.com/wjyhk/vps-commander/internal/executor" + "github.com/wjyhk/vps-commander/internal/model" + "github.com/wjyhk/vps-commander/internal/storage" + panelweb "github.com/wjyhk/vps-commander/internal/web" +) + +//go:embed openapi.json +var openAPIFS embed.FS + +const ( + defaultFileLimit int64 = 256 * 1024 + maxFileLimit int64 = 1024 * 1024 +) + +type Server struct { + Auth *auth.Manager + Exec executor.Local + Cluster *cluster.Manager + Store *storage.Store + LocalName string +} + +func (s *Server) Panel(password string) http.Handler { + p := panelweb.New(password, s) + go p.Cleanup() + return p.Handler() +} + +func (s *Server) DevicesJSON(w http.ResponseWriter, r *http.Request) { s.devices(w, r) } +func (s *Server) PanelExec(w http.ResponseWriter, r *http.Request) { s.exec(w, r) } + +func (s *Server) RotateAPIKey(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + if s.Auth == nil { + http.Error(w, "auth manager unavailable", 500) + return + } + jsonOut(w, map[string]string{"api_key": s.Auth.Rotate()}) +} + +func (s *Server) AuditsJSON(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", 405) + return + } + limit := 100 + if v, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && v > 0 { + limit = v + } + logs, err := s.Store.RecentAudits(limit) + if err != nil { + http.Error(w, "audit query failed", 500) + return + } + jsonOut(w, logs) +} + +func (s *Server) Routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/healthz", s.health) + mux.HandleFunc("/api/v1/devices", s.devices) + mux.HandleFunc("/api/v1/exec", s.exec) + mux.HandleFunc("/api/v1/file/read", s.fileRead) + mux.HandleFunc("/api/v1/file/write", s.fileWrite) + mux.HandleFunc("/agent/ws", s.agentWS) + mux.HandleFunc("/openapi.json", s.openapi) + return mux +} +func jsonOut(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + +func (s *Server) health(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", 405) + return + } + jsonOut(w, map[string]string{"status": "ok"}) +} + +func (s *Server) devices(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", 405) + return + } + out := []model.Device{{Name: s.LocalName, Status: "online", Local: true, Arch: runtime.GOARCH, OS: runtime.GOOS}} + if s.Cluster != nil { + for _, d := range s.Cluster.Devices() { + b, _ := json.Marshal(d) + var v model.Device + _ = json.Unmarshal(b, &v) + out = append(out, v) + } + } + jsonOut(w, out) +} + +func (s *Server) agentWS(w http.ResponseWriter, r *http.Request) { + if s.Cluster == nil { + http.Error(w, "cluster unavailable", 503) + return + } + s.Cluster.ServeWS(w, r) +} + +func (s *Server) isRemote(device string) bool { + return device != "" && device != "local" && device != s.LocalName +} +func (s *Server) exec(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + var req model.ExecRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024)).Decode(&req); err != nil { + http.Error(w, "bad json", 400) + return + } + if strings.TrimSpace(req.Command) == "" { + http.Error(w, "command required", 400) + return + } + if req.Device == "" { + req.Device = s.LocalName + } + if req.Timeout <= 0 { + req.Timeout = 30 + } + if req.Timeout > 300 { + req.Timeout = 300 + } + ctx, cancel := context.WithTimeout(r.Context(), time.Duration(req.Timeout)*time.Second) + defer cancel() + var res executor.Result + if s.isRemote(req.Device) { + if s.Cluster == nil || !s.Cluster.Online(req.Device) { + http.Error(w, "device offline", 404) + return + } + rr, err := s.Cluster.Exec(ctx, req.Device, cluster.ExecPayload{Command: req.Command, Workdir: req.Workdir, Timeout: req.Timeout}) + if err != nil { + http.Error(w, err.Error(), 502) + return + } + res = executor.Result{ExitCode: rr.ExitCode, Stdout: rr.Stdout, Stderr: rr.Stderr, DurationMS: rr.DurationMS} + } else { + res = s.Exec.Run(ctx, req.Command, req.Workdir) + } + if err := s.Store.Audit(clientIP(r), req.Device, "exec", req.Command, res.ExitCode, res.DurationMS, res.Stderr); err != nil { + http.Error(w, "audit write failed", 500) + return + } + jsonOut(w, model.ExecResponse{Device: req.Device, ExitCode: res.ExitCode, Stdout: res.Stdout, Stderr: res.Stderr, DurationMS: res.DurationMS}) +} +func (s *Server) fileRead(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + var req model.FileReadRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16*1024)).Decode(&req); err != nil { + http.Error(w, "bad json", 400) + return + } + if req.Device == "" { + req.Device = s.LocalName + } + if req.Path == "" || !validPath(req.Path) || req.Offset < 0 { + http.Error(w, "invalid path or offset", 400) + return + } + if req.Limit <= 0 { + req.Limit = defaultFileLimit + } + if req.Limit > maxFileLimit { + req.Limit = maxFileLimit + } + var content string + var n int64 + var eof bool + var totalSize int64 + var hasMore bool + if s.isRemote(req.Device) { + if s.Cluster == nil || !s.Cluster.Online(req.Device) { + http.Error(w, "device offline", 404) + return + } + fr, err := s.Cluster.ReadFile(r.Context(), req.Device, cluster.FileReadPayload{Path: req.Path, Offset: req.Offset, Limit: req.Limit}) + if err != nil { + http.Error(w, err.Error(), 502) + return + } + content, n, totalSize, hasMore, eof = fr.Content, fr.Bytes, fr.TotalSize, fr.HasMore, fr.EOF + } else { + f, err := os.Open(req.Path) + if err != nil { + http.Error(w, "open failed: "+err.Error(), 404) + return + } + defer f.Close() + if _, err = f.Seek(req.Offset, io.SeekStart); err != nil { + http.Error(w, "seek failed: "+err.Error(), 400) + return + } + info, err := f.Stat() + if err != nil { + http.Error(w, "stat failed: "+err.Error(), 500) + return + } + totalSize = info.Size() + buf := make([]byte, req.Limit) + rn, re := io.ReadFull(f, buf) + eof = re == io.EOF || re == io.ErrUnexpectedEOF + if re != nil && !eof { + http.Error(w, "read failed: "+re.Error(), 500) + return + } + content, n = string(buf[:rn]), int64(rn) + hasMore = req.Offset+n < totalSize + } + if err := s.Store.Audit(clientIP(r), req.Device, "file_read", req.Path, 0, 0, ""); err != nil { + http.Error(w, "audit write failed", 500) + return + } + jsonOut(w, model.FileReadResponse{Device: req.Device, Path: req.Path, Offset: req.Offset, Content: content, Bytes: n, TotalSize: totalSize, HasMore: hasMore, EOF: eof}) +} +func (s *Server) fileWrite(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + var req model.FileWriteRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxFileLimit+16*1024)).Decode(&req); err != nil { + http.Error(w, "bad json or content too large", 400) + return + } + if req.Device == "" { + req.Device = s.LocalName + } + if req.Path == "" || !validPath(req.Path) { + http.Error(w, "path required", 400) + return + } + if int64(len(req.Content)) > maxFileLimit { + http.Error(w, "content too large", 413) + return + } + var n int64 + if s.isRemote(req.Device) { + if s.Cluster == nil || !s.Cluster.Online(req.Device) { + http.Error(w, "device offline", 404) + return + } + var err error + n, err = s.Cluster.WriteFile(r.Context(), req.Device, cluster.FileWritePayload{Path: req.Path, Content: req.Content}) + if err != nil { + http.Error(w, err.Error(), 502) + return + } + } else { + if err := os.WriteFile(req.Path, []byte(req.Content), 0644); err != nil { + http.Error(w, "write failed: "+err.Error(), 500) + return + } + n = int64(len(req.Content)) + } + if err := s.Store.Audit(clientIP(r), req.Device, "file_write", req.Path, 0, 0, ""); err != nil { + http.Error(w, "audit write failed", 500) + return + } + jsonOut(w, model.FileWriteResponse{Device: req.Device, Path: req.Path, Bytes: n}) +} + +func validPath(path string) bool { + return !strings.ContainsRune(path, 0) && !strings.HasPrefix(path, "-") +} + +func clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil { + return host + } + return r.RemoteAddr +} + +func (s *Server) openapi(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", 405) + return + } + data, err := openAPIFS.ReadFile("openapi.json") + if err != nil { + http.Error(w, "openapi unavailable", 500) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(data) +} diff --git a/internal/api/api_test.go b/internal/api/api_test.go new file mode 100644 index 0000000..bd290bf --- /dev/null +++ b/internal/api/api_test.go @@ -0,0 +1,50 @@ +package api + +import ( + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/wjyhk/vps-commander/internal/executor" + "github.com/wjyhk/vps-commander/internal/storage" +) + +func TestExecConcurrent100AuditsPersisted(t *testing.T) { + store, err := storage.Open(filepath.Join(t.TempDir(), "commander.db")) + if err != nil { + t.Fatal(err) + } + defer store.DB.Close() + + s := &Server{Exec: executor.Local{MaxOutput: 1024 * 1024}, Store: store, LocalName: "local"} + + const n = 100 + var wg sync.WaitGroup + statuses := make(chan int, n) + wg.Add(n) + for i := 0; i < n; i++ { + go func() { + defer wg.Done() + req := httptest.NewRequest("POST", "/api/v1/exec", strings.NewReader("{\"device\":\"local\",\"command\":\"true\",\"timeout\":10}")) + rec := httptest.NewRecorder() + s.exec(rec, req) + statuses <- rec.Code + }() + } + wg.Wait() + close(statuses) + for status := range statuses { + if status != 200 { + t.Fatalf("unexpected HTTP status: %d", status) + } + } + logs, err := store.RecentAudits(500) + if err != nil { + t.Fatal(err) + } + if len(logs) != n { + t.Fatalf("audit loss: got %d rows, want %d", len(logs), n) + } +} diff --git a/internal/api/mcp_adapter.go b/internal/api/mcp_adapter.go new file mode 100644 index 0000000..f0160fc --- /dev/null +++ b/internal/api/mcp_adapter.go @@ -0,0 +1,190 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "runtime" + "time" + + "github.com/wjyhk/vps-commander/internal/cluster" + "github.com/wjyhk/vps-commander/internal/executor" + "github.com/wjyhk/vps-commander/internal/model" +) + +// MCPBackend implements mcp.ActionBackend +type MCPBackend struct { + server *Server +} + +func (s *Server) NewMCPBackend() *MCPBackend { + return &MCPBackend{server: s} +} + +func (b *MCPBackend) ListDevices(ctx context.Context) (any, error) { + out := []model.Device{{Name: b.server.LocalName, Status: "online", Local: true, Arch: runtime.GOARCH, OS: runtime.GOOS}} + if b.server.Cluster != nil { + for _, d := range b.server.Cluster.Devices() { + data, _ := json.Marshal(d) + var dev model.Device + _ = json.Unmarshal(data, &dev) + out = append(out, dev) + } + } + return out, nil +} + +func (b *MCPBackend) ExecCommand(ctx context.Context, clientIP, device, command, workdir string, timeout int) (any, error) { + if command == "" { + return nil, errors.New("command required") + } + if device == "" { + device = b.server.LocalName + } + if timeout <= 0 { + timeout = 30 + } + if timeout > 300 { + timeout = 300 + } + + execCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) + defer cancel() + + var res executor.Result + if b.server.isRemote(device) { + if b.server.Cluster == nil || !b.server.Cluster.Online(device) { + return nil, fmt.Errorf("device %s is offline", device) + } + rr, err := b.server.Cluster.Exec(execCtx, device, cluster.ExecPayload{Command: command, Workdir: workdir, Timeout: timeout}) + if err != nil { + return nil, err + } + res = executor.Result{ExitCode: rr.ExitCode, Stdout: rr.Stdout, Stderr: rr.Stderr, DurationMS: rr.DurationMS} + } else { + res = b.server.Exec.Run(execCtx, command, workdir) + } + + if b.server.Store != nil { + _ = b.server.Store.Audit(clientIP, device, "exec", command, res.ExitCode, res.DurationMS, res.Stderr) + } + + return model.ExecResponse{ + Device: device, + ExitCode: res.ExitCode, + Stdout: res.Stdout, + Stderr: res.Stderr, + DurationMS: res.DurationMS, + }, nil +} + +func (b *MCPBackend) ReadFile(ctx context.Context, clientIP, device, path string, offset, limit int64) (any, error) { + if device == "" { + device = b.server.LocalName + } + if path == "" || !validPath(path) || offset < 0 { + return nil, errors.New("invalid path or offset") + } + if limit <= 0 { + limit = defaultFileLimit + } + if limit > maxFileLimit { + limit = maxFileLimit + } + + var content string + var n int64 + var eof bool + var totalSize int64 + var hasMore bool + + if b.server.isRemote(device) { + if b.server.Cluster == nil || !b.server.Cluster.Online(device) { + return nil, fmt.Errorf("device %s is offline", device) + } + fr, err := b.server.Cluster.ReadFile(ctx, device, cluster.FileReadPayload{Path: path, Offset: offset, Limit: limit}) + if err != nil { + return nil, err + } + content, n, totalSize, hasMore, eof = fr.Content, fr.Bytes, fr.TotalSize, fr.HasMore, fr.EOF + } else { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open failed: %w", err) + } + defer f.Close() + if _, err = f.Seek(offset, io.SeekStart); err != nil { + return nil, fmt.Errorf("seek failed: %w", err) + } + info, err := f.Stat() + if err != nil { + return nil, fmt.Errorf("stat failed: %w", err) + } + totalSize = info.Size() + buf := make([]byte, limit) + rn, re := io.ReadFull(f, buf) + eof = re == io.EOF || re == io.ErrUnexpectedEOF + if re != nil && !eof { + return nil, fmt.Errorf("read failed: %w", re) + } + content, n = string(buf[:rn]), int64(rn) + hasMore = offset+n < totalSize + } + + if b.server.Store != nil { + _ = b.server.Store.Audit(clientIP, device, "file_read", path, 0, 0, "") + } + + return model.FileReadResponse{ + Device: device, + Path: path, + Offset: offset, + Content: content, + Bytes: n, + TotalSize: totalSize, + HasMore: hasMore, + EOF: eof, + }, nil +} + +func (b *MCPBackend) WriteFile(ctx context.Context, clientIP, device, path, content string) (any, error) { + if device == "" { + device = b.server.LocalName + } + if path == "" || !validPath(path) { + return nil, errors.New("path required") + } + if int64(len(content)) > maxFileLimit { + return nil, errors.New("content too large") + } + + var n int64 + if b.server.isRemote(device) { + if b.server.Cluster == nil || !b.server.Cluster.Online(device) { + return nil, fmt.Errorf("device %s is offline", device) + } + var err error + n, err = b.server.Cluster.WriteFile(ctx, device, cluster.FileWritePayload{Path: path, Content: content}) + if err != nil { + return nil, err + } + } else { + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + return nil, fmt.Errorf("write failed: %w", err) + } + n = int64(len(content)) + } + + if b.server.Store != nil { + _ = b.server.Store.Audit(clientIP, device, "file_write", path, 0, 0, "") + } + + return model.FileWriteResponse{ + Device: device, + Path: path, + Bytes: n, + }, nil +} diff --git a/internal/api/openapi.json b/internal/api/openapi.json new file mode 100644 index 0000000..30768c1 --- /dev/null +++ b/internal/api/openapi.json @@ -0,0 +1,286 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "VPS-Commander", + "version": "1.1.0" + }, + "servers": [ + { + "url": "https://vpstool.kory.kdns.fr" + } + ], + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer" + } + }, + "schemas": { + "Device": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "is_local": { + "type": "boolean" + }, + "arch": { + "type": "string" + }, + "os": { + "type": "string" + } + } + }, + "ExecRequest": { + "type": "object", + "required": [ + "command" + ], + "properties": { + "device": { + "type": "string" + }, + "command": { + "type": "string" + }, + "workdir": { + "type": "string" + }, + "timeout": { + "type": "integer" + } + } + }, + "ExecResponse": { + "type": "object", + "properties": { + "device": { + "type": "string" + }, + "exit_code": { + "type": "integer" + }, + "stdout": { + "type": "string" + }, + "stderr": { + "type": "string" + }, + "duration_ms": { + "type": "integer" + } + } + }, + "FileReadRequest": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "device": { + "type": "string" + }, + "path": { + "type": "string" + }, + "offset": { + "type": "integer" + }, + "limit": { + "type": "integer" + } + } + }, + "FileReadResponse": { + "type": "object", + "properties": { + "device": { + "type": "string" + }, + "path": { + "type": "string" + }, + "offset": { + "type": "integer" + }, + "content": { + "type": "string" + }, + "bytes": { + "type": "integer" + }, + "eof": { + "type": "boolean" + }, + "total_size": { + "type": "integer" + }, + "has_more": { + "type": "boolean" + } + } + }, + "FileWriteRequest": { + "type": "object", + "required": [ + "path", + "content" + ], + "properties": { + "device": { + "type": "string" + }, + "path": { + "type": "string" + }, + "content": { + "type": "string" + } + } + }, + "FileWriteResponse": { + "type": "object", + "properties": { + "device": { + "type": "string" + }, + "path": { + "type": "string" + }, + "bytes": { + "type": "integer" + } + } + } + } + }, + "paths": { + "/api/v1/devices": { + "get": { + "operationId": "list_devices", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Devices", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Device" + } + } + } + } + } + } + } + }, + "/api/v1/exec": { + "post": { + "operationId": "exec_command", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Execution result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecResponse" + } + } + } + } + } + } + }, + "/api/v1/file/read": { + "post": { + "operationId": "read_file", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileReadRequest" + } + } + } + }, + "responses": { + "200": { + "description": "File content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileReadResponse" + } + } + } + } + } + } + }, + "/api/v1/file/write": { + "post": { + "operationId": "write_file", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileWriteRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Write result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileWriteResponse" + } + } + } + } + } + } + } + } +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..d7af3e3 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,76 @@ +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "net/http" + "strings" + "sync" +) + +type Manager struct { + mu sync.RWMutex + token string +} + +func NewManager(token string) *Manager { return &Manager{token: token} } + +func (m *Manager) Token() string { m.mu.RLock(); defer m.mu.RUnlock(); return m.token } + +func (m *Manager) Rotate() string { + b := make([]byte, 24) + if _, err := rand.Read(b); err != nil { + panic(err) + } + t := hex.EncodeToString(b) + m.mu.Lock() + m.token = t + m.mu.Unlock() + return t +} + +type Middleware struct { + Token string + Manager *Manager + Optional bool +} + +func (m Middleware) Handler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got := "" + h := r.Header.Get("Authorization") + if strings.HasPrefix(h, "Bearer ") { + got = strings.TrimSpace(strings.TrimPrefix(h, "Bearer ")) + } else if q := r.URL.Query().Get("token"); q != "" { + got = q + } else if q := r.URL.Query().Get("key"); q != "" { + got = q + } else if q := r.URL.Query().Get("api_key"); q != "" { + got = q + } + + expected := m.Token + if m.Manager != nil { + expected = m.Manager.Token() + } + + if got == "" && m.Optional { + next.ServeHTTP(w, r) + return + } + + if expected == "" || got == "" || subtle.ConstantTimeCompare([]byte(got), []byte(expected)) != 1 { + unauthorized(w) + return + } + next.ServeHTTP(w, r) + }) +} + +func unauthorized(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("WWW-Authenticate", "Bearer realm=\"vps-commander\"") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("{\"error\":\"Unauthorized\"}")) +} diff --git a/internal/cluster/manager.go b/internal/cluster/manager.go new file mode 100644 index 0000000..9979910 --- /dev/null +++ b/internal/cluster/manager.go @@ -0,0 +1,234 @@ +package cluster + +import ( + "context" + "crypto/subtle" + "encoding/json" + "errors" + "fmt" + "net/http" + "sync" + "sync/atomic" + "time" + + "github.com/gorilla/websocket" +) + +type Manager struct { + Secret string + mu sync.RWMutex + nodes map[string]*Node +} + +type Node struct { + Name string + Conn *websocket.Conn + WriteMu sync.Mutex + Pending sync.Map + LastSeen atomic.Int64 +} + +type Result struct { + ExitCode int + Stdout string + Stderr string + DurationMS int64 +} + +type FileResult struct { + Content string `json:"content"` + Bytes int64 `json:"bytes"` + TotalSize int64 `json:"total_size"` + HasMore bool `json:"has_more"` + EOF bool `json:"eof"` +} + +func NewManager(secret string) *Manager { + return &Manager{Secret: secret, nodes: make(map[string]*Node)} +} +func (m *Manager) ServeWS(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + provided := "" + if len(auth) >= 7 && auth[:7] == "Bearer " { + provided = auth[7:] + } + if m.Secret == "" || subtle.ConstantTimeCompare([]byte(provided), []byte(m.Secret)) != 1 { + http.Error(w, "unauthorized", 401) + return + } + name := r.URL.Query().Get("device_name") + if name == "" { + http.Error(w, "device_name required", 400) + return + } + up := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + c, err := up.Upgrade(w, r, nil) + if err != nil { + return + } + n := &Node{Name: name, Conn: c} + n.LastSeen.Store(time.Now().UnixNano()) + m.mu.Lock() + if old := m.nodes[name]; old != nil { + _ = old.Conn.Close() + } + m.nodes[name] = n + m.mu.Unlock() + _ = n.write(Message{Event: "registered", Device: name, TS: time.Now().Unix()}) + go m.heartbeat(n) + go m.readLoop(n) +} + +func (n *Node) write(msg Message) error { + n.WriteMu.Lock() + defer n.WriteMu.Unlock() + if err := n.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second)); err != nil { + return err + } + return n.Conn.WriteJSON(msg) +} + +func (m *Manager) readLoop(n *Node) { + defer func() { + m.mu.Lock() + if m.nodes[n.Name] == n { + delete(m.nodes, n.Name) + } + m.mu.Unlock() + _ = n.Conn.Close() + n.Pending.Range(func(key, v any) bool { + if ch, ok := v.(chan Message); ok { + select { + case ch <- Message{Status: "error", Error: "device disconnected"}: + default: + } + n.Pending.Delete(key) + } + return true + }) + }() + _ = n.Conn.SetReadDeadline(time.Now().Add(75 * time.Second)) + for { + var msg Message + if err := n.Conn.ReadJSON(&msg); err != nil { + return + } + n.LastSeen.Store(time.Now().UnixNano()) + _ = n.Conn.SetReadDeadline(time.Now().Add(75 * time.Second)) + if msg.Event == "pong" || msg.Event == "ping" { + continue + } + if msg.ID != "" { + if v, ok := n.Pending.LoadAndDelete(msg.ID); ok { + if ch, ok := v.(chan Message); ok { + select { + case ch <- msg: + default: + } + } + } + } + } +} +func (m *Manager) Online(name string) bool { + m.mu.RLock() + defer m.mu.RUnlock() + n := m.nodes[name] + return n != nil && time.Since(time.Unix(0, n.LastSeen.Load())) < 70*time.Second +} + +func (m *Manager) Devices() []map[string]any { + m.mu.RLock() + defer m.mu.RUnlock() + out := make([]map[string]any, 0, len(m.nodes)) + for name, n := range m.nodes { + status := "online" + lastSeen := time.Unix(0, n.LastSeen.Load()) + if time.Since(lastSeen) >= 70*time.Second { + status = "offline" + } + out = append(out, map[string]any{"name": name, "status": status, "is_local": false, "last_heartbeat": lastSeen.Unix()}) + } + return out +} + +func (m *Manager) call(ctx context.Context, name, action string, payload any) (Message, error) { + m.mu.RLock() + n := m.nodes[name] + m.mu.RUnlock() + if n == nil || !m.Online(name) { + return Message{}, errors.New("device offline") + } + id := fmt.Sprintf("task_%d", time.Now().UnixNano()) + ch := make(chan Message, 1) + n.Pending.Store(id, ch) + if err := n.write(Message{ID: id, Action: action, Payload: payload}); err != nil { + n.Pending.Delete(id) + return Message{}, err + } + select { + case msg, ok := <-ch: + if !ok { + return Message{}, errors.New("device disconnected") + } + if msg.Status == "error" { + return Message{}, errors.New(msg.Error) + } + return msg, nil + case <-ctx.Done(): + n.Pending.Delete(id) + return Message{}, ctx.Err() + } +} +func (m *Manager) Exec(ctx context.Context, name string, p ExecPayload) (Result, error) { + msg, err := m.call(ctx, name, "exec", p) + if err != nil { + return Result{}, err + } + return Result{msg.ExitCode, msg.Stdout, msg.Stderr, msg.DurationMS}, nil +} + +func (m *Manager) ReadFile(ctx context.Context, name string, p FileReadPayload) (FileResult, error) { + msg, err := m.call(ctx, name, "file_read", p) + if err != nil { + return FileResult{}, err + } + var v FileResult + b, _ := json.Marshal(msg.Payload) + if err := json.Unmarshal(b, &v); err != nil { + return FileResult{}, err + } + return v, nil +} + +func (m *Manager) WriteFile(ctx context.Context, name string, p FileWritePayload) (int64, error) { + msg, err := m.call(ctx, name, "file_write", p) + if err != nil { + return 0, err + } + var v struct { + Bytes int64 `json:"bytes"` + } + b, _ := json.Marshal(msg.Payload) + if err := json.Unmarshal(b, &v); err != nil { + return 0, err + } + return v.Bytes, nil +} + +func (m *Manager) heartbeat(n *Node) { + t := time.NewTicker(30 * time.Second) + defer t.Stop() + for range t.C { + m.mu.RLock() + current := m.nodes[n.Name] == n + m.mu.RUnlock() + if !current { + return + } + if err := n.write(Message{Event: "ping", TS: time.Now().Unix()}); err != nil { + _ = n.Conn.Close() + return + } + } +} diff --git a/internal/cluster/protocol.go b/internal/cluster/protocol.go new file mode 100644 index 0000000..7b6dbe4 --- /dev/null +++ b/internal/cluster/protocol.go @@ -0,0 +1,33 @@ +package cluster + +type Message struct { + ID string `json:"id,omitempty"` + Event string `json:"event,omitempty"` + Action string `json:"action,omitempty"` + Device string `json:"device,omitempty"` + Status string `json:"status,omitempty"` + Payload any `json:"payload,omitempty"` + ExitCode int `json:"exit_code,omitempty"` + Stdout string `json:"stdout,omitempty"` + Stderr string `json:"stderr,omitempty"` + DurationMS int64 `json:"duration_ms,omitempty"` + Error string `json:"error,omitempty"` + TS int64 `json:"ts,omitempty"` +} + +type ExecPayload struct { + Command string `json:"command"` + Workdir string `json:"workdir,omitempty"` + Timeout int `json:"timeout,omitempty"` +} + +type FileReadPayload struct { + Path string `json:"path"` + Offset int64 `json:"offset,omitempty"` + Limit int64 `json:"limit,omitempty"` +} + +type FileWritePayload struct { + Path string `json:"path"` + Content string `json:"content"` +} diff --git a/internal/executor/executor.go b/internal/executor/executor.go new file mode 100644 index 0000000..ec610f3 --- /dev/null +++ b/internal/executor/executor.go @@ -0,0 +1,79 @@ +package executor + +import ( + "bytes" + "context" + "os/exec" + "strings" + "syscall" + "time" +) + +type Result struct { + ExitCode int + Stdout string + Stderr string + DurationMS int64 +} + +type Local struct{ MaxOutput int } + +type cappedBuffer struct { + buf bytes.Buffer + max int +} + +func (b *cappedBuffer) Write(p []byte) (int, error) { + if b.max <= 0 { + return len(p), nil + } + if b.buf.Len() < b.max { + n := b.max - b.buf.Len() + if n > len(p) { + n = len(p) + } + _, _ = b.buf.Write(p[:n]) + } + return len(p), nil +} + +func (e Local) Run(ctx context.Context, command, workdir string) Result { + start := time.Now() + if e.MaxOutput <= 0 { + e.MaxOutput = 1024 * 1024 + } + cmd := exec.Command("/bin/sh", "-c", command) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if workdir != "" { + cmd.Dir = workdir + } + var stdout, stderr cappedBuffer + stdout.max, stderr.max = e.MaxOutput, e.MaxOutput + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Start(); err != nil { + return Result{ExitCode: 1, Stderr: err.Error(), DurationMS: time.Since(start).Milliseconds()} + } + waitCh := make(chan error, 1) + go func() { waitCh <- cmd.Wait() }() + timedOut := false + var err error + select { + case err = <-waitCh: + case <-ctx.Done(): + timedOut = true + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + err = <-waitCh + } + code := 0 + if err != nil { + code = 1 + if ee, ok := err.(*exec.ExitError); ok { + code = ee.ExitCode() + } + } + if timedOut || ctx.Err() != nil { + code = 124 + } + return Result{code, stdout.buf.String(), strings.TrimSpace(stderr.buf.String()), time.Since(start).Milliseconds()} +} diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go new file mode 100644 index 0000000..52f1733 --- /dev/null +++ b/internal/mcp/mcp.go @@ -0,0 +1,382 @@ +package mcp + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + "time" + + "github.com/wjyhk/vps-commander/internal/auth" +) + +// MCP Protocol / JSON-RPC 2.0 Types +type JSONRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +type JSONRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id,omitempty"` + Result any `json:"result,omitempty"` + Error *JSONRPCError `json:"error,omitempty"` +} + +type JSONRPCError struct { + Code int `json:"code"` + Message string `json:"message"` + Data any `json:"data,omitempty"` +} + +// Tool Definition Structures +type Tool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema InputSchema `json:"inputSchema"` +} + +type InputSchema struct { + Type string `json:"type"` + Properties map[string]PropertyDef `json:"properties"` + Required []string `json:"required,omitempty"` +} + +type PropertyDef struct { + Type string `json:"type"` + Description string `json:"description"` +} + +type CallToolParams struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` +} + +type TextContent struct { + Type string `json:"type"` + Text string `json:"text"` +} + +type CallToolResult struct { + Content []TextContent `json:"content"` + IsError bool `json:"isError,omitempty"` +} + +// Backend interface for invoking actions +type ActionBackend interface { + ListDevices(ctx context.Context) (any, error) + ExecCommand(ctx context.Context, clientIP, device, command, workdir string, timeout int) (any, error) + ReadFile(ctx context.Context, clientIP, device, path string, offset, limit int64) (any, error) + WriteFile(ctx context.Context, clientIP, device, path, content string) (any, error) +} + +type Server struct { + Backend ActionBackend + Auth *auth.Manager + + mu sync.RWMutex + sessions map[string]chan []byte +} + +func NewServer(backend ActionBackend, authMgr *auth.Manager) *Server { + return &Server{ + Backend: backend, + Auth: authMgr, + sessions: make(map[string]chan []byte), + } +} + +func (s *Server) newSessionID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// SSE Handler: GET /mcp/sse +func (s *Server) HandleSSE(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + + sessionID := s.newSessionID() + msgCh := make(chan []byte, 64) + + s.mu.Lock() + s.sessions[sessionID] = msgCh + s.mu.Unlock() + + defer func() { + s.mu.Lock() + delete(s.sessions, sessionID) + close(msgCh) + s.mu.Unlock() + }() + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + // Endpoint event telling client where to send POST messages + endpointURI := fmt.Sprintf("/mcp/message?sessionId=%s", sessionID) + fmt.Fprintf(w, "event: endpoint\ndata: %s\n\n", endpointURI) + flusher.Flush() + + notify := r.Context().Done() + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + + for { + select { + case <-notify: + return + case msg, ok := <-msgCh: + if !ok { + return + } + fmt.Fprintf(w, "event: message\ndata: %s\n\n", string(msg)) + flusher.Flush() + case <-ticker.C: + fmt.Fprintf(w, ": keepalive\n\n") + flusher.Flush() + } + } +} + +// Message Handler: POST /mcp/message +func (s *Server) HandleMessage(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + sessionID := r.URL.Query().Get("sessionId") + s.mu.RLock() + ch, exists := s.sessions[sessionID] + s.mu.RUnlock() + + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1024*1024)) + if err != nil { + http.Error(w, "read error", http.StatusBadRequest) + return + } + + var req JSONRPCRequest + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, "invalid json-rpc", http.StatusBadRequest) + return + } + + resp := s.handleRequest(r.Context(), r, req) + + // If session exists, push response via SSE; otherwise reply directly via HTTP + respBytes, _ := json.Marshal(resp) + if exists && ch != nil { + select { + case ch <- respBytes: + default: + } + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte("accepted")) + } else { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(respBytes) + } +} + +func (s *Server) handleRequest(ctx context.Context, r *http.Request, req JSONRPCRequest) JSONRPCResponse { + switch req.Method { + case "initialize": + return JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: map[string]any{ + "protocolVersion": "2024-11-05", + "serverInfo": map[string]string{ + "name": "vps-commander", + "version": "1.2.0", + }, + "capabilities": map[string]any{ + "tools": map[string]bool{"listChanged": false}, + }, + }, + } + + case "notifications/initialized": + return JSONRPCResponse{JSONRPC: "2.0"} + + case "ping": + return JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: map[string]string{}} + + case "tools/list": + tools := []Tool{ + { + Name: "list_devices", + Description: "列出当前所有已注册的 VPS 节点及其在线状态与系统架构", + InputSchema: InputSchema{ + Type: "object", + Properties: map[string]PropertyDef{}, + }, + }, + { + Name: "exec_command", + Description: "在指定受控节点(默认本机 local)执行任意 Shell 命令并返回输出和退出码", + InputSchema: InputSchema{ + Type: "object", + Properties: map[string]PropertyDef{ + "device": {Type: "string", Description: "目标设备节点名称/别名(缺省则在当前 Hub 所在节点执行)"}, + "command": {Type: "string", Description: "需要执行的 Shell 命令"}, + "workdir": {Type: "string", Description: "可选的工作目录绝对路径"}, + "timeout": {Type: "integer", Description: "超时时间(秒,默认 30,最大 300)"}, + }, + Required: []string{"command"}, + }, + }, + { + Name: "read_file", + Description: "从指定受控节点读取文件内容(支持偏移与字节分页)", + InputSchema: InputSchema{ + Type: "object", + Properties: map[string]PropertyDef{ + "device": {Type: "string", Description: "目标设备节点名称/别名(缺省为 local)"}, + "path": {Type: "string", Description: "文件的绝对路径"}, + "offset": {Type: "integer", Description: "读取的起始字节偏移量(默认 0)"}, + "limit": {Type: "integer", Description: "读取的最大字节数(默认 262144,最大 1048576)"}, + }, + Required: []string{"path"}, + }, + }, + { + Name: "write_file", + Description: "向指定受控节点创建或覆写文件", + InputSchema: InputSchema{ + Type: "object", + Properties: map[string]PropertyDef{ + "device": {Type: "string", Description: "目标设备节点名称/别名(缺省为 local)"}, + "path": {Type: "string", Description: "文件的绝对路径"}, + "content": {Type: "string", Description: "要写入的文件文本内容"}, + }, + Required: []string{"path", "content"}, + }, + }, + } + return JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: map[string]any{"tools": tools}, + } + + case "tools/call": + var params CallToolParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Error: &JSONRPCError{Code: -32602, Message: "invalid tool params"}, + } + } + + clientIP := r.RemoteAddr + res, isErr := s.callTool(ctx, clientIP, params) + resJSON, _ := json.MarshalIndent(res, "", " ") + + return JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: CallToolResult{ + Content: []TextContent{ + {Type: "text", Text: string(resJSON)}, + }, + IsError: isErr, + }, + } + + default: + return JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Error: &JSONRPCError{Code: -32601, Message: fmt.Sprintf("method not found: %s", req.Method)}, + } + } +} + +func (s *Server) callTool(ctx context.Context, clientIP string, params CallToolParams) (any, bool) { + switch params.Name { + case "list_devices": + devs, err := s.Backend.ListDevices(ctx) + if err != nil { + return map[string]string{"error": err.Error()}, true + } + return devs, false + + case "exec_command": + var args struct { + Device string `json:"device"` + Command string `json:"command"` + Workdir string `json:"workdir"` + Timeout int `json:"timeout"` + } + _ = json.Unmarshal(params.Arguments, &args) + if args.Command == "" { + return map[string]string{"error": "command is required"}, true + } + res, err := s.Backend.ExecCommand(ctx, clientIP, args.Device, args.Command, args.Workdir, args.Timeout) + if err != nil { + return map[string]string{"error": err.Error()}, true + } + return res, false + + case "read_file": + var args struct { + Device string `json:"device"` + Path string `json:"path"` + Offset int64 `json:"offset"` + Limit int64 `json:"limit"` + } + _ = json.Unmarshal(params.Arguments, &args) + if args.Path == "" { + return map[string]string{"error": "path is required"}, true + } + res, err := s.Backend.ReadFile(ctx, clientIP, args.Device, args.Path, args.Offset, args.Limit) + if err != nil { + return map[string]string{"error": err.Error()}, true + } + return res, false + + case "write_file": + var args struct { + Device string `json:"device"` + Path string `json:"path"` + Content string `json:"content"` + } + _ = json.Unmarshal(params.Arguments, &args) + if args.Path == "" { + return map[string]string{"error": "path is required"}, true + } + res, err := s.Backend.WriteFile(ctx, clientIP, args.Device, args.Path, args.Content) + if err != nil { + return map[string]string{"error": err.Error()}, true + } + return res, false + + default: + return map[string]string{"error": "unknown tool: " + params.Name}, true + } +} + +func (s *Server) HandleDirectRequest(ctx context.Context, req JSONRPCRequest) JSONRPCResponse { + return s.handleRequest(ctx, &http.Request{RemoteAddr: "stdio"}, req) +} diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go new file mode 100644 index 0000000..cf40878 --- /dev/null +++ b/internal/mcp/mcp_test.go @@ -0,0 +1,112 @@ +package mcp + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +type mockBackend struct{} + +func (m *mockBackend) ListDevices(ctx context.Context) (any, error) { + return []map[string]any{{"name": "local", "status": "online"}}, nil +} +func (m *mockBackend) ExecCommand(ctx context.Context, clientIP, device, command, workdir string, timeout int) (any, error) { + return map[string]any{"device": device, "exit_code": 0, "stdout": "ok"}, nil +} +func (m *mockBackend) ReadFile(ctx context.Context, clientIP, device, path string, offset, limit int64) (any, error) { + return map[string]any{"device": device, "path": path, "content": "hello"}, nil +} +func (m *mockBackend) WriteFile(ctx context.Context, clientIP, device, path, content string) (any, error) { + return map[string]any{"device": device, "path": path, "bytes": len(content)}, nil +} + +func TestMCPInitializeAndListTools(t *testing.T) { + srv := NewServer(&mockBackend{}, nil) + + // Test Initialize + initReq := JSONRPCRequest{ + JSONRPC: "2.0", + ID: 1, + Method: "initialize", + } + body, _ := json.Marshal(initReq) + req := httptest.NewRequest("POST", "/mcp/message", bytes.NewReader(body)) + w := httptest.NewRecorder() + srv.HandleMessage(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp JSONRPCResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error) + } + + // Test tools/list + listReq := JSONRPCRequest{ + JSONRPC: "2.0", + ID: 2, + Method: "tools/list", + } + body, _ = json.Marshal(listReq) + req = httptest.NewRequest("POST", "/mcp/message", bytes.NewReader(body)) + w = httptest.NewRecorder() + srv.HandleMessage(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var listResp JSONRPCResponse + if err := json.Unmarshal(w.Body.Bytes(), &listResp); err != nil { + t.Fatal(err) + } + resMap, ok := listResp.Result.(map[string]any) + if !ok { + t.Fatalf("expected map result") + } + tools, ok := resMap["tools"].([]any) + if !ok || len(tools) != 4 { + t.Fatalf("expected 4 tools, got %d", len(tools)) + } +} + +func TestMCPCallTool(t *testing.T) { + srv := NewServer(&mockBackend{}, nil) + + callReq := JSONRPCRequest{ + JSONRPC: "2.0", + ID: 3, + Method: "tools/call", + Params: json.RawMessage(`{"name":"exec_command","arguments":{"command":"echo test"}}`), + } + body, _ := json.Marshal(callReq) + req := httptest.NewRequest("POST", "/mcp/message", bytes.NewReader(body)) + w := httptest.NewRecorder() + srv.HandleMessage(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp JSONRPCResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error) + } + contentStr := string(w.Body.Bytes()) + if !strings.Contains(contentStr, "stdout") { + t.Fatalf("expected stdout in result, got %s", contentStr) + } +} diff --git a/internal/mcp/oauth.go b/internal/mcp/oauth.go new file mode 100644 index 0000000..d42c157 --- /dev/null +++ b/internal/mcp/oauth.go @@ -0,0 +1,58 @@ +package mcp + +import ( + "encoding/json" + "fmt" + "net/http" +) + +// HandleOAuthMetadata serves /.well-known/oauth-authorization-server +func (s *Server) HandleOAuthMetadata(w http.ResponseWriter, r *http.Request) { + scheme := "https" + host := r.Host + meta := map[string]any{ + "issuer": fmt.Sprintf("%s://%s", scheme, host), + "authorization_endpoint": fmt.Sprintf("%s://%s/oauth/authorize", scheme, host), + "token_endpoint": fmt.Sprintf("%s://%s/oauth/token", scheme, host), + "response_types_supported": []string{"code"}, + "grant_types_supported": []string{"authorization_code"}, + "code_challenge_methods_supported": []string{"S256", "plain"}, + "token_endpoint_auth_methods_supported": []string{"client_secret_post", "client_secret_basic", "none"}, + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + _ = json.NewEncoder(w).Encode(meta) +} + +// HandleAuthorize handles the user/client authorization step +func (s *Server) HandleAuthorize(w http.ResponseWriter, r *http.Request) { + redirectURI := r.URL.Query().Get("redirect_uri") + state := r.URL.Query().Get("state") + + if redirectURI == "" { + http.Error(w, "missing redirect_uri", http.StatusBadRequest) + return + } + + code := "vps-commander-auth-code" + target := fmt.Sprintf("%s?code=%s&state=%s", redirectURI, code, state) + http.Redirect(w, r, target, http.StatusFound) +} + +// HandleToken exchanges code for bearer token +func (s *Server) HandleToken(w http.ResponseWriter, r *http.Request) { + token := "" + if s.Auth != nil { + token = s.Auth.Token() + } + + resp := map[string]any{ + "access_token": token, + "token_type": "Bearer", + "expires_in": 86400 * 365, + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + _ = json.NewEncoder(w).Encode(resp) +} diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..5a2f6d5 --- /dev/null +++ b/internal/model/model.go @@ -0,0 +1,54 @@ +package model + +type ExecRequest struct { + Device string `json:"device,omitempty"` + Command string `json:"command"` + Workdir string `json:"workdir,omitempty"` + Timeout int `json:"timeout,omitempty"` +} + +type ExecResponse struct { + Device string `json:"device"` + ExitCode int `json:"exit_code"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + DurationMS int64 `json:"duration_ms"` +} + +type Device struct { + Name string `json:"name"` + Status string `json:"status"` + Local bool `json:"is_local"` + Arch string `json:"arch,omitempty"` + OS string `json:"os,omitempty"` +} + +type FileReadRequest struct { + Device string `json:"device,omitempty"` + Path string `json:"path"` + Offset int64 `json:"offset,omitempty"` + Limit int64 `json:"limit,omitempty"` +} + +type FileReadResponse struct { + Device string `json:"device"` + Path string `json:"path"` + Offset int64 `json:"offset"` + Content string `json:"content"` + Bytes int64 `json:"bytes"` + TotalSize int64 `json:"total_size"` + HasMore bool `json:"has_more"` + EOF bool `json:"eof,omitempty"` +} + +type FileWriteRequest struct { + Device string `json:"device,omitempty"` + Path string `json:"path"` + Content string `json:"content"` +} + +type FileWriteResponse struct { + Device string `json:"device"` + Path string `json:"path"` + Bytes int64 `json:"bytes"` +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..daaf11d --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,99 @@ +package storage + +import ( + "database/sql" + _ "modernc.org/sqlite" + "os" + "path/filepath" + "time" +) + +type Store struct{ DB *sql.DB } + +func Open(path string) (*Store, error) { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return nil, err + } + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + // SQLite has a single-writer model. Keep one application connection so + // concurrent callers queue instead of opening competing writer connections. + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + s := &Store{db} + if err := s.init(); err != nil { + db.Close() + return nil, err + } + return s, nil +} + +func (s *Store) init() error { + if _, err := s.DB.Exec(`PRAGMA journal_mode = WAL`); err != nil { + return err + } + if _, err := s.DB.Exec(`PRAGMA busy_timeout = 5000`); err != nil { + return err + } + _, err := s.DB.Exec(`CREATE TABLE IF NOT EXISTS devices ( + name TEXT PRIMARY KEY, status TEXT NOT NULL, is_local INTEGER NOT NULL, + arch TEXT, os TEXT, updated_at INTEGER NOT NULL + ); CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER NOT NULL, + caller_ip TEXT, device TEXT, action TEXT, command TEXT, + exit_code INTEGER, duration_ms INTEGER, error_msg TEXT + );`) + return err +} + +func (s *Store) UpsertLocal(name, arch, osName string) error { + _, err := s.DB.Exec(`INSERT INTO devices(name,status,is_local,arch,os,updated_at) + VALUES(?,?,?,?,?,?) ON CONFLICT(name) DO UPDATE SET status=excluded.status, + arch=excluded.arch, os=excluded.os, updated_at=excluded.updated_at`, + name, "online", 1, arch, osName, time.Now().Unix()) + return err +} + +type AuditLog struct { + ID int64 `json:"id"` + Timestamp int64 `json:"timestamp"` + CallerIP string `json:"caller_ip"` + Device string `json:"device"` + Action string `json:"action"` + Command string `json:"command"` + ExitCode int `json:"exit_code"` + DurationMS int64 `json:"duration_ms"` + ErrorMsg string `json:"error_msg"` +} + +func (s *Store) Audit(ip, device, action, command string, code int, ms int64, msg string) error { + _, err := s.DB.Exec(`INSERT INTO audit_logs(timestamp,caller_ip,device,action,command,exit_code,duration_ms,error_msg) + VALUES(?,?,?,?,?,?,?,?)`, time.Now().Unix(), ip, device, action, command, code, ms, msg) + return err +} + +func (s *Store) RecentAudits(limit int) ([]AuditLog, error) { + if limit <= 0 { + limit = 100 + } + if limit > 500 { + limit = 500 + } + rows, err := s.DB.Query(`SELECT id,timestamp,caller_ip,device,action,command,exit_code,duration_ms,error_msg + FROM audit_logs ORDER BY id DESC LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]AuditLog, 0, limit) + for rows.Next() { + var v AuditLog + if err := rows.Scan(&v.ID, &v.Timestamp, &v.CallerIP, &v.Device, &v.Action, &v.Command, &v.ExitCode, &v.DurationMS, &v.ErrorMsg); err != nil { + return nil, err + } + out = append(out, v) + } + return out, rows.Err() +} diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go new file mode 100644 index 0000000..a5383c8 --- /dev/null +++ b/internal/storage/storage_test.go @@ -0,0 +1,60 @@ +package storage + +import ( + "fmt" + "path/filepath" + "sync" + "testing" +) + +func TestAuditConcurrent100NoLoss(t *testing.T) { + dir := t.TempDir() + s, err := Open(filepath.Join(dir, "commander.db")) + if err != nil { + t.Fatal(err) + } + defer s.DB.Close() + + const n = 100 + var wg sync.WaitGroup + errs := make(chan error, n) + wg.Add(n) + for i := 0; i < n; i++ { + go func(i int) { + defer wg.Done() + errs <- s.Audit("127.0.0.1", "local", "exec", fmt.Sprintf("echo %d", i), 0, 1, "") + }(i) + } + wg.Wait() + close(errs) + + for err := range errs { + if err != nil { + t.Fatalf("audit write failed: %v", err) + } + } + + var count int + if err := s.DB.QueryRow("SELECT COUNT(*) FROM audit_logs").Scan(&count); err != nil { + t.Fatal(err) + } + if count != n { + t.Fatalf("audit loss: got %d rows, want %d", count, n) + } + + var journal string + if err := s.DB.QueryRow("PRAGMA journal_mode").Scan(&journal); err != nil { + t.Fatal(err) + } + if journal != "wal" { + t.Fatalf("journal_mode=%q, want wal", journal) + } + + var busy int + if err := s.DB.QueryRow("PRAGMA busy_timeout").Scan(&busy); err != nil { + t.Fatal(err) + } + if busy < 5000 { + t.Fatalf("busy_timeout=%d, want >=5000", busy) + } +} diff --git a/internal/web/panel.go b/internal/web/panel.go new file mode 100644 index 0000000..bda2511 --- /dev/null +++ b/internal/web/panel.go @@ -0,0 +1,159 @@ +package web + +import ( + "crypto/rand" + "crypto/subtle" + "embed" + "encoding/hex" + "encoding/json" + "net/http" + "strings" + "sync" + "time" +) + +//go:embed static/* +var staticFS embed.FS + +type Runner interface { + DevicesJSON(http.ResponseWriter, *http.Request) + AuditsJSON(http.ResponseWriter, *http.Request) + PanelExec(http.ResponseWriter, *http.Request) + RotateAPIKey(http.ResponseWriter, *http.Request) +} + +type Panel struct { + Password string + Runner Runner + mu sync.RWMutex + sessions map[string]time.Time +} + +func New(password string, runner Runner) *Panel { + return &Panel{Password: password, Runner: runner, sessions: make(map[string]time.Time)} +} + +func (p *Panel) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/panel/login", p.login) + mux.HandleFunc("/panel/logout", p.logout) + mux.HandleFunc("/static/", p.static) + mux.HandleFunc("/panel/api/devices", p.protected(p.Runner.DevicesJSON)) + mux.HandleFunc("/panel/api/audits", p.protected(p.Runner.AuditsJSON)) + mux.HandleFunc("/panel/api/exec", p.protected(p.Runner.PanelExec)) + mux.HandleFunc("/panel/api/key/rotate", p.protected(p.Runner.RotateAPIKey)) + mux.HandleFunc("/", p.index) + return mux +} + +func (p *Panel) index(w http.ResponseWriter, r *http.Request) { + if !p.valid(r) { + http.Redirect(w, r, "/panel/login", http.StatusFound) + return + } + data, _ := staticFS.ReadFile("static/index.html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(data) +} + +func (p *Panel) login(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + data, _ := staticFS.ReadFile("static/login.html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(data) + return + } + if r.Method != "POST" { + http.Error(w, "method not allowed", 405) + return + } + var req struct { + Password string `json:"password"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil { + http.Error(w, "bad request", 400) + return + } + if p.Password == "" || subtle.ConstantTimeCompare([]byte(req.Password), []byte(p.Password)) != 1 { + http.Error(w, "unauthorized", 401) + return + } + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + http.Error(w, "session error", 500) + return + } + token := hex.EncodeToString(b) + p.mu.Lock() + p.sessions[token] = time.Now().Add(12 * time.Hour) + p.mu.Unlock() + secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") + http.SetCookie(w, &http.Cookie{Name: "vpc_session", Value: token, Path: "/", HttpOnly: true, Secure: secure, SameSite: http.SameSiteStrictMode, MaxAge: 43200}) + w.WriteHeader(http.StatusNoContent) +} + +func (p *Panel) logout(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie("vpc_session"); err == nil { + p.mu.Lock() + delete(p.sessions, c.Value) + p.mu.Unlock() + } + secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") + http.SetCookie(w, &http.Cookie{Name: "vpc_session", Value: "", Path: "/", HttpOnly: true, Secure: secure, MaxAge: -1, SameSite: http.SameSiteStrictMode}) + w.WriteHeader(http.StatusNoContent) +} + +func (p *Panel) valid(r *http.Request) bool { + c, err := r.Cookie("vpc_session") + if err != nil || c.Value == "" { + return false + } + p.mu.RLock() + expiry, ok := p.sessions[c.Value] + p.mu.RUnlock() + return ok && time.Now().Before(expiry) +} + +func (p *Panel) protected(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !p.valid(r) { + http.Error(w, "unauthorized", 401) + return + } + next(w, r) + } +} + +func (p *Panel) static(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + if len(path) < 9 { + http.NotFound(w, r) + return + } + data, err := staticFS.ReadFile("static/" + path[len("/static/"):]) + if err != nil { + http.NotFound(w, r) + return + } + if strings.HasSuffix(path, ".css") { + w.Header().Set("Content-Type", "text/css; charset=utf-8") + } + if len(path) >= 3 && path[len(path)-3:] == ".js" { + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + } + _, _ = w.Write(data) +} + +func (p *Panel) Cleanup() { + t := time.NewTicker(time.Hour) + defer t.Stop() + for now := range t.C { + p.mu.Lock() + for k, v := range p.sessions { + if now.After(v) { + delete(p.sessions, k) + } + } + p.mu.Unlock() + } +} diff --git a/internal/web/panel_test.go b/internal/web/panel_test.go new file mode 100644 index 0000000..345ea46 --- /dev/null +++ b/internal/web/panel_test.go @@ -0,0 +1,33 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +type testRunner struct{} + +func (testRunner) DevicesJSON(w http.ResponseWriter, r *http.Request) {} +func (testRunner) AuditsJSON(w http.ResponseWriter, r *http.Request) {} +func (testRunner) PanelExec(w http.ResponseWriter, r *http.Request) {} +func (testRunner) RotateAPIKey(w http.ResponseWriter, r *http.Request) {} + +func TestStaticCSSContentType(t *testing.T) { + p := New("test-password", testRunner{}) + req := httptest.NewRequest("GET", "/static/app.css", nil) + rec := httptest.NewRecorder() + + p.static(rec, req) + + if rec.Code != 200 { + t.Fatalf("status=%d, want 200", rec.Code) + } + if got := rec.Header().Get("Content-Type"); got != "text/css; charset=utf-8" { + t.Fatalf("content-type=%q, want text/css; charset=utf-8", got) + } + if strings.TrimSpace(rec.Body.String()) == "" { + t.Fatal("CSS body is empty") + } +} diff --git a/internal/web/static/app.css b/internal/web/static/app.css new file mode 100644 index 0000000..9c7fca4 --- /dev/null +++ b/internal/web/static/app.css @@ -0,0 +1 @@ +*{box-sizing:border-box}body{margin:0;background:#0b1020;color:#e7ebf3;font:14px/1.5 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}header{height:64px;padding:0 24px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #202a3d;background:#10182b;position:sticky;top:0;z-index:2}header strong{font-size:18px}header span{margin-left:12px;color:#7f8ba3;font-size:12px}main{max-width:1400px;margin:auto;padding:22px}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.wide{grid-column:1/-1}.card{background:#111a2d;border:1px solid #24304a;border-radius:14px;padding:18px;box-shadow:0 8px 30px #0003}.cardhead{display:flex;align-items:center;justify-content:space-between}h2{margin:0 0 14px;font-size:16px}button,select,input,textarea{border:1px solid #34415d;border-radius:9px;background:#0d1526;color:#e7ebf3;padding:9px 12px}button{cursor:pointer}button:hover{background:#17233a}textarea{width:100%;height:110px;resize:vertical;font-family:ui-monospace,monospace;margin:8px 0}pre{white-space:pre-wrap;overflow:auto;background:#090e1a;border-radius:9px;padding:12px;color:#b9c5da}.devices{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px}.device{padding:14px;border:1px solid #283650;border-radius:10px}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#4ade80;margin-right:7px}.offline .dot{background:#f87171}.muted{color:#7f8ba3}.tablewrap{overflow:auto}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:9px;border-bottom:1px solid #26324a;font-size:12px}th{color:#91a0bb}.login{display:grid;place-items:center;min-height:100vh}.login form{width:min(380px,90vw);background:#111a2d;border:1px solid #24304a;border-radius:16px;padding:28px}.login input,.login button{width:100%;margin-top:10px}.login h1{margin:0}.login p,#error{color:#8c9ab2}.status{font-size:12px;color:#9aabc5}@media(max-width:800px){.grid{grid-template-columns:1fr}.wide{grid-column:auto}main{padding:12px}header{padding:0 14px}} \ No newline at end of file diff --git a/internal/web/static/app.js b/internal/web/static/app.js new file mode 100644 index 0000000..abefbdf --- /dev/null +++ b/internal/web/static/app.js @@ -0,0 +1,12 @@ +const $=s=>document.querySelector(s); +async function api(path,opt={}){let r=await fetch("/panel/api"+path,opt);if(r.status===401){location.href="/panel/login";throw Error("unauthorized")}if(!r.ok)throw Error(await r.text());return r.json()} +function esc(v){return String(v??"").replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c]))} +async function loadDevices(){let ds=await api("/devices");$("#devices").innerHTML=ds.map(d=>`
${esc(d.name)}
${esc(d.status)} · ${d.is_local?"local":"remote"} · ${esc(d.arch||"")}/${esc(d.os||"")}
`).join("");$("#device").innerHTML=ds.map(d=>``).join("")} +async function loadAudits(){let a=await api("/audits?limit=100");$("#audits").innerHTML=a.map(x=>`${new Date(x.timestamp*1000).toLocaleString()}${esc(x.device)}${esc(x.action)}${esc(x.command)}${x.exit_code}${x.duration_ms}ms${esc(x.caller_ip)}`).join("")} +$("#refresh").onclick=loadDevices;$("#auditRefresh").onclick=loadAudits; +$("#run").onclick=async()=>{let o=$("#output");o.textContent="执行中…";try{let x=await api("/exec",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({device:$("#device").value,command:$("#command").value,timeout:30})});o.textContent=`exit_code: ${x.exit_code}\n\nstdout:\n${x.stdout}\n\nstderr:\n${x.stderr}\nduration: ${x.duration_ms}ms`}catch(e){o.textContent=e.message}}; +$("#copySchema").onclick=()=>navigator.clipboard.writeText(location.origin+"/openapi.json"); +$("#copyPrompt").onclick=()=>navigator.clipboard.writeText("你是我的私有 VPS 运维助手。执行操作前确认目标设备;优先使用 list_devices 确认节点状态;高风险操作先向用户确认。"); +$("#rotateKey").onclick=async()=>{if(!confirm("轮换后旧 API Key 会立即失效,确认继续?"))return;try{let x=await api("/key/rotate",{method:"POST"});$("#newKey").textContent="新 API Key(仅本次显示):\n"+x.api_key}catch(e){$("#newKey").textContent=e.message}}; +$("#logout").onclick=async()=>{await fetch("/panel/logout",{method:"POST"});location.href="/panel/login"}; +loadDevices().then(loadAudits).catch(console.error); \ No newline at end of file diff --git a/internal/web/static/index.html b/internal/web/static/index.html new file mode 100644 index 0000000..e84dde9 --- /dev/null +++ b/internal/web/static/index.html @@ -0,0 +1,13 @@ + + +VPS-Commander + +
VPS-CommanderPrivate Infrastructure Console
+
+
+

设备看板

+

Actions 向导

当前 Hub 的 OpenAPI Schema:

/openapi.json
+

快速控制台

+

审计日志

时间设备动作命令/路径退出码耗时来源
+
+
\ No newline at end of file diff --git a/internal/web/static/login.html b/internal/web/static/login.html new file mode 100644 index 0000000..f5a8cb6 --- /dev/null +++ b/internal/web/static/login.html @@ -0,0 +1 @@ +VPS-Commander Login

VPS-Commander

Private Infrastructure Console

\ No newline at end of file diff --git a/openapi.json b/openapi.json new file mode 100644 index 0000000..30768c1 --- /dev/null +++ b/openapi.json @@ -0,0 +1,286 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "VPS-Commander", + "version": "1.1.0" + }, + "servers": [ + { + "url": "https://vpstool.kory.kdns.fr" + } + ], + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer" + } + }, + "schemas": { + "Device": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "is_local": { + "type": "boolean" + }, + "arch": { + "type": "string" + }, + "os": { + "type": "string" + } + } + }, + "ExecRequest": { + "type": "object", + "required": [ + "command" + ], + "properties": { + "device": { + "type": "string" + }, + "command": { + "type": "string" + }, + "workdir": { + "type": "string" + }, + "timeout": { + "type": "integer" + } + } + }, + "ExecResponse": { + "type": "object", + "properties": { + "device": { + "type": "string" + }, + "exit_code": { + "type": "integer" + }, + "stdout": { + "type": "string" + }, + "stderr": { + "type": "string" + }, + "duration_ms": { + "type": "integer" + } + } + }, + "FileReadRequest": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "device": { + "type": "string" + }, + "path": { + "type": "string" + }, + "offset": { + "type": "integer" + }, + "limit": { + "type": "integer" + } + } + }, + "FileReadResponse": { + "type": "object", + "properties": { + "device": { + "type": "string" + }, + "path": { + "type": "string" + }, + "offset": { + "type": "integer" + }, + "content": { + "type": "string" + }, + "bytes": { + "type": "integer" + }, + "eof": { + "type": "boolean" + }, + "total_size": { + "type": "integer" + }, + "has_more": { + "type": "boolean" + } + } + }, + "FileWriteRequest": { + "type": "object", + "required": [ + "path", + "content" + ], + "properties": { + "device": { + "type": "string" + }, + "path": { + "type": "string" + }, + "content": { + "type": "string" + } + } + }, + "FileWriteResponse": { + "type": "object", + "properties": { + "device": { + "type": "string" + }, + "path": { + "type": "string" + }, + "bytes": { + "type": "integer" + } + } + } + } + }, + "paths": { + "/api/v1/devices": { + "get": { + "operationId": "list_devices", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Devices", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Device" + } + } + } + } + } + } + } + }, + "/api/v1/exec": { + "post": { + "operationId": "exec_command", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Execution result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecResponse" + } + } + } + } + } + } + }, + "/api/v1/file/read": { + "post": { + "operationId": "read_file", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileReadRequest" + } + } + } + }, + "responses": { + "200": { + "description": "File content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileReadResponse" + } + } + } + } + } + } + }, + "/api/v1/file/write": { + "post": { + "operationId": "write_file", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileWriteRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Write result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileWriteResponse" + } + } + } + } + } + } + } + } +} diff --git a/web/icon.png b/web/icon.png new file mode 100644 index 0000000..8806b37 Binary files /dev/null and b/web/icon.png differ diff --git a/web/icon_clean.png b/web/icon_clean.png new file mode 100644 index 0000000..cc79e4c Binary files /dev/null and b/web/icon_clean.png differ diff --git a/web/icon_rgb.png b/web/icon_rgb.png new file mode 100644 index 0000000..8806b37 Binary files /dev/null and b/web/icon_rgb.png differ diff --git a/web/icon_symbol.png b/web/icon_symbol.png new file mode 100644 index 0000000..910eb31 Binary files /dev/null and b/web/icon_symbol.png differ diff --git a/web/static/app.css b/web/static/app.css new file mode 100644 index 0000000..9c7fca4 --- /dev/null +++ b/web/static/app.css @@ -0,0 +1 @@ +*{box-sizing:border-box}body{margin:0;background:#0b1020;color:#e7ebf3;font:14px/1.5 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}header{height:64px;padding:0 24px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #202a3d;background:#10182b;position:sticky;top:0;z-index:2}header strong{font-size:18px}header span{margin-left:12px;color:#7f8ba3;font-size:12px}main{max-width:1400px;margin:auto;padding:22px}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.wide{grid-column:1/-1}.card{background:#111a2d;border:1px solid #24304a;border-radius:14px;padding:18px;box-shadow:0 8px 30px #0003}.cardhead{display:flex;align-items:center;justify-content:space-between}h2{margin:0 0 14px;font-size:16px}button,select,input,textarea{border:1px solid #34415d;border-radius:9px;background:#0d1526;color:#e7ebf3;padding:9px 12px}button{cursor:pointer}button:hover{background:#17233a}textarea{width:100%;height:110px;resize:vertical;font-family:ui-monospace,monospace;margin:8px 0}pre{white-space:pre-wrap;overflow:auto;background:#090e1a;border-radius:9px;padding:12px;color:#b9c5da}.devices{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px}.device{padding:14px;border:1px solid #283650;border-radius:10px}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#4ade80;margin-right:7px}.offline .dot{background:#f87171}.muted{color:#7f8ba3}.tablewrap{overflow:auto}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:9px;border-bottom:1px solid #26324a;font-size:12px}th{color:#91a0bb}.login{display:grid;place-items:center;min-height:100vh}.login form{width:min(380px,90vw);background:#111a2d;border:1px solid #24304a;border-radius:16px;padding:28px}.login input,.login button{width:100%;margin-top:10px}.login h1{margin:0}.login p,#error{color:#8c9ab2}.status{font-size:12px;color:#9aabc5}@media(max-width:800px){.grid{grid-template-columns:1fr}.wide{grid-column:auto}main{padding:12px}header{padding:0 14px}} \ No newline at end of file diff --git a/web/static/app.js b/web/static/app.js new file mode 100644 index 0000000..abefbdf --- /dev/null +++ b/web/static/app.js @@ -0,0 +1,12 @@ +const $=s=>document.querySelector(s); +async function api(path,opt={}){let r=await fetch("/panel/api"+path,opt);if(r.status===401){location.href="/panel/login";throw Error("unauthorized")}if(!r.ok)throw Error(await r.text());return r.json()} +function esc(v){return String(v??"").replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c]))} +async function loadDevices(){let ds=await api("/devices");$("#devices").innerHTML=ds.map(d=>`
${esc(d.name)}
${esc(d.status)} · ${d.is_local?"local":"remote"} · ${esc(d.arch||"")}/${esc(d.os||"")}
`).join("");$("#device").innerHTML=ds.map(d=>``).join("")} +async function loadAudits(){let a=await api("/audits?limit=100");$("#audits").innerHTML=a.map(x=>`${new Date(x.timestamp*1000).toLocaleString()}${esc(x.device)}${esc(x.action)}${esc(x.command)}${x.exit_code}${x.duration_ms}ms${esc(x.caller_ip)}`).join("")} +$("#refresh").onclick=loadDevices;$("#auditRefresh").onclick=loadAudits; +$("#run").onclick=async()=>{let o=$("#output");o.textContent="执行中…";try{let x=await api("/exec",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({device:$("#device").value,command:$("#command").value,timeout:30})});o.textContent=`exit_code: ${x.exit_code}\n\nstdout:\n${x.stdout}\n\nstderr:\n${x.stderr}\nduration: ${x.duration_ms}ms`}catch(e){o.textContent=e.message}}; +$("#copySchema").onclick=()=>navigator.clipboard.writeText(location.origin+"/openapi.json"); +$("#copyPrompt").onclick=()=>navigator.clipboard.writeText("你是我的私有 VPS 运维助手。执行操作前确认目标设备;优先使用 list_devices 确认节点状态;高风险操作先向用户确认。"); +$("#rotateKey").onclick=async()=>{if(!confirm("轮换后旧 API Key 会立即失效,确认继续?"))return;try{let x=await api("/key/rotate",{method:"POST"});$("#newKey").textContent="新 API Key(仅本次显示):\n"+x.api_key}catch(e){$("#newKey").textContent=e.message}}; +$("#logout").onclick=async()=>{await fetch("/panel/logout",{method:"POST"});location.href="/panel/login"}; +loadDevices().then(loadAudits).catch(console.error); \ No newline at end of file diff --git a/web/static/index.html b/web/static/index.html new file mode 100644 index 0000000..e84dde9 --- /dev/null +++ b/web/static/index.html @@ -0,0 +1,13 @@ + + +VPS-Commander + +
VPS-CommanderPrivate Infrastructure Console
+
+
+

设备看板

+

Actions 向导

当前 Hub 的 OpenAPI Schema:

/openapi.json
+

快速控制台

+

审计日志

时间设备动作命令/路径退出码耗时来源
+
+
\ No newline at end of file diff --git a/web/static/login.html b/web/static/login.html new file mode 100644 index 0000000..f5a8cb6 --- /dev/null +++ b/web/static/login.html @@ -0,0 +1 @@ +VPS-Commander Login

VPS-Commander

Private Infrastructure Console

\ No newline at end of file diff --git a/web/vps_commander_icon.png b/web/vps_commander_icon.png new file mode 100644 index 0000000..4986e82 Binary files /dev/null and b/web/vps_commander_icon.png differ diff --git a/web/vps_commander_icon_256.png b/web/vps_commander_icon_256.png new file mode 100644 index 0000000..2e54efc Binary files /dev/null and b/web/vps_commander_icon_256.png differ diff --git a/web/vps_commander_pure.png b/web/vps_commander_pure.png new file mode 100644 index 0000000..deb69e9 Binary files /dev/null and b/web/vps_commander_pure.png differ