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"
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user