Actions 向导
当前 Hub 的 OpenAPI Schema:
/openapi.json
快速控制台
审计日志
| 时间 | 设备 | 动作 | 命令/路径 | 退出码 | 耗时 | 来源 |
|---|
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(t.name||"")+""+(t.description?" — "+esc(t.description):"")+"当前 Hub 的 OpenAPI Schema:
/openapi.json
| 时间 | 设备 | 动作 | 命令/路径 | 退出码 | 耗时 | 来源 |
|---|
当前 Hub 的 OpenAPI Schema:
/openapi.json
| 节点 | 单机策略 | 有效策略 | 操作 |
|---|
| 时间 | 设备 | 动作 | 命令/路径 | 退出码 | 耗时 | 来源 |
|---|
Infrastructure status
探针数据暂时不可用
';}} + load();setInterval(load,5000); +})();