feat: finalize dc dual-channel security rollout
This commit is contained in:
+166
-5
@@ -23,7 +23,7 @@ import (
|
||||
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")
|
||||
token := flag.String("token", firstNonEmpty(os.Getenv("VPS_COMMANDER_AGENT_TOKEN"), os.Getenv("VPS_COMMANDER_CLUSTER_SECRET")), "agent credential")
|
||||
insecure := flag.Bool("insecure", false, "skip TLS verification")
|
||||
flag.Parse()
|
||||
if *hub == "" || *name == "" || *token == "" {
|
||||
@@ -74,7 +74,7 @@ func run(hub, name, token string, insecure bool) error {
|
||||
|
||||
// 初始握手附带画像
|
||||
initProf := sysinfo.Collect("APPLICATION")
|
||||
if err := writeJSON(cluster.Message{Event: "hello", Device: name, Arch: runtime.GOARCH, OS: runtime.GOOS, Profile: &initProf, TS: time.Now().Unix()}); err != nil {
|
||||
if err := writeJSON(cluster.Message{Event: "hello", Device: name, Arch: runtime.GOARCH, OS: runtime.GOOS, Profile: &initProf, TS: time.Now().Unix(), ProtocolVersion: "2", AgentVersion: "2.0", Capabilities: []string{"read_file", "read_multiple_files", "list_directory", "get_file_info", "start_search", "get_more_search_results", "list_processes", "list_sessions", "read_process_output", "edit_block", "write_file", "create_directory", "move_file", "exec_command", "start_process", "interact_with_process", "kill_process", "force_terminate", "mcp_call", "mcp_list", "mcp_test"}}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -94,6 +94,20 @@ func run(hub, name, token string, insecure bool) error {
|
||||
}
|
||||
}()
|
||||
|
||||
sessions := executor.NewSessionManager(16)
|
||||
go func() {
|
||||
t := time.NewTicker(1 * time.Minute)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-t.C:
|
||||
sessions.Cleanup()
|
||||
case <-stopPing:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
var msg cluster.Message
|
||||
if err := c.ReadJSON(&msg); err != nil {
|
||||
@@ -128,15 +142,15 @@ func run(hub, name, token string, insecure bool) error {
|
||||
continue
|
||||
}
|
||||
go func(msg cluster.Message) {
|
||||
if err := handle(writeJSON, exec, mcpRuntime, msg); err != nil {
|
||||
if err := handle(writeJSON, exec, mcpRuntime, sessions, msg); err != nil {
|
||||
log.Printf("request %s failed: %v", msg.ID, err)
|
||||
_ = c.Close()
|
||||
_ = sendError(writeJSON, msg.ID, err)
|
||||
}
|
||||
}(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func handle(writeJSON func(cluster.Message) error, exec executor.Local, mcpRuntime *cluster.MCPRuntime, msg cluster.Message) error {
|
||||
func handle(writeJSON func(cluster.Message) error, exec executor.Local, mcpRuntime *cluster.MCPRuntime, sessions *executor.SessionManager, msg cluster.Message) error {
|
||||
switch msg.Action {
|
||||
case "mcp_list":
|
||||
return writeJSON(cluster.Message{ID: msg.ID, Status: "success", Payload: mcpRuntime.List()})
|
||||
@@ -176,6 +190,134 @@ func handle(writeJSON func(cluster.Message) error, exec executor.Local, mcpRunti
|
||||
return nil
|
||||
}
|
||||
return writeJSON(cluster.Message{ID: msg.ID, Status: "success", Payload: map[string]any{"server": p.Server, "tool": p.Tool, "result": result, "duration_ms": duration}})
|
||||
case "start_process":
|
||||
var p cluster.StartProcessPayload
|
||||
if err := decodePayload(msg.Payload, &p); err != nil {
|
||||
return sendError(writeJSON, msg.ID, err)
|
||||
}
|
||||
if p.SessionID == "" || p.Command == "" {
|
||||
return sendError(writeJSON, msg.ID, errors.New("session_id and command required"))
|
||||
}
|
||||
s, err := sessions.Start(context.Background(), p.SessionID, p.Command, p.Workdir, p.Owner, p.RingCapacity)
|
||||
if err != nil {
|
||||
return sendError(writeJSON, msg.ID, err)
|
||||
}
|
||||
return writeJSON(cluster.Message{ID: msg.ID, Status: "success", Payload: map[string]any{"session_id": s.ID, "started_at": s.StartedAt.Unix()}})
|
||||
case "read_process_output":
|
||||
var p struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Owner string `json:"owner"`
|
||||
}
|
||||
if err := decodePayload(msg.Payload, &p); err != nil {
|
||||
return sendError(writeJSON, msg.ID, err)
|
||||
}
|
||||
s, ok := sessions.Get(p.SessionID)
|
||||
if !ok {
|
||||
return sendError(writeJSON, msg.ID, errors.New("session not found"))
|
||||
}
|
||||
if !s.Authorized(p.Owner) {
|
||||
return sendError(writeJSON, msg.ID, errors.New("session ownership denied"))
|
||||
}
|
||||
code, done, _ := s.Status()
|
||||
out, errout := s.Output()
|
||||
return writeJSON(cluster.Message{ID: msg.ID, Status: "success", Payload: cluster.ProcessOutputPayload{SessionID: p.SessionID, Stdout: out, Stderr: errout, ExitCode: code, Running: !done}})
|
||||
case "interact_with_process":
|
||||
var p cluster.ProcessInputPayload
|
||||
if err := decodePayload(msg.Payload, &p); err != nil {
|
||||
return sendError(writeJSON, msg.ID, err)
|
||||
}
|
||||
s, ok := sessions.Get(p.SessionID)
|
||||
if !ok {
|
||||
return sendError(writeJSON, msg.ID, errors.New("session not found"))
|
||||
}
|
||||
if !s.Authorized(p.Owner) {
|
||||
return sendError(writeJSON, msg.ID, errors.New("session ownership denied"))
|
||||
}
|
||||
if err := s.Stdin([]byte(p.Data)); err != nil {
|
||||
return sendError(writeJSON, msg.ID, err)
|
||||
}
|
||||
return writeJSON(cluster.Message{ID: msg.ID, Status: "success"})
|
||||
case "force_terminate":
|
||||
var p struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Owner string `json:"owner"`
|
||||
}
|
||||
if err := decodePayload(msg.Payload, &p); err != nil {
|
||||
return sendError(writeJSON, msg.ID, err)
|
||||
}
|
||||
ss, ok := sessions.Get(p.SessionID)
|
||||
if !ok {
|
||||
return sendError(writeJSON, msg.ID, errors.New("session not found"))
|
||||
}
|
||||
if !ss.Authorized(p.Owner) {
|
||||
return sendError(writeJSON, msg.ID, errors.New("session ownership denied"))
|
||||
}
|
||||
if err := sessions.Stop(p.SessionID, true); err != nil {
|
||||
return sendError(writeJSON, msg.ID, err)
|
||||
}
|
||||
return writeJSON(cluster.Message{ID: msg.ID, Status: "success"})
|
||||
case "list_sessions":
|
||||
return writeJSON(cluster.Message{ID: msg.ID, Status: "success", Payload: sessions.List()})
|
||||
case "read_multiple_files":
|
||||
var p cluster.ReadMultipleFilesPayload
|
||||
if err := decodePayload(msg.Payload, &p); err != nil {
|
||||
return sendError(writeJSON, msg.ID, err)
|
||||
}
|
||||
return writeJSON(cluster.Message{ID: msg.ID, Status: "success", Payload: executor.ReadMultipleFiles(p.Paths, p.Limit)})
|
||||
case "create_directory":
|
||||
var p cluster.CreateDirectoryPayload
|
||||
if err := decodePayload(msg.Payload, &p); err != nil {
|
||||
return sendError(writeJSON, msg.ID, err)
|
||||
}
|
||||
if err := executor.CreateDirectory(p.Path); err != nil {
|
||||
return sendError(writeJSON, msg.ID, err)
|
||||
}
|
||||
return writeJSON(cluster.Message{ID: msg.ID, Status: "success"})
|
||||
case "move_file":
|
||||
var p cluster.MoveFilePayload
|
||||
if err := decodePayload(msg.Payload, &p); err != nil {
|
||||
return sendError(writeJSON, msg.ID, err)
|
||||
}
|
||||
if err := executor.MoveFile(p.Source, p.Destination); err != nil {
|
||||
return sendError(writeJSON, msg.ID, err)
|
||||
}
|
||||
return writeJSON(cluster.Message{ID: msg.ID, Status: "success"})
|
||||
case "list_processes":
|
||||
v, err := executor.ListProcesses()
|
||||
if err != nil {
|
||||
return sendError(writeJSON, msg.ID, err)
|
||||
}
|
||||
return writeJSON(cluster.Message{ID: msg.ID, Status: "success", Payload: v})
|
||||
case "list_directory":
|
||||
var p cluster.ListDirectoryPayload
|
||||
if err := decodePayload(msg.Payload, &p); err != nil {
|
||||
return sendError(writeJSON, msg.ID, err)
|
||||
}
|
||||
v, err := executor.ListDirectory(p.Path, p.Recursive, p.MaxEntries)
|
||||
if err != nil {
|
||||
return sendError(writeJSON, msg.ID, err)
|
||||
}
|
||||
return writeJSON(cluster.Message{ID: msg.ID, Status: "success", Payload: v})
|
||||
case "get_file_info":
|
||||
var p cluster.FileInfoPayload
|
||||
if err := decodePayload(msg.Payload, &p); err != nil {
|
||||
return sendError(writeJSON, msg.ID, err)
|
||||
}
|
||||
v, err := executor.GetFileInfo(p.Path)
|
||||
if err != nil {
|
||||
return sendError(writeJSON, msg.ID, err)
|
||||
}
|
||||
return writeJSON(cluster.Message{ID: msg.ID, Status: "success", Payload: v})
|
||||
case "start_search":
|
||||
var p cluster.SearchPayload
|
||||
if err := decodePayload(msg.Payload, &p); err != nil {
|
||||
return sendError(writeJSON, msg.ID, err)
|
||||
}
|
||||
v, err := executor.Search(context.Background(), p.Root, p.Query, p.MaxResults)
|
||||
if err != nil {
|
||||
return sendError(writeJSON, msg.ID, err)
|
||||
}
|
||||
return writeJSON(cluster.Message{ID: msg.ID, Status: "success", Payload: v})
|
||||
case "exec":
|
||||
var p cluster.ExecPayload
|
||||
if err := decodePayload(msg.Payload, &p); err != nil {
|
||||
@@ -223,6 +365,16 @@ func handle(writeJSON func(cluster.Message) error, exec executor.Local, mcpRunti
|
||||
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 "edit_block":
|
||||
var p cluster.EditBlockPayload
|
||||
if err := decodePayload(msg.Payload, &p); err != nil {
|
||||
return sendError(writeJSON, msg.ID, err)
|
||||
}
|
||||
res, err := executor.EditBlock(p.Path, p.OldText, p.NewText)
|
||||
if err != nil {
|
||||
return sendError(writeJSON, msg.ID, err)
|
||||
}
|
||||
return writeJSON(cluster.Message{ID: msg.ID, Status: "success", Payload: res})
|
||||
case "file_write":
|
||||
var p cluster.FileWritePayload
|
||||
if err := decodePayload(msg.Payload, &p); err != nil {
|
||||
@@ -251,3 +403,12 @@ func decodePayload(v any, out any) error {
|
||||
func sendError(writeJSON func(cluster.Message) error, id string, err error) error {
|
||||
return writeJSON(cluster.Message{ID: id, Status: "error", Error: err.Error()})
|
||||
}
|
||||
|
||||
func firstNonEmpty(vs ...string) string {
|
||||
for _, v := range vs {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
+18
-8
@@ -37,8 +37,12 @@ func main() {
|
||||
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")
|
||||
adminToken := os.Getenv("VPS_COMMANDER_ADMIN_TOKEN")
|
||||
if adminToken == "" {
|
||||
adminToken = webPassword
|
||||
}
|
||||
if apiKey == "" || webPassword == "" {
|
||||
log.Fatal("VPS_COMMANDER_API_KEY and VPS_COMMANDER_WEB_PASSWORD are required via environment")
|
||||
}
|
||||
authManager := auth.NewManager(apiKey)
|
||||
store, err := storage.Open(*dbPath)
|
||||
@@ -57,12 +61,18 @@ func main() {
|
||||
notificationManager.Start()
|
||||
defer notificationManager.Stop()
|
||||
s := &api.Server{
|
||||
Auth: authManager,
|
||||
Exec: executor.Local{MaxOutput: 1024 * 1024},
|
||||
Cluster: cluster.NewManager(clusterSecret, store),
|
||||
Store: store,
|
||||
LocalName: local,
|
||||
Notify: notificationManager,
|
||||
Auth: authManager,
|
||||
Exec: executor.Local{MaxOutput: 1024 * 1024},
|
||||
Cluster: cluster.NewManager(clusterSecret, store),
|
||||
Store: store,
|
||||
LocalName: local,
|
||||
Notify: notificationManager,
|
||||
AdminToken: adminToken,
|
||||
ExecLimiter: make(chan struct{}, 32),
|
||||
SearchLimiter: make(chan struct{}, 8),
|
||||
}
|
||||
if err := s.LoadSecurityPolicySnapshot(); err != nil {
|
||||
log.Fatalf("load security policy snapshot: %v", err)
|
||||
}
|
||||
|
||||
// MCP Server instance
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
# VPS-Commander 双通道架构与安全整改——送审交接文档
|
||||
|
||||
**文档版本:** 2026-10-02 / Review Handoff v1
|
||||
**项目:** VPS-Commander v2.0
|
||||
**用途:** 提交外部审核,审核通过后作为生产上线前置依据
|
||||
|
||||
---
|
||||
|
||||
## 1. 本次交接目标
|
||||
|
||||
本次开发针对双通道架构重构及此前审核专家 A/B 提出的安全、并发、权限、会话和文件操作问题进行集中整改。
|
||||
|
||||
本文件只描述**当前代码实际完成状态**,不把尚未部署到生产的代码描述为“已上线”。
|
||||
|
||||
当前原则:
|
||||
|
||||
- 生产 Hub / Agent 在本轮整改过程中**没有重启**。
|
||||
- 所有新增能力先在代码与测试环境完成验证。
|
||||
- 生产切换采用分阶段、可回滚方式。
|
||||
- 审核通过后,才进入生产上线阶段。
|
||||
|
||||
---
|
||||
|
||||
## 2. 当前生产保护状态
|
||||
|
||||
本轮整改期间确认生产进程仍保持运行,未执行 Hub/Agent 重启:
|
||||
|
||||
- Hub:`/opt/vps-commander/vps-commander-hub`
|
||||
- Agent:`/opt/vps-commander/vps-commander-agent`
|
||||
|
||||
因此,本次代码整改本身没有主动造成生产节点失联。
|
||||
|
||||
**注意:** 当前生产进程运行的是现有部署版本,不等同于本工作树代码已经上线。
|
||||
|
||||
---
|
||||
|
||||
## 3. 审核问题整改总表
|
||||
|
||||
| 审核问题 | 当前状态 | 说明 |
|
||||
|---|---|---|
|
||||
| Security Snapshot 统一门禁 | PASS | API/MCP 等执行路径使用统一安全模式判断 |
|
||||
| MCP 绕过安全门禁 | PASS | `exec/read/write/edit/mcp` 路径已接入门禁;Agent-MCP 直通已补齐 |
|
||||
| 未知设备继承 GlobalRisk | PASS | 未注册/未知设备 fail-closed 为 `unknown` |
|
||||
| Security Policy Version 回退 | PASS | Snapshot Publish 拒绝版本回退 |
|
||||
| Admin 安全策略权限 | PASS | 安全策略修改要求独立 Admin Token |
|
||||
| edit_block 原子写入 | PASS | 临时文件、fsync、原子 rename、权限保留 |
|
||||
| edit_block 并发/生成冲突 | PASS* | 已增加提交前重新读取校验及进程内串行保护;可选 expected hash |
|
||||
| Session Owner | PARTIAL / HARDENING | 当前 owner 绑定认证主体指纹;若所有调用者共享同一全局 token,无法天然区分操作者 |
|
||||
| Session TTL | PASS | 30 分钟硬 TTL + 10 分钟 idle TTL,访问会刷新 LastAccess |
|
||||
| Session 资源上限 | PASS | 单 Manager 会话/缓冲总量有限制 |
|
||||
| WebSocket 并发写 | PASS | Node 写操作通过 WriteMu 串行化 |
|
||||
| Agent 业务错误导致断链 | PASS | 业务错误返回错误消息,不主动关闭 WebSocket |
|
||||
| Agent 能力声明 | PASS | Modern Agent 按 capability 校验;旧 Agent 保持兼容 |
|
||||
| 设备级凭证 | IMPLEMENTED / NOT DEPLOYED | SQLite 已支持 per-device credential hash;未切换生产认证 |
|
||||
| URL Query Token | PASS | Auth 不再接受 `?token/?key/?api_key` |
|
||||
| 审计记录 | PASS | 关键安全拒绝、MCP 调用等路径具备审计 |
|
||||
| 审计敏感信息治理 | HARDENING | 仍建议上线前进一步检查 command/error 的敏感字段脱敏 |
|
||||
| 全局 exec/search 资源配额 | HARDENING | 已有 session/ring buffer 限制,但仍可进一步增加全局并发/搜索资源配额 |
|
||||
| 生产部署验证 | PENDING | 必须在审核通过后分阶段执行 |
|
||||
|
||||
> `PASS*` 表示代码层已完成并通过测试;最终生产审核仍应覆盖真实文件系统与并发场景。
|
||||
|
||||
---
|
||||
|
||||
## 4. 已完成的核心安全机制
|
||||
|
||||
### 4.1 Security Snapshot
|
||||
|
||||
Hub 启动时加载安全策略 Snapshot。策略版本保存在 SQLite 中。
|
||||
|
||||
安全模式包括:
|
||||
|
||||
- `low`
|
||||
- `medium`
|
||||
- `high`
|
||||
- `inherit`
|
||||
|
||||
工具动作通过固定 Registry 映射到风险等级,避免调用方自行声明风险等级。
|
||||
|
||||
### 4.2 Fail-Closed
|
||||
|
||||
对于未知、未注册或不在线的远程设备,不再默认继承 GlobalRisk。
|
||||
|
||||
此类设备的有效安全模式为 `unknown`,高风险执行因此被拒绝。
|
||||
|
||||
### 4.3 MCP 安全门禁
|
||||
|
||||
MCP 不再被视为绕过 Hub Security Snapshot 的旁路。
|
||||
|
||||
目前已经覆盖:
|
||||
|
||||
- `exec_command`
|
||||
- `read_file`
|
||||
- `write_file`
|
||||
- `edit_block`
|
||||
- `call_agent_mcp`
|
||||
- `list_agent_mcp`
|
||||
|
||||
并增加了对应单元测试。
|
||||
|
||||
### 4.4 Admin Token
|
||||
|
||||
安全策略修改接口要求独立 Admin Token:
|
||||
|
||||
`X-Admin-Token`
|
||||
|
||||
启动时优先读取:
|
||||
|
||||
`VPS_COMMANDER_ADMIN_TOKEN`
|
||||
|
||||
同时保留旧 Web Password 作为兼容 fallback,方便后续迁移。
|
||||
|
||||
### 4.5 URL Token 禁用
|
||||
|
||||
API Auth 不再从 URL Query 中接受:
|
||||
|
||||
- `token`
|
||||
- `key`
|
||||
- `api_key`
|
||||
|
||||
统一使用 Authorization Bearer 认证路径。
|
||||
|
||||
---
|
||||
|
||||
## 5. 文件修改安全
|
||||
|
||||
`edit_block` 当前实现包含:
|
||||
|
||||
1. 唯一匹配检查
|
||||
2. 初始内容 SHA-256 generation
|
||||
3. 提交前重新读取
|
||||
4. generation/hash 冲突检测
|
||||
5. 进程内编辑串行化
|
||||
6. 同目录临时文件
|
||||
7. 写入后 fsync
|
||||
8. atomic rename
|
||||
9. 保留原文件权限
|
||||
10. 目录同步
|
||||
|
||||
目标是避免“读取后文件被其他操作修改,随后旧内容覆盖新内容”的典型 lost-update 问题。
|
||||
|
||||
---
|
||||
|
||||
## 6. Session 安全
|
||||
|
||||
当前 Session 具备:
|
||||
|
||||
- Owner
|
||||
- 30 分钟 hard TTL
|
||||
- 10 分钟 idle TTL
|
||||
- 访问刷新 `LastAccess`
|
||||
- 最大输入 64 KiB
|
||||
- 环形输出缓冲
|
||||
- Manager 总缓冲/会话上限
|
||||
- terminate / kill 按 Session 控制
|
||||
|
||||
Owner 校验失败或 Session 过期时,不允许继续操作。
|
||||
|
||||
### 已知边界
|
||||
|
||||
当前 Owner 来源仍然是 API 认证主体的 token fingerprint。在所有调用者共用同一个 API Token 的部署中,这个 fingerprint 无法区分不同人工操作者。
|
||||
|
||||
因此上线前如果要求强操作者隔离,应继续引入明确的 principal/operator identity。
|
||||
|
||||
---
|
||||
|
||||
## 7. WebSocket 稳定性
|
||||
|
||||
Agent 与 Hub 通信增加了写锁,避免多个 goroutine 同时向同一 WebSocket 写入。
|
||||
|
||||
Agent 业务请求错误不再直接导致 WebSocket 连接被关闭。
|
||||
|
||||
设备断线后:
|
||||
|
||||
- Node 保留在 Manager
|
||||
- 状态变为 offline
|
||||
- SQLite 保留设备记录
|
||||
- pending request 收到 disconnect error
|
||||
- 自动重连可以恢复 online 状态
|
||||
|
||||
---
|
||||
|
||||
## 8. 设备级凭证迁移
|
||||
|
||||
这是本轮新增的重要安全能力。
|
||||
|
||||
SQLite 新增:
|
||||
|
||||
`device_credentials`
|
||||
|
||||
数据库只保存 token 的 SHA-256 hash,不保存明文 token。
|
||||
|
||||
当前 WebSocket 握手逻辑:
|
||||
|
||||
```text
|
||||
设备存在独立 credential
|
||||
|
|
||||
+--> 使用该设备 credential 校验
|
||||
|
||||
设备尚未配置 credential
|
||||
|
|
||||
+--> 暂时兼容 Cluster Secret
|
||||
```
|
||||
|
||||
这样可以支持逐节点迁移,而不是一次性切换所有 Agent。
|
||||
|
||||
### 当前状态
|
||||
|
||||
**代码已经实现,但尚未在生产逐节点启用。**
|
||||
|
||||
这是有意保留的上线门禁,避免在没有审核和迁移计划的情况下直接造成 Agent 全部掉线。
|
||||
|
||||
---
|
||||
|
||||
## 9. 测试与验证结果
|
||||
|
||||
当前工作树已经完成:
|
||||
|
||||
```text
|
||||
go test ./... PASS
|
||||
go test -race ./... PASS
|
||||
go build ./cmd/hub PASS
|
||||
go build ./cmd/agent PASS
|
||||
go build ./cmd/mcp-stdio PASS
|
||||
git diff --check PASS
|
||||
```
|
||||
|
||||
并增加/验证了:
|
||||
|
||||
- MCP Security Snapshot 测试
|
||||
- 未知设备 fail-closed 测试
|
||||
- MCP Agent-MCP gate 测试
|
||||
- MCP list-agent gate 测试
|
||||
- Session idle authorization 测试
|
||||
- Device credential hash/rotation 测试
|
||||
- Cluster lifecycle acceptance 测试
|
||||
- Race detector
|
||||
|
||||
---
|
||||
|
||||
## 10. 审核者重点检查项
|
||||
|
||||
建议审核者重点确认以下问题,而不是重复审查已经验证的普通功能:
|
||||
|
||||
### A. 设备身份
|
||||
|
||||
确认:
|
||||
|
||||
- device name 不能单独成为可信身份
|
||||
- 已配置 credential 的设备必须使用对应 credential
|
||||
- credential 不能跨设备复用
|
||||
- revoked device 不能重新建立连接
|
||||
- 迁移期间 legacy Cluster Secret 的边界是否可接受
|
||||
|
||||
### B. Session 操作者身份
|
||||
|
||||
确认:
|
||||
|
||||
- Owner 是否满足当前部署需要
|
||||
- 是否需要进一步引入 operator/principal ID
|
||||
- kill/terminate/input/output 是否全部经过 Owner 检查
|
||||
|
||||
### C. 审计
|
||||
|
||||
确认:
|
||||
|
||||
- command/error 是否可能写入密码、Token、Cookie、Authorization 等敏感内容
|
||||
- MCP 参数是否需要脱敏
|
||||
- 安全拒绝事件是否足够追踪
|
||||
|
||||
### D. 资源控制
|
||||
|
||||
确认:
|
||||
|
||||
- exec 并发数
|
||||
- search 并发数
|
||||
- 单文件读取大小
|
||||
- 单 Agent MCP 请求并发
|
||||
- WebSocket pending request 数量
|
||||
|
||||
是否满足生产部署规模。
|
||||
|
||||
### E. 上线安全
|
||||
|
||||
审核通过后必须采用:
|
||||
|
||||
**备份 → 单节点 → 健康检查 → 保持旧进程可回滚 → 再逐节点扩散**
|
||||
|
||||
而不是直接全量替换。
|
||||
|
||||
---
|
||||
|
||||
## 11. 上线前置条件
|
||||
|
||||
只有审核者确认以下事项后,才进入生产部署:
|
||||
|
||||
- [ ] 代码审核通过
|
||||
- [ ] 明确 Session principal 要求
|
||||
- [ ] 明确设备 credential 迁移方案
|
||||
- [ ] 确认现有 Cluster Secret fallback 的过渡期限
|
||||
- [ ] 确认审计脱敏要求
|
||||
- [ ] 确认生产资源配额
|
||||
- [ ] 确认 DB 备份/回滚方案
|
||||
|
||||
---
|
||||
|
||||
## 12. 推荐上线顺序
|
||||
|
||||
### Stage 0 — Preflight
|
||||
|
||||
- 备份 `commander.db`
|
||||
- 记录当前 Hub/Agent PID、版本、配置
|
||||
- 记录当前在线节点
|
||||
- 保留当前生产 binary
|
||||
|
||||
### Stage 1 — Hub
|
||||
|
||||
先部署 Hub 新版本。
|
||||
|
||||
验证:
|
||||
|
||||
- `/panel`
|
||||
- API Auth
|
||||
- Security Snapshot
|
||||
- MCP
|
||||
- Device state
|
||||
- WebSocket handshake
|
||||
|
||||
### Stage 2 — 单 Agent
|
||||
|
||||
选择非关键节点进行验证:
|
||||
|
||||
- 独立 credential
|
||||
- online/offline
|
||||
- exec
|
||||
- read
|
||||
- write
|
||||
- edit_block
|
||||
- MCP
|
||||
- reconnect
|
||||
|
||||
### Stage 3 — 逐节点迁移
|
||||
|
||||
每次只迁移一个 Agent,并确认:
|
||||
|
||||
```text
|
||||
connected
|
||||
→ authenticated
|
||||
→ heartbeat
|
||||
→ command
|
||||
→ MCP
|
||||
→ disconnect
|
||||
→ reconnect
|
||||
```
|
||||
|
||||
全部正常后再处理下一节点。
|
||||
|
||||
### Stage 4 — Legacy Secret 收口
|
||||
|
||||
所有 Agent 完成 credential migration 后,再考虑关闭 shared Cluster Secret fallback。
|
||||
|
||||
**不能提前关闭。**
|
||||
|
||||
---
|
||||
|
||||
## 13. 审核结论请求
|
||||
|
||||
请审核者对本文件给出以下三类结论之一:
|
||||
|
||||
### A — APPROVED FOR DEPLOYMENT
|
||||
|
||||
允许进入生产上线阶段。
|
||||
|
||||
### B — APPROVED WITH CONDITIONS
|
||||
|
||||
允许进入上线准备,但指定条件必须在上线前完成。
|
||||
|
||||
### C — BLOCKED
|
||||
|
||||
存在必须修复的阻断性安全/架构问题。
|
||||
|
||||
如果选择 B 或 C,请明确指出:
|
||||
|
||||
1. 问题
|
||||
2. 风险
|
||||
3. 必须修改的代码/配置
|
||||
4. 验证方法
|
||||
5. 是否要求生产变更
|
||||
|
||||
---
|
||||
|
||||
## 14. 当前开发团队声明
|
||||
|
||||
截至本文件生成时:
|
||||
|
||||
- 本轮整改代码未直接替换生产二进制。
|
||||
- 生产 Hub/Agent 未因本轮整改重启。
|
||||
- 自动化测试与 race detector 已通过。
|
||||
- 设备级 credential 已实现兼容迁移机制,但尚未执行生产迁移。
|
||||
- 当前目标是先接受独立审核,再执行生产上线。
|
||||
|
||||
**因此,本文件是“送审版”,不是“生产上线完成报告”。**
|
||||
|
||||
## 11. 最终上线收口(2026-10-02)
|
||||
|
||||
本轮批准后的生产实施已完成:
|
||||
|
||||
- Hub 已部署最终版本并验证启动;共享 Cluster Secret fallback 已关闭。
|
||||
- 5 个远程 Agent 均已配置独立设备凭证,数据库仅保存 SHA-256 hash。
|
||||
- 5 个远程 Agent 均已滚动升级到最终 Agent 构建,且重新上线。
|
||||
- `wjyhk / N3450 / hwhk / hwsg / m4-live-agent / qnvn` 六个节点全部 online。
|
||||
- 六个节点均已通过实际执行请求验证控制链路可用。
|
||||
- Hub 全局安全级别保持 `medium`;节点级策略保持 `high`,未通过降低全局策略绕过门禁。
|
||||
- 审计写入增加敏感字段脱敏、换行清理和长度上限。
|
||||
- Hub 增加 exec 32 并发槽、search 8 并发槽,防止高频资源请求拖垮主控。
|
||||
- DC 工具池补齐:多文件读取、进程列表、目录创建、文件移动,并接入 Agent/Hub/MCP。
|
||||
- Agent 仍保留协议兼容能力;设备级凭证优先,未配置设备不会再因为共享 Secret fallback 而自动获得认证。
|
||||
|
||||
最终验证:
|
||||
|
||||
```text
|
||||
go test ./... PASS
|
||||
go test -race ./... PASS
|
||||
git diff --check PASS
|
||||
```
|
||||
|
||||
最终生产 Agent SHA-256:
|
||||
`1969456fb09004e4be5b05ba1bf30a0a19a6ed2d41236c2bccd391b2ad6bc362`
|
||||
|
||||
最终生产 Hub SHA-256:
|
||||
`0978eb06a993909137ee64913c0b09b85835b51a65ef854ddcfbb9cecbd63f6c`
|
||||
|
||||
Shared Secret 已从 Hub 生产环境移除;现阶段生产认证权威为 per-device credential。
|
||||
+404
-23
@@ -1,9 +1,12 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -11,6 +14,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -20,6 +24,7 @@ import (
|
||||
"github.com/wjyhk/vps-commander/internal/executor"
|
||||
"github.com/wjyhk/vps-commander/internal/model"
|
||||
"github.com/wjyhk/vps-commander/internal/notify"
|
||||
"github.com/wjyhk/vps-commander/internal/security"
|
||||
"github.com/wjyhk/vps-commander/internal/storage"
|
||||
"github.com/wjyhk/vps-commander/internal/sysinfo"
|
||||
panelweb "github.com/wjyhk/vps-commander/internal/web"
|
||||
@@ -34,12 +39,16 @@ const (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
Auth *auth.Manager
|
||||
Exec executor.Local
|
||||
Cluster *cluster.Manager
|
||||
Store *storage.Store
|
||||
LocalName string
|
||||
Notify *notify.Manager
|
||||
Auth *auth.Manager
|
||||
Exec executor.Local
|
||||
Cluster *cluster.Manager
|
||||
Store *storage.Store
|
||||
LocalName string
|
||||
Notify *notify.Manager
|
||||
Policy *security.Snapshot
|
||||
AdminToken string
|
||||
ExecLimiter chan struct{}
|
||||
SearchLimiter chan struct{}
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -49,27 +58,71 @@ const (
|
||||
SecurityInherit = "inherit"
|
||||
)
|
||||
|
||||
func (s *Server) GetEffectiveSecurityMode(node string) string {
|
||||
func (s *Server) LoadSecurityPolicySnapshot() error {
|
||||
if s.Store == nil {
|
||||
return SecurityMedium
|
||||
return fmt.Errorf("security store unavailable")
|
||||
}
|
||||
if mode, ok := s.Store.GetNodeSecurityMode(node); ok && mode != "" && mode != SecurityInherit {
|
||||
return mode
|
||||
state, err := s.Store.LoadSecurityPolicy()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Store.GetGlobalSecurityMode()
|
||||
devices := make(map[string]security.DevicePolicy, len(state.Nodes))
|
||||
for node, mode := range state.Nodes {
|
||||
devices[node] = security.DevicePolicy{Mode: mode}
|
||||
}
|
||||
global := security.ModeRisk(state.GlobalMode)
|
||||
if global == 0 {
|
||||
global = security.RiskMedium
|
||||
}
|
||||
if s.Policy == nil {
|
||||
s.Policy = security.New(global, devices, state.Version)
|
||||
return nil
|
||||
}
|
||||
return s.Policy.Publish(global, devices, state.Version)
|
||||
}
|
||||
|
||||
func (s *Server) GetEffectiveSecurityMode(node string) string {
|
||||
if s.Policy == nil && s.Store != nil {
|
||||
if mode, ok := s.Store.GetNodeSecurityMode(node); ok {
|
||||
if mode != SecurityInherit {
|
||||
return mode
|
||||
}
|
||||
return s.Store.GetGlobalSecurityMode()
|
||||
}
|
||||
if node == s.LocalName {
|
||||
return s.Store.GetGlobalSecurityMode()
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
if s.Policy != nil {
|
||||
snap := s.Policy.Current()
|
||||
risk := s.Policy.EffectiveRisk(node)
|
||||
if p, ok := snap.Devices[node]; ok && security.ModeRisk(p.Mode) != 0 {
|
||||
return p.Mode
|
||||
}
|
||||
if node != s.LocalName && (s.Cluster == nil || !s.Cluster.Online(node)) {
|
||||
return "unknown"
|
||||
}
|
||||
for mode, v := range map[string]int{"low": security.RiskLow, "medium": security.RiskMedium, "high": security.RiskHigh} {
|
||||
if v == risk {
|
||||
return mode
|
||||
}
|
||||
}
|
||||
}
|
||||
if node == s.LocalName && s.Store != nil {
|
||||
return s.Store.GetGlobalSecurityMode()
|
||||
}
|
||||
if s.Cluster != nil && s.Cluster.Online(node) && s.Store != nil {
|
||||
return s.Store.GetGlobalSecurityMode()
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
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:
|
||||
required, ok := security.RequiredRiskForAction(action)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return security.ModeRisk(mode) >= required
|
||||
}
|
||||
|
||||
func (s *Server) requireSecurity(w http.ResponseWriter, r *http.Request, device, action, detail string) bool {
|
||||
@@ -180,11 +233,53 @@ func (s *Server) SecuritySettingsJSON(w http.ResponseWriter, r *http.Request) {
|
||||
}()})
|
||||
}
|
||||
|
||||
func (s *Server) ProvisionDeviceCredentialJSON(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
if s.AdminToken == "" || subtle.ConstantTimeCompare([]byte(r.Header.Get("X-Admin-Token")), []byte(s.AdminToken)) != 1 {
|
||||
http.Error(w, "admin authorization required", 403)
|
||||
return
|
||||
}
|
||||
var q struct {
|
||||
Device string `json:"device"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16*1024)).Decode(&q); err != nil || strings.TrimSpace(q.Device) == "" {
|
||||
http.Error(w, "device required", 400)
|
||||
return
|
||||
}
|
||||
if q.Device == s.LocalName || q.Device == "local" {
|
||||
http.Error(w, "local control node does not use agent credential", 400)
|
||||
return
|
||||
}
|
||||
if s.Cluster == nil || !s.Cluster.Online(q.Device) {
|
||||
http.Error(w, "device offline", 409)
|
||||
return
|
||||
}
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
http.Error(w, "credential generation failed", 500)
|
||||
return
|
||||
}
|
||||
token := hex.EncodeToString(b)
|
||||
if err := s.Store.SetDeviceCredential(q.Device, token); err != nil {
|
||||
http.Error(w, "credential save failed", 500)
|
||||
return
|
||||
}
|
||||
_ = s.Store.Audit(clientIP(r), q.Device, "device_credential_provision", "device="+q.Device, 0, 0, "credential provisioned")
|
||||
jsonOut(w, map[string]any{"ok": true, "device": q.Device, "token": token})
|
||||
}
|
||||
|
||||
func (s *Server) UpdateSecuritySettingsJSON(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
if s.AdminToken == "" || r.Header.Get("X-Admin-Token") != s.AdminToken {
|
||||
http.Error(w, "admin authorization required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
var q struct {
|
||||
Global string `json:"global"`
|
||||
Node string `json:"node"`
|
||||
@@ -194,21 +289,35 @@ func (s *Server) UpdateSecuritySettingsJSON(w http.ResponseWriter, r *http.Reque
|
||||
http.Error(w, "bad json", 400)
|
||||
return
|
||||
}
|
||||
oldGlobal := s.GetEffectiveSecurityMode(s.LocalName)
|
||||
oldNode := ""
|
||||
if q.Node != "" {
|
||||
oldNode = s.GetEffectiveSecurityMode(q.Node)
|
||||
}
|
||||
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})
|
||||
if err := s.LoadSecurityPolicySnapshot(); err != nil {
|
||||
http.Error(w, "security snapshot publish failed", 500)
|
||||
return
|
||||
}
|
||||
newGlobal := s.GetEffectiveSecurityMode(s.LocalName)
|
||||
if q.Global != "" {
|
||||
_ = s.Store.Audit(clientIP(r), "", "security_setting", "global="+q.Global, 0, 0, fmt.Sprintf("old=%s new=%s version=%d", oldGlobal, newGlobal, s.Policy.Current().Version))
|
||||
}
|
||||
if q.Node != "" {
|
||||
_ = s.Store.Audit(clientIP(r), q.Node, "security_setting", "mode="+q.Mode, 0, 0, fmt.Sprintf("old=%s new=%s version=%d", oldNode, s.GetEffectiveSecurityMode(q.Node), s.Policy.Current().Version))
|
||||
}
|
||||
jsonOut(w, map[string]any{"ok": true, "global": s.Store.GetGlobalSecurityMode(), "node": q.Node, "mode": q.Mode, "version": s.Policy.Current().Version})
|
||||
}
|
||||
|
||||
func (s *Server) clusterDeviceNames() []string {
|
||||
@@ -482,7 +591,12 @@ func (s *Server) Routes() http.Handler {
|
||||
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/list", s.listDirectory)
|
||||
mux.HandleFunc("/api/v1/file/info", s.fileInfo)
|
||||
mux.HandleFunc("/api/v1/file/search", s.searchFiles)
|
||||
mux.HandleFunc("/api/v1/process/session", s.processSession)
|
||||
mux.HandleFunc("/api/v1/file/write", s.fileWrite)
|
||||
mux.HandleFunc("/api/v1/devices/file/edit_block", s.editBlock)
|
||||
mux.HandleFunc("/api/v1/mcp/services", s.MCPServicesJSON)
|
||||
mux.HandleFunc("/api/v1/mcp/service", s.DeleteMCPService)
|
||||
mux.HandleFunc("/api/v1/mcp/list", s.ListAgentMCPJSON)
|
||||
@@ -490,6 +604,7 @@ func (s *Server) Routes() http.Handler {
|
||||
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("/api/v1/security/device-credentials/provision", s.ProvisionDeviceCredentialJSON)
|
||||
mux.HandleFunc("/api/v1/notifications/webhooks", s.WebhookTargetsJSON)
|
||||
mux.HandleFunc("/api/v1/notifications/webhook/update", s.UpdateWebhookTargetJSON)
|
||||
mux.HandleFunc("/api/v1/notifications/webhook/delete", s.DeleteWebhookTargetJSON)
|
||||
@@ -582,6 +697,21 @@ func (s *Server) agentWS(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) isRemote(device string) bool {
|
||||
return device != "" && device != "local" && device != s.LocalName
|
||||
}
|
||||
func (s *Server) acquireExec() func() {
|
||||
if s.ExecLimiter == nil {
|
||||
return func() {}
|
||||
}
|
||||
s.ExecLimiter <- struct{}{}
|
||||
return func() { <-s.ExecLimiter }
|
||||
}
|
||||
func (s *Server) acquireSearch() func() {
|
||||
if s.SearchLimiter == nil {
|
||||
return func() {}
|
||||
}
|
||||
s.SearchLimiter <- struct{}{}
|
||||
return func() { <-s.SearchLimiter }
|
||||
}
|
||||
|
||||
func (s *Server) exec(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
@@ -602,6 +732,8 @@ func (s *Server) exec(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireSecurity(w, r, req.Device, "exec", req.Command) {
|
||||
return
|
||||
}
|
||||
releaseExec := s.acquireExec()
|
||||
defer releaseExec()
|
||||
if req.Timeout <= 0 {
|
||||
req.Timeout = 30
|
||||
}
|
||||
@@ -631,6 +763,197 @@ func (s *Server) exec(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
jsonOut(w, model.ExecResponse{Device: req.Device, ExitCode: res.ExitCode, Stdout: res.Stdout, Stderr: res.Stderr, DurationMS: res.DurationMS})
|
||||
}
|
||||
func sessionOwner(r *http.Request) string {
|
||||
v := r.Header.Get("Authorization") + "|" + r.Header.Get("X-Operator-ID") + "|" + clientIP(r)
|
||||
h := sha256.Sum256([]byte(v))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
func newSessionID() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func (s *Server) processSession(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Device, SessionID, Command, Workdir, Data string
|
||||
Force bool
|
||||
RingCapacity int `json:"ring_capacity"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 256*1024)).Decode(&req); err != nil {
|
||||
http.Error(w, "bad json", 400)
|
||||
return
|
||||
}
|
||||
if req.Device == "" {
|
||||
req.Device = s.LocalName
|
||||
}
|
||||
if !s.requireSecurity(w, r, req.Device, "exec", req.SessionID) {
|
||||
return
|
||||
}
|
||||
if s.Cluster == nil {
|
||||
http.Error(w, "cluster unavailable", 503)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
action := r.URL.Query().Get("action")
|
||||
var result any
|
||||
var err error
|
||||
switch action {
|
||||
case "start":
|
||||
if req.SessionID == "" {
|
||||
req.SessionID, err = newSessionID()
|
||||
if err != nil {
|
||||
http.Error(w, "session id generation failed", 500)
|
||||
return
|
||||
}
|
||||
}
|
||||
result, err = s.Cluster.StartProcess(ctx, req.Device, cluster.StartProcessPayload{Command: req.Command, Workdir: req.Workdir, SessionID: req.SessionID, Owner: sessionOwner(r), RingCapacity: req.RingCapacity})
|
||||
case "read":
|
||||
result, err = s.Cluster.ReadProcessOutput(ctx, req.Device, req.SessionID, sessionOwner(r))
|
||||
case "input":
|
||||
err = s.Cluster.InteractProcess(ctx, req.Device, req.SessionID, req.Data, sessionOwner(r))
|
||||
result = map[string]any{"ok": true}
|
||||
case "stop":
|
||||
err = s.Cluster.ForceTerminate(ctx, req.Device, req.SessionID, sessionOwner(r))
|
||||
result = map[string]any{"ok": true}
|
||||
case "list":
|
||||
result, err = s.Cluster.ListSessions(ctx, req.Device)
|
||||
default:
|
||||
http.Error(w, "unknown action", 400)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 409)
|
||||
return
|
||||
}
|
||||
jsonOut(w, result)
|
||||
}
|
||||
|
||||
func (s *Server) listDirectory(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Device, Path string
|
||||
Recursive bool `json:"recursive"`
|
||||
MaxEntries int `json:"max_entries"`
|
||||
}
|
||||
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 !s.requireSecurity(w, r, req.Device, "read", req.Path) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
var v []cluster.Entry
|
||||
var err error
|
||||
if s.isRemote(req.Device) {
|
||||
v, err = s.Cluster.ListDirectory(ctx, req.Device, cluster.ListDirectoryPayload{Path: req.Path, Recursive: req.Recursive, MaxEntries: req.MaxEntries})
|
||||
} else {
|
||||
var ev []executor.Entry
|
||||
ev, err = executor.ListDirectory(req.Path, req.Recursive, req.MaxEntries)
|
||||
if err == nil {
|
||||
b, _ := json.Marshal(ev)
|
||||
err = json.Unmarshal(b, &v)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 409)
|
||||
return
|
||||
}
|
||||
jsonOut(w, v)
|
||||
}
|
||||
func (s *Server) fileInfo(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
var req struct{ Device, Path string }
|
||||
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 !s.requireSecurity(w, r, req.Device, "read", req.Path) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
var v cluster.FileInfo
|
||||
var err error
|
||||
if s.isRemote(req.Device) {
|
||||
v, err = s.Cluster.GetFileInfo(ctx, req.Device, cluster.FileInfoPayload{Path: req.Path})
|
||||
} else {
|
||||
var ev executor.FileInfo
|
||||
ev, err = executor.GetFileInfo(req.Path)
|
||||
if err == nil {
|
||||
b, _ := json.Marshal(ev)
|
||||
err = json.Unmarshal(b, &v)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 404)
|
||||
return
|
||||
}
|
||||
jsonOut(w, v)
|
||||
}
|
||||
func (s *Server) searchFiles(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Device, Root, Query string
|
||||
MaxResults int `json:"max_results"`
|
||||
}
|
||||
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 !s.requireSecurity(w, r, req.Device, "read", req.Root) {
|
||||
return
|
||||
}
|
||||
releaseSearch := s.acquireSearch()
|
||||
defer releaseSearch()
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
var v []cluster.SearchResult
|
||||
var err error
|
||||
if s.isRemote(req.Device) {
|
||||
v, err = s.Cluster.Search(ctx, req.Device, cluster.SearchPayload{Root: req.Root, Query: req.Query, MaxResults: req.MaxResults})
|
||||
} else {
|
||||
var ev []executor.SearchResult
|
||||
ev, err = executor.Search(ctx, req.Root, req.Query, req.MaxResults)
|
||||
if err == nil {
|
||||
b, _ := json.Marshal(ev)
|
||||
err = json.Unmarshal(b, &v)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 409)
|
||||
return
|
||||
}
|
||||
jsonOut(w, v)
|
||||
}
|
||||
|
||||
func (s *Server) fileRead(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
@@ -706,6 +1029,64 @@ func (s *Server) fileRead(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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) editBlock(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Device string `json:"device,omitempty"`
|
||||
Path string `json:"path"`
|
||||
OldText string `json:"old_text"`
|
||||
NewText string `json:"new_text"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxFileLimit+64*1024)).Decode(&req); err != nil {
|
||||
http.Error(w, "bad json or content too large", 400)
|
||||
return
|
||||
}
|
||||
if req.Device == "" {
|
||||
req.Device = s.LocalName
|
||||
}
|
||||
if !s.requireSecurity(w, r, req.Device, "write", req.Path) {
|
||||
return
|
||||
}
|
||||
if req.Path == "" || !validPath(req.Path) || req.OldText == "" {
|
||||
http.Error(w, "path and old_text required", 400)
|
||||
return
|
||||
}
|
||||
if len(req.OldText) > int(maxFileLimit) || len(req.NewText) > int(maxFileLimit) {
|
||||
http.Error(w, "edit block too large", 413)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
var result executor.EditBlockResult
|
||||
if s.isRemote(req.Device) {
|
||||
if s.Cluster == nil || !s.Cluster.Online(req.Device) {
|
||||
http.Error(w, "device offline", 409)
|
||||
return
|
||||
}
|
||||
rr, err := s.Cluster.EditBlock(ctx, req.Device, cluster.EditBlockPayload{Path: req.Path, OldText: req.OldText, NewText: req.NewText})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 409)
|
||||
return
|
||||
}
|
||||
result.BytesWritten = rr.BytesWritten
|
||||
} else {
|
||||
var err error
|
||||
result, err = executor.EditBlock(req.Path, req.OldText, req.NewText)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 409)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.Store.Audit(clientIP(r), req.Device, "edit_block", req.Path, 0, 0, fmt.Sprintf("bytes_written=%d", result.BytesWritten)); err != nil {
|
||||
http.Error(w, "audit write failed", 500)
|
||||
return
|
||||
}
|
||||
jsonOut(w, map[string]any{"device": req.Device, "path": req.Path, "bytes_written": result.BytesWritten})
|
||||
}
|
||||
|
||||
func (s *Server) fileWrite(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
|
||||
@@ -24,6 +24,20 @@ func (s *Server) NewMCPBackend() *MCPBackend {
|
||||
return &MCPBackend{server: s}
|
||||
}
|
||||
|
||||
func (b *MCPBackend) authorize(device, action, clientIP string) error {
|
||||
if device == "" {
|
||||
device = b.server.LocalName
|
||||
}
|
||||
mode := b.server.GetEffectiveSecurityMode(device)
|
||||
if !securityAllows(mode, action) {
|
||||
if b.server.Store != nil {
|
||||
_ = b.server.Store.Audit(clientIP, device, "security_denied", action, 403, 0, "MCP security gate")
|
||||
}
|
||||
return fmt.Errorf("security mode %s denies %s", mode, action)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -44,6 +58,9 @@ func (b *MCPBackend) ExecCommand(ctx context.Context, clientIP, device, command,
|
||||
if device == "" {
|
||||
device = b.server.LocalName
|
||||
}
|
||||
if err := b.authorize(device, "exec", clientIP); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = 30
|
||||
}
|
||||
@@ -85,6 +102,9 @@ func (b *MCPBackend) ReadFile(ctx context.Context, clientIP, device, path string
|
||||
if device == "" {
|
||||
device = b.server.LocalName
|
||||
}
|
||||
if err := b.authorize(device, "read", clientIP); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if path == "" || !validPath(path) || offset < 0 {
|
||||
return nil, errors.New("invalid path or offset")
|
||||
}
|
||||
@@ -150,10 +170,45 @@ func (b *MCPBackend) ReadFile(ctx context.Context, clientIP, device, path string
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *MCPBackend) EditBlock(ctx context.Context, clientIP, device, path, oldText, newText string) (any, error) {
|
||||
if device == "" {
|
||||
device = b.server.LocalName
|
||||
}
|
||||
if err := b.authorize(device, "write", clientIP); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if path == "" || !validPath(path) || oldText == "" {
|
||||
return nil, errors.New("path and old_text required")
|
||||
}
|
||||
if len(oldText) > int(maxFileLimit) || len(newText) > int(maxFileLimit) {
|
||||
return nil, errors.New("edit block too large")
|
||||
}
|
||||
if b.server.isRemote(device) {
|
||||
if b.server.Cluster == nil || !b.server.Cluster.Online(device) {
|
||||
return nil, fmt.Errorf("device %s is offline", device)
|
||||
}
|
||||
res, err := b.server.Cluster.EditBlock(ctx, device, cluster.EditBlockPayload{Path: path, OldText: oldText, NewText: newText})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = b.server.Store.Audit(clientIP, device, "edit_block", path, 0, 0, fmt.Sprintf("bytes_written=%d", res.BytesWritten))
|
||||
return map[string]any{"device": device, "path": path, "bytes_written": res.BytesWritten}, nil
|
||||
}
|
||||
res, err := executor.EditBlock(path, oldText, newText)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = b.server.Store.Audit(clientIP, device, "edit_block", path, 0, 0, fmt.Sprintf("bytes_written=%d", res.BytesWritten))
|
||||
return map[string]any{"device": device, "path": path, "bytes_written": res.BytesWritten}, nil
|
||||
}
|
||||
|
||||
func (b *MCPBackend) WriteFile(ctx context.Context, clientIP, device, path, content string) (any, error) {
|
||||
if device == "" {
|
||||
device = b.server.LocalName
|
||||
}
|
||||
if err := b.authorize(device, "write", clientIP); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if path == "" || !validPath(path) {
|
||||
return nil, errors.New("path required")
|
||||
}
|
||||
@@ -223,3 +278,84 @@ func (b *MCPBackend) CallAgentMCP(ctx context.Context, clientIP, device, server,
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
|
||||
// AuthorizeMCP exposes the same Hub-side gate to the MCP transport layer.
|
||||
func (b *MCPBackend) AuthorizeMCP(device, action, clientIP string) error {
|
||||
return b.authorize(device, action, clientIP)
|
||||
}
|
||||
|
||||
func (b *MCPBackend) ReadMultipleFiles(ctx context.Context, clientIP, device string, paths []string, limit int64) (any, error) {
|
||||
if device == "" {
|
||||
device = b.server.LocalName
|
||||
}
|
||||
if err := b.authorize(device, "read", clientIP); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(paths) > 32 {
|
||||
return nil, errors.New("too many paths")
|
||||
}
|
||||
if b.server.isRemote(device) {
|
||||
if b.server.Cluster == nil || !b.server.Cluster.Online(device) {
|
||||
return nil, fmt.Errorf("device %s is offline", device)
|
||||
}
|
||||
return b.server.Cluster.ReadMultipleFiles(ctx, device, cluster.ReadMultipleFilesPayload{Paths: paths, Limit: limit})
|
||||
}
|
||||
return executor.ReadMultipleFiles(paths, limit), nil
|
||||
}
|
||||
func (b *MCPBackend) CreateDirectory(ctx context.Context, clientIP, device, path string) (any, error) {
|
||||
if device == "" {
|
||||
device = b.server.LocalName
|
||||
}
|
||||
if err := b.authorize(device, "write", clientIP); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if b.server.isRemote(device) {
|
||||
if b.server.Cluster == nil || !b.server.Cluster.Online(device) {
|
||||
return nil, fmt.Errorf("device %s is offline", device)
|
||||
}
|
||||
if err := b.server.Cluster.CreateDirectory(ctx, device, cluster.CreateDirectoryPayload{Path: path}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err := executor.CreateDirectory(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return map[string]any{"device": device, "path": path, "ok": true}, nil
|
||||
}
|
||||
func (b *MCPBackend) MoveFile(ctx context.Context, clientIP, device, source, destination string) (any, error) {
|
||||
if device == "" {
|
||||
device = b.server.LocalName
|
||||
}
|
||||
if err := b.authorize(device, "write", clientIP); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if b.server.isRemote(device) {
|
||||
if b.server.Cluster == nil || !b.server.Cluster.Online(device) {
|
||||
return nil, fmt.Errorf("device %s is offline", device)
|
||||
}
|
||||
if err := b.server.Cluster.MoveFile(ctx, device, cluster.MoveFilePayload{Source: source, Destination: destination}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err := executor.MoveFile(source, destination); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return map[string]any{"device": device, "source": source, "destination": destination, "ok": true}, nil
|
||||
}
|
||||
func (b *MCPBackend) ListProcesses(ctx context.Context, clientIP, device string) (any, error) {
|
||||
if device == "" {
|
||||
device = b.server.LocalName
|
||||
}
|
||||
if err := b.authorize(device, "read", clientIP); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if b.server.isRemote(device) {
|
||||
if b.server.Cluster == nil || !b.server.Cluster.Online(device) {
|
||||
return nil, fmt.Errorf("device %s is offline", device)
|
||||
}
|
||||
return b.server.Cluster.ListProcesses(ctx, device)
|
||||
}
|
||||
return executor.ListProcesses()
|
||||
}
|
||||
|
||||
+202
-25
@@ -2,17 +2,18 @@ package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/wjyhk/vps-commander/internal/security"
|
||||
"github.com/wjyhk/vps-commander/internal/storage"
|
||||
"github.com/wjyhk/vps-commander/internal/sysinfo"
|
||||
)
|
||||
@@ -27,22 +28,26 @@ type Manager struct {
|
||||
}
|
||||
|
||||
type Node struct {
|
||||
Name string
|
||||
Conn *websocket.Conn
|
||||
ConnectionID string
|
||||
WriteMu sync.Mutex
|
||||
Pending sync.Map
|
||||
LastSeen atomic.Int64
|
||||
LastHeartbeat atomic.Int64
|
||||
Connected atomic.Bool
|
||||
LastProfile *sysinfo.Profile
|
||||
Arch string
|
||||
OS string
|
||||
IsLocal bool
|
||||
RemoteIP string
|
||||
CountryCode string
|
||||
Country string
|
||||
MCP *MCPRuntime
|
||||
Name string
|
||||
Conn *websocket.Conn
|
||||
ConnectionID string
|
||||
WriteMu sync.Mutex
|
||||
OpMu sync.RWMutex
|
||||
Pending sync.Map
|
||||
LastSeen atomic.Int64
|
||||
LastHeartbeat atomic.Int64
|
||||
Connected atomic.Bool
|
||||
LastProfile *sysinfo.Profile
|
||||
Arch string
|
||||
OS string
|
||||
IsLocal bool
|
||||
RemoteIP string
|
||||
CountryCode string
|
||||
Country string
|
||||
MCP *MCPRuntime
|
||||
ProtocolVersion string
|
||||
AgentVersion string
|
||||
Capabilities map[string]bool
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
@@ -96,16 +101,31 @@ func (m *Manager) ServeWS(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
|
||||
if m.Store != nil {
|
||||
configured, err := m.Store.HasDeviceCredential(name)
|
||||
if err != nil {
|
||||
http.Error(w, "credential lookup failed", 500)
|
||||
return
|
||||
}
|
||||
if configured {
|
||||
valid, err := m.Store.VerifyDeviceCredential(name, provided)
|
||||
if err != nil || !valid {
|
||||
http.Error(w, "unauthorized", 401)
|
||||
return
|
||||
}
|
||||
} else if m.Secret == "" || subtle.ConstantTimeCompare([]byte(provided), []byte(m.Secret)) != 1 {
|
||||
http.Error(w, "unauthorized", 401)
|
||||
return
|
||||
}
|
||||
} else if m.Secret == "" || subtle.ConstantTimeCompare([]byte(provided), []byte(m.Secret)) != 1 {
|
||||
http.Error(w, "unauthorized", 401)
|
||||
return
|
||||
}
|
||||
if m.Store != nil {
|
||||
if revoked, _ := m.Store.IsRevoked(name); revoked {
|
||||
http.Error(w, "device has been revoked by admin", 403)
|
||||
@@ -128,6 +148,7 @@ func (m *Manager) ServeWS(w http.ResponseWriter, r *http.Request) {
|
||||
CountryCode: cc,
|
||||
Country: countryName,
|
||||
MCP: NewMCPRuntime(),
|
||||
Capabilities: make(map[string]bool),
|
||||
}
|
||||
n.LastSeen.Store(time.Now().UnixNano())
|
||||
n.LastHeartbeat.Store(time.Now().UnixNano())
|
||||
@@ -197,6 +218,18 @@ func (m *Manager) readLoop(n *Node) {
|
||||
if msg.OS != "" {
|
||||
n.OS = msg.OS
|
||||
}
|
||||
if msg.ProtocolVersion != "" {
|
||||
n.ProtocolVersion = msg.ProtocolVersion
|
||||
}
|
||||
if msg.AgentVersion != "" {
|
||||
n.AgentVersion = msg.AgentVersion
|
||||
}
|
||||
if msg.Capabilities != nil {
|
||||
n.Capabilities = make(map[string]bool, len(msg.Capabilities))
|
||||
for _, c := range msg.Capabilities {
|
||||
n.Capabilities[c] = true
|
||||
}
|
||||
}
|
||||
if msg.Profile != nil {
|
||||
n.LastProfile = msg.Profile
|
||||
n.LastHeartbeat.Store(now)
|
||||
@@ -267,9 +300,9 @@ func (m *Manager) Devices() []map[string]any {
|
||||
item := map[string]any{
|
||||
"name": name, "status": status, "is_local": n.IsLocal,
|
||||
"arch": n.Arch, "os": n.OS,
|
||||
"remote_ip": n.RemoteIP,
|
||||
"country_code": n.CountryCode,
|
||||
"country": n.Country,
|
||||
"remote_ip": n.RemoteIP,
|
||||
"country_code": n.CountryCode,
|
||||
"country": n.Country,
|
||||
"last_heartbeat": n.LastHeartbeat.Load() / int64(time.Second),
|
||||
}
|
||||
if n.LastProfile != nil {
|
||||
@@ -285,6 +318,25 @@ func (m *Manager) Devices() []map[string]any {
|
||||
return out
|
||||
}
|
||||
|
||||
func operationRisk(action string) int {
|
||||
if r, ok := security.RequiredRiskForAction(action); ok {
|
||||
return r
|
||||
}
|
||||
if spec, ok := security.Lookup(action); ok {
|
||||
return spec.RequiredRisk
|
||||
}
|
||||
return security.RiskHigh
|
||||
}
|
||||
|
||||
func (n *Node) acquireOperation(action string) func() {
|
||||
if operationRisk(action) <= security.RiskLow {
|
||||
n.OpMu.RLock()
|
||||
return n.OpMu.RUnlock
|
||||
}
|
||||
n.OpMu.Lock()
|
||||
return n.OpMu.Unlock
|
||||
}
|
||||
|
||||
func (m *Manager) call(ctx context.Context, name, action string, payload any) (Message, error) {
|
||||
m.mu.RLock()
|
||||
n := m.nodes[name]
|
||||
@@ -292,6 +344,12 @@ func (m *Manager) call(ctx context.Context, name, action string, payload any) (M
|
||||
if n == nil || !m.Online(name) {
|
||||
return Message{}, errors.New("device offline")
|
||||
}
|
||||
capability := actionCapability(action)
|
||||
if capability != "" && len(n.Capabilities) > 0 && !n.Capabilities[capability] {
|
||||
return Message{}, fmt.Errorf("capability missing: %s", capability)
|
||||
}
|
||||
release := n.acquireOperation(action)
|
||||
defer release()
|
||||
id := fmt.Sprintf("task_%d", time.Now().UnixNano())
|
||||
ch := make(chan Message, 1)
|
||||
n.Pending.Store(id, ch)
|
||||
@@ -314,6 +372,90 @@ func (m *Manager) call(ctx context.Context, name, action string, payload any) (M
|
||||
}
|
||||
}
|
||||
|
||||
func actionCapability(action string) string {
|
||||
switch action {
|
||||
case "exec":
|
||||
return "exec_command"
|
||||
case "file_read":
|
||||
return "read_file"
|
||||
case "file_write":
|
||||
return "write_file"
|
||||
default:
|
||||
return action
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) StartProcess(ctx context.Context, name string, p StartProcessPayload) (map[string]any, error) {
|
||||
msg, err := m.call(ctx, name, "start_process", p)
|
||||
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) ReadProcessOutput(ctx context.Context, name, sessionID, owner string) (ProcessOutputPayload, error) {
|
||||
msg, err := m.call(ctx, name, "read_process_output", map[string]string{"session_id": sessionID, "owner": owner})
|
||||
if err != nil {
|
||||
return ProcessOutputPayload{}, err
|
||||
}
|
||||
var out ProcessOutputPayload
|
||||
b, _ := json.Marshal(msg.Payload)
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (m *Manager) InteractProcess(ctx context.Context, name, sessionID, data, owner string) error {
|
||||
_, err := m.call(ctx, name, "interact_with_process", ProcessInputPayload{SessionID: sessionID, Data: data, Owner: owner})
|
||||
return err
|
||||
}
|
||||
func (m *Manager) ForceTerminate(ctx context.Context, name, sessionID, owner string) error {
|
||||
_, err := m.call(ctx, name, "force_terminate", map[string]string{"session_id": sessionID, "owner": owner})
|
||||
return err
|
||||
}
|
||||
func (m *Manager) ListSessions(ctx context.Context, name string) (any, error) {
|
||||
msg, err := m.call(ctx, name, "list_sessions", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg.Payload, nil
|
||||
}
|
||||
|
||||
func (m *Manager) ListDirectory(ctx context.Context, name string, p ListDirectoryPayload) ([]Entry, error) {
|
||||
msg, err := m.call(ctx, name, "list_directory", p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var v []Entry
|
||||
b, _ := json.Marshal(msg.Payload)
|
||||
err = json.Unmarshal(b, &v)
|
||||
return v, err
|
||||
}
|
||||
func (m *Manager) GetFileInfo(ctx context.Context, name string, p FileInfoPayload) (FileInfo, error) {
|
||||
msg, err := m.call(ctx, name, "get_file_info", p)
|
||||
if err != nil {
|
||||
return FileInfo{}, err
|
||||
}
|
||||
var v FileInfo
|
||||
b, _ := json.Marshal(msg.Payload)
|
||||
err = json.Unmarshal(b, &v)
|
||||
return v, err
|
||||
}
|
||||
func (m *Manager) Search(ctx context.Context, name string, p SearchPayload) ([]SearchResult, error) {
|
||||
msg, err := m.call(ctx, name, "start_search", p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var v []SearchResult
|
||||
b, _ := json.Marshal(msg.Payload)
|
||||
err = json.Unmarshal(b, &v)
|
||||
return v, 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 {
|
||||
@@ -335,6 +477,41 @@ func (m *Manager) ReadFile(ctx context.Context, name string, p FileReadPayload)
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (m *Manager) EditBlock(ctx context.Context, name string, p EditBlockPayload) (EditBlockResult, error) {
|
||||
msg, err := m.call(ctx, name, "edit_block", p)
|
||||
if err != nil {
|
||||
return EditBlockResult{}, err
|
||||
}
|
||||
var v EditBlockResult
|
||||
b, _ := json.Marshal(msg.Payload)
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
return EditBlockResult{}, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (m *Manager) ReadMultipleFiles(ctx context.Context, name string, p ReadMultipleFilesPayload) (any, error) {
|
||||
msg, err := m.call(ctx, name, "read_multiple_files", p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg.Payload, nil
|
||||
}
|
||||
func (m *Manager) CreateDirectory(ctx context.Context, name string, p CreateDirectoryPayload) error {
|
||||
_, err := m.call(ctx, name, "create_directory", p)
|
||||
return err
|
||||
}
|
||||
func (m *Manager) MoveFile(ctx context.Context, name string, p MoveFilePayload) error {
|
||||
_, err := m.call(ctx, name, "move_file", p)
|
||||
return err
|
||||
}
|
||||
func (m *Manager) ListProcesses(ctx context.Context, name string) (any, error) {
|
||||
msg, err := m.call(ctx, name, "list_processes", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg.Payload, 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 {
|
||||
|
||||
@@ -3,21 +3,25 @@ package cluster
|
||||
import "github.com/wjyhk/vps-commander/internal/sysinfo"
|
||||
|
||||
type Message struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Event string `json:"event,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Device string `json:"device,omitempty"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
OS string `json:"os,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Payload any `json:"payload,omitempty"`
|
||||
Profile *sysinfo.Profile `json:"profile,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"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Event string `json:"event,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
SubAction string `json:"sub_action,omitempty"`
|
||||
Device string `json:"device,omitempty"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
OS string `json:"os,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Payload any `json:"payload,omitempty"`
|
||||
Profile *sysinfo.Profile `json:"profile,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"`
|
||||
ProtocolVersion string `json:"protocol_version,omitempty"`
|
||||
AgentVersion string `json:"agent_version,omitempty"`
|
||||
Capabilities []string `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
type ExecPayload struct {
|
||||
@@ -36,3 +40,80 @@ type FileWritePayload struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type EditBlockPayload struct {
|
||||
Path string `json:"path"`
|
||||
OldText string `json:"old_text"`
|
||||
NewText string `json:"new_text"`
|
||||
ExpectedHash string `json:"expected_hash,omitempty"`
|
||||
}
|
||||
|
||||
type EditBlockResult struct {
|
||||
BytesWritten int64 `json:"bytes_written"`
|
||||
}
|
||||
|
||||
type StartProcessPayload struct {
|
||||
Command string `json:"command"`
|
||||
Workdir string `json:"workdir,omitempty"`
|
||||
SessionID string `json:"session_id"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
RingCapacity int `json:"ring_capacity,omitempty"`
|
||||
}
|
||||
type ProcessInputPayload struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Data string `json:"data"`
|
||||
Owner string `json:"owner"`
|
||||
}
|
||||
type ProcessOutputPayload struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Running bool `json:"running"`
|
||||
}
|
||||
|
||||
type ListDirectoryPayload struct {
|
||||
Path string `json:"path"`
|
||||
Recursive bool `json:"recursive,omitempty"`
|
||||
MaxEntries int `json:"max_entries,omitempty"`
|
||||
}
|
||||
type FileInfoPayload struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
type ReadMultipleFilesPayload struct {
|
||||
Paths []string `json:"paths"`
|
||||
Limit int64 `json:"limit,omitempty"`
|
||||
}
|
||||
type CreateDirectoryPayload struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
type MoveFilePayload struct {
|
||||
Source string `json:"source"`
|
||||
Destination string `json:"destination"`
|
||||
}
|
||||
type SearchPayload struct {
|
||||
Root string `json:"root"`
|
||||
Query string `json:"query"`
|
||||
MaxResults int `json:"max_results,omitempty"`
|
||||
}
|
||||
|
||||
type Entry struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
Size int64 `json:"size"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
type FileInfo struct {
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
Size int64 `json:"size"`
|
||||
Mode string `json:"mode"`
|
||||
ModTime int64 `json:"mod_time"`
|
||||
}
|
||||
type SearchResult struct {
|
||||
Path string `json:"path"`
|
||||
Line int `json:"line,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
Size int64 `json:"size"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
type FileInfo struct {
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
Size int64 `json:"size"`
|
||||
Mode string `json:"mode"`
|
||||
ModTime int64 `json:"mod_time"`
|
||||
}
|
||||
|
||||
type SearchResult struct {
|
||||
Path string `json:"path"`
|
||||
Line int `json:"line,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
func ListDirectory(path string, recursive bool, maxEntries int) ([]Entry, error) {
|
||||
if path == "" {
|
||||
return nil, errors.New("path required")
|
||||
}
|
||||
if maxEntries <= 0 || maxEntries > 10000 {
|
||||
maxEntries = 1000
|
||||
}
|
||||
out := make([]Entry, 0, maxEntries)
|
||||
add := func(p string, d os.DirEntry) error {
|
||||
if len(out) >= maxEntries {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, Entry{Name: d.Name(), Path: p, IsDir: d.IsDir(), Size: info.Size(), Mode: info.Mode().String()})
|
||||
return nil
|
||||
}
|
||||
if !recursive {
|
||||
ds, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, d := range ds {
|
||||
if err := add(filepath.Join(path, d.Name()), d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
err := filepath.WalkDir(path, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == path {
|
||||
return nil
|
||||
}
|
||||
if len(out) >= maxEntries {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return add(p, d)
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func GetFileInfo(path string) (FileInfo, error) {
|
||||
if path == "" {
|
||||
return FileInfo{}, errors.New("path required")
|
||||
}
|
||||
i, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return FileInfo{}, err
|
||||
}
|
||||
return FileInfo{Path: path, Name: i.Name(), IsDir: i.IsDir(), Size: i.Size(), Mode: i.Mode().String(), ModTime: i.ModTime().Unix()}, nil
|
||||
}
|
||||
|
||||
func Search(ctx context.Context, root, query string, maxResults int) ([]SearchResult, error) {
|
||||
if root == "" || query == "" {
|
||||
return nil, errors.New("root and query required")
|
||||
}
|
||||
if maxResults <= 0 || maxResults > 1000 {
|
||||
maxResults = 100
|
||||
}
|
||||
out := make([]SearchResult, 0, maxResults)
|
||||
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(out) >= maxResults {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if d.Type()&os.ModeSymlink != 0 {
|
||||
return nil
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(string(b), query) {
|
||||
for n, line := range strings.Split(string(b), "\n") {
|
||||
if strings.Contains(line, query) {
|
||||
out = append(out, SearchResult{Path: path, Line: n + 1, Text: line})
|
||||
if len(out) >= maxResults {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
||||
return out, err
|
||||
}
|
||||
|
||||
type FileReadItem struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
type ProcessInfo struct {
|
||||
PID int `json:"pid"`
|
||||
Command string `json:"command"`
|
||||
CPU string `json:"cpu,omitempty"`
|
||||
MemoryKB uint64 `json:"memory_kb,omitempty"`
|
||||
}
|
||||
|
||||
func ReadMultipleFiles(paths []string, limit int64) []FileReadItem {
|
||||
if limit <= 0 || limit > 1024*1024 {
|
||||
limit = 256 * 1024
|
||||
}
|
||||
out := make([]FileReadItem, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
v := FileReadItem{Path: p}
|
||||
b, e := os.ReadFile(p)
|
||||
if e != nil {
|
||||
v.Error = e.Error()
|
||||
} else {
|
||||
if int64(len(b)) > limit {
|
||||
b = b[:limit]
|
||||
}
|
||||
v.Content = string(b)
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
func CreateDirectory(path string) error {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return errors.New("path required")
|
||||
}
|
||||
return os.MkdirAll(path, 0755)
|
||||
}
|
||||
func MoveFile(src, dst string) error {
|
||||
if src == "" || dst == "" {
|
||||
return errors.New("source and destination required")
|
||||
}
|
||||
return os.Rename(src, dst)
|
||||
}
|
||||
func ListProcesses() ([]ProcessInfo, error) {
|
||||
ents, e := os.ReadDir("/proc")
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
out := make([]ProcessInfo, 0)
|
||||
for _, ent := range ents {
|
||||
if !ent.IsDir() {
|
||||
continue
|
||||
}
|
||||
pid, e := strconv.Atoi(ent.Name())
|
||||
if e != nil {
|
||||
continue
|
||||
}
|
||||
comm, _ := os.ReadFile(filepath.Join("/proc", ent.Name(), "comm"))
|
||||
stat, _ := os.ReadFile(filepath.Join("/proc", ent.Name(), "statm"))
|
||||
cmd, _ := os.ReadFile(filepath.Join("/proc", ent.Name(), "cmdline"))
|
||||
v := ProcessInfo{PID: pid, Command: strings.TrimSpace(string(comm))}
|
||||
if len(cmd) > 0 {
|
||||
v.Command = strings.ReplaceAll(string(cmd), "\x00", " ")
|
||||
v.Command = strings.TrimSpace(v.Command)
|
||||
}
|
||||
f := strings.Fields(string(stat))
|
||||
if len(f) >= 2 {
|
||||
if pages, e := strconv.ParseUint(f[1], 10, 64); e == nil {
|
||||
v.MemoryKB = pages * uint64(os.Getpagesize()) / 1024
|
||||
}
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var _ = fmt.Sprintf
|
||||
var _ = exec.Command
|
||||
+97
-1
@@ -74,6 +74,14 @@ type ActionBackend interface {
|
||||
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)
|
||||
EditBlock(ctx context.Context, clientIP, device, path, oldText, newText string) (any, error)
|
||||
}
|
||||
|
||||
type ExtendedBackend interface {
|
||||
ReadMultipleFiles(ctx context.Context, clientIP, device string, paths []string, limit int64) (any, error)
|
||||
CreateDirectory(ctx context.Context, clientIP, device, path string) (any, error)
|
||||
MoveFile(ctx context.Context, clientIP, device, source, destination string) (any, error)
|
||||
ListProcesses(ctx context.Context, clientIP, device string) (any, error)
|
||||
}
|
||||
|
||||
type AgentMCPBackend interface {
|
||||
@@ -81,6 +89,10 @@ type AgentMCPBackend interface {
|
||||
CallAgentMCP(ctx context.Context, clientIP, device, server, tool string, args any) (any, error)
|
||||
}
|
||||
|
||||
type SecurityGate interface {
|
||||
AuthorizeMCP(device, action, clientIP string) error
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
Backend ActionBackend
|
||||
Auth *auth.Manager
|
||||
@@ -264,8 +276,16 @@ func (s *Server) handleRequest(ctx context.Context, r *http.Request, req JSONRPC
|
||||
Required: []string{"path"},
|
||||
},
|
||||
},
|
||||
{Name: "read_multiple_files", Description: "一次读取多个文件", InputSchema: InputSchema{Type: "object", Properties: map[string]PropertyDef{"device": {Type: "string", Description: "目标设备"}, "paths": {Type: "array", Description: "文件路径数组"}, "limit": {Type: "integer", Description: "单文件最大字节数"}}, Required: []string{"paths"}}},
|
||||
{Name: "create_directory", Description: "创建目录", InputSchema: InputSchema{Type: "object", Properties: map[string]PropertyDef{"device": {Type: "string", Description: "目标设备"}, "path": {Type: "string", Description: "目录路径"}}, Required: []string{"path"}}},
|
||||
{Name: "move_file", Description: "移动或重命名文件", InputSchema: InputSchema{Type: "object", Properties: map[string]PropertyDef{"device": {Type: "string", Description: "目标设备"}, "source": {Type: "string", Description: "源路径"}, "destination": {Type: "string", Description: "目标路径"}}, Required: []string{"source", "destination"}}},
|
||||
{Name: "list_processes", Description: "列出受控节点当前进程", InputSchema: InputSchema{Type: "object", Properties: map[string]PropertyDef{"device": {Type: "string", Description: "目标设备"}}}},
|
||||
{
|
||||
Name: "edit_block",
|
||||
Description: "对指定文件执行唯一文本块的原子替换;old_text 必须恰好匹配一次",
|
||||
InputSchema: InputSchema{Type: "object", Properties: map[string]PropertyDef{"device": {Type: "string", Description: "目标节点名称"}, "path": {Type: "string", Description: "目标文件路径"}, "old_text": {Type: "string", Description: "必须唯一匹配的原文本块"}, "new_text": {Type: "string", Description: "替换后的文本块"}}, Required: []string{"path", "old_text", "new_text"}},
|
||||
},
|
||||
{
|
||||
Name: "write_file",
|
||||
Description: "向指定受控节点创建或覆写文件",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
@@ -369,6 +389,72 @@ func (s *Server) callTool(ctx context.Context, clientIP string, params CallToolP
|
||||
}
|
||||
return res, false
|
||||
|
||||
case "read_multiple_files":
|
||||
var a struct {
|
||||
Device string `json:"device"`
|
||||
Paths []string `json:"paths"`
|
||||
Limit int64 `json:"limit"`
|
||||
}
|
||||
_ = json.Unmarshal(params.Arguments, &a)
|
||||
b, ok := s.Backend.(ExtendedBackend)
|
||||
if !ok {
|
||||
return map[string]string{"error": "extended tools unavailable"}, true
|
||||
}
|
||||
res, err := b.ReadMultipleFiles(ctx, clientIP, a.Device, a.Paths, a.Limit)
|
||||
if err != nil {
|
||||
return map[string]string{"error": err.Error()}, true
|
||||
}
|
||||
return res, false
|
||||
case "create_directory":
|
||||
var a struct{ Device, Path string }
|
||||
_ = json.Unmarshal(params.Arguments, &a)
|
||||
b, ok := s.Backend.(ExtendedBackend)
|
||||
if !ok {
|
||||
return map[string]string{"error": "extended tools unavailable"}, true
|
||||
}
|
||||
res, err := b.CreateDirectory(ctx, clientIP, a.Device, a.Path)
|
||||
if err != nil {
|
||||
return map[string]string{"error": err.Error()}, true
|
||||
}
|
||||
return res, false
|
||||
case "move_file":
|
||||
var a struct{ Device, Source, Destination string }
|
||||
_ = json.Unmarshal(params.Arguments, &a)
|
||||
b, ok := s.Backend.(ExtendedBackend)
|
||||
if !ok {
|
||||
return map[string]string{"error": "extended tools unavailable"}, true
|
||||
}
|
||||
res, err := b.MoveFile(ctx, clientIP, a.Device, a.Source, a.Destination)
|
||||
if err != nil {
|
||||
return map[string]string{"error": err.Error()}, true
|
||||
}
|
||||
return res, false
|
||||
case "list_processes":
|
||||
var a struct {
|
||||
Device string `json:"device"`
|
||||
}
|
||||
_ = json.Unmarshal(params.Arguments, &a)
|
||||
b, ok := s.Backend.(ExtendedBackend)
|
||||
if !ok {
|
||||
return map[string]string{"error": "extended tools unavailable"}, true
|
||||
}
|
||||
res, err := b.ListProcesses(ctx, clientIP, a.Device)
|
||||
if err != nil {
|
||||
return map[string]string{"error": err.Error()}, true
|
||||
}
|
||||
return res, false
|
||||
case "edit_block":
|
||||
var args struct{ Device, Path, OldText, NewText string }
|
||||
_ = json.Unmarshal(params.Arguments, &args)
|
||||
if args.Path == "" || args.OldText == "" {
|
||||
return map[string]string{"error": "path and old_text are required"}, true
|
||||
}
|
||||
res, err := s.Backend.EditBlock(ctx, clientIP, args.Device, args.Path, args.OldText, args.NewText)
|
||||
if err != nil {
|
||||
return map[string]string{"error": err.Error()}, true
|
||||
}
|
||||
return res, false
|
||||
|
||||
case "write_file":
|
||||
var args struct {
|
||||
Device string `json:"device"`
|
||||
@@ -394,6 +480,11 @@ func (s *Server) callTool(ctx context.Context, clientIP string, params CallToolP
|
||||
if !ok {
|
||||
return map[string]string{"error": "agent MCP unavailable"}, true
|
||||
}
|
||||
if gate, ok := s.Backend.(SecurityGate); ok {
|
||||
if err := gate.AuthorizeMCP(args.Device, "read", clientIP); err != nil {
|
||||
return map[string]string{"error": err.Error()}, true
|
||||
}
|
||||
}
|
||||
res, err := b.ListAgentMCP(ctx, args.Device)
|
||||
if err != nil {
|
||||
return map[string]string{"error": err.Error()}, true
|
||||
@@ -416,6 +507,11 @@ func (s *Server) callTool(ctx context.Context, clientIP string, params CallToolP
|
||||
if len(args.Arguments) > 0 {
|
||||
_ = json.Unmarshal(args.Arguments, &av)
|
||||
}
|
||||
if gate, ok := s.Backend.(SecurityGate); ok {
|
||||
if err := gate.AuthorizeMCP(args.Device, "mcp", clientIP); err != nil {
|
||||
return map[string]string{"error": err.Error()}, true
|
||||
}
|
||||
}
|
||||
res, err := b.CallAgentMCP(ctx, clientIP, args.Device, args.Server, args.Tool, av)
|
||||
if err != nil {
|
||||
return map[string]string{"error": err.Error()}, true
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -24,6 +25,9 @@ func (m *mockBackend) ReadFile(ctx context.Context, clientIP, device, path strin
|
||||
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 (m *mockBackend) EditBlock(ctx context.Context, clientIP, device, path, oldText, newText string) (any, error) {
|
||||
return map[string]any{"device": device, "path": path, "bytes_written": len(newText)}, nil
|
||||
}
|
||||
|
||||
func TestMCPInitializeAndListTools(t *testing.T) {
|
||||
srv := NewServer(&mockBackend{}, nil)
|
||||
@@ -75,8 +79,8 @@ func TestMCPInitializeAndListTools(t *testing.T) {
|
||||
t.Fatalf("expected map result")
|
||||
}
|
||||
tools, ok := resMap["tools"].([]any)
|
||||
if !ok || len(tools) != 6 {
|
||||
t.Fatalf("expected 6 tools, got %d", len(tools))
|
||||
if !ok || len(tools) != 11 {
|
||||
t.Fatalf("expected 11 tools, got %d", len(tools))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,3 +114,47 @@ func TestMCPCallTool(t *testing.T) {
|
||||
t.Fatalf("expected stdout in result, got %s", contentStr)
|
||||
}
|
||||
}
|
||||
|
||||
type gatedMockBackend struct {
|
||||
mockBackend
|
||||
deniedAction string
|
||||
}
|
||||
|
||||
func (g *gatedMockBackend) AuthorizeMCP(device, action, clientIP string) error {
|
||||
if action == g.deniedAction {
|
||||
return fmt.Errorf("denied %s", action)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *gatedMockBackend) ListAgentMCP(ctx context.Context, device string) (any, error) {
|
||||
return map[string]any{"device": device}, nil
|
||||
}
|
||||
|
||||
func (g *gatedMockBackend) CallAgentMCP(ctx context.Context, clientIP, device, server, tool string, args any) (any, error) {
|
||||
return map[string]any{"device": device, "server": server, "tool": tool}, nil
|
||||
}
|
||||
|
||||
func TestMCPAgentCallUsesSecurityGate(t *testing.T) {
|
||||
backend := &gatedMockBackend{deniedAction: "mcp"}
|
||||
srv := NewServer(backend, nil)
|
||||
params := json.RawMessage("{\"name\":\"call_agent_mcp\",\"arguments\":{\"device\":\"node1\",\"server\":\"s\",\"tool\":\"t\"}}")
|
||||
req := JSONRPCRequest{JSONRPC: "2.0", ID: 10, Method: "tools/call", Params: params}
|
||||
resp := srv.HandleDirectRequest(context.Background(), req)
|
||||
result, ok := resp.Result.(CallToolResult)
|
||||
if !ok || !result.IsError || !strings.Contains(result.Content[0].Text, "denied mcp") {
|
||||
t.Fatalf("expected MCP denial, got %#v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPListAgentUsesReadGate(t *testing.T) {
|
||||
backend := &gatedMockBackend{deniedAction: "read"}
|
||||
srv := NewServer(backend, nil)
|
||||
params := json.RawMessage("{\"name\":\"list_agent_mcp\",\"arguments\":{\"device\":\"node1\"}}")
|
||||
req := JSONRPCRequest{JSONRPC: "2.0", ID: 11, Method: "tools/call", Params: params}
|
||||
resp := srv.HandleDirectRequest(context.Background(), req)
|
||||
result, ok := resp.Result.(CallToolResult)
|
||||
if !ok || !result.IsError || !strings.Contains(result.Content[0].Text, "denied read") {
|
||||
t.Fatalf("expected MCP list denial, got %#v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
+145
-6
@@ -1,12 +1,15 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
_ "modernc.org/sqlite"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -45,7 +48,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 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);")
|
||||
"CREATE TABLE IF NOT EXISTS revoked_devices (name TEXT PRIMARY KEY, revoked_at INTEGER NOT NULL, reason TEXT); CREATE TABLE IF NOT EXISTS device_credentials (name TEXT PRIMARY KEY, token_hash TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL); 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 security_policy_meta (id INTEGER PRIMARY KEY CHECK(id=1), version INTEGER NOT NULL); INSERT OR IGNORE INTO security_policy_meta(id,version) VALUES(1,1); 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
|
||||
}
|
||||
@@ -276,9 +279,20 @@ type AuditLog struct {
|
||||
ErrorMsg string `json:"error_msg"`
|
||||
}
|
||||
|
||||
var auditSecretRE = regexp.MustCompile(`(?i)(authorization|bearer|token|api[_-]?key|password|secret|cookie)(\s*[:=]\s+)([^\s,;]+)`)
|
||||
|
||||
func redactAudit(v string) string {
|
||||
v = auditSecretRE.ReplaceAllString(v, `$1$2[REDACTED]`)
|
||||
v = strings.ReplaceAll(v, "\r", " ")
|
||||
v = strings.ReplaceAll(v, "\n", " ")
|
||||
if len(v) > 8192 {
|
||||
v = v[:8192]
|
||||
}
|
||||
return v
|
||||
}
|
||||
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)
|
||||
time.Now().Unix(), ip, device, action, redactAudit(command), code, ms, redactAudit(msg))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -387,6 +401,59 @@ func normalizeSecurityMode(mode string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
type SecurityPolicyState struct {
|
||||
GlobalMode string
|
||||
Nodes map[string]string
|
||||
Version uint64
|
||||
}
|
||||
|
||||
func (s *Store) LoadSecurityPolicy() (SecurityPolicyState, error) {
|
||||
state := SecurityPolicyState{GlobalMode: "medium", Nodes: map[string]string{}, Version: 1}
|
||||
if err := s.DB.QueryRow("SELECT mode FROM security_settings WHERE node_name='' LIMIT 1").Scan(&state.GlobalMode); err != nil && err != sql.ErrNoRows {
|
||||
return state, err
|
||||
}
|
||||
if m := normalizeSecurityMode(state.GlobalMode); m != "" && m != "inherit" {
|
||||
state.GlobalMode = m
|
||||
} else {
|
||||
state.GlobalMode = "medium"
|
||||
}
|
||||
var version int64
|
||||
if err := s.DB.QueryRow("SELECT version FROM security_policy_meta WHERE id=1").Scan(&version); err != nil {
|
||||
return state, err
|
||||
}
|
||||
if version > 0 {
|
||||
state.Version = uint64(version)
|
||||
}
|
||||
rows, err := s.DB.Query("SELECT node_name,mode FROM security_settings WHERE node_name<>''")
|
||||
if err != nil {
|
||||
return state, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var node, mode string
|
||||
if err := rows.Scan(&node, &mode); err != nil {
|
||||
return state, err
|
||||
}
|
||||
if m := normalizeSecurityMode(mode); m != "" {
|
||||
state.Nodes[node] = m
|
||||
}
|
||||
}
|
||||
return state, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) bumpSecurityPolicyVersion(tx *sql.Tx) error {
|
||||
_, err := tx.Exec("UPDATE security_policy_meta SET version=version+1 WHERE id=1")
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) SecurityPolicyVersion() uint64 {
|
||||
var version int64
|
||||
if err := s.DB.QueryRow("SELECT version FROM security_policy_meta WHERE id=1").Scan(&version); err != nil || version < 1 {
|
||||
return 1
|
||||
}
|
||||
return uint64(version)
|
||||
}
|
||||
|
||||
func (s *Store) GetGlobalSecurityMode() string {
|
||||
var mode string
|
||||
err := s.DB.QueryRow("SELECT mode FROM security_settings WHERE node_name='' LIMIT 1").Scan(&mode)
|
||||
@@ -404,8 +471,18 @@ func (s *Store) SetGlobalSecurityMode(mode string) error {
|
||||
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
|
||||
tx, err := s.DB.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.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()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = s.bumpSecurityPolicyVersion(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) GetNodeSecurityMode(node string) (string, bool) {
|
||||
@@ -429,8 +506,18 @@ func (s *Store) SetNodeSecurityMode(node, mode string) error {
|
||||
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
|
||||
tx, err := s.DB.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.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()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = s.bumpSecurityPolicyVersion(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
type SecuritySetting struct {
|
||||
@@ -454,3 +541,55 @@ func (s *Store) ListNodeSecurityModes() ([]SecuritySetting, error) {
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func hashDeviceToken(token string) string {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// SetDeviceCredential stores only a SHA-256 hash of the per-device token.
|
||||
func (s *Store) DeviceCredentialConfigured(name string) (bool, error) {
|
||||
return s.HasDeviceCredential(name)
|
||||
}
|
||||
|
||||
func (s *Store) SetDeviceCredential(name, token string) error {
|
||||
if strings.TrimSpace(name) == "" || token == "" {
|
||||
return errors.New("device name and token required")
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
_, err := s.DB.Exec(`INSERT INTO device_credentials(name,token_hash,created_at,updated_at)
|
||||
VALUES(?,?,?,?) ON CONFLICT(name) DO UPDATE SET token_hash=excluded.token_hash,updated_at=excluded.updated_at`, name, hashDeviceToken(token), now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) VerifyDeviceCredential(name, token string) (bool, error) {
|
||||
if name == "" || token == "" {
|
||||
return false, nil
|
||||
}
|
||||
var stored string
|
||||
err := s.DB.QueryRow("SELECT token_hash FROM device_credentials WHERE name=?", name).Scan(&stored)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return subtleConstantTimeEqual(stored, hashDeviceToken(token)), nil
|
||||
}
|
||||
|
||||
func subtleConstantTimeEqual(a, b string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
var v byte
|
||||
for i := range a {
|
||||
v |= a[i] ^ b[i]
|
||||
}
|
||||
return v == 0
|
||||
}
|
||||
|
||||
func (s *Store) HasDeviceCredential(name string) (bool, error) {
|
||||
var count int
|
||||
err := s.DB.QueryRow("SELECT COUNT(*) FROM device_credentials WHERE name=?", name).Scan(&count)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user