feat: aether-proxy 远程配置下发、连通性测试与 setup TUI

- 后端新增远程配置管理 API (PUT /config) 和连通性测试 API (POST /test)
- 前端新增远程配置编辑对话框和节点连通性测试按钮
- aether-proxy 支持通过心跳接收并热加载远程配置 (端口白名单、日志级别、心跳间隔、时间戳容差)
- aether-proxy 新增 TOML 配置文件支持和交互式 setup TUI
- aether-proxy 心跳 404 时自动重注册节点
- plain proxy 响应改为流式传输,减少内存缓冲
- 新增 remote_config 和 config_version 数据库字段及迁移
This commit is contained in:
fawney19
2026-02-07 19:20:09 +08:00
parent 3b8398b2e5
commit 31bc452374
18 changed files with 2771 additions and 113 deletions

View File

@@ -1,5 +1,12 @@
import apiClient from './client'
export interface ProxyNodeRemoteConfig {
allowed_ports?: number[]
log_level?: string
heartbeat_interval?: number
timestamp_tolerance?: number
}
export interface ProxyNode {
id: string
name: string
@@ -12,6 +19,9 @@ export interface ProxyNode {
proxy_url?: string
proxy_username?: string
proxy_password?: string // 脱敏后的密码
// 远程配置aether-proxy 节点)
remote_config: ProxyNodeRemoteConfig | null
config_version: number
registered_by: string | null
last_heartbeat_at: string | null
heartbeat_interval: number
@@ -45,6 +55,13 @@ export interface ManualProxyNodeUpdateRequest {
region?: string
}
export interface ProxyNodeTestResult {
success: boolean
latency_ms: number | null
exit_ip: string | null
error: string | null
}
export const proxyNodesApi = {
async listProxyNodes(params?: { status?: string; skip?: number; limit?: number }): Promise<ProxyNodeListResponse> {
const response = await apiClient.get<ProxyNodeListResponse>('/api/admin/proxy-nodes', { params })
@@ -65,4 +82,14 @@ export const proxyNodesApi = {
const response = await apiClient.delete<{ message: string; node_id: string; cleared_system_proxy: boolean }>(`/api/admin/proxy-nodes/${nodeId}`)
return response.data
},
async testNode(nodeId: string): Promise<ProxyNodeTestResult> {
const response = await apiClient.post<ProxyNodeTestResult>(`/api/admin/proxy-nodes/${nodeId}/test`)
return response.data
},
async updateNodeConfig(nodeId: string, data: ProxyNodeRemoteConfig): Promise<{ node_id: string; config_version: number; remote_config: ProxyNodeRemoteConfig; node: ProxyNode }> {
const response = await apiClient.put<{ node_id: string; config_version: number; remote_config: ProxyNodeRemoteConfig; node: ProxyNode }>(`/api/admin/proxy-nodes/${nodeId}/config`, data)
return response.data
},
}

View File

@@ -152,6 +152,17 @@
</TableCell>
<TableCell class="py-4 text-center">
<div class="flex items-center justify-center gap-0.5">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:title="testingNodes.has(node.id) ? '测试中...' : '测试连通性'"
:disabled="testingNodes.has(node.id)"
@click="handleTest(node)"
>
<Loader2 v-if="testingNodes.has(node.id)" class="h-4 w-4 animate-spin" />
<Activity v-else class="h-4 w-4" />
</Button>
<Button
v-if="node.is_manual"
variant="ghost"
@@ -162,6 +173,16 @@
>
<SquarePen class="h-4 w-4" />
</Button>
<Button
v-if="!node.is_manual"
variant="ghost"
size="icon"
class="h-8 w-8"
title="远程配置"
@click="handleConfig(node)"
>
<Settings class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -225,6 +246,17 @@
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">{{ formatTime(node.last_heartbeat_at) }}</span>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
:disabled="testingNodes.has(node.id)"
@click="handleTest(node)"
>
<Loader2 v-if="testingNodes.has(node.id)" class="h-3 w-3 mr-1 animate-spin" />
<Activity v-else class="h-3 w-3 mr-1" />
{{ testingNodes.has(node.id) ? '测试中' : '测试' }}
</Button>
<Button
v-if="node.is_manual"
variant="ghost"
@@ -235,6 +267,16 @@
<SquarePen class="h-3 w-3 mr-1" />
编辑
</Button>
<Button
v-if="!node.is_manual"
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
@click="handleConfig(node)"
>
<Settings class="h-3 w-3 mr-1" />
配置
</Button>
<Button
variant="ghost"
size="sm"
@@ -338,6 +380,77 @@
</Button>
</template>
</Dialog>
<!-- 远程配置对话框 (aether-proxy 节点) -->
<Dialog
:model-value="showConfigDialog"
title="远程配置"
description="修改后将在下次心跳时自动下发到 aether-proxy 节点"
:icon="Settings"
size="md"
@update:model-value="handleConfigDialogClose"
>
<form class="space-y-4" @submit.prevent>
<div class="space-y-1.5">
<Label>允许的端口</Label>
<Input
v-model="configForm.allowed_ports"
placeholder="80, 443, 8080, 8443"
/>
<p class="text-xs text-muted-foreground">逗号分隔的目标端口白名单</p>
</div>
<div class="space-y-1.5">
<Label>日志级别</Label>
<Select v-model="configForm.log_level">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="trace">trace</SelectItem>
<SelectItem value="debug">debug</SelectItem>
<SelectItem value="info">info</SelectItem>
<SelectItem value="warn">warn</SelectItem>
<SelectItem value="error">error</SelectItem>
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label>心跳间隔 ()</Label>
<Input
v-model="configForm.heartbeat_interval"
type="number"
min="5"
max="600"
/>
</div>
<div class="space-y-1.5">
<Label>时间戳容差 ()</Label>
<Input
v-model="configForm.timestamp_tolerance"
type="number"
min="10"
max="3600"
/>
</div>
</div>
<div v-if="configNode" class="text-xs text-muted-foreground">
配置版本: v{{ configNode.config_version }}
</div>
</form>
<template #footer>
<Button
variant="outline"
@click="handleConfigDialogClose(false)"
>
取消
</Button>
<Button
:disabled="savingConfig"
@click="handleSaveConfig"
>
{{ savingConfig ? '保存中...' : '保存' }}
</Button>
</template>
</Dialog>
</div>
</template>
@@ -346,7 +459,7 @@ import { ref, computed, onMounted, watch } from 'vue'
import { useProxyNodesStore } from '@/stores/proxy-nodes'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { proxyNodesApi, type ProxyNode } from '@/api/proxy-nodes'
import { proxyNodesApi, type ProxyNode, type ProxyNodeRemoteConfig } from '@/api/proxy-nodes'
import {
Card,
@@ -370,7 +483,7 @@ import {
Dialog,
} from '@/components/ui'
import { Search, Trash2, Plus, SquarePen } from 'lucide-vue-next'
import { Search, Trash2, Plus, SquarePen, Activity, Loader2, Settings } from 'lucide-vue-next'
const { success, error: toastError } = useToast()
const { confirmDanger } = useConfirm()
@@ -393,6 +506,20 @@ const addForm = ref({
region: '',
})
// 远程配置对话框 (aether-proxy 节点)
const showConfigDialog = ref(false)
const savingConfig = ref(false)
const configNode = ref<ProxyNode | null>(null)
const configForm = ref({
allowed_ports: '',
log_level: 'info',
heartbeat_interval: '30',
timestamp_tolerance: '300',
})
// 测试连通性
const testingNodes = ref(new Set<string>())
const filteredNodes = computed(() => {
let filtered = [...store.nodes]
@@ -492,6 +619,62 @@ async function handleAddManualNode() {
}
}
function handleConfig(node: ProxyNode) {
configNode.value = node
const rc: ProxyNodeRemoteConfig = node.remote_config ?? {}
configForm.value = {
allowed_ports: rc.allowed_ports?.join(', ') || '',
log_level: rc.log_level || 'info',
heartbeat_interval: String(rc.heartbeat_interval || node.heartbeat_interval || 30),
timestamp_tolerance: String(rc.timestamp_tolerance || 300),
}
showConfigDialog.value = true
}
function handleConfigDialogClose(open: boolean) {
if (!open) {
showConfigDialog.value = false
configNode.value = null
}
}
async function handleSaveConfig() {
if (!configNode.value) return
savingConfig.value = true
try {
const data: Partial<ProxyNodeRemoteConfig> = {}
const portsInput = configForm.value.allowed_ports.trim()
if (portsInput) {
data.allowed_ports = portsInput
.split(',')
.map((s: string) => parseInt(s.trim()))
.filter((n: number) => !isNaN(n) && n >= 1 && n <= 65535)
} else if (configNode.value.remote_config?.allowed_ports) {
// 输入清空 → 显式发送空数组以清除已有端口白名单
data.allowed_ports = []
}
if (configForm.value.log_level) {
data.log_level = configForm.value.log_level
}
const hb = parseInt(configForm.value.heartbeat_interval)
if (!isNaN(hb) && hb >= 5) {
data.heartbeat_interval = hb
}
const tt = parseInt(configForm.value.timestamp_tolerance)
if (!isNaN(tt) && tt >= 10) {
data.timestamp_tolerance = tt
}
await proxyNodesApi.updateNodeConfig(configNode.value.id, data)
success('远程配置已保存,将在下次心跳时生效')
handleConfigDialogClose(false)
await store.fetchNodes()
} catch (err: any) {
toastError(err.response?.data?.error?.message || err.response?.data?.detail || '保存失败')
} finally {
savingConfig.value = false
}
}
async function handleDelete(node: ProxyNode) {
const confirmed = await confirmDanger(
`确定要删除代理节点 "${node.name}" (${node.ip}:${node.port}) 吗?`,
@@ -513,6 +696,26 @@ async function handleDelete(node: ProxyNode) {
}
}
async function handleTest(node: ProxyNode) {
if (testingNodes.value.has(node.id)) return
testingNodes.value.add(node.id)
try {
const result = await proxyNodesApi.testNode(node.id)
if (result.success) {
const parts = [`延迟: ${result.latency_ms}ms`]
if (result.exit_ip) parts.push(`出口IP: ${result.exit_ip}`)
success(`连通性测试通过,${parts.join('')}`)
} else {
toastError(`连通性测试失败: ${result.error || '未知错误'}`)
}
} catch (err: any) {
toastError(err.response?.data?.error?.message || '测试请求失败')
} finally {
testingNodes.value.delete(node.id)
}
}
function statusVariant(status: string) {
switch (status) {
case 'online': return 'success' as const