From bd44465eaa02c64134da8213b869ca3588fa690e Mon Sep 17 00:00:00 2001 From: kyaring Date: Tue, 29 Sep 2026 02:16:32 +0800 Subject: [PATCH] feat: add v2 phase2 security policy --- cmd/agent/main.go | 14 +++ internal/api/api.go | 189 ++++++++++++++++++++++++++++++++- internal/api/api_test.go | 40 +++++++ internal/api/openapi.json | 52 +++++++++ internal/cluster/manager.go | 19 ++++ internal/cluster/mcp.go | 154 +++++++++++++++++++++++++++ internal/storage/storage.go | 80 +++++++++++++- internal/sysinfo/sysinfo.go | 97 +++++++++++++++-- internal/web/panel.go | 54 +++++++++- internal/web/static/app.css | 4 +- internal/web/static/app.js | 66 +++++++++++- internal/web/static/index.html | 4 +- internal/web/static/probe.css | 1 + internal/web/static/probe.html | 4 + internal/web/static/probe.js | 14 +++ 15 files changed, 773 insertions(+), 19 deletions(-) create mode 100644 internal/web/static/probe.css create mode 100644 internal/web/static/probe.html create mode 100644 internal/web/static/probe.js diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 7f27fb3..5a57bd6 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -140,6 +140,20 @@ func handle(writeJSON func(cluster.Message) error, exec executor.Local, mcpRunti switch msg.Action { case "mcp_list": return writeJSON(cluster.Message{ID: msg.ID, Status: "success", Payload: mcpRuntime.List()}) + case "mcp_test": + var p cluster.MCPService + if err := decodePayload(msg.Payload, &p); err != nil { + return sendError(writeJSON, msg.ID, err) + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + start := time.Now() + tools, err := cluster.ProbeMCP(ctx, p) + duration := time.Since(start).Milliseconds() + if err != nil { + return writeJSON(cluster.Message{ID: msg.ID, Status: "error", Error: err.Error(), Payload: map[string]any{"duration_ms": duration}}) + } + return writeJSON(cluster.Message{ID: msg.ID, Status: "success", Payload: tools}) case "mcp_call": var p struct { Server string `json:"server"` diff --git a/internal/api/api.go b/internal/api/api.go index 3c0301f..dc0d3a2 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -4,6 +4,7 @@ import ( "context" "embed" "encoding/json" + "fmt" "io" "net" "net/http" @@ -38,6 +39,113 @@ type Server struct { LocalName string } +const ( + SecurityLow = "low" + SecurityMedium = "medium" + SecurityHigh = "high" + SecurityInherit = "inherit" +) + +func (s *Server) GetEffectiveSecurityMode(node string) string { + if s.Store == nil { + return SecurityMedium + } + if mode, ok := s.Store.GetNodeSecurityMode(node); ok && mode != "" && mode != SecurityInherit { + return mode + } + return s.Store.GetGlobalSecurityMode() +} + +func securityAllows(mode, action string) bool { + switch action { + case "read", "probe": + return true + case "write", "mcp": + return mode == SecurityMedium || mode == SecurityHigh + case "exec": + return mode == SecurityHigh + default: + return false + } +} + +func (s *Server) requireSecurity(w http.ResponseWriter, r *http.Request, device, action, detail string) bool { + mode := s.GetEffectiveSecurityMode(device) + if securityAllows(mode, action) { + return true + } + reason := fmt.Sprintf("security mode %s denies %s", mode, action) + if detail != "" { + reason += ": " + detail + } + _ = s.Store.Audit(clientIP(r), device, "security_denied", action, http.StatusForbidden, 0, reason) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(fmt.Sprintf(`{"error":"forbidden","device":%q,"mode":%q,"reason":%q}`, device, mode, reason))) + return false +} + +func (s *Server) SecuritySettingsJSON(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", 405) + return + } + nodes, err := s.Store.ListNodeSecurityModes() + if err != nil { + http.Error(w, "settings query failed", 500) + return + } + jsonOut(w, map[string]any{"global": s.Store.GetGlobalSecurityMode(), "nodes": nodes, "effective": func() map[string]string { + out := map[string]string{} + for _, d := range s.clusterDeviceNames() { + out[d] = s.GetEffectiveSecurityMode(d) + } + return out + }()}) +} + +func (s *Server) UpdateSecuritySettingsJSON(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + var q struct { + Global string `json:"global"` + Node string `json:"node"` + Mode string `json:"mode"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16*1024)).Decode(&q); err != nil { + http.Error(w, "bad json", 400) + return + } + if q.Global != "" { + if err := s.Store.SetGlobalSecurityMode(q.Global); err != nil { + http.Error(w, err.Error(), 400) + return + } + _ = s.Store.Audit(clientIP(r), "", "security_setting", "global="+q.Global, 0, 0, "") + } + if q.Node != "" { + if err := s.Store.SetNodeSecurityMode(q.Node, q.Mode); err != nil { + http.Error(w, err.Error(), 400) + return + } + _ = s.Store.Audit(clientIP(r), q.Node, "security_setting", "mode="+q.Mode, 0, 0, "") + } + jsonOut(w, map[string]any{"ok": true, "global": s.Store.GetGlobalSecurityMode(), "node": q.Node, "mode": q.Mode}) +} + +func (s *Server) clusterDeviceNames() []string { + out := []string{s.LocalName} + if s.Cluster == nil { + return out + } + for _, d := range s.Cluster.Devices() { + out = append(out, d["name"].(string)) + } + return out +} + func (s *Server) Panel(password string) http.Handler { p := panelweb.New(password, s) go p.Cleanup() @@ -97,6 +205,68 @@ type MCPCallRequest struct { Arguments any `json:"arguments"` } +type MCPTestRequest struct { + Device string `json:"device"` + Service MCPServiceRequest `json:"service"` +} + +func (s *Server) TestMCPJSON(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + var q MCPTestRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 256*1024)).Decode(&q); err != nil { + http.Error(w, "bad json", 400) + return + } + if q.Device == "" { + q.Device = s.LocalName + } + q.Service.Transport = strings.ToLower(strings.TrimSpace(q.Service.Transport)) + if q.Service.Transport == "" { + http.Error(w, "transport required", 400) + return + } + if q.Service.Transport == "stdio" { + if strings.TrimSpace(q.Service.Command) == "" { + http.Error(w, "stdio command required", 400) + return + } + } else if q.Service.Transport == "http" || q.Service.Transport == "sse" { + if strings.TrimSpace(q.Service.URL) == "" { + http.Error(w, "HTTP/SSE URL required", 400) + return + } + } else { + http.Error(w, "unsupported transport", 400) + return + } + service := cluster.MCPService{ID: q.Service.ID, Name: q.Service.Name, Description: q.Service.Description, Transport: q.Service.Transport, Command: q.Service.Command, Args: q.Service.Args, Env: q.Service.Env, URL: q.Service.URL, Headers: q.Service.Headers} + start := time.Now() + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + var tools []map[string]any + var err error + if s.isRemote(q.Device) { + if s.Cluster == nil || !s.Cluster.Online(q.Device) { + err = fmt.Errorf("device %s is offline", q.Device) + } else { + tools, err = s.Cluster.TestMCP(ctx, q.Device, service) + } + } else { + tools, err = cluster.ProbeMCP(ctx, service) + } + ms := time.Since(start).Milliseconds() + if err != nil { + _ = s.Store.Audit(clientIP(r), q.Device, "mcp_test", service.Name, 1, ms, err.Error()) + jsonOut(w, map[string]any{"success": false, "device": q.Device, "transport": service.Transport, "duration_ms": ms, "tool_count": 0, "tools": []any{}, "error": map[string]any{"code": "MCP_TEST_FAILED", "message": err.Error()}}) + return + } + _ = s.Store.Audit(clientIP(r), q.Device, "mcp_test", service.Name, 0, ms, "") + jsonOut(w, map[string]any{"success": true, "device": q.Device, "transport": service.Transport, "duration_ms": ms, "tool_count": len(tools), "tools": tools, "error": nil}) +} + func (s *Server) MCPServicesJSON(w http.ResponseWriter, r *http.Request) { if s.Store == nil { http.Error(w, "storage unavailable", 500) @@ -139,7 +309,7 @@ func (s *Server) MCPServicesJSON(w http.ResponseWriter, r *http.Request) { if s.Cluster != nil { s.Cluster.SyncMCPServices() } - _ = s.Store.Audit(clientIP(r), "", "mcp_service_save", q.ID, 0, 0, "") + _ = s.Store.Audit(clientIP(r), s.LocalName, "mcp_service_save", q.ID, 0, 0, "") jsonOut(w, v) default: http.Error(w, "method not allowed", 405) @@ -162,7 +332,7 @@ func (s *Server) DeleteMCPService(w http.ResponseWriter, r *http.Request) { if s.Cluster != nil { s.Cluster.SyncMCPServices() } - _ = s.Store.Audit(clientIP(r), "", "mcp_service_delete", id, 0, 0, "") + _ = s.Store.Audit(clientIP(r), s.LocalName, "mcp_service_delete", id, 0, 0, "") jsonOut(w, map[string]any{"ok": true}) } func (s *Server) ListAgentMCPJSON(w http.ResponseWriter, r *http.Request) { @@ -204,6 +374,9 @@ func (s *Server) CallAgentMCPJSON(w http.ResponseWriter, r *http.Request) { http.Error(w, "device, server and tool required", 400) return } + if !s.requireSecurity(w, r, q.Device, "mcp", q.Server+"/"+q.Tool) { + return + } if s.isRemote(q.Device) && (s.Cluster == nil || !s.Cluster.Online(q.Device)) { if s.Store != nil { _ = s.Store.Audit(clientIP(r), q.Device, "mcp_call", q.Server+"/"+q.Tool, 1, 0, "device offline") @@ -238,6 +411,9 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("/api/v1/mcp/service", s.DeleteMCPService) mux.HandleFunc("/api/v1/mcp/list", s.ListAgentMCPJSON) mux.HandleFunc("/api/v1/mcp/call", s.CallAgentMCPJSON) + mux.HandleFunc("/api/v1/mcp/test", s.TestMCPJSON) + mux.HandleFunc("/api/v1/security/settings", s.SecuritySettingsJSON) + mux.HandleFunc("/api/v1/security/settings/update", s.UpdateSecuritySettingsJSON) mux.HandleFunc("/agent/ws", s.agentWS) mux.HandleFunc("/openapi.json", s.openapi) return mux @@ -323,6 +499,9 @@ func (s *Server) exec(w http.ResponseWriter, r *http.Request) { if req.Device == "" { req.Device = s.LocalName } + if !s.requireSecurity(w, r, req.Device, "exec", req.Command) { + return + } if req.Timeout <= 0 { req.Timeout = 30 } @@ -365,6 +544,9 @@ func (s *Server) fileRead(w http.ResponseWriter, r *http.Request) { if req.Device == "" { req.Device = s.LocalName } + if !s.requireSecurity(w, r, req.Device, "read", req.Path) { + return + } if req.Path == "" || !validPath(req.Path) || req.Offset < 0 { http.Error(w, "invalid path or offset", 400) return @@ -437,6 +619,9 @@ func (s *Server) fileWrite(w http.ResponseWriter, r *http.Request) { if req.Device == "" { req.Device = s.LocalName } + if !s.requireSecurity(w, r, req.Device, "write", req.Path) { + return + } if req.Path == "" || !validPath(req.Path) { http.Error(w, "path required", 400) return diff --git a/internal/api/api_test.go b/internal/api/api_test.go index bd290bf..d3cb302 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -18,6 +18,9 @@ func TestExecConcurrent100AuditsPersisted(t *testing.T) { } defer store.DB.Close() + if err := store.SetGlobalSecurityMode("high"); err != nil { + t.Fatal(err) + } s := &Server{Exec: executor.Local{MaxOutput: 1024 * 1024}, Store: store, LocalName: "local"} const n = 100 @@ -48,3 +51,40 @@ func TestExecConcurrent100AuditsPersisted(t *testing.T) { t.Fatalf("audit loss: got %d rows, want %d", len(logs), n) } } + +func TestSecurityEffectiveModeAndExecGate(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"} + if got := s.GetEffectiveSecurityMode("node-a"); got != "medium" { + t.Fatalf("default mode=%s", got) + } + if err := store.SetNodeSecurityMode("node-a", "high"); err != nil { + t.Fatal(err) + } + if got := s.GetEffectiveSecurityMode("node-a"); got != "high" { + t.Fatalf("override=%s", got) + } + if err := store.SetNodeSecurityMode("node-a", "inherit"); err != nil { + t.Fatal(err) + } + if err := store.SetGlobalSecurityMode("low"); err != nil { + t.Fatal(err) + } + if got := s.GetEffectiveSecurityMode("node-a"); got != "low" { + t.Fatalf("inherit=%s", got) + } + req := httptest.NewRequest("POST", "/api/v1/exec", strings.NewReader(`{"device":"node-a","command":"true"}`)) + rec := httptest.NewRecorder() + s.exec(rec, req) + if rec.Code != 403 { + t.Fatalf("low exec status=%d", rec.Code) + } + logs, _ := store.RecentAudits(10) + if len(logs) == 0 || logs[0].Action != "security_denied" { + t.Fatalf("missing denial audit: %+v", logs) + } +} diff --git a/internal/api/openapi.json b/internal/api/openapi.json index ea93e2b..31e886c 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -473,6 +473,58 @@ } } } + }, + "/api/v1/security/settings": { + "get": { + "summary": "Get security policy", + "responses": { + "200": { + "description": "Current global and per-node security policy" + } + } + } + }, + "/api/v1/security/settings/update": { + "post": { + "summary": "Update security policy", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "global": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "node": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "inherit", + "low", + "medium", + "high" + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated" + } + } + } } } } diff --git a/internal/cluster/manager.go b/internal/cluster/manager.go index 0cd94a7..e360292 100644 --- a/internal/cluster/manager.go +++ b/internal/cluster/manager.go @@ -407,6 +407,25 @@ func (m *Manager) CallMCP(ctx context.Context, name, server, tool string, args a return out, nil } +func (m *Manager) TestMCP(ctx context.Context, name string, service MCPService) ([]map[string]any, error) { + m.mu.RLock() + n := m.nodes[name] + m.mu.RUnlock() + if n == nil || !m.Online(name) { + return nil, fmt.Errorf("device %s is offline", name) + } + msg, err := m.call(ctx, name, "mcp_test", service) + if err != nil { + return nil, err + } + var out []map[string]any + b, _ := json.Marshal(msg.Payload) + if err := json.Unmarshal(b, &out); err != nil { + return nil, err + } + return out, nil +} + func (m *Manager) heartbeat(n *Node) { t := time.NewTicker(30 * time.Second) defer t.Stop() diff --git a/internal/cluster/mcp.go b/internal/cluster/mcp.go index ff728a9..820ae97 100644 --- a/internal/cluster/mcp.go +++ b/internal/cluster/mcp.go @@ -105,6 +105,160 @@ func (r *MCPRuntime) Call(ctx context.Context, server, tool string, args any) (a } } +func ProbeMCP(ctx context.Context, s MCPService) ([]map[string]any, error) { + switch strings.ToLower(s.Transport) { + case "stdio": + return probeStdio(ctx, s) + case "http", "sse": + return probeHTTP(ctx, s) + default: + return nil, fmt.Errorf("unsupported MCP transport %q", s.Transport) + } +} + +func probeStdio(ctx context.Context, s MCPService) ([]map[string]any, error) { + if s.Command == "" { + return nil, errors.New("MCP stdio command is empty") + } + cmd := exec.CommandContext(ctx, s.Command, s.Args...) + if len(s.Env) > 0 { + cmd.Env = append(os.Environ(), envList(s.Env)...) + } + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + cmd.Stderr = io.Discard + if err := cmd.Start(); err != nil { + return nil, err + } + defer func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }() + enc := json.NewEncoder(stdin) + dec := bufio.NewReaderSize(stdout, 64*1024) + if err := enc.Encode(mcpRPC{JSONRPC: "2.0", ID: int64(1), Method: "initialize", Params: map[string]any{ + "protocolVersion": "2024-11-05", "capabilities": map[string]any{}, + "clientInfo": map[string]string{"name": "vps-commander-probe", "version": "1.0"}, + }}); err != nil { + return nil, err + } + if _, err := readRPC(ctx, dec, 1); err != nil { + return nil, err + } + if err := enc.Encode(mcpRPC{JSONRPC: "2.0", Method: "notifications/initialized", Params: map[string]any{}}); err != nil { + return nil, err + } + if err := enc.Encode(mcpRPC{JSONRPC: "2.0", ID: int64(2), Method: "tools/list", Params: map[string]any{}}); err != nil { + return nil, err + } + out, err := readRPC(ctx, dec, 2) + if err != nil { + return nil, err + } + return extractMCPTools(out) +} + +func probeHTTP(ctx context.Context, s MCPService) ([]map[string]any, error) { + if s.URL == "" { + return nil, errors.New("MCP HTTP URL is empty") + } + client := &http.Client{Timeout: 0} + var session string + post := func(req mcpRPC) (any, error) { + b, _ := json.Marshal(req) + hr, err := http.NewRequestWithContext(ctx, http.MethodPost, s.URL, bytes.NewReader(b)) + if err != nil { + return nil, err + } + hr.Header.Set("Content-Type", "application/json") + hr.Header.Set("Accept", "application/json, text/event-stream") + for k, v := range s.Headers { + hr.Header.Set(k, v) + } + if session != "" { + hr.Header.Set("Mcp-Session-Id", session) + } + resp, err := client.Do(hr) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("MCP HTTP status %s", resp.Status) + } + if v := resp.Header.Get("Mcp-Session-Id"); v != "" { + session = v + } + data, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024)) + if err != nil { + return nil, err + } + if len(bytes.TrimSpace(data)) == 0 { + return nil, nil + } + if bytes.Contains(data, []byte("event:")) { + data = parseSSEData(data) + } + var rr mcpRPCResponse + if err := json.Unmarshal(bytes.TrimSpace(data), &rr); err != nil { + return nil, err + } + if rr.Error != nil { + return nil, fmt.Errorf("MCP error %d: %s", rr.Error.Code, rr.Error.Message) + } + var out any + if len(rr.Result) > 0 { + if err := json.Unmarshal(rr.Result, &out); err != nil { + return nil, err + } + } + return out, nil + } + if strings.EqualFold(s.Transport, "sse") { + endpoint, err := discoverSSEEndpoint(ctx, s) + if err != nil { + return nil, err + } + s.URL = endpoint + } + if _, err := post(mcpRPC{JSONRPC: "2.0", ID: int64(1), Method: "initialize", Params: map[string]any{ + "protocolVersion": "2024-11-05", "capabilities": map[string]any{}, + "clientInfo": map[string]string{"name": "vps-commander-probe", "version": "1.0"}, + }}); err != nil { + return nil, err + } + if _, err := post(mcpRPC{JSONRPC: "2.0", Method: "notifications/initialized", Params: map[string]any{}}); err != nil { + return nil, err + } + out, err := post(mcpRPC{JSONRPC: "2.0", ID: int64(2), Method: "tools/list", Params: map[string]any{}}) + if err != nil { + return nil, err + } + return extractMCPTools(out) +} + +func extractMCPTools(v any) ([]map[string]any, error) { + obj, ok := v.(map[string]any) + if !ok { + return nil, errors.New("invalid tools/list response") + } + raw, ok := obj["tools"].([]any) + if !ok { + return []map[string]any{}, nil + } + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + if m, ok := item.(map[string]any); ok { + tool := map[string]any{"name": m["name"], "description": m["description"]} + out = append(out, tool) + } + } + return out, nil +} + func callStdio(ctx context.Context, s MCPService, tool string, args any) (any, error) { if s.Command == "" { return nil, errors.New("MCP stdio command is empty") diff --git a/internal/storage/storage.go b/internal/storage/storage.go index d68dcb6..e0fa608 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -7,6 +7,7 @@ import ( _ "modernc.org/sqlite" "os" "path/filepath" + "strings" "time" ) @@ -44,7 +45,7 @@ func (s *Store) init() error { "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); " + - "CREATE TABLE IF NOT EXISTS revoked_devices (name TEXT PRIMARY KEY, revoked_at INTEGER NOT NULL, reason TEXT); CREATE TABLE IF NOT EXISTS mcp_services (id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT, transport TEXT NOT NULL, command TEXT, args_json TEXT, env_json TEXT, url TEXT, headers_json TEXT, scope TEXT NOT NULL DEFAULT 'all', target_nodes_json TEXT, enabled INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL); CREATE INDEX IF NOT EXISTS idx_mcp_services_enabled ON mcp_services(enabled);") + "CREATE TABLE IF NOT EXISTS revoked_devices (name TEXT PRIMARY KEY, revoked_at INTEGER NOT NULL, reason TEXT); CREATE TABLE IF NOT EXISTS security_settings (node_name TEXT PRIMARY KEY, mode TEXT NOT NULL, updated_at INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS mcp_services (id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT, transport TEXT NOT NULL, command TEXT, args_json TEXT, env_json TEXT, url TEXT, headers_json TEXT, scope TEXT NOT NULL DEFAULT 'all', target_nodes_json TEXT, enabled INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL); CREATE INDEX IF NOT EXISTS idx_mcp_services_enabled ON mcp_services(enabled);") if err != nil { return err } @@ -330,3 +331,80 @@ func (s *Store) IsRevoked(name string) (bool, error) { err := s.DB.QueryRow("SELECT COUNT(*) FROM revoked_devices WHERE name = ?", name).Scan(&count) return count > 0, err } + +func normalizeSecurityMode(mode string) string { + mode = strings.ToLower(strings.TrimSpace(mode)) + switch mode { + case "inherit", "low", "medium", "high": + return mode + } + return "" +} + +func (s *Store) GetGlobalSecurityMode() string { + var mode string + err := s.DB.QueryRow("SELECT mode FROM security_settings WHERE node_name='' LIMIT 1").Scan(&mode) + if err != nil { + return "medium" + } + if m := normalizeSecurityMode(mode); m != "" && m != "inherit" { + return m + } + return "medium" +} + +func (s *Store) SetGlobalSecurityMode(mode string) error { + mode = normalizeSecurityMode(mode) + if mode == "" || mode == "inherit" { + return errors.New("invalid global security mode") + } + _, err := s.DB.Exec("INSERT INTO security_settings(node_name,mode,updated_at) VALUES('',?,?) ON CONFLICT(node_name) DO UPDATE SET mode=excluded.mode,updated_at=excluded.updated_at", mode, time.Now().Unix()) + return err +} + +func (s *Store) GetNodeSecurityMode(node string) (string, bool) { + var mode string + err := s.DB.QueryRow("SELECT mode FROM security_settings WHERE node_name=?", node).Scan(&mode) + if err != nil { + return "inherit", false + } + m := normalizeSecurityMode(mode) + if m == "" { + return "inherit", false + } + return m, true +} + +func (s *Store) SetNodeSecurityMode(node, mode string) error { + if node == "" { + return errors.New("node name required") + } + mode = normalizeSecurityMode(mode) + if mode == "" { + return errors.New("invalid security mode") + } + _, err := s.DB.Exec("INSERT INTO security_settings(node_name,mode,updated_at) VALUES(?,?,?) ON CONFLICT(node_name) DO UPDATE SET mode=excluded.mode,updated_at=excluded.updated_at", node, mode, time.Now().Unix()) + return err +} + +type SecuritySetting struct { + Node string `json:"node"` + Mode string `json:"mode"` +} + +func (s *Store) ListNodeSecurityModes() ([]SecuritySetting, error) { + rows, err := s.DB.Query("SELECT node_name,mode FROM security_settings WHERE node_name<>'' ORDER BY node_name") + if err != nil { + return nil, err + } + defer rows.Close() + out := []SecuritySetting{} + for rows.Next() { + var v SecuritySetting + if err := rows.Scan(&v.Node, &v.Mode); err != nil { + return nil, err + } + out = append(out, v) + } + return out, rows.Err() +} diff --git a/internal/sysinfo/sysinfo.go b/internal/sysinfo/sysinfo.go index 64171af..0498de1 100644 --- a/internal/sysinfo/sysinfo.go +++ b/internal/sysinfo/sysinfo.go @@ -7,15 +7,30 @@ import ( "runtime" "strconv" "strings" + "sync" "syscall" "time" ) +var netMu sync.Mutex +var lastNet = struct { + rx, tx uint64 + at time.Time +}{} + type Profile struct { Role string `json:"role,omitempty"` CPUCores int `json:"cpu_cores"` CPUUsagePercent int `json:"cpu_usage_percent"` Load1M float64 `json:"load_1m"` + Load5M float64 `json:"load_5m"` + Load15M float64 `json:"load_15m"` + NetRXRate float64 `json:"net_rx_rate"` + NetTXRate float64 `json:"net_tx_rate"` + NetRXTotal uint64 `json:"net_rx_total"` + NetTXTotal uint64 `json:"net_tx_total"` + UptimeSeconds float64 `json:"uptime_seconds"` + BootTime int64 `json:"boot_time"` MemPercent int `json:"mem_percent"` MemAvailable string `json:"mem_available"` DiskPercent int `json:"disk_percent"` @@ -36,7 +51,9 @@ func Collect(defaultRole string) Profile { cores := runtime.NumCPU() cpuPercent := getCPUUsage() - load1m := getLoad1M() + load1m, load5m, load15m := getLoads() + netRX, netTX, netRXT, netTXT := getNetwork() + uptime, boot := getUptime() memPct, memAvl := getMemInfo() diskPct, diskFree := getDiskInfo() psi := getIOPSI() @@ -47,6 +64,14 @@ func Collect(defaultRole string) Profile { CPUCores: cores, CPUUsagePercent: cpuPercent, Load1M: load1m, + Load5M: load5m, + Load15M: load15m, + NetRXRate: netRX, + NetTXRate: netTX, + NetRXTotal: netRXT, + NetTXTotal: netTXT, + UptimeSeconds: uptime, + BootTime: boot, MemPercent: memPct, MemAvailable: memAvl, DiskPercent: diskPct, @@ -57,16 +82,70 @@ func Collect(defaultRole string) Profile { } } -func getLoad1M() float64 { - if data, err := os.ReadFile("/proc/loadavg"); err == nil { - fields := strings.Fields(string(data)) - if len(fields) > 0 { - if val, err := strconv.ParseFloat(fields[0], 64); err == nil { - return val - } +func getLoads() (float64, float64, float64) { + data, err := os.ReadFile("/proc/loadavg") + if err != nil { + return 0, 0, 0 + } + f := strings.Fields(string(data)) + if len(f) < 3 { + return 0, 0, 0 + } + v := func(i int) float64 { x, _ := strconv.ParseFloat(f[i], 64); return x } + return v(0), v(1), v(2) +} + +func getNetwork() (float64, float64, uint64, uint64) { + data, err := os.ReadFile("/proc/net/dev") + if err != nil { + return 0, 0, 0, 0 + } + var rx, tx uint64 + for _, line := range strings.Split(string(data), "\n") { + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 || strings.TrimSpace(parts[0]) == "lo" { + continue + } + f := strings.Fields(parts[1]) + if len(f) < 9 { + continue + } + r, e1 := strconv.ParseUint(f[0], 10, 64) + t, e2 := strconv.ParseUint(f[8], 10, 64) + if e1 == nil && e2 == nil { + rx += r + tx += t } } - return 0.0 + now := time.Now() + netMu.Lock() + defer netMu.Unlock() + var rr, tr float64 + if !lastNet.at.IsZero() && now.After(lastNet.at) && rx >= lastNet.rx && tx >= lastNet.tx { + d := now.Sub(lastNet.at).Seconds() + if d > 0 { + rr = float64(rx-lastNet.rx) / d + tr = float64(tx-lastNet.tx) / d + } + } + lastNet.rx, lastNet.tx, lastNet.at = rx, tx, now + return rr, tr, rx, tx +} + +func getUptime() (float64, int64) { + data, err := os.ReadFile("/proc/uptime") + if err != nil { + return 0, time.Now().Unix() + } + f := strings.Fields(string(data)) + if len(f) == 0 { + return 0, time.Now().Unix() + } + u, err := strconv.ParseFloat(f[0], 64) + if err != nil { + return 0, time.Now().Unix() + } + return u, time.Now().Add(-time.Duration(u * float64(time.Second))).Unix() } func getCPUUsage() int { diff --git a/internal/web/panel.go b/internal/web/panel.go index 23444ac..a64a74a 100644 --- a/internal/web/panel.go +++ b/internal/web/panel.go @@ -38,6 +38,9 @@ func (p *Panel) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/panel/login", p.login) mux.HandleFunc("/panel/logout", p.logout) + mux.HandleFunc("/panel", p.index) + mux.HandleFunc("/panel/", p.index) + mux.HandleFunc("/probe/summary", p.probeSummary) mux.HandleFunc("/static/", p.static) mux.HandleFunc("/panel/api/devices", p.protected(p.handleDevices)) mux.HandleFunc("/panel/api/audits", p.protected(p.Runner.AuditsJSON)) @@ -47,15 +50,24 @@ func (p *Panel) Handler() http.Handler { mux.HandleFunc("/panel/api/mcp/service", p.protected(p.deleteMCPService)) mux.HandleFunc("/panel/api/mcp/list", p.protected(p.listAgentMCP)) mux.HandleFunc("/panel/api/mcp/call", p.protected(p.callAgentMCP)) - mux.HandleFunc("/", p.index) + mux.HandleFunc("/panel/api/mcp/test", p.protected(p.testMCP)) + mux.HandleFunc("/panel/api/security/settings", p.protected(p.securitySettings)) + mux.HandleFunc("/panel/api/security/settings/update", p.protected(p.updateSecuritySettings)) + mux.HandleFunc("/", p.probe) return mux } +type securityPanelRunner interface { + SecuritySettingsJSON(http.ResponseWriter, *http.Request) + UpdateSecuritySettingsJSON(http.ResponseWriter, *http.Request) +} + type mcpPanelRunner interface { MCPServicesJSON(http.ResponseWriter, *http.Request) DeleteMCPService(http.ResponseWriter, *http.Request) ListAgentMCPJSON(http.ResponseWriter, *http.Request) CallAgentMCPJSON(http.ResponseWriter, *http.Request) + TestMCPJSON(http.ResponseWriter, *http.Request) } func (p *Panel) mcpServices(w http.ResponseWriter, r *http.Request) { @@ -86,6 +98,28 @@ func (p *Panel) callAgentMCP(w http.ResponseWriter, r *http.Request) { } http.Error(w, "MCP API unavailable", 501) } +func (p *Panel) securitySettings(w http.ResponseWriter, r *http.Request) { + if v, ok := p.Runner.(securityPanelRunner); ok { + v.SecuritySettingsJSON(w, r) + return + } + http.Error(w, "security API unavailable", 501) +} +func (p *Panel) updateSecuritySettings(w http.ResponseWriter, r *http.Request) { + if v, ok := p.Runner.(securityPanelRunner); ok { + v.UpdateSecuritySettingsJSON(w, r) + return + } + http.Error(w, "security API unavailable", 501) +} + +func (p *Panel) testMCP(w http.ResponseWriter, r *http.Request) { + if v, ok := p.Runner.(mcpPanelRunner); ok { + v.TestMCPJSON(w, r) + return + } + http.Error(w, "MCP API unavailable", 501) +} func (p *Panel) handleDevices(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodDelete { @@ -95,6 +129,24 @@ func (p *Panel) handleDevices(w http.ResponseWriter, r *http.Request) { p.Runner.DevicesJSON(w, r) } +func (p *Panel) probeSummary(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", 405) + return + } + p.Runner.DevicesJSON(w, r) +} + +func (p *Panel) probe(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + data, _ := staticFS.ReadFile("static/probe.html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(data) +} + func (p *Panel) index(w http.ResponseWriter, r *http.Request) { if !p.valid(r) { http.Redirect(w, r, "/panel/login", http.StatusFound) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 9e42639..50f99e5 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -41,5 +41,7 @@ header strong{font-size:18px}header span{margin-left:12px;color:var(--text-muted .device-card.offline{opacity:.62;filter:saturate(.45)} .device-card.offline .btn-revoke{opacity:1} -.mcp-toolbar{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:14px}.mcp-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px}.mcp-item{border:1px solid var(--line,#30343b);border-radius:10px;padding:14px;background:var(--card,#11151a)}.mcp-item h3{margin:0 0 6px;font-size:15px}.mcp-meta{font-size:12px;opacity:.72;line-height:1.7}.mcp-actions{display:flex;gap:8px;margin-top:10px}.mcp-form{display:grid;grid-template-columns:1fr 1fr;gap:10px}.mcp-form .full{grid-column:1/-1}.mcp-form label{display:flex;flex-direction:column;gap:5px;font-size:12px;opacity:.9}.mcp-form input,.mcp-form select,.mcp-form textarea{width:100%;box-sizing:border-box}.mcp-form textarea{min-height:72px;font-family:monospace}.mcp-nodes{display:flex;flex-wrap:wrap;gap:8px}.mcp-nodes label{flex-direction:row;align-items:center}.mcp-status{font-size:12px;margin-top:8px;min-height:18px} +.mcp-toolbar{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:14px}.mcp-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px}.mcp-item{border:1px solid var(--line,#30343b);border-radius:10px;padding:14px;background:var(--card,#11151a)}.mcp-item h3{margin:0 0 6px;font-size:15px}.mcp-meta{font-size:12px;opacity:.72;line-height:1.7}.mcp-actions{display:flex;gap:8px;margin-top:10px}.mcp-form{display:grid;grid-template-columns:1fr 1fr;gap:10px}.mcp-form .full{grid-column:1/-1}.mcp-form label{display:flex;flex-direction:column;gap:5px;font-size:12px;opacity:.9}.mcp-form input,.mcp-form select,.mcp-form textarea{width:100%;box-sizing:border-box}.mcp-form textarea{min-height:72px;font-family:monospace}.mcp-nodes{display:flex;flex-wrap:wrap;gap:8px}.mcp-nodes label{flex-direction:row;align-items:center}.mcp-status{font-size:12px;margin-top:8px;min-height:18px}.mcp-field-error{border-color:#e05252!important;outline:1px solid #e05252}.mcp-test-result{margin-top:12px;padding:12px;border:1px solid var(--line,#30343b);border-radius:10px;white-space:normal}.mcp-test-result.ok{border-color:#3a9b68}.mcp-test-result.fail{border-color:#c44}.mcp-test-tools{margin:8px 0 0;padding-left:18px}.mcp-test-detail{margin-top:8px;white-space:pre-wrap;overflow:auto;max-height:220px;font-family:monospace;font-size:12px} @media(max-width:700px){.mcp-form{grid-template-columns:1fr}.mcp-form .full{grid-column:auto}.mcp-toolbar{align-items:flex-start;flex-direction:column}} + +.security-grid{display:flex;gap:12px;align-items:end;flex-wrap:wrap;margin-bottom:16px}.security-grid label{display:flex;flex-direction:column;gap:6px}.security-grid select,.tablewrap select{padding:7px 9px;border:1px solid var(--line,#30343b);border-radius:6px;background:var(--card,#11151a);color:inherit}.security-grid button{height:34px} diff --git a/internal/web/static/app.js b/internal/web/static/app.js index b92aa16..c43fe6d 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -111,20 +111,80 @@ $("#logout").onclick=async()=>{ loadDevices().then(loadAudits).catch(console.error); let mcpEditingId=""; +function mcpFieldError(id,message){ + const el=$("#"+id); el.classList.add("mcp-field-error"); el.title=message; + const status=$("#mcpStatus"); status.textContent=message; status.style.color="#e05252"; + el.focus(); +} +function mcpClearErrors(){ + ["mcpId","mcpName","mcpCommand","mcpUrl","mcpArgs","mcpHeaders","mcpEnv"].forEach(id=>{const el=$("#"+id);if(el){el.classList.remove("mcp-field-error");el.removeAttribute("title")}}); + $("#mcpStatus").textContent=""; $("#mcpStatus").style.color=""; +} +function parseMCPField(id,label,kind){ + const el=$("#"+id); let value; + try{value=JSON.parse(el.value||"");}catch(e){mcpFieldError(id,label+" 必须是合法的 JSON "+kind+",请检查格式。");throw e} + if(kind==="数组" && !Array.isArray(value)){mcpFieldError(id,label+" 必须是合法的 JSON 数组,如 []。");throw Error("invalid "+id)} + if(kind==="对象" && (value===null||Array.isArray(value)||typeof value!=="object")){mcpFieldError(id,label+" 必须是合法的 JSON 对象,如 {}。");throw Error("invalid "+id)} + return value; +} +function showMCPToast(message){ + let t=$("#mcpToast"); if(!t){t=document.createElement("div");t.id="mcpToast";t.style.cssText="position:fixed;right:18px;top:18px;z-index:9999;padding:10px 14px;border:1px solid var(--line,#30343b);border-radius:8px;background:var(--card,#11151a);box-shadow:0 8px 30px rgba(0,0,0,.25)";document.body.appendChild(t)} + t.textContent=message; t.hidden=false; clearTimeout(t._timer); t._timer=setTimeout(()=>t.hidden=true,2600); +} async function loadMCPServices(){ const services=await api("/mcp/services"); - const devices=(await api("/devices")).filter(d=>!d.is_local); + const allDevices=await api("/devices"); + const devices=allDevices.filter(d=>!d.is_local); + $("#mcpTestNode").innerHTML=''+devices.filter(d=>d.status==="online").map(d=>'').join(""); $("#mcpNodes").innerHTML=devices.map(d=>``).join(""); $("#mcpList").innerHTML=services.map(s=>`

${esc(s.name)} ${esc(s.id)}

${esc(s.transport)} · ${s.scope==='custom'?'指定节点':'全部节点'} · ${s.enabled?'启用':'停用'}${s.url?' · '+esc(s.url):s.command?' · '+esc(s.command):''}
`).join("")||'
暂无 MCP 服务配置。
'; } function resetMCPForm(){ - mcpEditingId=""; $("#mcpEditor").hidden=false; $("#mcpId").disabled=false; ["mcpId","mcpName","mcpCommand","mcpUrl"].forEach(id=>$("#"+id).value=""); $("#mcpTransport").value="stdio"; $("#mcpEnabled").value="1"; $("#mcpArgs").value="[]"; $("#mcpHeaders").value="{}"; $("#mcpEnv").value="{}"; $("#mcpScope").value="all"; $("#mcpStatus").textContent=""; $("#mcpNodes").querySelectorAll("input").forEach(x=>x.checked=false); + mcpEditingId=""; $("#mcpEditor").hidden=false; $("#mcpId").disabled=false; ["mcpId","mcpName","mcpCommand","mcpUrl"].forEach(id=>$("#"+id).value=""); $("#mcpTransport").value="stdio"; $("#mcpEnabled").value="1"; $("#mcpArgs").value="[]"; $("#mcpHeaders").value="{}"; $("#mcpEnv").value="{}"; $("#mcpScope").value="all"; $("#mcpStatus").textContent=""; $("#mcpTestResult").hidden=true; $("#mcpTestResult").textContent=""; $("#mcpNodes").querySelectorAll("input").forEach(x=>x.checked=false); } async function editMCP(id){ const services=await api("/mcp/services"); const s=services.find(x=>x.id===id); if(!s)return; resetMCPForm(); mcpEditingId=id; $("#mcpId").value=s.id; $("#mcpId").disabled=true; $("#mcpName").value=s.name||""; $("#mcpTransport").value=s.transport; $("#mcpEnabled").value=s.enabled?"1":"0"; $("#mcpCommand").value=s.command||""; $("#mcpUrl").value=s.url||""; $("#mcpArgs").value=s.args_json||"[]"; $("#mcpHeaders").value=s.headers_json||"{}"; $("#mcpEnv").value=s.env_json||"{}"; $("#mcpScope").value=s.scope||"all"; let nodes=[];try{nodes=JSON.parse(s.target_nodes_json||"[]")}catch(e){};$("#mcpNodes").querySelectorAll("input").forEach(x=>x.checked=nodes.includes(x.value)); } async function deleteMCP(id){if(!confirm(`确认删除 MCP 服务 [${id}]?`))return;try{await api("/mcp/service?id="+encodeURIComponent(id),{method:"DELETE"});await loadMCPServices()}catch(e){alert("删除失败: "+e.message)}} $("#mcpNew").onclick=resetMCPForm; $("#mcpCancel").onclick=()=>$("#mcpEditor").hidden=true; -$("#mcpSave").onclick=async()=>{try{const scope=$("#mcpScope").value;const body={id:$("#mcpId").value.trim(),name:$("#mcpName").value.trim(),transport:$("#mcpTransport").value,command:$("#mcpCommand").value.trim(),url:$("#mcpUrl").value.trim(),scope,enabled:$("#mcpEnabled").value==="1",args:JSON.parse($("#mcpArgs").value||"[]"),headers:JSON.parse($("#mcpHeaders").value||"{}"),env:JSON.parse($("#mcpEnv").value||"{}"),target_nodes:[...$("#mcpNodes").querySelectorAll("input:checked")].map(x=>x.value)};if(!body.id||!body.name)throw Error("服务标识和显示名称不能为空");await api("/mcp/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});$("#mcpStatus").textContent="已保存并向在线节点同步。";await loadMCPServices()}catch(e){$("#mcpStatus").textContent="保存失败: "+e.message}}; +$("#mcpSave").onclick=async()=>{ + mcpClearErrors(); + try{ + const transport=$("#mcpTransport").value; + const body={id:$("#mcpId").value.trim(),name:$("#mcpName").value.trim(),transport,command:$("#mcpCommand").value.trim(),url:$("#mcpUrl").value.trim(),scope:$("#mcpScope").value,enabled:$("#mcpEnabled").value==="1",args:parseMCPField("mcpArgs","Arguments","数组"),headers:parseMCPField("mcpHeaders","Headers","对象"),env:parseMCPField("mcpEnv","环境变量","对象"),target_nodes:[...$("#mcpNodes").querySelectorAll("input:checked")].map(x=>x.value)}; + if(!body.id){mcpFieldError("mcpId","服务标识不能为空。");return} + if(!body.name){mcpFieldError("mcpName","显示名称不能为空。");return} + if(transport==="stdio"&&!body.command){mcpFieldError("mcpCommand","stdio 模式必须填写 Command。");return} + if(transport!=="stdio"&&!body.url){mcpFieldError("mcpUrl","HTTP/SSE 模式必须填写 URL。");return} + await api("/mcp/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}); + showMCPToast("MCP ["+body.id+"] 已保存并同步到在线节点"); + $("#mcpEditor").hidden=true; + await loadMCPServices(); + }catch(e){if(!$("#mcpStatus").textContent)$("#mcpStatus").textContent="保存失败: "+e.message} +}; +$("#mcpTest").onclick=async()=>{ + mcpClearErrors(); + const result=$("#mcpTestResult"); result.hidden=false; result.className="mcp-test-result"; result.textContent="正在测试连通性…"; + try{ + const transport=$("#mcpTransport").value; + const service={id:$("#mcpId").value.trim(),name:$("#mcpName").value.trim(),transport,command:$("#mcpCommand").value.trim(),url:$("#mcpUrl").value.trim(),args:parseMCPField("mcpArgs","Arguments","数组"),headers:parseMCPField("mcpHeaders","Headers","对象"),env:parseMCPField("mcpEnv","环境变量","对象")}; + if(transport==="stdio"&&!service.command){mcpFieldError("mcpCommand","stdio 模式必须填写 Command。");result.hidden=true;return} + if(transport!=="stdio"&&!service.url){mcpFieldError("mcpUrl","HTTP/SSE 模式必须填写 URL。");result.hidden=true;return} + let node=$("#mcpTestNode").value; + if(node==="auto"||!node){const ds=(await api("/devices")).filter(d=>d.status==="online");node=ds.find(d=>!d.is_local)?.name||"local"} + const x=await api("/mcp/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({device:node,service})}); + if(!x.success)throw Error((x.error&&x.error.message)||"连通失败"); + result.className="mcp-test-result ok"; + result.innerHTML="✓ 连通成功! "+esc(x.device)+" · "+esc(x.transport)+" · "+x.duration_ms+"ms · 检测到 "+x.tool_count+" 个可用工具"; + if(x.tool_count){result.innerHTML+='"} + }catch(e){ + result.className="mcp-test-result fail"; + result.innerHTML='✗ 连通失败
'+esc(e.message)+"
"; + } +}; $("#mcpTransport").onchange=()=>{const stdio=$("#mcpTransport").value==="stdio";$("#mcpCommand").parentElement.style.display=stdio?"":"none";$("#mcpArgs").parentElement.style.display=stdio?"":"none";$("#mcpUrl").parentElement.style.display=stdio?"none":""}; loadMCPServices().catch(console.error); + +async function loadSecuritySettings(){const x=await api("/security/settings"),nodes=x.nodes||[],effective=x.effective||{},configured=new Map(nodes.map(n=>[n.node,n.mode])),devices=await api("/devices");$("#globalSecurity").value=x.global||"medium";$("#securityNodes").innerHTML=devices.map(d=>{const mode=configured.get(d.name)||"inherit",eff=effective[d.name]||x.global||"medium";return `${esc(d.name)}${esc(eff)}`}).join("")} +async function saveNodeSecurity(node){const el=document.querySelector(`[data-security-node="${CSS.escape(node)}"]`);if(!el)return;try{await api("/security/settings/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({node,mode:el.value})});$("#securityStatus").textContent=`${node} 策略已保存。`;await loadSecuritySettings()}catch(e){$("#securityStatus").textContent="保存失败: "+e.message}} +$("#saveGlobalSecurity").onclick=async()=>{try{await api("/security/settings/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({global:$("#globalSecurity").value})});$("#securityStatus").textContent="全局安全策略已保存。";await loadSecuritySettings()}catch(e){$("#securityStatus").textContent="保存失败: "+e.message}};$("#securityRefresh").onclick=()=>loadSecuritySettings().catch(console.error);loadSecuritySettings().catch(console.error); diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 3715c04..177c836 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -1,2 +1,2 @@ -VPS-Commander
VPS-CommanderPrivate Infrastructure Console

设备看板

Actions 向导

当前 Hub 的 OpenAPI Schema:

/openapi.json

MCP 挂载

-

快速控制台

审计日志

时间设备动作命令/路径退出码耗时来源
+VPS-Commander
VPS-CommanderPrivate Infrastructure Console

设备看板

Actions 向导

当前 Hub 的 OpenAPI Schema:

/openapi.json

MCP 挂载

+

快速控制台

安全设置

节点单机策略有效策略操作

审计日志

时间设备动作命令/路径退出码耗时来源
diff --git a/internal/web/static/probe.css b/internal/web/static/probe.css new file mode 100644 index 0000000..c9ae759 --- /dev/null +++ b/internal/web/static/probe.css @@ -0,0 +1 @@ +*{box-sizing:border-box}body{margin:0;background:#0b0d10;color:#e8edf2;font:14px system-ui,-apple-system,sans-serif}main{max-width:1400px;margin:auto;padding:28px 20px}header{display:flex;justify-content:space-between;align-items:center;margin-bottom:22px}h1{margin:0;font-size:24px}header p{margin:4px 0;color:#7f8b98}.switch{display:flex;gap:4px}.switch button{border:1px solid #2a3139;background:#11161b;color:#aeb8c2;padding:7px 12px;border-radius:7px;cursor:pointer}.switch button.active{color:#fff;border-color:#647180}.card{display:grid;grid-template-columns:1.4fr repeat(7,1fr);gap:12px;align-items:center;border:1px solid #20262d;background:#11161b;border-radius:10px;padding:14px;margin-bottom:10px}.name{display:flex;gap:8px;align-items:center}.on,.off{font-size:12px;padding:2px 6px;border-radius:10px;border:1px solid #303840}.on{color:#9bd6ad}.off{color:#e2a1a1}.card>span,.tr>span{color:#9ca8b4;font-size:13px}.table{border:1px solid #20262d;border-radius:9px;overflow:auto}.tr{min-width:1050px;display:grid;grid-template-columns:1.2fr .8fr 1.2fr .7fr .7fr 1.3fr 1.5fr 1fr;gap:12px;padding:11px 13px;border-bottom:1px solid #1e242a;align-items:center}.tr:last-child{border-bottom:0}.error{color:#e2a1a1}@media(max-width:900px){.card{grid-template-columns:1fr 1fr}.card .name{grid-column:1/-1}} diff --git a/internal/web/static/probe.html b/internal/web/static/probe.html new file mode 100644 index 0000000..3ba7fd7 --- /dev/null +++ b/internal/web/static/probe.html @@ -0,0 +1,4 @@ + + +VPS Probe +

VPS Probe

Infrastructure status

diff --git a/internal/web/static/probe.js b/internal/web/static/probe.js new file mode 100644 index 0000000..96827bf --- /dev/null +++ b/internal/web/static/probe.js @@ -0,0 +1,14 @@ +(() => { + const root = document.querySelector('#content'); + const fmtBytes = n => { if (!Number.isFinite(n)) return '-'; const u=['B','KB','MB','GB','TB']; let i=0,v=n; while(v>=1024&&i `${fmtBytes(n)}/s`; + const fmtUp = n => { let s=Math.max(0,Math.floor(n||0)); const d=Math.floor(s/86400); s%=86400; const h=Math.floor(s/3600); s%=3600; const m=Math.floor(s/60); return d?`${d}天${h}小时`:h?`${h}小时${m}分`:`${m}分`; }; + const esc = s => String(s??'-').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + let mode=localStorage.getItem('vpc_probe_view')||'card'; + const setMode=m=>{mode=m;localStorage.setItem('vpc_probe_view',m);render(window.__data||[]);}; + document.querySelector('#cardBtn').onclick=()=>setMode('card'); document.querySelector('#compactBtn').onclick=()=>setMode('compact'); + const row=(d,card)=>{const p=d.profile||{}; return `
${esc(d.name)}${d.status==='online'?'在线':'离线'}
Uptime ${fmtUp(p.uptime_seconds)}Load ${[p.load_1m,p.load_5m,p.load_15m].map(x=>Number(x||0).toFixed(2)).join(' / ')}CPU ${p.cpu_usage_percent??'-'}%MEM ${p.mem_percent??'-'}%↓ ${fmtRate(p.net_rx_rate)} ↑ ${fmtRate(p.net_tx_rate)}流量 ↓ ${fmtBytes(p.net_rx_total)} ↑ ${fmtBytes(p.net_tx_total)}Disk ${p.disk_free||'-'} ${p.disk_percent??'-'}%
`;}; + function render(data){document.querySelector('#cardBtn').classList.toggle('active',mode==='card');document.querySelector('#compactBtn').classList.toggle('active',mode==='compact');root.className=mode;root.innerHTML=mode==='card'?data.map(d=>row(d,true)).join(''):`
${data.map(d=>row(d,false)).join('')}
`;} + async function load(){try{const r=await fetch('/probe/summary',{cache:'no-store'});if(!r.ok)throw Error();window.__data=await r.json();render(window.__data);}catch(e){root.innerHTML='

探针数据暂时不可用

';}} + load();setInterval(load,5000); +})();