fix: 使用访问域名安装 Agent 并发布下载文件
- 前端提交当前访问 origin,避免反向代理内网地址泄漏 - 校验并安全转义 Agent 服务地址 - 主程序构建时同步生成多平台 Agent 二进制
This commit is contained in:
1 parent
0b785da8ae
commit
7553ef54de
5 files changed
+119
-50
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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<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);
|
||||
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 }>) => {
|
||||
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;
|
||||
return http.post<{ total: number; success: number; results: Record<string, unknown>[] }>(`/servers/install-agent?token=${encodeURIComponent(token)}`, body);
|
||||
},
|
||||
|
||||
@@ -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 "构建前端生产包..."
|
||||
|
||||
Reference in new issue
Block a user