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
12 changes: 9 additions & 3 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ not a promise to deploy or mutate a production Workspace.
Hysteria2, TUIC, and AnyTLS. ShadowsocksR remains importable and is skipped
from sing-box output with a warning; unknown and malformed lines are
warning-first.
- Shared Hysteria2 URI validation and export parsing for implicit port `443`,
password and userpass authentication, bracketed IPv6, port-hopping lists and
ranges, and Salamander or Gecko obfuscation. Hysteria2 certificate pinning,
ECH, and Realm sharing fail closed instead of being silently mis-mapped.
- Bounded subscription fetching with a 15 second timeout, a 4 MiB response
limit, fatal UTF-8 decoding, HTTP status classes, and network/CORS-safe
diagnostics.
Expand All @@ -32,9 +36,11 @@ not a promise to deploy or mutate a production Workspace.

## Next product and reliability work

1. Expand sing-box protocol fidelity from the current URI matrix using checked-in
fixtures and upstream schema review. Each new mapping must preserve the
warning-first behavior for partial or legacy input.
1. Continue sing-box protocol fidelity using checked-in fixtures and upstream
schema review. Candidate slices include verified Hysteria2 ECH semantics,
Realm as a separate sharing scheme, and additional transport/TLS fields for
other supported protocols. Each mapping must preserve warning-first behavior
for partial or legacy input and fail closed for security-sensitive fields.
2. Add the first public API expansion only after the contract in
[`docs/api/roadmap.md`](api/roadmap.md) is reviewed. All writes continue to
use the Workspace coordinator.
Expand Down
49 changes: 46 additions & 3 deletions docs/sing-box-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ 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.14 line on 2026-08-28. A mapping is added
only when the source URI field has a reliable sing-box equivalent.
outbound documentation for the v1.14 line and the Hysteria2 URI specification
on 2026-08-28. A mapping is added only when the source URI field has a reliable
sing-box equivalent.

## Supported outbound matrix

Expand All @@ -18,7 +19,7 @@ only when the source URI field has a reliable sing-box equivalent.
| `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 |
| `hysteria2://`, `hy2://` | `hysteria2` | server, default/single port, port-hopping lists and ranges, password or userpass, network, TLS/SNI/ALPN/insecure, Salamander or Gecko obfuscation | Omitted port defaults to `443`; bracketed IPv6 is accepted; certificate pinning and ECH fail closed until exact mappings exist |
| `tuic://` | `tuic` | server/port, UUID, password, congestion control, UDP relay mode, UDP-over-stream, zero-RTT, heartbeat, network, TLS/SNI/ALPN/insecure | Supported congestion controls are `cubic`, `new_reno`, and `bbr` |
| `anytls://` | `anytls` | server/port, password, TLS/SNI/ALPN/insecure, idle-session settings | `client_metadata` is never synthesized or copied |
| `ssr://` | none | none | The line is skipped with a stable warning; no fake outbound is generated |
Expand All @@ -36,6 +37,47 @@ The parser also rejects missing required credentials for the protocols that
need them. Duplicate display tags are made deterministic by adding a numeric
suffix; fixed selector, URL-test, direct, and block tags are reserved.

## Hysteria2 URI fidelity

SubMan uses one shared Hysteria2 parser for node validation and sing-box export.
This avoids accepting an URI in the editor and rejecting the same URI only when
building a client configuration.

Supported authority forms include:

- `hy2://password@example.com`, with an implicit port of `443`;
- `hy2://username:password@example.com:8443`, preserving the complete
`username:password` authentication value;
- independent port lists such as `443,8443,9443`;
- ranges such as `5000-6000`;
- mixed lists and ranges such as `443,5000-6000,8443`;
- bracketed IPv6 hosts with the same port-hopping syntax.

A single explicit or default port is emitted as `server_port`. Any URI that
contains more than one port or a range is emitted as `server_ports`, without a
conflicting `server_port`. Hysteria2 URI ranges use `start-end`; sing-box parses
port ranges as `start:end`, so SubMan converts each segment deterministically.
Independent ports in a multi-port URI become exact ranges such as `443:443`.

Both Salamander and Gecko obfuscation are supported when `obfs-password` (or the
legacy `obfs_password` alias) is non-empty. Gecko packet-size values are not
invented because the standard sharing URI does not carry them.

Known connection-security fields fail closed instead of being silently dropped:

- Hysteria2 `pinSHA256` is a full certificate fingerprint. It is not equivalent
to sing-box `certificate_public_key_sha256`, which hashes the certificate
public key, so SubMan does not copy or reinterpret it.
- Hysteria2 `ech` is not converted until the source ECH config-list semantics
have a verified mapping to sing-box's structured TLS ECH options.
- `hysteria2+realm://` and `hysteria2+realm+http://` are separate Realm sharing
schemes and are not handled by the ordinary Hysteria2 parser.

An unsupported or malformed Hysteria2 line produces a stable warning and is
skipped. Other valid lines in the same Aggregate remain eligible for export.
Warnings never include the original URI, authentication value, certificate
fingerprint, ECH payload, or obfuscation password.

## Subscription input

Subscription URLs are fetched by the browser when an Aggregate or export is
Expand Down Expand Up @@ -74,5 +116,6 @@ Official schema references:
- [Trojan outbound](https://sing-box.sagernet.org/configuration/outbound/trojan/)
- [Shadowsocks outbound](https://sing-box.sagernet.org/configuration/outbound/shadowsocks/)
- [Hysteria2 outbound](https://sing-box.sagernet.org/configuration/outbound/hysteria2/)
- [Hysteria2 URI scheme](https://v2.hysteria.network/docs/developers/URI-Scheme/)
- [TUIC outbound](https://sing-box.sagernet.org/configuration/outbound/tuic/)
- [AnyTLS outbound](https://sing-box.sagernet.org/configuration/outbound/anytls/)
12 changes: 12 additions & 0 deletions src/lib/client-export/fixtures/hysteria2.invalid.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
hy2://password@example.com:0#Zero
hy2://password@example.com:65536#Overflow
hy2://password@example.com:6000-5000#Reverse
hy2://password@example.com:-6000#MissingStart
hy2://password@example.com:5000-#MissingEnd
hy2://password@example.com:443,,8443#EmptySegment
hy2://password@example.com:443,#TrailingSeparator
hy2://bad%ZZ@example.com:443#BadPercent
hy2://password@example.com:443?obfs=salamander#MissingObfsPassword
hy2://password@example.com:443?obfs=unknown&obfs-password=secret#UnknownObfs
hy2://blocked-secret@example.com:443?pinSHA256=deadbeef#Pinned
hy2://blocked-secret@example.com:443?ech=ZWNoLWNvbmZpZw==#ECH
10 changes: 10 additions & 0 deletions src/lib/client-export/fixtures/hysteria2.valid.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
hy2://password@example.com#Default
hysteria2://username:password@example.com:8443#Userpass
hy2://password@example.com:443,8443,9443#Ports
hy2://password@example.com:5000-6000#Range
hy2://password@example.com:443,5000-6000,8443#Mixed
hy2://password@[2001:db8::1]:443,5000-6000#IPv6
hy2://user%3Aname:pass%40word@example.com:443#Encoded
hy2://password@example.com:443?obfs=salamander&obfs-password=obfs-pass#Salamander
hy2://password@example.com:443?obfs=gecko&obfs-password=gecko-pass#Gecko
hy2://password@example.com:443?sni=edge.example.com&alpn=h3&insecure=1#TLS
236 changes: 236 additions & 0 deletions src/lib/client-export/hysteria2-fidelity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import { describe, expect, it } from "bun:test";
import type { AggregateRule, NodeItem } from "$lib/models";
import { validateProxyUri } from "$lib/proxy-protocol";
import { createDefaultSingBoxClientProfile } from "./profile";
import { buildSingBoxClientConfig } from "./sing-box";
import { parseProxyUriToSingBoxOutbound } from "./uri";

declare const Bun: {
file(path: URL): { text(): Promise<string> };
};

async function fixtureLines(name: string): Promise<string[]> {
return (await Bun.file(new URL(`./fixtures/${name}`, import.meta.url)).text())
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}

describe("Hysteria2 sing-box URI fidelity", () => {
it("keeps shared validation and export parsing aligned for fixtures", async () => {
for (const raw of await fixtureLines("hysteria2.valid.txt")) {
const validation = validateProxyUri(raw, "hysteria2");
const parsed = parseProxyUriToSingBoxOutbound(raw, "fixture");
expect(validation.syntaxValid).toBe(true);
expect(validation.coreFieldsValid).toBe(true);
expect(parsed.warning).toBeNull();
expect(Boolean(parsed.outbound)).toBe(true);
}

for (const raw of await fixtureLines("hysteria2.invalid.txt")) {
const validation = validateProxyUri(raw, "hysteria2");
const parsed = parseProxyUriToSingBoxOutbound(raw, "fixture");
expect(validation.coreFieldsValid).toBe(false);
expect(parsed.outbound).toBeNull();
expect(Boolean(parsed.warning)).toBe(true);
}
});

it("uses server_port only for a single or default port", () => {
const defaultPort = parseProxyUriToSingBoxOutbound(
"hy2://password@example.com#Default",
"fallback",
);
const singlePort = parseProxyUriToSingBoxOutbound(
"hy2://password@example.com:8443#Single",
"fallback",
);

expect(defaultPort.outbound).toEqual({
type: "hysteria2",
tag: "Default",
server: "example.com",
server_port: 443,
password: "password",
tls: { enabled: true },
});
expect(singlePort.outbound).toEqual({
type: "hysteria2",
tag: "Single",
server: "example.com",
server_port: 8443,
password: "password",
tls: { enabled: true },
});
expect("server_ports" in (defaultPort.outbound ?? {})).toBe(false);
expect("server_ports" in (singlePort.outbound ?? {})).toBe(false);
});

it("uses server_ports for lists and ranges without a conflicting port", () => {
const result = parseProxyUriToSingBoxOutbound(
"hy2://password@example.com:443,5000-6000,8443#Mixed",
"fallback",
);

expect(result.warning).toBeNull();
expect(result.outbound).toEqual({
type: "hysteria2",
tag: "Mixed",
server: "example.com",
server_ports: ["443:443", "5000:6000", "8443:8443"],
password: "password",
tls: { enabled: true },
});
expect("server_port" in (result.outbound ?? {})).toBe(false);
});

it("preserves userpass and bracketed IPv6", () => {
const result = parseProxyUriToSingBoxOutbound(
"hy2://user%3Aname:pass%40word@[2001:db8::1]:443,5000-6000#IPv6",
"fallback",
);

expect(result.warning).toBeNull();
expect(result.outbound).toEqual({
type: "hysteria2",
tag: "IPv6",
server: "2001:db8::1",
server_ports: ["443:443", "5000:6000"],
password: "user:name:pass@word",
tls: { enabled: true },
});
});

it("maps Salamander and Gecko without inventing Gecko packet sizes", () => {
const salamander = parseProxyUriToSingBoxOutbound(
"hy2://password@example.com:443?obfs=salamander&obfs-password=obfs-pass#Salamander",
"fallback",
);
const gecko = parseProxyUriToSingBoxOutbound(
"hy2://password@example.com:443?obfs=gecko&obfs-password=gecko-pass#Gecko",
"fallback",
);

expect(salamander.outbound?.obfs).toEqual({
type: "salamander",
password: "obfs-pass",
});
expect(gecko.outbound?.obfs).toEqual({
type: "gecko",
password: "gecko-pass",
});
expect(JSON.stringify(gecko.outbound)).not.toContain("min_packet_size");
expect(JSON.stringify(gecko.outbound)).not.toContain("max_packet_size");
});

it("returns stable safe warnings for pinning and ECH", () => {
const pinned = parseProxyUriToSingBoxOutbound(
"hy2://blocked-password@example.com:443?pinSHA256=deadbeef#Pinned",
"Pinned",
);
const ech = parseProxyUriToSingBoxOutbound(
"hy2://blocked-password@example.com:443?ech=ZWNoLXNlY3JldA==#ECH",
"ECH",
);

expect(pinned.warning).toBe(
"Skipped Hysteria2 URI: certificate pinning is not supported for Pinned",
);
expect(ech.warning).toBe(
"Skipped Hysteria2 URI: ECH is not supported for ECH",
);
for (const warning of [pinned.warning ?? "", ech.warning ?? ""]) {
expect(warning).not.toContain("blocked-password");
expect(warning).not.toContain("deadbeef");
expect(warning).not.toContain("ZWNoLXNlY3JldA");
expect(warning).not.toContain("hy2://");
}
});
});

describe("Hysteria2 warning-first client export", () => {
const updatedAt = "2026-08-28T00:00:00.000Z";
const rule: AggregateRule = {
id: "rule-hy2",
name: "Hysteria2 fidelity",
nodeIds: ["hy2-valid", "hy2-blocked", "vless-valid"],
subscriptionIds: [],
excludeTagIds: [],
renameMap: {},
allowedTypes: [],
prependRegionFlags: false,
updatedAt,
};
const nodes: NodeItem[] = [
{
id: "hy2-valid",
name: "Valid HY2",
type: "hysteria2",
raw: "hy2://valid-user:valid-pass@example.com:443,5000-5001?obfs=gecko&obfs-password=valid-gecko#Valid%20HY2",
tags: [],
enabled: true,
updatedAt,
source: "single",
},
{
id: "hy2-blocked",
name: "Blocked HY2",
type: "hysteria2",
raw: "hy2://blocked-hy2-secret@example.net:443?pinSHA256=blocked-fingerprint#Blocked%20HY2",
tags: [],
enabled: true,
updatedAt,
source: "single",
},
{
id: "vless-valid",
name: "Valid VLESS",
type: "vless",
raw: "vless://00000000-0000-4000-8000-000000000001@example.org:443?security=tls&sni=example.org#Valid%20VLESS",
tags: [],
enabled: true,
updatedAt,
source: "single",
},
];

it("skips one unsafe Hysteria2 line while retaining other outbounds", async () => {
const profile = createDefaultSingBoxClientProfile("rule-hy2", updatedAt);
const result = await buildSingBoxClientConfig(profile, rule, nodes, []);

expect(result.errors).toEqual([]);
expect(result.totalLines).toBe(3);
expect(result.outbounds).toBe(2);
expect(result.skipped).toBe(1);
expect(result.warnings).toEqual([
"Skipped Hysteria2 URI: certificate pinning is not supported for Blocked HY2",
]);

const config = result.config as {
outbounds: Array<Record<string, unknown>>;
};
const hysteria2 = config.outbounds.find(
(outbound) => outbound.type === "hysteria2",
);
expect(hysteria2).toEqual({
type: "hysteria2",
tag: "Valid HY2",
server: "example.com",
server_ports: ["443:443", "5000:5001"],
password: "valid-user:valid-pass",
tls: { enabled: true },
obfs: { type: "gecko", password: "valid-gecko" },
});
expect(config.outbounds.some((outbound) => outbound.type === "vless")).toBe(
true,
);

const warnings = result.warnings.join("\n");
expect(warnings).not.toContain("blocked-hy2-secret");
expect(warnings).not.toContain("blocked-fingerprint");
expect(warnings).not.toContain("hy2://");
expect(result.content).not.toContain("blocked-hy2-secret");
expect(result.content).not.toContain("blocked-fingerprint");
expect(result.content).not.toContain("pinSHA256");
});
});
Loading
Loading