Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docs/sing-box-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@ 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

| 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 |
Expand All @@ -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.

Expand Down
5 changes: 3 additions & 2 deletions src/lib/client-export/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
80 changes: 80 additions & 0 deletions src/lib/client-export/vmess-fidelity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, expect, it } from "bun:test";
import { parseProxyUriToSingBoxOutbound } from "./uri";

function vmessUri(payload: Record<string, unknown>): 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");
});
});
22 changes: 19 additions & 3 deletions src/lib/client-export/vmess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}
Expand All @@ -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();
Expand Down
Loading