From 980511bcad1937ab98aee5fb354cd059e66bea97 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 27 May 2026 12:56:15 +0200 Subject: [PATCH] feat(port-conflict): include offending inbound + L4 in the error, cover quic and tunnel.allowedNetwork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit checkPortConflict used to return a bare bool, which the API layer translated into "Port already exists: 443" with no hint about which existing inbound owned the port, what listen address it used, or which L4 transport actually clashed. On a panel with dozens of inbounds the admin had to scan the list by hand to figure out the collision. Return a portConflictDetail{InboundID, Remark, Tag, Listen, Port, Transports} instead; a String() method formats it as "port 443 (tcp) already used by inbound 'my-vless' (#7) on *" so the existing common.NewError wrapping carries the full context up to the UI without a second round-trip. Two predicate gaps fixed at the same time: - streamSettings.network="quic" rides on UDP the same way "kcp" does, so it now joins KCP in the UDP branch instead of falling through to the TCP default (a QUIC inbound used to silently allow a UDP neighbour on the same port). - Tunnel reads settings.allowedNetwork ("tcp" / "udp" / "tcp,udp"), not settings.network — 3x-ui's dokodemo-door wrapper renames the field, and treating it as Shadowsocks-shaped left every Tunnel inbound looking like plain TCP regardless of what the admin configured. Tests: TCP/UDP coexist + same-transport collision matrix already covered the happy path; added QUICTreatedAsUDP, TunnelAllowedNetwork, and DetailMessage to lock in the new behaviour. Dropped the unused transportBits.conflicts() helper now that the call site composes the mask itself to populate the detail. --- .../src/pages/inbounds/InboundFormModal.tsx | 1178 ++++++++--------- frontend/src/pages/xray/OutboundFormModal.tsx | 44 +- web/service/inbound.go | 12 +- web/service/port_conflict.go | 105 +- web/service/port_conflict_test.go | 129 +- 5 files changed, 815 insertions(+), 653 deletions(-) diff --git a/frontend/src/pages/inbounds/InboundFormModal.tsx b/frontend/src/pages/inbounds/InboundFormModal.tsx index 689ba50b..5227f134 100644 --- a/frontend/src/pages/inbounds/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/InboundFormModal.tsx @@ -1446,13 +1446,13 @@ export default function InboundFormModal({ // the Select shows blank instead of the "Default (path)" option. const newStreamSlice = (n: string): Record => { switch (n) { - case 'tcp': return TcpStreamSettingsSchema.parse({ header: { type: 'none' } }); - case 'kcp': return KcpStreamSettingsSchema.parse({}); - case 'ws': return WsStreamSettingsSchema.parse({}); - case 'grpc': return GrpcStreamSettingsSchema.parse({}); + case 'tcp': return TcpStreamSettingsSchema.parse({ header: { type: 'none' } }); + case 'kcp': return KcpStreamSettingsSchema.parse({}); + case 'ws': return WsStreamSettingsSchema.parse({}); + case 'grpc': return GrpcStreamSettingsSchema.parse({}); case 'httpupgrade': return HttpUpgradeStreamSettingsSchema.parse({}); - case 'xhttp': return XHttpStreamSettingsSchema.parse({}); - default: return {}; + case 'xhttp': return XHttpStreamSettingsSchema.parse({}); + default: return {}; } }; const onNetworkChange = (next: string) => { @@ -1474,7 +1474,7 @@ export default function InboundFormModal({ style={{ width: '75%' }} onChange={onNetworkChange} options={[ - { value: 'tcp', label: 'TCP (RAW)' }, + { value: 'tcp', label: 'RAW' }, { value: 'kcp', label: 'mKCP' }, { value: 'ws', label: 'WebSocket' }, { value: 'grpc', label: 'gRPC' }, @@ -1521,10 +1521,10 @@ export default function InboundFormModal({ ['streamSettings', 'hysteriaSettings', 'masquerade'], checked ? { - type: '', dir: '', url: '', - rewriteHost: false, insecure: false, - content: '', headers: {}, statusCode: 0, - } + type: '', dir: '', url: '', + rewriteHost: false, insecure: false, + content: '', headers: {}, statusCode: 0, + } : undefined, ) } @@ -1644,20 +1644,20 @@ export default function InboundFormModal({ ['streamSettings', 'tcpSettings', 'header'], v ? { - type: 'http', - request: { - version: '1.1', - method: 'GET', - path: ['/'], - headers: {}, - }, - response: { - version: '1.1', - status: '200', - reason: 'OK', - headers: {}, - }, - } + type: 'http', + request: { + version: '1.1', + method: 'GET', + path: ['/'], + headers: {}, + }, + response: { + version: '1.1', + status: '200', + reason: 'OK', + headers: {}, + }, + } : { type: 'none' }, ); }} @@ -2181,233 +2181,233 @@ export default function InboundFormModal({ {on && ( <> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ({ value: c, label: c }))} - /> - - - - - - - - - ({ value: v, label: v }))} - /> - - - {({ getFieldValue, setFieldValue }) => { - const he = getFieldValue(['streamSettings', 'sockopt', 'happyEyeballs']); - const hasHe = he != null; - return ( - <> - - { - setFieldValue( - ['streamSettings', 'sockopt', 'happyEyeballs'], - v ? HappyEyeballsSchema.parse({}) : undefined, - ); - }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - ))} - - )} - + + + + + + + + + + ({ value: v, label: v }))} + /> + + + {({ getFieldValue, setFieldValue }) => { + const he = getFieldValue(['streamSettings', 'sockopt', 'happyEyeballs']); + const hasHe = he != null; + return ( + <> + + { + setFieldValue( + ['streamSettings', 'sockopt', 'happyEyeballs'], + v ? HappyEyeballsSchema.parse({}) : undefined, + ); + }} + /> + + {hasHe && ( + <> + + + + + + + + + + + + + + )} + + ); + }} + + + {(fields, { add, remove }) => ( + <> + + + + {fields.map((field) => ( + + + + + + + + + + + + + + + + ))} + + )} + )} @@ -2473,240 +2473,240 @@ export default function InboundFormModal({ return ( <> - - - - + + ({ value: v, label: v }))} + /> + + + ({ value: v, label: v }))} + options={[ + { value: '', label: 'None' }, + ...Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp })), + ]} /> - - - - ({ value: a, label: a }))} - /> - - - - - - - - - - + + - - - - - - ) : ( - <> - typeof v === 'string' - ? v.split('\n') - : v} - getValueProps={(v) => ({ - value: Array.isArray(v) ? v.join('\n') : v, - })} - > -