diff --git a/backend/controller/agent_install.go b/backend/controller/agent_install.go index 9d24c37..1a479cb 100644 --- a/backend/controller/agent_install.go +++ b/backend/controller/agent_install.go @@ -6,6 +6,7 @@ import ( "log" "net" "net/http" + "net/url" "strconv" "strings" "time" @@ -142,28 +143,41 @@ func runSSHCommandWithTimeout(client *ssh.Client, cmd string, timeout time.Durat } } -// installServerURL 根据请求构造后端可访问地址,兼容 Nginx 等反向代理 // Logan.Li -func installServerURL(c *gin.Context) string { +// installServerURL 解析 Agent 对外服务地址,优先使用前端提交的浏览器 origin。 // Logan.Li +func installServerURL(c *gin.Context, preferred string) (string, error) { + if strings.TrimSpace(preferred) != "" { + return validateInstallServerURL(preferred) + } scheme := "http" if c.Request.TLS != nil { scheme = "https" } if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" { - scheme = proto + scheme = strings.TrimSpace(strings.Split(proto, ",")[0]) } // 优先使用 X-Forwarded-Host(Nginx 暴露给客户端的真实域名),其次用 Host // Logan.Li host := c.Request.Host if fwd := c.GetHeader("X-Forwarded-Host"); fwd != "" { - host = fwd + host = strings.TrimSpace(strings.Split(fwd, ",")[0]) } - // 去掉 scheme 的默认端口,避免生成 http://example.com:80 这类无效地址 // Logan.Li - if scheme == "http" && strings.HasSuffix(host, ":80") { - host = strings.TrimSuffix(host, ":80") + return validateInstallServerURL(fmt.Sprintf("%s://%s", scheme, host)) +} + +// validateInstallServerURL 只允许无路径的 HTTP(S) origin,防止命令参数注入。 // Logan.Li +func validateInstallServerURL(rawURL string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" { + return "", fmt.Errorf("Agent 服务地址必须是有效的 HTTP(S) 地址") } - if scheme == "https" && strings.HasSuffix(host, ":443") { - host = strings.TrimSuffix(host, ":443") + if parsed.User != nil || (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", fmt.Errorf("Agent 服务地址只能填写域名 origin,不允许包含路径、认证信息或查询参数") } - return fmt.Sprintf("%s://%s", scheme, host) + return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host), nil +} + +// shellQuote 对 SSH 远程命令参数做单引号转义。 // Logan.Li +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" } // detectAgentVersion SSH 到目标主机读取已安装 Agent 的真实版本号 // Logan.Li @@ -187,11 +201,12 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) var req struct { - Username string `json:"username"` - Password string `json:"password"` - SSHKey string `json:"ssh_key"` - AuthType string `json:"auth_type"` - Token string `json:"token"` + Username string `json:"username"` + Password string `json:"password"` + SSHKey string `json:"ssh_key"` + AuthType string `json:"auth_type"` + Token string `json:"token"` + ServerURL string `json:"server_url"` } // 兼容旧调用方式:token 在 query,新方式在 body c.ShouldBindJSON(&req) @@ -273,7 +288,11 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) { } // 3. 构造安装命令(使用 Agent API 服务地址) - serverURL := installServerURL(c) + serverURL, urlErr := installServerURL(c, req.ServerURL) + if urlErr != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: urlErr.Error()}) + return + } token := req.Token if token == "" { token = c.Query("token") @@ -282,8 +301,8 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) { // 4. 构造安装命令:root 用户直接 bash,非 root 用 sudo -n(无密码才执行,避免挂死)// Logan.Li installCmd := fmt.Sprintf( `if [ "$(id -u)" -eq 0 ]; then curl -fsSL %s/agent/install.sh | bash -s -- --server %s --token %s; else curl -fsSL %s/agent/install.sh | sudo -n bash -s -- --server %s --token %s; fi`, - serverURL, serverURL, token, - serverURL, serverURL, token, + shellQuote(serverURL), shellQuote(serverURL), shellQuote(token), + shellQuote(serverURL), shellQuote(serverURL), shellQuote(token), ) // 5. 执行安装 @@ -367,8 +386,8 @@ func (h *AgentInstallHandler) installOne(server model.Server, serverURL, token s installCmd := fmt.Sprintf( `if [ "$(id -u)" -eq 0 ]; then curl -fsSL %s/agent/install.sh | bash -s -- --server %s --token %s; else curl -fsSL %s/agent/install.sh | sudo -n bash -s -- --server %s --token %s; fi`, - serverURL, serverURL, token, - serverURL, serverURL, token, + shellQuote(serverURL), shellQuote(serverURL), shellQuote(token), + shellQuote(serverURL), shellQuote(serverURL), shellQuote(token), ) installOut, err := runSSHCommand(client, installCmd) @@ -400,6 +419,7 @@ func (h *AgentInstallHandler) installOne(server model.Server, serverURL, token s func (h *AgentInstallHandler) BatchInstallAgent(c *gin.Context) { var req struct { ServerIDs []uint `json:"server_ids"` + ServerURL string `json:"server_url"` Credentials map[string]struct { Username string `json:"username"` Password string `json:"password"` @@ -412,7 +432,11 @@ func (h *AgentInstallHandler) BatchInstallAgent(c *gin.Context) { return } - serverURL := installServerURL(c) + serverURL, urlErr := installServerURL(c, req.ServerURL) + if urlErr != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: urlErr.Error()}) + return + } token := c.Query("token") ac := newAuditCtx(c) // 提前快照上下文,goroutine 内不可再用 c // Logan.Li @@ -592,13 +616,18 @@ func (h *AgentInstallHandler) BatchUninstallAgent(c *gin.Context) { func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) { var req struct { ServerIDs []uint `json:"server_ids"` + ServerURL string `json:"server_url"` } if err := c.ShouldBindJSON(&req); err != nil || len(req.ServerIDs) == 0 { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请提供要升级的服务器 ID 列表"}) return } - serverURL := installServerURL(c) + serverURL, urlErr := installServerURL(c, req.ServerURL) + if urlErr != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: urlErr.Error()}) + return + } ac := newAuditCtx(c) // 提前快照上下文,goroutine 内不可再用 c // Logan.Li // 异步执行:立即返回,后台逐台升级 @@ -634,8 +663,8 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) { // 下载新二进制 + 获取版本号 + 重启服务 upgradeScript := fmt.Sprintf( - `curl -fsSL -o /tmp/aiops-agent %s/api/agent/bin/%s && chmod +x /tmp/aiops-agent && systemctl stop aiops-agent 2>/dev/null; mv /tmp/aiops-agent /usr/local/bin/aiops-agent && systemctl start aiops-agent; ver=$(/usr/local/bin/aiops-agent version 2>/dev/null | awk '/AIOps Agent/{print $3}'); echo "UPGRADE_OK ver=$ver"`, - serverURL, binaryFile, + `curl -fsSL -o /tmp/aiops-agent %s && chmod +x /tmp/aiops-agent && systemctl stop aiops-agent 2>/dev/null; mv /tmp/aiops-agent /usr/local/bin/aiops-agent && systemctl start aiops-agent; ver=$(/usr/local/bin/aiops-agent version 2>/dev/null | awk '/AIOps Agent/{print $3}'); echo "UPGRADE_OK ver=$ver"`, + shellQuote(serverURL+"/api/agent/bin/"+binaryFile), ) out, err := runSSHCommand(client, upgradeScript) diff --git a/backend/controller/agent_install_test.go b/backend/controller/agent_install_test.go new file mode 100644 index 0000000..c75eb3b --- /dev/null +++ b/backend/controller/agent_install_test.go @@ -0,0 +1,38 @@ +package controller + +import "testing" + +// TestValidateInstallServerURL 验证 Agent 公网地址仅接受安全的 HTTP(S) origin。 // Logan.Li +func TestValidateInstallServerURL(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + {name: "HTTP 域名", input: "http://aiops.example.com", want: "http://aiops.example.com"}, + {name: "HTTPS 域名", input: "https://aiops.example.com/", want: "https://aiops.example.com"}, + {name: "自定义端口", input: "https://aiops.example.com:8443", want: "https://aiops.example.com:8443"}, + {name: "拒绝路径", input: "https://aiops.example.com/api", wantErr: true}, + {name: "拒绝查询参数", input: "https://aiops.example.com?x=1", wantErr: true}, + {name: "拒绝非 HTTP 协议", input: "file:///tmp/agent", wantErr: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := validateInstallServerURL(test.input) + if test.wantErr { + if err == nil { + t.Fatalf("期望校验失败,实际返回 %q", got) + } + return + } + if err != nil { + t.Fatalf("地址校验失败: %v", err) + } + if got != test.want { + t.Fatalf("期望 %q,实际 %q", test.want, got) + } + }) + } +} diff --git a/backend/router/agent_static.go b/backend/router/agent_static.go index c146bc9..3196427 100644 --- a/backend/router/agent_static.go +++ b/backend/router/agent_static.go @@ -113,6 +113,10 @@ func requestBaseURL(c *gin.Context) string { scheme = proto } host := c.Request.Host + if forwardedHost := c.GetHeader("X-Forwarded-Host"); forwardedHost != "" { + // 多级代理可能传逗号列表,首项是客户端最初访问的域名。 // Logan.Li + host = strings.TrimSpace(strings.Split(forwardedHost, ",")[0]) + } if host == "" { host = "localhost:8080" } diff --git a/frontend/src/api/server/server.ts b/frontend/src/api/server/server.ts index 5d3c9fe..512a77f 100644 --- a/frontend/src/api/server/server.ts +++ b/frontend/src/api/server/server.ts @@ -39,12 +39,16 @@ export const serverApi = { return http.get('/dashboard/metrics'); }, installAgent: async (id: number | string, token: string, creds?: { username?: string; password?: string; ssh_key?: string; auth_type?: string }) => { - const body: Record = { token }; + // 浏览器当前 origin 是用户真实访问域名,避免反向代理把上游内网地址传给 Agent。 // Logan.Li + const serverUrl = typeof window !== 'undefined' ? window.location.origin : ''; + const body: Record = { token, server_url: serverUrl }; if (creds) Object.assign(body, creds); return http.post<{ installed: boolean; arch: string; output: string }>(`/servers/${id}/install-agent`, body); }, batchInstallAgent: async (ids: number[], token: string, credentials?: Record) => { - const body: Record = { server_ids: ids }; + // 批量异步任务同样显式携带公网 origin,后台 goroutine 不再依赖代理头推断。 // Logan.Li + const serverUrl = typeof window !== 'undefined' ? window.location.origin : ''; + const body: Record = { server_ids: ids, server_url: serverUrl }; if (credentials) body.credentials = credentials; return http.post<{ total: number; success: number; results: Record[] }>(`/servers/install-agent?token=${encodeURIComponent(token)}`, body); }, diff --git a/run.sh b/run.sh index c65eac9..ff340a8 100755 --- a/run.sh +++ b/run.sh @@ -572,6 +572,8 @@ kubernetes_update_manifest_image() { build_standalone_binary() { local version_tag="${1:-}" ensure_go_path || return 1 + # 独立部署依赖 agent/bin 下载资源,主程序构建时一并生成。 // Logan.Li + build_agent_binaries || return 1 build_frontend_embed ensure_binary_config clean_go_artifacts @@ -853,6 +855,21 @@ cmd_docker() { # ============================================================ # 编译独立二进制 # ============================================================ +build_agent_binaries() { + cd "$SCRIPT_DIR/agent" + mkdir -p bin + + info "编译 Agent Linux AMD64..." + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-w -s" -o bin/aiops-agent-linux-amd64 . + info "编译 Agent Linux ARM64..." + GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-w -s" -o bin/aiops-agent-linux-arm64 . + info "编译 Agent macOS ARM64..." + GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-w -s" -o bin/aiops-agent-darwin-arm64 . + info "编译 Agent Windows AMD64..." + GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-w -s" -o bin/aiops-agent-windows-amd64.exe . + success "Agent 多平台二进制编译完成: agent/bin/" +} + cmd_build() { local target=${1:-binary} @@ -881,30 +898,7 @@ cmd_build() { ;; agent) banner "========== 编译 Agent ==========" - cd "$SCRIPT_DIR/agent" - mkdir -p bin - - info "编译 Linux AMD64..." - GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-w -s" -o bin/aiops-agent-linux-amd64 . - success "bin/aiops-agent-linux-amd64 ($(du -h bin/aiops-agent-linux-amd64 | cut -f1))" - - info "编译 Linux ARM64..." - GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-w -s" -o bin/aiops-agent-linux-arm64 . - success "bin/aiops-agent-linux-arm64 ($(du -h bin/aiops-agent-linux-arm64 | cut -f1))" - - info "编译 macOS ARM64..." - GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-w -s" -o bin/aiops-agent-darwin-arm64 . - success "bin/aiops-agent-darwin-arm64 ($(du -h bin/aiops-agent-darwin-arm64 | cut -f1))" - - info "编译 Windows AMD64..." - GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-w -s" -o bin/aiops-agent-windows-amd64.exe . - success "bin/aiops-agent-windows-amd64.exe ($(du -h bin/aiops-agent-windows-amd64.exe | cut -f1))" - - if [ "$OS" = "macos" ]; then - info "编译 macOS (当前平台)..." - CGO_ENABLED=0 go build -ldflags="-w -s" -o bin/aiops-agent . - success "bin/aiops-agent ($(du -h bin/aiops-agent | cut -f1))" - fi + build_agent_binaries ;; frontend) info "构建前端生产包..."