diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6c236c7..257fcff 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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. @@ -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. diff --git a/docs/sing-box-export.md b/docs/sing-box-export.md index 5023f96..389473d 100644 --- a/docs/sing-box-export.md +++ b/docs/sing-box-export.md @@ -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 @@ -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 | @@ -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 @@ -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/) diff --git a/src/lib/client-export/fixtures/hysteria2.invalid.txt b/src/lib/client-export/fixtures/hysteria2.invalid.txt new file mode 100644 index 0000000..a18bb05 --- /dev/null +++ b/src/lib/client-export/fixtures/hysteria2.invalid.txt @@ -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 diff --git a/src/lib/client-export/fixtures/hysteria2.valid.txt b/src/lib/client-export/fixtures/hysteria2.valid.txt new file mode 100644 index 0000000..64002ed --- /dev/null +++ b/src/lib/client-export/fixtures/hysteria2.valid.txt @@ -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 diff --git a/src/lib/client-export/hysteria2-fidelity.test.ts b/src/lib/client-export/hysteria2-fidelity.test.ts new file mode 100644 index 0000000..347bb5b --- /dev/null +++ b/src/lib/client-export/hysteria2-fidelity.test.ts @@ -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 }; +}; + +async function fixtureLines(name: string): Promise { + 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>; + }; + 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"); + }); +}); diff --git a/src/lib/client-export/hysteria2.ts b/src/lib/client-export/hysteria2.ts index 6cab2a5..8134987 100644 --- a/src/lib/client-export/hysteria2.ts +++ b/src/lib/client-export/hysteria2.ts @@ -1,35 +1,24 @@ -import { - buildTls, - decodeComponent, - type ParsedProxyUri, - type ProtocolParseResult, - queryValue, -} from "./common"; +import type { Hysteria2UriIssue, ParsedHysteria2Uri } from "$lib/hysteria2-uri"; +import { buildTls, type ProtocolParseResult, queryValue } from "./common"; import type { SingBoxOutbound } from "./uri-types"; export function parseHysteria2( - parsed: ParsedProxyUri, + parsed: ParsedHysteria2Uri, fallbackTag: string, ): ProtocolParseResult { - const username = decodeComponent(parsed.url.username); - const passwordPart = decodeComponent(parsed.url.password); - const password = passwordPart || username; - if (!password) { - return `Invalid Hysteria2 URI: missing password for ${fallbackTag}`; - } - - const obfs = queryValue(parsed.query, "obfs"); - if (obfs && obfs !== "salamander") { - return `Invalid Hysteria2 URI: unsupported obfs for ${fallbackTag}`; - } const outbound: SingBoxOutbound = { type: "hysteria2", - tag: parsed.tag, + tag: parsed.tag ?? fallbackTag, server: parsed.server, - server_port: parsed.serverPort, - password, + password: parsed.password, tls: buildTls(parsed.query), }; + if (parsed.serverPort !== null) { + outbound.server_port = parsed.serverPort; + } else { + outbound.server_ports = parsed.serverPorts; + } + const network = queryValue(parsed.query, "network"); if (network) { if (network !== "tcp" && network !== "udp") { @@ -37,16 +26,35 @@ export function parseHysteria2( } outbound.network = network; } - const obfsPassword = queryValue( - parsed.query, - "obfs-password", - "obfs_password", - ); - if (obfs) { + if (parsed.obfs) { outbound.obfs = { - type: obfs, - ...(obfsPassword ? { password: obfsPassword } : {}), + type: parsed.obfs.type, + password: parsed.obfs.password, }; } return outbound; } + +export function describeHysteria2UriIssue( + issue: Hysteria2UriIssue, + fallbackTag: string, +): string { + switch (issue) { + case "missing-auth": + return `Invalid Hysteria2 URI: missing authentication for ${fallbackTag}`; + case "missing-server": + return `Invalid Hysteria2 URI: missing server for ${fallbackTag}`; + case "invalid-port": + return `Invalid Hysteria2 URI: invalid port specification for ${fallbackTag}`; + case "unsupported-obfs": + return `Invalid Hysteria2 URI: unsupported obfs for ${fallbackTag}`; + case "missing-obfs-password": + return `Invalid Hysteria2 URI: missing obfs password for ${fallbackTag}`; + case "unsupported-pin-sha256": + return `Skipped Hysteria2 URI: certificate pinning is not supported for ${fallbackTag}`; + case "unsupported-ech": + return `Skipped Hysteria2 URI: ECH is not supported for ${fallbackTag}`; + case "malformed-uri": + return `Invalid Hysteria2 URI: ${fallbackTag}`; + } +} diff --git a/src/lib/client-export/uri.ts b/src/lib/client-export/uri.ts index 64db654..16d1c6b 100644 --- a/src/lib/client-export/uri.ts +++ b/src/lib/client-export/uri.ts @@ -1,7 +1,8 @@ +import { parseHysteria2Uri } from "$lib/hysteria2-uri"; import { detectProxyScheme } from "$lib/proxy-protocol"; import { parseAnyTls } from "./anytls"; import { type ProtocolParseResult, parseProxyUrl } from "./common"; -import { parseHysteria2 } from "./hysteria2"; +import { describeHysteria2UriIssue, parseHysteria2 } from "./hysteria2"; import { parseShadowsocks } from "./shadowsocks"; import { parseTrojan } from "./trojan"; import { parseTuic } from "./tuic"; @@ -41,6 +42,15 @@ export function parseProxyUriToSingBoxOutbound( if (protocol === "vmess") { return finish(parseVmess(normalized, fallbackTag)); } + if (protocol === "hysteria2" || protocol === "hy2") { + const parsedHysteria2 = parseHysteria2Uri(normalized); + if (!parsedHysteria2.ok) { + return warning( + describeHysteria2UriIssue(parsedHysteria2.issue, fallbackTag), + ); + } + return finish(parseHysteria2(parsedHysteria2.value, fallbackTag)); + } const parsed = parseProxyUrl(normalized, fallbackTag, protocol); if (typeof parsed === "string") { @@ -61,10 +71,6 @@ export function parseProxyUriToSingBoxOutbound( case "ss": result = parseShadowsocks(normalized, parsed, fallbackTag); break; - case "hysteria2": - case "hy2": - result = parseHysteria2(parsed, fallbackTag); - break; case "tuic": result = parseTuic(parsed, fallbackTag); break; diff --git a/src/lib/hysteria2-uri.test.ts b/src/lib/hysteria2-uri.test.ts new file mode 100644 index 0000000..aa39ac1 --- /dev/null +++ b/src/lib/hysteria2-uri.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "bun:test"; +import { type ParsedHysteria2Uri, parseHysteria2Uri } from "./hysteria2-uri"; +import { validateProxyUri } from "./proxy-protocol"; + +function parse(raw: string): ParsedHysteria2Uri { + const result = parseHysteria2Uri(raw); + if (!result.ok) + throw new Error(`unexpected Hysteria2 issue: ${result.issue}`); + return result.value; +} + +describe("Hysteria2 shared URI parsing", () => { + it("defaults an omitted port to 443", () => { + const parsed = parse("hy2://password@example.com#Default"); + + expect(parsed.server).toBe("example.com"); + expect(parsed.serverPort).toBe(443); + expect(parsed.serverPorts).toEqual([]); + expect(parsed.password).toBe("password"); + expect(parsed.tag).toBe("Default"); + }); + + it("preserves userpass authentication after component decoding", () => { + const parsed = parse( + "hysteria2://user%3Aname:pass%40word@example.com:8443#Userpass", + ); + + expect(parsed.password).toBe("user:name:pass@word"); + expect(parsed.serverPort).toBe(8443); + }); + + it("converts multi-port and range syntax for sing-box", () => { + const ports = parse("hy2://password@example.com:443,8443,9443#Ports"); + const range = parse("hy2://password@example.com:5000-6000#Range"); + const mixed = parse("hy2://password@example.com:443,5000-6000,8443#Mixed"); + + expect(ports.serverPort).toBeNull(); + expect(ports.serverPorts).toEqual(["443:443", "8443:8443", "9443:9443"]); + expect(range.serverPort).toBeNull(); + expect(range.serverPorts).toEqual(["5000:6000"]); + expect(mixed.serverPorts).toEqual(["443:443", "5000:6000", "8443:8443"]); + }); + + it("accepts bracketed IPv6 with port hopping", () => { + const parsed = parse("hy2://password@[2001:db8::1]:443,5000-6000#IPv6"); + + expect(parsed.server).toBe("2001:db8::1"); + expect(parsed.serverPort).toBeNull(); + expect(parsed.serverPorts).toEqual(["443:443", "5000:6000"]); + }); + + it("rejects malformed port specifications deterministically", () => { + for (const raw of [ + "hy2://password@example.com:0", + "hy2://password@example.com:65536", + "hy2://password@example.com:6000-5000", + "hy2://password@example.com:-6000", + "hy2://password@example.com:5000-", + "hy2://password@example.com:443,,8443", + "hy2://password@example.com:443,", + ]) { + expect(parseHysteria2Uri(raw)).toEqual({ + ok: false, + issue: "invalid-port", + }); + } + }); + + it("fails closed for unsupported security semantics", () => { + expect( + parseHysteria2Uri("hy2://password@example.com:443?pinSHA256=deadbeef"), + ).toEqual({ ok: false, issue: "unsupported-pin-sha256" }); + expect( + parseHysteria2Uri("hy2://password@example.com:443?ech=ZWNoLWNvbmZpZw=="), + ).toEqual({ ok: false, issue: "unsupported-ech" }); + }); + + it("requires passwords for supported obfuscation types", () => { + expect( + parseHysteria2Uri("hy2://password@example.com:443?obfs=salamander"), + ).toEqual({ ok: false, issue: "missing-obfs-password" }); + expect( + parseHysteria2Uri( + "hy2://password@example.com:443?obfs=unknown&obfs-password=secret", + ), + ).toEqual({ ok: false, issue: "unsupported-obfs" }); + }); +}); + +describe("Hysteria2 shared validation", () => { + it("accepts default and multi-port forms without a missing-port issue", () => { + for (const raw of [ + "hy2://password@example.com", + "hysteria2://password@example.com:443,5000-6000", + ]) { + const result = validateProxyUri(raw, "hysteria2"); + expect(result.syntaxValid).toBe(true); + expect(result.coreFieldsValid).toBe(true); + expect(result.issues).not.toContain("missing-port"); + } + }); + + it("keeps malformed ranges core-invalid while preserving scheme inference", () => { + const result = validateProxyUri( + "hy2://password@example.com:6000-5000", + "hysteria2", + ); + + expect(result.inferredType).toBe("hysteria2"); + expect(result.syntaxValid).toBe(true); + expect(result.coreFieldsValid).toBe(false); + expect(result.issues).toContain("invalid-port"); + }); + + it("surfaces unsupported certificate pinning and ECH", () => { + const pinned = validateProxyUri( + "hy2://password@example.com?pinSHA256=deadbeef", + "hysteria2", + ); + const ech = validateProxyUri( + "hy2://password@example.com?ech=ZWNoLWNvbmZpZw==", + "hysteria2", + ); + + expect(pinned.coreFieldsValid).toBe(false); + expect(pinned.issues).toContain("unsupported-pin-sha256"); + expect(ech.coreFieldsValid).toBe(false); + expect(ech.issues).toContain("unsupported-ech"); + }); +}); diff --git a/src/lib/hysteria2-uri.ts b/src/lib/hysteria2-uri.ts new file mode 100644 index 0000000..dd05d16 --- /dev/null +++ b/src/lib/hysteria2-uri.ts @@ -0,0 +1,272 @@ +export type Hysteria2Obfs = { + type: "salamander" | "gecko"; + password: string; +}; + +export type ParsedHysteria2Uri = { + scheme: "hysteria2" | "hy2"; + server: string; + serverPort: number | null; + serverPorts: string[]; + password: string; + tag: string | null; + query: URLSearchParams; + obfs: Hysteria2Obfs | null; +}; + +export type Hysteria2UriIssue = + | "malformed-uri" + | "missing-auth" + | "missing-server" + | "invalid-port" + | "unsupported-obfs" + | "missing-obfs-password" + | "unsupported-pin-sha256" + | "unsupported-ech"; + +export type Hysteria2UriParseResult = + | { ok: true; value: ParsedHysteria2Uri } + | { ok: false; issue: Hysteria2UriIssue }; + +type PortSegment = { + start: number; + end: number; + isRange: boolean; +}; + +const DEFAULT_HYSTERIA2_PORT = 443; +const HYSTERIA2_SCHEME_PATTERN = /^(hysteria2|hy2):\/\//i; +const INVALID_PERCENT_ENCODING = /%(?![0-9a-f]{2})/i; + +export function parseHysteria2Uri(raw: string): Hysteria2UriParseResult { + const normalized = raw.trim(); + const schemeMatch = normalized.match(HYSTERIA2_SCHEME_PATTERN); + if (!schemeMatch || INVALID_PERCENT_ENCODING.test(normalized)) { + return failure("malformed-uri"); + } + + const scheme = schemeMatch[1].toLowerCase() as "hysteria2" | "hy2"; + const remainder = normalized.slice(schemeMatch[0].length); + const fragmentIndex = remainder.indexOf("#"); + const beforeFragment = + fragmentIndex >= 0 ? remainder.slice(0, fragmentIndex) : remainder; + const fragment = fragmentIndex >= 0 ? remainder.slice(fragmentIndex + 1) : ""; + if (fragment.includes("#")) return failure("malformed-uri"); + + const queryIndex = beforeFragment.indexOf("?"); + const beforeQuery = + queryIndex >= 0 ? beforeFragment.slice(0, queryIndex) : beforeFragment; + const queryString = + queryIndex >= 0 ? beforeFragment.slice(queryIndex + 1) : ""; + const slashIndex = beforeQuery.indexOf("/"); + const authority = + slashIndex >= 0 ? beforeQuery.slice(0, slashIndex) : beforeQuery; + const path = slashIndex >= 0 ? beforeQuery.slice(slashIndex) : ""; + if (path && path !== "/") return failure("malformed-uri"); + + const atIndex = authority.lastIndexOf("@"); + if (atIndex < 0) return failure("missing-auth"); + if (authority.slice(0, atIndex).includes("@")) { + return failure("malformed-uri"); + } + + const rawAuth = authority.slice(0, atIndex); + const serverAuthority = authority.slice(atIndex + 1); + const password = parseAuthentication(rawAuth); + if (!password) return failure("missing-auth"); + + const serverResult = parseServerAuthority(serverAuthority); + if (serverResult.ok === false) { + return failure(serverResult.issue); + } + + const query = new URLSearchParams(queryString); + if (hasNonEmptyQueryValue(query, "pinSHA256")) { + return failure("unsupported-pin-sha256"); + } + if (hasNonEmptyQueryValue(query, "ech")) { + return failure("unsupported-ech"); + } + + const obfsType = firstNonEmptyQueryValue(query, "obfs")?.toLowerCase(); + let obfs: Hysteria2Obfs | null = null; + if (obfsType) { + if (obfsType !== "salamander" && obfsType !== "gecko") { + return failure("unsupported-obfs"); + } + const obfsPassword = firstNonEmptyQueryValue( + query, + "obfs-password", + "obfs_password", + ); + if (!obfsPassword) return failure("missing-obfs-password"); + obfs = { type: obfsType, password: obfsPassword }; + } + + let tag: string | null = null; + if (fragment) { + try { + tag = decodeURIComponent(fragment) || null; + } catch { + return failure("malformed-uri"); + } + } + + return { + ok: true, + value: { + scheme, + server: serverResult.server, + serverPort: serverResult.serverPort, + serverPorts: serverResult.serverPorts, + password, + tag, + query, + obfs, + }, + }; +} + +function parseAuthentication(rawAuth: string): string | null { + if (!rawAuth) return null; + const separatorIndex = rawAuth.indexOf(":"); + try { + if (separatorIndex < 0) { + return decodeURIComponent(rawAuth) || null; + } + if (rawAuth.indexOf(":", separatorIndex + 1) >= 0) return null; + const username = decodeURIComponent(rawAuth.slice(0, separatorIndex)); + const password = decodeURIComponent(rawAuth.slice(separatorIndex + 1)); + if (!username || !password) return null; + return `${username}:${password}`; + } catch { + return null; + } +} + +function parseServerAuthority(value: string): + | { + ok: true; + server: string; + serverPort: number | null; + serverPorts: string[]; + } + | { ok: false; issue: "missing-server" | "invalid-port" | "malformed-uri" } { + if (!value) return failure("missing-server"); + + let hostLiteral = ""; + let portSpec: string | null = null; + if (value.startsWith("[")) { + const closingBracket = value.indexOf("]"); + if (closingBracket < 0) return failure("malformed-uri"); + hostLiteral = value.slice(0, closingBracket + 1); + const suffix = value.slice(closingBracket + 1); + if (suffix) { + if (!suffix.startsWith(":")) return failure("malformed-uri"); + portSpec = suffix.slice(1); + } + } else { + const colonIndex = value.lastIndexOf(":"); + if (colonIndex >= 0) { + hostLiteral = value.slice(0, colonIndex); + portSpec = value.slice(colonIndex + 1); + if (hostLiteral.includes(":")) return failure("malformed-uri"); + } else { + hostLiteral = value; + } + } + + const server = normalizeServer(hostLiteral); + if (!server) return failure("missing-server"); + if (portSpec === "") return failure("invalid-port"); + + const ports = parsePortSpec(portSpec); + if (!ports) return failure("invalid-port"); + const usesServerPorts = + ports.length > 1 || ports.some((segment) => segment.isRange); + if (!usesServerPorts) { + return { + ok: true, + server, + serverPort: ports[0].start, + serverPorts: [], + }; + } + + return { + ok: true, + server, + serverPort: null, + serverPorts: ports.map((segment) => `${segment.start}:${segment.end}`), + }; +} + +function normalizeServer(hostLiteral: string): string | null { + if (!hostLiteral) return null; + try { + const probe = new URL(`http://${hostLiteral}:443`); + if (!probe.hostname || probe.username || probe.password) return null; + const hostname = probe.hostname; + return hostname.startsWith("[") && hostname.endsWith("]") + ? hostname.slice(1, -1) + : hostname; + } catch { + return null; + } +} + +function parsePortSpec(value: string | null): PortSegment[] | null { + if (value === null) { + return [ + { + start: DEFAULT_HYSTERIA2_PORT, + end: DEFAULT_HYSTERIA2_PORT, + isRange: false, + }, + ]; + } + + const rawSegments = value.split(","); + if (rawSegments.length === 0 || rawSegments.some((segment) => !segment)) { + return null; + } + + const segments: PortSegment[] = []; + for (const rawSegment of rawSegments) { + const match = rawSegment.match(/^(\d+)(?:-(\d+))?$/); + if (!match) return null; + const start = Number(match[1]); + const end = match[2] ? Number(match[2]) : start; + if (!isValidPort(start) || !isValidPort(end) || start > end) { + return null; + } + segments.push({ start, end, isRange: Boolean(match[2]) }); + } + return segments; +} + +function isValidPort(value: number): boolean { + return Number.isSafeInteger(value) && value >= 1 && value <= 65_535; +} + +function firstNonEmptyQueryValue( + query: URLSearchParams, + ...names: string[] +): string | null { + for (const name of names) { + for (const value of query.getAll(name)) { + if (value.trim()) return value.trim(); + } + } + return null; +} + +function hasNonEmptyQueryValue(query: URLSearchParams, name: string): boolean { + return query.getAll(name).some((value) => Boolean(value.trim())); +} + +function failure( + issue: T, +): { ok: false; issue: T } { + return { ok: false, issue }; +} diff --git a/src/lib/proxy-protocol.ts b/src/lib/proxy-protocol.ts index ba52ca1..606bdd6 100644 --- a/src/lib/proxy-protocol.ts +++ b/src/lib/proxy-protocol.ts @@ -1,3 +1,4 @@ +import { type Hysteria2UriIssue, parseHysteria2Uri } from "$lib/hysteria2-uri"; import type { ProxyType } from "$lib/models"; export const PROXY_TYPES = [ @@ -70,6 +71,38 @@ export function validateProxyUri( }; } + if ( + declaredType && + declaredType !== "other" && + inferredType !== "other" && + declaredType !== inferredType + ) { + issues.push("declared-type-mismatch"); + } + + if (inferredType === "hysteria2") { + const parsed = parseHysteria2Uri(raw); + if (!parsed.ok) { + issues.push(hysteria2ValidationIssue(parsed.issue)); + return { + scheme, + inferredType, + declaredType: declaredType ?? null, + syntaxValid: parsed.issue !== "malformed-uri", + coreFieldsValid: false, + issues, + }; + } + return { + scheme, + inferredType, + declaredType: declaredType ?? null, + syntaxValid: true, + coreFieldsValid: true, + issues, + }; + } + let url: URL; try { url = new URL(raw.trim()); @@ -85,14 +118,6 @@ export function validateProxyUri( } if (inferredType === "other") issues.push("unsupported-scheme"); - if ( - declaredType && - declaredType !== "other" && - inferredType !== "other" && - declaredType !== inferredType - ) { - issues.push("declared-type-mismatch"); - } const decoded = decodeBase64Json(raw, scheme); const server = decoded?.server ?? url.hostname; @@ -106,7 +131,7 @@ export function validateProxyUri( if (!isUuid(decoded?.credential ?? url.username)) issues.push("missing-uuid"); } - if (inferredType === "trojan" || inferredType === "hysteria2") { + if (inferredType === "trojan") { if (!(url.username || url.password)) issues.push("missing-password"); } if (inferredType === "tuic") { @@ -137,6 +162,27 @@ export function validateProxyUri( }; } +function hysteria2ValidationIssue(issue: Hysteria2UriIssue): string { + switch (issue) { + case "missing-auth": + return "missing-password"; + case "missing-server": + return "missing-server"; + case "invalid-port": + return "invalid-port"; + case "unsupported-obfs": + return "unsupported-obfs"; + case "missing-obfs-password": + return "missing-obfs-password"; + case "unsupported-pin-sha256": + return "unsupported-pin-sha256"; + case "unsupported-ech": + return "unsupported-ech"; + case "malformed-uri": + return "malformed-uri"; + } +} + function isUuid(value: string | null | undefined): boolean { return Boolean( value &&