From 05ad7f417cec0069166792c041b51a0224d8daaf Mon Sep 17 00:00:00 2001 From: Nikan Zeyaei <72458440+NikanZeyaei@users.noreply.github.com> Date: Mon, 15 Jun 2026 00:40:52 +0330 Subject: [PATCH] feat(node): per node outbound routing (#5275) * feat: add per-node outbound routing for panel-to-node connections * feat(ui): add outbound tag selector to node form with i18n * fix(xray): avoid potential overflow warning in node egress rule allocation * chore: run "npm run gen" * fix --------- Co-authored-by: Sanaei --- frontend/public/openapi.json | 5 + frontend/src/api/queries/useOutboundTags.ts | 9 +- frontend/src/generated/examples.ts | 1 + frontend/src/generated/schemas.ts | 4 + frontend/src/generated/types.ts | 1 + frontend/src/generated/zod.ts | 1 + frontend/src/pages/nodes/NodeFormModal.tsx | 17 +++ frontend/src/schemas/node.ts | 2 + internal/database/model/model.go | 1 + internal/web/controller/node.go | 57 +++++++- internal/web/runtime/manager.go | 28 +++- internal/web/runtime/remote.go | 15 ++- internal/web/runtime/remote_test.go | 2 +- internal/web/runtime/tls_client.go | 53 ++++++-- internal/web/runtime/tls_client_test.go | 8 +- .../service/inbound_node_reconcile_test.go | 4 +- internal/web/service/node.go | 125 +++++++++++++++++- internal/web/service/setting.go | 20 +++ internal/web/service/xray.go | 91 +++++++++++++ internal/web/translation/ar-EG.json | 3 + internal/web/translation/en-US.json | 3 + internal/web/translation/es-ES.json | 3 + internal/web/translation/fa-IR.json | 3 + internal/web/translation/id-ID.json | 3 + internal/web/translation/ja-JP.json | 3 + internal/web/translation/pt-BR.json | 3 + internal/web/translation/ru-RU.json | 3 + internal/web/translation/tr-TR.json | 3 + internal/web/translation/uk-UA.json | 3 + internal/web/translation/vi-VN.json | 3 + internal/web/translation/zh-CN.json | 3 + internal/web/translation/zh-TW.json | 3 + internal/web/web.go | 1 + 33 files changed, 443 insertions(+), 41 deletions(-) diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index e7581aaec..8d1275b28 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -1621,6 +1621,9 @@ "example": 3, "type": "integer" }, + "outboundTag": { + "type": "string" + }, "panelVersion": { "example": "v3.x.x", "type": "string" @@ -1708,6 +1711,7 @@ "memPct", "name", "onlineCount", + "outboundTag", "panelVersion", "pinnedCertSha256", "port", @@ -6118,6 +6122,7 @@ "memPct": 45.1, "name": "de-fra-1", "onlineCount": 3, + "outboundTag": "", "panelVersion": "v3.x.x", "parentGuid": "", "pinnedCertSha256": "", diff --git a/frontend/src/api/queries/useOutboundTags.ts b/frontend/src/api/queries/useOutboundTags.ts index 102dea7f8..33c242886 100644 --- a/frontend/src/api/queries/useOutboundTags.ts +++ b/frontend/src/api/queries/useOutboundTags.ts @@ -7,7 +7,8 @@ import { fetchXrayConfig } from '@/hooks/useXraySetting'; // inbound's Telegram traffic to. Shares the cached xray config query so opening // the inbound form costs no extra request when the Xray page was already // visited; `select` derives just the tag list without disturbing other readers. -export function useOutboundTags() { +export function useOutboundTags(opts?: { excludeBlackhole?: boolean }) { + const excludeBlackhole = opts?.excludeBlackhole ?? false; return useQuery({ queryKey: keys.xray.config(), queryFn: fetchXrayConfig, @@ -15,8 +16,10 @@ export function useOutboundTags() { select: (data): string[] => { const tags = new Set(); for (const o of data?.xraySetting?.outbounds ?? []) { - const tag = (o as { tag?: string } | null)?.tag; - if (tag) tags.add(tag); + const ob = o as { tag?: string; protocol?: string } | null; + if (!ob?.tag) continue; + if (excludeBlackhole && ob.protocol === 'blackhole') continue; + tags.add(ob.tag); } for (const t of data?.subscriptionOutboundTags ?? []) { if (t) tags.add(t); diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index fa76c9e63..59b9f783d 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -354,6 +354,7 @@ export const EXAMPLES: Record = { "memPct": 45.1, "name": "de-fra-1", "onlineCount": 3, + "outboundTag": "", "panelVersion": "v3.x.x", "parentGuid": "", "pinnedCertSha256": "", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 3dea9c489..9a524fac1 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -1595,6 +1595,9 @@ export const SCHEMAS: Record = { "example": 3, "type": "integer" }, + "outboundTag": { + "type": "string" + }, "panelVersion": { "example": "v3.x.x", "type": "string" @@ -1682,6 +1685,7 @@ export const SCHEMAS: Record = { "memPct", "name", "onlineCount", + "outboundTag", "panelVersion", "pinnedCertSha256", "port", diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index f38e609b8..884a4d5dc 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -358,6 +358,7 @@ export interface Node { memPct: number; name: string; onlineCount: number; + outboundTag: string; panelVersion: string; parentGuid?: string; pinnedCertSha256: string; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 9a49eefec..cfdc086f1 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -384,6 +384,7 @@ export const NodeSchema = z.object({ memPct: z.number(), name: z.string(), onlineCount: z.number().int(), + outboundTag: z.string(), panelVersion: z.string(), parentGuid: z.string().optional(), pinnedCertSha256: z.string(), diff --git a/frontend/src/pages/nodes/NodeFormModal.tsx b/frontend/src/pages/nodes/NodeFormModal.tsx index 6d5f58d9a..3941f70bf 100644 --- a/frontend/src/pages/nodes/NodeFormModal.tsx +++ b/frontend/src/pages/nodes/NodeFormModal.tsx @@ -18,6 +18,7 @@ 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'; +import { useOutboundTags } from '@/api/queries/useOutboundTags'; import './NodeFormModal.css'; type Mode = 'add' | 'edit'; @@ -49,6 +50,7 @@ function defaultValues(): NodeFormValues { pinnedCertSha256: '', inboundSyncMode: 'all', inboundTags: [], + outboundTag: '', }; } @@ -75,6 +77,7 @@ export default function NodeFormModal({ const scheme = Form.useWatch('scheme', form) ?? 'https'; const tlsVerifyMode = Form.useWatch('tlsVerifyMode', form) ?? 'verify'; const inboundSyncMode = Form.useWatch('inboundSyncMode', form) ?? 'all'; + const { data: outboundTags } = useOutboundTags({ excludeBlackhole: true }); useEffect(() => { if (!open) return; @@ -117,6 +120,7 @@ export default function NodeFormModal({ pinnedCertSha256: values.tlsVerifyMode === 'pin' ? values.pinnedCertSha256.trim() : '', inboundSyncMode: values.inboundSyncMode, inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [], + outboundTag: values.outboundTag || '', }; } @@ -356,6 +360,19 @@ export default function NodeFormModal({ + +