From 554d85c2f7acccb64e19eb6aeebda75ef29d3083 Mon Sep 17 00:00:00 2001 From: animesha3 <42974910+animesha3@users.noreply.github.com> Date: Thu, 11 Jun 2026 20:48:26 +0200 Subject: [PATCH] feat: allow selecting inbounds synchronized from nodes (#5178) * feat: select node inbounds for synchronization Allow node owners to import either all remote inbounds or an explicit tag-based selection. Add remote inbound discovery, persistence, snapshot filtering, API documentation, tests, and localized UI labels. * fix * fix: scope node reconcile and orphan sweep to selected inbound tags In 'selected' sync mode unselected inbounds never enter the panel DB, so ReconcileNode treated them as undesired and deleted them from the node the first time it went config-dirty. Reconcile now only sweeps remote tags that are part of the selection; everything else on the node is unmanaged. Panel-created or renamed inbounds on a selected-mode node also vanished: their tag was outside the selection, so the next traffic pull filtered them out of the snapshot and the orphan sweep silently dropped the central row. AddInbound/UpdateInbound now allow the tag on the node before committing. --------- Co-authored-by: Sanaei --- frontend/public/openapi.json | 78 +++++++ frontend/src/api/queries/useNodeMutations.ts | 9 + frontend/src/generated/examples.ts | 4 + frontend/src/generated/schemas.ts | 15 ++ frontend/src/generated/types.ts | 2 + frontend/src/generated/zod.ts | 2 + frontend/src/pages/api-docs/endpoints.ts | 7 + frontend/src/pages/nodes/NodeFormModal.tsx | 71 +++++++ frontend/src/pages/nodes/NodesPage.tsx | 3 +- frontend/src/schemas/node.ts | 4 + internal/database/model/model.go | 26 +-- internal/web/controller/node.go | 13 ++ internal/web/job/node_traffic_sync_job.go | 3 +- internal/web/runtime/remote.go | 19 ++ internal/web/service/inbound.go | 16 ++ internal/web/service/inbound_node.go | 21 +- .../service/inbound_node_reconcile_test.go | 197 ++++++++++++++++++ internal/web/service/node.go | 85 ++++++++ internal/web/service/node_test.go | 52 +++++ internal/web/translation/ar-EG.json | 10 + internal/web/translation/en-US.json | 10 + internal/web/translation/es-ES.json | 10 + internal/web/translation/fa-IR.json | 10 + internal/web/translation/id-ID.json | 10 + internal/web/translation/ja-JP.json | 10 + internal/web/translation/pt-BR.json | 10 + internal/web/translation/ru-RU.json | 10 + internal/web/translation/tr-TR.json | 10 + internal/web/translation/uk-UA.json | 10 + internal/web/translation/vi-VN.json | 10 + internal/web/translation/zh-CN.json | 10 + internal/web/translation/zh-TW.json | 10 + 32 files changed, 741 insertions(+), 16 deletions(-) create mode 100644 internal/web/service/inbound_node_reconcile_test.go diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index d142f0d43..d87b5265e 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -1572,6 +1572,19 @@ "example": 5, "type": "integer" }, + "inboundSyncMode": { + "enum": [ + "all", + "selected" + ], + "type": "string" + }, + "inboundTags": { + "items": { + "type": "string" + }, + "type": "array" + }, "lastError": { "type": "string" }, @@ -1675,6 +1688,8 @@ "guid", "id", "inboundCount", + "inboundSyncMode", + "inboundTags", "lastError", "lastHeartbeat", "latencyMs", @@ -6011,6 +6026,10 @@ "guid": "", "id": 1, "inboundCount": 5, + "inboundSyncMode": "all", + "inboundTags": [ + "" + ], "lastError": "", "lastHeartbeat": 1700000000, "latencyMs": 42, @@ -6451,6 +6470,65 @@ } } }, + "/panel/api/nodes/inbounds": { + "post": { + "tags": [ + "Nodes" + ], + "summary": "Use unsaved node connection details to list the remote inbounds available for selective import.", + "operationId": "post_panel_api_nodes_inbounds", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "name": "de-fra-1", + "scheme": "https", + "address": "node1.example.com", + "port": 2053, + "basePath": "/", + "apiToken": "abcdef..." + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + }, + "example": { + "success": true, + "obj": [ + { + "tag": "inbound-443", + "remark": "VLESS", + "protocol": "vless", + "port": 443 + } + ] + } + } + } + } + } + } + }, "/panel/api/nodes/probe/{id}": { "post": { "tags": [ diff --git a/frontend/src/api/queries/useNodeMutations.ts b/frontend/src/api/queries/useNodeMutations.ts index abdc0c249..aad3b12ca 100644 --- a/frontend/src/api/queries/useNodeMutations.ts +++ b/frontend/src/api/queries/useNodeMutations.ts @@ -15,6 +15,13 @@ export interface NodeUpdateResult { error?: string; } +export interface RemoteInboundOption { + tag: string; + remark?: string; + protocol?: string; + port?: number; +} + export function useNodeMutations() { const queryClient = useQueryClient(); const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.nodes.root() }); @@ -72,5 +79,7 @@ export function useNodeMutations() { }, fetchFingerprint: (payload: Partial): Promise> => HttpUtil.post('/panel/api/nodes/certFingerprint', payload), + fetchInbounds: (payload: Partial): Promise> => + HttpUtil.post('/panel/api/nodes/inbounds', payload), }; } diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 16992415f..3f4ea7e32 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -342,6 +342,10 @@ export const EXAMPLES: Record = { "guid": "", "id": 1, "inboundCount": 5, + "inboundSyncMode": "all", + "inboundTags": [ + "" + ], "lastError": "", "lastHeartbeat": 1700000000, "latencyMs": 42, diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 6c63731fb..4a8b6d186 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -1546,6 +1546,19 @@ export const SCHEMAS: Record = { "example": 5, "type": "integer" }, + "inboundSyncMode": { + "enum": [ + "all", + "selected" + ], + "type": "string" + }, + "inboundTags": { + "items": { + "type": "string" + }, + "type": "array" + }, "lastError": { "type": "string" }, @@ -1649,6 +1662,8 @@ export const SCHEMAS: Record = { "guid", "id", "inboundCount", + "inboundSyncMode", + "inboundTags", "lastError", "lastHeartbeat", "latencyMs", diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index c9869acf0..725b99120 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -348,6 +348,8 @@ export interface Node { guid: string; id: number; inboundCount: number; + inboundSyncMode: string; + inboundTags: string[]; lastError: string; lastHeartbeat: number; latencyMs: number; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index b3207d6c1..3db667349 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -374,6 +374,8 @@ export const NodeSchema = z.object({ guid: z.string(), id: z.number().int(), inboundCount: z.number().int(), + inboundSyncMode: z.enum(['all', 'selected']), + inboundTags: z.array(z.string()), lastError: z.string(), lastHeartbeat: z.number().int(), latencyMs: z.number().int(), diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index f948bcb71..e9aa879e1 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -843,6 +843,13 @@ export const sections: readonly Section[] = [ body: '{\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/"\n}', response: '{\n "success": true,\n "obj": "k3b1...base64-sha256...="\n}', }, + { + method: 'POST', + path: '/panel/api/nodes/inbounds', + summary: 'Use unsaved node connection details to list the remote inbounds available for selective import.', + body: '{\n "name": "de-fra-1",\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}', + response: '{\n "success": true,\n "obj": [\n { "tag": "inbound-443", "remark": "VLESS", "protocol": "vless", "port": 443 }\n ]\n}', + }, { method: 'POST', path: '/panel/api/nodes/probe/:id', diff --git a/frontend/src/pages/nodes/NodeFormModal.tsx b/frontend/src/pages/nodes/NodeFormModal.tsx index 985e80d3a..a6906a52e 100644 --- a/frontend/src/pages/nodes/NodeFormModal.tsx +++ b/frontend/src/pages/nodes/NodeFormModal.tsx @@ -14,6 +14,7 @@ import { message, } from 'antd'; import type { NodeRecord } from '@/api/queries/useNodesQuery'; +import type { RemoteInboundOption } from '@/api/queries/useNodeMutations'; import type { Msg } from '@/utils'; import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node'; import { antdRule } from '@/utils/zodForm'; @@ -27,6 +28,7 @@ interface NodeFormModalProps { node: NodeRecord | null; testConnection: (payload: Partial) => Promise>; fetchFingerprint: (payload: Partial) => Promise>; + fetchInbounds: (payload: Partial) => Promise>; save: (payload: Partial) => Promise>; onOpenChange: (open: boolean) => void; } @@ -45,6 +47,8 @@ function defaultValues(): NodeFormValues { allowPrivateAddress: false, tlsVerifyMode: 'verify', pinnedCertSha256: '', + inboundSyncMode: 'all', + inboundTags: [], }; } @@ -54,6 +58,7 @@ export default function NodeFormModal({ node, testConnection, fetchFingerprint, + fetchInbounds, save, onOpenChange, }: NodeFormModalProps) { @@ -64,9 +69,12 @@ export default function NodeFormModal({ const [submitting, setSubmitting] = useState(false); const [testing, setTesting] = useState(false); const [fetchingPin, setFetchingPin] = useState(false); + const [fetchingInbounds, setFetchingInbounds] = useState(false); + const [inboundOptions, setInboundOptions] = useState([]); const [testResult, setTestResult] = useState(null); const scheme = Form.useWatch('scheme', form) ?? 'https'; const tlsVerifyMode = Form.useWatch('tlsVerifyMode', form) ?? 'verify'; + const inboundSyncMode = Form.useWatch('inboundSyncMode', form) ?? 'all'; useEffect(() => { if (!open) return; @@ -82,6 +90,7 @@ export default function NodeFormModal({ if (next.scheme === 'http') next.tlsVerifyMode = 'skip'; form.resetFields(); form.setFieldsValue(next); + setInboundOptions((next.inboundTags || []).map((tag) => ({ tag }))); setTestResult(null); }, [open, mode, node, form]); @@ -104,6 +113,8 @@ export default function NodeFormModal({ allowPrivateAddress: values.allowPrivateAddress, tlsVerifyMode: values.tlsVerifyMode, pinnedCertSha256: values.tlsVerifyMode === 'pin' ? values.pinnedCertSha256.trim() : '', + inboundSyncMode: values.inboundSyncMode, + inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [], }; } @@ -149,6 +160,26 @@ export default function NodeFormModal({ } } + async function onFetchInbounds() { + try { + await form.validateFields(['name', 'address', 'port', 'apiToken']); + } catch { + return; + } + setFetchingInbounds(true); + try { + const msg = await fetchInbounds(buildPayload(form.getFieldsValue(true))); + if (msg?.success && Array.isArray(msg.obj)) { + setInboundOptions(msg.obj); + messageApi.success(t('pages.nodes.inboundsLoaded', { count: msg.obj.length })); + } else { + messageApi.error(msg?.msg || t('pages.nodes.inboundsLoadFailed')); + } + } finally { + setFetchingInbounds(false); + } + } + async function onFinish(values: NodeFormValues) { const result = NodeFormSchema.safeParse(values); if (!result.success) { @@ -323,6 +354,46 @@ export default function NodeFormModal({ + + ( + <> + + {menu} + + )} + options={inboundOptions.map((inbound) => ({ + value: inbound.tag, + label: `${inbound.remark || inbound.tag}${inbound.protocol ? ` (${inbound.protocol}:${inbound.port || 0})` : ''}`, + }))} + /> + + )} +