diff --git a/frontend/src/lib/xray/protocol-capabilities.ts b/frontend/src/lib/xray/protocol-capabilities.ts new file mode 100644 index 00000000..417071df --- /dev/null +++ b/frontend/src/lib/xray/protocol-capabilities.ts @@ -0,0 +1,74 @@ +// Pure-function ports of the legacy Inbound class capability predicates +// (canEnableTls, canEnableReality, canEnableTlsFlow, canEnableStream, +// canEnableVisionSeed, isSS2022, isSSMultiUser). Each accepts the minimal +// slice of an InboundFormValues it needs, so the same predicate can be +// called against a partial-row, a full form value, or a hand-built test +// fixture without the caller projecting a whole object. + +const TLS_ELIGIBLE_PROTOCOLS = ['vmess', 'vless', 'trojan', 'shadowsocks']; +const TLS_NETWORKS = ['tcp', 'ws', 'http', 'grpc', 'httpupgrade', 'xhttp']; +const REALITY_ELIGIBLE_PROTOCOLS = ['vless', 'trojan']; +const REALITY_NETWORKS = ['tcp', 'http', 'grpc', 'xhttp']; +const STREAM_PROTOCOLS = ['vmess', 'vless', 'trojan', 'shadowsocks', 'hysteria']; +const VISION_FLOW = 'xtls-rprx-vision'; +const SS_2022_PREFIX = '2022'; +const SS_BLAKE3_CHACHA20 = '2022-blake3-chacha20-poly1305'; + +export interface CapabilityProtocolSlice { + protocol: string; + streamSettings?: { network?: string; security?: string }; +} + +export interface CapabilityVlessSlice extends CapabilityProtocolSlice { + settings?: { clients?: { flow?: string }[] }; +} + +export interface CapabilityShadowsocksSlice { + protocol: string; + settings?: { method?: string }; +} + +export function canEnableTls(values: CapabilityProtocolSlice): boolean { + if (values.protocol === 'hysteria') return true; + if (!TLS_ELIGIBLE_PROTOCOLS.includes(values.protocol)) return false; + return TLS_NETWORKS.includes(values.streamSettings?.network ?? ''); +} + +export function canEnableReality(values: CapabilityProtocolSlice): boolean { + if (!REALITY_ELIGIBLE_PROTOCOLS.includes(values.protocol)) return false; + return REALITY_NETWORKS.includes(values.streamSettings?.network ?? ''); +} + +export function canEnableTlsFlow(values: CapabilityProtocolSlice): boolean { + const security = values.streamSettings?.security; + if (security !== 'tls' && security !== 'reality') return false; + if (values.streamSettings?.network !== 'tcp') return false; + return values.protocol === 'vless'; +} + +export function canEnableStream(values: { protocol: string }): boolean { + return STREAM_PROTOCOLS.includes(values.protocol); +} + +// Vision seed applies only when XTLS Vision (TCP/TLS) flow is selected +// AND at least one VLESS client uses the vision flow. Excludes UDP variant. +export function canEnableVisionSeed(values: CapabilityVlessSlice): boolean { + if (!canEnableTlsFlow(values)) return false; + const clients = values.settings?.clients; + if (!Array.isArray(clients)) return false; + return clients.some((c) => c?.flow === VISION_FLOW); +} + +// Why: legacy returns true on non-SS protocols too (the method getter +// resolves to "" and "" !== blake3-chacha20-poly1305). Preserved for +// parity with the legacy class; in practice the callers all narrow on +// protocol === shadowsocks before checking. +export function isSSMultiUser(values: CapabilityShadowsocksSlice): boolean { + const method = values.protocol === 'shadowsocks' ? (values.settings?.method ?? '') : ''; + return method !== SS_BLAKE3_CHACHA20; +} + +export function isSS2022(values: CapabilityShadowsocksSlice): boolean { + const method = values.protocol === 'shadowsocks' ? (values.settings?.method ?? '') : ''; + return method.substring(0, 4) === SS_2022_PREFIX; +} diff --git a/frontend/src/test/protocol-capabilities.test.ts b/frontend/src/test/protocol-capabilities.test.ts new file mode 100644 index 00000000..77520f77 --- /dev/null +++ b/frontend/src/test/protocol-capabilities.test.ts @@ -0,0 +1,85 @@ +/// +import { describe, expect, it } from 'vitest'; + +import { Inbound } from '@/models/inbound'; +import { + canEnableTls, + canEnableReality, + canEnableTlsFlow, + canEnableStream, + canEnableVisionSeed, + isSS2022, + isSSMultiUser, +} from '@/lib/xray/protocol-capabilities'; + +// Parity harness for the capability predicates. For each golden fixture +// (protocol+settings), cross with a matrix of stream configurations +// (network × security), build the legacy Inbound class via fromJson, and +// assert each pure-function predicate matches the class method. +// +// Only the (protocol × stream-shape) cross matters here — the predicates +// never read sniffing/port/listen, so we hold those constant. + +const fixtures = import.meta.glob( + './golden/fixtures/inbound/*.json', + { eager: true, import: 'default' }, +); + +interface FixtureShape { protocol: string; settings: Record } + +const STREAM_CASES: { network: string; security: string }[] = [ + { network: 'tcp', security: 'none' }, + { network: 'tcp', security: 'tls' }, + { network: 'tcp', security: 'reality' }, + { network: 'ws', security: 'none' }, + { network: 'ws', security: 'tls' }, + { network: 'grpc', security: 'none' }, + { network: 'grpc', security: 'tls' }, + { network: 'grpc', security: 'reality' }, + { network: 'kcp', security: 'none' }, + { network: 'httpupgrade', security: 'none' }, + { network: 'httpupgrade', security: 'tls' }, + { network: 'xhttp', security: 'none' }, + { network: 'xhttp', security: 'tls' }, + { network: 'xhttp', security: 'reality' }, +]; + +function fixtureName(path: string): string { + return (path.split('/').pop() ?? path).replace(/\.json$/, ''); +} + +describe('protocol capability predicates: pure ↔ legacy parity', () => { + const entries = Object.entries(fixtures).sort(([a], [b]) => a.localeCompare(b)); + for (const [path, raw] of entries) { + const name = fixtureName(path); + const fix = raw as FixtureShape; + + for (const stream of STREAM_CASES) { + + it(`${name} :: ${stream.network}/${stream.security}`, () => { + const wireConfig = { + port: 12345, + listen: '127.0.0.1', + protocol: fix.protocol, + settings: fix.settings, + streamSettings: { network: stream.network, security: stream.security }, + sniffing: {}, + }; + const legacy = Inbound.fromJson(wireConfig); + const values = { + protocol: fix.protocol, + streamSettings: { network: stream.network, security: stream.security }, + settings: fix.settings, + }; + + expect(canEnableTls(values)).toBe(legacy.canEnableTls()); + expect(canEnableReality(values)).toBe(legacy.canEnableReality()); + expect(canEnableTlsFlow(values)).toBe(legacy.canEnableTlsFlow()); + expect(canEnableStream(values)).toBe(legacy.canEnableStream()); + expect(canEnableVisionSeed(values)).toBe(legacy.canEnableVisionSeed()); + expect(isSS2022(values)).toBe(legacy.isSS2022); + expect(isSSMultiUser(values)).toBe(legacy.isSSMultiUser); + }); + } + } +});