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 <ho3ein.sanaei@gmail.com>
This commit is contained in:
animesha3
2026-06-11 20:48:26 +02:00
committed by GitHub
parent 2a7342baa9
commit 554d85c2f7
32 changed files with 741 additions and 16 deletions
@@ -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<NodeRecord>): Promise<Msg<string>> =>
HttpUtil.post<string>('/panel/api/nodes/certFingerprint', payload),
fetchInbounds: (payload: Partial<NodeRecord>): Promise<Msg<RemoteInboundOption[]>> =>
HttpUtil.post<RemoteInboundOption[]>('/panel/api/nodes/inbounds', payload),
};
}
+4
View File
@@ -342,6 +342,10 @@ export const EXAMPLES: Record<string, unknown> = {
"guid": "",
"id": 1,
"inboundCount": 5,
"inboundSyncMode": "all",
"inboundTags": [
""
],
"lastError": "",
"lastHeartbeat": 1700000000,
"latencyMs": 42,
+15
View File
@@ -1546,6 +1546,19 @@ export const SCHEMAS: Record<string, unknown> = {
"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<string, unknown> = {
"guid",
"id",
"inboundCount",
"inboundSyncMode",
"inboundTags",
"lastError",
"lastHeartbeat",
"latencyMs",
+2
View File
@@ -348,6 +348,8 @@ export interface Node {
guid: string;
id: number;
inboundCount: number;
inboundSyncMode: string;
inboundTags: string[];
lastError: string;
lastHeartbeat: number;
latencyMs: number;
+2
View File
@@ -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(),
+7
View File
@@ -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',
@@ -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<NodeRecord>) => Promise<Msg<ProbeResult>>;
fetchFingerprint: (payload: Partial<NodeRecord>) => Promise<Msg<string>>;
fetchInbounds: (payload: Partial<NodeRecord>) => Promise<Msg<RemoteInboundOption[]>>;
save: (payload: Partial<NodeRecord>) => Promise<Msg<unknown>>;
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<RemoteInboundOption[]>([]);
const [testResult, setTestResult] = useState<ProbeResult | null>(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({
<Input.Password placeholder={t('pages.nodes.apiTokenPlaceholder')} />
</Form.Item>
<Form.Item
label={t('pages.nodes.inboundSyncMode')}
name="inboundSyncMode"
extra={t('pages.nodes.inboundSyncModeHint')}
>
<Select
options={[
{ value: 'all', label: t('pages.nodes.allInbounds') },
{ value: 'selected', label: t('pages.nodes.selectedInbounds') },
]}
/>
</Form.Item>
{inboundSyncMode === 'selected' && (
<Form.Item
label={t('pages.nodes.inboundTags')}
name="inboundTags"
extra={t('pages.nodes.inboundTagsHint')}
>
<Select
mode="multiple"
allowClear
loading={fetchingInbounds}
placeholder={t('pages.nodes.inboundTagsPlaceholder')}
popupRender={(menu) => (
<>
<Button type="text" block loading={fetchingInbounds} onClick={onFetchInbounds}>
{t('pages.nodes.loadInbounds')}
</Button>
{menu}
</>
)}
options={inboundOptions.map((inbound) => ({
value: inbound.tag,
label: `${inbound.remark || inbound.tag}${inbound.protocol ? ` (${inbound.protocol}:${inbound.port || 0})` : ''}`,
}))}
/>
</Form.Item>
)}
<div className="test-row">
<Button type="default" loading={testing} onClick={onTest}>
{t('pages.nodes.testConnection')}
+2 -1
View File
@@ -30,7 +30,7 @@ export default function NodesPage() {
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const { nodes, loading, fetched, fetchError, refetch, totals } = useNodesQuery();
const { create, update, remove, setEnable, testConnection, fetchFingerprint, probe, updatePanels } = useNodeMutations();
const { create, update, remove, setEnable, testConnection, fetchFingerprint, fetchInbounds, probe, updatePanels } = useNodeMutations();
const { data: latestVersion = '' } = useQuery({
queryKey: ['server', 'panelUpdateInfo'],
@@ -235,6 +235,7 @@ export default function NodesPage() {
node={formNode}
testConnection={testConnection}
fetchFingerprint={fetchFingerprint}
fetchInbounds={fetchInbounds}
save={onSave}
onOpenChange={setFormOpen}
/>
+4
View File
@@ -31,6 +31,8 @@ export const NodeRecordSchema = z.object({
allowPrivateAddress: z.boolean().optional(),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']).optional(),
pinnedCertSha256: z.string().optional(),
inboundSyncMode: z.enum(['all', 'selected']).optional(),
inboundTags: z.array(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(),
@@ -63,6 +65,8 @@ export const NodeFormSchema = z.object({
allowPrivateAddress: z.boolean(),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']),
pinnedCertSha256: z.string().optional().default(''),
inboundSyncMode: z.enum(['all', 'selected']),
inboundTags: z.array(z.string()),
});
export type NodeRecord = z.infer<typeof NodeRecordSchema>;