diff --git a/docs/sing-box-export.md b/docs/sing-box-export.md index b68ccd3..5023f96 100644 --- a/docs/sing-box-export.md +++ b/docs/sing-box-export.md @@ -7,7 +7,7 @@ editing and presentation; Workspace publication goes through the existing revisioned mutation boundary. The URI mappings in this document were checked against the current sing-box -outbound documentation for the v1.13 line on 2026-08-24. A mapping is added +outbound documentation for the v1.14 line on 2026-08-28. A mapping is added only when the source URI field has a reliable sing-box equivalent. ## Supported outbound matrix @@ -15,7 +15,7 @@ only when the source URI field has a reliable sing-box equivalent. | Source URI | Generated outbound | Mapped fields | Notes | | --- | --- | --- | --- | | `vless://` | `vless` | server, port, UUID, flow, network, packet encoding, TLS/Reality, transport, multiplex | `none`, `tls`, and `reality` security; only `xtls-rprx-vision` flow is accepted | -| `vmess://` | `vmess` | base64 JSON server/port/UUID, security, alter ID, network, TLS, transport, packet encoding, multiplex | Payload must be valid base64 UTF-8 JSON | +| `vmess://` | `vmess` | base64 JSON server/port/UUID, security, alter ID, global padding, authenticated length, network, TLS, transport, packet encoding, multiplex | Includes the legacy `aes-128-ctr` security value; payload must be valid base64 UTF-8 JSON | | `trojan://` | `trojan` | server, port, password, TLS, transport, multiplex | Trojan's TLS outbound is enabled by default | | `ss://` | `shadowsocks` | method, password, server/port, SIP003 plugin/options, UDP-over-TCP, multiplex | Direct and base64 authority forms are accepted | | `hysteria2://`, `hy2://` | `hysteria2` | server/port, password, network, TLS/SNI/ALPN/insecure, Salamander obfuscation | Only the `salamander` obfuscation type is emitted | @@ -26,7 +26,9 @@ only when the source URI field has a reliable sing-box equivalent. TLS mappings include SNI, ALPN, certificate verification bypass (`insecure`), and the VLESS Reality public key/short ID when present. WebSocket, gRPC, HTTP, and HTTPUpgrade transport fields preserve their path, host, and service name -where the source format provides them. Unsupported transport, security, +where the source format provides them. Packet encoding values `packetaddr` and +`xudp` are emitted directly; an explicit `none` disables the field and is +therefore omitted from generated JSON. Unsupported transport, security, packet-encoding, or malformed percent/base64 values produce a warning for that line and do not discard other convertible lines. diff --git a/src/lib/client-export/common.ts b/src/lib/client-export/common.ts index 75358d3..2831a20 100644 --- a/src/lib/client-export/common.ts +++ b/src/lib/client-export/common.ts @@ -229,11 +229,12 @@ export function buildMultiplex( export function buildPacketEncoding( query: URLSearchParams, -): string | null | "invalid" { +): "packetaddr" | "xudp" | null | "invalid" { const value = queryValue(query, "packetEncoding", "packet_encoding"); if (!value) return null; const normalized = value.toLowerCase(); - if (["none", "packetaddr", "xudp"].includes(normalized)) { + if (normalized === "none") return null; + if (normalized === "packetaddr" || normalized === "xudp") { return normalized; } return "invalid"; diff --git a/src/lib/client-export/vmess-fidelity.test.ts b/src/lib/client-export/vmess-fidelity.test.ts new file mode 100644 index 0000000..bde664a --- /dev/null +++ b/src/lib/client-export/vmess-fidelity.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "bun:test"; +import { parseProxyUriToSingBoxOutbound } from "./uri"; + +function vmessUri(payload: Record): string { + const json = JSON.stringify(payload); + const encoded = btoa(String.fromCharCode(...new TextEncoder().encode(json))); + return `vmess://${encoded}`; +} + +describe("sing-box VMess fidelity", () => { + it("omits packet_encoding when VLESS explicitly disables it", () => { + const result = parseProxyUriToSingBoxOutbound( + "vless://00000000-0000-4000-8000-000000000101@example.com:443?security=tls&packetEncoding=none#VLESS%20No%20Packet", + "fallback", + ); + + expect(result.warning).toBeNull(); + expect(Boolean(result.outbound)).toBe(true); + expect(result.outbound && "packet_encoding" in result.outbound).toBe(false); + }); + + it("omits packet_encoding when VMess explicitly disables it", () => { + const result = parseProxyUriToSingBoxOutbound( + vmessUri({ + add: "example.com", + port: 443, + id: "00000000-0000-4000-8000-000000000102", + packetEncoding: "none", + }), + "VMess No Packet", + ); + + expect(result.warning).toBeNull(); + expect(Boolean(result.outbound)).toBe(true); + expect(result.outbound && "packet_encoding" in result.outbound).toBe(false); + }); + + it("preserves legacy VMess security and protocol booleans", () => { + const result = parseProxyUriToSingBoxOutbound( + vmessUri({ + add: "legacy.example.com", + port: "443", + id: "00000000-0000-4000-8000-000000000103", + scy: "aes-128-ctr", + aid: "1", + globalPadding: "1", + authenticated_length: 0, + }), + "Legacy VMess", + ); + + expect(result.warning).toBeNull(); + expect(result.outbound).toEqual({ + type: "vmess", + tag: "Legacy VMess", + server: "legacy.example.com", + server_port: 443, + uuid: "00000000-0000-4000-8000-000000000103", + security: "aes-128-ctr", + alter_id: 1, + global_padding: true, + authenticated_length: false, + }); + }); + + it("continues to reject unknown packet encodings", () => { + const result = parseProxyUriToSingBoxOutbound( + vmessUri({ + add: "example.com", + port: 443, + id: "00000000-0000-4000-8000-000000000104", + packet_encoding: "unsupported", + }), + "Invalid VMess", + ); + + expect(result.outbound).toBeNull(); + expect(result.warning).toContain("unsupported packet encoding"); + }); +}); diff --git a/src/lib/client-export/vmess.ts b/src/lib/client-export/vmess.ts index 17bb163..01e40c5 100644 --- a/src/lib/client-export/vmess.ts +++ b/src/lib/client-export/vmess.ts @@ -37,9 +37,14 @@ export function parseVmess( const security = stringValue(data.scy) ?? stringValue(data.security); if ( security && - !["auto", "none", "zero", "aes-128-gcm", "chacha20-poly1305"].includes( - security, - ) + ![ + "auto", + "none", + "zero", + "aes-128-gcm", + "chacha20-poly1305", + "aes-128-ctr", + ].includes(security) ) { return `Invalid vmess URI: unsupported security for ${fallbackTag}`; } @@ -60,6 +65,17 @@ export function parseVmess( } } + const globalPadding = booleanValue( + data.global_padding ?? data.globalPadding, + ); + if (globalPadding !== null) outbound.global_padding = globalPadding; + const authenticatedLength = booleanValue( + data.authenticated_length ?? data.authenticatedLength, + ); + if (authenticatedLength !== null) { + outbound.authenticated_length = authenticatedLength; + } + const network = stringValue(data.net) ?? stringValue(data.network); if (network === "tcp" || network === "udp") outbound.network = network; const transportQuery = new URLSearchParams();