fix: 使用访问域名安装 Agent 并发布下载文件

- 前端提交当前访问 origin,避免反向代理内网地址泄漏
- 校验并安全转义 Agent 服务地址
- 主程序构建时同步生成多平台 Agent 二进制
This commit is contained in:
Logan.Li committed 2026-07-25 15:00:02 +08:00
1 parent 0b785da8ae
commit 7553ef54de
5 files changed
+114 -45

No files matched your search

+48 -19
View File
@@ -6,6 +6,7 @@ import (
"log" "log"
"net" "net"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -142,28 +143,41 @@ func runSSHCommandWithTimeout(client *ssh.Client, cmd string, timeout time.Durat
} }
} }
// installServerURL 根据请求构造后端可访问地址,兼容 Nginx 等反向代理 // Logan.Li // installServerURL 解析 Agent 对外服务地址,优先使用前端提交的浏览器 origin。 // Logan.Li
func installServerURL(c *gin.Context) string { func installServerURL(c *gin.Context, preferred string) (string, error) {
if strings.TrimSpace(preferred) != "" {
return validateInstallServerURL(preferred)
}
scheme := "http" scheme := "http"
if c.Request.TLS != nil { if c.Request.TLS != nil {
scheme = "https" scheme = "https"
} }
if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" { if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" {
scheme = proto scheme = strings.TrimSpace(strings.Split(proto, ",")[0])
} }
// 优先使用 X-Forwarded-Host(Nginx 暴露给客户端的真实域名),其次用 Host // Logan.Li // 优先使用 X-Forwarded-Host(Nginx 暴露给客户端的真实域名),其次用 Host // Logan.Li
host := c.Request.Host host := c.Request.Host
if fwd := c.GetHeader("X-Forwarded-Host"); fwd != "" { if fwd := c.GetHeader("X-Forwarded-Host"); fwd != "" {
host = fwd host = strings.TrimSpace(strings.Split(fwd, ",")[0])
} }
// 去掉 scheme 的默认端口,避免生成 http://example.com:80 这类无效地址 // Logan.Li return validateInstallServerURL(fmt.Sprintf("%s://%s", scheme, host))
if scheme == "http" && strings.HasSuffix(host, ":80") {
host = strings.TrimSuffix(host, ":80")
} }
if scheme == "https" && strings.HasSuffix(host, ":443") {
host = strings.TrimSuffix(host, ":443") // 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) 地址")
} }
return fmt.Sprintf("%s://%s", scheme, host) if parsed.User != nil || (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", fmt.Errorf("Agent 服务地址只能填写域名 origin,不允许包含路径、认证信息或查询参数")
}
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 // detectAgentVersion SSH 到目标主机读取已安装 Agent 的真实版本号 // Logan.Li
@@ -192,6 +206,7 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
SSHKey string `json:"ssh_key"` SSHKey string `json:"ssh_key"`
AuthType string `json:"auth_type"` AuthType string `json:"auth_type"`
Token string `json:"token"` Token string `json:"token"`
ServerURL string `json:"server_url"`
} }
// 兼容旧调用方式:token 在 query,新方式在 body // 兼容旧调用方式:token 在 query,新方式在 body
c.ShouldBindJSON(&req) c.ShouldBindJSON(&req)
@@ -273,7 +288,11 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
} }
// 3. 构造安装命令(使用 Agent API 服务地址) // 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 token := req.Token
if token == "" { if token == "" {
token = c.Query("token") token = c.Query("token")
@@ -282,8 +301,8 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
// 4. 构造安装命令:root 用户直接 bash,非 root 用 sudo -n(无密码才执行,避免挂死)// Logan.Li // 4. 构造安装命令:root 用户直接 bash,非 root 用 sudo -n(无密码才执行,避免挂死)// Logan.Li
installCmd := fmt.Sprintf( 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`, `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, shellQuote(serverURL), shellQuote(serverURL), shellQuote(token),
serverURL, serverURL, token, shellQuote(serverURL), shellQuote(serverURL), shellQuote(token),
) )
// 5. 执行安装 // 5. 执行安装
@@ -367,8 +386,8 @@ func (h *AgentInstallHandler) installOne(server model.Server, serverURL, token s
installCmd := fmt.Sprintf( 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`, `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, shellQuote(serverURL), shellQuote(serverURL), shellQuote(token),
serverURL, serverURL, token, shellQuote(serverURL), shellQuote(serverURL), shellQuote(token),
) )
installOut, err := runSSHCommand(client, installCmd) 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) { func (h *AgentInstallHandler) BatchInstallAgent(c *gin.Context) {
var req struct { var req struct {
ServerIDs []uint `json:"server_ids"` ServerIDs []uint `json:"server_ids"`
ServerURL string `json:"server_url"`
Credentials map[string]struct { Credentials map[string]struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
@@ -412,7 +432,11 @@ func (h *AgentInstallHandler) BatchInstallAgent(c *gin.Context) {
return 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") token := c.Query("token")
ac := newAuditCtx(c) // 提前快照上下文,goroutine 内不可再用 c // Logan.Li 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) { func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) {
var req struct { var req struct {
ServerIDs []uint `json:"server_ids"` ServerIDs []uint `json:"server_ids"`
ServerURL string `json:"server_url"`
} }
if err := c.ShouldBindJSON(&req); err != nil || len(req.ServerIDs) == 0 { if err := c.ShouldBindJSON(&req); err != nil || len(req.ServerIDs) == 0 {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请提供要升级的服务器 ID 列表"}) c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请提供要升级的服务器 ID 列表"})
return 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 ac := newAuditCtx(c) // 提前快照上下文,goroutine 内不可再用 c // Logan.Li
// 异步执行:立即返回,后台逐台升级 // 异步执行:立即返回,后台逐台升级
@@ -634,8 +663,8 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) {
// 下载新二进制 + 获取版本号 + 重启服务 // 下载新二进制 + 获取版本号 + 重启服务
upgradeScript := fmt.Sprintf( 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"`, `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"`,
serverURL, binaryFile, shellQuote(serverURL+"/api/agent/bin/"+binaryFile),
) )
out, err := runSSHCommand(client, upgradeScript) out, err := runSSHCommand(client, upgradeScript)
+38
View File
@@ -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)
}
})
}
}
+4
View File
@@ -113,6 +113,10 @@ func requestBaseURL(c *gin.Context) string {
scheme = proto scheme = proto
} }
host := c.Request.Host host := c.Request.Host
if forwardedHost := c.GetHeader("X-Forwarded-Host"); forwardedHost != "" {
// 多级代理可能传逗号列表,首项是客户端最初访问的域名。 // Logan.Li
host = strings.TrimSpace(strings.Split(forwardedHost, ",")[0])
}
if host == "" { if host == "" {
host = "localhost:8080" host = "localhost:8080"
} }
+6 -2
View File
@@ -39,12 +39,16 @@ export const serverApi = {
return http.get('/dashboard/metrics'); return http.get('/dashboard/metrics');
}, },
installAgent: async (id: number | string, token: string, creds?: { username?: string; password?: string; ssh_key?: string; auth_type?: string }) => { installAgent: async (id: number | string, token: string, creds?: { username?: string; password?: string; ssh_key?: string; auth_type?: string }) => {
const body: Record<string, unknown> = { token }; // 浏览器当前 origin 是用户真实访问域名,避免反向代理把上游内网地址传给 Agent。 // Logan.Li
const serverUrl = typeof window !== 'undefined' ? window.location.origin : '';
const body: Record<string, unknown> = { token, server_url: serverUrl };
if (creds) Object.assign(body, creds); if (creds) Object.assign(body, creds);
return http.post<{ installed: boolean; arch: string; output: string }>(`/servers/${id}/install-agent`, body); return http.post<{ installed: boolean; arch: string; output: string }>(`/servers/${id}/install-agent`, body);
}, },
batchInstallAgent: async (ids: number[], token: string, credentials?: Record<number, { username?: string; password?: string; ssh_key?: string; auth_type?: string }>) => { batchInstallAgent: async (ids: number[], token: string, credentials?: Record<number, { username?: string; password?: string; ssh_key?: string; auth_type?: string }>) => {
const body: Record<string, unknown> = { server_ids: ids }; // 批量异步任务同样显式携带公网 origin,后台 goroutine 不再依赖代理头推断。 // Logan.Li
const serverUrl = typeof window !== 'undefined' ? window.location.origin : '';
const body: Record<string, unknown> = { server_ids: ids, server_url: serverUrl };
if (credentials) body.credentials = credentials; if (credentials) body.credentials = credentials;
return http.post<{ total: number; success: number; results: Record<string, unknown>[] }>(`/servers/install-agent?token=${encodeURIComponent(token)}`, body); return http.post<{ total: number; success: number; results: Record<string, unknown>[] }>(`/servers/install-agent?token=${encodeURIComponent(token)}`, body);
}, },
+18 -24
View File
@@ -572,6 +572,8 @@ kubernetes_update_manifest_image() {
build_standalone_binary() { build_standalone_binary() {
local version_tag="${1:-}" local version_tag="${1:-}"
ensure_go_path || return 1 ensure_go_path || return 1
# 独立部署依赖 agent/bin 下载资源,主程序构建时一并生成。 // Logan.Li
build_agent_binaries || return 1
build_frontend_embed build_frontend_embed
ensure_binary_config ensure_binary_config
clean_go_artifacts 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() { cmd_build() {
local target=${1:-binary} local target=${1:-binary}
@@ -881,30 +898,7 @@ cmd_build() {
;; ;;
agent) agent)
banner "========== 编译 Agent ==========" banner "========== 编译 Agent =========="
cd "$SCRIPT_DIR/agent" build_agent_binaries
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
;; ;;
frontend) frontend)
info "构建前端生产包..." info "构建前端生产包..."