mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 12:29:34 +00:00
fix(inbound): re-derive auto tags on edit and keep node tags consistent
Auto-generated inbound tags (in-<port>-<l4>, n<id>- prefixed for node inbounds) now re-derive when port/listen/transport change on update instead of keeping the stale round-tripped value. The resolved tag is mirrored onto the API response, and NodeID is pinned to the stored row so a node inbound never loses its n<id>- prefix on edit. The edit form recomputes the tag live via a Go-parity helper so the JSON preview matches what gets saved. Make node/central tag matching prefix-agnostic in all three places (traffic attribution, remote-id resolution, and the orphan sweep) so an n<id>- prefix present on only one side can no longer spawn duplicate inbounds or drop traffic on sync. Force LF on shell scripts via .gitattributes (CRLF broke the Docker build shebang when the repo is checked out on Windows) and add a .dockerignore to keep node_modules/.git out of the build context. Adds Go and frontend tests covering tag re-derivation, prefix-agnostic matching, and node-snapshot prefix mismatch.
This commit is contained in:
91
frontend/src/lib/xray/inbound-tag.ts
Normal file
91
frontend/src/lib/xray/inbound-tag.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
// Client-side mirror of the backend inbound-tag derivation
|
||||
// (web/service/port_conflict.go). Keep in sync; inbound-tag.test.ts guards parity.
|
||||
|
||||
type TransportBits = number;
|
||||
const TCP: TransportBits = 1;
|
||||
const UDP: TransportBits = 2;
|
||||
|
||||
function asString(v: unknown): string {
|
||||
return typeof v === 'string' ? v : '';
|
||||
}
|
||||
|
||||
function inboundTransports(
|
||||
protocol: string,
|
||||
streamSettings: Record<string, unknown> | undefined,
|
||||
settings: Record<string, unknown> | undefined,
|
||||
): TransportBits {
|
||||
if (protocol === 'hysteria' || protocol === 'wireguard') return UDP;
|
||||
|
||||
let bits: TransportBits = 0;
|
||||
const network = asString(streamSettings?.network);
|
||||
if (network === 'kcp' || network === 'quic') bits |= UDP;
|
||||
else bits |= TCP;
|
||||
|
||||
if (settings) {
|
||||
if (protocol === 'shadowsocks' || protocol === 'tunnel') {
|
||||
const key = protocol === 'tunnel' ? 'allowedNetwork' : 'network';
|
||||
const n = asString(settings[key]);
|
||||
if (n !== '') {
|
||||
bits = 0;
|
||||
for (const part of n.split(',')) {
|
||||
const p = part.trim();
|
||||
if (p === 'tcp') bits |= TCP;
|
||||
else if (p === 'udp') bits |= UDP;
|
||||
}
|
||||
}
|
||||
} else if (protocol === 'mixed') {
|
||||
if (settings.udp === true) bits |= UDP;
|
||||
}
|
||||
}
|
||||
|
||||
if (bits === 0) bits = TCP;
|
||||
return bits;
|
||||
}
|
||||
|
||||
function transportTagSuffix(bits: TransportBits): string {
|
||||
if (bits === TCP) return 'tcp';
|
||||
if (bits === UDP) return 'udp';
|
||||
if (bits === (TCP | UDP)) return 'tcpudp';
|
||||
return 'any';
|
||||
}
|
||||
|
||||
function isAnyListen(listen: string): boolean {
|
||||
return listen === '' || listen === '0.0.0.0' || listen === '::' || listen === '::0';
|
||||
}
|
||||
|
||||
function baseInboundTag(listen: string, port: number): string {
|
||||
return isAnyListen(listen) ? `in-${port}` : `in-${listen}:${port}`;
|
||||
}
|
||||
|
||||
function nodeTagPrefix(nodeId: number | null | undefined): string {
|
||||
return nodeId == null ? '' : `n${nodeId}-`;
|
||||
}
|
||||
|
||||
export interface InboundTagInput {
|
||||
listen: string;
|
||||
port: number;
|
||||
nodeId: number | null | undefined;
|
||||
protocol: string;
|
||||
streamSettings?: Record<string, unknown>;
|
||||
settings?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function composeInboundTag(input: InboundTagInput): string {
|
||||
const bits = inboundTransports(input.protocol, input.streamSettings, input.settings);
|
||||
return (
|
||||
nodeTagPrefix(input.nodeId)
|
||||
+ baseInboundTag(input.listen ?? '', input.port ?? 0)
|
||||
+ '-'
|
||||
+ transportTagSuffix(bits)
|
||||
);
|
||||
}
|
||||
|
||||
export function isAutoInboundTag(tag: string, input: InboundTagInput): boolean {
|
||||
if (tag === '') return true;
|
||||
const base = composeInboundTag(input);
|
||||
if (tag === base) return true;
|
||||
const prefix = `${base}-`;
|
||||
if (!tag.startsWith(prefix)) return false;
|
||||
const suffix = tag.slice(prefix.length);
|
||||
return suffix !== '' && /^[0-9]+$/.test(suffix);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
formValuesToWirePayload,
|
||||
} from '@/lib/xray/inbound-form-adapter';
|
||||
import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
|
||||
import { composeInboundTag, isAutoInboundTag, type InboundTagInput } from '@/lib/xray/inbound-tag';
|
||||
import {
|
||||
canEnableReality,
|
||||
canEnableStream,
|
||||
@@ -158,6 +159,23 @@ export default function InboundFormModal({
|
||||
const network = Form.useWatch(['streamSettings', 'network'], form) ?? '';
|
||||
const security = Form.useWatch(['streamSettings', 'security'], form) ?? 'none';
|
||||
const streamEnabled = canEnableStream({ protocol });
|
||||
|
||||
const wListen = Form.useWatch('listen', form) ?? '';
|
||||
const wPort = Form.useWatch('port', form);
|
||||
const wNodeId = Form.useWatch('nodeId', form) ?? null;
|
||||
const wTag = Form.useWatch('tag', form) ?? '';
|
||||
const wSsNetwork = Form.useWatch(['settings', 'network'], form);
|
||||
const wTunnelNetwork = Form.useWatch(['settings', 'allowedNetwork'], form);
|
||||
const autoTagRef = useRef(true);
|
||||
const lastWrittenTagRef = useRef('');
|
||||
const currentTagInput = (): InboundTagInput => ({
|
||||
listen: typeof wListen === 'string' ? wListen : '',
|
||||
port: typeof wPort === 'number' ? wPort : 0,
|
||||
nodeId: typeof wNodeId === 'number' ? wNodeId : null,
|
||||
protocol,
|
||||
streamSettings: { network },
|
||||
settings: { network: wSsNetwork, allowedNetwork: wTunnelNetwork, udp: mixedUdpOn },
|
||||
});
|
||||
const isFallbackHost =
|
||||
(protocol === Protocols.VLESS || protocol === Protocols.TROJAN)
|
||||
&& network === 'tcp'
|
||||
@@ -273,6 +291,16 @@ export default function InboundFormModal({
|
||||
: buildAddModeValues();
|
||||
form.resetFields();
|
||||
form.setFieldsValue(initial);
|
||||
const initialTag = (initial.tag ?? '') as string;
|
||||
autoTagRef.current = isAutoInboundTag(initialTag, {
|
||||
listen: initial.listen ?? '',
|
||||
port: initial.port ?? 0,
|
||||
nodeId: initial.nodeId ?? null,
|
||||
protocol: initial.protocol,
|
||||
streamSettings: (initial.streamSettings ?? {}) as Record<string, unknown>,
|
||||
settings: (initial.settings ?? {}) as Record<string, unknown>,
|
||||
});
|
||||
lastWrittenTagRef.current = initialTag;
|
||||
if (
|
||||
mode === 'edit'
|
||||
&& dbInbound
|
||||
@@ -286,6 +314,23 @@ export default function InboundFormModal({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, mode, dbInbound, form]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (wTag === lastWrittenTagRef.current) return;
|
||||
autoTagRef.current = isAutoInboundTag(wTag, currentTagInput());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, wTag]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !autoTagRef.current) return;
|
||||
const next = composeInboundTag(currentTagInput());
|
||||
if (next !== (form.getFieldValue('tag') ?? '')) {
|
||||
lastWrittenTagRef.current = next;
|
||||
form.setFieldValue('tag', next);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, wListen, wPort, wNodeId, protocol, network, mixedUdpOn, wSsNetwork, wTunnelNetwork]);
|
||||
|
||||
// Why: protocol picker reset cascades through the form — clearing the
|
||||
// settings DU branch and dropping a nodeId that no longer applies. The
|
||||
// legacy modal did this imperatively in onProtocolChange; here we hook
|
||||
|
||||
65
frontend/src/test/inbound-tag.test.ts
Normal file
65
frontend/src/test/inbound-tag.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { composeInboundTag, isAutoInboundTag, type InboundTagInput } from '@/lib/xray/inbound-tag';
|
||||
|
||||
// Parity with web/service/port_conflict.go TestInboundTransports: the L4 suffix
|
||||
// the tag encodes must match the Go service so the form preview agrees with the
|
||||
// tag the backend re-derives on save.
|
||||
describe('composeInboundTag transport suffix parity', () => {
|
||||
const base = (over: Partial<InboundTagInput>): InboundTagInput => ({
|
||||
listen: '0.0.0.0',
|
||||
port: 443,
|
||||
nodeId: null,
|
||||
protocol: 'vless',
|
||||
...over,
|
||||
});
|
||||
|
||||
const cases: Array<[string, InboundTagInput, string]> = [
|
||||
['vless tcp', base({ streamSettings: { network: 'tcp' } }), 'in-443-tcp'],
|
||||
['vless ws (still tcp)', base({ streamSettings: { network: 'ws' } }), 'in-443-tcp'],
|
||||
['vless kcp is udp', base({ streamSettings: { network: 'kcp' } }), 'in-443-udp'],
|
||||
['vless quic is udp', base({ streamSettings: { network: 'quic' } }), 'in-443-udp'],
|
||||
['vless empty stream defaults tcp', base({}), 'in-443-tcp'],
|
||||
['vmess tcp', base({ protocol: 'vmess', streamSettings: { network: 'tcp' } }), 'in-443-tcp'],
|
||||
['trojan grpc is tcp', base({ protocol: 'trojan', streamSettings: { network: 'grpc' } }), 'in-443-tcp'],
|
||||
['hysteria forced udp', base({ protocol: 'hysteria', streamSettings: { network: 'tcp' } }), 'in-443-udp'],
|
||||
['wireguard forced udp', base({ protocol: 'wireguard' }), 'in-443-udp'],
|
||||
['shadowsocks tcp,udp', base({ protocol: 'shadowsocks', settings: { network: 'tcp,udp' } }), 'in-443-tcpudp'],
|
||||
['shadowsocks udp only', base({ protocol: 'shadowsocks', settings: { network: 'udp' } }), 'in-443-udp'],
|
||||
['shadowsocks tcp only', base({ protocol: 'shadowsocks', settings: { network: 'tcp' } }), 'in-443-tcp'],
|
||||
['mixed udp on', base({ protocol: 'mixed', streamSettings: { network: 'tcp' }, settings: { udp: true } }), 'in-443-tcpudp'],
|
||||
['mixed udp off', base({ protocol: 'mixed', streamSettings: { network: 'tcp' }, settings: { udp: false } }), 'in-443-tcp'],
|
||||
['tunnel allowedNetwork udp', base({ protocol: 'tunnel', settings: { allowedNetwork: 'udp' } }), 'in-443-udp'],
|
||||
];
|
||||
|
||||
it.each(cases)('%s', (_name, input, want) => {
|
||||
expect(composeInboundTag(input)).toBe(want);
|
||||
});
|
||||
|
||||
it('scopes a non-any listen and node prefix', () => {
|
||||
expect(composeInboundTag(base({ listen: '127.0.0.1', port: 8443, streamSettings: { network: 'tcp' } })))
|
||||
.toBe('in-127.0.0.1:8443-tcp');
|
||||
expect(composeInboundTag(base({ nodeId: 1, port: 443, streamSettings: { network: 'tcp' } })))
|
||||
.toBe('n1-in-443-tcp');
|
||||
});
|
||||
});
|
||||
|
||||
// Parity with TestIsAutoGeneratedTag.
|
||||
describe('isAutoInboundTag', () => {
|
||||
const input: InboundTagInput = {
|
||||
listen: '0.0.0.0', port: 443, nodeId: null, protocol: 'vless', streamSettings: { network: 'tcp' },
|
||||
};
|
||||
|
||||
it('recognises canonical, dedup-suffixed and empty as auto', () => {
|
||||
expect(isAutoInboundTag('in-443-tcp', input)).toBe(true);
|
||||
expect(isAutoInboundTag('in-443-tcp-2', input)).toBe(true);
|
||||
expect(isAutoInboundTag('', input)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats custom / stale / malformed-suffix tags as not auto', () => {
|
||||
expect(isAutoInboundTag('my-custom', input)).toBe(false);
|
||||
expect(isAutoInboundTag('in-8443-tcp', input)).toBe(false);
|
||||
expect(isAutoInboundTag('in-443-tcp-x', input)).toBe(false);
|
||||
expect(isAutoInboundTag('in-443-tcp-', input)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user