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"
|
"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")
|
|
||||||
|
// 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") {
|
if parsed.User != nil || (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||||
host = strings.TrimSuffix(host, ":443")
|
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
|
// detectAgentVersion SSH 到目标主机读取已安装 Agent 的真实版本号 // Logan.Li
|
||||||
@@ -187,11 +201,12 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
|
|||||||
id, _ := strconv.Atoi(c.Param("id"))
|
id, _ := strconv.Atoi(c.Param("id"))
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
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)
|
||||||
|
|||||||
@@ -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
|
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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 "构建前端生产包..."
|
||||||
|
|||||||
Reference in new issue
Block a user