fix: make agent install command proxy independent (#50)

This commit is contained in:
Wu Qing
2026-04-25 13:43:30 +08:00
committed by GitHub
parent eff48342c8
commit 1715abfcfb
13 changed files with 232 additions and 35 deletions

View File

@@ -19,6 +19,25 @@ server {
proxy_read_timeout 3600s;
}
# Agent one-click install endpoints.
# Some external reverse proxies strip the /api prefix before reaching this
# container, so /install/ must be proxied here instead of falling through to
# the SPA index.html.
location /install/ {
proxy_pass http://127.0.0.1:8341/install/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
}
location = /health { proxy_pass http://127.0.0.1:8341/health; }
location = /ready { proxy_pass http://127.0.0.1:8341/ready; }
location = /metrics { proxy_pass http://127.0.0.1:8341/metrics; }
# SPA fallback
location / {
try_files $uri $uri/ /index.html;

View File

@@ -34,15 +34,11 @@ In the Web Console → **Node Management** → **Add Node**. You'll see a three-
- **Step 1 — Node info.** Give the node a name, or switch to batch mode and paste multiple names (one per line, max 50).
- **Step 2 — Deploy options.** Pick install mode (`systemd` recommended, `docker`, or `foreground` for debugging), architecture (auto-detect by default), agent version (defaults to the master's version), TTL for the install link (5 min / 15 min / 1 h / 24 h), and download source (`github` direct, or the `ghproxy` mirror for mainland China).
- **Step 3 — Copy the command.** A single `curl ... | sudo sh` line is shown with a live countdown. Click copy, paste into the target machine, and run with root privileges.
- **Step 3 — Copy the command.** A one-line install command is shown with a live countdown. Click copy, paste into the target machine, and run with root privileges. The default command embeds the rendered installer, so the target host does not need to fetch `/api/install/:token` through your reverse proxy. The public install URL is still available as a fallback.
### 2. One-line install on the target host
Example (systemd mode):
```bash
curl -fsSL https://master.example.com/install/Xk3p9...vM | sudo sh
```
Use the command generated by the Web Console. It writes the installer to a temporary file, validates the `BACKUPX_AGENT_INSTALL_V1` marker, then runs it with root privileges.
The script runs automatically and:
@@ -53,6 +49,8 @@ The script runs automatically and:
5. Runs `systemctl enable --now backupx-agent`
6. Polls `/api/v1/agent/self` until the master confirms `status: online` (up to 30 s)
If you choose the URL-based fallback command and `curl` prints HTML or the shell reports `Syntax error: newline unexpected`, the install URL is being served by the web console instead of the backend. Ensure either `/api/install/` or `/install/` is forwarded to the BackupX backend, or use the embedded command generated by the console.
Reruns are idempotent — to upgrade or re-provision, simply generate a new install command and run it again. The one-time install link expires after its TTL or after first consumption, whichever is sooner.
### 3. Rotate agent tokens at any time

View File

@@ -34,15 +34,11 @@ Web 控制台 → **节点管理** → **添加节点**,打开三步向导:
- **第一步 · 节点信息**:填写节点名称;或切换"批量创建"粘贴多行名称(每行一个,最多 50 个)
- **第二步 · 部署参数**:选择安装模式(`systemd` 推荐、`Docker``前台运行` 调试用、架构默认自动检测、Agent 版本(默认跟随 Master 版本、有效期5 分钟 / 15 分钟 / 1 小时 / 24 小时)、下载源(`GitHub` 直连或 `ghproxy` 镜像,国内服务器建议后者)
- **第三步 · 安装命令**:一`curl ... | sudo sh` 命令 + 实时倒计时。点击复制,粘贴到目标机以 root 权限执行
- **第三步 · 安装命令**:一条一键安装命令 + 实时倒计时。点击复制,粘贴到目标机以 root 权限执行。默认命令会嵌入已渲染的安装脚本,目标机无需再通过反向代理访问 `/api/install/:token`;公开安装 URL 仍作为备用路径保留。
### 2. 目标机一条命令完成
示例systemd 模式):
```bash
curl -fsSL https://master.example.com/install/Xk3p9...vM | sudo sh
```
请直接使用 Web 控制台生成的命令。该命令会把安装脚本写入临时文件,校验 `BACKUPX_AGENT_INSTALL_V1` 魔数,再以 root 权限执行。
脚本会自动:
@@ -53,6 +49,8 @@ curl -fsSL https://master.example.com/install/Xk3p9...vM | sudo sh
5. 执行 `systemctl enable --now backupx-agent`
6. 轮询 `/api/v1/agent/self`,直到 Master 确认 `status: online`(最多 30 秒)
如果使用 URL 备用命令时 `curl` 输出 HTML或 shell 报 `Syntax error: newline unexpected`,说明安装 URL 被 Web 控制台接管而不是转发到后端。需要确保 `/api/install/``/install/` 至少一个路径能转发到 BackupX 后端,或改用控制台生成的嵌入式命令。
脚本是幂等的:升级或重装只需重新生成一条安装命令再跑一次。一次性安装链接在 TTL 到期或被首次消费后立即作废。
### 3. 随时轮换 Agent Token

View File

@@ -3,6 +3,7 @@ package http
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -153,6 +154,8 @@ func TestOneClickInstallFlow(t *testing.T) {
Data struct {
InstallToken string `json:"installToken"`
URL string `json:"url"`
FallbackURL string `json:"fallbackUrl"`
ScriptBase64 string `json:"scriptBase64"`
} `json:"data"`
}
if err := json.Unmarshal(genRec.Body.Bytes(), &genResp); err != nil {
@@ -161,6 +164,16 @@ func TestOneClickInstallFlow(t *testing.T) {
if genResp.Data.InstallToken == "" {
t.Fatalf("missing installToken")
}
if !strings.Contains(genResp.Data.FallbackURL, "/install/") {
t.Fatalf("missing fallback install URL, got %q", genResp.Data.FallbackURL)
}
decodedScript, err := base64.StdEncoding.DecodeString(genResp.Data.ScriptBase64)
if err != nil {
t.Fatalf("scriptBase64 should be valid base64: %v", err)
}
if !strings.Contains(string(decodedScript), "BACKUPX_AGENT_INSTALL_V1") {
t.Fatalf("scriptBase64 should contain rendered install script")
}
// 3. 公开端点消费
scriptReq := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil)
@@ -241,6 +254,8 @@ func TestInstallScriptAliasUnderAPI(t *testing.T) {
Data struct {
InstallToken string `json:"installToken"`
URL string `json:"url"`
FallbackURL string `json:"fallbackUrl"`
ScriptBase64 string `json:"scriptBase64"`
} `json:"data"`
}
_ = json.Unmarshal(genRec.Body.Bytes(), &genResp)
@@ -249,6 +264,12 @@ func TestInstallScriptAliasUnderAPI(t *testing.T) {
if !strings.Contains(genResp.Data.URL, "/api/install/") {
t.Errorf("new install URL should use /api/install/ prefix, got %s", genResp.Data.URL)
}
if !strings.Contains(genResp.Data.FallbackURL, "/install/") {
t.Errorf("fallback install URL should use /install/ prefix, got %s", genResp.Data.FallbackURL)
}
if genResp.Data.ScriptBase64 == "" {
t.Errorf("new install response should include scriptBase64 for proxy-independent commands")
}
// 3. /api/install/:token 必须可消费(与 /install/:token 等价)
aliasReq := httptest.NewRequest(http.MethodGet, "/api/install/"+genResp.Data.InstallToken, nil)

View File

@@ -59,16 +59,7 @@ func (h *InstallHandler) Script(c *gin.Context) {
return
}
h.recordConsumeAudit(c, consumed, "script")
script, err := installscript.RenderScript(installscript.Context{
MasterURL: resolveMasterURL(c, h.externalURL),
AgentToken: consumed.Node.Token,
AgentVersion: consumed.Record.AgentVer,
Mode: consumed.Record.Mode,
Arch: consumed.Record.Arch,
DownloadBase: installscript.DownloadBaseFor(consumed.Record.DownloadSrc),
InstallPrefix: "/opt/backupx-agent",
NodeID: consumed.Node.ID,
})
script, err := renderInstallScript(resolveMasterURL(c, h.externalURL), consumed.Node, consumed.Record)
if err != nil {
c.String(stdhttp.StatusInternalServerError, "render error\n")
return
@@ -141,6 +132,19 @@ func (h *InstallHandler) recordConsumeAudit(c *gin.Context, consumed *service.Co
})
}
func renderInstallScript(masterURL string, node *model.Node, record *model.AgentInstallToken) (string, error) {
return installscript.RenderScript(installscript.Context{
MasterURL: masterURL,
AgentToken: node.Token,
AgentVersion: record.AgentVer,
Mode: record.Mode,
Arch: record.Arch,
DownloadBase: installscript.DownloadBaseFor(record.DownloadSrc),
InstallPrefix: "/opt/backupx-agent",
NodeID: node.ID,
})
}
// resolveMasterURL 按优先级推导 Master URL外部配置 > X-Forwarded-* > Request.Host。
// 此为包级 helper供 install_handler 和 node_handler 共用。
func resolveMasterURL(c *gin.Context, externalURL string) string {

View File

@@ -1,6 +1,7 @@
package http
import (
"encoding/base64"
"fmt"
stdhttp "net/http"
"strconv"
@@ -262,16 +263,28 @@ func (h *NodeHandler) CreateInstallToken(c *gin.Context) {
fmt.Sprintf("生成 %s/%s install token TTL=%ds", input.Mode, input.Arch, input.TTLSeconds))
masterURL := resolveMasterURL(c, h.externalURL)
script, err := renderInstallScript(masterURL, out.Node, out.Record)
if err != nil {
response.Error(c, err)
return
}
// 使用 /api/install/... 而非 /install/... —— 让反向代理的 /api/ 转发规则
// 自动接管,避免 SPA fallback 把请求当成前端路由返回 index.htmlissue #46
// 同时返回 /install/... 备用地址,兼容会剥离 /api 前缀的外层反向代理。
// scriptBase64 让前端可以生成不依赖公开下载路径的嵌入式命令,解决 Lucky 等代理
// 把 /api/install/* 也 fallback 到 index.html 的场景。
body := gin.H{
"installToken": out.Token,
"expiresAt": out.ExpiresAt,
"url": masterURL + "/api/install/" + out.Token,
"composeUrl": "",
"installToken": out.Token,
"expiresAt": out.ExpiresAt,
"url": masterURL + "/api/install/" + out.Token,
"fallbackUrl": masterURL + "/install/" + out.Token,
"scriptBase64": base64.StdEncoding.EncodeToString([]byte(script)),
"composeUrl": "",
"fallbackComposeUrl": "",
}
if input.Mode == "docker" {
body["composeUrl"] = masterURL + "/api/install/" + out.Token + "/compose.yml"
body["fallbackComposeUrl"] = masterURL + "/install/" + out.Token + "/compose.yml"
}
response.Success(c, body)
}

View File

@@ -36,3 +36,20 @@ func TestRenderScriptBashBootstrap(t *testing.T) {
t.Errorf("script missing exec bash fallback:\n%s", got)
}
}
func TestRenderScriptCreatesBackupXUserAndGroup(t *testing.T) {
got, err := RenderScript(testCtx)
if err != nil {
t.Fatalf("render err: %v", err)
}
for _, want := range []string{
"getent group backupx",
"groupadd --system backupx",
"useradd --system --gid backupx",
"Group=backupx",
} {
if !strings.Contains(got, want) {
t.Errorf("script missing %q:\n%s", want, got)
}
}
}

View File

@@ -49,7 +49,27 @@ tar xzf "$TMPDIR/pkg.tar.gz" -C "$TMPDIR"
# 4. 安装二进制 + 用户
echo "[2/4] 安装到 ${INSTALL_PREFIX}"
id backupx >/dev/null 2>&1 || useradd --system --home-dir "$INSTALL_PREFIX" --shell /usr/sbin/nologin backupx
if ! getent group backupx >/dev/null 2>&1; then
if command -v groupadd >/dev/null 2>&1; then
groupadd --system backupx
elif command -v addgroup >/dev/null 2>&1; then
addgroup --system backupx
else
echo "需要 groupadd 或 addgroup 来创建 backupx 组" >&2
exit 1
fi
fi
if ! id backupx >/dev/null 2>&1; then
if command -v useradd >/dev/null 2>&1; then
useradd --system --gid backupx --home-dir "$INSTALL_PREFIX" --shell /usr/sbin/nologin backupx
elif command -v adduser >/dev/null 2>&1; then
adduser --system --ingroup backupx --home "$INSTALL_PREFIX" --shell /usr/sbin/nologin backupx
else
echo "需要 useradd 或 adduser 来创建 backupx 用户" >&2
exit 1
fi
fi
id backupx >/dev/null 2>&1 || { echo "backupx 用户创建失败" >&2; exit 1; }
install -d -o backupx -g backupx "$INSTALL_PREFIX" /var/lib/backupx-agent
install -m 0755 "$TMPDIR/backupx-${AGENT_VERSION}-linux-${ARCH}/backupx" "$INSTALL_PREFIX/backupx"
{{end}}
@@ -66,6 +86,7 @@ Wants=network-online.target
[Service]
Type=simple
User=backupx
Group=backupx
Environment="BACKUPX_AGENT_MASTER=${MASTER_URL}"
Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"
ExecStart=${INSTALL_PREFIX}/backupx agent --temp-dir /var/lib/backupx-agent

View File

@@ -6,6 +6,7 @@ import { Step3CommandPreview } from './wizard/Step3CommandPreview'
import { BatchCommandTable, type BatchCommandRow } from './BatchCommandTable'
import { batchCreateNodes, createInstallToken } from '../../services/nodes'
import type { InstallTokenResult } from '../../types/nodes'
import { buildAgentInstallCommand } from './installCommands'
const Step = Steps.Step
@@ -162,7 +163,7 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
const rows: BatchCommandRow[] = tokens.map(({ c, tok }) => ({
nodeId: c.id,
nodeName: c.name,
command: `curl -fsSL ${tok.url} | sudo bash`,
command: buildAgentInstallCommand(tok.url, tok.fallbackUrl, tok.scriptBase64),
expiresAt: tok.expiresAt,
}))
if (mountedRef.current) setBatchRows(rows)

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { buildAgentDownloadCommand, buildAgentInstallCommand } from './installCommands'
describe('install command builders', () => {
it('adds script marker validation and fallback install path', () => {
const cmd = buildAgentInstallCommand('https://master.example.com/api/install/abc')
expect(cmd).toContain('BACKUPX_AGENT_INSTALL_V1')
expect(cmd).toContain("'https://master.example.com/api/install/abc'")
expect(cmd).toContain("'https://master.example.com/install/abc'")
expect(cmd).toContain('sh "$tmp"')
})
it('uses explicit fallback URL when provided', () => {
const cmd = buildAgentDownloadCommand(
'https://master.example.com/api/install/abc',
'https://master.example.com/install/abc',
)
expect(cmd).toContain('/tmp/bx-agent-install.sh')
expect(cmd).toContain("'https://master.example.com/install/abc'")
expect(cmd).toContain('non-script content')
})
it('prefers embedded script content when available', () => {
const cmd = buildAgentInstallCommand(
'https://master.example.com/api/install/abc',
'https://master.example.com/install/abc',
'IyEvYmluL3NoCg==',
)
expect(cmd).toContain('base64 -d')
expect(cmd).toContain('base64 -D')
expect(cmd).toContain("'IyEvYmluL3NoCg=='")
expect(cmd).not.toContain('https://master.example.com/api/install/abc')
})
})

View File

@@ -0,0 +1,67 @@
const INSTALL_MAGIC_MARKER = 'BACKUPX_AGENT_INSTALL_V1'
function shellQuote(value: string) {
return `'${value.replace(/'/g, `'\\''`)}'`
}
function legacyInstallUrl(url: string) {
return url.replace('/api/install/', '/install/')
}
function runScriptCommand(path: string) {
return `if [ "$(id -u)" -eq 0 ]; then sh ${path}; else sudo sh ${path}; fi`
}
export function buildAgentInstallCommand(url: string, fallbackUrl?: string, scriptBase64?: string) {
if (scriptBase64?.trim()) {
const marker = shellQuote(INSTALL_MAGIC_MARKER)
return [
'enc=$(mktemp)',
'tmp=$(mktemp)',
`printf %s ${shellQuote(scriptBase64.trim())} > "$enc"`,
'(base64 -d < "$enc" > "$tmp" 2>/dev/null || base64 -D < "$enc" > "$tmp")',
`{ grep -q ${marker} "$tmp" || { echo 'BackupX embedded installer is invalid.' >&2; head -5 "$tmp" >&2; false; }; }`,
runScriptCommand('"$tmp"'),
].join(' && ') + '; rc=$?; rm -f "$enc" "$tmp"; test $rc -eq 0'
}
const primary = url.trim()
const fallback = (fallbackUrl || legacyInstallUrl(primary)).trim()
const urls = fallback && fallback !== primary ? [primary, fallback] : [primary]
const marker = shellQuote(INSTALL_MAGIC_MARKER)
const fetchScript = urls.length > 1
? `(curl -fsSL ${shellQuote(urls[0])} -o "$tmp" && grep -q ${marker} "$tmp" || curl -fsSL ${shellQuote(urls[1])} -o "$tmp")`
: `(curl -fsSL ${shellQuote(urls[0])} -o "$tmp" && grep -q ${marker} "$tmp")`
return [
'tmp=$(mktemp)',
fetchScript,
`{ grep -q ${marker} "$tmp" || { echo 'BackupX install endpoint returned non-script content; check reverse proxy /api/install or /install forwarding.' >&2; head -5 "$tmp" >&2; false; }; }`,
runScriptCommand('"$tmp"'),
].join(' && ') + '; rc=$?; rm -f "$tmp"; test $rc -eq 0'
}
export function buildAgentDownloadCommand(url: string, fallbackUrl?: string, scriptBase64?: string) {
if (scriptBase64?.trim()) {
const marker = shellQuote(INSTALL_MAGIC_MARKER)
return [
`printf %s ${shellQuote(scriptBase64.trim())} > /tmp/bx-agent-install.b64`,
'(base64 -d < /tmp/bx-agent-install.b64 > /tmp/bx-agent-install.sh 2>/dev/null || base64 -D < /tmp/bx-agent-install.b64 > /tmp/bx-agent-install.sh)',
`{ grep -q ${marker} /tmp/bx-agent-install.sh || { echo 'BackupX embedded installer is invalid.' >&2; head -5 /tmp/bx-agent-install.sh >&2; false; }; }`,
runScriptCommand('/tmp/bx-agent-install.sh'),
].join(' && ')
}
const primary = url.trim()
const fallback = (fallbackUrl || legacyInstallUrl(primary)).trim()
const marker = shellQuote(INSTALL_MAGIC_MARKER)
const fetchScript = fallback && fallback !== primary
? `(curl -fsSL ${shellQuote(primary)} -o /tmp/bx-agent-install.sh && grep -q ${marker} /tmp/bx-agent-install.sh || curl -fsSL ${shellQuote(fallback)} -o /tmp/bx-agent-install.sh)`
: `(curl -fsSL ${shellQuote(primary)} -o /tmp/bx-agent-install.sh && grep -q ${marker} /tmp/bx-agent-install.sh)`
return [
fetchScript,
`{ grep -q ${marker} /tmp/bx-agent-install.sh || { echo 'BackupX install endpoint returned non-script content; check reverse proxy /api/install or /install forwarding.' >&2; head -5 /tmp/bx-agent-install.sh >&2; false; }; }`,
runScriptCommand('/tmp/bx-agent-install.sh'),
].join(' && ')
}

View File

@@ -3,6 +3,7 @@ import { Typography, Button, Space, Collapse, Spin, Message, Tag } from '@arco-d
import { IconCopy, IconRefresh } from '@arco-design/web-react/icon'
import { fetchScriptPreview } from '../../../services/nodes'
import type { InstallTokenResult, InstallMode } from '../../../types/nodes'
import { buildAgentDownloadCommand, buildAgentInstallCommand } from '../installCommands'
const { Text } = Typography
@@ -29,11 +30,8 @@ export function Step3CommandPreview({ nodeId, nodeName, token, mode, previewPara
}, [token.expiresAt])
const expired = remaining === 0
// 使用 bash 管道执行:避开 Debian/Ubuntu 默认 /bin/sh=dash 的差异,
// 同时让反向代理 / CDN 不再按 "sh" 的脚本类型做内容识别issue #46
const command = `curl -fsSL ${token.url} | sudo bash`
// 备用命令:若当前机器无 bash或中间代理过滤了管道响应可先落盘再执行。
const fallbackCommand = `curl -fsSL ${token.url} -o /tmp/bx-agent-install.sh && sudo sh /tmp/bx-agent-install.sh`
const command = buildAgentInstallCommand(token.url, token.fallbackUrl, token.scriptBase64)
const fallbackCommand = buildAgentDownloadCommand(token.url, token.fallbackUrl, token.scriptBase64)
const dockerComposeCmd = mode === 'docker' && token.composeUrl
? `curl -fsSL ${token.composeUrl} -o docker-compose.yml && docker-compose up -d`
: null
@@ -82,7 +80,7 @@ export function Step3CommandPreview({ nodeId, nodeName, token, mode, previewPara
<div style={{ background: 'var(--color-fill-2)', padding: '12px 14px', borderRadius: 6, marginBottom: 12 }}>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
bash /
/tmp
</Text>
<Text style={{
fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all',
@@ -110,7 +108,7 @@ export function Step3CommandPreview({ nodeId, nodeName, token, mode, previewPara
)}
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 8 }}>
token
token TTL
</Text>
<Collapse bordered={false} onChange={(_key, keys) => {

View File

@@ -44,5 +44,8 @@ export interface InstallTokenResult {
installToken: string
expiresAt: string
url: string
fallbackUrl?: string
scriptBase64?: string
composeUrl: string
fallbackComposeUrl?: string
}