mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-08-16 09:54:00 +08:00
feat: 优化集群部署与堡垒机接入 (#106)
支持受限网络、正向代理、私有 CA 与 SSH 堡垒机部署 Agent。 加固 Docker、systemd、Nginx、安装器、Release 校验与可信代理边界,并完善命令队列索引、前端安装向导及中英文运维文档。
This commit is contained in:
491
web/package-lock.json
generated
491
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,14 +11,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@arco-design/web-react": "^2.66.0",
|
||||
"axios": "^1.16.0",
|
||||
"echarts": "^6.0.0",
|
||||
"axios": "^1.19.0",
|
||||
"echarts": "^6.1.0",
|
||||
"echarts-for-react": "^3.0.6",
|
||||
"i18next": "^25.8.14",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^16.5.6",
|
||||
"react-router-dom": "^6.30.0",
|
||||
"react-router-dom": "^6.30.4",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -31,7 +31,7 @@
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"jsdom": "^26.0.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.4.2",
|
||||
"vitest": "^3.0.8"
|
||||
"vite": "^6.4.3",
|
||||
"vitest": "^3.2.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Modal, Steps, Button, Space, Message, Spin } from '@arco-design/web-react'
|
||||
import { Step1NodeName, type Mode } from './wizard/Step1NodeName'
|
||||
import { Step2DeployOptions, type DeployOptions } from './wizard/Step2DeployOptions'
|
||||
import { Step2DeployOptions, isReleaseVersion, type DeployOptions } from './wizard/Step2DeployOptions'
|
||||
import { Step3CommandPreview } from './wizard/Step3CommandPreview'
|
||||
import { BatchCommandTable, type BatchCommandRow } from './BatchCommandTable'
|
||||
import type { InstallTokenResult } from '../../types/nodes'
|
||||
import type { InstallTokenInput, InstallTokenResult } from '../../types/nodes'
|
||||
import { useAgentDeployFlow, type AgentDeployRow } from './useAgentDeployFlow'
|
||||
import { validateAgentConnection } from './wizard/AgentConnectionOptions'
|
||||
|
||||
const Step = Steps.Step
|
||||
|
||||
@@ -29,15 +30,19 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
|
||||
const [deploy, setDeploy] = useState<DeployOptions>({
|
||||
mode: 'systemd',
|
||||
arch: 'auto',
|
||||
agentVersion: masterVersion || '',
|
||||
agentVersion: isReleaseVersion(masterVersion) ? masterVersion || '' : '',
|
||||
downloadSrc: 'github',
|
||||
ttlSeconds: 900,
|
||||
connectionMode: 'direct',
|
||||
agentMasterUrl: '',
|
||||
proxyUrl: '',
|
||||
caCertFile: '',
|
||||
})
|
||||
|
||||
// 当父组件异步拿到 masterVersion 后,同步到 deploy.agentVersion(仅初始为空时)
|
||||
useEffect(() => {
|
||||
if (masterVersion && !deploy.agentVersion) {
|
||||
setDeploy((prev) => ({ ...prev, agentVersion: masterVersion }))
|
||||
if (isReleaseVersion(masterVersion) && !deploy.agentVersion) {
|
||||
setDeploy((prev) => ({ ...prev, agentVersion: masterVersion as string }))
|
||||
}
|
||||
}, [masterVersion]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -98,17 +103,23 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
|
||||
Message.warning('请填写 Agent 版本号(形如 v1.7.0)')
|
||||
return
|
||||
}
|
||||
const connectionError = validateAgentConnection(deploy)
|
||||
if (connectionError) {
|
||||
Message.warning(connectionError)
|
||||
return
|
||||
}
|
||||
const installInput = toInstallTokenInput(deploy)
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (fixedNode) {
|
||||
const result = await deployFlow.submitExistingNode(fixedNode, deploy)
|
||||
const result = await deployFlow.submitExistingNode(fixedNode, installInput)
|
||||
applySingleOrTableResult(result.rows, fixedNode)
|
||||
} else if (mode === 'single') {
|
||||
const result = await deployFlow.submitNewNodes([singleName.trim()], deploy)
|
||||
const result = await deployFlow.submitNewNodes([singleName.trim()], installInput)
|
||||
applySingleOrTableResult(result.rows)
|
||||
} else {
|
||||
const names = parseBatchNames()
|
||||
const result = await deployFlow.submitNewNodes(names, deploy)
|
||||
const result = await deployFlow.submitNewNodes(names, installInput)
|
||||
if (mountedRef.current) setBatchRows(toBatchRows(result.rows))
|
||||
if (result.status === 'partialFailed') {
|
||||
Message.warning('部分节点安装命令生成失败,可在结果表中查看')
|
||||
@@ -127,7 +138,7 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
|
||||
if (!singleNodeInfo) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const row = await deployFlow.regenerateNode(singleNodeInfo, deploy)
|
||||
const row = await deployFlow.regenerateNode(singleNodeInfo, toInstallTokenInput(deploy))
|
||||
if (row.status === 'ready' && row.installToken) {
|
||||
setSingleToken(row.installToken)
|
||||
} else {
|
||||
@@ -143,7 +154,7 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
|
||||
const retryBatchNode = async (row: BatchCommandRow) => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const next = await deployFlow.regenerateNode({ id: row.nodeId, name: row.nodeName }, deploy)
|
||||
const next = await deployFlow.regenerateNode({ id: row.nodeId, name: row.nodeName }, toInstallTokenInput(deploy))
|
||||
setBatchRows((rows) => rows.map((item) => (
|
||||
item.nodeId === row.nodeId ? toBatchRows([next])[0] : item
|
||||
)))
|
||||
@@ -164,6 +175,9 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
|
||||
arch: deploy.arch,
|
||||
agentVersion: deploy.agentVersion,
|
||||
downloadSrc: deploy.downloadSrc,
|
||||
agentMasterUrl: deploy.connectionMode === 'restricted' ? deploy.agentMasterUrl.trim() : '',
|
||||
proxyUrl: deploy.connectionMode === 'restricted' ? deploy.proxyUrl.trim() : '',
|
||||
caCertFile: deploy.connectionMode === 'restricted' ? deploy.caCertFile.trim() : '',
|
||||
}
|
||||
|
||||
// fixedNode 路径下步骤只有 2 步(部署参数 + 安装命令),step 值从 1 开始,
|
||||
@@ -236,7 +250,6 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
|
||||
nodeId={singleNodeInfo.id}
|
||||
nodeName={singleNodeInfo.name}
|
||||
token={singleToken}
|
||||
mode={deploy.mode}
|
||||
previewParams={previewParams}
|
||||
onRegenerate={regenerateSingle}
|
||||
/>
|
||||
@@ -268,6 +281,19 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
|
||||
}
|
||||
}
|
||||
|
||||
function toInstallTokenInput(deploy: DeployOptions): InstallTokenInput {
|
||||
return {
|
||||
mode: deploy.mode,
|
||||
arch: deploy.arch,
|
||||
agentVersion: deploy.agentVersion.trim(),
|
||||
downloadSrc: deploy.downloadSrc,
|
||||
ttlSeconds: deploy.ttlSeconds,
|
||||
agentMasterUrl: deploy.connectionMode === 'restricted' ? deploy.agentMasterUrl.trim() : undefined,
|
||||
proxyUrl: deploy.connectionMode === 'restricted' ? deploy.proxyUrl.trim() : undefined,
|
||||
caCertFile: deploy.connectionMode === 'restricted' ? deploy.caCertFile.trim() : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function toBatchRows(rows: AgentDeployRow[]): BatchCommandRow[] {
|
||||
return rows.map((row) => ({
|
||||
nodeId: row.nodeId,
|
||||
|
||||
@@ -81,7 +81,7 @@ export function BatchCommandTable({ rows, onRetryNode }: Props) {
|
||||
}
|
||||
return (
|
||||
<Text style={{
|
||||
fontFamily: 'monospace', fontSize: 12, wordBreak: 'break-all',
|
||||
fontSize: 12, wordBreak: 'break-all',
|
||||
opacity: left === 0 ? 0.4 : 1,
|
||||
}}>
|
||||
{cmd as string}
|
||||
|
||||
@@ -121,7 +121,7 @@ export default function NodesPage() {
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
新 Token(24 小时内新旧 Token 均可认证,便于滚动替换):
|
||||
</Text>
|
||||
<Text copyable style={{ fontFamily: 'monospace', fontSize: 12, wordBreak: 'break-all' }}>
|
||||
<Text copyable style={{ fontSize: 12, wordBreak: 'break-all' }}>
|
||||
{newToken}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -138,7 +138,7 @@ export default function NodesPage() {
|
||||
render: (name: string, record: NodeSummary) => (
|
||||
<Space>
|
||||
{record.isLocal ? <IconDesktop style={{ color: 'var(--color-primary-6)' }} /> : <IconCloudDownload />}
|
||||
<Text bold>{name}</Text>
|
||||
<Text>{name}</Text>
|
||||
{record.isLocal && <Tag color="arcoblue" size="small" bordered>本机</Tag>}
|
||||
</Space>
|
||||
),
|
||||
|
||||
@@ -17,21 +17,32 @@ describe('install command builders', () => {
|
||||
'https://master.example.com/install/abc',
|
||||
)
|
||||
|
||||
expect(cmd).toContain('/tmp/bx-agent-install.sh')
|
||||
expect(cmd).toContain('mktemp /tmp/bx-agent-install.XXXXXX')
|
||||
expect(cmd).toContain("'https://master.example.com/install/abc'")
|
||||
expect(cmd).toContain('non-script content')
|
||||
expect(cmd).toContain('umask 077')
|
||||
expect(cmd).toContain('rm -f "$tmp"')
|
||||
})
|
||||
|
||||
it('keeps URL install command as primary even when embedded script is available', () => {
|
||||
it('keeps the one-time URL as the primary install command', () => {
|
||||
const cmd = buildAgentInstallCommand(
|
||||
'https://master.example.com/api/install/abc',
|
||||
'https://master.example.com/install/abc',
|
||||
'IyEvYmluL3NoCg==',
|
||||
)
|
||||
|
||||
expect(cmd).toContain('https://master.example.com/api/install/abc')
|
||||
expect(cmd).toContain('https://master.example.com/install/abc')
|
||||
expect(cmd).not.toContain('IyEvYmluL3NoCg==')
|
||||
})
|
||||
|
||||
it('binds proxy and private CA settings to installer downloads', () => {
|
||||
const cmd = buildAgentInstallCommand(
|
||||
'https://master.internal/api/install/abc',
|
||||
undefined,
|
||||
{ proxyUrl: 'socks5h://127.0.0.1:1080', caCertFile: '/etc/backupx-agent/ca.pem' },
|
||||
)
|
||||
|
||||
expect(cmd).toContain("--proxy 'socks5h://127.0.0.1:1080'")
|
||||
expect(cmd).toContain("--cacert '/etc/backupx-agent/ca.pem'")
|
||||
})
|
||||
|
||||
it('builds embedded fallback command explicitly', () => {
|
||||
|
||||
@@ -12,16 +12,34 @@ 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) {
|
||||
export interface InstallFetchOptions {
|
||||
proxyUrl?: string
|
||||
caCertFile?: string
|
||||
}
|
||||
|
||||
function curlFetch(url: string, destination: string, options: InstallFetchOptions) {
|
||||
const args = ['curl', '-fsS']
|
||||
if (options.proxyUrl?.trim()) {
|
||||
args.push('--proxy', shellQuote(options.proxyUrl.trim()))
|
||||
}
|
||||
if (options.caCertFile?.trim()) {
|
||||
args.push('--cacert', shellQuote(options.caCertFile.trim()))
|
||||
}
|
||||
args.push(shellQuote(url), '-o', destination)
|
||||
return args.join(' ')
|
||||
}
|
||||
|
||||
export function buildAgentInstallCommand(url: string, fallbackUrl?: string, options: InstallFetchOptions = {}) {
|
||||
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")`
|
||||
? `(${curlFetch(urls[0], '"$tmp"', options)} && grep -q ${marker} "$tmp" || ${curlFetch(urls[1], '"$tmp"', options)})`
|
||||
: `(${curlFetch(urls[0], '"$tmp"', options)} && grep -q ${marker} "$tmp")`
|
||||
|
||||
return [
|
||||
'umask 077',
|
||||
'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; }; }`,
|
||||
@@ -29,24 +47,27 @@ export function buildAgentInstallCommand(url: string, fallbackUrl?: string, _scr
|
||||
].join(' && ') + '; rc=$?; rm -f "$tmp"; test $rc -eq 0'
|
||||
}
|
||||
|
||||
export function buildAgentDownloadCommand(url: string, fallbackUrl?: string, _scriptBase64?: string) {
|
||||
export function buildAgentDownloadCommand(url: string, fallbackUrl?: string, options: InstallFetchOptions = {}) {
|
||||
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)`
|
||||
? `(${curlFetch(primary, '"$tmp"', options)} && grep -q ${marker} "$tmp" || ${curlFetch(fallback, '"$tmp"', options)})`
|
||||
: `(${curlFetch(primary, '"$tmp"', options)} && grep -q ${marker} "$tmp")`
|
||||
|
||||
return [
|
||||
'umask 077',
|
||||
'tmp=$(mktemp /tmp/bx-agent-install.XXXXXX)',
|
||||
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(' && ')
|
||||
`{ 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 buildEmbeddedAgentInstallCommand(scriptBase64: string) {
|
||||
const marker = shellQuote(INSTALL_MAGIC_MARKER)
|
||||
return [
|
||||
'umask 077',
|
||||
'enc=$(mktemp)',
|
||||
'tmp=$(mktemp)',
|
||||
`printf %s ${shellQuote(scriptBase64.trim())} > "$enc"`,
|
||||
|
||||
@@ -76,6 +76,24 @@ describe('createAgentDeployFlow', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses restricted-network options in batch install commands', async () => {
|
||||
const flow = createAgentDeployFlow({
|
||||
batchCreateNodes: async () => [{ id: 1, name: 'restricted' }],
|
||||
createInstallToken: async () => tokenResult({
|
||||
url: 'https://master.internal/api/install/install-token',
|
||||
fallbackUrl: 'https://master.internal/install/install-token',
|
||||
}),
|
||||
})
|
||||
const result = await flow.submitNewNodes(['restricted'], {
|
||||
...deployOptions(),
|
||||
proxyUrl: 'socks5h://127.0.0.1:1080',
|
||||
caCertFile: '/etc/backupx-agent/ca.pem',
|
||||
})
|
||||
|
||||
expect(result.rows[0].command).toContain("--proxy 'socks5h://127.0.0.1:1080'")
|
||||
expect(result.rows[0].command).toContain("--cacert '/etc/backupx-agent/ca.pem'")
|
||||
})
|
||||
|
||||
it('rejects duplicate names before creating nodes', async () => {
|
||||
const flow = createAgentDeployFlow({
|
||||
batchCreateNodes: async () => {
|
||||
|
||||
@@ -41,7 +41,7 @@ export function createAgentDeployFlow(deps: AgentDeployFlowDeps) {
|
||||
const issueTokenForNode = async (node: AgentDeployNode, input: InstallTokenInput): Promise<AgentDeployRow> => {
|
||||
try {
|
||||
const token = await deps.createInstallToken(node.id, input)
|
||||
return readyRow(node, token)
|
||||
return readyRow(node, token, input)
|
||||
} catch (error) {
|
||||
return {
|
||||
nodeId: node.id,
|
||||
@@ -77,12 +77,15 @@ export function useAgentDeployFlow() {
|
||||
return useMemo(() => createAgentDeployFlow({ batchCreateNodes, createInstallToken }), [])
|
||||
}
|
||||
|
||||
function readyRow(node: AgentDeployNode, token: InstallTokenResult): AgentDeployRow {
|
||||
function readyRow(node: AgentDeployNode, token: InstallTokenResult, input: InstallTokenInput): AgentDeployRow {
|
||||
return {
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
status: 'ready',
|
||||
command: buildAgentInstallCommand(token.url, token.fallbackUrl),
|
||||
command: buildAgentInstallCommand(token.url, token.fallbackUrl, {
|
||||
proxyUrl: input.proxyUrl,
|
||||
caCertFile: input.caCertFile,
|
||||
}),
|
||||
expiresAt: token.expiresAt,
|
||||
installToken: token,
|
||||
embeddedCommand: token.scriptBase64
|
||||
|
||||
34
web/src/pages/nodes/wizard/AgentConnectionOptions.test.ts
Normal file
34
web/src/pages/nodes/wizard/AgentConnectionOptions.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { validateAgentConnection, type AgentConnectionValue } from './AgentConnectionOptions'
|
||||
|
||||
function connection(patch: Partial<AgentConnectionValue> = {}): AgentConnectionValue {
|
||||
return {
|
||||
connectionMode: 'restricted',
|
||||
agentMasterUrl: '',
|
||||
proxyUrl: '',
|
||||
caCertFile: '',
|
||||
...patch,
|
||||
}
|
||||
}
|
||||
|
||||
describe('validateAgentConnection', () => {
|
||||
it('accepts direct connectivity without overrides', () => {
|
||||
expect(validateAgentConnection(connection({ connectionMode: 'direct' }))).toBe('')
|
||||
})
|
||||
|
||||
it('accepts an SSH local-forward URL and SOCKS5 proxy', () => {
|
||||
expect(validateAgentConnection(connection({ agentMasterUrl: 'http://127.0.0.1:18340' }))).toBe('')
|
||||
expect(validateAgentConnection(connection({ proxyUrl: 'socks5h://127.0.0.1:1080' }))).toBe('')
|
||||
})
|
||||
|
||||
it('rejects empty restricted settings and relative CA paths', () => {
|
||||
expect(validateAgentConnection(connection())).not.toBe('')
|
||||
expect(validateAgentConnection(connection({ caCertFile: 'internal-ca.pem' }))).not.toBe('')
|
||||
})
|
||||
|
||||
it('rejects credentials and shell-unsafe values before submission', () => {
|
||||
expect(validateAgentConnection(connection({ agentMasterUrl: 'https://user:pass@master.example.com' }))).not.toBe('')
|
||||
expect(validateAgentConnection(connection({ proxyUrl: 'http://user:pass@proxy.example.com' }))).not.toBe('')
|
||||
expect(validateAgentConnection(connection({ caCertFile: '/etc/pki/internal ca.pem' }))).not.toBe('')
|
||||
})
|
||||
})
|
||||
110
web/src/pages/nodes/wizard/AgentConnectionOptions.tsx
Normal file
110
web/src/pages/nodes/wizard/AgentConnectionOptions.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React from 'react'
|
||||
import { Form, Input, Radio, Typography } from '@arco-design/web-react'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
export type ConnectionMode = 'direct' | 'restricted'
|
||||
|
||||
export interface AgentConnectionValue {
|
||||
connectionMode: ConnectionMode
|
||||
agentMasterUrl: string
|
||||
proxyUrl: string
|
||||
caCertFile: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: AgentConnectionValue
|
||||
onChange: (value: AgentConnectionValue) => void
|
||||
}
|
||||
|
||||
export function AgentConnectionOptions({ value, onChange }: Props) {
|
||||
const update = (patch: Partial<AgentConnectionValue>) => onChange({ ...value, ...patch })
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
label="Agent 网络路径"
|
||||
extra={<Text type="secondary">Agent 只需主动访问 Master,不需要从 Master 反向开放节点端口。</Text>}
|
||||
>
|
||||
<Radio.Group
|
||||
type="button"
|
||||
value={value.connectionMode}
|
||||
onChange={(mode) => update({ connectionMode: mode as ConnectionMode })}
|
||||
options={[
|
||||
{ label: '直连', value: 'direct' },
|
||||
{ label: '代理或堡垒机', value: 'restricted' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{value.connectionMode === 'restricted' && (
|
||||
<>
|
||||
<Form.Item
|
||||
label="Agent 连接地址"
|
||||
extra={<Text type="secondary">可填写经 SSH 本地转发后的地址;留空则继续使用 Master 对外地址。</Text>}
|
||||
>
|
||||
<Input
|
||||
value={value.agentMasterUrl}
|
||||
placeholder="例如 http://127.0.0.1:18340"
|
||||
onChange={(agentMasterUrl) => update({ agentMasterUrl })}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="显式代理 URL"
|
||||
extra={<Text type="secondary">支持 http、https、socks5、socks5h;SSH 动态转发可使用 socks5h://127.0.0.1:1080。</Text>}
|
||||
>
|
||||
<Input
|
||||
value={value.proxyUrl}
|
||||
placeholder="可选,例如 socks5h://127.0.0.1:1080"
|
||||
onChange={(proxyUrl) => update({ proxyUrl })}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="私有 CA 证书路径"
|
||||
extra={<Text type="secondary">目标节点上已存在的 PEM 文件绝对路径;安装器会复制到受保护的 Agent 配置目录。</Text>}
|
||||
>
|
||||
<Input
|
||||
value={value.caCertFile}
|
||||
placeholder="可选,例如 /etc/pki/ca-trust/source/anchors/internal-ca.pem"
|
||||
onChange={(caCertFile) => update({ caCertFile })}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function validateAgentConnection(value: AgentConnectionValue) {
|
||||
if (value.connectionMode === 'direct') return ''
|
||||
const agentMasterUrl = value.agentMasterUrl.trim()
|
||||
const proxyUrl = value.proxyUrl.trim()
|
||||
const caCertFile = value.caCertFile.trim()
|
||||
if (!agentMasterUrl && !proxyUrl && !caCertFile) {
|
||||
return '请至少填写 Agent 连接地址、代理 URL 或私有 CA 路径'
|
||||
}
|
||||
if (agentMasterUrl) {
|
||||
try {
|
||||
const parsed = new URL(agentMasterUrl)
|
||||
if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.host || parsed.username || parsed.password || parsed.search || parsed.hash || /\s/.test(agentMasterUrl)) {
|
||||
return 'Agent 连接地址必须是不含凭据、查询参数和片段的完整 HTTP(S) URL'
|
||||
}
|
||||
} catch {
|
||||
return 'Agent 连接地址必须是完整的 HTTP 或 HTTPS URL'
|
||||
}
|
||||
}
|
||||
if (proxyUrl) {
|
||||
try {
|
||||
const parsed = new URL(proxyUrl)
|
||||
if (!['http:', 'https:', 'socks5:', 'socks5h:'].includes(parsed.protocol) || !parsed.host || parsed.username || parsed.password || (parsed.pathname !== '' && parsed.pathname !== '/') || parsed.search || parsed.hash || /\s/.test(proxyUrl)) {
|
||||
return '代理 URL 仅支持无凭据、无路径的 http、https、socks5 或 socks5h 地址'
|
||||
}
|
||||
} catch {
|
||||
return '代理 URL 仅支持 http、https、socks5 或 socks5h'
|
||||
}
|
||||
}
|
||||
if (caCertFile && (!/^\/(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+$/.test(caCertFile) || caCertFile.split('/').some((part) => part === '..'))) {
|
||||
return '私有 CA 证书必须使用不含空格或特殊字符的绝对路径'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
30
web/src/pages/nodes/wizard/InstallCommandBlock.tsx
Normal file
30
web/src/pages/nodes/wizard/InstallCommandBlock.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React, { type ReactNode } from 'react'
|
||||
import { Button, Space, Typography } from '@arco-design/web-react'
|
||||
import { IconCopy } from '../../../components/icons'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface Props {
|
||||
label?: string
|
||||
command: string
|
||||
disabled?: boolean
|
||||
action?: ReactNode
|
||||
onCopy: (command: string) => void
|
||||
}
|
||||
|
||||
export function InstallCommandBlock({ label, command, disabled, action, onCopy }: Props) {
|
||||
return (
|
||||
<div style={{ background: 'var(--color-fill-2)', padding: '12px 14px', borderRadius: 4, marginBottom: 12 }}>
|
||||
{label && <Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>{label}</Text>}
|
||||
<Text style={{ fontSize: 13, wordBreak: 'break-all', opacity: disabled ? 0.4 : 1, userSelect: 'all' }}>
|
||||
{command}
|
||||
</Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Space>
|
||||
<Button size="small" icon={<IconCopy />} disabled={disabled} onClick={() => onCopy(command)}>复制</Button>
|
||||
{action}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export function Step1NodeName({
|
||||
</div>
|
||||
{mode === 'single' ? (
|
||||
<div>
|
||||
<Text bold style={{ marginBottom: 6, display: 'block' }}>节点名称</Text>
|
||||
<Text style={{ marginBottom: 6, display: 'block' }}>节点名称</Text>
|
||||
<Input
|
||||
placeholder="如:prod-db-01"
|
||||
value={singleName}
|
||||
@@ -42,13 +42,13 @@ export function Step1NodeName({
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Text bold style={{ marginBottom: 6, display: 'block' }}>节点名称(每行一个,最多 50 个)</Text>
|
||||
<Text style={{ marginBottom: 6, display: 'block' }}>节点名称(每行一个,最多 50 个)</Text>
|
||||
<TextArea
|
||||
rows={8}
|
||||
placeholder={'prod-db-01\nprod-db-02\nprod-web-01'}
|
||||
value={batchText}
|
||||
onChange={onBatchTextChange}
|
||||
style={{ fontFamily: 'monospace', fontSize: 13 }}
|
||||
style={{ fontSize: 13 }}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
|
||||
空行自动忽略;重名会在提交时报错
|
||||
|
||||
12
web/src/pages/nodes/wizard/Step2DeployOptions.test.ts
Normal file
12
web/src/pages/nodes/wizard/Step2DeployOptions.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isReleaseVersion } from './Step2DeployOptions'
|
||||
|
||||
describe('isReleaseVersion', () => {
|
||||
it('accepts release tags and rejects source-build versions', () => {
|
||||
expect(isReleaseVersion('v2.4.0')).toBe(true)
|
||||
expect(isReleaseVersion('2.4.0-rc.1')).toBe(true)
|
||||
expect(isReleaseVersion('dev')).toBe(false)
|
||||
expect(isReleaseVersion('00151e4')).toBe(false)
|
||||
expect(isReleaseVersion(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from 'react'
|
||||
import { Form, Radio, Select, Input, Typography } from '@arco-design/web-react'
|
||||
import type { InstallMode, InstallArch, InstallSource } from '../../../types/nodes'
|
||||
import { AgentConnectionOptions, type AgentConnectionValue } from './AgentConnectionOptions'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
export interface DeployOptions {
|
||||
export interface DeployOptions extends AgentConnectionValue {
|
||||
mode: InstallMode
|
||||
arch: InstallArch
|
||||
agentVersion: string
|
||||
@@ -21,12 +22,17 @@ interface Props {
|
||||
|
||||
export function Step2DeployOptions({ masterVersion, value, onChange }: Props) {
|
||||
const update = (patch: Partial<DeployOptions>) => onChange({ ...value, ...patch })
|
||||
const versionKnown = !!masterVersion
|
||||
const versionKnown = isReleaseVersion(masterVersion)
|
||||
const versionLoading = masterVersion === null
|
||||
|
||||
return (
|
||||
<Form layout="vertical" size="default">
|
||||
<Form.Item label="安装模式">
|
||||
<Form.Item
|
||||
label="安装模式"
|
||||
extra={value.mode === 'docker'
|
||||
? <Text type="warning">Docker Agent 只能访问显式挂载的目录;备份源使用只读 volume,恢复目录需单独授权写入,或改用 systemd。</Text>
|
||||
: undefined}
|
||||
>
|
||||
<Radio.Group
|
||||
type="button"
|
||||
value={value.mode}
|
||||
@@ -56,7 +62,9 @@ export function Step2DeployOptions({ masterVersion, value, onChange }: Props) {
|
||||
extra={
|
||||
!versionKnown && !versionLoading ? (
|
||||
<Text type="warning" style={{ fontSize: 12 }}>
|
||||
未能自动获取 Master 版本,请手动输入(形如 v1.7.0)
|
||||
{masterVersion
|
||||
? `当前 Master 版本 ${masterVersion} 不是可下载的 Release,请手动输入 Agent Release 标签`
|
||||
: '未能自动获取 Master 版本,请手动输入 Agent Release 标签(形如 v1.7.0)'}
|
||||
</Text>
|
||||
) : undefined
|
||||
}
|
||||
@@ -106,6 +114,12 @@ export function Step2DeployOptions({ masterVersion, value, onChange }: Props) {
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<AgentConnectionOptions value={value} onChange={(connection) => update(connection)} />
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export function isReleaseVersion(version: string | null) {
|
||||
return !!version && /^v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Typography, Button, Space, Collapse, Spin, Message, Tag } from '@arco-design/web-react'
|
||||
import { IconCopy, IconRefresh } from '../../../components/icons'
|
||||
import { IconRefresh } from '../../../components/icons'
|
||||
import { fetchScriptPreview } from '../../../services/nodes'
|
||||
import type { InstallTokenResult, InstallMode } from '../../../types/nodes'
|
||||
import type { InstallTokenResult } from '../../../types/nodes'
|
||||
import { buildAgentDownloadCommand, buildAgentInstallCommand, buildEmbeddedAgentInstallCommand } from '../installCommands'
|
||||
import { InstallCommandBlock } from './InstallCommandBlock'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
@@ -11,12 +12,19 @@ interface Props {
|
||||
nodeId: number
|
||||
nodeName: string
|
||||
token: InstallTokenResult
|
||||
mode: InstallMode
|
||||
previewParams: { mode: string; arch: string; agentVersion: string; downloadSrc: string }
|
||||
previewParams: {
|
||||
mode: string
|
||||
arch: string
|
||||
agentVersion: string
|
||||
downloadSrc: string
|
||||
agentMasterUrl?: string
|
||||
proxyUrl?: string
|
||||
caCertFile?: string
|
||||
}
|
||||
onRegenerate: () => void
|
||||
}
|
||||
|
||||
export function Step3CommandPreview({ nodeId, nodeName, token, mode, previewParams, onRegenerate }: Props) {
|
||||
export function Step3CommandPreview({ nodeId, nodeName, token, previewParams, onRegenerate }: Props) {
|
||||
const [remaining, setRemaining] = useState(0)
|
||||
const [preview, setPreview] = useState<string>('')
|
||||
const [loadingPreview, setLoadingPreview] = useState(false)
|
||||
@@ -30,12 +38,10 @@ export function Step3CommandPreview({ nodeId, nodeName, token, mode, previewPara
|
||||
}, [token.expiresAt])
|
||||
|
||||
const expired = remaining === 0
|
||||
const command = buildAgentInstallCommand(token.url, token.fallbackUrl)
|
||||
const fallbackCommand = buildAgentDownloadCommand(token.url, token.fallbackUrl)
|
||||
const fetchOptions = { proxyUrl: previewParams.proxyUrl, caCertFile: previewParams.caCertFile }
|
||||
const command = buildAgentInstallCommand(token.url, token.fallbackUrl, fetchOptions)
|
||||
const fallbackCommand = buildAgentDownloadCommand(token.url, token.fallbackUrl, fetchOptions)
|
||||
const embeddedCommand = token.scriptBase64 ? buildEmbeddedAgentInstallCommand(token.scriptBase64) : null
|
||||
const dockerComposeCmd = mode === 'docker' && token.composeUrl
|
||||
? `curl -fsSL ${token.composeUrl} -o docker-compose.yml && docker-compose up -d`
|
||||
: null
|
||||
|
||||
const copy = async (s: string) => {
|
||||
await navigator.clipboard.writeText(s)
|
||||
@@ -57,73 +63,28 @@ export function Step3CommandPreview({ nodeId, nodeName, token, mode, previewPara
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Text bold>节点:</Text>
|
||||
<Text>节点:</Text>
|
||||
<Tag>{nodeName}</Tag>
|
||||
<Tag color={expired ? 'gray' : 'green'}>
|
||||
{expired ? '已过期' : `有效期 ${Math.floor(remaining / 60)}:${String(remaining % 60).padStart(2, '0')}`}
|
||||
</Tag>
|
||||
</Space>
|
||||
|
||||
<div style={{ background: 'var(--color-fill-2)', padding: '12px 14px', borderRadius: 6, marginBottom: 12 }}>
|
||||
<Text style={{
|
||||
fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all',
|
||||
opacity: expired ? 0.4 : 1, userSelect: 'all',
|
||||
}}>
|
||||
{command}
|
||||
</Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Space>
|
||||
<Button size="small" icon={<IconCopy />} disabled={expired} onClick={() => copy(command)}>复制</Button>
|
||||
{expired && <Button size="small" type="primary" icon={<IconRefresh />} onClick={onRegenerate}>重新生成</Button>}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
<InstallCommandBlock
|
||||
command={command}
|
||||
disabled={expired}
|
||||
onCopy={copy}
|
||||
action={expired ? <Button size="small" type="primary" icon={<IconRefresh />} onClick={onRegenerate}>重新生成</Button> : undefined}
|
||||
/>
|
||||
|
||||
<div style={{ background: 'var(--color-fill-2)', padding: '12px 14px', borderRadius: 6, marginBottom: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||
或固定下载到 /tmp 后执行:
|
||||
</Text>
|
||||
<Text style={{
|
||||
fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all',
|
||||
opacity: expired ? 0.4 : 1, userSelect: 'all',
|
||||
}}>
|
||||
{fallbackCommand}
|
||||
</Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Button size="small" icon={<IconCopy />} disabled={expired} onClick={() => copy(fallbackCommand)}>复制</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dockerComposeCmd && (
|
||||
<div style={{ background: 'var(--color-fill-2)', padding: '12px 14px', borderRadius: 6, marginBottom: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||
或使用 docker-compose:
|
||||
</Text>
|
||||
<Text style={{ fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all', opacity: expired ? 0.4 : 1 }}>
|
||||
{dockerComposeCmd}
|
||||
</Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Button size="small" icon={<IconCopy />} disabled={expired} onClick={() => copy(dockerComposeCmd)}>复制</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<InstallCommandBlock label="或先下载到 /tmp 后执行:" command={fallbackCommand} disabled={expired} onCopy={copy} />
|
||||
|
||||
{embeddedCommand && (
|
||||
<div style={{ background: 'var(--color-fill-2)', padding: '12px 14px', borderRadius: 6, marginBottom: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||
代理异常时使用嵌入式备用命令:
|
||||
</Text>
|
||||
<Text style={{ fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all', userSelect: 'all' }}>
|
||||
{embeddedCommand}
|
||||
</Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Button size="small" icon={<IconCopy />} onClick={() => copy(embeddedCommand)}>复制</Button>
|
||||
</div>
|
||||
</div>
|
||||
<InstallCommandBlock label="安装入口不可达时使用嵌入式备用命令:" command={embeddedCommand} onCopy={copy} />
|
||||
)}
|
||||
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 8 }}>
|
||||
主安装命令包含公开 install token,会在 TTL 到期或首次消费后作废;嵌入式备用命令包含完整节点 token,不依赖公开链接消费状态,请仅在目标机执行并妥善保存。
|
||||
主安装命令包含一次性 install token,会在 TTL 到期或首次消费后作废;嵌入式备用命令包含完整节点 Token,不依赖公开入口,请仅在目标机执行并妥善保存。
|
||||
</Text>
|
||||
|
||||
<Collapse bordered={false} onChange={(_key, keys) => {
|
||||
|
||||
@@ -59,7 +59,15 @@ export async function rotateNodeToken(nodeId: number) {
|
||||
|
||||
export async function fetchScriptPreview(
|
||||
nodeId: number,
|
||||
params: { mode: string; arch: string; agentVersion: string; downloadSrc: string },
|
||||
params: {
|
||||
mode: string
|
||||
arch: string
|
||||
agentVersion: string
|
||||
downloadSrc: string
|
||||
agentMasterUrl?: string
|
||||
proxyUrl?: string
|
||||
caCertFile?: string
|
||||
},
|
||||
) {
|
||||
const response = await http.get<string>(`/nodes/${nodeId}/install-script-preview`, {
|
||||
params,
|
||||
|
||||
@@ -51,6 +51,9 @@ export interface InstallTokenInput {
|
||||
agentVersion: string
|
||||
downloadSrc: InstallSource
|
||||
ttlSeconds: number
|
||||
agentMasterUrl?: string
|
||||
proxyUrl?: string
|
||||
caCertFile?: string
|
||||
}
|
||||
|
||||
export interface InstallTokenResult {
|
||||
|
||||
Reference in New Issue
Block a user