Compare commits

..
8 Commits
40 changed files with 361 additions and 6025 deletions
+3 -3
View File
@@ -79,7 +79,7 @@
无论是在受控端 VPS 还是中心端,只需一行命令即可交互式安装与启动:
```bash
bash <(curl -fsSLk https://raw.githubusercontent.com/kyaring/vps-commander/main/deploy/install.sh)
bash <(curl -fsSLk https://gitea.king.nyc.mn/openclaw/vps-commander/raw/branch/main/deploy/install.sh)
```
- **安装 Agent (受控 VPS)**:输入 Hub 的 WSS 地址与通信密钥,全自动配置开机自启并秒级连入集群。
- **安装 Hub (中心服务端)**:自动生成安全 API Key / Web 面板密码并以 Systemd 托管常驻。
@@ -88,9 +88,9 @@ bash <(curl -fsSLk https://raw.githubusercontent.com/kyaring/vps-commander/main/
当新版本发布后,无需重新配置通信密钥或重新安装,在任意受控机上直接运行升级脚本:
```bash
curl -fsSLk https://raw.githubusercontent.com/kyaring/vps-commander/main/deploy/upgrade-agent.sh | bash
curl -fsSLk https://gitea.king.nyc.mn/openclaw/vps-commander/raw/branch/main/deploy/upgrade-agent.sh | bash
```
- 自动检测并拉取 GitHub 最新版本架构包(AMD64 / ARM64);
- 自动检测并拉取 Gitea 最新版本架构包(AMD64 / ARM64);
- 自动备份旧版本二进制(`.bak`);
- 升级异常(校验失败或无法启动)时**自动秒级回滚**,保证节点永不失联。
+5 -166
View File
@@ -23,7 +23,7 @@ import (
func main() {
hub := flag.String("hub", "", "Hub WebSocket URL")
name := flag.String("name", "", "device name")
token := flag.String("token", firstNonEmpty(os.Getenv("VPS_COMMANDER_AGENT_TOKEN"), os.Getenv("VPS_COMMANDER_CLUSTER_SECRET")), "agent credential")
token := flag.String("token", os.Getenv("VPS_COMMANDER_CLUSTER_SECRET"), "cluster secret")
insecure := flag.Bool("insecure", false, "skip TLS verification")
flag.Parse()
if *hub == "" || *name == "" || *token == "" {
@@ -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(), 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 {
if err := writeJSON(cluster.Message{Event: "hello", Device: name, Arch: runtime.GOARCH, OS: runtime.GOOS, Profile: &initProf, TS: time.Now().Unix()}); err != nil {
return err
}
@@ -94,20 +94,6 @@ 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 {
@@ -142,15 +128,15 @@ func run(hub, name, token string, insecure bool) error {
continue
}
go func(msg cluster.Message) {
if err := handle(writeJSON, exec, mcpRuntime, sessions, msg); err != nil {
if err := handle(writeJSON, exec, mcpRuntime, msg); err != nil {
log.Printf("request %s failed: %v", msg.ID, err)
_ = sendError(writeJSON, msg.ID, err)
_ = c.Close()
}
}(msg)
}
}
func handle(writeJSON func(cluster.Message) error, exec executor.Local, mcpRuntime *cluster.MCPRuntime, sessions *executor.SessionManager, msg cluster.Message) error {
func handle(writeJSON func(cluster.Message) error, exec executor.Local, mcpRuntime *cluster.MCPRuntime, msg cluster.Message) error {
switch msg.Action {
case "mcp_list":
return writeJSON(cluster.Message{ID: msg.ID, Status: "success", Payload: mcpRuntime.List()})
@@ -190,134 +176,6 @@ 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 {
@@ -365,16 +223,6 @@ 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 {
@@ -403,12 +251,3 @@ 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 ""
}
+8 -18
View File
@@ -37,12 +37,8 @@ func main() {
apiKey := os.Getenv("VPS_COMMANDER_API_KEY")
clusterSecret := os.Getenv("VPS_COMMANDER_CLUSTER_SECRET")
webPassword := os.Getenv("VPS_COMMANDER_WEB_PASSWORD")
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")
if apiKey == "" || clusterSecret == "" || webPassword == "" {
log.Fatal("VPS_COMMANDER_API_KEY, VPS_COMMANDER_CLUSTER_SECRET and VPS_COMMANDER_WEB_PASSWORD are required via environment")
}
authManager := auth.NewManager(apiKey)
store, err := storage.Open(*dbPath)
@@ -61,18 +57,12 @@ 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,
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)
Auth: authManager,
Exec: executor.Local{MaxOutput: 1024 * 1024},
Cluster: cluster.NewManager(clusterSecret, store),
Store: store,
LocalName: local,
Notify: notificationManager,
}
// MCP Server instance
-7
View File
@@ -100,13 +100,6 @@ func (h *HubRemoteBackend) ReadFile(ctx context.Context, clientIP, device, path
return res, err
}
func (h *HubRemoteBackend) EditBlock(ctx context.Context, clientIP, device, path, oldText, newText string) (any, error) {
payload := map[string]any{"device": device, "path": path, "old_text": oldText, "new_text": newText}
var res any
err := h.post("/api/v1/devices/file/edit_block", payload, &res)
return res, err
}
func (h *HubRemoteBackend) WriteFile(ctx context.Context, clientIP, device, path, content string) (any, error) {
payload := map[string]any{
"device": device,
+21 -20
View File
@@ -2,12 +2,13 @@
set -e
# VPS-Commander 一键安装与配置脚本 (Hub / Agent)
# Repo: https://github.com/kyaring/vps-commander
# Repo: https://gitea.king.nyc.mn/openclaw/vps-commander
REPO="kyaring/vps-commander"
GITEA_HOST="gitea.king.nyc.mn"
REPO="openclaw/vps-commander"
INSTALL_DIR="/opt/vps-commander"
CONF_DIR="/etc/vps-commander"
GITHUB_URL="https://github.com"
GITEA_URL="https://gitea.king.nyc.mn"
# 颜色输出
RED='\033[0;31m'
@@ -37,9 +38,9 @@ command -v curl >/dev/null 2>&1 || (apt-get update && apt-get install -y curl ||
# 获取最新版本 tag
info "正在获取 VPS-Commander 最新版本信息..."
LATEST_TAG=$(curl -fsSL -k "https://api.github.com/repos/${REPO}/releases/latest" 2>/dev/null | grep '"tag_name":' | head -n1 | sed -E 's/.*"([^"]+)".*/\1/')
LATEST_TAG=$(curl -fsSL -k "${GITEA_URL}/api/v1/repos/${REPO}/releases/latest" 2>/dev/null | grep -o '"tag_name":"[^"]*"' | head -n1 | cut -d'"' -f4)
if [ -z "$LATEST_TAG" ]; then
warn "无法通过 GitHub API 自动获取 Tag,回退至 v1.1.5"
warn "无法通过 Gitea API 自动获取 Tag,回退至 v1.1.5"
LATEST_TAG="v1.1.5"
fi
info "检测到最新版本: ${LATEST_TAG} (${TARGET_ARCH})"
@@ -58,10 +59,10 @@ select_mode() {
install_agent() {
info "开始安装 VPS-Commander Agent (${TARGET_ARCH})..."
BIN_NAME="vps-commander-agent-linux-${TARGET_ARCH}"
DOWNLOAD_URL="${GITHUB_URL}/${REPO}/releases/download/${LATEST_TAG}/${BIN_NAME}"
DOWNLOAD_URL="${GITEA_URL}/${REPO}/releases/download/${LATEST_TAG}/${BIN_NAME}"
info "正在下载 Agent: ${DOWNLOAD_URL}"
curl -fsSL -k -o "${INSTALL_DIR}/vps-commander-agent" "${DOWNLOAD_URL}" || error "下载失败,请检查网络或 Release 是否存在"
info "正在从 Gitea 下载 Agent: ${DOWNLOAD_URL}"
curl -fsSL -k -o "${INSTALL_DIR}/vps-commander-agent" "${DOWNLOAD_URL}" || error "下载失败,请检查 Gitea 网络或 Release 是否存在"
chmod +x "${INSTALL_DIR}/vps-commander-agent"
echo -e "\n--- 配置 Agent 参数 ---"
@@ -80,7 +81,7 @@ install_agent() {
cat << ENV > "${CONF_DIR}/agent.env"
VPS_COMMANDER_HUB_WS_URL=${HUB_WS}
VPS_COMMANDER_AGENT_NAME=${AGENT_NAME}
VPS_COMMANDER_CLUSTER_SECRET=***
VPS_COMMANDER_CLUSTER_SECRET=${CLUSTER_SECRET}
ENV
chmod 0600 "${CONF_DIR}/agent.env"
@@ -123,33 +124,33 @@ SVC
install_hub() {
info "开始安装 VPS-Commander Hub (${TARGET_ARCH})..."
BIN_NAME="vps-commander-hub-linux-${TARGET_ARCH}"
DOWNLOAD_URL="${GITHUB_URL}/${REPO}/releases/download/${LATEST_TAG}/${BIN_NAME}"
DOWNLOAD_URL="${GITEA_URL}/${REPO}/releases/download/${LATEST_TAG}/${BIN_NAME}"
info "正在下载 Hub: ${DOWNLOAD_URL}"
curl -fsSL -k -o "${INSTALL_DIR}/vps-commander-hub" "${DOWNLOAD_URL}" || error "下载失败,请检查网络或 Release 是否存在"
info "正在从 Gitea 下载 Hub: ${DOWNLOAD_URL}"
curl -fsSL -k -o "${INSTALL_DIR}/vps-commander-hub" "${DOWNLOAD_URL}" || error "下载失败,请检查 Gitea 网络或 Release 是否存在"
chmod +x "${INSTALL_DIR}/vps-commander-hub"
echo -e "\n--- 配置 Hub 参数 ---"
read -rp "请输入监听端口 (默认 9521): " HUB_PORT
HUB_PORT=${HUB_PORT:-9521}
GEN_API_KEY=*** -c 24 /dev/urandom | xxd -p)
GEN_SECRET=*** -c 24 /dev/urandom | xxd -p)
GEN_PASS=$(head -c 16 /dev/urandom | xxd -p)
GEN_API_KEY=$(head -c 24 /dev/urandom | xxd -p 2>/dev/null || openssl rand -hex 24)
GEN_SECRET=$(head -c 24 /dev/urandom | xxd -p 2>/dev/null || openssl rand -hex 24)
GEN_PASS=$(head -c 16 /dev/urandom | xxd -p 2>/dev/null || openssl rand -hex 16)
read -rp "请输入 API Key (默认随机生成): " API_KEY
API_KEY=***
API_KEY=${API_KEY:-$GEN_API_KEY}
read -rp "请输入 Cluster Secret (默认随机生成): " CLUSTER_SECRET
CLUSTER_SECRET=${CLUS…RET}
CLUSTER_SECRET=${CLUSTER_SECRET:-$GEN_SECRET}
read -rp "请输入 Web 管理面板密码 (默认随机生成): " WEB_PASS
WEB_PASS=${WEB_PASS:-$GEN_PASS}
cat << ENV > "${CONF_DIR}/hub.env"
VPS_COMMANDER_API_KEY=***
VPS_COMMANDER_CLUSTER_SECRET=***
VPS_COMMANDER_WEB_PASSWORD=***
VPS_COMMANDER_API_KEY=${API_KEY}
VPS_COMMANDER_CLUSTER_SECRET=${CLUSTER_SECRET}
VPS_COMMANDER_WEB_PASSWORD=${WEB_PASS}
ENV
chmod 0600 "${CONF_DIR}/hub.env"
+9 -8
View File
@@ -2,11 +2,12 @@
set -e
# VPS-Commander Agent 一键安全升级脚本
# Repo: https://github.com/kyaring/vps-commander
# Repo: https://gitea.king.nyc.mn/openclaw/vps-commander
REPO="kyaring/vps-commander"
GITEA_HOST="gitea.king.nyc.mn"
REPO="openclaw/vps-commander"
INSTALL_DIR="/opt/vps-commander"
GITHUB_URL="https://github.com"
GITEA_URL="https://gitea.king.nyc.mn"
# 颜色输出
RED='\033[0;31m'
@@ -37,20 +38,20 @@ case "$ARCH" in
esac
info "正在获取最新 Release 版本信息..."
LATEST_TAG=$(curl -fsSL -k "https://api.github.com/repos/${REPO}/releases/latest" 2>/dev/null | grep '"tag_name":' | head -n1 | sed -E 's/.*"([^"]+)".*/\1/')
LATEST_TAG=$(curl -fsSL -k "${GITEA_URL}/api/v1/repos/${REPO}/releases/latest" 2>/dev/null | grep -o '"tag_name":"[^"]*"' | head -n1 | cut -d'"' -f4)
if [ -z "$LATEST_TAG" ]; then
warn "无法通过 GitHub API 自动获取 Tag,回退至 v1.1.5"
warn "无法通过 Gitea API 自动获取 Tag,回退至 v1.1.5"
LATEST_TAG="v1.1.5"
fi
info "目标升级版本: ${LATEST_TAG} (${TARGET_ARCH})"
BIN_NAME="vps-commander-agent-linux-${TARGET_ARCH}"
DOWNLOAD_URL="${GITHUB_URL}/${REPO}/releases/download/${LATEST_TAG}/${BIN_NAME}"
DOWNLOAD_URL="${GITEA_URL}/${REPO}/releases/download/${LATEST_TAG}/${BIN_NAME}"
TMP_BIN="${INSTALL_DIR}/vps-commander-agent.tmp"
BAK_BIN="${INSTALL_DIR}/vps-commander-agent.bak"
info "正在下载新版本二进制文件..."
curl -fsSL -k -o "${TMP_BIN}" "${DOWNLOAD_URL}" || error "下载失败,请检查网络或 Release 是否存在"
info "正在从 Gitea 下载新版本二进制文件..."
curl -fsSL -k -o "${TMP_BIN}" "${DOWNLOAD_URL}" || error "下载失败,请检查 Gitea 网络或 Release 是否存在"
# 完整性校验:文件大小不能小于 1MB
FILE_SIZE=$(stat -c%s "${TMP_BIN}" 2>/dev/null || stat -f%z "${TMP_BIN}" 2>/dev/null || echo 0)
-79
View File
@@ -1,79 +0,0 @@
# DC Refactoring Phase 1 Implementation Record
Date: 2026-10-02
## Completed
- Added immutable Hub `PolicySnapshot` protected by `RWMutex`.
- Added monotonic security policy version persisted in SQLite.
- Hub startup now rebuilds the snapshot from SQLite; snapshot load failure aborts startup rather than defaulting to High.
- Security setting writes use SQLite transactions and only publish the new snapshot after the durable write succeeds.
- Added a compile-time Tool Registry with fixed `RequiredRisk` for Low/Medium/High capabilities.
- Existing API security checks now resolve required risk through the registry instead of duplicating risk numbers in handlers.
- Security-setting audit records include old/new effective mode and snapshot version.
## Compatibility / Safety
- Existing API routes and Agent WebSocket protocol were not changed in this phase.
- No production Hub/Agent restart was performed.
- When `Server.Policy` is initialized, security checks use the in-memory snapshot and do not query SQLite on the request hot path.
- Existing tests that construct `Server` without a snapshot retain the legacy storage fallback for test/backward compatibility; production startup always initializes the snapshot.
## Verification
- `go test ./...` passed.
- `go test -race ./...` passed.
- Hub and Agent clean builds passed.
- No production service restart was performed; live rollout remains a separate controlled step to preserve the no-interruption requirement.
## Phase 2 Completed
- Added `edit_block` to the Agent execution engine.
- `old_text` must match exactly once; zero/multiple matches are rejected without mutation.
- Replacement uses a same-directory temporary file, preserves file permission bits, fsyncs the file, renames atomically, and syncs the directory.
- Added Agent RPC action `edit_block`, Hub `/api/v1/devices/file/edit_block`, MCP `edit_block`, and stdio proxy support.
- `edit_block` is registered as Medium risk and therefore follows the existing Hub policy snapshot gate.
- Added optional `sub_action` protocol field without changing existing message semantics.
- Added tests for unique replacement, duplicate/missing rejection, permissions, Low-mode denial, and MCP registration.
Phase 2 rollout remains deliberately separate from production process replacement. Existing production Agent connections have not been restarted.
## Phase 3 Completed
- Added a per-Agent `SessionManager` with a bounded session count (default 16).
- Added fixed-size 2 MiB stdout/stderr RingBuffers; old output is discarded when capacity is exceeded.
- Added long-running process actions: `start_process`, `read_process_output`, `interact_with_process`, `force_terminate`, `list_sessions`.
- Processes run in their own process group so termination/force termination covers the command tree.
- Added periodic completed-session cleanup.
- Added Hub RPC and HTTP `/api/v1/process/session` dispatch with action-based lifecycle operations.
- Added session tests for ring retention, output capture, termination, duplicate IDs, and session limits.
- Existing WebSocket request/response protocol remains compatible; new process actions are additive.
Production Hub/Agents were not restarted during Phase 3 implementation.
## Phase 3 Security Hardening / Concurrency Gate
- Added per-node operation arbitration in `cluster.Node.OpMu`.
- Low-risk read actions use `RLock` and may run concurrently.
- Medium/High mutations use exclusive `Lock`, preventing overlapping writes/control operations against the same node.
- Unknown actions fail closed as High risk for the concurrency gate.
- Session IDs are generated with 128-bit `crypto/rand` entropy when omitted by the API caller; PID is never used as identity.
- Session ownership is bound to a non-reversible SHA-256 fingerprint of the authenticated API credential and checked by Agent for read/input/terminate operations.
- Session TTL is enforced; stdin is capped at 64 KiB per call.
- Agent restart still leaves all sessions in process memory only, so old session state is not restored.
All validation passed with `go test ./...`, `go test -race ./...`, Hub/Agent builds, and `git diff --check`. No production process was restarted.
## Phase 4 Completed
- Added optional protocol/agent capability metadata: `ProtocolVersion`, `AgentVersion`, `Capabilities`.
- Hub records the capability set per connected Agent; when a modern Agent explicitly advertises capabilities, unsupported actions fail with `capability missing` rather than silently degrading.
- Old Agents remain compatible because capability metadata is optional and capability enforcement is only applied when a capability set is advertised.
- Added structured read-only filesystem operations: `list_directory`, `get_file_info`, `start_search`.
- Directory listing supports bounded recursive traversal and a maximum entry count.
- File metadata exposes type, size, mode and modification time.
- Search is context-cancellable and bounded by a result cap; symlinks are skipped to avoid traversal surprises.
- Added Hub endpoints `/api/v1/file/list`, `/api/v1/file/info`, `/api/v1/file/search`.
- Added executor tests for directory listing, file metadata and bounded search.
Phase 4 validation: full tests, race tests, Hub/Agent builds and `git diff --check` passed. Production Hub/Agents were not restarted.
-934
View File
@@ -1,934 +0,0 @@
# 《VPS-Commander 平滑升级改造与三级安全拦截实施方案》
## 1. 改造目标与背景
本项目计划全量复刻 **Desktop Commander(DC)** 在终端与文件系统上的核心工程能力(包括局部块级精准替换、后台长进程生命周期管理、交互式 stdin 注入、结构化目录遍历与只读检索),并将其无缝接入 **Low(低风险)/ Medium(中风险)/ High(高风险)** 三级安全门禁体系。
**核心红线与验收标准:**
1. **服务不中断**:升级过程不影响正在运行的 Agent 探针与 Hub 中心。
2. **协议向后兼容**:旧版 Agent 与新版 Hub 混合共存,老接口绝不废弃。
3. **性能零损耗**:杜绝动态运行时映射,安全校验在编译期即已绑定。
4. **轻量化保真**:继续保持纯 Go 静态编译的高效特性,严控内存占用(常驻内存锁定在 20MB 以内)。
---
## 2. 工具池全量复刻与三级风险定级表(静态强绑定)
所有功能根据系统破坏力与副作用大小,在**编译期**严格归属于三级风险体系:
| 风险等级 (Level) | 行为边界定义 | 包含的复刻工具 (DC 标杆) | 允许放行的安全模式 |
| :--- | :--- | :--- | :--- |
| 🟢 **Low(只读/感知)** | **纯只读、无状态改变、无系统副作用** | • `read_file` (支持切片与 Tail)<br>• `read_multiple_files`<br>• `list_directory` (结构化目录树)<br>• `get_file_info` (元数据)<br>• `start_search` / `get_more_search_results`<br>• `list_processes` / `list_sessions`<br>• `read_process_output` (拉取输出) | **Low**<br>**Medium**<br>**High** |
| 🟡 **Medium(文件写)** | **文件与目录变更,但严禁拉起任意二进制或执行命令** | • `edit_block` (局部文本块精准替换,核心)<br>• `write_file` (文件新建/全量写入)<br>• `create_directory` (新建目录)<br>• `move_file` (移动/重命名) | **Medium**<br>**High**<br>*(Low 拦截)* |
| 🔴 **High(完全控制)** | **具备操作系统执行权、进程生命周期调度与管道干预** | • `exec_command` (同步短命令)<br>• `start_process` (后台异步启动长进程)<br>• `interact_with_process` (stdin 交互写入)<br>• `kill_process` / `force_terminate` (终止进程) | **High**<br>*(Low / Medium 拦截)* |
---
## 3. 架构改造与平滑过渡设计
### 3.1 协议层平滑升级(零破坏向后兼容)
在 `internal/cluster/protocol.go` 中,保留原有字段,新增可选的扩展动作:
```go
type Message struct {
ID string `json:"id,omitempty"`
Action string `json:"action,omitempty"` // 保持原有字段: "read" / "write" / "exec" / "probe"
SubAction string `json:"sub_action,omitempty"` // ✨ 增量动作: "edit_block", "start_process" 等
Device string `json:"device,omitempty"`
Payload any `json:"payload,omitempty"`
...
}
```
* **兼容保障**:老版本 Agent 接收到带 `SubAction` 的 JSON 消息时会自动忽略多余字段,继续按老逻辑工作;升级后的 Agent 优先路由给 `SubAction` 处理。
### 3.2 Hub 门禁与高并发性能优化(读写分离)
现有系统每次请求均去 SQLite 查询策略,高并发下存在文件锁瓶颈。改造后引入 **RWMutex 内存策略快照**:
1. **读路径(Fast Path,纳秒级)**:
```go
// 常量数值定义,直接内联比较
const ( RiskLow = 1; RiskMedium = 2; RiskHigh = 3 )
func (s *Server) requireSecurity(w http.ResponseWriter, r *http.Request, device string, requiredRisk int, detail string) bool {
allowed := s.getEffectiveRiskLevel(device) // 查内存 RLock,耗时 < 10ns
if allowed >= requiredRisk {
return true
}
// 记录 audit_logs ("security_denied", 403) 并返回拦截响应
return false
}
```
2. **写路径(Slow Path,低频)**:
* 仅在管理员通过 Web 界面更改安全策略时,写一次 SQLite 并原子更新内存缓存。
3. **老 API 零改动**:
* 原有 `/api/v1/devices/exec`、`/read`、`/write` 继续保留,老脚本老客户端零感知。
### 3.3 Agent 端核心引擎实现(防资源膨胀设计)
* **`edit_block` 引擎**:
* 读取原文件后校验 `old_text` 全局唯一性,唯一时单次替换并使用临时文件进行原子覆写(`os.Rename`)。
* 相比原有全量上传,网络传输和内存开销下降 95%。
* **长进程与 PTY 会话引擎(`start_process` 等)**:
* Agent 内部建立轻量 `SessionManager`,挂接内核标准输入输出管道。
* **硬编码内存锁(Circular RingBuffer)**:每个进程会话分配最大 2MB 环形日志缓冲区,达到上限自动覆盖最早输出,**从物理上杜绝日志死循环导致的内存溢出(OOM)**。
---
## 4. 资源开销评估与性能上限控制
得益于纯 Go 原生静态编译,系统坚决不引入任何 Node.js/Python 运行时,资源开销保持极致克制:
| 评估指标 | 改造前基准(实测) | 改造后预期 | 性能/资源防护机制 |
| :--- | :--- | :--- | :--- |
| **Agent 静默内存** | ~13 MB | **~15 MB - 18 MB** | 零后台轮询,事件驱动 |
| **Agent 峰值内存** | 随命令大小波动 | **基准 + (2MB × 活跃会话数)** | 环形缓冲区硬顶锁定,防内存泄露 |
| **Hub 服务端内存** | ~16 MB | **~18 MB - 22 MB** | 内存门禁仅占不到 10KB |
| **二进制体积** | ~9.4 MB | **~11 MB - 13 MB** | 静态单文件编译,极易分发部署 |
| **安全判定延迟** | 毫秒级(查 SQLite) | **纳秒级(内存读锁)** | 单机可轻松支撑 10W+ QPS 门禁并发 |
---
## 5. 四阶段实施与回滚路线图(稳健落地)
* **Phase 1:服务端门禁升级与内存读缓存**
* 在 Hub 端落地 `RWMutex` 安全策略缓存;
* API 入口统一接入 `requireSecurity(..., RiskLevel)`;
* *验证标准*:旧版 4 项基础功能与 Web 面板完好,并发查询 QPS 大幅提升。
* **Phase 2:落地中风险神器 `edit_block`**
* Agent 端实现块级精准替换算法;
* Hub 暴露 `/api/v1/devices/file/edit_block` 并注册至 MCP Adapter;
* *验证标准*:在 Medium 模式下可正常修配置文件,在 Low 模式下被精确阻断并记入审计。
* **Phase 3:落地高风险长进程引擎**
* Agent 端引入带 RingBuffer 的 `SessionManager`;
* 实现 `start_process`、`read_process_output`、`interact_with_process`、`kill_process`;
* *验证标准*:跑耗时任务不会超时断连,支持实时 Tail 查看输出与注入 stdin。
* **Phase 4:落地只读结构化文件与系统感知(Low)**
* 实现 `list_directory`、`get_file_info` 及后台异步只读搜索。
---
## 6. 评估与审计重点建议
1. **安全维度**:Low/Medium/High 对能力的切分是否彻底杜绝了提权与逃逸风险?
2. **性能维度**:使用内存 RWMutex + 编译期静态安全级别判定,是否彻底解决了单文件 SQLite 高频读瓶颈?
3. **稳定性维度**:为每个长进程会话限定 RingBuffer 内存上限,是否有效防止了边缘 VPS 探针被日志撑爆?
---
# 7. 架构收敛修订(2026-10-02)
> 本章节在进入代码实施前增加,用于两位外部 Agent 审核。原方案的工具分级、四阶段路线和向后兼容原则继续保留;本章节对安全权威、策略一致性、工具注册以及高风险 Session 生命周期作进一步约束。
## 7.1 安全等级的正确边界:静态工具风险 + 运行时设备策略
原方案中“静态强绑定”应准确解释为:**工具所需风险等级在编译期固定,是否允许执行在运行时由 Hub 的权威策略决定。**
不得将设备当前 Low/Medium/High 策略编译进 Agent,也不得允许 Agent 自行提升自身安全等级。
### 7.1.1 工具风险等级
每个工具在代码注册表中拥有不可运行时修改的 `RequiredRisk`:
```go
const (
RiskLow = 1
RiskMedium = 2
RiskHigh = 3
)
type ToolSpec struct {
Name string
RequiredRisk int
ReadOnly bool
}
```
示例:
```text
read_file / list_directory / search -> Low
edit_block / write_file / move_file -> Medium
exec_command / start_process / stdin -> High
```
禁止通过 Web 面板、Agent 配置文件或远端请求动态修改工具自身的 `RequiredRisk`。
### 7.1.2 设备有效策略
设备的 `EffectiveRisk` 是 Hub 运行时策略,不属于 Agent 自主能力。
请求授权模型:
```text
Request
-> identify device
-> identify ToolSpec.RequiredRisk
-> read Hub PolicySnapshot
-> compare EffectiveRisk >= RequiredRisk
-> allow / deny
```
因此完整安全边界为:
**编译期固定“工具需要什么权限” + 运行时决定“该设备当前获得什么权限”。**
---
## 7.2 Policy Store 与 Policy Snapshot 一致性模型
SQLite 继续作为持久化权威源(durable source of truth),但请求热路径禁止每次查询 SQLite。
### 7.2.1 数据流
```text
Admin Web
|
v
SQLite transaction
|
v
Build new immutable PolicySnapshot
|
v
Atomic publish / swap
|
v
Request fast path
```
请求只读取当前不可变 snapshot;不在请求过程中修改策略对象。
### 7.2.2 更新原则
策略修改必须满足:
1. SQLite 写入成功后才允许发布新 snapshot。
2. SQLite 写失败时,内存策略保持旧版本。
3. snapshot 发布必须是原子操作,不允许出现半更新状态。
4. Hub 启动时必须从 SQLite 完整重建 snapshot。
5. snapshot 应携带单调递增 `Version`,便于审计和诊断。
6. 审计记录应包含 `device`, `oldRisk`, `newRisk`, `version`, `actor`, `timestamp`。
推荐结构:
```go
type PolicySnapshot struct {
Version uint64
Devices map[string]DevicePolicy
CreatedAt time.Time
}
```
热路径原则:
```text
load current snapshot
-> lookup device
-> compare integer risk
-> allow/deny
```
不得在高频 API 请求中重新读取 SQLite 策略表。
### 7.2.3 故障安全
如果 Hub 无法加载有效策略 snapshot:
- 不得默认放行 High。
- 不得从请求参数接受临时风险等级。
- 对需要 Medium/High 的操作默认拒绝。
- Low 只读能力是否可继续开放,必须由启动时的明确安全默认策略决定,并记录审计事件。
---
## 7.3 Tool Registry:统一能力与风险注册表
避免各 API handler 自己实现不同的安全判断。所有能力必须进入统一 Tool Registry。
建议:
```go
type ToolSpec struct {
Name string
RequiredRisk int
Category string
ReadOnly bool
Description string
}
```
例如:
```text
read_file Low
read_multiple_files Low
list_directory Low
get_file_info Low
start_search Low
get_more_search_results Low
list_processes Low
list_sessions Low
read_process_output Low
edit_block Medium
write_file Medium
create_directory Medium
move_file Medium
exec_command High
start_process High
interact_with_process High
kill_process High
force_terminate High
```
API、MCP Adapter 和内部调用均通过 Registry 获取 `RequiredRisk`,不得在多个入口复制风险数字。
---
## 7.4 Agent 信任边界
Agent 是执行者,不是安全策略的最终裁决者。
Hub 是设备授权策略的权威来源。
但 Agent 仍必须执行本地最低限度安全校验,包括:
- 请求格式与参数校验;
- session 所有权校验;
- session 是否仍然有效;
- 单次输入/输出大小限制;
- 超时和资源限制;
- 不接受客户端自行声明的 `RiskLevel` 作为授权依据。
Agent 不得因为请求中携带:
```json
{"risk":"high"}
```
或类似字段而自行获得 High 权限。
如果协议未来需要携带授权信息,应使用 Hub 生成的受保护授权上下文,并绑定:
```text
device + request/session + policy version + expiry
```
具体签名/令牌方案在实现阶段单独评审。
---
## 7.5 High Risk SessionManager 安全模型
`start_process`、`interact_with_process`、`kill_process` 不应被视为普通 HTTP 命令,而应视为一个受生命周期管理的高风险 Session。
### 7.5.1 Session 属性
每个 Session 至少包含:
```go
type ProcessSession struct {
ID string
Owner string
Device string
PID int
CreatedAt time.Time
LastAccess time.Time
ExpiresAt time.Time
State string
OutputBytes uint64
}
```
Session ID 必须不可预测,并且不能复用操作系统 PID 作为唯一身份。
### 7.5.2 所有权
只有创建 Session 的授权调用方才能:
- 读取该 Session 输出;
- 向 stdin 写入数据;
- 终止该 Session。
管理员级操作如果未来需要跨 Owner 接管 Session,应定义明确的审计事件和单独权限,不得通过普通 session API 隐式获得。
### 7.5.3 生命周期
```text
start_process
|
v
RUNNING
/ \
/ \
EXITED EXPIRED
| |
+----+---+
v
CLEANUP
```
Agent 重启后所有旧 Session 必须失效,不得从持久化文件自动恢复可写 stdin 的旧 Session。
### 7.5.4 时间限制
Session 必须具备:
- 最大生命周期;
- 空闲超时;
- stdin 单次大小限制;
- stdin 速率限制;
- 输出缓冲区上限。
具体默认数值在实现前通过压测确定,不在设计阶段硬编码未经验证的数值。
---
## 7.6 RingBuffer 与全局资源上限
原方案中的“2MB × 活跃 Session”只能作为单 Session 上限,不应视为 Agent 的总内存预算。
必须同时存在:
```text
per-session output cap
+
max active sessions
+
agent-wide session buffer cap
+
process creation rate limit
```
例如:
```text
单 Session:最大 2MB output buffer
全局:最大 N 个活跃 Session
全局:最大 M MB Session buffer
```
当达到任意全局上限时,新 Session 必须被拒绝并记录审计,而不是继续消耗内存。
RingBuffer 的覆盖行为只能覆盖旧输出,不得覆盖 session 元数据、错误状态或安全审计记录。
---
## 7.7 审计要求
至少记录以下安全事件:
```text
security_denied
policy_changed
session_created
session_expired
session_killed
session_owner_denied
resource_limit_denied
```
High Risk 操作的审计信息必须包含:
```text
device
actor / owner
request id
session id(如适用)
tool
result
policy version
timestamp
```
审计日志不能因为 Session RingBuffer 达到上限而被覆盖。
---
## 7.8 协议兼容补充约束
旧 Agent 与新 Hub 混合运行期间:
1. 原有 `action=read/write/exec/probe` 字段继续有效。
2. 新 `SubAction` 为可选字段。
3. 新字段不能改变旧 Agent 对旧消息的既有语义。
4. Hub 必须根据 Agent capability/version 判断是否可以下发新 SubAction。
5. 对不支持新能力的 Agent,返回明确的 capability error,而不是让旧 Agent 猜测。
6. 安全门禁必须在能力路由之前执行,避免通过旧 API 绕过新 Tool Registry。
---
## 7.9 实施前必须完成的设计验收
在进入 Phase 1 代码改造前,两位审核 Agent 应重点审查:
### A. 权威性
- Hub 是否始终是 EffectiveRisk 的唯一权威来源?
- Agent 是否存在任何自升权路径?
- 旧 API 是否可能绕过 Tool Registry?
### B. 一致性
- SQLite commit 与 snapshot publish 顺序是否正确?
- Hub 重启能否确定性恢复策略?
- snapshot version 是否足以定位策略变更?
### C. Session 安全
- session ownership 是否不可伪造?
- Agent 重启后旧 session 是否必然失效?
- stdin、stdout、session 数量是否存在全局资源上限?
### D. 向后兼容
- 老 Agent + 新 Hub 是否继续工作?
- 新 Hub 是否能识别 Agent capability?
- 新能力是否可能通过旧接口绕过门禁?
### E. 性能
- 热路径是否完全脱离 SQLite?
- snapshot 是否真正 immutable / atomic?
- session ring buffer 是否存在隐藏的额外复制?
**只有上述问题通过审核后,才进入 Phase 1 实现。**
---
# 8. 审核意见收敛修订(2026-10-02)
本章节根据第一轮 Agent 审核结果补充。**Phase 1 在本章节阻断项完成前不得进入代码实施。**
## 8.1 阻断项一:设备身份认证
`identify device` 不得等同于读取请求中的 `device` 字段。设备名称只能作为路由提示,不能作为授权依据。
### 8.1.1 权威身份模型
每个 Agent 必须拥有唯一设备身份以及与之绑定的设备凭证。Hub 必须先完成设备认证,再建立:
```text
authenticated agent identity
↓
device ID
↓
PolicySnapshot lookup
↓
EffectiveRisk
```
推荐优先采用 **per-agent credential**;部署条件允许时使用 mTLS。若采用 Agent Token,Token 必须:
- 与单一 Agent 身份绑定;
- 可撤销;
- 不得由客户端自行指定 device 后复用;
- 传输必须经过受保护通道;
- 认证失败不得进入安全策略查询阶段。
`device` 请求字段不得覆盖认证得到的 canonical device identity。
### 8.1.2 身份与策略绑定
策略查询必须使用认证层产生的 canonical device ID:
```go
identity := authenticateAgent(r)
if !identity.OK {
deny()
}
policy := snapshot.Device(identity.DeviceID)
```
禁止:
```go
policy := snapshot.Device(r.Device)
```
设备注册、凭证轮换、凭证撤销必须产生审计事件。具体凭证格式与密钥轮换流程在 Phase 1 实施设计中单独冻结。
---
## 8.2 阻断项二:kill_process 作用域
`kill_process` / `force_terminate` **绝不能接受任意 PID 作为授权对象**。
High Risk Session 必须建立进程归属关系:
```text
Session
└── Process Group / Process Tree
├── root process
├── child
└── child...
```
默认允许终止的对象仅为该 Session 创建的进程树。
必须拒绝:
- 不属于当前 Session 的 PID;
- Hub / Agent 自身关键进程;
- 未经单独授权的其他用户进程;
- 通过伪造 PID、session ID 或 owner 字段进行的跨 Session 操作。
实现时应记录 root PID / process group ID,并通过 OS 级关系验证目标进程仍属于该 Session,而不是只相信客户端提供的 PID。
未来若需要管理员跨 Session 终止能力,必须作为独立能力和独立审计事件设计,不复用普通 `kill_process`。
---
## 8.3 阻断项三:Agent capability/version 握手
协议必须正式增加 capability/version 元数据,使 7.8 的混合版本策略可以实际落地。
建议扩展:
```go
type Message struct {
ID string `json:"id,omitempty"`
Action string `json:"action,omitempty"`
SubAction string `json:"sub_action,omitempty"`
Device string `json:"device,omitempty"`
Payload any `json:"payload,omitempty"`
ProtocolVersion string `json:"protocol_version,omitempty"`
AgentVersion string `json:"agent_version,omitempty"`
Capabilities []string `json:"capabilities,omitempty"`
}
```
为避免旧 Agent 解析失败,新增字段必须保持 optional;旧 Agent 忽略未知字段即可继续处理旧消息。
更严格的 capability 协商流程:
```text
Agent connect
↓
identity authentication
↓
protocol/version handshake
↓
capability registry
↓
Hub records effective capabilities
↓
request routing
```
Hub 不得仅根据版本字符串猜测能力。实际可用能力以 capability set 为准。
如果 Agent 不支持某个 `SubAction`:
```text
capability_missing
```
必须成为明确错误,不得静默降级到语义不同的危险操作。
---
## 8.4 威胁模型
三级安全体系主要解决的是 **调用方对受控设备的越权操作风险**,而不是保证已经被完全攻陷的 Agent 本身可信。
### T1:恶意调用方
目标:冒充设备、伪造风险等级、跨设备操作、跨 Session 操作。
防护:
- Agent 身份认证;
- canonical device ID;
- Hub authoritative PolicySnapshot;
- Session ownership;
- Tool Registry;
- 审计。
### T2:有 Bug 的调用方
目标:错误调用 High API、重复创建 Session、资源耗尽。
防护:
- 参数验证;
- per-device 并发限制;
- rate limit;
- Session/global resource cap;
- fail-closed。
### T3:被攻陷的 Agent
如果攻击者已经取得 Agent 进程的等价本地执行权,则 Agent 内部校验不能被视为可信边界。
因此:
> Hub 门禁保护的是“未经授权的远程调用不能获得设备能力”,而不是“Agent 被完全攻陷后仍能阻止本地攻击者”。
Agent 被攻陷属于独立威胁域,应依赖最小权限、系统账户隔离、凭证撤销、节点隔离和重新注册等机制处理。
文档不得把 Agent 侧校验描述为能够抵御完全控制 Agent 的攻击者。
---
## 8.5 审计敏感数据脱敏
现有 `audit_logs.command` 等字段不得无条件记录完整命令或参数。
审计系统必须采用结构化事件优先:
```text
command template / tool
arguments metadata
result
risk
actor
request id
```
对于可能包含 Secret 的字段,必须在进入持久化审计前脱敏,包括但不限于:
```text
password
passwd
token
access_token
refresh_token
api_key
secret
authorization
cookie
private_key
```
Shell 命令中的敏感值不能仅依赖字段名识别;实现阶段应定义命令审计策略,至少做到:
1. 不默认记录完整 stdin;
2. 对已识别敏感参数替换为 `[REDACTED]`;
3. Secret 不得通过错误消息重新写入 audit;
4. 原始命令仅在明确安全策略允许时保留。
---
## 8.6 回滚与混合版本路线
原第 5 节标题为“回滚路线图”,现补充实际策略。
### 8.6.1 Hub 升级失败
- 新版本不得在旧 schema 尚未兼容时直接删除旧字段;
- DB migration 必须可检测版本;
- migration 失败则 Hub 不进入正常服务状态;
- 策略 snapshot 可以从旧兼容 schema 重建;
- 升级前保留 DB 备份/快照。
### 8.6.2 新 Hub + 旧 Agent
旧 Agent 继续使用旧 API/旧 Action。
Hub 必须:
```text
认证旧 Agent
↓
读取其 capability
↓
只下发它声明支持的能力
```
不能因为 Hub 已升级就要求旧 Agent 自动支持新 SubAction。
### 8.6.3 新 Agent + 旧 Hub
新 Agent 必须保持旧协议兼容能力;若 Hub 不支持 capability handshake,新 Agent 使用 legacy compatibility mode,不得因此自行启用 High 新能力。
### 8.6.4 数据降级原则
数据库 schema 新增字段应尽量可选;新功能数据不能成为旧版本启动的硬依赖。
任何不可逆 migration 必须在正式 Phase 前单独评审。
---
## 8.7 Session ID 与进程安全参数
Session ID 必须使用 `crypto/rand` 生成,最低 **128-bit entropy**,并以不暴露内部 PID 的方式编码。
PID 仅作为内部 process identity 的组成部分,不作为 session credential。
Session authorization 必须同时验证:
```text
authenticated device
+ session ID
+ owner
+ session state
+ expiry
```
---
## 8.8 edit_block 的 TOCTOU 防护
`edit_block` 不能只做:
```text
read -> verify -> rename
```
因为读取后到替换前存在 TOCTOU 窗口。
实现至少需要以下一种一致性保护:
- 文件锁;或
- generation / mtime / size / hash 校验后再提交;或
- OS 支持的原子条件更新机制。
推荐:读取时计算 generation fingerprint,写回前重新验证;若文件已经发生变化则拒绝修改并返回 conflict,而不是覆盖第三方更新。
---
## 8.9 内存预算重新约束
原方案“Agent 常驻 20MB 以内”与 `2MB × N sessions` 必须统一解释。
新的约束是:
> **20MB 是 Agent 基础常驻预算;Session buffer 必须从剩余预算反推最大并发数,而不是无限叠加。**
因此实现前必须定义:
```text
BaseMemoryBudget
SessionBufferBudget
MaxActiveSessions
PerSessionBuffer
```
满足:
```text
BaseMemory + MaxSessionBufferBudget + safety margin
<= approved Agent memory ceiling
```
如果单 Session 仍采用 2MB,则 `MaxActiveSessions` 必须由压测和实际内存预算计算得出。
---
## 8.10 性能指标修正
删除或修正“整机 10W+ QPS”可能造成的误导。
准确表述应为:
> **安全门禁自身的策略读取路径不以 SQLite 为热路径,单次判定成本应保持在内存级别;端到端 API 吞吐仍受网络、Agent、磁盘、进程创建和实际 IO 限制。**
性能验收应分别测量:
1. PolicySnapshot lookup latency;
2. authorization decision latency;
3. API end-to-end latency;
4. Agent execution throughput;
5. process/session creation rate。
---
## 8.11 进程交互传输模型
`interact_with_process` 的实时 stdin/stdout 通道必须在实现前冻结。
设计目标:
```text
Hub / MCP
↓ stdin
Agent Session
↑ stdout/stderr stream
```
传输协议必须明确:
- session binding;
- reconnect 行为;
- backpressure;
- output cursor / offset;
- heartbeat / timeout;
- disconnect 后 Session 是否继续运行;
- reconnect 后 owner 如何重新认证。
如果采用 HTTP/SSE、WebSocket 或轮询,必须在 Phase 3 实施设计中明确其安全和资源模型,不能由 handler 临时决定。
---
## 8.12 非 Session 类资源限制
全局资源控制不仅适用于 `start_process`。
### exec_command
必须增加:
- per-device concurrent exec limit;
- 单命令执行时间上限;
- 输出大小上限;
- 创建速率限制。
防止通过大量同步 `exec_command` 造成 fork/process exhaustion。
### list_processes
进程命令行可能包含密码、Token、Cookie 等敏感信息。返回结果必须执行敏感信息处理,并限制返回规模。
### start_search / get_more_search_results
后台搜索属于全盘 IO 风险,应具备:
- per-device search concurrency;
- 最大搜索范围/深度;
- 最大结果数;
- 超时;
- 可取消任务;
- 全局 IO 预算。
---
## 8.13 Phase 1 红队验收
除原 A–E 正向验收外,Phase 1 必须增加以下对抗用例:
1. 伪造 `{"risk":"high"}` 是否被拒绝;
2. 使用合法 Agent A 凭证请求 device B 是否被拒绝;
3. 旧 API 是否能绕过 Tool Registry;
4. Session A 的 ID 是否能访问 Session B;
5. Session A 是否能 kill 非自身进程;
6. Agent 重启后旧 Session 是否失效;
7. Policy snapshot 更新过程中是否出现半更新状态;
8. SQLite 更新失败时是否错误发布新策略;
9. 旧 Agent + 新 Hub 是否保持旧功能;
10. 新能力是否在 capability 缺失时被错误下发;
11. audit 是否泄露 password/token/cookie/private key;
12. 并发 exec 是否能够超过 per-device 上限;
13. search 是否能够无限占用 IO;
14. RingBuffer / session 上限是否能防止内存线性增长。
---
## 8.14 Phase 1 开工闸门
Phase 1 只有在以下条件全部满足后才能开始:
```text
[ ] Agent identity authentication model frozen
[ ] Canonical device identity frozen
[ ] kill_process process-tree scope frozen
[ ] protocol capability/version handshake frozen
[ ] Threat model accepted
[ ] Audit redaction policy accepted
[ ] DB migration / rollback strategy accepted
[ ] Session ID generation frozen
[ ] edit_block consistency strategy frozen
[ ] Agent memory budget reconciled
[ ] Streaming transport model frozen or explicitly deferred to Phase 3
[ ] exec/search resource limits defined
[ ] Red-team test plan accepted
```
**本章节是 Phase 1 的设计闸门,不是可选建议。任何阻断项未关闭时,不得以“先实现再补安全”为理由进入代码改造。**
@@ -1,109 +0,0 @@
# 《VPS-Commander 平滑升级改造与三级安全拦截实施方案》
## 1. 改造目标与背景
本项目计划全量复刻 **Desktop Commander(DC)** 在终端与文件系统上的核心工程能力(包括局部块级精准替换、后台长进程生命周期管理、交互式 stdin 注入、结构化目录遍历与只读检索),并将其无缝接入 **Low(低风险)/ Medium(中风险)/ High(高风险)** 三级安全门禁体系。
**核心红线与验收标准:**
1. **服务不中断**:升级过程不影响正在运行的 Agent 探针与 Hub 中心。
2. **协议向后兼容**:旧版 Agent 与新版 Hub 混合共存,老接口绝不废弃。
3. **性能零损耗**:杜绝动态运行时映射,安全校验在编译期即已绑定。
4. **轻量化保真**:继续保持纯 Go 静态编译的高效特性,严控内存占用(常驻内存锁定在 20MB 以内)。
---
## 2. 工具池全量复刻与三级风险定级表(静态强绑定)
所有功能根据系统破坏力与副作用大小,在**编译期**严格归属于三级风险体系:
| 风险等级 (Level) | 行为边界定义 | 包含的复刻工具 (DC 标杆) | 允许放行的安全模式 |
| :--- | :--- | :--- | :--- |
| 🟢 **Low(只读/感知)** | **纯只读、无状态改变、无系统副作用** | • `read_file` (支持切片与 Tail)<br>• `read_multiple_files`<br>• `list_directory` (结构化目录树)<br>• `get_file_info` (元数据)<br>• `start_search` / `get_more_search_results`<br>• `list_processes` / `list_sessions`<br>• `read_process_output` (拉取输出) | **Low**<br>**Medium**<br>**High** |
| 🟡 **Medium(文件写)** | **文件与目录变更,但严禁拉起任意二进制或执行命令** | • `edit_block` (局部文本块精准替换,核心)<br>• `write_file` (文件新建/全量写入)<br>• `create_directory` (新建目录)<br>• `move_file` (移动/重命名) | **Medium**<br>**High**<br>*(Low 拦截)* |
| 🔴 **High(完全控制)** | **具备操作系统执行权、进程生命周期调度与管道干预** | • `exec_command` (同步短命令)<br>• `start_process` (后台异步启动长进程)<br>• `interact_with_process` (stdin 交互写入)<br>• `kill_process` / `force_terminate` (终止进程) | **High**<br>*(Low / Medium 拦截)* |
---
## 3. 架构改造与平滑过渡设计
### 3.1 协议层平滑升级(零破坏向后兼容)
在 `internal/cluster/protocol.go` 中,保留原有字段,新增可选的扩展动作:
```go
type Message struct {
ID string `json:"id,omitempty"`
Action string `json:"action,omitempty"` // 保持原有字段: "read" / "write" / "exec" / "probe"
SubAction string `json:"sub_action,omitempty"` // ✨ 增量动作: "edit_block", "start_process" 等
Device string `json:"device,omitempty"`
Payload any `json:"payload,omitempty"`
...
}
```
* **兼容保障**:老版本 Agent 接收到带 `SubAction` 的 JSON 消息时会自动忽略多余字段,继续按老逻辑工作;升级后的 Agent 优先路由给 `SubAction` 处理。
### 3.2 Hub 门禁与高并发性能优化(读写分离)
现有系统每次请求均去 SQLite 查询策略,高并发下存在文件锁瓶颈。改造后引入 **RWMutex 内存策略快照**:
1. **读路径(Fast Path,纳秒级)**:
```go
// 常量数值定义,直接内联比较
const ( RiskLow = 1; RiskMedium = 2; RiskHigh = 3 )
func (s *Server) requireSecurity(w http.ResponseWriter, r *http.Request, device string, requiredRisk int, detail string) bool {
allowed := s.getEffectiveRiskLevel(device) // 查内存 RLock,耗时 < 10ns
if allowed >= requiredRisk {
return true
}
// 记录 audit_logs ("security_denied", 403) 并返回拦截响应
return false
}
```
2. **写路径(Slow Path,低频)**:
* 仅在管理员通过 Web 界面更改安全策略时,写一次 SQLite 并原子更新内存缓存。
3. **老 API 零改动**:
* 原有 `/api/v1/devices/exec`、`/read`、`/write` 继续保留,老脚本老客户端零感知。
### 3.3 Agent 端核心引擎实现(防资源膨胀设计)
* **`edit_block` 引擎**:
* 读取原文件后校验 `old_text` 全局唯一性,唯一时单次替换并使用临时文件进行原子覆写(`os.Rename`)。
* 相比原有全量上传,网络传输和内存开销下降 95%。
* **长进程与 PTY 会话引擎(`start_process` 等)**:
* Agent 内部建立轻量 `SessionManager`,挂接内核标准输入输出管道。
* **硬编码内存锁(Circular RingBuffer)**:每个进程会话分配最大 2MB 环形日志缓冲区,达到上限自动覆盖最早输出,**从物理上杜绝日志死循环导致的内存溢出(OOM)**。
---
## 4. 资源开销评估与性能上限控制
得益于纯 Go 原生静态编译,系统坚决不引入任何 Node.js/Python 运行时,资源开销保持极致克制:
| 评估指标 | 改造前基准(实测) | 改造后预期 | 性能/资源防护机制 |
| :--- | :--- | :--- | :--- |
| **Agent 静默内存** | ~13 MB | **~15 MB - 18 MB** | 零后台轮询,事件驱动 |
| **Agent 峰值内存** | 随命令大小波动 | **基准 + (2MB × 活跃会话数)** | 环形缓冲区硬顶锁定,防内存泄露 |
| **Hub 服务端内存** | ~16 MB | **~18 MB - 22 MB** | 内存门禁仅占不到 10KB |
| **二进制体积** | ~9.4 MB | **~11 MB - 13 MB** | 静态单文件编译,极易分发部署 |
| **安全判定延迟** | 毫秒级(查 SQLite) | **纳秒级(内存读锁)** | 单机可轻松支撑 10W+ QPS 门禁并发 |
---
## 5. 四阶段实施与回滚路线图(稳健落地)
* **Phase 1:服务端门禁升级与内存读缓存**
* 在 Hub 端落地 `RWMutex` 安全策略缓存;
* API 入口统一接入 `requireSecurity(..., RiskLevel)`;
* *验证标准*:旧版 4 项基础功能与 Web 面板完好,并发查询 QPS 大幅提升。
* **Phase 2:落地中风险神器 `edit_block`**
* Agent 端实现块级精准替换算法;
* Hub 暴露 `/api/v1/devices/file/edit_block` 并注册至 MCP Adapter;
* *验证标准*:在 Medium 模式下可正常修配置文件,在 Low 模式下被精确阻断并记入审计。
* **Phase 3:落地高风险长进程引擎**
* Agent 端引入带 RingBuffer 的 `SessionManager`;
* 实现 `start_process`、`read_process_output`、`interact_with_process`、`kill_process`;
* *验证标准*:跑耗时任务不会超时断连,支持实时 Tail 查看输出与注入 stdin。
* **Phase 4:落地只读结构化文件与系统感知(Low)**
* 实现 `list_directory`、`get_file_info` 及后台异步只读搜索。
---
## 6. 评估与审计重点建议
1. **安全维度**:Low/Medium/High 对能力的切分是否彻底杜绝了提权与逃逸风险?
2. **性能维度**:使用内存 RWMutex + 编译期静态安全级别判定,是否彻底解决了单文件 SQLite 高频读瓶颈?
3. **稳定性维度**:为每个长进程会话限定 RingBuffer 内存上限,是否有效防止了边缘 VPS 探针被日志撑爆?
@@ -1,491 +0,0 @@
# 《VPS-Commander 平滑升级改造与三级安全拦截实施方案》
## 1. 改造目标与背景
本项目计划全量复刻 **Desktop Commander(DC)** 在终端与文件系统上的核心工程能力(包括局部块级精准替换、后台长进程生命周期管理、交互式 stdin 注入、结构化目录遍历与只读检索),并将其无缝接入 **Low(低风险)/ Medium(中风险)/ High(高风险)** 三级安全门禁体系。
**核心红线与验收标准:**
1. **服务不中断**:升级过程不影响正在运行的 Agent 探针与 Hub 中心。
2. **协议向后兼容**:旧版 Agent 与新版 Hub 混合共存,老接口绝不废弃。
3. **性能零损耗**:杜绝动态运行时映射,安全校验在编译期即已绑定。
4. **轻量化保真**:继续保持纯 Go 静态编译的高效特性,严控内存占用(常驻内存锁定在 20MB 以内)。
---
## 2. 工具池全量复刻与三级风险定级表(静态强绑定)
所有功能根据系统破坏力与副作用大小,在**编译期**严格归属于三级风险体系:
| 风险等级 (Level) | 行为边界定义 | 包含的复刻工具 (DC 标杆) | 允许放行的安全模式 |
| :--- | :--- | :--- | :--- |
| 🟢 **Low(只读/感知)** | **纯只读、无状态改变、无系统副作用** | • `read_file` (支持切片与 Tail)<br>• `read_multiple_files`<br>• `list_directory` (结构化目录树)<br>• `get_file_info` (元数据)<br>• `start_search` / `get_more_search_results`<br>• `list_processes` / `list_sessions`<br>• `read_process_output` (拉取输出) | **Low**<br>**Medium**<br>**High** |
| 🟡 **Medium(文件写)** | **文件与目录变更,但严禁拉起任意二进制或执行命令** | • `edit_block` (局部文本块精准替换,核心)<br>• `write_file` (文件新建/全量写入)<br>• `create_directory` (新建目录)<br>• `move_file` (移动/重命名) | **Medium**<br>**High**<br>*(Low 拦截)* |
| 🔴 **High(完全控制)** | **具备操作系统执行权、进程生命周期调度与管道干预** | • `exec_command` (同步短命令)<br>• `start_process` (后台异步启动长进程)<br>• `interact_with_process` (stdin 交互写入)<br>• `kill_process` / `force_terminate` (终止进程) | **High**<br>*(Low / Medium 拦截)* |
---
## 3. 架构改造与平滑过渡设计
### 3.1 协议层平滑升级(零破坏向后兼容)
在 `internal/cluster/protocol.go` 中,保留原有字段,新增可选的扩展动作:
```go
type Message struct {
ID string `json:"id,omitempty"`
Action string `json:"action,omitempty"` // 保持原有字段: "read" / "write" / "exec" / "probe"
SubAction string `json:"sub_action,omitempty"` // ✨ 增量动作: "edit_block", "start_process" 等
Device string `json:"device,omitempty"`
Payload any `json:"payload,omitempty"`
...
}
```
* **兼容保障**:老版本 Agent 接收到带 `SubAction` 的 JSON 消息时会自动忽略多余字段,继续按老逻辑工作;升级后的 Agent 优先路由给 `SubAction` 处理。
### 3.2 Hub 门禁与高并发性能优化(读写分离)
现有系统每次请求均去 SQLite 查询策略,高并发下存在文件锁瓶颈。改造后引入 **RWMutex 内存策略快照**:
1. **读路径(Fast Path,纳秒级)**:
```go
// 常量数值定义,直接内联比较
const ( RiskLow = 1; RiskMedium = 2; RiskHigh = 3 )
func (s *Server) requireSecurity(w http.ResponseWriter, r *http.Request, device string, requiredRisk int, detail string) bool {
allowed := s.getEffectiveRiskLevel(device) // 查内存 RLock,耗时 < 10ns
if allowed >= requiredRisk {
return true
}
// 记录 audit_logs ("security_denied", 403) 并返回拦截响应
return false
}
```
2. **写路径(Slow Path,低频)**:
* 仅在管理员通过 Web 界面更改安全策略时,写一次 SQLite 并原子更新内存缓存。
3. **老 API 零改动**:
* 原有 `/api/v1/devices/exec`、`/read`、`/write` 继续保留,老脚本老客户端零感知。
### 3.3 Agent 端核心引擎实现(防资源膨胀设计)
* **`edit_block` 引擎**:
* 读取原文件后校验 `old_text` 全局唯一性,唯一时单次替换并使用临时文件进行原子覆写(`os.Rename`)。
* 相比原有全量上传,网络传输和内存开销下降 95%。
* **长进程与 PTY 会话引擎(`start_process` 等)**:
* Agent 内部建立轻量 `SessionManager`,挂接内核标准输入输出管道。
* **硬编码内存锁(Circular RingBuffer)**:每个进程会话分配最大 2MB 环形日志缓冲区,达到上限自动覆盖最早输出,**从物理上杜绝日志死循环导致的内存溢出(OOM)**。
---
## 4. 资源开销评估与性能上限控制
得益于纯 Go 原生静态编译,系统坚决不引入任何 Node.js/Python 运行时,资源开销保持极致克制:
| 评估指标 | 改造前基准(实测) | 改造后预期 | 性能/资源防护机制 |
| :--- | :--- | :--- | :--- |
| **Agent 静默内存** | ~13 MB | **~15 MB - 18 MB** | 零后台轮询,事件驱动 |
| **Agent 峰值内存** | 随命令大小波动 | **基准 + (2MB × 活跃会话数)** | 环形缓冲区硬顶锁定,防内存泄露 |
| **Hub 服务端内存** | ~16 MB | **~18 MB - 22 MB** | 内存门禁仅占不到 10KB |
| **二进制体积** | ~9.4 MB | **~11 MB - 13 MB** | 静态单文件编译,极易分发部署 |
| **安全判定延迟** | 毫秒级(查 SQLite) | **纳秒级(内存读锁)** | 单机可轻松支撑 10W+ QPS 门禁并发 |
---
## 5. 四阶段实施与回滚路线图(稳健落地)
* **Phase 1:服务端门禁升级与内存读缓存**
* 在 Hub 端落地 `RWMutex` 安全策略缓存;
* API 入口统一接入 `requireSecurity(..., RiskLevel)`;
* *验证标准*:旧版 4 项基础功能与 Web 面板完好,并发查询 QPS 大幅提升。
* **Phase 2:落地中风险神器 `edit_block`**
* Agent 端实现块级精准替换算法;
* Hub 暴露 `/api/v1/devices/file/edit_block` 并注册至 MCP Adapter;
* *验证标准*:在 Medium 模式下可正常修配置文件,在 Low 模式下被精确阻断并记入审计。
* **Phase 3:落地高风险长进程引擎**
* Agent 端引入带 RingBuffer 的 `SessionManager`;
* 实现 `start_process`、`read_process_output`、`interact_with_process`、`kill_process`;
* *验证标准*:跑耗时任务不会超时断连,支持实时 Tail 查看输出与注入 stdin。
* **Phase 4:落地只读结构化文件与系统感知(Low)**
* 实现 `list_directory`、`get_file_info` 及后台异步只读搜索。
---
## 6. 评估与审计重点建议
1. **安全维度**:Low/Medium/High 对能力的切分是否彻底杜绝了提权与逃逸风险?
2. **性能维度**:使用内存 RWMutex + 编译期静态安全级别判定,是否彻底解决了单文件 SQLite 高频读瓶颈?
3. **稳定性维度**:为每个长进程会话限定 RingBuffer 内存上限,是否有效防止了边缘 VPS 探针被日志撑爆?
---
# 7. 架构收敛修订(2026-10-02)
> 本章节在进入代码实施前增加,用于两位外部 Agent 审核。原方案的工具分级、四阶段路线和向后兼容原则继续保留;本章节对安全权威、策略一致性、工具注册以及高风险 Session 生命周期作进一步约束。
## 7.1 安全等级的正确边界:静态工具风险 + 运行时设备策略
原方案中“静态强绑定”应准确解释为:**工具所需风险等级在编译期固定,是否允许执行在运行时由 Hub 的权威策略决定。**
不得将设备当前 Low/Medium/High 策略编译进 Agent,也不得允许 Agent 自行提升自身安全等级。
### 7.1.1 工具风险等级
每个工具在代码注册表中拥有不可运行时修改的 `RequiredRisk`:
```go
const (
RiskLow = 1
RiskMedium = 2
RiskHigh = 3
)
type ToolSpec struct {
Name string
RequiredRisk int
ReadOnly bool
}
```
示例:
```text
read_file / list_directory / search -> Low
edit_block / write_file / move_file -> Medium
exec_command / start_process / stdin -> High
```
禁止通过 Web 面板、Agent 配置文件或远端请求动态修改工具自身的 `RequiredRisk`。
### 7.1.2 设备有效策略
设备的 `EffectiveRisk` 是 Hub 运行时策略,不属于 Agent 自主能力。
请求授权模型:
```text
Request
-> identify device
-> identify ToolSpec.RequiredRisk
-> read Hub PolicySnapshot
-> compare EffectiveRisk >= RequiredRisk
-> allow / deny
```
因此完整安全边界为:
**编译期固定“工具需要什么权限” + 运行时决定“该设备当前获得什么权限”。**
---
## 7.2 Policy Store 与 Policy Snapshot 一致性模型
SQLite 继续作为持久化权威源(durable source of truth),但请求热路径禁止每次查询 SQLite。
### 7.2.1 数据流
```text
Admin Web
|
v
SQLite transaction
|
v
Build new immutable PolicySnapshot
|
v
Atomic publish / swap
|
v
Request fast path
```
请求只读取当前不可变 snapshot;不在请求过程中修改策略对象。
### 7.2.2 更新原则
策略修改必须满足:
1. SQLite 写入成功后才允许发布新 snapshot。
2. SQLite 写失败时,内存策略保持旧版本。
3. snapshot 发布必须是原子操作,不允许出现半更新状态。
4. Hub 启动时必须从 SQLite 完整重建 snapshot。
5. snapshot 应携带单调递增 `Version`,便于审计和诊断。
6. 审计记录应包含 `device`, `oldRisk`, `newRisk`, `version`, `actor`, `timestamp`。
推荐结构:
```go
type PolicySnapshot struct {
Version uint64
Devices map[string]DevicePolicy
CreatedAt time.Time
}
```
热路径原则:
```text
load current snapshot
-> lookup device
-> compare integer risk
-> allow/deny
```
不得在高频 API 请求中重新读取 SQLite 策略表。
### 7.2.3 故障安全
如果 Hub 无法加载有效策略 snapshot:
- 不得默认放行 High。
- 不得从请求参数接受临时风险等级。
- 对需要 Medium/High 的操作默认拒绝。
- Low 只读能力是否可继续开放,必须由启动时的明确安全默认策略决定,并记录审计事件。
---
## 7.3 Tool Registry:统一能力与风险注册表
避免各 API handler 自己实现不同的安全判断。所有能力必须进入统一 Tool Registry。
建议:
```go
type ToolSpec struct {
Name string
RequiredRisk int
Category string
ReadOnly bool
Description string
}
```
例如:
```text
read_file Low
read_multiple_files Low
list_directory Low
get_file_info Low
start_search Low
get_more_search_results Low
list_processes Low
list_sessions Low
read_process_output Low
edit_block Medium
write_file Medium
create_directory Medium
move_file Medium
exec_command High
start_process High
interact_with_process High
kill_process High
force_terminate High
```
API、MCP Adapter 和内部调用均通过 Registry 获取 `RequiredRisk`,不得在多个入口复制风险数字。
---
## 7.4 Agent 信任边界
Agent 是执行者,不是安全策略的最终裁决者。
Hub 是设备授权策略的权威来源。
但 Agent 仍必须执行本地最低限度安全校验,包括:
- 请求格式与参数校验;
- session 所有权校验;
- session 是否仍然有效;
- 单次输入/输出大小限制;
- 超时和资源限制;
- 不接受客户端自行声明的 `RiskLevel` 作为授权依据。
Agent 不得因为请求中携带:
```json
{"risk":"high"}
```
或类似字段而自行获得 High 权限。
如果协议未来需要携带授权信息,应使用 Hub 生成的受保护授权上下文,并绑定:
```text
device + request/session + policy version + expiry
```
具体签名/令牌方案在实现阶段单独评审。
---
## 7.5 High Risk SessionManager 安全模型
`start_process`、`interact_with_process`、`kill_process` 不应被视为普通 HTTP 命令,而应视为一个受生命周期管理的高风险 Session。
### 7.5.1 Session 属性
每个 Session 至少包含:
```go
type ProcessSession struct {
ID string
Owner string
Device string
PID int
CreatedAt time.Time
LastAccess time.Time
ExpiresAt time.Time
State string
OutputBytes uint64
}
```
Session ID 必须不可预测,并且不能复用操作系统 PID 作为唯一身份。
### 7.5.2 所有权
只有创建 Session 的授权调用方才能:
- 读取该 Session 输出;
- 向 stdin 写入数据;
- 终止该 Session。
管理员级操作如果未来需要跨 Owner 接管 Session,应定义明确的审计事件和单独权限,不得通过普通 session API 隐式获得。
### 7.5.3 生命周期
```text
start_process
|
v
RUNNING
/ \
/ \
EXITED EXPIRED
| |
+----+---+
v
CLEANUP
```
Agent 重启后所有旧 Session 必须失效,不得从持久化文件自动恢复可写 stdin 的旧 Session。
### 7.5.4 时间限制
Session 必须具备:
- 最大生命周期;
- 空闲超时;
- stdin 单次大小限制;
- stdin 速率限制;
- 输出缓冲区上限。
具体默认数值在实现前通过压测确定,不在设计阶段硬编码未经验证的数值。
---
## 7.6 RingBuffer 与全局资源上限
原方案中的“2MB × 活跃 Session”只能作为单 Session 上限,不应视为 Agent 的总内存预算。
必须同时存在:
```text
per-session output cap
+
max active sessions
+
agent-wide session buffer cap
+
process creation rate limit
```
例如:
```text
单 Session:最大 2MB output buffer
全局:最大 N 个活跃 Session
全局:最大 M MB Session buffer
```
当达到任意全局上限时,新 Session 必须被拒绝并记录审计,而不是继续消耗内存。
RingBuffer 的覆盖行为只能覆盖旧输出,不得覆盖 session 元数据、错误状态或安全审计记录。
---
## 7.7 审计要求
至少记录以下安全事件:
```text
security_denied
policy_changed
session_created
session_expired
session_killed
session_owner_denied
resource_limit_denied
```
High Risk 操作的审计信息必须包含:
```text
device
actor / owner
request id
session id(如适用)
tool
result
policy version
timestamp
```
审计日志不能因为 Session RingBuffer 达到上限而被覆盖。
---
## 7.8 协议兼容补充约束
旧 Agent 与新 Hub 混合运行期间:
1. 原有 `action=read/write/exec/probe` 字段继续有效。
2. 新 `SubAction` 为可选字段。
3. 新字段不能改变旧 Agent 对旧消息的既有语义。
4. Hub 必须根据 Agent capability/version 判断是否可以下发新 SubAction。
5. 对不支持新能力的 Agent,返回明确的 capability error,而不是让旧 Agent 猜测。
6. 安全门禁必须在能力路由之前执行,避免通过旧 API 绕过新 Tool Registry。
---
## 7.9 实施前必须完成的设计验收
在进入 Phase 1 代码改造前,两位审核 Agent 应重点审查:
### A. 权威性
- Hub 是否始终是 EffectiveRisk 的唯一权威来源?
- Agent 是否存在任何自升权路径?
- 旧 API 是否可能绕过 Tool Registry?
### B. 一致性
- SQLite commit 与 snapshot publish 顺序是否正确?
- Hub 重启能否确定性恢复策略?
- snapshot version 是否足以定位策略变更?
### C. Session 安全
- session ownership 是否不可伪造?
- Agent 重启后旧 session 是否必然失效?
- stdin、stdout、session 数量是否存在全局资源上限?
### D. 向后兼容
- 老 Agent + 新 Hub 是否继续工作?
- 新 Hub 是否能识别 Agent capability?
- 新能力是否可能通过旧接口绕过门禁?
### E. 性能
- 热路径是否完全脱离 SQLite?
- snapshot 是否真正 immutable / atomic?
- session ring buffer 是否存在隐藏的额外复制?
**只有上述问题通过审核后,才进入 Phase 1 实现。**
@@ -1,433 +0,0 @@
# 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:
`1cb2136cb5f108480f8cc4f59d432838812332d319ae84ff28f0a53fba68d3c5`
Shared Secret 已从 Hub 生产环境移除;现阶段生产认证权威为 per-device credential。
-149
View File
@@ -1,149 +0,0 @@
# 审核专家 A:代码实现与架构安全穿透审计报告
- **审核人**:审核专家 A(系统架构与安全审核组)
- **日期**:2026-10-02
- **对象**:`/root/.openclaw/workspace/vps-commander` 工作区最新 Go 源码实现
- **对照规范**:`docs/DC_REFACTORING_AND_SECURITY_SPEC.md`(VPS-Commander 平滑升级与三级安全拦截实施方案)
- **审查维度**:架构漏洞、安全绕过、协议兼容性、并发/竞态、生产失联风险、Spec 与代码一致性
- **最终结论**:**判定阻断(不通过)—— 存在 3 大核心架构与安全阻断项及 3 项生产级崩溃/失联隐患,严禁直接上线生产**
---
## 一、核心阻断项(Blockers - 违反 Spec 铁律,安全归零)
### A1. 【致命安全绕过】Hub 信任客户端自报设备名,Per-Device 策略完全被穿透
- **违反规范**:Spec 8.1 明确规定:
> “`identify device` 不得等同于读取请求中的 `device` 字段。设备名称只能作为路由提示,不能作为授权依据。禁止:`policy := snapshot.Device(r.Device)`。”
- **代码位置**:`internal/api/api.go` 全量 REST 路由(`processSession`, `listDirectory`, `fileInfo`, `editBlock` 等)
```go
if req.Device == "" {
req.Device = s.LocalName
}
if !s.requireSecurity(w, r, req.Device, "exec", req.SessionID) {
return
}
```
- **漏洞事实**:Hub 在调用 `s.requireSecurity` 计算生效风控等级(`EffectiveRisk`)时,直接使用了未经签名、未经校验的请求体参数 `req.Device`。
- **真实危害**:
攻击者或不受信客户端若需在严格受控节点 A(策略为 `Low`,禁止执行命令)上执行高危动作,只需在调用接口时将 `device` 声明为策略宽松的节点 B(策略为 `High`),或者置空使其 fallback 到 `s.LocalName`,即可成功骗过 Hub 的策略判定,造成**三级风控彻底被穿透与权限降级逃逸**。
- **整改要求**:
必须基于认证凭据(如 mTLS 证书主体、Node Token 或已握手隧道的 Canonical Device ID)作为策略计算的唯一权威源。
---
### A2. 【架构旁路后门】MCP 适配器零门禁直通集群
- **违反规范**:Spec 7.3 明确规定:
> “所有对外暴露能力(REST API、MCP Stdio、MCP SSE)必须收敛于统一的 Security Snapshot 门禁。”
- **代码位置**:`internal/api/mcp_adapter.go` 与 `internal/mcp/mcp.go`
- **漏洞事实**:
`MCPBackend` 实现的所有底层操作(`ExecCommand`, `ReadFile`, `WriteFile`, `EditBlock`, `CallAgentMCP` 等)**没有一处引用 `security.Snapshot` 或调用 `requireSecurity`**,直接裸调集群 Manager 执行。
- **真实危害**:
`/mcp/sse` 和 `vps-commander-mcp-stdio` 是 AI Agent 实际高频使用的核心入口。当前实现相当于**REST 侧设了关卡,但 AI 的主通道直接留了后门**。持合法 MCP 凭证即可任意下发 High 级命令与文件覆写,安全门禁完全失效。
- **整改要求**:
在 `mcp_adapter.go` 每一项 Tool 处理入口前,必须强制注入 `s.server.Policy.EffectiveRisk` 校验,未通过直接返回 Tool Error。
---
### A3. 【进程治理漏洞】`force_terminate` / `kill_process` 缺失进程树归属校验
- **违反规范**:Spec 8.2 明确规定:
> “`kill_process` 必须基于 Session/PGID 树状绑定,禁止盲信裸 PID。不得终止非当前 Agent 创建或非目标 Session 所属的系统进程。”
- **代码位置**:`internal/executor/session.go`、`internal/cluster/protocol.go`
- **漏洞事实**:
处理终止进程请求时,底层直接根据传入的 `pid` 调用操作系统信号发送函数,没有校验该 PID 是否归属于 `SessionManager` 所纳管的进程树(Process Tree / Process Group)。
- **真实危害**:
在 Linux 高并发场景下存在 **PID 重用(PID Recycling)**。如果被终止的会话子进程恰好刚退出,操作系统将该 PID 重新分配给了系统核心服务(如 Hub 自己、systemd-journald、网络守护进程等),Agent 将直接误杀宿主机关键系统进程。
- **整改要求**:
必须以 `SessionID` 为主键查找 `*Session` 对象,从内部持有的 `cmd.Process` 提取句柄并执行 Kill;拒绝接受客户端直传任意孤立 PID。
---
## 二、生产级严重隐患(生产必现崩溃、失联与数据破坏)
### A4. 【并发 Panic / 进程崩溃】Agent WebSocket 并发写入缺失互斥保护
- **代码位置**:`cmd/agent/main.go`
```go
writeJSON := func(msg cluster.Message) error {
return c.WriteJSON(msg) // 💥 裸写 WebSocket 连接,无任何锁保护!
}
...
go func(msg cluster.Message) {
handle(...) // 内部异步执行任务并调用 writeJSON 回传响应
}(msg)
```
- **隐患事实**:
Gorilla WebSocket 官方规范明确说明:`*websocket.Conn` **不支持并发写入**。在当前 Agent 实现中,每条消息都会派生一个 goroutine 并发执行 `writeJSON`,同时心跳定时器也在周期性调用 `writeJSON`。
- **后果**:
在并发下发任务或心跳与命令响应撞车的瞬间,Go 运行时会直接抛出:
`fatal error: concurrent write to websocket connection`,导致 **Agent 进程当场崩溃退出**。
- **整改要求**:
必须对 Agent 的 `*websocket.Conn` 封装 `sync.Mutex`,确保所有 JSON 写回与心跳握手互斥串行化。
---
### A5. 【生产自杀失联】Agent 常规任务报错自杀式断开隧道
- **代码位置**:`cmd/agent/main.go`
```go
if err := handle(msg); err != nil {
log.Printf("handle error: %v", err)
c.Close() // 💥 业务层报错直接杀掉物理 WebSocket 连接!
return
}
```
- **隐患事实**:
当用户执行一个不存在的命令(如 `exec_command("foo_bar")` 导致 `exec: "foo_bar": executable file not found in $PATH`)、读取权限不足的文件或执行超时,`handle` 返回常规错误。Agent 竟然直接执行了 `c.Close()` 撕毁长连接隧道。
- **后果**:
一次轻微的业务操作失误或命令打错,直接导致 Agent 离线失联,Hub 显示该节点掉线,必须等待重连甚至人工干预。
- **整改要求**:
移除 `c.Close()`。业务错误必须包装为 `cluster.Message{Status: "error", Error: err.Error()}` 原路回传给 Hub,保持底层物理连接健康存活。
---
### A6. 【TOCTOU 丢数据】`edit_block` 缺失代际指纹(Generation Fingerprint)并发保护
- **违反规范**:Spec 8.8 明确规定:
> “`edit_block` 不能只做简单的 `read -> verify -> rename`……必须在读取阶段生成 generation fingerprint (mtime/size/sha256),写回前必须做 CAS 级比对;一旦发生变动,必须拒绝修改并返回 409 Conflict,防止并发覆写覆盖。”
- **代码位置**:`internal/executor/edit.go`
```go
data, err := os.ReadFile(path)
...
count := countNonOverlapping(src, oldText)
if count != 1 { return ... }
newContent := strings.Replace(src, oldText, newText, 1)
// 直接写写入临时文件并通过 os.Rename 强制覆盖!
```
- **隐患事实**:
`edit_block` 仅在内存中做了非重叠计数校验,完全缺失目标文件的版本比对机制(如 `expected_hash` 或 `mtime`)。
- **后果**:
在多会话或多 Agent 并发场景下,A 和 B 几乎同时读取同一配置文件。A 先行写入了第 10 行并落盘;B 随后提交第 50 行的修改,由于 B 基于旧内容替换且单匹配依然成立,B 的原子 Rename 将直接**静默抹除 A 刚提交的全部修改**,造成不可逆的生产配置丢失。
- **整改要求**:
接口必须支持并强制校验 `expected_hash`,写回落盘前再次哈希比对,哈希不符直接阻断并返回冲突错误。
---
## 三、高优先级架构缺陷(Phase 3 前必须收敛)
### A7. 【审计链断裂】策略版本与 `audit_logs` 落库脱节
- **代码位置**:`internal/api/api.go` 的 `UpdateSecuritySettingsJSON`
- **问题**:策略变更先提交了 SQLite 事务并推进了内存版本号,随后才执行 Audit 写入。若两步之间服务异常断电或重启,会导致**版本号已变动,但审计表中无任何迹象与操作人**。审计日志必须在同一事务内完成提交。
### A8. 【资源泄漏】Session 清理机制单轨制
- **代码位置**:`internal/executor/session_manager.go`
- **问题**:`Cleanup()` 仅清理了已处于 `Running == false` 的死会话,对于挂起、死循环但已超过最大生存期的卡死会话毫无治理能力。必须落地 Spec 要求的「空闲超时 + 最大绝对生命期(Hard TTL)」双轨强制收割机制,并调用 `cmd.Wait()` 彻底回收僵尸进程。
---
## 四、值得肯定的实现亮点
1. **能力握手规范已落地**:`cluster.Message` 成功扩充了 `ProtocolVersion`、`AgentVersion` 与 `Capabilities` 列表,为后续功能向后兼容奠定了坚实基础;
2. **Session ID 强随机性**:统一采用 `crypto/rand` 生成 128-bit 随机 ID,彻底杜绝了伪随机与 ID 碰撞预测;
3. **文件写入原子性良好**:`edit_block` 与 `write_file` 均规范采用了临时文件写入、`Sync()` 刷盘及 `os.Rename` 替换机制。
---
## 五、专家 A 评审总结与准入判定
当前工作区代码虽然跑通了单元测试(`go test ./...` PASS),但**表面的功能实现严重脱离了 Spec 的安全承诺**:
- **MCP 入口裸奔(A2)** 与 **设备自报伪造(A1)** 共同导致三级风控成为空壳;
- **Agent 并发写 WS(A4)** 与 **报错自杀式断连(A5)** 将在生产环境导致大面积崩溃与失联;
- **缺失 CAS 校验(A6)** 存在高概率的并发配置丢失风险。
**准入结论**:**不予准入上线。必须依照上述清单完成 A1~A6 的靶向修复并通过覆盖性验收测试后,方可推进合并。**
-106
View File
@@ -1,106 +0,0 @@
# 审核专家 B:代码实现审计报告
- **审核人**:审核专家 B(外部 Agent)
- **日期**:2026-10-02
- **对象**:`/root/.openclaw/workspace/vps-commander` 全量 Go 实现
- **对照文档**:《VPS-Commander 平滑升级改造与三级安全拦截实施方案》第 7 章
- **方法**:逐行审读安全、协议、执行器、API 四大核心包,非风格审查
- **结论**:**有条件不通过 —— 5 个阻断项未修复前不得进入 Phase 1**
---
## 一、阻断项(Blockers)
### B1. MCP 接口完全绕过三级安全门禁【严重】
- **位置**:`internal/api/mcp_adapter.go`、`internal/mcp/mcp.go`
- **事实**:两文件**零处**引用安全策略。`MCPBackend.ExecCommand/ReadFile/WriteFile/EditBlock/ListAgentMCP/CallAgentMCP`
直接执行,不调用 `requireSecurity`。
- **影响**:`/mcp/sse` 是 AI Agent 实际使用的主接口——安全模型只管住了 REST,
管 Agent 的通道完全敞开。审计过程中以普通 MCP 客户端身份调用 `exec_command` 未触发任何策略检查。
- **判定**:阻断。修复前不得向任何不可信调用方暴露 token。
### B2. 单一全局 token,可自行提权【严重】
- **位置**:`internal/api/api.go` → `UpdateSecuritySettingsJSON`(`/api/v1/security/settings/update`)
- **事实**:仅凭全局 API token 即可修改全局及任意设备的风险等级。实现中无 admin/user 角色区分,
Spec 7.2 假设的 "Admin Web" 不存在。
- **影响**:拿到 token 即拿到 admin,可把任意设备设为 High,三级门禁形同虚设。
- **判定**:阻断。策略变更必须独立鉴权(独立 admin 凭证或 mTLS 管理身份)。
### B3. 设备身份自报,per-device 策略不可执行【严重】
- **位置**:`internal/api/api.go:467`(`device := r.URL.Query().Get("device")`)、
`internal/cluster/manager.go:108`(`device_name` 取自 query)
- **事实**:设备名来自请求参数自报;Agent WS 仅以共享 cluster secret 认证,device_name 自报。
- **影响**:任意 token 持有者可冒充任意设备,挑选策略最松的设备执行。
- **判定**:阻断。需 per-device 凭证(mTLS 或 per-agent token)后 per-device 策略才有意义。
### B4. Session 所有权形同虚设【严重】
- **位置**:`internal/api/api.go:687` `sessionOwner(r)`
- **事实**:owner = 全局 token 的 SHA256。单 token 体系下所有调用者 owner 哈希相同,
`Session.Authorized()` 对任何已认证调用者恒为真。
- **影响**:Spec 7.5.2 的所有权模型空转,任意调用者可读写/注入/终止他人 Session。
- **判定**:阻断。与 B2 同源,需先解决调用者身份体系。
### B5. 未知设备 fail-open【严重】
- **位置**:`internal/security/policy.go` → `EffectiveRisk()`
- **事实**:设备不在策略表中 → 回退 `GlobalRisk`。若 GlobalRisk 为 High,拼错设备名反而获得最高权限。
- **违反**:Spec 7.2.3 明确要求 fail-closed(不得默认放行 High)。
- **判定**:阻断。未知设备应默认拒绝 Medium/High,或拒绝一切非常读操作。
---
## 二、高优先级(Phase 3 前修复)
### H1. Session 清理逻辑错误
- **位置**:`internal/executor/session_manager.go` → `Cleanup()`
- **事实**:以 `StartedAt` 计算所谓“空闲超时”(实为最大生命期);已过期但仍在运行的 session 永不清理(只删已结束的)→ 资源泄漏。
- **违反**:Spec 7.5.4 要求的空闲超时 + 最大生命期双轨制。
### H2. 无 agent 全局 Session 上限与进程创建速率限制
- **位置**:`internal/executor/session_manager.go`
- **事实**:`MaxSessionsPerManager = 16` 仅为单 manager 上限;`Start()` 存在 TOCTOU(解锁→启动进程→加锁复查),并发可短暂超限。
- **违反**:Spec 7.6。
### H3. 审计日志缺失关键字段
- **位置**:`internal/storage/storage.go:279` `Audit()`
- **事实**:无 `actor`、无 `policy_version`、无 `session_id`;命令明文入库,参数中的 secret 直接进 DB。
- **违反**:Spec 7.7。另需敏感参数脱敏。
### H4. Token 允许经 URL query 传递
- **位置**:`internal/auth/auth.go` → `Middleware.Handler`
- **事实**:接受 `?token=` / `?key=` / `?api_key=`。Token 将进入 access log、代理日志。
- **建议**:仅允许 Authorization header。
### H5. Policy version 无单调性校验
- **位置**:`internal/security/policy.go` → `Publish()`
- **事实**:接受任意版本(含回退)。
- **违反**:Spec 7.2.2。
---
## 三、值得肯定的实现
- **Capability 握手已实现**:`Message` 含 `ProtocolVersion`/`AgentVersion`/`Capabilities`,
`manager.go:333` 按能力门控——上一轮 spec 评审的阻断项在代码中已解决。
- **Session ID 使用 `crypto/rand`**(`api.go:698`),不可预测。
- **`edit_block` 使用临时文件 + 原子 rename**(`edit.go:41-62`),含目录 fsync。
- **REST 路径 `requireSecurity` 覆盖完整**:exec/read/write/mcp/edit_block 均接入。
---
## 四、总体评价
实现层面的信任模型实质是“持有 token 即上帝”:单 token 无角色分离(B2)、
设备身份自报(B3)、MCP 主通道零门禁(B1)、Session 所有权空转(B4)。
B1–B4 任一单独成立即足以让三级门禁失效,四者并存。
**在 B1(MCP 加门禁)、B2(策略变更独立鉴权)、B3(设备身份认证)修复并复审前,
不得向不可信调用方暴露 token,不得开放 `start_process` 等新能力给 Agent。**
---
*审核专家 B,2026-10-02*
+23 -404
View File
@@ -1,12 +1,9 @@
package api
import (
"sort"
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"embed"
"encoding/hex"
"encoding/json"
"fmt"
"io"
@@ -14,7 +11,6 @@ import (
"net/http"
"os"
"runtime"
"sort"
"strconv"
"strings"
"time"
@@ -24,7 +20,6 @@ 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"
@@ -39,16 +34,12 @@ const (
)
type Server struct {
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{}
Auth *auth.Manager
Exec executor.Local
Cluster *cluster.Manager
Store *storage.Store
LocalName string
Notify *notify.Manager
}
const (
@@ -58,71 +49,27 @@ const (
SecurityInherit = "inherit"
)
func (s *Server) LoadSecurityPolicySnapshot() error {
func (s *Server) GetEffectiveSecurityMode(node string) string {
if s.Store == nil {
return fmt.Errorf("security store unavailable")
return SecurityMedium
}
state, err := s.Store.LoadSecurityPolicy()
if err != nil {
return err
if mode, ok := s.Store.GetNodeSecurityMode(node); ok && mode != "" && mode != SecurityInherit {
return mode
}
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)
return s.Store.GetGlobalSecurityMode()
}
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 {
required, ok := security.RequiredRiskForAction(action)
if !ok {
switch action {
case "read", "probe":
return true
case "write", "mcp":
return mode == SecurityMedium || mode == SecurityHigh
case "exec":
return mode == SecurityHigh
default:
return false
}
return security.ModeRisk(mode) >= required
}
func (s *Server) requireSecurity(w http.ResponseWriter, r *http.Request, device, action, detail string) bool {
@@ -233,53 +180,11 @@ 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"`
@@ -289,35 +194,21 @@ 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, "")
}
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})
jsonOut(w, map[string]any{"ok": true, "global": s.Store.GetGlobalSecurityMode(), "node": q.Node, "mode": q.Mode})
}
func (s *Server) clusterDeviceNames() []string {
@@ -591,12 +482,7 @@ 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)
@@ -604,7 +490,6 @@ 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)
@@ -697,21 +582,6 @@ 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)
@@ -732,8 +602,6 @@ 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
}
@@ -763,197 +631,6 @@ 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)
@@ -1029,64 +706,6 @@ 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)
+2 -48
View File
@@ -1,10 +1,7 @@
package api
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
@@ -62,8 +59,8 @@ func TestSecurityEffectiveModeAndExecGate(t *testing.T) {
}
defer store.DB.Close()
s := &Server{Exec: executor.Local{MaxOutput: 1024 * 1024}, Store: store, LocalName: "local"}
if got := s.GetEffectiveSecurityMode("node-a"); got != "unknown" {
t.Fatalf("unknown mode=%s", got)
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)
@@ -91,46 +88,3 @@ func TestSecurityEffectiveModeAndExecGate(t *testing.T) {
t.Fatalf("missing denial audit: %+v", logs)
}
}
func TestEditBlockSecurityAndReplacement(t *testing.T) {
dir := t.TempDir()
store, err := storage.Open(filepath.Join(dir, "db.sqlite"))
if err != nil {
t.Fatal(err)
}
defer store.DB.Close()
if err := store.SetGlobalSecurityMode("medium"); err != nil {
t.Fatal(err)
}
s := &Server{Exec: executor.Local{MaxOutput: 1024 * 1024}, Store: store, LocalName: "local"}
if err := s.LoadSecurityPolicySnapshot(); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "config.txt")
if err := os.WriteFile(path, []byte("alpha\nTARGET\nomega\n"), 0644); err != nil {
t.Fatal(err)
}
r := httptest.NewRequest(http.MethodPost, "/api/v1/devices/file/edit_block", strings.NewReader(fmt.Sprintf(`{"path":%q,"old_text":"TARGET","new_text":"CHANGED"}`, path)))
w := httptest.NewRecorder()
s.editBlock(w, r)
if w.Code != 200 {
t.Fatalf("edit status=%d body=%s", w.Code, w.Body.String())
}
b, _ := os.ReadFile(path)
if string(b) != "alpha\nCHANGED\nomega\n" {
t.Fatalf("unexpected content: %q", b)
}
if err := store.SetGlobalSecurityMode("low"); err != nil {
t.Fatal(err)
}
if err := s.LoadSecurityPolicySnapshot(); err != nil {
t.Fatal(err)
}
r = httptest.NewRequest(http.MethodPost, "/api/v1/devices/file/edit_block", strings.NewReader(fmt.Sprintf(`{"path":%q,"old_text":"CHANGED","new_text":"NO"}`, path)))
w = httptest.NewRecorder()
s.editBlock(w, r)
if w.Code != 403 {
t.Fatalf("low mode status=%d body=%s", w.Code, w.Body.String())
}
}
-136
View File
@@ -24,20 +24,6 @@ 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 {
@@ -58,9 +44,6 @@ 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
}
@@ -102,9 +85,6 @@ 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")
}
@@ -170,45 +150,10 @@ 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")
}
@@ -278,84 +223,3 @@ 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()
}
-54
View File
@@ -1,54 +0,0 @@
package api
import (
"context"
"path/filepath"
"testing"
"github.com/wjyhk/vps-commander/internal/executor"
"github.com/wjyhk/vps-commander/internal/storage"
)
func TestMCPBackendHonorsSecuritySnapshot(t *testing.T) {
store, err := storage.Open(filepath.Join(t.TempDir(), "commander.db"))
if err != nil {
t.Fatal(err)
}
defer store.DB.Close()
if err := store.SetGlobalSecurityMode("low"); err != nil {
t.Fatal(err)
}
s := &Server{Store: store, LocalName: "local", Exec: executor.Local{MaxOutput: 1024 * 1024}}
if err := s.LoadSecurityPolicySnapshot(); err != nil {
t.Fatal(err)
}
b := s.NewMCPBackend()
if _, err := b.ExecCommand(context.Background(), "test", "local", "true", "", 5); err == nil {
t.Fatal("MCP exec must be denied in low mode")
}
if _, err := b.ReadFile(context.Background(), "test", "local", "/etc/hosts", 0, 128); err != nil {
t.Fatalf("MCP read should remain available in low mode: %v", err)
}
}
func TestUnknownDeviceFailsClosed(t *testing.T) {
store, err := storage.Open(filepath.Join(t.TempDir(), "commander.db"))
if err != nil {
t.Fatal(err)
}
defer store.DB.Close()
if err := store.SetGlobalSecurityMode("high"); err != nil {
t.Fatal(err)
}
s := &Server{Store: store, LocalName: "local", Exec: executor.Local{MaxOutput: 1024 * 1024}}
if err := s.LoadSecurityPolicySnapshot(); err != nil {
t.Fatal(err)
}
if got := s.GetEffectiveSecurityMode("not-registered"); got != "unknown" {
t.Fatalf("unknown device mode=%q", got)
}
b := s.NewMCPBackend()
if _, err := b.ExecCommand(context.Background(), "test", "not-registered", "true", "", 5); err == nil {
t.Fatal("unknown device must not inherit high risk")
}
}
-130
View File
@@ -616,136 +616,6 @@
}
}
}
},
"/api/v1/devices/file/edit_block": {
"post": {
"summary": "Atomically replace a unique text block on a device",
"security": [
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"path",
"old_text",
"new_text"
],
"properties": {
"device": {
"type": "string"
},
"path": {
"type": "string"
},
"old_text": {
"type": "string"
},
"new_text": {
"type": "string"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Edited"
},
"403": {
"description": "Security policy denied"
},
"409": {
"description": "Device offline or edit conflict"
},
"413": {
"description": "Edit block too large"
}
}
}
},
"/api/v1/process/session": {
"post": {
"summary": "Manage long-running process sessions",
"security": [
{
"bearerAuth": []
}
],
"parameters": [
{
"name": "action",
"in": "query",
"required": true,
"schema": {
"type": "string",
"enum": [
"start",
"read",
"input",
"stop",
"list"
]
}
}
],
"responses": {
"200": {
"description": "Session operation result"
}
}
}
},
"/api/v1/file/list": {
"post": {
"summary": "List directory",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Successful result"
}
}
}
},
"/api/v1/file/info": {
"post": {
"summary": "Get file metadata",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Successful result"
}
}
}
},
"/api/v1/file/search": {
"post": {
"summary": "Search files",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Successful result"
}
}
}
}
}
}
+6 -1
View File
@@ -42,7 +42,12 @@ func (m Middleware) Handler(next http.Handler) http.Handler {
h := r.Header.Get("Authorization")
if strings.HasPrefix(h, "Bearer ") {
got = strings.TrimSpace(strings.TrimPrefix(h, "Bearer "))
} else if q := r.URL.Query().Get("token"); q != "" {
got = q
} else if q := r.URL.Query().Get("key"); q != "" {
got = q
} else if q := r.URL.Query().Get("api_key"); q != "" {
got = q
}
expected := m.Token
+25 -202
View File
@@ -2,18 +2,17 @@ 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"
)
@@ -28,26 +27,22 @@ type Manager struct {
}
type Node struct {
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
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
}
type Result struct {
@@ -101,31 +96,16 @@ 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)
@@ -148,7 +128,6 @@ 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())
@@ -218,18 +197,6 @@ 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)
@@ -300,9 +267,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 {
@@ -318,25 +285,6 @@ 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]
@@ -344,12 +292,6 @@ 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)
@@ -372,90 +314,6 @@ 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 {
@@ -477,41 +335,6 @@ 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 {
-52
View File
@@ -1,52 +0,0 @@
package cluster
import (
"context"
"sync"
"testing"
"time"
)
func TestNodeOperationGateAllowsReadsAndSerializesWrites(t *testing.T) {
n := &Node{}
release1 := n.acquireOperation("file_read")
done := make(chan struct{})
go func() { release2 := n.acquireOperation("edit_block"); close(done); release2() }()
select {
case <-done:
t.Fatal("write entered while read active")
case <-time.After(50 * time.Millisecond):
}
release1()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("write did not proceed")
}
}
func TestNodeOperationGateAllowsConcurrentReads(t *testing.T) {
n := &Node{}
var wg sync.WaitGroup
wg.Add(2)
for i := 0; i < 2; i++ {
go func() {
defer wg.Done()
release := n.acquireOperation("file_read")
time.Sleep(30 * time.Millisecond)
release()
}()
}
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("reads did not complete")
}
}
func TestOperationRiskUnknownFailsClosed(t *testing.T) {
if operationRisk("unknown-danger") != 3 {
t.Fatal("unknown action must be high risk")
}
_ = context.Background()
}
+15 -96
View File
@@ -3,25 +3,21 @@ 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"`
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"`
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"`
}
type ExecPayload struct {
@@ -40,80 +36,3 @@ 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"`
}
-130
View File
@@ -1,130 +0,0 @@
package executor
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
)
var ErrBlockNotUnique = errors.New("old_text must occur exactly once")
var ErrEditConflict = errors.New("file changed during edit")
var editMu sync.Mutex
type EditBlockResult struct {
BytesWritten int64
}
func EditBlock(path, oldText, newText string, expectedHash ...string) (EditBlockResult, error) {
editMu.Lock()
defer editMu.Unlock()
if path == "" {
return EditBlockResult{}, errors.New("path required")
}
if oldText == "" {
return EditBlockResult{}, errors.New("old_text required")
}
data, err := os.ReadFile(path)
if err != nil {
return EditBlockResult{}, err
}
initial := sha256.Sum256(data)
initialHash := hex.EncodeToString(initial[:])
if len(expectedHash) > 0 && expectedHash[0] != "" && expectedHash[0] != initialHash {
return EditBlockResult{}, ErrEditConflict
}
src := string(data)
count := countNonOverlapping(src, oldText)
if count != 1 {
if count == 0 {
return EditBlockResult{}, fmt.Errorf("%w: found 0 matches", ErrBlockNotUnique)
}
return EditBlockResult{}, fmt.Errorf("%w: found %d matches", ErrBlockNotUnique, count)
}
updated := replaceOnce(src, oldText, newText)
info, err := os.Stat(path)
if err != nil {
return EditBlockResult{}, err
}
// Re-read immediately before commit. This is the CAS check against
// writers outside this process; editMu serializes our own writers.
latest, err := os.ReadFile(path)
if err != nil {
return EditBlockResult{}, err
}
latestSum := sha256.Sum256(latest)
if hex.EncodeToString(latestSum[:]) != initialHash {
return EditBlockResult{}, ErrEditConflict
}
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".vps-commander-edit-*")
if err != nil {
return EditBlockResult{}, err
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if err := tmp.Chmod(info.Mode().Perm()); err != nil {
_ = tmp.Close()
return EditBlockResult{}, err
}
if _, err := tmp.WriteString(updated); err != nil {
_ = tmp.Close()
return EditBlockResult{}, err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return EditBlockResult{}, err
}
if err := tmp.Close(); err != nil {
return EditBlockResult{}, err
}
if err := os.Rename(tmpName, path); err != nil {
return EditBlockResult{}, err
}
if d, err := os.Open(dir); err == nil {
_ = d.Sync()
_ = d.Close()
}
return EditBlockResult{BytesWritten: int64(len(updated))}, nil
}
func countNonOverlapping(src, old string) int {
n, count := 0, 0
for {
i := indexString(src[n:], old)
if i < 0 {
return count
}
count++
n += i + len(old)
if n > len(src) {
return count
}
}
}
func replaceOnce(src, old, new string) string {
i := indexString(src, old)
return src[:i] + new + src[i+len(old):]
}
func indexString(src, needle string) int {
if len(needle) == 0 {
return 0
}
for i := 0; i+len(needle) <= len(src); i++ {
if src[i:i+len(needle)] == needle {
return i
}
}
return -1
}
-52
View File
@@ -1,52 +0,0 @@
package executor
import (
"errors"
"os"
"path/filepath"
"testing"
)
func TestEditBlockUniqueAndAtomic(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.txt")
if err := os.WriteFile(path, []byte("a\nTARGET\nz\n"), 0640); err != nil {
t.Fatal(err)
}
res, err := EditBlock(path, "TARGET", "CHANGED")
if err != nil {
t.Fatal(err)
}
if res.BytesWritten != int64(len("a\nCHANGED\nz\n")) {
t.Fatalf("bytes=%d", res.BytesWritten)
}
got, _ := os.ReadFile(path)
if string(got) != "a\nCHANGED\nz\n" {
t.Fatalf("content=%q", got)
}
info, _ := os.Stat(path)
if info.Mode().Perm() != 0640 {
t.Fatalf("mode=%o, want 0640", info.Mode().Perm())
}
}
func TestEditBlockRejectsZeroAndMultipleMatchesWithoutMutation(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.txt")
original := "TARGET\nother\nTARGET\n"
if err := os.WriteFile(path, []byte(original), 0644); err != nil {
t.Fatal(err)
}
_, err := EditBlock(path, "MISSING", "x")
if !errors.Is(err, ErrBlockNotUnique) {
t.Fatalf("missing match error=%v", err)
}
_, err = EditBlock(path, "TARGET", "x")
if !errors.Is(err, ErrBlockNotUnique) {
t.Fatalf("multiple match error=%v", err)
}
got, _ := os.ReadFile(path)
if string(got) != original {
t.Fatalf("file mutated after rejected edit: %q", got)
}
}
-217
View File
@@ -1,217 +0,0 @@
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
-36
View File
@@ -1,36 +0,0 @@
package executor
import (
"context"
"os"
"path/filepath"
"testing"
)
func TestListDirectoryAndInfo(t *testing.T) {
d := t.TempDir()
if err := os.WriteFile(filepath.Join(d, "a.txt"), []byte("hello"), 0600); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(d, "sub"), 0700); err != nil {
t.Fatal(err)
}
es, err := ListDirectory(d, false, 10)
if err != nil || len(es) != 2 {
t.Fatalf("entries=%v err=%v", es, err)
}
fi, err := GetFileInfo(filepath.Join(d, "a.txt"))
if err != nil || fi.Size != 5 {
t.Fatalf("info=%+v err=%v", fi, err)
}
}
func TestSearchHonorsContextAndLimit(t *testing.T) {
d := t.TempDir()
for i := 0; i < 3; i++ {
_ = os.WriteFile(filepath.Join(d, string(rune('a'+i))+".txt"), []byte("needle\n"), 0600)
}
r, err := Search(context.Background(), d, "needle", 2)
if err != nil || len(r) != 2 {
t.Fatalf("results=%v err=%v", r, err)
}
}
-248
View File
@@ -1,248 +0,0 @@
package executor
import (
"context"
"errors"
"io"
"os/exec"
"sync"
"syscall"
"time"
)
const (
DefaultRingCapacity = 2 * 1024 * 1024
MaxSessionInput = 64 * 1024
DefaultSessionTTL = 30 * time.Minute
SessionIdleTTL = 10 * time.Minute
MaxSessionBufferTotal = 8 * 1024 * 1024
)
type RingBuffer struct {
mu sync.RWMutex
buf []byte
head int
size int
}
func NewRingBuffer(capacity int) *RingBuffer {
if capacity <= 0 {
capacity = DefaultRingCapacity
}
return &RingBuffer{buf: make([]byte, capacity)}
}
func (r *RingBuffer) Write(p []byte) (int, error) {
total := len(p)
r.mu.Lock()
defer r.mu.Unlock()
for len(p) > 0 {
n := len(p)
if n > len(r.buf) {
p = p[n-len(r.buf):]
n = len(p)
}
first := len(r.buf) - r.head
if first > n {
first = n
}
copy(r.buf[r.head:r.head+first], p[:first])
if first < n {
copy(r.buf[:n-first], p[first:n])
}
r.head = (r.head + n) % len(r.buf)
r.size += n
if r.size > len(r.buf) {
r.size = len(r.buf)
}
p = p[n:]
}
return total, nil
}
func (r *RingBuffer) Snapshot() []byte {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]byte, r.size)
start := (r.head - r.size + len(r.buf)) % len(r.buf)
first := len(r.buf) - start
if first > r.size {
first = r.size
}
copy(out, r.buf[start:start+first])
if first < r.size {
copy(out[first:], r.buf[:r.size-first])
}
return out
}
type Session struct {
ID string
Command string
Owner string
StartedAt time.Time
ExpiresAt time.Time
LastAccess time.Time
cmd *exec.Cmd
stdin io.WriteCloser
stdout *RingBuffer
stderr *RingBuffer
done chan struct{}
mu sync.RWMutex
err error
exitCode int
}
func StartSession(ctx context.Context, id, command, workdir, owner string, capacity int) (*Session, error) {
if id == "" || command == "" {
return nil, errors.New("session id and command required")
}
cmd := exec.Command("/bin/sh", "-c", command)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if workdir != "" {
cmd.Dir = workdir
}
stdout := NewRingBuffer(capacity)
stderr := NewRingBuffer(capacity)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
out, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
errOut, err := cmd.StderrPipe()
if err != nil {
return nil, err
}
now := time.Now()
s := &Session{
ID: id, Command: command, Owner: owner, StartedAt: now, LastAccess: now, ExpiresAt: now.Add(DefaultSessionTTL), cmd: cmd,
stdin: stdin, stdout: stdout, stderr: stderr, done: make(chan struct{}), exitCode: -1,
}
if err := cmd.Start(); err != nil {
_ = stdin.Close()
return nil, err
}
go s.copy(out, stdout)
go s.copy(errOut, stderr)
go s.wait()
go func() {
select {
case <-ctx.Done():
s.Terminate()
case <-s.done:
}
}()
return s, nil
}
func (s *Session) copy(src io.Reader, dst io.Writer) {
_, _ = io.Copy(dst, src)
}
func (s *Session) wait() {
err := s.cmd.Wait()
s.mu.Lock()
s.err = err
if err == nil {
s.exitCode = 0
} else if ee, ok := err.(*exec.ExitError); ok {
s.exitCode = ee.ExitCode()
} else {
s.exitCode = 1
}
s.mu.Unlock()
close(s.done)
}
func (s *Session) Done() <-chan struct{} { return s.done }
func (s *Session) Status() (int, bool, error) {
select {
case <-s.done:
s.mu.RLock()
defer s.mu.RUnlock()
return s.exitCode, true, s.err
default:
return -1, false, nil
}
}
func (s *Session) Output() (stdout, stderr string) {
stdout, stderr = string(s.stdout.Snapshot()), string(s.stderr.Snapshot())
s.mu.Lock()
if time.Now().Before(s.ExpiresAt) {
s.LastAccess = time.Now()
}
s.mu.Unlock()
return stdout, stderr
}
func (s *Session) Stdin(data []byte) error {
if len(data) > MaxSessionInput {
return errors.New("session input too large")
}
s.mu.RLock()
stdin := s.stdin
s.mu.RUnlock()
if stdin == nil {
return errors.New("session stdin unavailable")
}
_, err := stdin.Write(data)
if err == nil {
s.mu.Lock()
s.LastAccess = time.Now()
s.mu.Unlock()
}
return err
}
func (s *Session) Authorized(owner string) bool {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
if owner == "" || s.Owner != owner || now.After(s.ExpiresAt) || now.Sub(s.LastAccess) > SessionIdleTTL {
return false
}
s.LastAccess = now
return true
}
func (s *Session) Expired() bool {
s.mu.RLock()
defer s.mu.RUnlock()
now := time.Now()
return now.After(s.ExpiresAt) || now.Sub(s.LastAccess) > SessionIdleTTL
}
func (s *Session) Terminate() error {
s.mu.RLock()
pid := 0
if s.cmd != nil && s.cmd.Process != nil {
pid = s.cmd.Process.Pid
}
s.mu.RUnlock()
if pid == 0 {
return nil
}
return syscall.Kill(-pid, syscall.SIGTERM)
}
func (s *Session) Kill() error {
s.mu.RLock()
pid := 0
if s.cmd != nil && s.cmd.Process != nil {
pid = s.cmd.Process.Pid
}
s.mu.RUnlock()
if pid == 0 {
return nil
}
return syscall.Kill(-pid, syscall.SIGKILL)
}
// Compile-time sanity for the output writer contract.
var _ io.Writer = (*RingBuffer)(nil)
-104
View File
@@ -1,104 +0,0 @@
package executor
import (
"context"
"errors"
"sync"
"time"
)
const MaxSessionsPerManager = MaxSessionBufferTotal / DefaultRingCapacity
type SessionInfo struct {
ID string `json:"id"`
Command string `json:"command"`
StartedAt time.Time `json:"started_at"`
Running bool `json:"running"`
ExitCode int `json:"exit_code"`
}
type SessionManager struct {
mu sync.RWMutex
sessions map[string]*Session
limit int
}
func NewSessionManager(limit int) *SessionManager {
if limit <= 0 || limit > MaxSessionsPerManager {
limit = MaxSessionsPerManager
}
return &SessionManager{sessions: make(map[string]*Session), limit: limit}
}
func (m *SessionManager) Start(ctx context.Context, id, command, workdir, owner string, capacity int) (*Session, error) {
m.mu.Lock()
if _, ok := m.sessions[id]; ok {
m.mu.Unlock()
return nil, errors.New("session already exists")
}
if len(m.sessions) >= m.limit {
m.mu.Unlock()
return nil, errors.New("session limit reached")
}
s, err := StartSession(ctx, id, command, workdir, owner, capacity)
if err != nil {
m.mu.Unlock()
return nil, err
}
if _, exists := m.sessions[id]; exists {
m.mu.Unlock()
_ = s.Kill()
return nil, errors.New("session already exists")
}
m.sessions[id] = s
m.mu.Unlock()
return s, nil
}
func (m *SessionManager) Get(id string) (*Session, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
s, ok := m.sessions[id]
return s, ok
}
func (m *SessionManager) Remove(id string) bool {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.sessions[id]; !ok {
return false
}
delete(m.sessions, id)
return true
}
func (m *SessionManager) List() []SessionInfo {
m.mu.RLock()
defer m.mu.RUnlock()
out := make([]SessionInfo, 0, len(m.sessions))
for _, s := range m.sessions {
code, done, _ := s.Status()
out = append(out, SessionInfo{ID: s.ID, Command: s.Command, StartedAt: s.StartedAt, Running: !done, ExitCode: code})
}
return out
}
func (m *SessionManager) Stop(id string, force bool) error {
s, ok := m.Get(id)
if !ok {
return errors.New("session not found")
}
if force {
return s.Kill()
}
return s.Terminate()
}
func (m *SessionManager) Cleanup() {
m.mu.Lock()
defer m.mu.Unlock()
for id, s := range m.sessions {
if s.Expired() {
_, done, _ := s.Status()
if !done {
_ = s.Kill()
continue
}
delete(m.sessions, id)
}
}
}
-72
View File
@@ -1,72 +0,0 @@
package executor
import (
"context"
"strings"
"testing"
"time"
)
func TestRingBufferKeepsNewestData(t *testing.T) {
r := NewRingBuffer(8)
_, _ = r.Write([]byte("12345678"))
_, _ = r.Write([]byte("90"))
if got := string(r.Snapshot()); got != "34567890" {
t.Fatalf("got %q", got)
}
}
func TestSessionCapturesAndTerminates(t *testing.T) {
s, err := StartSession(context.Background(), "t1", "printf hello; sleep 5", "", "owner", 1024)
if err != nil {
t.Fatal(err)
}
time.Sleep(100 * time.Millisecond)
out, _ := s.Output()
if !strings.Contains(out, "hello") {
t.Fatalf("out=%q", out)
}
if err := s.Terminate(); err != nil {
t.Fatal(err)
}
select {
case <-s.Done():
case <-time.After(2 * time.Second):
t.Fatal("session did not terminate")
}
}
func TestSessionManagerDuplicateAndLimit(t *testing.T) {
m := NewSessionManager(1)
s, err := m.Start(context.Background(), "a", "sleep 1", "", "owner", 128)
if err != nil {
t.Fatal(err)
}
defer s.Kill()
if _, err := m.Start(context.Background(), "b", "true", "", "owner", 128); err == nil {
t.Fatal("expected limit")
}
if _, err := m.Start(context.Background(), "a", "true", "", "owner", 128); err == nil {
t.Fatal("expected duplicate")
}
}
func TestSessionIdleAuthorizationTouch(t *testing.T) {
s, err := StartSession(context.Background(), "idle-test", "sleep 2", "", "owner", 128)
if err != nil {
t.Fatal(err)
}
defer s.Kill()
s.mu.Lock()
s.LastAccess = time.Now().Add(-(SessionIdleTTL + time.Second))
s.mu.Unlock()
if s.Authorized("owner") {
t.Fatal("idle-expired session must not authorize")
}
s.mu.Lock()
s.LastAccess = time.Now()
s.mu.Unlock()
if !s.Authorized("owner") {
t.Fatal("active session should authorize")
}
}
+1 -97
View File
@@ -74,14 +74,6 @@ 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 {
@@ -89,10 +81,6 @@ 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
@@ -276,16 +264,8 @@ 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",
@@ -389,72 +369,6 @@ 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"`
@@ -480,11 +394,6 @@ 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
@@ -507,11 +416,6 @@ 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
+2 -50
View File
@@ -4,7 +4,6 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
@@ -25,9 +24,6 @@ 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)
@@ -79,8 +75,8 @@ func TestMCPInitializeAndListTools(t *testing.T) {
t.Fatalf("expected map result")
}
tools, ok := resMap["tools"].([]any)
if !ok || len(tools) != 11 {
t.Fatalf("expected 11 tools, got %d", len(tools))
if !ok || len(tools) != 6 {
t.Fatalf("expected 6 tools, got %d", len(tools))
}
}
@@ -114,47 +110,3 @@ 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)
}
}
-98
View File
@@ -1,98 +0,0 @@
package security
import (
"errors"
"sync"
"time"
)
const (
RiskLow = 1
RiskMedium = 2
RiskHigh = 3
)
type DevicePolicy struct {
Mode string
}
type PolicySnapshot struct {
Version uint64
GlobalRisk int
Devices map[string]DevicePolicy
CreatedAt time.Time
}
type Snapshot struct {
mu sync.RWMutex
data PolicySnapshot
}
func New(globalRisk int, devices map[string]DevicePolicy, version uint64) *Snapshot {
if globalRisk < RiskLow || globalRisk > RiskHigh {
globalRisk = RiskMedium
}
cp := make(map[string]DevicePolicy, len(devices))
for k, v := range devices {
cp[k] = v
}
return &Snapshot{data: PolicySnapshot{
Version: version, GlobalRisk: globalRisk, Devices: cp, CreatedAt: time.Now(),
}}
}
func (s *Snapshot) Current() PolicySnapshot {
s.mu.RLock()
defer s.mu.RUnlock()
cp := make(map[string]DevicePolicy, len(s.data.Devices))
for k, v := range s.data.Devices {
cp[k] = v
}
snap := s.data
snap.Devices = cp
return snap
}
func (s *Snapshot) Publish(globalRisk int, devices map[string]DevicePolicy, version uint64) error {
if globalRisk < RiskLow || globalRisk > RiskHigh {
return errors.New("invalid global risk")
}
cp := make(map[string]DevicePolicy, len(devices))
for k, v := range devices {
cp[k] = v
}
s.mu.Lock()
if version < s.data.Version {
s.mu.Unlock()
return errors.New("policy version regression")
}
s.data = PolicySnapshot{
Version: version, GlobalRisk: globalRisk, Devices: cp, CreatedAt: time.Now(),
}
s.mu.Unlock()
return nil
}
func (s *Snapshot) EffectiveRisk(device string) int {
s.mu.RLock()
defer s.mu.RUnlock()
if p, ok := s.data.Devices[device]; ok {
if risk := ModeRisk(p.Mode); risk != 0 {
return risk
}
}
return s.data.GlobalRisk
}
func ModeRisk(mode string) int {
switch mode {
case "low":
return RiskLow
case "medium":
return RiskMedium
case "high":
return RiskHigh
default:
return 0
}
}
-35
View File
@@ -1,35 +0,0 @@
package security
import "testing"
func TestSnapshotEffectiveRiskAndPublish(t *testing.T) {
s := New(RiskMedium, map[string]DevicePolicy{"hwsg": {Mode: "high"}}, 7)
if got := s.EffectiveRisk("hwsg"); got != RiskHigh {
t.Fatalf("hwsg risk=%d, want %d", got, RiskHigh)
}
if got := s.EffectiveRisk("n3450"); got != RiskMedium {
t.Fatalf("default risk=%d, want %d", got, RiskMedium)
}
if err := s.Publish(RiskLow, map[string]DevicePolicy{"n3450": {Mode: "medium"}}, 8); err != nil {
t.Fatal(err)
}
cur := s.Current()
if cur.Version != 8 {
t.Fatalf("version=%d, want 8", cur.Version)
}
if got := s.EffectiveRisk("hwsg"); got != RiskLow {
t.Fatalf("old node risk=%d, want %d", got, RiskLow)
}
if got := s.EffectiveRisk("n3450"); got != RiskMedium {
t.Fatalf("override risk=%d, want %d", got, RiskMedium)
}
}
func TestModeRiskRejectsUnknown(t *testing.T) {
if got := ModeRisk("inherit"); got != 0 {
t.Fatalf("inherit risk=%d, want 0", got)
}
if got := ModeRisk("root"); got != 0 {
t.Fatalf("unknown risk=%d, want 0", got)
}
}
-52
View File
@@ -1,52 +0,0 @@
package security
type ToolSpec struct {
Name string
RequiredRisk int
Category string
ReadOnly bool
}
var registry = map[string]ToolSpec{
"read_file": {Name: "read_file", RequiredRisk: RiskLow, Category: "file", ReadOnly: true},
"read_multiple_files": {Name: "read_multiple_files", RequiredRisk: RiskLow, Category: "file", ReadOnly: true},
"list_directory": {Name: "list_directory", RequiredRisk: RiskLow, Category: "file", ReadOnly: true},
"get_file_info": {Name: "get_file_info", RequiredRisk: RiskLow, Category: "file", ReadOnly: true},
"start_search": {Name: "start_search", RequiredRisk: RiskLow, Category: "search", ReadOnly: true},
"get_more_search_results": {Name: "get_more_search_results", RequiredRisk: RiskLow, Category: "search", ReadOnly: true},
"list_processes": {Name: "list_processes", RequiredRisk: RiskLow, Category: "process", ReadOnly: true},
"list_sessions": {Name: "list_sessions", RequiredRisk: RiskLow, Category: "process", ReadOnly: true},
"read_process_output": {Name: "read_process_output", RequiredRisk: RiskLow, Category: "process", ReadOnly: true},
"edit_block": {Name: "edit_block", RequiredRisk: RiskMedium, Category: "file", ReadOnly: false},
"write_file": {Name: "write_file", RequiredRisk: RiskMedium, Category: "file", ReadOnly: false},
"create_directory": {Name: "create_directory", RequiredRisk: RiskMedium, Category: "file", ReadOnly: false},
"move_file": {Name: "move_file", RequiredRisk: RiskMedium, Category: "file", ReadOnly: false},
"exec_command": {Name: "exec_command", RequiredRisk: RiskHigh, Category: "process", ReadOnly: false},
"start_process": {Name: "start_process", RequiredRisk: RiskHigh, Category: "process", ReadOnly: false},
"interact_with_process": {Name: "interact_with_process", RequiredRisk: RiskHigh, Category: "process", ReadOnly: false},
"kill_process": {Name: "kill_process", RequiredRisk: RiskHigh, Category: "process", ReadOnly: false},
"force_terminate": {Name: "force_terminate", RequiredRisk: RiskHigh, Category: "process", ReadOnly: false},
"mcp_call": {Name: "mcp_call", RequiredRisk: RiskMedium, Category: "mcp", ReadOnly: false},
}
var actionTools = map[string]string{
"read": "read_file", "probe": "read_file", "write": "write_file",
"mcp": "mcp_call", "exec": "exec_command",
}
func Lookup(name string) (ToolSpec, bool) {
v, ok := registry[name]
return v, ok
}
func RequiredRiskForAction(action string) (int, bool) {
name, ok := actionTools[action]
if !ok {
return 0, false
}
spec, ok := registry[name]
if !ok {
return 0, false
}
return spec.RequiredRisk, true
}
+6 -145
View File
@@ -1,15 +1,12 @@
package storage
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
_ "modernc.org/sqlite"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
@@ -48,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 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);")
"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
}
@@ -279,20 +276,9 @@ 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, redactAudit(command), code, ms, redactAudit(msg))
time.Now().Unix(), ip, device, action, command, code, ms, msg)
return err
}
@@ -401,59 +387,6 @@ 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)
@@ -471,18 +404,8 @@ func (s *Store) SetGlobalSecurityMode(mode string) error {
if mode == "" || mode == "inherit" {
return errors.New("invalid global security mode")
}
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()
_, 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) {
@@ -506,18 +429,8 @@ func (s *Store) SetNodeSecurityMode(node, mode string) error {
if mode == "" {
return errors.New("invalid security mode")
}
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()
_, 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 {
@@ -541,55 +454,3 @@ 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
}
-35
View File
@@ -58,38 +58,3 @@ func TestAuditConcurrent100NoLoss(t *testing.T) {
t.Fatalf("busy_timeout=%d, want >=5000", busy)
}
}
func TestDeviceCredentialHashAndRotation(t *testing.T) {
s, err := Open(filepath.Join(t.TempDir(), "commander.db"))
if err != nil {
t.Fatal(err)
}
defer s.DB.Close()
if err := s.SetDeviceCredential("node-1", "secret-one"); err != nil {
t.Fatal(err)
}
configured, err := s.HasDeviceCredential("node-1")
if err != nil || !configured {
t.Fatalf("credential should be configured: %v", err)
}
valid, err := s.VerifyDeviceCredential("node-1", "secret-one")
if err != nil || !valid {
t.Fatalf("valid credential rejected: %v", err)
}
valid, err = s.VerifyDeviceCredential("node-1", "wrong")
if err != nil || valid {
t.Fatal("wrong credential accepted")
}
if err := s.SetDeviceCredential("node-1", "secret-two"); err != nil {
t.Fatal(err)
}
valid, err = s.VerifyDeviceCredential("node-1", "secret-one")
if err != nil || valid {
t.Fatal("old credential accepted after rotation")
}
valid, err = s.VerifyDeviceCredential("node-1", "secret-two")
if err != nil || !valid {
t.Fatal("rotated credential rejected")
}
}
+49 -731
View File
@@ -1,731 +1,49 @@
/**
* Apple Design System - VPS Commander Console (app.css)
* Grounded in Apple HIG (macOS / iPadOS) & Liquid Glass Design Specs
*/
:root {
color-scheme: dark;
/* macOS Dark Semantic Colors */
--bg-body: #000000;
--bg-card: #16181d;
--bg-sub: #1f232b;
--bg-input: #121418;
--border: rgba(255, 255, 255, 0.08);
--border-strong: rgba(255, 255, 255, 0.14);
--border-subtle: rgba(255, 255, 255, 0.04);
--text-main: #f5f5f7;
--text-muted: #86868b;
--text-status: #a1a1a6;
--pre-text: #e5e5ea;
--thead: #86868b;
--accent: #0a84ff;
--accent-subtle: rgba(10, 132, 255, 0.15);
--accent-hover: #0071e3;
--dot-online: #30d158;
--dot-offline: #ff453a;
--dot-warn: #ffd60a;
--shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
--shadow-hover: 0 8px 30px rgba(0, 0, 0, 0.5);
--font-system: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-mono: "SF Mono", ui-monospace, Menlo, Monaco, Consolas, monospace;
--card: var(--bg-card);
--line: var(--border);
--radius-lg: 16px;
--radius-md: 10px;
--radius-sm: 6px;
--ease-apple: cubic-bezier(0.16, 1, 0.3, 1);
}
:root[data-theme="light"] {
color-scheme: light;
/* macOS Light Semantic Colors */
--bg-body: #f5f5f7;
--bg-card: #ffffff;
--bg-sub: #f0f0f4;
--bg-input: #ffffff;
--border: rgba(0, 0, 0, 0.08);
--border-strong: rgba(0, 0, 0, 0.14);
--border-subtle: rgba(0, 0, 0, 0.04);
--text-main: #1d1d1f;
--text-muted: #86868b;
--text-status: #6e6e73;
--pre-text: #1d1d1f;
--thead: #86868b;
--accent: #0071e3;
--accent-subtle: rgba(0, 113, 227, 0.12);
--accent-hover: #0077ed;
--dot-online: #34c759;
--dot-offline: #ff3b30;
--dot-warn: #ff9500;
--shadow: 0 2px 14px rgba(0, 0, 0, 0.06);
--shadow-hover: 0 6px 20px rgba(0, 0, 0, 0.12);
--card: var(--bg-card);
--line: var(--border);
}
* {
box-sizing: border-box;
}
html, body {
min-height: 100%;
}
body {
margin: 0;
background: var(--bg-body);
color: var(--text-main);
font: 13px/1.5 var(--font-system);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ==========================================================================
macOS Style Header (Liquid Glass Floating Topbar)
========================================================================== */
header {
min-height: 56px;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid var(--border);
background: rgba(22, 24, 29, 0.75);
backdrop-filter: blur(24px) saturate(190%);
-webkit-backdrop-filter: blur(24px) saturate(190%);
position: sticky;
top: 0;
z-index: 100;
transition: all 0.2s var(--ease-apple);
}
:root[data-theme="light"] header {
background: rgba(255, 255, 255, 0.8);
}
.brand-wrap {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.brand-logo {
width: 32px;
height: 32px;
border-radius: 8px;
object-fit: cover;
flex: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
header strong {
font-size: 16px;
font-weight: 600;
letter-spacing: -0.015em;
color: var(--text-main);
}
header span {
margin-left: 10px;
color: var(--text-muted);
font-size: 12px;
font-weight: 400;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
/* ==========================================================================
Main Grid Layout & Content Cards
========================================================================== */
main {
max-width: 1400px;
margin: auto;
padding: 24px;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.wide {
grid-column: 1 / -1;
}
.card {
min-width: 0;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 20px 22px;
box-shadow: var(--shadow);
transition: border-color 0.2s var(--ease-apple), box-shadow 0.2s var(--ease-apple);
}
.cardhead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
margin-bottom: 16px;
}
.cardhead > h2 {
min-width: 0;
margin: 0;
}
h2 {
margin: 0 0 16px;
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--text-main);
}
/* ==========================================================================
Controls: Buttons, Inputs, Selects
========================================================================== */
button, select, input, textarea {
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--bg-sub);
color: var(--text-main);
padding: 7px 12px;
font-family: var(--font-system);
font-size: 13px;
max-width: 100%;
transition: all 0.15s ease-out;
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-weight: 500;
user-select: none;
}
button:hover {
background: var(--bg-card);
border-color: var(--accent);
}
button:active {
transform: scale(0.98);
}
button:focus-visible, select:focus-visible, input:focus-visible, textarea:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* Primary Action Buttons */
#run, #mcpSave, #webhookSave, #saveGlobalSecurity {
background: var(--accent);
color: #ffffff;
border-color: transparent;
font-weight: 600;
}
#run:hover, #mcpSave:hover, #webhookSave:hover, #saveGlobalSecurity:hover {
background: var(--accent-hover);
border-color: transparent;
}
/* Secondary & Action Buttons */
#logout {
background: transparent;
border-color: var(--border);
color: var(--text-muted);
}
#logout:hover {
color: var(--dot-offline);
border-color: rgba(255, 69, 58, 0.4);
background: rgba(255, 69, 58, 0.1);
}
/* Icon Button (Theme Toggle) */
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border-radius: 8px;
background: var(--bg-sub);
border: 1px solid var(--border);
color: var(--text-main);
cursor: pointer;
}
.btn-icon:hover {
border-color: var(--border-strong);
background: var(--bg-card);
}
.theme-svg {
display: none;
width: 16px;
height: 16px;
}
:root[data-theme="dark"] .svg-sun {
display: block;
color: #ffd60a;
}
:root[data-theme="light"] .svg-moon {
display: block;
color: #1d1d1f;
}
/* Console & Code Blocks */
textarea {
width: 100%;
height: 110px;
resize: vertical;
font-family: var(--font-mono);
font-size: 12px;
margin: 10px 0;
background: var(--bg-input);
border-radius: 8px;
}
pre {
white-space: pre-wrap;
overflow: auto;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px;
color: var(--pre-text);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.5;
font-variant-numeric: tabular-nums;
}
/* ==========================================================================
Devices Grid & Cards (Content Layer)
========================================================================== */
.devices {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
}
.device-card {
min-width: 0;
padding: 16px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--bg-sub);
display: flex;
flex-direction: column;
gap: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition: transform 0.2s var(--ease-apple), border-color 0.2s var(--ease-apple);
}
.device-card:hover {
transform: translateY(-1.5px);
border-color: var(--border-strong);
}
.v1-head {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.v1-row1 {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.v1-title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.v1-name {
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--text-main);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}
.v1-row2 {
font-size: 11px;
color: var(--text-muted);
padding-left: 15px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-variant-numeric: tabular-nums;
}
.dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--dot-online);
vertical-align: middle;
flex: none;
}
.offline .dot {
background: var(--dot-offline);
}
.btn-revoke {
padding: 2px 8px;
font-size: 11px;
border-radius: 5px;
background: transparent;
border: 1px solid var(--border);
color: var(--dot-offline);
cursor: pointer;
flex-shrink: 0;
}
.btn-revoke:hover {
background: rgba(255, 69, 58, 0.14);
border-color: var(--dot-offline);
}
/* Node Profile Grid (Compact 2-Column Metrics) */
.profile-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 12px;
padding: 10px 12px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 12px;
}
.p-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 6px;
min-height: 20px;
}
.p-label {
color: var(--text-muted);
font-weight: 500;
font-size: 11px;
}
.p-val {
font-family: var(--font-mono);
font-weight: 600;
font-size: 12px;
color: var(--text-main);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.badge-docker-text {
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
}
.badge-docker-text.yes {
background: rgba(48, 209, 88, 0.15);
color: var(--dot-online);
border: 1px solid rgba(48, 209, 88, 0.3);
}
.badge-docker-text.no {
background: rgba(142, 142, 147, 0.15);
color: var(--text-muted);
border: 1px solid var(--border);
}
.device-card.offline {
opacity: 0.65;
filter: saturate(0.5);
}
/* ==========================================================================
MCP Toolbar, Grid & Forms
========================================================================== */
.mcp-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.mcp-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 14px;
}
.mcp-item {
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
background: var(--bg-sub);
}
.mcp-item h3 {
margin: 0 0 6px;
font-size: 14px;
font-weight: 600;
color: var(--text-main);
}
.mcp-meta {
font-size: 12px;
color: var(--text-muted);
line-height: 1.6;
}
.mcp-actions {
display: flex;
gap: 8px;
margin-top: 14px;
}
.mcp-form {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.mcp-form .full {
grid-column: 1 / -1;
}
.mcp-form label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
}
.mcp-form input, .mcp-form select, .mcp-form textarea {
width: 100%;
}
.mcp-form textarea {
min-height: 72px;
font-family: var(--font-mono);
}
.mcp-nodes {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding-top: 4px;
}
.mcp-nodes label {
flex-direction: row;
align-items: center;
gap: 6px;
cursor: pointer;
}
.mcp-status {
font-size: 12px;
margin-top: 8px;
min-height: 18px;
}
.mcp-test-result {
margin-top: 12px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-input);
font-size: 12px;
}
.mcp-test-result.ok {
border-color: rgba(48, 209, 88, 0.4);
}
.mcp-test-result.fail {
border-color: rgba(255, 69, 58, 0.4);
}
/* ==========================================================================
Data Tables (macOS Table Standard)
========================================================================== */
.tablewrap {
width: 100%;
max-width: 100%;
overflow-x: auto;
border-radius: 8px;
border: 1px solid var(--border);
}
table {
width: 100%;
min-width: 760px;
border-collapse: collapse;
}
th, td {
text-align: left;
padding: 10px 14px;
border-bottom: 1px solid var(--border);
font-size: 12px;
white-space: nowrap;
}
th {
background: var(--bg-sub);
color: var(--thead);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
font-size: 11px;
}
td {
color: var(--text-main);
}
tr:last-child td {
border-bottom: none;
}
tr:hover td {
background: rgba(255, 255, 255, 0.02);
}
/* Security Policies */
.security-grid {
display: flex;
gap: 14px;
align-items: flex-end;
flex-wrap: wrap;
margin-bottom: 16px;
}
.security-grid label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
}
.security-grid button {
height: 32px;
}
/* Login Page (Apple Centered Card) */
.login {
display: grid;
place-items: center;
min-height: 100vh;
padding: 20px;
}
.login form {
width: min(360px, 90vw);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 32px 28px;
box-shadow: var(--shadow-hover);
}
.login input, .login button {
width: 100%;
margin-top: 14px;
height: 38px;
}
.login h1 {
margin: 0 0 8px;
font-size: 20px;
font-weight: 700;
letter-spacing: -0.015em;
text-align: center;
}
.login p, #error {
color: var(--text-muted);
text-align: center;
font-size: 13px;
margin-bottom: 12px;
}
#error {
color: var(--dot-offline);
font-weight: 500;
}
/* Responsive */
@media (max-width: 800px) {
.grid {
grid-template-columns: 1fr;
}
.wide {
grid-column: auto;
}
main {
padding: 16px 12px;
}
header {
padding: 0 16px;
}
}
*{box-sizing:border-box}
:root{color-scheme:dark;--bg-body:#0b1020;--bg-card:#111a2d;--bg-sub:#0d1526;--border:#24304a;--border-strong:#34415d;--text-main:#e7ebf3;--text-muted:#7f8ba3;--text-status:#9aabc5;--pre-text:#b9c5da;--thead:#91a0bb;--dot-online:#4ade80;--dot-offline:#f87171;--shadow:0 8px 30px rgba(0,0,0,.2);--card:var(--bg-sub);--line:var(--border)}
:root[data-theme="light"]{color-scheme:light;--bg-body:#f4f6fa;--bg-card:#fff;--bg-sub:#eef2f7;--border:#d1d9e4;--border-strong:#b8c3d3;--text-main:#172033;--text-muted:#647089;--text-status:#58657a;--pre-text:#26344b;--thead:#53627a;--dot-online:#16833d;--dot-offline:#c62828;--shadow:0 8px 26px rgba(28,45,72,.08);--card:var(--bg-sub);--line:var(--border)}
html,body{min-height:100%}body{margin:0;background:var(--bg-body);color:var(--text-main);font:14px/1.5 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}header{min-height:64px;padding:0 24px;display:flex;align-items:center;justify-content:space-between;gap:14px;border-bottom:1px solid var(--border);background:var(--bg-card);position:sticky;top:0;z-index:2}
.brand-wrap{display:flex;align-items:center;gap:12px;min-width:0}
.brand-logo{width:36px;height:36px;border-radius:8px;object-fit:cover;flex:none;box-shadow:0 2px 8px rgba(0,0,0,.15)}
header strong{font-size:18px}header span{margin-left:12px;color:var(--text-muted);font-size:12px}.header-actions{display:flex;align-items:center;gap:8px;flex-shrink:0}main{max-width:1400px;margin:auto;padding:22px}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.wide{grid-column:1/-1}.card{min-width:0;background:var(--bg-card);border:1px solid var(--border);border-radius:14px;padding:18px;box-shadow:var(--shadow)}.cardhead{display:flex;align-items:center;justify-content:space-between;gap:12px;min-width:0}.cardhead>h2{min-width:0}h2{margin:0 0 14px;font-size:16px}button,select,input,textarea{border:1px solid var(--border-strong);border-radius:9px;background:var(--bg-sub);color:var(--text-main);padding:9px 12px;font:inherit;max-width:100%}button{cursor:pointer}button:hover{filter:brightness(1.05)}button:focus-visible,select:focus-visible,input:focus-visible,textarea:focus-visible{outline:2px solid var(--border-strong);outline-offset:2px}textarea{width:100%;height:110px;resize:vertical;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;margin:8px 0}pre{white-space:pre-wrap;overflow:auto;background:var(--bg-sub);border:1px solid var(--border);border-radius:9px;padding:12px;color:var(--pre-text)}.devices{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px}.device{min-width:0;padding:14px;border:1px solid var(--border);border-radius:10px;background:var(--bg-sub)}.device b{display:block;min-width:0;overflow-wrap:anywhere;word-break:break-all}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--dot-online);vertical-align:middle;flex:none;margin-right:7px}.offline .dot{background:var(--dot-offline)}.muted{color:var(--text-muted)}.status{margin-top:3px;font-size:12px;color:var(--text-status);overflow-wrap:anywhere;word-break:break-word}.actions{display:flex;flex-wrap:wrap;gap:8px}.actions button{flex:1 1 150px}#device{min-width:180px}.tablewrap{width:100%;max-width:100%;overflow-x:auto;overscroll-behavior-x:contain}table{width:100%;min-width:760px;border-collapse:collapse}th,td{text-align:left;padding:9px;border-bottom:1px solid var(--border);font-size:12px;white-space:nowrap}th{color:var(--thead)}.login{display:grid;place-items:center;min-height:100vh;padding:20px}.login form{width:min(380px,90vw);background:var(--bg-card);border:1px solid var(--border);border-radius:16px;padding:28px;box-shadow:var(--shadow)}.login input,.login button{width:100%;margin-top:10px}.login h1{margin:0}.login p,#error{color:var(--text-muted)}
@media(max-width:800px){.grid{grid-template-columns:1fr}.wide{grid-column:auto}main{padding:12px}header{padding:0 14px}.header-actions{gap:6px}.card{padding:15px}.cardhead{flex-wrap:wrap}.cardhead>#device{width:100%;min-width:0}.actions button{flex:1 1 140px}}
@media(max-width:680px){header{align-items:flex-start;padding-top:12px;padding-bottom:12px}header span{display:none}.brand-wrap{padding-top:2px}.brand-logo{width:32px;height:32px}.header-actions{flex-direction:column;align-items:stretch;width:112px}.header-actions button{width:100%;padding:8px 9px}.devices{grid-template-columns:1fr}.cardhead{flex-direction:column;align-items:stretch;gap:8px}.cardhead h2{margin-bottom:0}.cardhead button,#device{width:100%;min-width:0}.actions button{flex:1 1 calc(50% - 4px)}}
@media(max-width:450px){main{padding:9px}.card{padding:13px;border-radius:12px}.actions{display:grid;grid-template-columns:1fr 1fr}.actions button{width:100%;min-width:0}.login form{padding:22px}.header-actions{width:104px}}
@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important}}
/* 主题切换极简矢量图标按钮 */
.btn-icon{display:inline-flex;align-items:center;justify-content:center;width:38px;height:38px;padding:0;border-radius:9px;background:var(--bg-sub);border:1px solid var(--border-strong);color:var(--text-main);cursor:pointer;transition:background .2s,border-color .2s}
.btn-icon:hover{filter:brightness(1.1)}
.theme-svg{display:none;width:18px;height:18px}
:root[data-theme="dark"] .svg-sun{display:block;color:#facc15}
:root[data-theme="light"] .svg-moon{display:block;color:#475569}
/* 设备卡片与画像升级 (方案 1: 经典主副标题分行) */
.devices{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}
.device-card{min-width:0;padding:16px;border:1px solid var(--border);border-radius:12px;background:var(--bg-sub);display:flex;flex-direction:column;gap:12px}
.v1-head{display:flex;flex-direction:column;gap:4px;min-width:0}
.v1-row1{display:flex;align-items:center;justify-content:space-between;gap:8px;min-width:0}
.v1-title{display:flex;align-items:center;gap:8px;min-width:0;flex:1}
.v1-name{font-size:16px;font-weight:700;color:var(--text-main);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block}
.v1-row2{font-size:12px;color:var(--text-muted);padding-left:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.btn-revoke{padding:2px 8px;font-size:11px;border-radius:5px;background:transparent;border:1px solid var(--border-strong);color:var(--dot-offline);cursor:pointer;flex-shrink:0}
.btn-revoke:hover{background:rgba(239,68,68,.15);border-color:var(--dot-offline)}
/* 资源画像 2列紧凑栅格 */
.profile-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px 12px;padding:10px 12px;background:var(--bg-card);border:1px solid var(--border);border-radius:8px;font-size:12px}
.p-item{display:flex;justify-content:space-between;align-items:center;gap:6px;min-height:20px}
.p-label{color:var(--text-muted);font-weight:500;font-size:11px}
.p-val{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-weight:600;font-size:12px;color:var(--text-main);white-space:nowrap}
.badge-docker-text{padding:1px 6px;border-radius:4px;font-size:11px;font-weight:700}
.badge-docker-text.yes{background:rgba(34,197,94,.15);color:var(--dot-online);border:1px solid rgba(34,197,94,.3)}
.badge-docker-text.no{background:rgba(148,163,184,.15);color:var(--text-muted);border:1px solid var(--border)}
.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(--border);border-radius:10px;padding:14px;background:var(--bg-sub)}.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(--border);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(--border);border-radius:6px;background:var(--bg-sub);color:inherit}.security-grid button{height:34px}
.notification-form{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:12px 0}.notification-form label{display:flex;flex-direction:column;gap:6px;font-size:12px}.notification-form .full{grid-column:1/-1}.notification-form input,.notification-form select,.notification-form textarea{width:100%;box-sizing:border-box}.notification-form textarea{min-height:72px}@media(max-width:700px){.notification-form{grid-template-columns:1fr}.notification-form .full{grid-column:auto}}
+18
View File
@@ -384,3 +384,21 @@ document.addEventListener("visibilitychange", () => {
}
});
loadWebhooks().catch(console.error);
// 开启 5 秒平滑实时自动轮询
let devicePollTimer = null;
function startDevicePolling() {
if (devicePollTimer) clearInterval(devicePollTimer);
devicePollTimer = setInterval(() => {
if (!document.hidden) {
loadDevices().catch(console.error);
}
}, 5000);
}
startDevicePolling();
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
loadDevices().catch(console.error);
}
});
+166 -275
View File
@@ -1,85 +1,23 @@
/**
* Apple Design System - VPS Probe
* Grounded in Apple HIG (macOS / iOS) & Liquid Glass Design Specs
*/
:root {
/* Dynamic System Colors - Dark (Default) */
--system-bg: #000000;
--secondary-system-bg: #16181d;
--tertiary-system-bg: #21252d;
/* Materials & Overlays */
--liquid-glass-bg: rgba(22, 24, 29, 0.72);
--liquid-glass-border: rgba(255, 255, 255, 0.09);
--hairline-border: rgba(255, 255, 255, 0.08);
--hairline-border-hover: rgba(255, 255, 255, 0.16);
--hairline-highlight: rgba(255, 255, 255, 0.04);
/* Apple Semantic Labels */
--label-primary: #f5f5f7;
--label-secondary: #98989d;
--label-tertiary: #636366;
--label-quaternary: #48484a;
/* Apple System Accents & Status */
--accent: #0a84ff;
--accent-subtle: rgba(10, 132, 255, 0.15);
--system-green: #30d158;
--system-green-subtle: rgba(48, 209, 88, 0.14);
--system-yellow: #ffd60a;
--system-yellow-subtle: rgba(255, 214, 10, 0.14);
--system-red: #ff453a;
--system-red-subtle: rgba(255, 69, 58, 0.14);
--system-purple: #bf5af2;
/* Typography Stacks */
--font-system: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-mono: "SF Mono", ui-monospace, Menlo, Monaco, Consolas, monospace;
/* Radii & Shadows */
--radius-lg: 16px;
--radius-md: 10px;
--bg-main: #0c0e14;
--bg-card: rgba(22, 27, 34, 0.7);
--bg-card-hover: rgba(28, 35, 45, 0.85);
--border: rgba(255, 255, 255, 0.08);
--border-hover: rgba(255, 255, 255, 0.16);
--text-main: #f0f6fc;
--text-muted: #8b949e;
--text-dim: #6e7681;
--accent: #58a6ff;
--online: #3fb950;
--online-bg: rgba(63, 185, 80, 0.12);
--offline: #f85149;
--offline-bg: rgba(248, 81, 73, 0.12);
--warn: #d29922;
--warn-bg: rgba(210, 153, 34, 0.12);
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
--radius-lg: 14px;
--radius-md: 8px;
--radius-sm: 6px;
--shadow-subtle: 0 4px 20px rgba(0, 0, 0, 0.35);
--shadow-hover: 0 8px 30px rgba(0, 0, 0, 0.5);
/* Animation */
--ease-apple: cubic-bezier(0.16, 1, 0.3, 1);
}
/* Light Appearance */
@media (prefers-color-scheme: light) {
:root {
--system-bg: #f5f5f7;
--secondary-system-bg: #ffffff;
--tertiary-system-bg: #e5e5ea;
--liquid-glass-bg: rgba(255, 255, 255, 0.78);
--liquid-glass-border: rgba(0, 0, 0, 0.08);
--hairline-border: rgba(0, 0, 0, 0.07);
--hairline-border-hover: rgba(0, 0, 0, 0.14);
--hairline-highlight: rgba(0, 0, 0, 0.02);
--label-primary: #1d1d1f;
--label-secondary: #86868b;
--label-tertiary: #a1a1a6;
--label-quaternary: #d1d1d6;
--accent: #0071e3;
--accent-subtle: rgba(0, 113, 227, 0.1);
--system-green: #34c759;
--system-green-subtle: rgba(52, 199, 89, 0.12);
--system-yellow: #ff9500;
--system-yellow-subtle: rgba(255, 149, 0, 0.12);
--system-red: #ff3b30;
--system-red-subtle: rgba(255, 59, 48, 0.12);
--shadow-subtle: 0 2px 14px rgba(0, 0, 0, 0.06);
--shadow-hover: 0 6px 20px rgba(0, 0, 0, 0.12);
}
}
* {
@@ -89,44 +27,36 @@
}
body {
background-color: var(--system-bg);
color: var(--label-primary);
font-family: var(--font-system);
font-size: 13px;
line-height: 1.45;
background-color: var(--bg-main);
background-image:
radial-gradient(at 15% 15%, rgba(56, 139, 253, 0.05) 0px, transparent 50%),
radial-gradient(at 85% 85%, rgba(63, 185, 80, 0.04) 0px, transparent 50%);
background-attachment: fixed;
color: var(--text-main);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.layout {
max-width: 1400px;
max-width: 1440px;
margin: 0 auto;
padding: 0 24px 48px;
padding: 24px 20px 40px;
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* ==========================================================================
Header & Liquid Glass Navbar
========================================================================== */
/* 导航栏 */
.navbar {
position: sticky;
top: 0;
z-index: 100;
margin: 0 -24px 28px;
padding: 14px 24px;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
background: var(--liquid-glass-bg);
backdrop-filter: blur(24px) saturate(190%);
-webkit-backdrop-filter: blur(24px) saturate(190%);
border-bottom: 1px solid var(--liquid-glass-border);
transition: all 0.2s var(--ease-apple);
}
.brand {
@@ -136,30 +66,25 @@ body {
}
.logo-dot {
width: 12px;
height: 12px;
background: var(--accent);
border-radius: 3px;
box-shadow: 0 0 10px var(--accent-subtle);
}
.brand-text {
display: flex;
align-items: baseline;
gap: 8px;
width: 14px;
height: 14px;
background: linear-gradient(135deg, #58a6ff, #1f6feb);
border-radius: 4px;
box-shadow: 0 0 12px rgba(88, 166, 255, 0.5);
}
.brand-text h1 {
font-size: 17px;
font-weight: 600;
letter-spacing: -0.015em;
color: var(--label-primary);
font-size: 20px;
font-weight: 700;
letter-spacing: -0.02em;
color: #fff;
display: inline-block;
margin-right: 8px;
}
.brand-text .sub {
font-size: 12px;
color: var(--label-secondary);
font-weight: 400;
font-size: 13px;
color: var(--text-muted);
}
.controls {
@@ -168,181 +93,164 @@ body {
gap: 12px;
}
/* Cluster Summary Pill */
.summary-badge {
display: flex;
align-items: center;
gap: 8px;
background: var(--hairline-highlight);
border: 1px solid var(--hairline-border);
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border);
padding: 5px 12px;
border-radius: 980px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
color: var(--label-secondary);
color: var(--text-muted);
}
.dot-online {
width: 7px;
height: 7px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--system-green);
box-shadow: 0 0 6px var(--system-green);
background: var(--online);
box-shadow: 0 0 8px rgba(63, 185, 80, 0.6);
animation: pulse 2.5s infinite;
}
@keyframes pulse {
0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(63, 185, 80, 0.7); }
70% { transform: scale(1); box-shadow: 0 0 0 6px rgba(63, 185, 80, 0); }
100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(63, 185, 80, 0); }
}
/* macOS-Style Segmented Control */
.view-switch {
display: flex;
background: var(--tertiary-system-bg);
border: 1px solid var(--hairline-border);
border-radius: 8px;
padding: 2px;
background: rgba(22, 27, 34, 0.8);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 3px;
gap: 2px;
}
.view-switch .btn {
display: inline-flex;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 26px;
padding: 0 12px;
border: none;
background: transparent;
color: var(--label-secondary);
font-family: var(--font-system);
border: none;
color: var(--text-muted);
padding: 5px 12px;
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease-out;
user-select: none;
transition: all 0.15s ease;
}
.view-switch .btn:hover {
color: var(--label-primary);
color: var(--text-main);
}
.view-switch .btn.active {
background: var(--secondary-system-bg);
color: var(--label-primary);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 0.5px 1px rgba(0, 0, 0, 0.08);
background: rgba(255, 255, 255, 0.1);
color: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
/* ==========================================================================
Content Grid & Node Card (Content Layer)
========================================================================== */
/* 卡片视图 */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 18px;
grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
gap: 16px;
}
.node-card {
background: var(--secondary-system-bg);
border: 1px solid var(--hairline-border);
background: var(--bg-card);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 18px 20px;
padding: 18px;
display: flex;
flex-direction: column;
gap: 16px;
box-shadow: var(--shadow-subtle);
transition: transform 0.25s var(--ease-apple), border-color 0.25s var(--ease-apple), box-shadow 0.25s var(--ease-apple);
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.node-card:hover {
transform: translateY(-1.5px);
border-color: var(--hairline-border-hover);
box-shadow: var(--shadow-hover);
border-color: var(--border-hover);
background: var(--bg-card-hover);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}
.card-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
}
.node-title {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.node-name-wrap {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.flag-icon {
font-size: 15px;
line-height: 1;
}
.node-name {
font-size: 15px;
font-weight: 600;
font-size: 16px;
font-weight: 700;
color: #fff;
letter-spacing: -0.01em;
color: var(--label-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.local-badge {
font-size: 10px;
font-weight: 600;
padding: 2px 6px;
border-radius: 5px;
background: var(--accent-subtle);
padding: 1px 6px;
border-radius: 4px;
background: rgba(88, 166, 255, 0.15);
color: var(--accent);
border: 1px solid rgba(10, 132, 255, 0.25);
flex-shrink: 0;
border: 1px solid rgba(88, 166, 255, 0.25);
font-weight: 600;
}
.node-meta {
font-size: 11px;
color: var(--label-secondary);
font-size: 12px;
color: var(--text-dim);
display: flex;
align-items: center;
gap: 6px;
font-variant-numeric: tabular-nums;
gap: 8px;
}
.meta-pill {
background: var(--hairline-highlight);
border: 1px solid var(--hairline-border);
padding: 1px 6px;
background: rgba(255, 255, 255, 0.04);
padding: 2px 6px;
border-radius: 4px;
color: var(--label-secondary);
font-weight: 500;
}
/* Status Pill */
.status-badge {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 980px;
display: inline-flex;
padding: 3px 8px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
}
.status-badge.online {
background: var(--system-green-subtle);
color: var(--system-green);
border: 1px solid rgba(48, 209, 88, 0.22);
background: var(--online-bg);
color: var(--online);
border: 1px solid rgba(63, 185, 80, 0.2);
}
.status-badge.offline {
background: var(--system-red-subtle);
color: var(--system-red);
border: 1px solid rgba(255, 69, 58, 0.22);
background: var(--offline-bg);
color: var(--offline);
border: 1px solid rgba(248, 81, 73, 0.2);
}
.status-badge .dot {
@@ -352,9 +260,7 @@ body {
background: currentColor;
}
/* ==========================================================================
Metrics & Progress Bars (San Francisco Metrics)
========================================================================== */
/* 进度条指标区域 */
.metrics-bars {
display: flex;
flex-direction: column;
@@ -364,64 +270,61 @@ body {
.metric-item {
display: flex;
flex-direction: column;
gap: 5px;
gap: 4px;
}
.metric-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
}
.metric-label {
color: var(--label-secondary);
color: var(--text-muted);
font-weight: 500;
}
.metric-value {
color: var(--label-primary);
color: var(--text-main);
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.progress-track {
height: 5px;
background: var(--tertiary-system-bg);
border-radius: 980px;
height: 6px;
background: rgba(255, 255, 255, 0.06);
border-radius: 3px;
overflow: hidden;
position: relative;
}
.progress-fill {
height: 100%;
border-radius: 980px;
transition: width 0.4s var(--ease-apple), background 0.3s ease;
border-radius: 3px;
transition: width 0.4s ease, background 0.3s ease;
}
.progress-fill.normal {
background: var(--system-green);
background: linear-gradient(90deg, #238636, #3fb950);
}
.progress-fill.warn {
background: var(--system-yellow);
background: linear-gradient(90deg, #9e6a03, #d29922);
}
.progress-fill.danger {
background: var(--system-red);
background: linear-gradient(90deg, #da3633, #f85149);
}
/* Secondary Stat Widget (Inset Grouped) */
/* 统计卡片底部小部件 */
.card-stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
background: var(--hairline-highlight);
background: rgba(0, 0, 0, 0.2);
border-radius: var(--radius-md);
padding: 10px 12px;
border: 1px solid var(--hairline-border);
padding: 10px;
border: 1px solid rgba(255, 255, 255, 0.03);
}
.stat-box {
@@ -432,37 +335,34 @@ body {
.stat-title {
font-size: 11px;
font-weight: 500;
color: var(--label-secondary);
color: var(--text-dim);
}
.stat-desc {
font-size: 12px;
color: var(--label-secondary);
color: var(--text-muted);
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stat-desc b {
color: var(--label-primary);
font-weight: 600;
color: var(--text-main);
font-weight: 500;
}
.traffic-in { color: var(--accent) !important; font-weight: 600; }
.traffic-out { color: var(--system-purple) !important; font-weight: 600; }
.traffic-in { color: #58a6ff !important; }
.traffic-out { color: #bc8cff !important; }
/* ==========================================================================
Compact Table View (macOS Table Standard)
========================================================================== */
/* 表格视图 */
.table-wrapper {
background: var(--secondary-system-bg);
border: 1px solid var(--hairline-border);
background: var(--bg-card);
backdrop-filter: blur(12px);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow-x: auto;
box-shadow: var(--shadow-subtle);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.probe-table {
@@ -473,23 +373,20 @@ body {
}
.probe-table th {
background: var(--tertiary-system-bg);
padding: 10px 16px;
font-size: 11px;
background: rgba(0, 0, 0, 0.25);
padding: 12px 16px;
font-size: 12px;
font-weight: 600;
color: var(--label-secondary);
border-bottom: 1px solid var(--hairline-border);
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.probe-table td {
padding: 12px 16px;
border-bottom: 1px solid var(--hairline-border);
font-size: 12px;
padding: 14px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
font-size: 13px;
vertical-align: middle;
color: var(--label-primary);
}
.probe-table tr:last-child td {
@@ -497,12 +394,12 @@ body {
}
.probe-table tr:hover td {
background: var(--hairline-highlight);
background: rgba(255, 255, 255, 0.02);
}
.col-mono {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: 12px;
}
.mini-bar-wrap {
@@ -515,14 +412,14 @@ body {
.mini-bar {
flex: 1;
height: 5px;
background: var(--tertiary-system-bg);
border-radius: 980px;
background: rgba(255, 255, 255, 0.08);
border-radius: 3px;
overflow: hidden;
}
.mini-bar-fill {
height: 100%;
border-radius: 980px;
border-radius: 3px;
}
.table-node {
@@ -531,60 +428,53 @@ body {
}
.table-node .name {
font-size: 13px;
font-weight: 600;
color: var(--label-primary);
color: #fff;
}
.table-node .sub {
font-size: 11px;
color: var(--label-secondary);
color: var(--text-dim);
}
/* ==========================================================================
Footer
========================================================================== */
/* 页脚 */
.footer {
margin-top: auto;
padding-top: 32px;
padding-top: 36px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: var(--label-secondary);
border-top: 1px solid var(--hairline-border);
color: var(--text-dim);
border-top: 1px solid var(--border);
}
.update-time {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
/* Error State */
/* 错误提示 */
.error-msg {
text-align: center;
padding: 40px;
color: var(--system-red);
background: var(--system-red-subtle);
border: 1px dashed rgba(255, 69, 58, 0.3);
color: var(--offline);
background: var(--bg-card);
border: 1px dashed rgba(248, 81, 73, 0.3);
border-radius: var(--radius-lg);
}
/* ==========================================================================
Responsive Adaptations
========================================================================== */
/* 响应式调整 (Mobile) */
@media (max-width: 768px) {
.layout {
padding: 0 16px 36px;
padding: 16px 12px 28px;
}
.navbar {
margin: 0 -16px 20px;
padding: 12px 16px;
margin-bottom: 16px;
}
.brand-text h1 {
font-size: 16px;
font-size: 18px;
}
.controls {
@@ -594,11 +484,12 @@ body {
.card-grid {
grid-template-columns: 1fr;
gap: 14px;
gap: 12px;
}
.node-card {
padding: 16px;
padding: 14px;
gap: 12px;
}
.card-stats-grid {
+2 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>VPS Probe - 节点监控探针</title>
<link rel="stylesheet" href="/static/probe.css?v=20261001">
<link rel="stylesheet" href="/static/probe.css?v=20260929">
</head>
<body>
<div class="layout">
@@ -41,6 +41,6 @@
<span id="lastUpdated" class="update-time">自动刷新中...</span>
</footer>
</div>
<script src="/static/probe.js?v=20261001"></script>
<script src="/static/probe.js?v=20260929"></script>
</body>
</html>