Actions 向导
当前 Hub 的 OpenAPI Schema:
/openapi.json
快速控制台
安全
| 节点 | 单机策略 | 有效策略 | 操作 |
|---|
快速控制台
设备接入
Token 只在这里明文显示;Hub 仅保存 Token 哈希。
+安全
| 节点 | 单机策略 | 有效策略 | 操作 |
|---|
diff --git a/README.md b/README.md index b18c8e0..ccf39b4 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ Agent 只主动连接 Hub,不需要公网入站端口。 ### 一键安装 ```bash -curl -fsSL https://raw.githubusercontent.com/kyaring/vps-commander/main/deploy/install.sh | sudo bash +curl -fsSL https://gitea.king.nyc.mn/openclaw/vps-commander/raw/branch/main/deploy/install.sh | sudo bash ``` 安装脚本自动识别 `amd64/arm64` 并进入 Hub / Agent 安装流程。 @@ -190,7 +190,7 @@ curl -fsSL https://raw.githubusercontent.com/kyaring/vps-commander/main/deploy/i ### 一键升级 Agent ```bash -curl -fsSL https://raw.githubusercontent.com/kyaring/vps-commander/main/deploy/upgrade-agent.sh | sudo bash +curl -fsSL https://gitea.king.nyc.mn/openclaw/vps-commander/raw/branch/main/deploy/upgrade-agent.sh | sudo bash ``` 升级脚本下载目标 Release、校验文件大小、备份旧版本并在启动失败时自动回滚。 diff --git a/cmd/hub/main.go b/cmd/hub/main.go index e6a9ef6..24f5d30 100644 --- a/cmd/hub/main.go +++ b/cmd/hub/main.go @@ -38,6 +38,7 @@ func main() { clusterSecret := os.Getenv("VPS_COMMANDER_CLUSTER_SECRET") webPassword := os.Getenv("VPS_COMMANDER_WEB_PASSWORD") adminToken := os.Getenv("VPS_COMMANDER_ADMIN_TOKEN") + installCommand := os.Getenv("VPS_COMMANDER_INSTALL_COMMAND") if adminToken == "" { adminToken = webPassword } @@ -61,16 +62,17 @@ 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), - LocalSessions: executor.NewSessionManager(0), + Auth: authManager, + Exec: executor.Local{MaxOutput: 1024 * 1024}, + Cluster: cluster.NewManager(clusterSecret, store), + Store: store, + LocalName: local, + Notify: notificationManager, + AdminToken: adminToken, + InstallCommand: installCommand, + ExecLimiter: make(chan struct{}, 32), + SearchLimiter: make(chan struct{}, 8), + LocalSessions: executor.NewSessionManager(0), } if err := s.LoadSecurityPolicySnapshot(); err != nil { log.Fatalf("load security policy snapshot: %v", err) diff --git a/deploy/install.sh b/deploy/install.sh index e27e27f..c2ded75 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -4,11 +4,13 @@ set -e # VPS-Commander 一键安装与配置脚本 (Hub / Agent) # Repo: https://gitea.king.nyc.mn/openclaw/vps-commander -REPO="kyaring/vps-commander" +REPO="openclaw/vps-commander" INSTALL_DIR="/opt/vps-commander" CONF_DIR="/etc/vps-commander" -GITEA_URL="https://gitea.king.nyc.mn" +RELEASE_BASE_URL="https://gitea.king.nyc.mn" API_URL="https://gitea.king.nyc.mn/api/v1" +RAW_INSTALL_URL="https://gitea.king.nyc.mn/openclaw/vps-commander/raw/branch/main/deploy/install.sh" +INSTALL_COMMAND="curl -fsSL ${RAW_INSTALL_URL} | bash" # 颜色输出 RED='\033[0;31m' @@ -60,7 +62,7 @@ download_verified() { error "下载结果为空: ${asset_name}" fi if command -v sha256sum >/dev/null 2>&1; then - checksum=$(curl -fsSL --proto '=https' --proto-redir '=https' --retry 3 --connect-timeout 15 --max-time 60 "${GITEA_URL}/${REPO}/releases/download/${LATEST_TAG}/checksums.txt") || error "无法获取 Release 校验文件" + checksum=$(curl -fsSL --proto '=https' --proto-redir '=https' --retry 3 --connect-timeout 15 --max-time 60 "${RELEASE_BASE_URL}/${REPO}/releases/download/${LATEST_TAG}/checksums.txt") || error "无法获取 Release 校验文件" expected=$(printf '%s\n' "$checksum" | awk -v f="$asset_name" '$2 == f {print $1; exit}') [ -n "$expected" ] || error "Release 校验文件中缺少 ${asset_name}" actual=$(sha256sum "$tmp" | awk '{print $1}') @@ -85,7 +87,7 @@ read_tty() { fi printf -v "$__var" '%s' "$value" else - error "检测不到可用 TTY。当前命令可能通过管道运行;请使用带终端的 shell 执行,例如: curl -fsSL https://gitea.king.nyc.mn/openclaw/vps-commander/raw/branch/main/deploy/install.sh | bash" + error "检测不到可用 TTY。当前命令可能通过管道运行;请使用带终端的 shell 执行,例如: curl -fsSL ${RAW_INSTALL_URL} | bash" fi } @@ -100,7 +102,7 @@ select_mode() { install_agent() { info "开始安装 VPS-Commander Agent (${TARGET_ARCH})..." BIN_NAME="vps-commander-agent-linux-${TARGET_ARCH}" - DOWNLOAD_URL="${GITEA_URL}/${REPO}/releases/download/${LATEST_TAG}/${BIN_NAME}" + DOWNLOAD_URL="${RELEASE_BASE_URL}/${REPO}/releases/download/${LATEST_TAG}/${BIN_NAME}" download_verified "${DOWNLOAD_URL}" "${INSTALL_DIR}/vps-commander-agent" "${BIN_NAME}" chmod +x "${INSTALL_DIR}/vps-commander-agent" @@ -164,7 +166,7 @@ SVC install_hub() { info "开始安装 VPS-Commander Hub (${TARGET_ARCH})..." BIN_NAME="vps-commander-hub-linux-${TARGET_ARCH}" - DOWNLOAD_URL="${GITEA_URL}/${REPO}/releases/download/${LATEST_TAG}/${BIN_NAME}" + DOWNLOAD_URL="${RELEASE_BASE_URL}/${REPO}/releases/download/${LATEST_TAG}/${BIN_NAME}" download_verified "${DOWNLOAD_URL}" "${INSTALL_DIR}/vps-commander-hub" "${BIN_NAME}" chmod +x "${INSTALL_DIR}/vps-commander-hub" @@ -185,6 +187,7 @@ install_hub() { cat << ENV > "${CONF_DIR}/hub.env" VPS_COMMANDER_API_KEY=${API_KEY} VPS_COMMANDER_WEB_PASSWORD=${WEB_PASS} +VPS_COMMANDER_INSTALL_COMMAND="${INSTALL_COMMAND}" ENV chmod 0600 "${CONF_DIR}/hub.env" diff --git a/deploy/upgrade-agent.sh b/deploy/upgrade-agent.sh index d0ae5de..69846ba 100755 --- a/deploy/upgrade-agent.sh +++ b/deploy/upgrade-agent.sh @@ -4,9 +4,9 @@ set -euo pipefail # VPS-Commander Agent 一键安全升级脚本 # Repo: https://gitea.king.nyc.mn/openclaw/vps-commander -REPO="kyaring/vps-commander" +REPO="openclaw/vps-commander" INSTALL_DIR="/opt/vps-commander" -GITEA_URL="https://gitea.king.nyc.mn" +RELEASE_BASE_URL="https://gitea.king.nyc.mn" API_URL="https://gitea.king.nyc.mn/api/v1" RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' @@ -35,8 +35,8 @@ LATEST_TAG=$(curl -fsSL --proto '=https' --proto-redir '=https' --retry 3 --conn info "目标升级版本: ${LATEST_TAG} (${TARGET_ARCH})" BIN_NAME="vps-commander-agent-linux-${TARGET_ARCH}" -DOWNLOAD_URL="${GITEA_URL}/${REPO}/releases/download/${LATEST_TAG}/${BIN_NAME}" -CHECKSUM_URL="${GITEA_URL}/${REPO}/releases/download/${LATEST_TAG}/checksums.txt" +DOWNLOAD_URL="${RELEASE_BASE_URL}/${REPO}/releases/download/${LATEST_TAG}/${BIN_NAME}" +CHECKSUM_URL="${RELEASE_BASE_URL}/${REPO}/releases/download/${LATEST_TAG}/checksums.txt" TMP_BIN=$(mktemp "${INSTALL_DIR}/vps-commander-agent.tmp.XXXXXX") || error "无法创建临时文件" TMP_SUM=$(mktemp "${INSTALL_DIR}/vps-commander-checksums.tmp.XXXXXX") || { rm -f "$TMP_BIN"; error "无法创建校验文件"; } BAK_BIN="${INSTALL_DIR}/vps-commander-agent.bak" diff --git a/docs/MCP_INTEGRATION.md b/docs/MCP_INTEGRATION.md index 2733d21..91f0a20 100644 --- a/docs/MCP_INTEGRATION.md +++ b/docs/MCP_INTEGRATION.md @@ -176,7 +176,7 @@ API Key 不应提交到 Git 仓库。 仓库已经提供统一安装脚本,支持 Linux `amd64` 与 `arm64`,安装时自动识别架构并从 GitHub Release 获取对应二进制。 ```bash -curl -fsSL https://raw.githubusercontent.com/kyaring/vps-commander/main/deploy/install.sh | sudo bash +curl -fsSL https://gitea.king.nyc.mn/openclaw/vps-commander/raw/branch/main/deploy/install.sh | sudo bash ``` 脚本会进入交互式安装流程,可选择 Hub 或 Agent。生产环境使用时,Agent 应使用独立设备凭据,不应重新启用全局 Shared Secret。 @@ -184,7 +184,7 @@ curl -fsSL https://raw.githubusercontent.com/kyaring/vps-commander/main/deploy/i ### 7.2 一键升级 Agent ```bash -curl -fsSL https://raw.githubusercontent.com/kyaring/vps-commander/main/deploy/upgrade-agent.sh | sudo bash +curl -fsSL https://gitea.king.nyc.mn/openclaw/vps-commander/raw/branch/main/deploy/upgrade-agent.sh | sudo bash ``` 升级采用临时文件下载 → 基础完整性检查 → 旧二进制备份 → 原子替换 → systemd 重启 → 健康检查;启动失败时自动回滚旧版本。 diff --git a/internal/api/api.go b/internal/api/api.go index 3e25db2..b677cb9 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -39,17 +39,18 @@ 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{} - LocalSessions *executor.SessionManager + Auth *auth.Manager + Exec executor.Local + Cluster *cluster.Manager + Store *storage.Store + LocalName string + Notify *notify.Manager + Policy *security.Snapshot + AdminToken string + InstallCommand string + ExecLimiter chan struct{} + SearchLimiter chan struct{} + LocalSessions *executor.SessionManager } const ( @@ -254,8 +255,17 @@ func (s *Server) ProvisionDeviceCredentialJSON(w http.ResponseWriter, r *http.Re 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) + if strings.ContainsAny(q.Device, "/\\?&=#%") { + http.Error(w, "invalid device name", 400) + return + } + configured, err := s.Store.HasDeviceCredential(q.Device) + if err != nil { + http.Error(w, "credential lookup failed", 500) + return + } + if configured { + http.Error(w, "device credential already exists; revoke/rotate it before provisioning again", 409) return } b := make([]byte, 32) @@ -269,7 +279,7 @@ func (s *Server) ProvisionDeviceCredentialJSON(w http.ResponseWriter, r *http.Re 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}) + jsonOut(w, map[string]any{"ok": true, "device": q.Device, "token": token, "install_command": s.InstallCommand}) } func (s *Server) UpdateSecuritySettingsJSON(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 8911de2..0496a3c 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -167,3 +167,44 @@ func TestEditBlockSecurityAndReplacement(t *testing.T) { t.Fatalf("low mode status=%d body=%s", w.Code, w.Body.String()) } } + +func TestProvisionDeviceCredentialForNewOfflineDevice(t *testing.T) { + store, err := storage.Open(filepath.Join(t.TempDir(), "commander.db")) + if err != nil { + t.Fatal(err) + } + defer store.DB.Close() + + s := &Server{ + Store: store, + LocalName: "wjyhk", + AdminToken: "admin-test-token", + } + + req := httptest.NewRequest(http.MethodPost, "/api/v1/security/device-credentials/provision", strings.NewReader("{\"device\":\"new-node-01\"}")) + req.Header.Set("X-Admin-Token", "admin-test-token") + w := httptest.NewRecorder() + s.ProvisionDeviceCredentialJSON(w, req) + if w.Code != http.StatusOK { + t.Fatalf("provision status=%d body=%s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "\"token\"") { + t.Fatalf("missing token: %s", w.Body.String()) + } + + ok, err := store.HasDeviceCredential("new-node-01") + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("credential was not persisted") + } + + req = httptest.NewRequest(http.MethodPost, "/api/v1/security/device-credentials/provision", strings.NewReader("{\"device\":\"new-node-01\"}")) + req.Header.Set("X-Admin-Token", "admin-test-token") + w = httptest.NewRecorder() + s.ProvisionDeviceCredentialJSON(w, req) + if w.Code != http.StatusConflict { + t.Fatalf("duplicate provision status=%d body=%s", w.Code, w.Body.String()) + } +} diff --git a/internal/web/panel.go b/internal/web/panel.go index c2818fd..cf82a38 100644 --- a/internal/web/panel.go +++ b/internal/web/panel.go @@ -54,6 +54,7 @@ func (p *Panel) Handler() http.Handler { mux.HandleFunc("/panel/api/mcp/test", p.protected(p.testMCP)) mux.HandleFunc("/panel/api/security/settings", p.protected(p.securitySettings)) mux.HandleFunc("/panel/api/security/settings/update", p.protected(p.updateSecuritySettings)) + mux.HandleFunc("/panel/api/security/device-credentials/provision", p.protected(p.provisionDeviceCredential)) mux.HandleFunc("/panel/api/notifications/webhooks", p.protected(p.webhookTargets)) mux.HandleFunc("/panel/api/notifications/webhook/update", p.protected(p.updateWebhookTarget)) mux.HandleFunc("/panel/api/notifications/webhook/delete", p.protected(p.deleteWebhookTarget)) @@ -65,6 +66,7 @@ func (p *Panel) Handler() http.Handler { type securityPanelRunner interface { SecuritySettingsJSON(http.ResponseWriter, *http.Request) UpdateSecuritySettingsJSON(http.ResponseWriter, *http.Request) + ProvisionDeviceCredentialJSON(http.ResponseWriter, *http.Request) } type mcpPanelRunner interface { @@ -121,6 +123,17 @@ func (p *Panel) updateSecuritySettings(w http.ResponseWriter, r *http.Request) { http.Error(w, "security API unavailable", 501) } +func (p *Panel) provisionDeviceCredential(w http.ResponseWriter, r *http.Request) { + if p.AdminToken != "" { + r.Header.Set("X-Admin-Token", p.AdminToken) + } + if v, ok := p.Runner.(securityPanelRunner); ok { + v.ProvisionDeviceCredentialJSON(w, r) + return + } + http.Error(w, "security API unavailable", 501) +} + func (p *Panel) testMCP(w http.ResponseWriter, r *http.Request) { if v, ok := p.Runner.(mcpPanelRunner); ok { v.TestMCPJSON(w, r) diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 62b6571..d7304e0 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -213,6 +213,25 @@ $("#mcpTest").onclick=async()=>{ $("#mcpTransport").onchange=()=>{const stdio=$("#mcpTransport").value==="stdio";$("#mcpCommand").parentElement.style.display=stdio?"":"none";$("#mcpArgs").parentElement.style.display=stdio?"":"none";$("#mcpUrl").parentElement.style.display=stdio?"none":""}; loadMCPServices().catch(console.error); +async function provisionDeviceCredential(){ + const result=$("#deviceProvisionResult"); + const status=$("#deviceProvisionStatus"); + const name=($("#provisionDeviceName").value||"").trim(); + if(!name){status.textContent="请输入节点名称。";return} + try{ + const x=await api("/security/device-credentials/provision",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({device:name})}); + $("#deviceProvisionToken").value=x.token||""; + $("#deviceProvisionCommand").value=x.install_command||""; + result.hidden=false; + status.textContent=name+" 接入凭证已生成。安装时把 Token 填入 Agent Token。"; + }catch(e){status.textContent="生成失败: "+e.message} +} +$("#deviceProvisionNew").onclick=()=>{$("#deviceProvisionEditor").hidden=false;$("#deviceProvisionResult").hidden=true;$("#deviceProvisionStatus").textContent="";$("#provisionDeviceName").focus()}; +$("#deviceProvisionCancel").onclick=()=>{$("#deviceProvisionEditor").hidden=true}; +$("#deviceProvisionCreate").onclick=()=>provisionDeviceCredential(); +$("#deviceProvisionToken").onclick=e=>e.target.select(); +$("#deviceProvisionCommand").onclick=e=>e.target.select(); + async function loadSecuritySettings(){const x=await api("/security/settings"),nodes=x.nodes||[],effective=x.effective||{},configured=new Map(nodes.map(n=>[n.node,n.mode])),devices=await api("/devices");$("#globalSecurity").value=x.global||"medium";$("#securityNodes").innerHTML=devices.map(d=>{const mode=configured.get(d.name)||"inherit",eff=effective[d.name]||x.global||"medium";return `
当前 Hub 的 OpenAPI Schema:
/openapi.json
| 节点 | 单机策略 | 有效策略 | 操作 |
|---|
Token 只在这里明文显示;Hub 仅保存 Token 哈希。
+| 节点 | 单机策略 | 有效策略 | 操作 |
|---|