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 <ho3ein.sanaei@gmail.com>
This commit is contained in:
Nikan Zeyaei
2026-06-15 00:40:52 +03:30
committed by GitHub
parent 2188830612
commit 05ad7f417c
33 changed files with 443 additions and 41 deletions
+6 -3
View File
@@ -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<string>();
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);
+1
View File
@@ -354,6 +354,7 @@ export const EXAMPLES: Record<string, unknown> = {
"memPct": 45.1,
"name": "de-fra-1",
"onlineCount": 3,
"outboundTag": "",
"panelVersion": "v3.x.x",
"parentGuid": "",
"pinnedCertSha256": "",
+4
View File
@@ -1595,6 +1595,9 @@ export const SCHEMAS: Record<string, unknown> = {
"example": 3,
"type": "integer"
},
"outboundTag": {
"type": "string"
},
"panelVersion": {
"example": "v3.x.x",
"type": "string"
@@ -1682,6 +1685,7 @@ export const SCHEMAS: Record<string, unknown> = {
"memPct",
"name",
"onlineCount",
"outboundTag",
"panelVersion",
"pinnedCertSha256",
"port",
+1
View File
@@ -358,6 +358,7 @@ export interface Node {
memPct: number;
name: string;
onlineCount: number;
outboundTag: string;
panelVersion: string;
parentGuid?: string;
pinnedCertSha256: string;
+1
View File
@@ -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(),
@@ -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({
<Input.Password placeholder={t('pages.nodes.apiTokenPlaceholder')} />
</Form.Item>
<Form.Item
label={t('pages.nodes.outboundTag')}
name="outboundTag"
extra={t('pages.nodes.outboundTagHint')}
>
<Select
allowClear
showSearch
placeholder={t('pages.nodes.outboundTagPlaceholder')}
options={(outboundTags ?? []).map((tag) => ({ value: tag, label: tag }))}
/>
</Form.Item>
<Form.Item
label={t('pages.nodes.inboundSyncMode')}
name="inboundSyncMode"
+2
View File
@@ -34,6 +34,7 @@ export const NodeRecordSchema = z.object({
inboundSyncMode: z.enum(['all', 'selected']).optional(),
// Backend serializes a nil []string as null for nodes saved before #5178.
inboundTags: z.array(z.string()).nullish(),
outboundTag: z.string().optional(),
// Multi-hop node tree (#4983): a node's stable GUID, its parent's GUID, and
// whether it's a read-only transitive sub-node surfaced from a downstream node.
guid: z.string().optional(),
@@ -70,6 +71,7 @@ export const NodeFormSchema = z.object({
// Unmounted when sync mode is "all" (absent from antd onFinish values) and
// serialized as null by the backend for a nil slice — tolerate both.
inboundTags: z.array(z.string()).nullish().transform((tags) => tags ?? []),
outboundTag: z.string().optional(),
});
export type NodeRecord = z.infer<typeof NodeRecordSchema>;