From 35f4c521c367cc2adf6f754db4a8c9ca4c544769 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?= Date: Mon, 28 Sep 2026 00:36:07 +0800 Subject: [PATCH] fix(vless): stop exporting flow on transports that cannot carry it (#87) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(vless): stop exporting flow on transports that cannot carry it The form promised that flow is cleared for ws/grpc/xhttp and the tooltip still says so, but exported links kept carrying flow=xtls-rprx-vision. Two separate reasons, both fixed. The clear came too late to matter. formValuesToConfig wrote `flow` into the config inside the protocol switch, and only afterwards did the "Vision is TCP-only" rule run — where it mutated the form state, not the config it had already been copied into. The transport is now resolved before anything writes, and the drop also removes a flow inherited from the previous config, so changing a listener's transport actually takes effect. The URI generators never asked. Every one of them emitted whatever flow it found: - node: vlessURIs decided flow before resolving the transport, so the type it had just computed was irrelevant to the decision. It now resolves the transport first. ClientURIsWithCredentials also merged the listener-level flow onto every user on protocol alone, which reintroduced the value the form had dropped. - protocol: the share URI and the client YAML both wrote spec.Flow unguarded, even though TransportSpec knows the network. Same for the server-side compile, which pushed the listener default onto users — Mihomo rejects that combination outright, so this one takes the listener down. - mui: BuildVLESSShare set flow unconditionally, and bridge.go injected the listener default into users before it was known to be applicable. - converter: copyTransport resolved `network` but left a stale flow behind. The rule is now one predicate per representation: TransportSpec.CarriesFlow, VLESSHandlerSpec.CarriesFlow, and protocol.TransportCarriesFlow for raw configs. TCP/raw keeps Vision; ws, grpc, xhttp, mkcp and mekya never emit it. Tests pin each path in both directions — a stale flow disappears on ws/grpc/ xhttp, and a genuine TCP listener keeps its flow. The frontend one runs the real form builder and fails, in three of five cases, against the previous ordering. * fix(uri): carry vmess ws Host and align hysteria2 TLS flags across user shapes Auditing the remaining exporters after the flow fix turned up two more places where the link disagreed with the listener. vmessURIs built its JSON by hand instead of going through transportParams, so a ws listener that pins a Host header exported only the path: the client had nothing to send as Host and the listener was unreachable through the link, even though the client YAML built from the same config worked. It also dropped allowInsecure, which vless and trojan both carry — a listener whose certificate the client cannot verify exported a link that failed the handshake. The repeated tlsParams() calls were hoisted while touching this. hysteria2URIs reads users either as rows or as a map, and the two branches disagreed: one asked certutil.ShouldSkipCertVerify and emitted only `insecure`, the other asked clientSkipCert and emitted `insecure` plus `allowInsecure`. The same listener therefore produced different links depending on how its users were stored. Both now use clientSkipCert, the helper intended for share URIs, and emit both keys. Not fixed, for the record: an SS listener with simple-obfs exports a bare ss:// link with no `plugin=` entry, so the obfs configuration is lost. The client YAML does translate it (plugin: obfs). Fixing the URI means committing to a SIP002 plugin encoding that varies between clients, which is worth deciding separately rather than guessing at here. * i18n: complete zh-CN and guard against the locale regressions already seen zh-CN was missing seven listener hints that en.ts declares, so those tooltips rendered in English for the primary UI language. Translated: client_auth_cert, client_auth_type, ech_key, enable_pure_downlink, mux, padding and table_type. The rest of the catalogue is deliberately left alone. Seventeen locales each lack 65 keys that en.ts has; the provider already resolves those from en.ts, so they degrade to English rather than breaking, and filling 1100 strings with unreviewed machine translation would be worse than the fallback. The test prints the per-locale gap so it stays visible. The new locale test guards the regressions this directory has actually hit: a duplicated key inside one object (which breaks the type check), and a key renamed in en.ts leaving an orphan behind. It also asserts zh-CN stays complete, since a gap there reads as a defect rather than as translation lag. * i18n: declare the 24 keys the UI asks for but no locale ever defined Twenty-four keys were referenced from components without being declared in any locale, so t() fell through to its hardcoded fallback. Where a fallback existed that merely meant the string could never be translated; where none existed — protocolHint, access_sniHint, jlsHint, shadowTlsHint and the rest — the tooltip either rendered the raw key, "listeners.protocolHint", or disappeared entirely, because fieldTip() suppresses a value that equals the key it looked up. en.ts takes the text the code was already falling back to, so nothing changes on screen; the strings just become translatable. The few without a fallback were written from the surrounding context — the field they annotate, its placeholder, and the wording of the neighbouring hints. trafficMultiplierHint is the exception: its fallback was Chinese, which is wrong for the English catalogue, so it now has a real English string. zh-CN gets all twenty-four, which keeps it complete against en.ts. The locale test now reads through the TypeScript AST. It previously parsed line by line and these files contain minified regions — several "key":"value" pairs on one line — so a whole block of keys was invisible to it, which is how this gap stayed hidden. It also gained the assertion that would have caught the bug directly: every dotted key handed to t() or fieldTip() must be declared in en.ts. The remaining languages still lag, now by 89 keys rather than 65, and still fall back to English; the test prints the per-locale figure. * fix(uri): drop the vmess allowInsecure field the reference client ignores The vmess skip-certificate field was added on internal consistency alone — vless and trojan carry allowInsecure in their query strings, so vmess looked like an oversight. Checked against the client that actually parses these links, it is not one. v2rayN's VmessQRCode model declares exactly: v, ps, add, port, id, aid, scy, net, type, host, path, tls, sni, alpn, fp. There is no skip-certificate field at all — neither allowInsecure nor insecure — so the field was dead weight in a payload that v2rayN reads field by field. v2rayNG reads `insecure` instead, and Xray-core has removed allowInsecure in favour of pinnedPeerCertSha256, so there is no spelling that the reference clients agree on. Emitting one would have produced a link that looks configured but is not. The ws `host` fix stands: v2rayN's own documentation lists "ws host" as the meaning of that field, so dropping it broke ws listeners that pin a Host. The conformance test now pins the emitted payload to v2rayN's field set, so a future field copied over from another protocol fails instead of being silently ignored by every client. Deliberately left alone, both confirmed against the official scheme and worth deciding separately: - hysteria2 links carry up/down. The Hysteria 2 URI specification is explicit that the parameters "should never include client modes ... or bandwidth values". Removing them changes behaviour for anyone relying on the link, so it is flagged rather than removed. `insecure` is the official parameter; allowInsecure is kept alongside it purely for clients that read that name. - an SS listener with simple-obfs still exports a bare ss:// link with no plugin entry, losing the obfuscation the client YAML does translate. Also noted while checking: tuicURIs defaults congestion_control to bbr where sing-box documents cubic as its default. Ours is set explicitly, which is valid, but the comment claiming it is what "most TUIC clients" use is not sourced. * fix(uri): keep vmess allowInsecure after all Reverts the revert. Dropping it was correct on the evidence and wrong on the consequences: v2rayN ignores the field, v2rayNG spells it `insecure`, and Xray-core has moved to pinnedPeerCertSha256 — but the clients that do read allowInsecure are precisely the ones whose handshake fails without it, and an ignored extra key costs the others nothing. Removing it would have traded a working link for a tidy payload. The conformance test keeps its guard, with allowInsecure recorded as an explicit, justified exception rather than a silent inclusion: the field set is still pinned to v2rayN's model, so any further field copied over from another protocol has to argue its way in. * fix(uri): restore the vmess ws Host in registry YAML, and stop dropping share links Two leftovers from auditing the exporters end to end. The registry's VMess compiler built ws-opts with the path only, so a listener that pins a Host exported a client config with nothing to send as Host. The vless YAML path in the same file already nests it under `headers` per the mihomo wiki, so this just mirrors that — and the converter has always carried it, which is how the difference went unnoticed. The node URI share endpoint also had a tier that could not be reached. It walks three tiers — m-ui, the registry, then the legacy builders — and the second one gates the third on having neither a URI nor client YAML. But the registry implements URI export for a different protocol set than the legacy builders: it compiles vmess, snell, shadowquic, mieru, sudoku and trusttunnel to client YAML without ever emitting a URI, so those listeners arrived at the third tier with a YAML in hand and the condition already false, and reported no link at all even though ClientURIsWithCredentials exports one. The third tier now runs whenever the URI is still missing, and only fills in YAML that earlier tiers did not already provide. --------- Co-authored-by: 風雪 fix(vless): stop exporting flow on transports that cannot carry it (#87) * fix(vless): stop exporting flow on transports that cannot carry it The form promised that flow is cleared for ws/grpc/xhttp and the tooltip still says so, but exported links kept carrying flow=xtls-rprx-vision. Two separate reasons, both fixed. The clear came too late to matter. formValuesToConfig wrote `flow` into the config inside the protocol switch, and only afterwards did the "Vision is TCP-only" rule run — where it mutated the form state, not the config it had already been copied into. The transport is now resolved before anything writes, and the drop also removes a flow inherited from the previous config, so changing a listener's transport actually takes effect. The URI generators never asked. Every one of them emitted whatever flow it found: - node: vlessURIs decided flow before resolving the transport, so the type it had just computed was irrelevant to the decision. It now resolves the transport first. ClientURIsWithCredentials also merged the listener-level flow onto every user on protocol alone, which reintroduced the value the form had dropped. - protocol: the share URI and the client YAML both wrote spec.Flow unguarded, even though TransportSpec knows the network. Same for the server-side compile, which pushed the listener default onto users — Mihomo rejects that combination outright, so this one takes the listener down. - mui: BuildVLESSShare set flow unconditionally, and bridge.go injected the listener default into users before it was known to be applicable. - converter: copyTransport resolved `network` but left a stale flow behind. The rule is now one predicate per representation: TransportSpec.CarriesFlow, VLESSHandlerSpec.CarriesFlow, and protocol.TransportCarriesFlow for raw configs. TCP/raw keeps Vision; ws, grpc, xhttp, mkcp and mekya never emit it. Tests pin each path in both directions — a stale flow disappears on ws/grpc/ xhttp, and a genuine TCP listener keeps its flow. The frontend one runs the real form builder and fails, in three of five cases, against the previous ordering. * fix(uri): carry vmess ws Host and align hysteria2 TLS flags across user shapes Auditing the remaining exporters after the flow fix turned up two more places where the link disagreed with the listener. vmessURIs built its JSON by hand instead of going through transportParams, so a ws listener that pins a Host header exported only the path: the client had nothing to send as Host and the listener was unreachable through the link, even though the client YAML built from the same config worked. It also dropped allowInsecure, which vless and trojan both carry — a listener whose certificate the client cannot verify exported a link that failed the handshake. The repeated tlsParams() calls were hoisted while touching this. hysteria2URIs reads users either as rows or as a map, and the two branches disagreed: one asked certutil.ShouldSkipCertVerify and emitted only `insecure`, the other asked clientSkipCert and emitted `insecure` plus `allowInsecure`. The same listener therefore produced different links depending on how its users were stored. Both now use clientSkipCert, the helper intended for share URIs, and emit both keys. Not fixed, for the record: an SS listener with simple-obfs exports a bare ss:// link with no `plugin=` entry, so the obfs configuration is lost. The client YAML does translate it (plugin: obfs). Fixing the URI means committing to a SIP002 plugin encoding that varies between clients, which is worth deciding separately rather than guessing at here. * i18n: complete zh-CN and guard against the locale regressions already seen zh-CN was missing seven listener hints that en.ts declares, so those tooltips rendered in English for the primary UI language. Translated: client_auth_cert, client_auth_type, ech_key, enable_pure_downlink, mux, padding and table_type. The rest of the catalogue is deliberately left alone. Seventeen locales each lack 65 keys that en.ts has; the provider already resolves those from en.ts, so they degrade to English rather than breaking, and filling 1100 strings with unreviewed machine translation would be worse than the fallback. The test prints the per-locale gap so it stays visible. The new locale test guards the regressions this directory has actually hit: a duplicated key inside one object (which breaks the type check), and a key renamed in en.ts leaving an orphan behind. It also asserts zh-CN stays complete, since a gap there reads as a defect rather than as translation lag. * i18n: declare the 24 keys the UI asks for but no locale ever defined Twenty-four keys were referenced from components without being declared in any locale, so t() fell through to its hardcoded fallback. Where a fallback existed that merely meant the string could never be translated; where none existed — protocolHint, access_sniHint, jlsHint, shadowTlsHint and the rest — the tooltip either rendered the raw key, "listeners.protocolHint", or disappeared entirely, because fieldTip() suppresses a value that equals the key it looked up. en.ts takes the text the code was already falling back to, so nothing changes on screen; the strings just become translatable. The few without a fallback were written from the surrounding context — the field they annotate, its placeholder, and the wording of the neighbouring hints. trafficMultiplierHint is the exception: its fallback was Chinese, which is wrong for the English catalogue, so it now has a real English string. zh-CN gets all twenty-four, which keeps it complete against en.ts. The locale test now reads through the TypeScript AST. It previously parsed line by line and these files contain minified regions — several "key":"value" pairs on one line — so a whole block of keys was invisible to it, which is how this gap stayed hidden. It also gained the assertion that would have caught the bug directly: every dotted key handed to t() or fieldTip() must be declared in en.ts. The remaining languages still lag, now by 89 keys rather than 65, and still fall back to English; the test prints the per-locale figure. * fix(uri): drop the vmess allowInsecure field the reference client ignores The vmess skip-certificate field was added on internal consistency alone — vless and trojan carry allowInsecure in their query strings, so vmess looked like an oversight. Checked against the client that actually parses these links, it is not one. v2rayN's VmessQRCode model declares exactly: v, ps, add, port, id, aid, scy, net, type, host, path, tls, sni, alpn, fp. There is no skip-certificate field at all — neither allowInsecure nor insecure — so the field was dead weight in a payload that v2rayN reads field by field. v2rayNG reads `insecure` instead, and Xray-core has removed allowInsecure in favour of pinnedPeerCertSha256, so there is no spelling that the reference clients agree on. Emitting one would have produced a link that looks configured but is not. The ws `host` fix stands: v2rayN's own documentation lists "ws host" as the meaning of that field, so dropping it broke ws listeners that pin a Host. The conformance test now pins the emitted payload to v2rayN's field set, so a future field copied over from another protocol fails instead of being silently ignored by every client. Deliberately left alone, both confirmed against the official scheme and worth deciding separately: - hysteria2 links carry up/down. The Hysteria 2 URI specification is explicit that the parameters "should never include client modes ... or bandwidth values". Removing them changes behaviour for anyone relying on the link, so it is flagged rather than removed. `insecure` is the official parameter; allowInsecure is kept alongside it purely for clients that read that name. - an SS listener with simple-obfs still exports a bare ss:// link with no plugin entry, losing the obfuscation the client YAML does translate. Also noted while checking: tuicURIs defaults congestion_control to bbr where sing-box documents cubic as its default. Ours is set explicitly, which is valid, but the comment claiming it is what "most TUIC clients" use is not sourced. * fix(uri): keep vmess allowInsecure after all Reverts the revert. Dropping it was correct on the evidence and wrong on the consequences: v2rayN ignores the field, v2rayNG spells it `insecure`, and Xray-core has moved to pinnedPeerCertSha256 — but the clients that do read allowInsecure are precisely the ones whose handshake fails without it, and an ignored extra key costs the others nothing. Removing it would have traded a working link for a tidy payload. The conformance test keeps its guard, with allowInsecure recorded as an explicit, justified exception rather than a silent inclusion: the field set is still pinned to v2rayN's model, so any further field copied over from another protocol has to argue its way in. * fix(uri): restore the vmess ws Host in registry YAML, and stop dropping share links Two leftovers from auditing the exporters end to end. The registry's VMess compiler built ws-opts with the path only, so a listener that pins a Host exported a client config with nothing to send as Host. The vless YAML path in the same file already nests it under `headers` per the mihomo wiki, so this just mirrors that — and the converter has always carried it, which is how the difference went unnoticed. The node URI share endpoint also had a tier that could not be reached. It walks three tiers — m-ui, the registry, then the legacy builders — and the second one gates the third on having neither a URI nor client YAML. But the registry implements URI export for a different protocol set than the legacy builders: it compiles vmess, snell, shadowquic, mieru, sudoku and trusttunnel to client YAML without ever emitting a URI, so those listeners arrived at the third tier with a YAML in hand and the condition already false, and reported no link at all even though ClientURIsWithCredentials exports one. The third tier now runs whenever the URI is still missing, and only fills in YAML that earlier tiers did not already provide. --------- Co-authored-by: 風雪 fix(vless): stop exporting flow on transports that cannot carry it (#87) * fix(vless): stop exporting flow on transports that cannot carry it The form promised that flow is cleared for ws/grpc/xhttp and the tooltip still says so, but exported links kept carrying flow=xtls-rprx-vision. Two separate reasons, both fixed. The clear came too late to matter. formValuesToConfig wrote `flow` into the config inside the protocol switch, and only afterwards did the "Vision is TCP-only" rule run — where it mutated the form state, not the config it had already been copied into. The transport is now resolved before anything writes, and the drop also removes a flow inherited from the previous config, so changing a listener's transport actually takes effect. The URI generators never asked. Every one of them emitted whatever flow it found: - node: vlessURIs decided flow before resolving the transport, so the type it had just computed was irrelevant to the decision. It now resolves the transport first. ClientURIsWithCredentials also merged the listener-level flow onto every user on protocol alone, which reintroduced the value the form had dropped. - protocol: the share URI and the client YAML both wrote spec.Flow unguarded, even though TransportSpec knows the network. Same for the server-side compile, which pushed the listener default onto users — Mihomo rejects that combination outright, so this one takes the listener down. - mui: BuildVLESSShare set flow unconditionally, and bridge.go injected the listener default into users before it was known to be applicable. - converter: copyTransport resolved `network` but left a stale flow behind. The rule is now one predicate per representation: TransportSpec.CarriesFlow, VLESSHandlerSpec.CarriesFlow, and protocol.TransportCarriesFlow for raw configs. TCP/raw keeps Vision; ws, grpc, xhttp, mkcp and mekya never emit it. Tests pin each path in both directions — a stale flow disappears on ws/grpc/ xhttp, and a genuine TCP listener keeps its flow. The frontend one runs the real form builder and fails, in three of five cases, against the previous ordering. * fix(uri): carry vmess ws Host and align hysteria2 TLS flags across user shapes Auditing the remaining exporters after the flow fix turned up two more places where the link disagreed with the listener. vmessURIs built its JSON by hand instead of going through transportParams, so a ws listener that pins a Host header exported only the path: the client had nothing to send as Host and the listener was unreachable through the link, even though the client YAML built from the same config worked. It also dropped allowInsecure, which vless and trojan both carry — a listener whose certificate the client cannot verify exported a link that failed the handshake. The repeated tlsParams() calls were hoisted while touching this. hysteria2URIs reads users either as rows or as a map, and the two branches disagreed: one asked certutil.ShouldSkipCertVerify and emitted only `insecure`, the other asked clientSkipCert and emitted `insecure` plus `allowInsecure`. The same listener therefore produced different links depending on how its users were stored. Both now use clientSkipCert, the helper intended for share URIs, and emit both keys. Not fixed, for the record: an SS listener with simple-obfs exports a bare ss:// link with no `plugin=` entry, so the obfs configuration is lost. The client YAML does translate it (plugin: obfs). Fixing the URI means committing to a SIP002 plugin encoding that varies between clients, which is worth deciding separately rather than guessing at here. * i18n: complete zh-CN and guard against the locale regressions already seen zh-CN was missing seven listener hints that en.ts declares, so those tooltips rendered in English for the primary UI language. Translated: client_auth_cert, client_auth_type, ech_key, enable_pure_downlink, mux, padding and table_type. The rest of the catalogue is deliberately left alone. Seventeen locales each lack 65 keys that en.ts has; the provider already resolves those from en.ts, so they degrade to English rather than breaking, and filling 1100 strings with unreviewed machine translation would be worse than the fallback. The test prints the per-locale gap so it stays visible. The new locale test guards the regressions this directory has actually hit: a duplicated key inside one object (which breaks the type check), and a key renamed in en.ts leaving an orphan behind. It also asserts zh-CN stays complete, since a gap there reads as a defect rather than as translation lag. * i18n: declare the 24 keys the UI asks for but no locale ever defined Twenty-four keys were referenced from components without being declared in any locale, so t() fell through to its hardcoded fallback. Where a fallback existed that merely meant the string could never be translated; where none existed — protocolHint, access_sniHint, jlsHint, shadowTlsHint and the rest — the tooltip either rendered the raw key, "listeners.protocolHint", or disappeared entirely, because fieldTip() suppresses a value that equals the key it looked up. en.ts takes the text the code was already falling back to, so nothing changes on screen; the strings just become translatable. The few without a fallback were written from the surrounding context — the field they annotate, its placeholder, and the wording of the neighbouring hints. trafficMultiplierHint is the exception: its fallback was Chinese, which is wrong for the English catalogue, so it now has a real English string. zh-CN gets all twenty-four, which keeps it complete against en.ts. The locale test now reads through the TypeScript AST. It previously parsed line by line and these files contain minified regions — several "key":"value" pairs on one line — so a whole block of keys was invisible to it, which is how this gap stayed hidden. It also gained the assertion that would have caught the bug directly: every dotted key handed to t() or fieldTip() must be declared in en.ts. The remaining languages still lag, now by 89 keys rather than 65, and still fall back to English; the test prints the per-locale figure. * fix(uri): drop the vmess allowInsecure field the reference client ignores The vmess skip-certificate field was added on internal consistency alone — vless and trojan carry allowInsecure in their query strings, so vmess looked like an oversight. Checked against the client that actually parses these links, it is not one. v2rayN's VmessQRCode model declares exactly: v, ps, add, port, id, aid, scy, net, type, host, path, tls, sni, alpn, fp. There is no skip-certificate field at all — neither allowInsecure nor insecure — so the field was dead weight in a payload that v2rayN reads field by field. v2rayNG reads `insecure` instead, and Xray-core has removed allowInsecure in favour of pinnedPeerCertSha256, so there is no spelling that the reference clients agree on. Emitting one would have produced a link that looks configured but is not. The ws `host` fix stands: v2rayN's own documentation lists "ws host" as the meaning of that field, so dropping it broke ws listeners that pin a Host. The conformance test now pins the emitted payload to v2rayN's field set, so a future field copied over from another protocol fails instead of being silently ignored by every client. Deliberately left alone, both confirmed against the official scheme and worth deciding separately: - hysteria2 links carry up/down. The Hysteria 2 URI specification is explicit that the parameters "should never include client modes ... or bandwidth values". Removing them changes behaviour for anyone relying on the link, so it is flagged rather than removed. `insecure` is the official parameter; allowInsecure is kept alongside it purely for clients that read that name. - an SS listener with simple-obfs still exports a bare ss:// link with no plugin entry, losing the obfuscation the client YAML does translate. Also noted while checking: tuicURIs defaults congestion_control to bbr where sing-box documents cubic as its default. Ours is set explicitly, which is valid, but the comment claiming it is what "most TUIC clients" use is not sourced. * fix(uri): keep vmess allowInsecure after all Reverts the revert. Dropping it was correct on the evidence and wrong on the consequences: v2rayN ignores the field, v2rayNG spells it `insecure`, and Xray-core has moved to pinnedPeerCertSha256 — but the clients that do read allowInsecure are precisely the ones whose handshake fails without it, and an ignored extra key costs the others nothing. Removing it would have traded a working link for a tidy payload. The conformance test keeps its guard, with allowInsecure recorded as an explicit, justified exception rather than a silent inclusion: the field set is still pinned to v2rayN's model, so any further field copied over from another protocol has to argue its way in. * fix(uri): restore the vmess ws Host in registry YAML, and stop dropping share links Two leftovers from auditing the exporters end to end. The registry's VMess compiler built ws-opts with the path only, so a listener that pins a Host exported a client config with nothing to send as Host. The vless YAML path in the same file already nests it under `headers` per the mihomo wiki, so this just mirrors that — and the converter has always carried it, which is how the difference went unnoticed. The node URI share endpoint also had a tier that could not be reached. It walks three tiers — m-ui, the registry, then the legacy builders — and the second one gates the third on having neither a URI nor client YAML. But the registry implements URI export for a different protocol set than the legacy builders: it compiles vmess, snell, shadowquic, mieru, sudoku and trusttunnel to client YAML without ever emitting a URI, so those listeners arrived at the third tier with a YAML in hand and the condition already false, and reported no link at all even though ClientURIsWithCredentials exports one. The third tier now runs whenever the URI is still missing, and only fills in YAML that earlier tiers did not already provide. --------- Co-authored-by: 風雪 fix(vless): stop exporting flow on transports that cannot carry it (#87) * fix(vless): stop exporting flow on transports that cannot carry it The form promised that flow is cleared for ws/grpc/xhttp and the tooltip still says so, but exported links kept carrying flow=xtls-rprx-vision. Two separate reasons, both fixed. The clear came too late to matter. formValuesToConfig wrote `flow` into the config inside the protocol switch, and only afterwards did the "Vision is TCP-only" rule run — where it mutated the form state, not the config it had already been copied into. The transport is now resolved before anything writes, and the drop also removes a flow inherited from the previous config, so changing a listener's transport actually takes effect. The URI generators never asked. Every one of them emitted whatever flow it found: - node: vlessURIs decided flow before resolving the transport, so the type it had just computed was irrelevant to the decision. It now resolves the transport first. ClientURIsWithCredentials also merged the listener-level flow onto every user on protocol alone, which reintroduced the value the form had dropped. - protocol: the share URI and the client YAML both wrote spec.Flow unguarded, even though TransportSpec knows the network. Same for the server-side compile, which pushed the listener default onto users — Mihomo rejects that combination outright, so this one takes the listener down. - mui: BuildVLESSShare set flow unconditionally, and bridge.go injected the listener default into users before it was known to be applicable. - converter: copyTransport resolved `network` but left a stale flow behind. The rule is now one predicate per representation: TransportSpec.CarriesFlow, VLESSHandlerSpec.CarriesFlow, and protocol.TransportCarriesFlow for raw configs. TCP/raw keeps Vision; ws, grpc, xhttp, mkcp and mekya never emit it. Tests pin each path in both directions — a stale flow disappears on ws/grpc/ xhttp, and a genuine TCP listener keeps its flow. The frontend one runs the real form builder and fails, in three of five cases, against the previous ordering. * fix(uri): carry vmess ws Host and align hysteria2 TLS flags across user shapes Auditing the remaining exporters after the flow fix turned up two more places where the link disagreed with the listener. vmessURIs built its JSON by hand instead of going through transportParams, so a ws listener that pins a Host header exported only the path: the client had nothing to send as Host and the listener was unreachable through the link, even though the client YAML built from the same config worked. It also dropped allowInsecure, which vless and trojan both carry — a listener whose certificate the client cannot verify exported a link that failed the handshake. The repeated tlsParams() calls were hoisted while touching this. hysteria2URIs reads users either as rows or as a map, and the two branches disagreed: one asked certutil.ShouldSkipCertVerify and emitted only `insecure`, the other asked clientSkipCert and emitted `insecure` plus `allowInsecure`. The same listener therefore produced different links depending on how its users were stored. Both now use clientSkipCert, the helper intended for share URIs, and emit both keys. Not fixed, for the record: an SS listener with simple-obfs exports a bare ss:// link with no `plugin=` entry, so the obfs configuration is lost. The client YAML does translate it (plugin: obfs). Fixing the URI means committing to a SIP002 plugin encoding that varies between clients, which is worth deciding separately rather than guessing at here. * i18n: complete zh-CN and guard against the locale regressions already seen zh-CN was missing seven listener hints that en.ts declares, so those tooltips rendered in English for the primary UI language. Translated: client_auth_cert, client_auth_type, ech_key, enable_pure_downlink, mux, padding and table_type. The rest of the catalogue is deliberately left alone. Seventeen locales each lack 65 keys that en.ts has; the provider already resolves those from en.ts, so they degrade to English rather than breaking, and filling 1100 strings with unreviewed machine translation would be worse than the fallback. The test prints the per-locale gap so it stays visible. The new locale test guards the regressions this directory has actually hit: a duplicated key inside one object (which breaks the type check), and a key renamed in en.ts leaving an orphan behind. It also asserts zh-CN stays complete, since a gap there reads as a defect rather than as translation lag. * i18n: declare the 24 keys the UI asks for but no locale ever defined Twenty-four keys were referenced from components without being declared in any locale, so t() fell through to its hardcoded fallback. Where a fallback existed that merely meant the string could never be translated; where none existed — protocolHint, access_sniHint, jlsHint, shadowTlsHint and the rest — the tooltip either rendered the raw key, "listeners.protocolHint", or disappeared entirely, because fieldTip() suppresses a value that equals the key it looked up. en.ts takes the text the code was already falling back to, so nothing changes on screen; the strings just become translatable. The few without a fallback were written from the surrounding context — the field they annotate, its placeholder, and the wording of the neighbouring hints. trafficMultiplierHint is the exception: its fallback was Chinese, which is wrong for the English catalogue, so it now has a real English string. zh-CN gets all twenty-four, which keeps it complete against en.ts. The locale test now reads through the TypeScript AST. It previously parsed line by line and these files contain minified regions — several "key":"value" pairs on one line — so a whole block of keys was invisible to it, which is how this gap stayed hidden. It also gained the assertion that would have caught the bug directly: every dotted key handed to t() or fieldTip() must be declared in en.ts. The remaining languages still lag, now by 89 keys rather than 65, and still fall back to English; the test prints the per-locale figure. * fix(uri): drop the vmess allowInsecure field the reference client ignores The vmess skip-certificate field was added on internal consistency alone — vless and trojan carry allowInsecure in their query strings, so vmess looked like an oversight. Checked against the client that actually parses these links, it is not one. v2rayN's VmessQRCode model declares exactly: v, ps, add, port, id, aid, scy, net, type, host, path, tls, sni, alpn, fp. There is no skip-certificate field at all — neither allowInsecure nor insecure — so the field was dead weight in a payload that v2rayN reads field by field. v2rayNG reads `insecure` instead, and Xray-core has removed allowInsecure in favour of pinnedPeerCertSha256, so there is no spelling that the reference clients agree on. Emitting one would have produced a link that looks configured but is not. The ws `host` fix stands: v2rayN's own documentation lists "ws host" as the meaning of that field, so dropping it broke ws listeners that pin a Host. The conformance test now pins the emitted payload to v2rayN's field set, so a future field copied over from another protocol fails instead of being silently ignored by every client. Deliberately left alone, both confirmed against the official scheme and worth deciding separately: - hysteria2 links carry up/down. The Hysteria 2 URI specification is explicit that the parameters "should never include client modes ... or bandwidth values". Removing them changes behaviour for anyone relying on the link, so it is flagged rather than removed. `insecure` is the official parameter; allowInsecure is kept alongside it purely for clients that read that name. - an SS listener with simple-obfs still exports a bare ss:// link with no plugin entry, losing the obfuscation the client YAML does translate. Also noted while checking: tuicURIs defaults congestion_control to bbr where sing-box documents cubic as its default. Ours is set explicitly, which is valid, but the comment claiming it is what "most TUIC clients" use is not sourced. * fix(uri): keep vmess allowInsecure after all Reverts the revert. Dropping it was correct on the evidence and wrong on the consequences: v2rayN ignores the field, v2rayNG spells it `insecure`, and Xray-core has moved to pinnedPeerCertSha256 — but the clients that do read allowInsecure are precisely the ones whose handshake fails without it, and an ignored extra key costs the others nothing. Removing it would have traded a working link for a tidy payload. The conformance test keeps its guard, with allowInsecure recorded as an explicit, justified exception rather than a silent inclusion: the field set is still pinned to v2rayN's model, so any further field copied over from another protocol has to argue its way in. * fix(uri): restore the vmess ws Host in registry YAML, and stop dropping share links Two leftovers from auditing the exporters end to end. The registry's VMess compiler built ws-opts with the path only, so a listener that pins a Host exported a client config with nothing to send as Host. The vless YAML path in the same file already nests it under `headers` per the mihomo wiki, so this just mirrors that — and the converter has always carried it, which is how the difference went unnoticed. The node URI share endpoint also had a tier that could not be reached. It walks three tiers — m-ui, the registry, then the legacy builders — and the second one gates the third on having neither a URI nor client YAML. But the registry implements URI export for a different protocol set than the legacy builders: it compiles vmess, snell, shadowquic, mieru, sudoku and trusttunnel to client YAML without ever emitting a URI, so those listeners arrived at the third tier with a YAML in hand and the condition already false, and reported no link at all even though ClientURIsWithCredentials exports one. The third tier now runs whenever the URI is still missing, and only fills in YAML that earlier tiers did not already provide. --------- Co-authored-by: 風雪 fix(vless): stop exporting flow on transports that cannot carry it (#87) * fix(vless): stop exporting flow on transports that cannot carry it The form promised that flow is cleared for ws/grpc/xhttp and the tooltip still says so, but exported links kept carrying flow=xtls-rprx-vision. Two separate reasons, both fixed. The clear came too late to matter. formValuesToConfig wrote `flow` into the config inside the protocol switch, and only afterwards did the "Vision is TCP-only" rule run — where it mutated the form state, not the config it had already been copied into. The transport is now resolved before anything writes, and the drop also removes a flow inherited from the previous config, so changing a listener's transport actually takes effect. The URI generators never asked. Every one of them emitted whatever flow it found: - node: vlessURIs decided flow before resolving the transport, so the type it had just computed was irrelevant to the decision. It now resolves the transport first. ClientURIsWithCredentials also merged the listener-level flow onto every user on protocol alone, which reintroduced the value the form had dropped. - protocol: the share URI and the client YAML both wrote spec.Flow unguarded, even though TransportSpec knows the network. Same for the server-side compile, which pushed the listener default onto users — Mihomo rejects that combination outright, so this one takes the listener down. - mui: BuildVLESSShare set flow unconditionally, and bridge.go injected the listener default into users before it was known to be applicable. - converter: copyTransport resolved `network` but left a stale flow behind. The rule is now one predicate per representation: TransportSpec.CarriesFlow, VLESSHandlerSpec.CarriesFlow, and protocol.TransportCarriesFlow for raw configs. TCP/raw keeps Vision; ws, grpc, xhttp, mkcp and mekya never emit it. Tests pin each path in both directions — a stale flow disappears on ws/grpc/ xhttp, and a genuine TCP listener keeps its flow. The frontend one runs the real form builder and fails, in three of five cases, against the previous ordering. * fix(uri): carry vmess ws Host and align hysteria2 TLS flags across user shapes Auditing the remaining exporters after the flow fix turned up two more places where the link disagreed with the listener. vmessURIs built its JSON by hand instead of going through transportParams, so a ws listener that pins a Host header exported only the path: the client had nothing to send as Host and the listener was unreachable through the link, even though the client YAML built from the same config worked. It also dropped allowInsecure, which vless and trojan both carry — a listener whose certificate the client cannot verify exported a link that failed the handshake. The repeated tlsParams() calls were hoisted while touching this. hysteria2URIs reads users either as rows or as a map, and the two branches disagreed: one asked certutil.ShouldSkipCertVerify and emitted only `insecure`, the other asked clientSkipCert and emitted `insecure` plus `allowInsecure`. The same listener therefore produced different links depending on how its users were stored. Both now use clientSkipCert, the helper intended for share URIs, and emit both keys. Not fixed, for the record: an SS listener with simple-obfs exports a bare ss:// link with no `plugin=` entry, so the obfs configuration is lost. The client YAML does translate it (plugin: obfs). Fixing the URI means committing to a SIP002 plugin encoding that varies between clients, which is worth deciding separately rather than guessing at here. * i18n: complete zh-CN and guard against the locale regressions already seen zh-CN was missing seven listener hints that en.ts declares, so those tooltips rendered in English for the primary UI language. Translated: client_auth_cert, client_auth_type, ech_key, enable_pure_downlink, mux, padding and table_type. The rest of the catalogue is deliberately left alone. Seventeen locales each lack 65 keys that en.ts has; the provider already resolves those from en.ts, so they degrade to English rather than breaking, and filling 1100 strings with unreviewed machine translation would be worse than the fallback. The test prints the per-locale gap so it stays visible. The new locale test guards the regressions this directory has actually hit: a duplicated key inside one object (which breaks the type check), and a key renamed in en.ts leaving an orphan behind. It also asserts zh-CN stays complete, since a gap there reads as a defect rather than as translation lag. * i18n: declare the 24 keys the UI asks for but no locale ever defined Twenty-four keys were referenced from components without being declared in any locale, so t() fell through to its hardcoded fallback. Where a fallback existed that merely meant the string could never be translated; where none existed — protocolHint, access_sniHint, jlsHint, shadowTlsHint and the rest — the tooltip either rendered the raw key, "listeners.protocolHint", or disappeared entirely, because fieldTip() suppresses a value that equals the key it looked up. en.ts takes the text the code was already falling back to, so nothing changes on screen; the strings just become translatable. The few without a fallback were written from the surrounding context — the field they annotate, its placeholder, and the wording of the neighbouring hints. trafficMultiplierHint is the exception: its fallback was Chinese, which is wrong for the English catalogue, so it now has a real English string. zh-CN gets all twenty-four, which keeps it complete against en.ts. The locale test now reads through the TypeScript AST. It previously parsed line by line and these files contain minified regions — several "key":"value" pairs on one line — so a whole block of keys was invisible to it, which is how this gap stayed hidden. It also gained the assertion that would have caught the bug directly: every dotted key handed to t() or fieldTip() must be declared in en.ts. The remaining languages still lag, now by 89 keys rather than 65, and still fall back to English; the test prints the per-locale figure. * fix(uri): drop the vmess allowInsecure field the reference client ignores The vmess skip-certificate field was added on internal consistency alone — vless and trojan carry allowInsecure in their query strings, so vmess looked like an oversight. Checked against the client that actually parses these links, it is not one. v2rayN's VmessQRCode model declares exactly: v, ps, add, port, id, aid, scy, net, type, host, path, tls, sni, alpn, fp. There is no skip-certificate field at all — neither allowInsecure nor insecure — so the field was dead weight in a payload that v2rayN reads field by field. v2rayNG reads `insecure` instead, and Xray-core has removed allowInsecure in favour of pinnedPeerCertSha256, so there is no spelling that the reference clients agree on. Emitting one would have produced a link that looks configured but is not. The ws `host` fix stands: v2rayN's own documentation lists "ws host" as the meaning of that field, so dropping it broke ws listeners that pin a Host. The conformance test now pins the emitted payload to v2rayN's field set, so a future field copied over from another protocol fails instead of being silently ignored by every client. Deliberately left alone, both confirmed against the official scheme and worth deciding separately: - hysteria2 links carry up/down. The Hysteria 2 URI specification is explicit that the parameters "should never include client modes ... or bandwidth values". Removing them changes behaviour for anyone relying on the link, so it is flagged rather than removed. `insecure` is the official parameter; allowInsecure is kept alongside it purely for clients that read that name. - an SS listener with simple-obfs still exports a bare ss:// link with no plugin entry, losing the obfuscation the client YAML does translate. Also noted while checking: tuicURIs defaults congestion_control to bbr where sing-box documents cubic as its default. Ours is set explicitly, which is valid, but the comment claiming it is what "most TUIC clients" use is not sourced. * fix(uri): keep vmess allowInsecure after all Reverts the revert. Dropping it was correct on the evidence and wrong on the consequences: v2rayN ignores the field, v2rayNG spells it `insecure`, and Xray-core has moved to pinnedPeerCertSha256 — but the clients that do read allowInsecure are precisely the ones whose handshake fails without it, and an ignored extra key costs the others nothing. Removing it would have traded a working link for a tidy payload. The conformance test keeps its guard, with allowInsecure recorded as an explicit, justified exception rather than a silent inclusion: the field set is still pinned to v2rayN's model, so any further field copied over from another protocol has to argue its way in. * fix(uri): restore the vmess ws Host in registry YAML, and stop dropping share links Two leftovers from auditing the exporters end to end. The registry's VMess compiler built ws-opts with the path only, so a listener that pins a Host exported a client config with nothing to send as Host. The vless YAML path in the same file already nests it under `headers` per the mihomo wiki, so this just mirrors that — and the converter has always carried it, which is how the difference went unnoticed. The node URI share endpoint also had a tier that could not be reached. It walks three tiers — m-ui, the registry, then the legacy builders — and the second one gates the third on having neither a URI nor client YAML. But the registry implements URI export for a different protocol set than the legacy builders: it compiles vmess, snell, shadowquic, mieru, sudoku and trusttunnel to client YAML without ever emitting a URI, so those listeners arrived at the third tier with a YAML in hand and the condition already false, and reported no link at all even though ClientURIsWithCredentials exports one. The third tier now runs whenever the URI is still missing, and only fills in YAML that earlier tiers did not already provide. --------- Co-authored-by: 風雪 fix(vless): stop exporting flow on transports that cannot carry it (#87) * fix(vless): stop exporting flow on transports that cannot carry it The form promised that flow is cleared for ws/grpc/xhttp and the tooltip still says so, but exported links kept carrying flow=xtls-rprx-vision. Two separate reasons, both fixed. The clear came too late to matter. formValuesToConfig wrote `flow` into the config inside the protocol switch, and only afterwards did the "Vision is TCP-only" rule run — where it mutated the form state, not the config it had already been copied into. The transport is now resolved before anything writes, and the drop also removes a flow inherited from the previous config, so changing a listener's transport actually takes effect. The URI generators never asked. Every one of them emitted whatever flow it found: - node: vlessURIs decided flow before resolving the transport, so the type it had just computed was irrelevant to the decision. It now resolves the transport first. ClientURIsWithCredentials also merged the listener-level flow onto every user on protocol alone, which reintroduced the value the form had dropped. - protocol: the share URI and the client YAML both wrote spec.Flow unguarded, even though TransportSpec knows the network. Same for the server-side compile, which pushed the listener default onto users — Mihomo rejects that combination outright, so this one takes the listener down. - mui: BuildVLESSShare set flow unconditionally, and bridge.go injected the listener default into users before it was known to be applicable. - converter: copyTransport resolved `network` but left a stale flow behind. The rule is now one predicate per representation: TransportSpec.CarriesFlow, VLESSHandlerSpec.CarriesFlow, and protocol.TransportCarriesFlow for raw configs. TCP/raw keeps Vision; ws, grpc, xhttp, mkcp and mekya never emit it. Tests pin each path in both directions — a stale flow disappears on ws/grpc/ xhttp, and a genuine TCP listener keeps its flow. The frontend one runs the real form builder and fails, in three of five cases, against the previous ordering. * fix(uri): carry vmess ws Host and align hysteria2 TLS flags across user shapes Auditing the remaining exporters after the flow fix turned up two more places where the link disagreed with the listener. vmessURIs built its JSON by hand instead of going through transportParams, so a ws listener that pins a Host header exported only the path: the client had nothing to send as Host and the listener was unreachable through the link, even though the client YAML built from the same config worked. It also dropped allowInsecure, which vless and trojan both carry — a listener whose certificate the client cannot verify exported a link that failed the handshake. The repeated tlsParams() calls were hoisted while touching this. hysteria2URIs reads users either as rows or as a map, and the two branches disagreed: one asked certutil.ShouldSkipCertVerify and emitted only `insecure`, the other asked clientSkipCert and emitted `insecure` plus `allowInsecure`. The same listener therefore produced different links depending on how its users were stored. Both now use clientSkipCert, the helper intended for share URIs, and emit both keys. Not fixed, for the record: an SS listener with simple-obfs exports a bare ss:// link with no `plugin=` entry, so the obfs configuration is lost. The client YAML does translate it (plugin: obfs). Fixing the URI means committing to a SIP002 plugin encoding that varies between clients, which is worth deciding separately rather than guessing at here. * i18n: complete zh-CN and guard against the locale regressions already seen zh-CN was missing seven listener hints that en.ts declares, so those tooltips rendered in English for the primary UI language. Translated: client_auth_cert, client_auth_type, ech_key, enable_pure_downlink, mux, padding and table_type. The rest of the catalogue is deliberately left alone. Seventeen locales each lack 65 keys that en.ts has; the provider already resolves those from en.ts, so they degrade to English rather than breaking, and filling 1100 strings with unreviewed machine translation would be worse than the fallback. The test prints the per-locale gap so it stays visible. The new locale test guards the regressions this directory has actually hit: a duplicated key inside one object (which breaks the type check), and a key renamed in en.ts leaving an orphan behind. It also asserts zh-CN stays complete, since a gap there reads as a defect rather than as translation lag. * i18n: declare the 24 keys the UI asks for but no locale ever defined Twenty-four keys were referenced from components without being declared in any locale, so t() fell through to its hardcoded fallback. Where a fallback existed that merely meant the string could never be translated; where none existed — protocolHint, access_sniHint, jlsHint, shadowTlsHint and the rest — the tooltip either rendered the raw key, "listeners.protocolHint", or disappeared entirely, because fieldTip() suppresses a value that equals the key it looked up. en.ts takes the text the code was already falling back to, so nothing changes on screen; the strings just become translatable. The few without a fallback were written from the surrounding context — the field they annotate, its placeholder, and the wording of the neighbouring hints. trafficMultiplierHint is the exception: its fallback was Chinese, which is wrong for the English catalogue, so it now has a real English string. zh-CN gets all twenty-four, which keeps it complete against en.ts. The locale test now reads through the TypeScript AST. It previously parsed line by line and these files contain minified regions — several "key":"value" pairs on one line — so a whole block of keys was invisible to it, which is how this gap stayed hidden. It also gained the assertion that would have caught the bug directly: every dotted key handed to t() or fieldTip() must be declared in en.ts. The remaining languages still lag, now by 89 keys rather than 65, and still fall back to English; the test prints the per-locale figure. * fix(uri): drop the vmess allowInsecure field the reference client ignores The vmess skip-certificate field was added on internal consistency alone — vless and trojan carry allowInsecure in their query strings, so vmess looked like an oversight. Checked against the client that actually parses these links, it is not one. v2rayN's VmessQRCode model declares exactly: v, ps, add, port, id, aid, scy, net, type, host, path, tls, sni, alpn, fp. There is no skip-certificate field at all — neither allowInsecure nor insecure — so the field was dead weight in a payload that v2rayN reads field by field. v2rayNG reads `insecure` instead, and Xray-core has removed allowInsecure in favour of pinnedPeerCertSha256, so there is no spelling that the reference clients agree on. Emitting one would have produced a link that looks configured but is not. The ws `host` fix stands: v2rayN's own documentation lists "ws host" as the meaning of that field, so dropping it broke ws listeners that pin a Host. The conformance test now pins the emitted payload to v2rayN's field set, so a future field copied over from another protocol fails instead of being silently ignored by every client. Deliberately left alone, both confirmed against the official scheme and worth deciding separately: - hysteria2 links carry up/down. The Hysteria 2 URI specification is explicit that the parameters "should never include client modes ... or bandwidth values". Removing them changes behaviour for anyone relying on the link, so it is flagged rather than removed. `insecure` is the official parameter; allowInsecure is kept alongside it purely for clients that read that name. - an SS listener with simple-obfs still exports a bare ss:// link with no plugin entry, losing the obfuscation the client YAML does translate. Also noted while checking: tuicURIs defaults congestion_control to bbr where sing-box documents cubic as its default. Ours is set explicitly, which is valid, but the comment claiming it is what "most TUIC clients" use is not sourced. * fix(uri): keep vmess allowInsecure after all Reverts the revert. Dropping it was correct on the evidence and wrong on the consequences: v2rayN ignores the field, v2rayNG spells it `insecure`, and Xray-core has moved to pinnedPeerCertSha256 — but the clients that do read allowInsecure are precisely the ones whose handshake fails without it, and an ignored extra key costs the others nothing. Removing it would have traded a working link for a tidy payload. The conformance test keeps its guard, with allowInsecure recorded as an explicit, justified exception rather than a silent inclusion: the field set is still pinned to v2rayN's model, so any further field copied over from another protocol has to argue its way in. * fix(uri): restore the vmess ws Host in registry YAML, and stop dropping share links Two leftovers from auditing the exporters end to end. The registry's VMess compiler built ws-opts with the path only, so a listener that pins a Host exported a client config with nothing to send as Host. The vless YAML path in the same file already nests it under `headers` per the mihomo wiki, so this just mirrors that — and the converter has always carried it, which is how the difference went unnoticed. The node URI share endpoint also had a tier that could not be reached. It walks three tiers — m-ui, the registry, then the legacy builders — and the second one gates the third on having neither a URI nor client YAML. But the registry implements URI export for a different protocol set than the legacy builders: it compiles vmess, snell, shadowquic, mieru, sudoku and trusttunnel to client YAML without ever emitting a URI, so those listeners arrived at the third tier with a YAML in hand and the condition already false, and reported no link at all even though ClientURIsWithCredentials exports one. The third tier now runs whenever the URI is still missing, and only fills in YAML that earlier tiers did not already provide. --------- Co-authored-by: 風雪 fix(vless): stop exporting flow on transports that cannot carry it (#87) * fix(vless): stop exporting flow on transports that cannot carry it The form promised that flow is cleared for ws/grpc/xhttp and the tooltip still says so, but exported links kept carrying flow=xtls-rprx-vision. Two separate reasons, both fixed. The clear came too late to matter. formValuesToConfig wrote `flow` into the config inside the protocol switch, and only afterwards did the "Vision is TCP-only" rule run — where it mutated the form state, not the config it had already been copied into. The transport is now resolved before anything writes, and the drop also removes a flow inherited from the previous config, so changing a listener's transport actually takes effect. The URI generators never asked. Every one of them emitted whatever flow it found: - node: vlessURIs decided flow before resolving the transport, so the type it had just computed was irrelevant to the decision. It now resolves the transport first. ClientURIsWithCredentials also merged the listener-level flow onto every user on protocol alone, which reintroduced the value the form had dropped. - protocol: the share URI and the client YAML both wrote spec.Flow unguarded, even though TransportSpec knows the network. Same for the server-side compile, which pushed the listener default onto users — Mihomo rejects that combination outright, so this one takes the listener down. - mui: BuildVLESSShare set flow unconditionally, and bridge.go injected the listener default into users before it was known to be applicable. - converter: copyTransport resolved `network` but left a stale flow behind. The rule is now one predicate per representation: TransportSpec.CarriesFlow, VLESSHandlerSpec.CarriesFlow, and protocol.TransportCarriesFlow for raw configs. TCP/raw keeps Vision; ws, grpc, xhttp, mkcp and mekya never emit it. Tests pin each path in both directions — a stale flow disappears on ws/grpc/ xhttp, and a genuine TCP listener keeps its flow. The frontend one runs the real form builder and fails, in three of five cases, against the previous ordering. * fix(uri): carry vmess ws Host and align hysteria2 TLS flags across user shapes Auditing the remaining exporters after the flow fix turned up two more places where the link disagreed with the listener. vmessURIs built its JSON by hand instead of going through transportParams, so a ws listener that pins a Host header exported only the path: the client had nothing to send as Host and the listener was unreachable through the link, even though the client YAML built from the same config worked. It also dropped allowInsecure, which vless and trojan both carry — a listener whose certificate the client cannot verify exported a link that failed the handshake. The repeated tlsParams() calls were hoisted while touching this. hysteria2URIs reads users either as rows or as a map, and the two branches disagreed: one asked certutil.ShouldSkipCertVerify and emitted only `insecure`, the other asked clientSkipCert and emitted `insecure` plus `allowInsecure`. The same listener therefore produced different links depending on how its users were stored. Both now use clientSkipCert, the helper intended for share URIs, and emit both keys. Not fixed, for the record: an SS listener with simple-obfs exports a bare ss:// link with no `plugin=` entry, so the obfs configuration is lost. The client YAML does translate it (plugin: obfs). Fixing the URI means committing to a SIP002 plugin encoding that varies between clients, which is worth deciding separately rather than guessing at here. * i18n: complete zh-CN and guard against the locale regressions already seen zh-CN was missing seven listener hints that en.ts declares, so those tooltips rendered in English for the primary UI language. Translated: client_auth_cert, client_auth_type, ech_key, enable_pure_downlink, mux, padding and table_type. The rest of the catalogue is deliberately left alone. Seventeen locales each lack 65 keys that en.ts has; the provider already resolves those from en.ts, so they degrade to English rather than breaking, and filling 1100 strings with unreviewed machine translation would be worse than the fallback. The test prints the per-locale gap so it stays visible. The new locale test guards the regressions this directory has actually hit: a duplicated key inside one object (which breaks the type check), and a key renamed in en.ts leaving an orphan behind. It also asserts zh-CN stays complete, since a gap there reads as a defect rather than as translation lag. * i18n: declare the 24 keys the UI asks for but no locale ever defined Twenty-four keys were referenced from components without being declared in any locale, so t() fell through to its hardcoded fallback. Where a fallback existed that merely meant the string could never be translated; where none existed — protocolHint, access_sniHint, jlsHint, shadowTlsHint and the rest — the tooltip either rendered the raw key, "listeners.protocolHint", or disappeared entirely, because fieldTip() suppresses a value that equals the key it looked up. en.ts takes the text the code was already falling back to, so nothing changes on screen; the strings just become translatable. The few without a fallback were written from the surrounding context — the field they annotate, its placeholder, and the wording of the neighbouring hints. trafficMultiplierHint is the exception: its fallback was Chinese, which is wrong for the English catalogue, so it now has a real English string. zh-CN gets all twenty-four, which keeps it complete against en.ts. The locale test now reads through the TypeScript AST. It previously parsed line by line and these files contain minified regions — several "key":"value" pairs on one line — so a whole block of keys was invisible to it, which is how this gap stayed hidden. It also gained the assertion that would have caught the bug directly: every dotted key handed to t() or fieldTip() must be declared in en.ts. The remaining languages still lag, now by 89 keys rather than 65, and still fall back to English; the test prints the per-locale figure. * fix(uri): drop the vmess allowInsecure field the reference client ignores The vmess skip-certificate field was added on internal consistency alone — vless and trojan carry allowInsecure in their query strings, so vmess looked like an oversight. Checked against the client that actually parses these links, it is not one. v2rayN's VmessQRCode model declares exactly: v, ps, add, port, id, aid, scy, net, type, host, path, tls, sni, alpn, fp. There is no skip-certificate field at all — neither allowInsecure nor insecure — so the field was dead weight in a payload that v2rayN reads field by field. v2rayNG reads `insecure` instead, and Xray-core has removed allowInsecure in favour of pinnedPeerCertSha256, so there is no spelling that the reference clients agree on. Emitting one would have produced a link that looks configured but is not. The ws `host` fix stands: v2rayN's own documentation lists "ws host" as the meaning of that field, so dropping it broke ws listeners that pin a Host. The conformance test now pins the emitted payload to v2rayN's field set, so a future field copied over from another protocol fails instead of being silently ignored by every client. Deliberately left alone, both confirmed against the official scheme and worth deciding separately: - hysteria2 links carry up/down. The Hysteria 2 URI specification is explicit that the parameters "should never include client modes ... or bandwidth values". Removing them changes behaviour for anyone relying on the link, so it is flagged rather than removed. `insecure` is the official parameter; allowInsecure is kept alongside it purely for clients that read that name. - an SS listener with simple-obfs still exports a bare ss:// link with no plugin entry, losing the obfuscation the client YAML does translate. Also noted while checking: tuicURIs defaults congestion_control to bbr where sing-box documents cubic as its default. Ours is set explicitly, which is valid, but the comment claiming it is what "most TUIC clients" use is not sourced. * fix(uri): keep vmess allowInsecure after all Reverts the revert. Dropping it was correct on the evidence and wrong on the consequences: v2rayN ignores the field, v2rayNG spells it `insecure`, and Xray-core has moved to pinnedPeerCertSha256 — but the clients that do read allowInsecure are precisely the ones whose handshake fails without it, and an ignored extra key costs the others nothing. Removing it would have traded a working link for a tidy payload. The conformance test keeps its guard, with allowInsecure recorded as an explicit, justified exception rather than a silent inclusion: the field set is still pinned to v2rayN's model, so any further field copied over from another protocol has to argue its way in. * fix(uri): restore the vmess ws Host in registry YAML, and stop dropping share links Two leftovers from auditing the exporters end to end. The registry's VMess compiler built ws-opts with the path only, so a listener that pins a Host exported a client config with nothing to send as Host. The vless YAML path in the same file already nests it under `headers` per the mihomo wiki, so this just mirrors that — and the converter has always carried it, which is how the difference went unnoticed. The node URI share endpoint also had a tier that could not be reached. It walks three tiers — m-ui, the registry, then the legacy builders — and the second one gates the third on having neither a URI nor client YAML. But the registry implements URI export for a different protocol set than the legacy builders: it compiles vmess, snell, shadowquic, mieru, sudoku and trusttunnel to client YAML without ever emitting a URI, so those listeners arrived at the third tier with a YAML in hand and the condition already false, and reported no link at all even though ClientURIsWithCredentials exports one. The third tier now runs whenever the URI is still missing, and only fills in YAML that earlier tiers did not already provide. --------- Co-authored-by: 風雪 fix(vless): stop exporting flow on transports that cannot carry it (#87) * fix(vless): stop exporting flow on transports that cannot carry it The form promised that flow is cleared for ws/grpc/xhttp and the tooltip still says so, but exported links kept carrying flow=xtls-rprx-vision. Two separate reasons, both fixed. The clear came too late to matter. formValuesToConfig wrote `flow` into the config inside the protocol switch, and only afterwards did the "Vision is TCP-only" rule run — where it mutated the form state, not the config it had already been copied into. The transport is now resolved before anything writes, and the drop also removes a flow inherited from the previous config, so changing a listener's transport actually takes effect. The URI generators never asked. Every one of them emitted whatever flow it found: - node: vlessURIs decided flow before resolving the transport, so the type it had just computed was irrelevant to the decision. It now resolves the transport first. ClientURIsWithCredentials also merged the listener-level flow onto every user on protocol alone, which reintroduced the value the form had dropped. - protocol: the share URI and the client YAML both wrote spec.Flow unguarded, even though TransportSpec knows the network. Same for the server-side compile, which pushed the listener default onto users — Mihomo rejects that combination outright, so this one takes the listener down. - mui: BuildVLESSShare set flow unconditionally, and bridge.go injected the listener default into users before it was known to be applicable. - converter: copyTransport resolved `network` but left a stale flow behind. The rule is now one predicate per representation: TransportSpec.CarriesFlow, VLESSHandlerSpec.CarriesFlow, and protocol.TransportCarriesFlow for raw configs. TCP/raw keeps Vision; ws, grpc, xhttp, mkcp and mekya never emit it. Tests pin each path in both directions — a stale flow disappears on ws/grpc/ xhttp, and a genuine TCP listener keeps its flow. The frontend one runs the real form builder and fails, in three of five cases, against the previous ordering. * fix(uri): carry vmess ws Host and align hysteria2 TLS flags across user shapes Auditing the remaining exporters after the flow fix turned up two more places where the link disagreed with the listener. vmessURIs built its JSON by hand instead of going through transportParams, so a ws listener that pins a Host header exported only the path: the client had nothing to send as Host and the listener was unreachable through the link, even though the client YAML built from the same config worked. It also dropped allowInsecure, which vless and trojan both carry — a listener whose certificate the client cannot verify exported a link that failed the handshake. The repeated tlsParams() calls were hoisted while touching this. hysteria2URIs reads users either as rows or as a map, and the two branches disagreed: one asked certutil.ShouldSkipCertVerify and emitted only `insecure`, the other asked clientSkipCert and emitted `insecure` plus `allowInsecure`. The same listener therefore produced different links depending on how its users were stored. Both now use clientSkipCert, the helper intended for share URIs, and emit both keys. Not fixed, for the record: an SS listener with simple-obfs exports a bare ss:// link with no `plugin=` entry, so the obfs configuration is lost. The client YAML does translate it (plugin: obfs). Fixing the URI means committing to a SIP002 plugin encoding that varies between clients, which is worth deciding separately rather than guessing at here. * i18n: complete zh-CN and guard against the locale regressions already seen zh-CN was missing seven listener hints that en.ts declares, so those tooltips rendered in English for the primary UI language. Translated: client_auth_cert, client_auth_type, ech_key, enable_pure_downlink, mux, padding and table_type. The rest of the catalogue is deliberately left alone. Seventeen locales each lack 65 keys that en.ts has; the provider already resolves those from en.ts, so they degrade to English rather than breaking, and filling 1100 strings with unreviewed machine translation would be worse than the fallback. The test prints the per-locale gap so it stays visible. The new locale test guards the regressions this directory has actually hit: a duplicated key inside one object (which breaks the type check), and a key renamed in en.ts leaving an orphan behind. It also asserts zh-CN stays complete, since a gap there reads as a defect rather than as translation lag. * i18n: declare the 24 keys the UI asks for but no locale ever defined Twenty-four keys were referenced from components without being declared in any locale, so t() fell through to its hardcoded fallback. Where a fallback existed that merely meant the string could never be translated; where none existed — protocolHint, access_sniHint, jlsHint, shadowTlsHint and the rest — the tooltip either rendered the raw key, "listeners.protocolHint", or disappeared entirely, because fieldTip() suppresses a value that equals the key it looked up. en.ts takes the text the code was already falling back to, so nothing changes on screen; the strings just become translatable. The few without a fallback were written from the surrounding context — the field they annotate, its placeholder, and the wording of the neighbouring hints. trafficMultiplierHint is the exception: its fallback was Chinese, which is wrong for the English catalogue, so it now has a real English string. zh-CN gets all twenty-four, which keeps it complete against en.ts. The locale test now reads through the TypeScript AST. It previously parsed line by line and these files contain minified regions — several "key":"value" pairs on one line — so a whole block of keys was invisible to it, which is how this gap stayed hidden. It also gained the assertion that would have caught the bug directly: every dotted key handed to t() or fieldTip() must be declared in en.ts. The remaining languages still lag, now by 89 keys rather than 65, and still fall back to English; the test prints the per-locale figure. * fix(uri): drop the vmess allowInsecure field the reference client ignores The vmess skip-certificate field was added on internal consistency alone — vless and trojan carry allowInsecure in their query strings, so vmess looked like an oversight. Checked against the client that actually parses these links, it is not one. v2rayN's VmessQRCode model declares exactly: v, ps, add, port, id, aid, scy, net, type, host, path, tls, sni, alpn, fp. There is no skip-certificate field at all — neither allowInsecure nor insecure — so the field was dead weight in a payload that v2rayN reads field by field. v2rayNG reads `insecure` instead, and Xray-core has removed allowInsecure in favour of pinnedPeerCertSha256, so there is no spelling that the reference clients agree on. Emitting one would have produced a link that looks configured but is not. The ws `host` fix stands: v2rayN's own documentation lists "ws host" as the meaning of that field, so dropping it broke ws listeners that pin a Host. The conformance test now pins the emitted payload to v2rayN's field set, so a future field copied over from another protocol fails instead of being silently ignored by every client. Deliberately left alone, both confirmed against the official scheme and worth deciding separately: - hysteria2 links carry up/down. The Hysteria 2 URI specification is explicit that the parameters "should never include client modes ... or bandwidth values". Removing them changes behaviour for anyone relying on the link, so it is flagged rather than removed. `insecure` is the official parameter; allowInsecure is kept alongside it purely for clients that read that name. - an SS listener with simple-obfs still exports a bare ss:// link with no plugin entry, losing the obfuscation the client YAML does translate. Also noted while checking: tuicURIs defaults congestion_control to bbr where sing-box documents cubic as its default. Ours is set explicitly, which is valid, but the comment claiming it is what "most TUIC clients" use is not sourced. * fix(uri): keep vmess allowInsecure after all Reverts the revert. Dropping it was correct on the evidence and wrong on the consequences: v2rayN ignores the field, v2rayNG spells it `insecure`, and Xray-core has moved to pinnedPeerCertSha256 — but the clients that do read allowInsecure are precisely the ones whose handshake fails without it, and an ignored extra key costs the others nothing. Removing it would have traded a working link for a tidy payload. The conformance test keeps its guard, with allowInsecure recorded as an explicit, justified exception rather than a silent inclusion: the field set is still pinned to v2rayN's model, so any further field copied over from another protocol has to argue its way in. * fix(uri): restore the vmess ws Host in registry YAML, and stop dropping share links Two leftovers from auditing the exporters end to end. The registry's VMess compiler built ws-opts with the path only, so a listener that pins a Host exported a client config with nothing to send as Host. The vless YAML path in the same file already nests it under `headers` per the mihomo wiki, so this just mirrors that — and the converter has always carried it, which is how the difference went unnoticed. The node URI share endpoint also had a tier that could not be reached. It walks three tiers — m-ui, the registry, then the legacy builders — and the second one gates the third on having neither a URI nor client YAML. But the registry implements URI export for a different protocol set than the legacy builders: it compiles vmess, snell, shadowquic, mieru, sudoku and trusttunnel to client YAML without ever emitting a URI, so those listeners arrived at the third tier with a YAML in hand and the condition already false, and reported no link at all even though ClientURIsWithCredentials exports one. The third tier now runs whenever the URI is still missing, and only fills in YAML that earlier tiers did not already provide. --------- Co-authored-by: 風雪 fix(vless): stop exporting flow on transports that cannot carry it (#87) * fix(vless): stop exporting flow on transports that cannot carry it The form promised that flow is cleared for ws/grpc/xhttp and the tooltip still says so, but exported links kept carrying flow=xtls-rprx-vision. Two separate reasons, both fixed. The clear came too late to matter. formValuesToConfig wrote `flow` into the config inside the protocol switch, and only afterwards did the "Vision is TCP-only" rule run — where it mutated the form state, not the config it had already been copied into. The transport is now resolved before anything writes, and the drop also removes a flow inherited from the previous config, so changing a listener's transport actually takes effect. The URI generators never asked. Every one of them emitted whatever flow it found: - node: vlessURIs decided flow before resolving the transport, so the type it had just computed was irrelevant to the decision. It now resolves the transport first. ClientURIsWithCredentials also merged the listener-level flow onto every user on protocol alone, which reintroduced the value the form had dropped. - protocol: the share URI and the client YAML both wrote spec.Flow unguarded, even though TransportSpec knows the network. Same for the server-side compile, which pushed the listener default onto users — Mihomo rejects that combination outright, so this one takes the listener down. - mui: BuildVLESSShare set flow unconditionally, and bridge.go injected the listener default into users before it was known to be applicable. - converter: copyTransport resolved `network` but left a stale flow behind. The rule is now one predicate per representation: TransportSpec.CarriesFlow, VLESSHandlerSpec.CarriesFlow, and protocol.TransportCarriesFlow for raw configs. TCP/raw keeps Vision; ws, grpc, xhttp, mkcp and mekya never emit it. Tests pin each path in both directions — a stale flow disappears on ws/grpc/ xhttp, and a genuine TCP listener keeps its flow. The frontend one runs the real form builder and fails, in three of five cases, against the previous ordering. * fix(uri): carry vmess ws Host and align hysteria2 TLS flags across user shapes Auditing the remaining exporters after the flow fix turned up two more places where the link disagreed with the listener. vmessURIs built its JSON by hand instead of going through transportParams, so a ws listener that pins a Host header exported only the path: the client had nothing to send as Host and the listener was unreachable through the link, even though the client YAML built from the same config worked. It also dropped allowInsecure, which vless and trojan both carry — a listener whose certificate the client cannot verify exported a link that failed the handshake. The repeated tlsParams() calls were hoisted while touching this. hysteria2URIs reads users either as rows or as a map, and the two branches disagreed: one asked certutil.ShouldSkipCertVerify and emitted only `insecure`, the other asked clientSkipCert and emitted `insecure` plus `allowInsecure`. The same listener therefore produced different links depending on how its users were stored. Both now use clientSkipCert, the helper intended for share URIs, and emit both keys. Not fixed, for the record: an SS listener with simple-obfs exports a bare ss:// link with no `plugin=` entry, so the obfs configuration is lost. The client YAML does translate it (plugin: obfs). Fixing the URI means committing to a SIP002 plugin encoding that varies between clients, which is worth deciding separately rather than guessing at here. * i18n: complete zh-CN and guard against the locale regressions already seen zh-CN was missing seven listener hints that en.ts declares, so those tooltips rendered in English for the primary UI language. Translated: client_auth_cert, client_auth_type, ech_key, enable_pure_downlink, mux, padding and table_type. The rest of the catalogue is deliberately left alone. Seventeen locales each lack 65 keys that en.ts has; the provider already resolves those from en.ts, so they degrade to English rather than breaking, and filling 1100 strings with unreviewed machine translation would be worse than the fallback. The test prints the per-locale gap so it stays visible. The new locale test guards the regressions this directory has actually hit: a duplicated key inside one object (which breaks the type check), and a key renamed in en.ts leaving an orphan behind. It also asserts zh-CN stays complete, since a gap there reads as a defect rather than as translation lag. * i18n: declare the 24 keys the UI asks for but no locale ever defined Twenty-four keys were referenced from components without being declared in any locale, so t() fell through to its hardcoded fallback. Where a fallback existed that merely meant the string could never be translated; where none existed — protocolHint, access_sniHint, jlsHint, shadowTlsHint and the rest — the tooltip either rendered the raw key, "listeners.protocolHint", or disappeared entirely, because fieldTip() suppresses a value that equals the key it looked up. en.ts takes the text the code was already falling back to, so nothing changes on screen; the strings just become translatable. The few without a fallback were written from the surrounding context — the field they annotate, its placeholder, and the wording of the neighbouring hints. trafficMultiplierHint is the exception: its fallback was Chinese, which is wrong for the English catalogue, so it now has a real English string. zh-CN gets all twenty-four, which keeps it complete against en.ts. The locale test now reads through the TypeScript AST. It previously parsed line by line and these files contain minified regions — several "key":"value" pairs on one line — so a whole block of keys was invisible to it, which is how this gap stayed hidden. It also gained the assertion that would have caught the bug directly: every dotted key handed to t() or fieldTip() must be declared in en.ts. The remaining languages still lag, now by 89 keys rather than 65, and still fall back to English; the test prints the per-locale figure. * fix(uri): drop the vmess allowInsecure field the reference client ignores The vmess skip-certificate field was added on internal consistency alone — vless and trojan carry allowInsecure in their query strings, so vmess looked like an oversight. Checked against the client that actually parses these links, it is not one. v2rayN's VmessQRCode model declares exactly: v, ps, add, port, id, aid, scy, net, type, host, path, tls, sni, alpn, fp. There is no skip-certificate field at all — neither allowInsecure nor insecure — so the field was dead weight in a payload that v2rayN reads field by field. v2rayNG reads `insecure` instead, and Xray-core has removed allowInsecure in favour of pinnedPeerCertSha256, so there is no spelling that the reference clients agree on. Emitting one would have produced a link that looks configured but is not. The ws `host` fix stands: v2rayN's own documentation lists "ws host" as the meaning of that field, so dropping it broke ws listeners that pin a Host. The conformance test now pins the emitted payload to v2rayN's field set, so a future field copied over from another protocol fails instead of being silently ignored by every client. Deliberately left alone, both confirmed against the official scheme and worth deciding separately: - hysteria2 links carry up/down. The Hysteria 2 URI specification is explicit that the parameters "should never include client modes ... or bandwidth values". Removing them changes behaviour for anyone relying on the link, so it is flagged rather than removed. `insecure` is the official parameter; allowInsecure is kept alongside it purely for clients that read that name. - an SS listener with simple-obfs still exports a bare ss:// link with no plugin entry, losing the obfuscation the client YAML does translate. Also noted while checking: tuicURIs defaults congestion_control to bbr where sing-box documents cubic as its default. Ours is set explicitly, which is valid, but the comment claiming it is what "most TUIC clients" use is not sourced. * fix(uri): keep vmess allowInsecure after all Reverts the revert. Dropping it was correct on the evidence and wrong on the consequences: v2rayN ignores the field, v2rayNG spells it `insecure`, and Xray-core has moved to pinnedPeerCertSha256 — but the clients that do read allowInsecure are precisely the ones whose handshake fails without it, and an ignored extra key costs the others nothing. Removing it would have traded a working link for a tidy payload. The conformance test keeps its guard, with allowInsecure recorded as an explicit, justified exception rather than a silent inclusion: the field set is still pinned to v2rayN's model, so any further field copied over from another protocol has to argue its way in. * fix(uri): restore the vmess ws Host in registry YAML, and stop dropping share links Two leftovers from auditing the exporters end to end. The registry's VMess compiler built ws-opts with the path only, so a listener that pins a Host exported a client config with nothing to send as Host. The vless YAML path in the same file already nests it under `headers` per the mihomo wiki, so this just mirrors that — and the converter has always carried it, which is how the difference went unnoticed. The node URI share endpoint also had a tier that could not be reached. It walks three tiers — m-ui, the registry, then the legacy builders — and the second one gates the third on having neither a URI nor client YAML. But the registry implements URI export for a different protocol set than the legacy builders: it compiles vmess, snell, shadowquic, mieru, sudoku and trusttunnel to client YAML without ever emitting a URI, so those listeners arrived at the third tier with a YAML in hand and the condition already false, and reported no link at all even though ClientURIsWithCredentials exports one. The third tier now runs whenever the URI is still missing, and only fills in YAML that earlier tiers did not already provide. --------- Co-authored-by: 風雪 fix(vless): stop exporting flow on transports that cannot carry it (#87) * fix(vless): stop exporting flow on transports that cannot carry it The form promised that flow is cleared for ws/grpc/xhttp and the tooltip still says so, but exported links kept carrying flow=xtls-rprx-vision. Two separate reasons, both fixed. The clear came too late to matter. formValuesToConfig wrote `flow` into the config inside the protocol switch, and only afterwards did the "Vision is TCP-only" rule run — where it mutated the form state, not the config it had already been copied into. The transport is now resolved before anything writes, and the drop also removes a flow inherited from the previous config, so changing a listener's transport actually takes effect. The URI generators never asked. Every one of them emitted whatever flow it found: - node: vlessURIs decided flow before resolving the transport, so the type it had just computed was irrelevant to the decision. It now resolves the transport first. ClientURIsWithCredentials also merged the listener-level flow onto every user on protocol alone, which reintroduced the value the form had dropped. - protocol: the share URI and the client YAML both wrote spec.Flow unguarded, even though TransportSpec knows the network. Same for the server-side compile, which pushed the listener default onto users — Mihomo rejects that combination outright, so this one takes the listener down. - mui: BuildVLESSShare set flow unconditionally, and bridge.go injected the listener default into users before it was known to be applicable. - converter: copyTransport resolved `network` but left a stale flow behind. The rule is now one predicate per representation: TransportSpec.CarriesFlow, VLESSHandlerSpec.CarriesFlow, and protocol.TransportCarriesFlow for raw configs. TCP/raw keeps Vision; ws, grpc, xhttp, mkcp and mekya never emit it. Tests pin each path in both directions — a stale flow disappears on ws/grpc/ xhttp, and a genuine TCP listener keeps its flow. The frontend one runs the real form builder and fails, in three of five cases, against the previous ordering. * fix(uri): carry vmess ws Host and align hysteria2 TLS flags across user shapes Auditing the remaining exporters after the flow fix turned up two more places where the link disagreed with the listener. vmessURIs built its JSON by hand instead of going through transportParams, so a ws listener that pins a Host header exported only the path: the client had nothing to send as Host and the listener was unreachable through the link, even though the client YAML built from the same config worked. It also dropped allowInsecure, which vless and trojan both carry — a listener whose certificate the client cannot verify exported a link that failed the handshake. The repeated tlsParams() calls were hoisted while touching this. hysteria2URIs reads users either as rows or as a map, and the two branches disagreed: one asked certutil.ShouldSkipCertVerify and emitted only `insecure`, the other asked clientSkipCert and emitted `insecure` plus `allowInsecure`. The same listener therefore produced different links depending on how its users were stored. Both now use clientSkipCert, the helper intended for share URIs, and emit both keys. Not fixed, for the record: an SS listener with simple-obfs exports a bare ss:// link with no `plugin=` entry, so the obfs configuration is lost. The client YAML does translate it (plugin: obfs). Fixing the URI means committing to a SIP002 plugin encoding that varies between clients, which is worth deciding separately rather than guessing at here. * i18n: complete zh-CN and guard against the locale regressions already seen zh-CN was missing seven listener hints that en.ts declares, so those tooltips rendered in English for the primary UI language. Translated: client_auth_cert, client_auth_type, ech_key, enable_pure_downlink, mux, padding and table_type. The rest of the catalogue is deliberately left alone. Seventeen locales each lack 65 keys that en.ts has; the provider already resolves those from en.ts, so they degrade to English rather than breaking, and filling 1100 strings with unreviewed machine translation would be worse than the fallback. The test prints the per-locale gap so it stays visible. The new locale test guards the regressions this directory has actually hit: a duplicated key inside one object (which breaks the type check), and a key renamed in en.ts leaving an orphan behind. It also asserts zh-CN stays complete, since a gap there reads as a defect rather than as translation lag. * i18n: declare the 24 keys the UI asks for but no locale ever defined Twenty-four keys were referenced from components without being declared in any locale, so t() fell through to its hardcoded fallback. Where a fallback existed that merely meant the string could never be translated; where none existed — protocolHint, access_sniHint, jlsHint, shadowTlsHint and the rest — the tooltip either rendered the raw key, "listeners.protocolHint", or disappeared entirely, because fieldTip() suppresses a value that equals the key it looked up. en.ts takes the text the code was already falling back to, so nothing changes on screen; the strings just become translatable. The few without a fallback were written from the surrounding context — the field they annotate, its placeholder, and the wording of the neighbouring hints. trafficMultiplierHint is the exception: its fallback was Chinese, which is wrong for the English catalogue, so it now has a real English string. zh-CN gets all twenty-four, which keeps it complete against en.ts. The locale test now reads through the TypeScript AST. It previously parsed line by line and these files contain minified regions — several "key":"value" pairs on one line — so a whole block of keys was invisible to it, which is how this gap stayed hidden. It also gained the assertion that would have caught the bug directly: every dotted key handed to t() or fieldTip() must be declared in en.ts. The remaining languages still lag, now by 89 keys rather than 65, and still fall back to English; the test prints the per-locale figure. * fix(uri): drop the vmess allowInsecure field the reference client ignores The vmess skip-certificate field was added on internal consistency alone — vless and trojan carry allowInsecure in their query strings, so vmess looked like an oversight. Checked against the client that actually parses these links, it is not one. v2rayN's VmessQRCode model declares exactly: v, ps, add, port, id, aid, scy, net, type, host, path, tls, sni, alpn, fp. There is no skip-certificate field at all — neither allowInsecure nor insecure — so the field was dead weight in a payload that v2rayN reads field by field. v2rayNG reads `insecure` instead, and Xray-core has removed allowInsecure in favour of pinnedPeerCertSha256, so there is no spelling that the reference clients agree on. Emitting one would have produced a link that looks configured but is not. The ws `host` fix stands: v2rayN's own documentation lists "ws host" as the meaning of that field, so dropping it broke ws listeners that pin a Host. The conformance test now pins the emitted payload to v2rayN's field set, so a future field copied over from another protocol fails instead of being silently ignored by every client. Deliberately left alone, both confirmed against the official scheme and worth deciding separately: - hysteria2 links carry up/down. The Hysteria 2 URI specification is explicit that the parameters "should never include client modes ... or bandwidth values". Removing them changes behaviour for anyone relying on the link, so it is flagged rather than removed. `insecure` is the official parameter; allowInsecure is kept alongside it purely for clients that read that name. - an SS listener with simple-obfs still exports a bare ss:// link with no plugin entry, losing the obfuscation the client YAML does translate. Also noted while checking: tuicURIs defaults congestion_control to bbr where sing-box documents cubic as its default. Ours is set explicitly, which is valid, but the comment claiming it is what "most TUIC clients" use is not sourced. * fix(uri): keep vmess allowInsecure after all Reverts the revert. Dropping it was correct on the evidence and wrong on the consequences: v2rayN ignores the field, v2rayNG spells it `insecure`, and Xray-core has moved to pinnedPeerCertSha256 — but the clients that do read allowInsecure are precisely the ones whose handshake fails without it, and an ignored extra key costs the others nothing. Removing it would have traded a working link for a tidy payload. The conformance test keeps its guard, with allowInsecure recorded as an explicit, justified exception rather than a silent inclusion: the field set is still pinned to v2rayN's model, so any further field copied over from another protocol has to argue its way in. * fix(uri): restore the vmess ws Host in registry YAML, and stop dropping share links Two leftovers from auditing the exporters end to end. The registry's VMess compiler built ws-opts with the path only, so a listener that pins a Host exported a client config with nothing to send as Host. The vless YAML path in the same file already nests it under `headers` per the mihomo wiki, so this just mirrors that — and the converter has always carried it, which is how the difference went unnoticed. The node URI share endpoint also had a tier that could not be reached. It walks three tiers — m-ui, the registry, then the legacy builders — and the second one gates the third on having neither a URI nor client YAML. But the registry implements URI export for a different protocol set than the legacy builders: it compiles vmess, snell, shadowquic, mieru, sudoku and trusttunnel to client YAML without ever emitting a URI, so those listeners arrived at the third tier with a YAML in hand and the condition already false, and reported no link at all even though ClientURIsWithCredentials exports one. The third tier now runs whenever the URI is still missing, and only fills in YAML that earlier tiers did not already provide. --------- Co-authored-by: 風雪 fix(vless): stop exporting flow on transports that cannot carry it (#87) * fix(vless): stop exporting flow on transports that cannot carry it The form promised that flow is cleared for ws/grpc/xhttp and the tooltip still says so, but exported links kept carrying flow=xtls-rprx-vision. Two separate reasons, both fixed. The clear came too late to matter. formValuesToConfig wrote `flow` into the config inside the protocol switch, and only afterwards did the "Vision is TCP-only" rule run — where it mutated the form state, not the config it had already been copied into. The transport is now resolved before anything writes, and the drop also removes a flow inherited from the previous config, so changing a listener's transport actually takes effect. The URI generators never asked. Every one of them emitted whatever flow it found: - node: vlessURIs decided flow before resolving the transport, so the type it had just computed was irrelevant to the decision. It now resolves the transport first. ClientURIsWithCredentials also merged the listener-level flow onto every user on protocol alone, which reintroduced the value the form had dropped. - protocol: the share URI and the client YAML both wrote spec.Flow unguarded, even though TransportSpec knows the network. Same for the server-side compile, which pushed the listener default onto users — Mihomo rejects that combination outright, so this one takes the listener down. - mui: BuildVLESSShare set flow unconditionally, and bridge.go injected the listener default into users before it was known to be applicable. - converter: copyTransport resolved `network` but left a stale flow behind. The rule is now one predicate per representation: TransportSpec.CarriesFlow, VLESSHandlerSpec.CarriesFlow, and protocol.TransportCarriesFlow for raw configs. TCP/raw keeps Vision; ws, grpc, xhttp, mkcp and mekya never emit it. Tests pin each path in both directions — a stale flow disappears on ws/grpc/ xhttp, and a genuine TCP listener keeps its flow. The frontend one runs the real form builder and fails, in three of five cases, against the previous ordering. * fix(uri): carry vmess ws Host and align hysteria2 TLS flags across user shapes Auditing the remaining exporters after the flow fix turned up two more places where the link disagreed with the listener. vmessURIs built its JSON by hand instead of going through transportParams, so a ws listener that pins a Host header exported only the path: the client had nothing to send as Host and the listener was unreachable through the link, even though the client YAML built from the same config worked. It also dropped allowInsecure, which vless and trojan both carry — a listener whose certificate the client cannot verify exported a link that failed the handshake. The repeated tlsParams() calls were hoisted while touching this. hysteria2URIs reads users either as rows or as a map, and the two branches disagreed: one asked certutil.ShouldSkipCertVerify and emitted only `insecure`, the other asked clientSkipCert and emitted `insecure` plus `allowInsecure`. The same listener therefore produced different links depending on how its users were stored. Both now use clientSkipCert, the helper intended for share URIs, and emit both keys. Not fixed, for the record: an SS listener with simple-obfs exports a bare ss:// link with no `plugin=` entry, so the obfs configuration is lost. The client YAML does translate it (plugin: obfs). Fixing the URI means committing to a SIP002 plugin encoding that varies between clients, which is worth deciding separately rather than guessing at here. * i18n: complete zh-CN and guard against the locale regressions already seen zh-CN was missing seven listener hints that en.ts declares, so those tooltips rendered in English for the primary UI language. Translated: client_auth_cert, client_auth_type, ech_key, enable_pure_downlink, mux, padding and table_type. The rest of the catalogue is deliberately left alone. Seventeen locales each lack 65 keys that en.ts has; the provider already resolves those from en.ts, so they degrade to English rather than breaking, and filling 1100 strings with unreviewed machine translation would be worse than the fallback. The test prints the per-locale gap so it stays visible. The new locale test guards the regressions this directory has actually hit: a duplicated key inside one object (which breaks the type check), and a key renamed in en.ts leaving an orphan behind. It also asserts zh-CN stays complete, since a gap there reads as a defect rather than as translation lag. * i18n: declare the 24 keys the UI asks for but no locale ever defined Twenty-four keys were referenced from components without being declared in any locale, so t() fell through to its hardcoded fallback. Where a fallback existed that merely meant the string could never be translated; where none existed — protocolHint, access_sniHint, jlsHint, shadowTlsHint and the rest — the tooltip either rendered the raw key, "listeners.protocolHint", or disappeared entirely, because fieldTip() suppresses a value that equals the key it looked up. en.ts takes the text the code was already falling back to, so nothing changes on screen; the strings just become translatable. The few without a fallback were written from the surrounding context — the field they annotate, its placeholder, and the wording of the neighbouring hints. trafficMultiplierHint is the exception: its fallback was Chinese, which is wrong for the English catalogue, so it now has a real English string. zh-CN gets all twenty-four, which keeps it complete against en.ts. The locale test now reads through the TypeScript AST. It previously parsed line by line and these files contain minified regions — several "key":"value" pairs on one line — so a whole block of keys was invisible to it, which is how this gap stayed hidden. It also gained the assertion that would have caught the bug directly: every dotted key handed to t() or fieldTip() must be declared in en.ts. The remaining languages still lag, now by 89 keys rather than 65, and still fall back to English; the test prints the per-locale figure. * fix(uri): drop the vmess allowInsecure field the reference client ignores The vmess skip-certificate field was added on internal consistency alone — vless and trojan carry allowInsecure in their query strings, so vmess looked like an oversight. Checked against the client that actually parses these links, it is not one. v2rayN's VmessQRCode model declares exactly: v, ps, add, port, id, aid, scy, net, type, host, path, tls, sni, alpn, fp. There is no skip-certificate field at all — neither allowInsecure nor insecure — so the field was dead weight in a payload that v2rayN reads field by field. v2rayNG reads `insecure` instead, and Xray-core has removed allowInsecure in favour of pinnedPeerCertSha256, so there is no spelling that the reference clients agree on. Emitting one would have produced a link that looks configured but is not. The ws `host` fix stands: v2rayN's own documentation lists "ws host" as the meaning of that field, so dropping it broke ws listeners that pin a Host. The conformance test now pins the emitted payload to v2rayN's field set, so a future field copied over from another protocol fails instead of being silently ignored by every client. Deliberately left alone, both confirmed against the official scheme and worth deciding separately: - hysteria2 links carry up/down. The Hysteria 2 URI specification is explicit that the parameters "should never include client modes ... or bandwidth values". Removing them changes behaviour for anyone relying on the link, so it is flagged rather than removed. `insecure` is the official parameter; allowInsecure is kept alongside it purely for clients that read that name. - an SS listener with simple-obfs still exports a bare ss:// link with no plugin entry, losing the obfuscation the client YAML does translate. Also noted while checking: tuicURIs defaults congestion_control to bbr where sing-box documents cubic as its default. Ours is set explicitly, which is valid, but the comment claiming it is what "most TUIC clients" use is not sourced. * fix(uri): keep vmess allowInsecure after all Reverts the revert. Dropping it was correct on the evidence and wrong on the consequences: v2rayN ignores the field, v2rayNG spells it `insecure`, and Xray-core has moved to pinnedPeerCertSha256 — but the clients that do read allowInsecure are precisely the ones whose handshake fails without it, and an ignored extra key costs the others nothing. Removing it would have traded a working link for a tidy payload. The conformance test keeps its guard, with allowInsecure recorded as an explicit, justified exception rather than a silent inclusion: the field set is still pinned to v2rayN's model, so any further field copied over from another protocol has to argue its way in. * fix(uri): restore the vmess ws Host in registry YAML, and stop dropping share links Two leftovers from auditing the exporters end to end. The registry's VMess compiler built ws-opts with the path only, so a listener that pins a Host exported a client config with nothing to send as Host. The vless YAML path in the same file already nests it under `headers` per the mihomo wiki, so this just mirrors that — and the converter has always carried it, which is how the difference went unnoticed. The node URI share endpoint also had a tier that could not be reached. It walks three tiers — m-ui, the registry, then the legacy builders — and the second one gates the third on having neither a URI nor client YAML. But the registry implements URI export for a different protocol set than the legacy builders: it compiles vmess, snell, shadowquic, mieru, sudoku and trusttunnel to client YAML without ever emitting a URI, so those listeners arrived at the third tier with a YAML in hand and the condition already false, and reported no link at all even though ClientURIsWithCredentials exports one. The third tier now runs whenever the URI is still missing, and only fills in YAML that earlier tiers did not already provide. --------- Co-authored-by: 風雪 fix(vless): stop exporting flow on transports that cannot carry it (#87) * fix(vless): stop exporting flow on transports that cannot carry it The form promised that flow is cleared for ws/grpc/xhttp and the tooltip still says so, but exported links kept carrying flow=xtls-rprx-vision. Two separate reasons, both fixed. The clear came too late to matter. formValuesToConfig wrote `flow` into the config inside the protocol switch, and only afterwards did the "Vision is TCP-only" rule run — where it mutated the form state, not the config it had already been copied into. The transport is now resolved before anything writes, and the drop also removes a flow inherited from the previous config, so changing a listener's transport actually takes effect. The URI generators never asked. Every one of them emitted whatever flow it found: - node: vlessURIs decided flow before resolving the transport, so the type it had just computed was irrelevant to the decision. It now resolves the transport first. ClientURIsWithCredentials also merged the listener-level flow onto every user on protocol alone, which reintroduced the value the form had dropped. - protocol: the share URI and the client YAML both wrote spec.Flow unguarded, even though TransportSpec knows the network. Same for the server-side compile, which pushed the listener default onto users — Mihomo rejects that combination outright, so this one takes the listener down. - mui: BuildVLESSShare set flow unconditionally, and bridge.go injected the listener default into users before it was known to be applicable. - converter: copyTransport resolved `network` but left a stale flow behind. The rule is now one predicate per representation: TransportSpec.CarriesFlow, VLESSHandlerSpec.CarriesFlow, and protocol.TransportCarriesFlow for raw configs. TCP/raw keeps Vision; ws, grpc, xhttp, mkcp and mekya never emit it. Tests pin each path in both directions — a stale flow disappears on ws/grpc/ xhttp, and a genuine TCP listener keeps its flow. The frontend one runs the real form builder and fails, in three of five cases, against the previous ordering. * fix(uri): carry vmess ws Host and align hysteria2 TLS flags across user shapes Auditing the remaining exporters after the flow fix turned up two more places where the link disagreed with the listener. vmessURIs built its JSON by hand instead of going through transportParams, so a ws listener that pins a Host header exported only the path: the client had nothing to send as Host and the listener was unreachable through the link, even though the client YAML built from the same config worked. It also dropped allowInsecure, which vless and trojan both carry — a listener whose certificate the client cannot verify exported a link that failed the handshake. The repeated tlsParams() calls were hoisted while touching this. hysteria2URIs reads users either as rows or as a map, and the two branches disagreed: one asked certutil.ShouldSkipCertVerify and emitted only `insecure`, the other asked clientSkipCert and emitted `insecure` plus `allowInsecure`. The same listener therefore produced different links depending on how its users were stored. Both now use clientSkipCert, the helper intended for share URIs, and emit both keys. Not fixed, for the record: an SS listener with simple-obfs exports a bare ss:// link with no `plugin=` entry, so the obfs configuration is lost. The client YAML does translate it (plugin: obfs). Fixing the URI means committing to a SIP002 plugin encoding that varies between clients, which is worth deciding separately rather than guessing at here. * i18n: complete zh-CN and guard against the locale regressions already seen zh-CN was missing seven listener hints that en.ts declares, so those tooltips rendered in English for the primary UI language. Translated: client_auth_cert, client_auth_type, ech_key, enable_pure_downlink, mux, padding and table_type. The rest of the catalogue is deliberately left alone. Seventeen locales each lack 65 keys that en.ts has; the provider already resolves those from en.ts, so they degrade to English rather than breaking, and filling 1100 strings with unreviewed machine translation would be worse than the fallback. The test prints the per-locale gap so it stays visible. The new locale test guards the regressions this directory has actually hit: a duplicated key inside one object (which breaks the type check), and a key renamed in en.ts leaving an orphan behind. It also asserts zh-CN stays complete, since a gap there reads as a defect rather than as translation lag. * i18n: declare the 24 keys the UI asks for but no locale ever defined Twenty-four keys were referenced from components without being declared in any locale, so t() fell through to its hardcoded fallback. Where a fallback existed that merely meant the string could never be translated; where none existed — protocolHint, access_sniHint, jlsHint, shadowTlsHint and the rest — the tooltip either rendered the raw key, "listeners.protocolHint", or disappeared entirely, because fieldTip() suppresses a value that equals the key it looked up. en.ts takes the text the code was already falling back to, so nothing changes on screen; the strings just become translatable. The few without a fallback were written from the surrounding context — the field they annotate, its placeholder, and the wording of the neighbouring hints. trafficMultiplierHint is the exception: its fallback was Chinese, which is wrong for the English catalogue, so it now has a real English string. zh-CN gets all twenty-four, which keeps it complete against en.ts. The locale test now reads through the TypeScript AST. It previously parsed line by line and these files contain minified regions — several "key":"value" pairs on one line — so a whole block of keys was invisible to it, which is how this gap stayed hidden. It also gained the assertion that would have caught the bug directly: every dotted key handed to t() or fieldTip() must be declared in en.ts. The remaining languages still lag, now by 89 keys rather than 65, and still fall back to English; the test prints the per-locale figure. * fix(uri): drop the vmess allowInsecure field the reference client ignores The vmess skip-certificate field was added on internal consistency alone — vless and trojan carry allowInsecure in their query strings, so vmess looked like an oversight. Checked against the client that actually parses these links, it is not one. v2rayN's VmessQRCode model declares exactly: v, ps, add, port, id, aid, scy, net, type, host, path, tls, sni, alpn, fp. There is no skip-certificate field at all — neither allowInsecure nor insecure — so the field was dead weight in a payload that v2rayN reads field by field. v2rayNG reads `insecure` instead, and Xray-core has removed allowInsecure in favour of pinnedPeerCertSha256, so there is no spelling that the reference clients agree on. Emitting one would have produced a link that looks configured but is not. The ws `host` fix stands: v2rayN's own documentation lists "ws host" as the meaning of that field, so dropping it broke ws listeners that pin a Host. The conformance test now pins the emitted payload to v2rayN's field set, so a future field copied over from another protocol fails instead of being silently ignored by every client. Deliberately left alone, both confirmed against the official scheme and worth deciding separately: - hysteria2 links carry up/down. The Hysteria 2 URI specification is explicit that the parameters "should never include client modes ... or bandwidth values". Removing them changes behaviour for anyone relying on the link, so it is flagged rather than removed. `insecure` is the official parameter; allowInsecure is kept alongside it purely for clients that read that name. - an SS listener with simple-obfs still exports a bare ss:// link with no plugin entry, losing the obfuscation the client YAML does translate. Also noted while checking: tuicURIs defaults congestion_control to bbr where sing-box documents cubic as its default. Ours is set explicitly, which is valid, but the comment claiming it is what "most TUIC clients" use is not sourced. * fix(uri): keep vmess allowInsecure after all Reverts the revert. Dropping it was correct on the evidence and wrong on the consequences: v2rayN ignores the field, v2rayNG spells it `insecure`, and Xray-core has moved to pinnedPeerCertSha256 — but the clients that do read allowInsecure are precisely the ones whose handshake fails without it, and an ignored extra key costs the others nothing. Removing it would have traded a working link for a tidy payload. The conformance test keeps its guard, with allowInsecure recorded as an explicit, justified exception rather than a silent inclusion: the field set is still pinned to v2rayN's model, so any further field copied over from another protocol has to argue its way in. * fix(uri): restore the vmess ws Host in registry YAML, and stop dropping share links Two leftovers from auditing the exporters end to end. The registry's VMess compiler built ws-opts with the path only, so a listener that pins a Host exported a client config with nothing to send as Host. The vless YAML path in the same file already nests it under `headers` per the mihomo wiki, so this just mirrors that — and the converter has always carried it, which is how the difference went unnoticed. The node URI share endpoint also had a tier that could not be reached. It walks three tiers — m-ui, the registry, then the legacy builders — and the second one gates the third on having neither a URI nor client YAML. But the registry implements URI export for a different protocol set than the legacy builders: it compiles vmess, snell, shadowquic, mieru, sudoku and trusttunnel to client YAML without ever emitting a URI, so those listeners arrived at the third tier with a YAML in hand and the condition already false, and reported no link at all even though ClientURIsWithCredentials exports one. The third tier now runs whenever the URI is still missing, and only fills in YAML that earlier tiers did not already provide. --------- Co-authored-by: 風雪 --- backend/internal/converter/client.go | 12 ++ .../internal/converter/transport_flow_test.go | 34 ++++ backend/internal/mui/bridge.go | 5 + backend/internal/mui/bridge_flow_test.go | 46 +++++ backend/internal/mui/domain/node.go | 13 ++ backend/internal/mui/protocol/vless.go | 5 +- backend/internal/node/api.go | 30 ++-- backend/internal/node/uri_builders.go | 44 +++-- backend/internal/node/uri_credentials.go | 7 +- .../internal/node/uri_flow_transport_test.go | 130 ++++++++++++++ .../internal/node/uri_transport_audit_test.go | 166 ++++++++++++++++++ backend/internal/protocol/compile.go | 26 ++- .../internal/protocol/flow_transport_test.go | 136 ++++++++++++++ backend/internal/protocol/share_modules.go | 14 +- backend/internal/protocol/types.go | 43 +++++ .../src/components/ListenerConfigFields.tsx | 25 ++- frontend/src/i18n/locales/en.ts | 26 ++- frontend/src/i18n/locales/zh-CN.ts | 33 +++- frontend/src/utils/i18nLocales.test.mjs | 142 +++++++++++++++ .../src/utils/listenerFlowTransport.test.mjs | 99 +++++++++++ 20 files changed, 995 insertions(+), 41 deletions(-) create mode 100644 backend/internal/converter/transport_flow_test.go create mode 100644 backend/internal/mui/bridge_flow_test.go create mode 100644 backend/internal/node/uri_flow_transport_test.go create mode 100644 backend/internal/node/uri_transport_audit_test.go create mode 100644 backend/internal/protocol/flow_transport_test.go create mode 100644 frontend/src/utils/i18nLocales.test.mjs create mode 100644 frontend/src/utils/listenerFlowTransport.test.mjs diff --git a/backend/internal/converter/client.go b/backend/internal/converter/client.go index 2d22cdb..62fdd63 100644 --- a/backend/internal/converter/client.go +++ b/backend/internal/converter/client.go @@ -1051,6 +1051,18 @@ func copyTransport(dst, src map[string]interface{}) { dst["mekya-opts"] = opts } } + // VLESS flow (XTLS Vision) is TCP-only. The transport has just been resolved + // into `network`, so drop a flow that no longer applies: a listener switched + // to ws/grpc/xhttp still carries the old value, and a client given it refuses + // to connect. + if network, _ := dst["network"].(string); network != "" { + switch strings.ToLower(strings.TrimSpace(network)) { + case "tcp", "raw": + // keep + default: + delete(dst, "flow") + } + } } func realityClientOptions(src map[string]interface{}) map[string]interface{} { diff --git a/backend/internal/converter/transport_flow_test.go b/backend/internal/converter/transport_flow_test.go new file mode 100644 index 0000000..98fb90e --- /dev/null +++ b/backend/internal/converter/transport_flow_test.go @@ -0,0 +1,34 @@ +package converter + +import "testing" + +// The client YAML is built from the same listener config, so a stale flow has to +// be dropped here too: copyTransport resolves the transport into `network`, and +// anything other than raw TCP cannot carry Vision. +func TestCopyTransportDropsFlowOnNonTCPNetwork(t *testing.T) { + cases := []struct { + name string + src map[string]interface{} + wantNet string + wantFlow bool + }{ + {"tcp keeps flow", map[string]interface{}{}, "", true}, + {"ws drops flow", map[string]interface{}{"ws-path": "/ws"}, "ws", false}, + {"grpc drops flow", map[string]interface{}{"grpc-service-name": "svc"}, "grpc", false}, + {"xhttp drops flow", map[string]interface{}{"xhttp-config": map[string]interface{}{"path": "/x"}}, "xhttp", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + dst := map[string]interface{}{"flow": "xtls-rprx-vision"} + copyTransport(dst, c.src) + + if got := dst["network"]; got != c.wantNet && !(c.wantNet == "" && got == nil) { + t.Fatalf("network = %v, want %q", got, c.wantNet) + } + _, present := dst["flow"] + if present != c.wantFlow { + t.Fatalf("flow present = %v, want %v: %#v", present, c.wantFlow, dst) + } + }) + } +} diff --git a/backend/internal/mui/bridge.go b/backend/internal/mui/bridge.go index 5537cd2..c336dd5 100644 --- a/backend/internal/mui/bridge.go +++ b/backend/internal/mui/bridge.go @@ -186,6 +186,11 @@ func BuildShares(l models.Listener, publicHost string, creds []Cred) ([]muiproto } enrichAccessProfileFromNode(&profile, node, l) flowDefault := listenerFlowHint(l) + // Vision flow is TCP-only, so the listener's default must not be pushed onto + // users of a listener that has since been switched to ws/grpc/xhttp. + if flowDefault != "" && node.VLESS != nil && !node.VLESS.Handler.CarriesFlow() { + flowDefault = "" + } state := domain.DesiredState{AsOf: time.Now().UTC(), PublicHost: profile.PublicHost} reg := muiprotocol.DefaultRegistry() out := make([]muiprotocol.Share, 0, len(node.Users)) diff --git a/backend/internal/mui/bridge_flow_test.go b/backend/internal/mui/bridge_flow_test.go new file mode 100644 index 0000000..b7d6459 --- /dev/null +++ b/backend/internal/mui/bridge_flow_test.go @@ -0,0 +1,46 @@ +package mui + +import ( + "strings" + "testing" + + "github.com/kazeyukiro/3m-ui/backend/internal/database/models" +) + +const flowTestUUID = "00000000-0000-0000-0000-000000000001" + +// The m-ui share path is what the node API serves first; it must not hand out a +// Vision flow over a transport that cannot carry it. +func TestBuildSharesOmitFlowForWebSocket(t *testing.T) { + cases := []struct { + name string + config string + wantFlow bool + }{ + {"raw keeps flow", `{"flow":"xtls-rprx-vision"}`, true}, + {"ws drops flow", `{"flow":"xtls-rprx-vision","ws-path":"/ws"}`, false}, + {"grpc drops flow", `{"flow":"xtls-rprx-vision","grpc-service-name":"svc"}`, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + l := models.Listener{ + Name: "n", Protocol: "vless", Type: "vless", Port: "443", + BindAddress: "0.0.0.0", Enabled: true, Config: c.config, + } + shares, err := BuildShares(l, "example.com", []Cred{{Username: "u", UUID: flowTestUUID}}) + if err != nil { + t.Fatalf("BuildShares: %v", err) + } + if len(shares) == 0 { + t.Fatal("expected at least one share") + } + uri := shares[0].URI + if strings.TrimSpace(uri) == "" { + t.Fatalf("empty share URI: %#v", shares[0]) + } + if got := strings.Contains(uri, "flow="); got != c.wantFlow { + t.Fatalf("flow present = %v, want %v: %s", got, c.wantFlow, uri) + } + }) + } +} diff --git a/backend/internal/mui/domain/node.go b/backend/internal/mui/domain/node.go index 1c148ef..ad349a0 100644 --- a/backend/internal/mui/domain/node.go +++ b/backend/internal/mui/domain/node.go @@ -246,6 +246,19 @@ type VLESSHandlerSpec struct { MKCP *MKCPConfig `json:"mkcp,omitempty"` } +// CarriesFlow reports whether this handler can carry VLESS flow (XTLS Vision). +// Vision is TCP-only: on ws, grpc, xhttp or mkcp the core rejects the +// combination, so a flow left over from an earlier transport must not be put +// into a share link. +func (h VLESSHandlerSpec) CarriesFlow() bool { + switch h.Type { + case "", VLESSHandlerRaw: + return true + default: + return false + } +} + // MKCPConfig mirrors listener/inbound.MKCPConfig on Mihomo Meta. It is // currently registered only by the VMess module. type MKCPConfig struct { diff --git a/backend/internal/mui/protocol/vless.go b/backend/internal/mui/protocol/vless.go index 89e5344..d6ae8bf 100644 --- a/backend/internal/mui/protocol/vless.go +++ b/backend/internal/mui/protocol/vless.go @@ -118,7 +118,10 @@ func (VLESSModule) BuildShare( if profile.PacketEncoding != "" { query.Set("packetEncoding", profile.PacketEncoding) } - if user.VLESS.Flow != "" { + // Vision flow is TCP-only. A listener migrated to ws/grpc/xhttp keeps its + // flow in storage; emitting it here would hand the client a link that cannot + // connect. + if user.VLESS.Flow != "" && node.VLESS.Handler.CarriesFlow() { query.Set("flow", user.VLESS.Flow) } switch node.VLESS.Handler.Type { diff --git a/backend/internal/node/api.go b/backend/internal/node/api.go index af65763..5eaabbf 100644 --- a/backend/internal/node/api.go +++ b/backend/internal/node/api.go @@ -230,21 +230,31 @@ func (h *Handler) ExportNodeURI(c *gin.Context) { } } } - if primary == "" && clientYAML == "" { + // Tier 2 covers a different protocol set than the legacy builders: vmess, + // snell, shadowquic, mieru, sudoku and trusttunnel compile to client YAML + // but never produce a share URI there. Gating this tier on the YAML too + // would leave those listeners reporting no link at all, even though + // ClientURIsWithCredentials exports one — so it runs whenever the URI is + // still missing, and only replaces the YAML if none arrived. + if primary == "" { legacy, lerr := ClientURIsWithCredentials(*listener, host, credentials) - if lerr != nil { + if lerr != nil && clientYAML == "" { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": lerr.Error()}) return } - uris = legacy - if uris == nil { - uris = []string{} + if lerr == nil { + uris = legacy + if uris == nil { + uris = []string{} + } + if len(uris) > 0 { + primary = uris[0] + } } - if len(uris) > 0 { - primary = uris[0] - } - if y, err := converter.ExportClientYAML(*listener, host, credentials); err == nil { - clientYAML = y + if clientYAML == "" { + if y, err := converter.ExportClientYAML(*listener, host, credentials); err == nil { + clientYAML = y + } } } if clientYAML == "" { diff --git a/backend/internal/node/uri_builders.go b/backend/internal/node/uri_builders.go index feffa12..5b9b1c2 100644 --- a/backend/internal/node/uri_builders.go +++ b/backend/internal/node/uri_builders.go @@ -11,6 +11,7 @@ import ( "github.com/kazeyukiro/3m-ui/backend/internal/certutil" "github.com/kazeyukiro/3m-ui/backend/internal/netutil" + "github.com/kazeyukiro/3m-ui/backend/internal/protocol" "golang.org/x/crypto/curve25519" ) @@ -106,7 +107,14 @@ func vlessURIs(name, host, port string, cfg map[string]interface{}) ([]string, e } params := tlsParams(cfg) params["type"] = "tcp" - if flow, _ := row["flow"].(string); flow != "" { + // Resolve the transport before deciding anything that depends on it. + for k, v := range transportParams(cfg) { + params[k] = v + } + // Vision flow is TCP-only. Ask the transport rather than the config: a + // listener switched to ws/grpc/xhttp keeps its old flow in storage, and + // exporting it produces a link no client can use. + if flow, _ := row["flow"].(string); flow != "" && protocol.TransportCarriesFlow(cfg) { params["flow"] = flow } if encryption, _ := cfg["encryption"].(string); encryption != "" { @@ -134,9 +142,6 @@ func vlessURIs(name, host, port string, cfg map[string]interface{}) ([]string, e params["fp"] = "chrome" } } - for k, v := range transportParams(cfg) { - params[k] = v - } result = append(result, addName(query("vless://"+url.PathEscape(uuid)+"@"+netutil.JoinHostPort(host, port), params), name)) } return result, nil @@ -156,18 +161,34 @@ func vmessURIs(name, host, port string, cfg map[string]interface{}) ([]string, e aid := stringValue(cfg["alterId"], "0") cipher := stringValue(cfg["cipher"], "auto") obj := map[string]string{"v": "2", "ps": name, "add": host, "port": port, "id": uuid, "aid": aid, "scy": cipher, "net": "tcp", "type": "none"} - if tls, ok := tlsParams(cfg)["security"]; ok && tls == "tls" { + tlsOpts := tlsParams(cfg) + if tlsOpts["security"] == "tls" { obj["tls"] = "tls" } - if sni := tlsParams(cfg)["sni"]; sni != "" { - obj["sni"] = sni + if tlsOpts["sni"] != "" { + obj["sni"] = tlsOpts["sni"] } - if fp := tlsParams(cfg)["fp"]; fp != "" { - obj["fp"] = fp + if tlsOpts["fp"] != "" { + obj["fp"] = tlsOpts["fp"] + } + // vless and trojan carry this in their query string. v2rayN's documented + // vmess field set has no skip-certificate field, so it ignores this one, + // but clients that do read it are the ones whose verification would + // otherwise fail — see vmessSchema for why it is kept anyway. + if tlsOpts["allowInsecure"] != "" { + obj["allowInsecure"] = tlsOpts["allowInsecure"] } if ws, ok := cfg["ws-path"].(string); ok && ws != "" { obj["net"] = "ws" obj["path"] = ws + // The Host header is carried separately from the path. Without it a + // ws listener that pins a Host is unreachable through the exported + // link, even though the same listener works from the client YAML. + if headers, ok := cfg["ws-headers"].(map[string]interface{}); ok { + if h, ok := headers["Host"].(string); ok && h != "" { + obj["host"] = h + } + } } if grpc, ok := cfg["grpc-service-name"].(string); ok && grpc != "" { obj["net"] = "grpc" @@ -258,8 +279,11 @@ func hysteria2URIs(name, host, port string, cfg map[string]interface{}) ([]strin if v, ok := cfg["sni"].(string); ok && v != "" { params["sni"] = v } - if certutil.ShouldSkipCertVerify(cfg) { + // Matches the config-embedded users branch below: same listener must + // produce the same link whichever way its users are stored. + if clientSkipCert(cfg, host) { params["insecure"] = "1" + params["allowInsecure"] = "1" } if v, ok := cfg["obfs"].(string); ok && v != "" { params["obfs"] = v diff --git a/backend/internal/node/uri_credentials.go b/backend/internal/node/uri_credentials.go index d678f22..b81f5de 100644 --- a/backend/internal/node/uri_credentials.go +++ b/backend/internal/node/uri_credentials.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/kazeyukiro/3m-ui/backend/internal/database/models" + "github.com/kazeyukiro/3m-ui/backend/internal/protocol" "github.com/kazeyukiro/3m-ui/backend/internal/user" ) @@ -67,7 +68,11 @@ func ClientURIsWithCredentials(listener models.Listener, host string, credential users := make([]interface{}, 0, len(credentials)) for _, credential := range credentials { row := map[string]interface{}{"username": credential.Username, "password": credential.Password, "uuid": credential.UUID} - if flow != "" && (listener.Protocol == "vless" || listener.Protocol == "vmess") { + // The listener-level flow is only meaningful on a raw/TCP + // transport; copying it onto users of a ws/grpc/xhttp listener + // reintroduces the value the form was supposed to have dropped. + if flow != "" && protocol.TransportCarriesFlow(cfg) && + (listener.Protocol == "vless" || listener.Protocol == "vmess") { row["flow"] = flow } users = append(users, row) diff --git a/backend/internal/node/uri_flow_transport_test.go b/backend/internal/node/uri_flow_transport_test.go new file mode 100644 index 0000000..6727c94 --- /dev/null +++ b/backend/internal/node/uri_flow_transport_test.go @@ -0,0 +1,130 @@ +package node + +import ( + "strings" + "testing" + + "github.com/kazeyukiro/3m-ui/backend/internal/database/models" + "github.com/kazeyukiro/3m-ui/backend/internal/user" +) + +const testUUID = "00000000-0000-0000-0000-000000000001" + +// XTLS Vision is TCP-only. A listener migrated to ws/grpc/xhttp keeps its flow in +// storage, and exporting it produced links that claim Vision over a transport +// that cannot carry it — clients either refuse to connect or silently misbehave. +// These pin that a stale flow never reaches a share URI, while a genuine TCP +// listener keeps its flow. +func TestVlessURIsOmitFlowForNonTCPTransports(t *testing.T) { + cases := []struct { + name string + cfg map[string]interface{} + wantType string + wantFlow bool + }{ + { + name: "raw keeps flow", + cfg: map[string]interface{}{"flow": "xtls-rprx-vision"}, + wantType: "tcp", wantFlow: true, + }, + { + name: "ws drops flow", + cfg: map[string]interface{}{"flow": "xtls-rprx-vision", "ws-path": "/ws"}, + wantType: "ws", wantFlow: false, + }, + { + name: "grpc drops flow", + cfg: map[string]interface{}{"flow": "xtls-rprx-vision", "grpc-service-name": "svc"}, + wantType: "grpc", wantFlow: false, + }, + { + name: "xhttp drops flow", + cfg: map[string]interface{}{ + "flow": "xtls-rprx-vision", + "xhttp-config": map[string]interface{}{ + "path": "/xhttp", + }, + }, + wantType: "xhttp", wantFlow: false, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + // vlessURIs reads the flow from the user row; the listener-level + // value is merged into rows by ClientURIsWithCredentials, which is + // covered separately below. + row := map[string]interface{}{"username": "u", "uuid": testUUID} + if flow, ok := c.cfg["flow"].(string); ok { + row["flow"] = flow + } + cfg := map[string]interface{}{ + "users": []interface{}{row}, + } + for _, key := range []string{"ws-path", "grpc-service-name", "xhttp-config"} { + if v, ok := c.cfg[key]; ok { + cfg[key] = v + } + } + + uris, err := vlessURIs("node", "example.com", "443", cfg) + if err != nil { + t.Fatalf("vlessURIs: %v", err) + } + if len(uris) != 1 { + t.Fatalf("expected 1 URI, got %d: %v", len(uris), uris) + } + uri := uris[0] + if !strings.Contains(uri, "type="+c.wantType) { + t.Fatalf("expected type=%s in %q", c.wantType, uri) + } + if got := strings.Contains(uri, "flow="); got != c.wantFlow { + t.Fatalf("flow present = %v, want %v: %s", got, c.wantFlow, uri) + } + }) + } +} + +// The listener-level flow is copied onto every user before the URI is built. +// That copy used to be made on protocol alone, which reintroduced the value the +// form had deliberately dropped for ws. +func TestClientURIsWithCredentialsDropFlowForWebSocket(t *testing.T) { + creds := []user.Credential{{Username: "u", UUID: testUUID}} + + ws := models.Listener{ + Name: "ws-node", Protocol: "vless", Type: "vless", Port: "443", + BindAddress: "0.0.0.0", Enabled: true, + Config: `{"flow":"xtls-rprx-vision","ws-path":"/ws"}`, + } + uris, err := ClientURIsWithCredentials(ws, "example.com", creds) + if err != nil { + t.Fatalf("ClientURIsWithCredentials: %v", err) + } + if len(uris) == 0 { + t.Fatal("expected at least one URI") + } + for _, uri := range uris { + if strings.Contains(uri, "flow=") { + t.Fatalf("ws listener must not export a flow: %s", uri) + } + if !strings.Contains(uri, "type=ws") { + t.Fatalf("expected a ws URI, got %s", uri) + } + } + + raw := models.Listener{ + Name: "tcp-node", Protocol: "vless", Type: "vless", Port: "443", + BindAddress: "0.0.0.0", Enabled: true, + Config: `{"flow":"xtls-rprx-vision"}`, + } + uris, err = ClientURIsWithCredentials(raw, "example.com", creds) + if err != nil { + t.Fatalf("ClientURIsWithCredentials: %v", err) + } + if len(uris) == 0 { + t.Fatal("expected at least one URI") + } + if !strings.Contains(uris[0], "flow=xtls-rprx-vision") { + t.Fatalf("tcp listener must keep its flow: %s", uris[0]) + } +} diff --git a/backend/internal/node/uri_transport_audit_test.go b/backend/internal/node/uri_transport_audit_test.go new file mode 100644 index 0000000..1be832c --- /dev/null +++ b/backend/internal/node/uri_transport_audit_test.go @@ -0,0 +1,166 @@ +package node + +import ( + "encoding/base64" + "encoding/json" + "net/url" + "strings" + "testing" +) + +// vmessURIs builds a v2rayN-style JSON blob, so its fields have to be checked +// through the base64 payload rather than a query string. +func decodeVMess(t *testing.T, uri string) map[string]interface{} { + t.Helper() + if !strings.HasPrefix(uri, "vmess://") { + t.Fatalf("not a vmess URI: %s", uri) + } + raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(uri, "vmess://")) + if err != nil { + t.Fatalf("decode vmess payload: %v", err) + } + var obj map[string]interface{} + if err := json.Unmarshal(raw, &obj); err != nil { + t.Fatalf("unmarshal vmess payload: %v (%s)", err, raw) + } + return obj +} + +func vmessURI(t *testing.T, cfg map[string]interface{}) string { + t.Helper() + uris, err := vmessURIs("node", "example.com", "443", cfg) + if err != nil { + t.Fatalf("vmessURIs: %v", err) + } + if len(uris) != 1 { + t.Fatalf("expected 1 URI, got %d", len(uris)) + } + return uris[0] +} + +func vmessCfg() map[string]interface{} { + return map[string]interface{}{ + "users": []interface{}{ + map[string]interface{}{"username": "u", "uuid": testUUID}, + }, + } +} + +// The Host header of a ws listener is separate from its path. It used to be +// dropped, so the exported link could not reach a listener that pins a Host — +// while the client YAML for the very same listener worked. +func TestVMessURICarriesWebSocketHost(t *testing.T) { + cfg := vmessCfg() + cfg["ws-path"] = "/ws" + cfg["ws-headers"] = map[string]interface{}{"Host": "cdn.example.com"} + + obj := decodeVMess(t, vmessURI(t, cfg)) + if obj["net"] != "ws" { + t.Fatalf("net = %v, want ws", obj["net"]) + } + if obj["path"] != "/ws" { + t.Fatalf("path = %v, want /ws", obj["path"]) + } + if obj["host"] != "cdn.example.com" { + t.Fatalf("host = %v, want the configured ws Host", obj["host"]) + } +} + +func TestVMessURIOmitsWebSocketHostWhenUnset(t *testing.T) { + cfg := vmessCfg() + cfg["ws-path"] = "/ws" + + obj := decodeVMess(t, vmessURI(t, cfg)) + if _, present := obj["host"]; present { + t.Fatalf("host must stay absent when no Host header is configured: %#v", obj) + } +} + +// v2rayN's VmessQRCode model — the reference parser for vmess:// links — +// declares exactly this field set. Anything outside it is ignored by v2rayN. +// +// allowInsecure is the one deliberate exception. v2rayN's model has no +// skip-certificate field at all (neither allowInsecure nor insecure), v2rayNG +// reads `insecure`, and Xray-core has removed allowInsecure in favour of +// pinnedPeerCertSha256 — so no spelling is honoured by every client. It is kept +// anyway because the clients that do read it are exactly the ones whose +// handshake would otherwise fail, and v2rayN ignoring an extra key costs +// nothing. Any further field needs the same justification before it joins this +// set. +var vmessSchema = map[string]bool{ + "v": true, "ps": true, "add": true, "port": true, "id": true, "aid": true, + "scy": true, "net": true, "type": true, "host": true, "path": true, + "tls": true, "sni": true, "alpn": true, "fp": true, + "allowInsecure": true, +} + +func TestVMessURICarriesAllowInsecure(t *testing.T) { + cfg := vmessCfg() + cfg["skip-cert-verify"] = true + cfg["certificate"] = "cert" + + obj := decodeVMess(t, vmessURI(t, cfg)) + if obj["allowInsecure"] != "1" { + t.Fatalf("allowInsecure = %v, want 1", obj["allowInsecure"]) + } +} + +func TestVMessURIStaysWithinV2RayNSchema(t *testing.T) { + cfg := vmessCfg() + cfg["ws-path"] = "/ws" + cfg["ws-headers"] = map[string]interface{}{"Host": "cdn.example.com"} + cfg["skip-cert-verify"] = true + cfg["certificate"] = "cert" + + obj := decodeVMess(t, vmessURI(t, cfg)) + for key := range obj { + if !vmessSchema[key] { + t.Errorf("%q is outside v2rayN's vmess schema and will be ignored", key) + } + } +} + +// A hysteria2 listener stores its users either as rows or as a map. Both branches +// produced different TLS flags for the same listener, so the exported link +// depended on the storage shape. +func TestHysteria2URIsAgreeAcrossUserShapes(t *testing.T) { + common := map[string]interface{}{ + "skip-cert-verify": true, + "up": "100 Mbps", + } + rows := map[string]interface{}{ + "users": []interface{}{ + map[string]interface{}{"username": "u", "password": "p"}, + }, + } + mapped := map[string]interface{}{ + "users": map[string]interface{}{"u": "p"}, + } + for k, v := range common { + rows[k] = v + mapped[k] = v + } + + want := map[string]string{"insecure": "1", "allowInsecure": "1"} + for name, cfg := range map[string]map[string]interface{}{"rows": rows, "map": mapped} { + t.Run(name, func(t *testing.T) { + uris, err := hysteria2URIs("node", "example.com", "443", cfg) + if err != nil { + t.Fatalf("hysteria2URIs: %v", err) + } + if len(uris) != 1 { + t.Fatalf("expected 1 URI, got %d", len(uris)) + } + parsed, err := url.Parse(uris[0]) + if err != nil { + t.Fatalf("parse URI: %v", err) + } + values := parsed.Query() + for key, want := range want { + if values.Get(key) != want { + t.Fatalf("%s = %q, want %q: %s", key, values.Get(key), want, uris[0]) + } + } + }) + } +} diff --git a/backend/internal/protocol/compile.go b/backend/internal/protocol/compile.go index 4377c9e..433b545 100644 --- a/backend/internal/protocol/compile.go +++ b/backend/internal/protocol/compile.go @@ -223,13 +223,7 @@ func asUsersArray(cfg map[string]interface{}, fromCreds []UserCred, field string out = normalizeUsersValue(raw) } } - if flow, ok := cfg["flow"].(string); ok && strings.TrimSpace(flow) != "" { - for _, user := range out { - if _, exists := user["flow"]; !exists { - user["flow"] = flow - } - } - } + applyListenerFlow(cfg, out) // Propagate top-level alterId to each user object. The official Mihomo // schema for VMess expects alterId inside users[].alterId; without it, // per-user alterId is silently dropped when panel credentials are bound. @@ -318,6 +312,24 @@ func asUsersMapUUID(cfg map[string]interface{}, fromCreds []UserCred, hasCredSta return out } +// applyListenerFlow copies a listener-level VLESS flow onto every user that does +// not carry one. +// +// Vision flow is TCP-only, so a stale flow must not be pushed onto users of a +// listener that has since been switched to ws/grpc/xhttp: Mihomo rejects that +// combination outright, which takes the whole listener down rather than one user. +func applyListenerFlow(cfg map[string]interface{}, users []map[string]interface{}) { + flow, ok := cfg["flow"].(string) + if !ok || strings.TrimSpace(flow) == "" || !TransportCarriesFlow(cfg) { + return + } + for _, user := range users { + if _, exists := user["flow"]; !exists { + user["flow"] = flow + } + } +} + func normalizeUsersValue(value interface{}) []map[string]interface{} { switch users := value.(type) { case []interface{}: diff --git a/backend/internal/protocol/flow_transport_test.go b/backend/internal/protocol/flow_transport_test.go new file mode 100644 index 0000000..56e565b --- /dev/null +++ b/backend/internal/protocol/flow_transport_test.go @@ -0,0 +1,136 @@ +package protocol + +import ( + "strings" + "testing" + + "github.com/kazeyukiro/3m-ui/backend/internal/database/models" +) + +func TestTransportSpecCarriesFlow(t *testing.T) { + cases := []struct { + network string + want bool + }{ + {"", true}, // empty means tcp + {"tcp", true}, + {"TCP", true}, // case-insensitive + {"raw", true}, + {"ws", false}, + {"grpc", false}, + {"xhttp", false}, + {"h2", false}, + } + for _, c := range cases { + if got := (TransportSpec{Network: c.network}).CarriesFlow(); got != c.want { + t.Errorf("TransportSpec{Network:%q}.CarriesFlow() = %v, want %v", c.network, got, c.want) + } + } +} + +func TestTransportCarriesFlowFromConfig(t *testing.T) { + cases := []struct { + name string + cfg map[string]interface{} + want bool + }{ + {"empty config", map[string]interface{}{}, true}, + {"ws path", map[string]interface{}{"ws-path": "/ws"}, false}, + {"blank ws path is not configured", map[string]interface{}{"ws-path": " "}, true}, + {"grpc service", map[string]interface{}{"grpc-service-name": "svc"}, false}, + {"xhttp block", map[string]interface{}{"xhttp-config": map[string]interface{}{"path": "/x"}}, false}, + {"empty xhttp block is not configured", map[string]interface{}{"xhttp-config": map[string]interface{}{}}, true}, + {"mkcp block", map[string]interface{}{"mkcp-config": map[string]interface{}{"mtu": 1350}}, false}, + {"mekya block", map[string]interface{}{"mekya-config": map[string]interface{}{"enable": true}}, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := TransportCarriesFlow(c.cfg); got != c.want { + t.Fatalf("TransportCarriesFlow(%v) = %v, want %v", c.cfg, got, c.want) + } + }) + } +} + +// A listener whose transport was switched to ws keeps its flow in storage; the +// exported share must not advertise it. +func TestVLESSShareOmitsFlowForWebSocket(t *testing.T) { + const uuid = "00000000-0000-0000-0000-000000000001" + cases := []struct { + name string + config string + wantFlow bool + }{ + {"tcp keeps flow", `{"flow":"xtls-rprx-vision"}`, true}, + {"ws drops flow", `{"flow":"xtls-rprx-vision","ws-path":"/ws"}`, false}, + {"grpc drops flow", `{"flow":"xtls-rprx-vision","grpc-service-name":"svc"}`, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + l := models.Listener{ + Name: "n", Protocol: "vless", Type: "vless", Port: "443", + BindAddress: "0.0.0.0", Enabled: true, Config: c.config, + } + shares, err := ExportShares(l, "example.com", []UserCred{{Username: "u", UUID: uuid}}) + if err != nil { + t.Fatalf("ExportShares: %v", err) + } + if len(shares) == 0 { + t.Fatal("expected at least one share") + } + uri := shares[0].URI + if strings.TrimSpace(uri) == "" { + t.Fatalf("empty share URI: %#v", shares[0]) + } + if got := strings.Contains(uri, "flow="); got != c.wantFlow { + t.Fatalf("flow present = %v, want %v: %s", got, c.wantFlow, uri) + } + }) + } +} + +// The server-side config must not be handed an impossible combination either: +// Mihomo rejects a Vision flow on a non-TCP listener outright. +func TestVLESSCompileSkipsStaleFlowForWebSocket(t *testing.T) { + cfg := map[string]interface{}{ + "flow": "xtls-rprx-vision", + "ws-path": "/ws", + } + users := []map[string]interface{}{ + {"username": "u", "uuid": "00000000-0000-0000-0000-000000000001"}, + } + applyListenerFlow(cfg, users) + if _, present := users[0]["flow"]; present { + t.Fatalf("flow must not be applied on a ws listener: %#v", users[0]) + } + + // A raw listener still inherits the listener-level default. + applyListenerFlow(map[string]interface{}{"flow": "xtls-rprx-vision"}, users) + if users[0]["flow"] != "xtls-rprx-vision" { + t.Fatalf("raw listener must keep the default flow: %#v", users[0]) + } +} + +// The VMess client YAML lost the ws Host header: it built ws-opts with the path +// only, so a listener that pins a Host was unreachable through the exported +// config while the identical vless YAML worked. +func TestVMessYAMLCarriesWebSocketHost(t *testing.T) { + l := models.Listener{ + Name: "n", Protocol: "vmess", Type: "vmess", Port: "443", + BindAddress: "0.0.0.0", Enabled: true, + Config: `{"ws-path":"/ws","ws-headers":{"Host":"cdn.example.com"}}`, + } + shares, err := ExportShares(l, "example.com", []UserCred{{Username: "u", UUID: "00000000-0000-0000-0000-000000000001"}}) + if err != nil { + t.Fatalf("ExportShares: %v", err) + } + if len(shares) == 0 { + t.Fatal("expected a share") + } + yaml := shares[0].ClientYAML + for _, want := range []string{"network: ws", "path: /ws", "Host: cdn.example.com"} { + if !strings.Contains(yaml, want) { + t.Fatalf("client YAML missing %q:\n%s", want, yaml) + } + } +} diff --git a/backend/internal/protocol/share_modules.go b/backend/internal/protocol/share_modules.go index 809e49e..d528039 100644 --- a/backend/internal/protocol/share_modules.go +++ b/backend/internal/protocol/share_modules.go @@ -87,7 +87,7 @@ func (VLESSCompiler) BuildShare(in ShareInput) (Share, error) { } else { params["encryption"] = "none" } - if spec.Flow != "" { + if spec.Flow != "" && spec.Transport.CarriesFlow() { params["flow"] = spec.Flow } if spec.SNI != "" { @@ -144,7 +144,7 @@ func vlessClientYAML(node NodeModel, host, port, uuid string, spec *VLESSSpec) ( if node.UDP { p["udp"] = true } - if spec.Flow != "" { + if spec.Flow != "" && spec.Transport.CarriesFlow() { p["flow"] = spec.Flow } // tls / reality @@ -379,7 +379,15 @@ func (VMessCompiler) BuildShare(in ShareInput) (Share, error) { extra["alpn"] = spec.ALPN } if netw == "ws" && spec.Transport.WSPath != "" { - extra["ws-opts"] = map[string]interface{}{"path": spec.Transport.WSPath} + // Per mihomo wiki (proxies-transport: ws-opts), the Host header belongs + // under `headers`, not at the top level of ws-opts. vlessClientYAML + // already emits it that way; a ws listener that pins a Host is + // unreachable when the client YAML leaves it out. + wsOpts := map[string]interface{}{"path": spec.Transport.WSPath} + if spec.Transport.WSHost != "" { + wsOpts["headers"] = map[string]interface{}{"Host": spec.Transport.WSHost} + } + extra["ws-opts"] = wsOpts } if netw == "grpc" && spec.Transport.GRPCService != "" { extra["grpc-opts"] = map[string]interface{}{"grpc-service-name": spec.Transport.GRPCService} diff --git a/backend/internal/protocol/types.go b/backend/internal/protocol/types.go index 203a6b4..798dca8 100644 --- a/backend/internal/protocol/types.go +++ b/backend/internal/protocol/types.go @@ -1,5 +1,7 @@ package protocol +import "strings" + // NodeModel is the strongly-typed node view used by the protocol registry. // Listener.Config remains JSON on disk; DecodeNodeModel maps it into this shape. type NodeModel struct { @@ -42,6 +44,47 @@ type TransportSpec struct { XHTTPPath string } +// CarriesFlow reports whether this transport can carry VLESS flow (XTLS +// Vision). Vision is TCP-only: the core rejects it on ws, grpc and xhttp, and a +// client that honours the flow simply fails to connect. Every place that emits a +// flow has to ask this rather than trust the stored configuration, because a +// listener whose transport was switched after the flow was set keeps the stale +// value. +func (t TransportSpec) CarriesFlow() bool { + switch strings.ToLower(strings.TrimSpace(t.Network)) { + case "", "tcp", "raw": + return true + default: + return false + } +} + +// TransportCarriesFlow answers the same question for a raw listener config, which +// records its transport as the presence of a per-transport block rather than as a +// single named field. +func TransportCarriesFlow(cfg map[string]interface{}) bool { + for _, key := range []string{"ws-path", "grpc-service-name", "xhttp-config", "mkcp-config", "mekya-config"} { + value, ok := cfg[key] + if !ok || value == nil { + continue + } + switch typed := value.(type) { + case string: + if strings.TrimSpace(typed) != "" { + return false + } + case map[string]interface{}: + if len(typed) > 0 { + return false + } + default: + // A block is present in some other shape; treat it as configured. + return false + } + } + return true +} + type VLESSSpec struct { Encryption string Flow string // default flow applied when user flow empty diff --git a/frontend/src/components/ListenerConfigFields.tsx b/frontend/src/components/ListenerConfigFields.tsx index 17fc4e3..294aca8 100644 --- a/frontend/src/components/ListenerConfigFields.tsx +++ b/frontend/src/components/ListenerConfigFields.tsx @@ -405,6 +405,18 @@ export function formValuesToConfig( cfg[key] = v; }; + // XTLS Vision flow is TCP-only (MetaCubeX / Xray). Decide this *before* any + // field writes the config. The protocol switch below used to write `flow` + // unconditionally and the clear only ran later, so it edited the form state + // after the value had already been copied into the saved listener — the UI + // said the flow was dropped for ws/grpc/xhttp while the share link kept + // advertising it. + const transportLayer = String(values.transport_layer ?? 'raw'); + const flowAllowed = + (transportLayer === 'raw' || transportLayer === 'tcp') && + !values.mkcp_enabled && + !values.mekya_enabled; + switch (protocol) { case 'shadowsocks': set('cipher', values.cipher); @@ -442,7 +454,7 @@ export function formValuesToConfig( set('max-streams', toInt(values['max-streams'])); break; case 'vless': - set('flow', values.flow); + set('flow', flowAllowed ? values.flow : undefined); set('ws-path', values['ws-path']); set('grpc-service-name', values['grpc-service-name']); set('decryption', values.decryption); @@ -604,13 +616,12 @@ export function formValuesToConfig( if (!XHTTP_PROTOCOLS.has(protocol)) { values.xhttp_enabled = false; } - // Vision flow is TCP-only (MetaCubeX / Xray); strip on ws/grpc/xhttp/mkcp/mekya. - const layerNow = values.transport_layer || 'raw'; - if (layerNow !== 'raw' && layerNow !== 'tcp') { - values.flow = undefined; - } - if (values.mkcp_enabled || values.mekya_enabled) { + // Keeps the form state consistent with what was written above, and drops any + // flow that reached cfg by another route. The decision itself was made before + // the protocol switch — see flowAllowed. + if (!flowAllowed) { values.flow = undefined; + delete cfg.flow; } const realityOn = REALITY_PROTOCOLS.has(protocol) && (!!values.reality_enabled || values.security_layer === 'reality'); const wantTLSMaterial = diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index a4e3510..864bad2 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -77,7 +77,8 @@ export default { "id": "ID", "network": "Network", "bound": "Bound", - "missing": "Missing" + "missing": "Missing", + "deleted": "Deleted", }, "login": { "title": "3M-UI", @@ -471,6 +472,25 @@ export default { "versionHint": "Protocol version (e.g. Snell version, TUIC v4/v5 semantics).", "zero_rttHint": "QUIC 0-RTT (wiki). Lower latency; be aware of replay trade-offs.", + "access_alpnHint": "ALPN list offered to clients on this access profile (wiki: alpn), e.g. h2,http/1.1.", + "access_sniHint": "SNI hostname clients should send on this access profile (wiki: sni).", + "alwaysTlsHint": "This protocol requires TLS. Leave certificate empty to auto-generate a panel self-signed pair on save.", + "applyCertHint": "Writes certificate + private-key into each selected node config. Use panel SSL files or paths under /etc/letsencrypt.", + "clientExportOnlyHint": "Below: client subscription only — not written to Mihomo inbound YAML.", + "from_panel_sslHint": "Use the panel's own SSL material instead of entering certificate paths by hand.", + "jlsHint": "JLS wrapper (wiki jls-config). Advanced; needs its own destination and password.", + "key_fileHint": "Path to the TLS key file, for when the key is not pasted inline.", + "mekyaExclusiveHint": "Requires TCP transport; exclusive with WS/gRPC/mKCP. Enabling clears other transports on save.", + "mkcpExclusiveHint": "Requires TCP transport; exclusive with WS/gRPC/Mekya. Enabling clears other transports on save.", + "protocolHint": "Inbound protocol (wiki). Changing it swaps the transport and security options below.", + "resTlsHint": "resTLS wrapper (wiki res-tls). Advanced; the client must be configured to match.", + "shadowTlsHint": "ShadowTLS wrapper (wiki shadow-tls). Version and password must match the client.", + "tlsMirrorHint": "Advanced. Requires dest and primary-key; mutually exclusive with certificate TLS.", + "tlsPairAutoHint": "Certificate + private-key required. Leave both empty → panel self-signed on save.", + "tlsPairHint": "Certificate and private-key for this listener; leave both empty for no TLS.", + "trafficMultiplierHint": "Charged traffic is the real traffic multiplied by this factor. Default 1; 1.5 counts 50% extra.", + "transportExclusiveHint": "One transport only. mKCP/Mekya (VMess) require TCP.", + "transportXhttpHint": "XHTTP is available for VLESS.", }, "core": { "title": "Core", @@ -1006,6 +1026,10 @@ export default { "public_urlHint": "Public base URL of the panel (used in OAuth redirect, subscription links, etc.). Include https://.", "web_pathHint": "Optional URL path prefix for the panel UI.", + "restoreAutoReload": "Auto-reloading in 5s…", + "restoreTimeout": "Panel did not come back in 30s; please refresh manually.", + "switchToPre": "Switch to pre-release channel", + "switchToStable": "Switch to stable channel", }, "share": { "title": "Share / Subscription", diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index b513806..0d80183 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -77,7 +77,8 @@ export default { "id": "ID", "network": "网络", "bound": "已绑定", - "missing": "缺失" + "missing": "缺失", + "deleted": "已删除", }, "login": { "title": "3M-UI", @@ -452,18 +453,44 @@ export default { "trafficMultiplier": "流量倍率", "allow_insecureHint": "客户端导出允许不安全 TLS(wiki allow-insecure)。", "alpnHint": "TLS/QUIC ALPN(wiki: alpn),如 h2、http/1.1。", + "client_auth_certHint": "启用客户端认证时的 CA / 证书内容。", + "client_auth_typeHint": "TLS 客户端认证类型(wiki client-auth)。", "congestion_controllerHint": "QUIC 拥塞控制(wiki: congestion-controller)。", + "ech_keyHint": "配置 Encrypted Client Hello (ECH) 时的密钥内容。", + "enable_pure_downlinkHint": "协议支持时的纯下行开关(高级选项)。", "enabledHint": "关闭后该 Listener 不会写入运行中的核心配置。", "grpcServiceNameHint": "gRPC service name(wiki grpc-opts)。", + "muxHint": "多路复用选项(wiki smux/mux),高级选项。", "obfs_passwordHint": "混淆密码(wiki: obfs-password),需与客户端一致。", + "paddingHint": "协议支持时的填充相关选项。", "private_keyHint": "TLS 私钥 PEM 或路径(wiki: private-key)。", "skip_cert_verifyHint": "仅影响客户端导出的 skip-cert-verify,不是服务端入站字段。", "snellVersionHint": "内核支持的 Snell 协议版本。", + "table_typeHint": "该协议路径下的高级 table/type 选项。", "tokenHint": "TUIC v4 token(wiki tuic-v4)。v5 用 UUID 用户可留空。", "transport_layerHint": "传输层(wiki network/transport):raw/TCP、ws、grpc、xhttp、mkcp… 仅能选一种。", "versionHint": "协议版本(如 Snell 版本,TUIC v4/v5 语义)。", "zero_rttHint": "QUIC 0-RTT(wiki)。延迟更低,需注意重放风险。", + "access_alpnHint": "该接入配置向客户端提供的 ALPN 列表(wiki: alpn),如 h2、http/1.1。", + "access_sniHint": "该接入配置下客户端应发送的 SNI 主机名(wiki: sni)。", + "alwaysTlsHint": "该协议必须使用 TLS。证书留空时保存会自动生成面板自签证书对。", + "applyCertHint": "把证书与私钥写入所选节点的配置。可使用面板 SSL 文件,或 /etc/letsencrypt 下的路径。", + "clientExportOnlyHint": "以下仅用于客户端订阅,不会写入 Mihomo 入站 YAML。", + "from_panel_sslHint": "使用面板自身的 SSL 证书,无需手工填写证书路径。", + "jlsHint": "JLS 包装(wiki jls-config)。高级选项,需单独配置目标与密码。", + "key_fileHint": "TLS 私钥文件路径,用于不直接粘贴私钥内容的场景。", + "mekyaExclusiveHint": "需要 TCP 传输层,与 WS/gRPC/mKCP 互斥。启用后保存时会清除其它传输层。", + "mkcpExclusiveHint": "需要 TCP 传输层,与 WS/gRPC/Mekya 互斥。启用后保存时会清除其它传输层。", + "protocolHint": "入站协议(wiki)。切换后下面的传输层与安全层选项会随之变化。", + "resTlsHint": "resTLS 包装(wiki res-tls)。高级选项,客户端需做对应配置。", + "shadowTlsHint": "ShadowTLS 包装(wiki shadow-tls)。版本与密码需与客户端一致。", + "tlsMirrorHint": "高级选项。需要 dest 与 primary-key,与证书型 TLS 互斥。", + "tlsPairAutoHint": "证书与私钥为必填。两者都留空时保存会生成面板自签证书。", + "tlsPairHint": "该监听器的证书与私钥对;两者都留空表示不使用 TLS。", + "trafficMultiplierHint": "实际流量 × 倍率计入用户配额。默认 1,例如 1.5 表示多计 50%。", + "transportExclusiveHint": "仅能选择一种传输层。mKCP/Mekya(VMess)需要 TCP。", + "transportXhttpHint": "XHTTP 仅适用于 VLESS。", }, "core": { "title": "核心管理", @@ -999,6 +1026,10 @@ export default { "public_urlHint": "面板公网根 URL(OAuth 回调、订阅拼接等)。需含 https://。", "web_pathHint": "面板 UI 可选路径前缀。", + "restoreAutoReload": "5 秒后自动刷新…", + "restoreTimeout": "面板 30 秒内未恢复,请手动刷新页面。", + "switchToPre": "切换到预发布通道", + "switchToStable": "切换到稳定通道", }, "share": { "title": "分享 / 订阅", diff --git a/frontend/src/utils/i18nLocales.test.mjs b/frontend/src/utils/i18nLocales.test.mjs new file mode 100644 index 0000000..186c050 --- /dev/null +++ b/frontend/src/utils/i18nLocales.test.mjs @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import ts from 'typescript'; + +// Locale files contain minified regions — several "key":"value" pairs on a single +// line — so they have to be read through the TypeScript AST. A line-based parser +// silently swallows those keys, which is how the gap this file guards went +// unnoticed: 24 keys were referenced from components without ever being declared, +// so `t()` fell through to its hardcoded fallback and, where there was none, +// rendered the raw key in the UI. +const localeDir = new URL('../i18n/locales/', import.meta.url); +const srcDir = new URL('../', import.meta.url); + +function parseLocale(name) { + const file = new URL(name, localeDir); + const src = ts.createSourceFile(name, fs.readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + let root = null; + for (const statement of src.statements) { + if (!ts.isExportAssignment(statement)) continue; + let expr = statement.expression; + if (ts.isAsExpression(expr) || ts.isSatisfiesExpression(expr)) expr = expr.expression; + if (ts.isObjectLiteralExpression(expr)) root = expr; + } + assert.ok(root, `${name} must default-export an object literal`); + + const keys = new Map(); + const duplicates = []; + const walk = (object, stack) => { + const seen = new Set(); + for (const property of object.properties) { + if (!ts.isPropertyAssignment(property)) continue; + const nameNode = property.name; + const key = ts.isStringLiteral(nameNode) || ts.isIdentifier(nameNode) ? nameNode.text : null; + if (key == null) continue; + if (seen.has(key)) duplicates.push([...stack, key].join('.')); + seen.add(key); + const full = [...stack, key].join('.'); + if (ts.isObjectLiteralExpression(property.initializer)) walk(property.initializer, [...stack, key]); + else if (ts.isStringLiteral(property.initializer)) keys.set(full, property.initializer.text); + } + }; + walk(root, []); + return { keys, duplicates }; +} + +/** Dotted keys handed to t() or fieldTip() anywhere in src. */ +function referencedKeys() { + const found = new Map(); + const visit = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const target = new URL(entry.name + (entry.isDirectory() ? '/' : ''), dir); + if (entry.isDirectory()) { + if (entry.name !== 'node_modules') visit(target); + continue; + } + if (!entry.name.endsWith('.ts') && !entry.name.endsWith('.tsx')) continue; + const source = ts.createSourceFile( + entry.name, + fs.readFileSync(target, 'utf8'), + ts.ScriptTarget.Latest, + true, + entry.name.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + const walkNode = (node) => { + if (ts.isCallExpression(node) && node.arguments.length && ts.isStringLiteral(node.arguments[0])) { + const callee = node.expression.getText(source); + if (/(^|\.)t$/.test(callee) || callee === 'fieldTip') { + const key = node.arguments[0].text; + if (key.includes('.')) { + if (!found.has(key)) found.set(key, new Set()); + found.get(key).add(`${path.relative(fs.realpathSync(srcDir), fs.realpathSync(target.pathname))}:${source.getLineAndCharacterOfPosition(node.pos).line + 1}`); + } + } + } + ts.forEachChild(node, walkNode); + }; + walkNode(source); + } + }; + visit(srcDir); + return found; +} + +const en = parseLocale('en.ts'); +const references = referencedKeys(); + +test('the locale parser sees the whole catalogue', () => { + assert.ok(en.keys.size > 1000, `en.ts should expose ~1000 keys, parsed ${en.keys.size}`); +}); + +// The reported defect: a key used by a component but never declared, so the +// tooltip either vanished (fieldTip) or showed the raw key (t). +test('every key referenced from code is declared in en.ts', () => { + const missing = [...references.keys()].filter((k) => !en.keys.has(k)).sort(); + assert.deepEqual(missing, [], `undeclared keys: ${missing.map((k) => `${k} (${[...references.get(k)][0]})`).join(', ')}`); +}); + +test('no locale declares the same key twice', () => { + for (const file of fs.readdirSync(localeDir).filter((f) => f.endsWith('.ts') && f !== 'zh.ts').sort()) { + assert.deepEqual(parseLocale(file).duplicates, [], `${file} has duplicate keys`); + } +}); + +test('no locale keeps a key en.ts no longer has', () => { + for (const file of fs.readdirSync(localeDir).filter((f) => f.endsWith('.ts') && f !== 'zh.ts').sort()) { + if (file === 'en.ts') continue; + const orphans = [...parseLocale(file).keys.keys()].filter((k) => !en.keys.has(k)); + assert.deepEqual(orphans, [], `${file} has keys missing from en.ts: ${orphans.join(', ')}`); + } +}); + +// Chinese is the primary UI language; a gap there reads as a defect, and English +// fallback would look broken rather than merely untranslated. +test('zh-CN covers every key en.ts declares', () => { + const zh = parseLocale('zh-CN.ts').keys; + const missing = [...en.keys.keys()].filter((k) => !zh.has(k)).sort(); + assert.deepEqual(missing, [], `zh-CN is missing: ${missing.join(', ')}`); +}); + +test('no locale carries an empty or non-string value', () => { + for (const file of fs.readdirSync(localeDir).filter((f) => f.endsWith('.ts') && f !== 'zh.ts').sort()) { + const { keys } = parseLocale(file); + for (const [key, value] of keys) { + assert.ok(value.trim() !== '', `${file}: ${key} is empty`); + } + } +}); + +test('other locales fall back to English for keys they lack', () => { + // Informational: these lag on purpose and the provider resolves them from + // en.ts. Printed so the gap stays visible instead of being forgotten. + const lagging = []; + for (const file of fs.readdirSync(localeDir).filter((f) => f.endsWith('.ts') && f !== 'zh.ts').sort()) { + if (file === 'en.ts' || file === 'zh-CN.ts') continue; + const missing = [...en.keys.keys()].filter((k) => !parseLocale(file).keys.has(k)).length; + if (missing > 0) lagging.push(`${file}:${missing}`); + } + if (lagging.length) console.log(' untranslated (falls back to en):', lagging.join(' ')); + assert.ok(true); +}); diff --git a/frontend/src/utils/listenerFlowTransport.test.mjs b/frontend/src/utils/listenerFlowTransport.test.mjs new file mode 100644 index 0000000..51bd8d0 --- /dev/null +++ b/frontend/src/utils/listenerFlowTransport.test.mjs @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import vm from 'node:vm'; +import ts from 'typescript'; + +// Executes the real form builder instead of reimplementing the rule. The bug +// being guarded lived in the ordering inside that function: the flow was written +// into the config before the transport was consulted, so "cleared for +// ws/grpc/xhttp" only ever edited the form state and the saved listener — and +// therefore every share link built from it — kept advertising a flow no client +// can use. +const source = ts.createSourceFile( + 'ListenerConfigFields.tsx', + fs.readFileSync(new URL('../components/ListenerConfigFields.tsx', import.meta.url), 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, +); + +// Pure data and helpers only: every top-level `new Set([...])` / array literal +// and every plain helper function. React components, hooks and anything touching +// JSX are deliberately left out of the harness. +const pieces = []; +ts.forEachChild(source, (node) => { + // Skip React components: capitalised, or containing JSX tags. Type + // annotations like Record also contain '<', so match tags only. + const isPlainFunction = + ts.isFunctionDeclaration(node) && + !node.name?.getText(source).match(/^[A-Z]/) && + !node.getText(source).includes(''); + const isDataStatement = + ts.isVariableStatement(node) && + (node.getText(source).includes('new Set(') || /^const [A-Z_]+ = \[/.test(node.getText(source))); + if (!isPlainFunction && !isDataStatement) return; + // `export function` is only valid in a module; this harness runs a script. + pieces.push(node.getText(source).replace(/^export\s+/, '')); +}); +assert.ok(pieces.length > 10, `expected the builder's dependencies, got ${pieces.length} pieces`); + +const javascript = ts.transpileModule( + `${pieces.join('\n')}\nglobalThis.formValuesToConfig = formValuesToConfig;`, + { compilerOptions: { target: ts.ScriptTarget.ES2022 } }, +).outputText; + +const sandbox = {}; +vm.createContext(sandbox); +vm.runInContext(javascript, sandbox); +const formValuesToConfig = sandbox.formValuesToConfig; +assert.equal(typeof formValuesToConfig, 'function', 'form builder must load'); + +const baseValues = (overrides) => ({ + transport_layer: 'raw', + security_layer: 'tls', + flow: 'xtls-rprx-vision', + ...overrides, +}); + +test('flow survives on a raw/TCP VLESS listener', () => { + const cfg = formValuesToConfig('vless', baseValues({})); + assert.equal(cfg.flow, 'xtls-rprx-vision', 'raw transport must keep flow'); +}); + +test('flow is dropped for ws, grpc and xhttp', () => { + const cases = [ + ['ws', { transport_layer: 'ws', 'ws-path': '/ws' }], + ['grpc', { transport_layer: 'grpc', 'grpc-service-name': 'svc' }], + ['xhttp', { transport_layer: 'xhttp', xhttp_config_path: '/x' }], + ]; + for (const [name, overrides] of cases) { + const cfg = formValuesToConfig('vless', baseValues(overrides)); + assert.equal(cfg.flow, undefined, `${name} must not carry flow into the saved config`); + assert.equal('flow' in cfg, false, `${name} must not emit a flow key at all`); + } +}); + +test('flow is dropped for mkcp and mekya transports', () => { + for (const flag of ['mkcp_enabled', 'mekya_enabled']) { + const cfg = formValuesToConfig('vless', baseValues({ [flag]: true })); + assert.equal('flow' in cfg, false, `${flag} must not carry flow`); + } +}); + +test('a flow arriving from the previous config is dropped for ws', () => { + const cfg = formValuesToConfig( + 'vless', + baseValues({ transport_layer: 'ws', 'ws-path': '/ws' }), + { flow: 'xtls-rprx-vision', 'ws-path': '/ws' }, + ); + assert.equal('flow' in cfg, false, 'a stale flow must not survive a transport change'); +}); + +test('an unknown transport keeps flow, so TCP listeners are unaffected', () => { + // The form defaults to raw when the layer is not recorded; failing that way + // is deliberate, since a TCP listener must not lose its Vision flow. + const cfg = formValuesToConfig('vless', baseValues({ transport_layer: undefined })); + assert.equal(cfg.flow, 'xtls-rprx-vision'); +});