From a19bb9268f7f4171271720f910d297fd74d3fe44 Mon Sep 17 00:00:00 2001 From: Shura Vlasov Date: Wed, 27 May 2026 21:35:56 +0300 Subject: [PATCH 1/2] Add narrow hysteria2 URI support Accept hysteria2 provider links via a dedicated parser, route URI validation through a shared scheme dispatcher, keep unsupported hysteria2 fields explicit, and document the provider-link versus runtime-support split. --- AGENTS.md | 5 +- README.md | 41 ++++-- src/connection-uri.test.ts | 48 +++++++ src/connection-uri.ts | 48 +++++++ src/hysteria2-uri.test.ts | 118 +++++++++++++++++ src/hysteria2-uri/index.ts | 248 ++++++++++++++++++++++++++++++++++++ src/sing-box-config.test.ts | 26 ++++ src/sing-box-config.ts | 4 +- src/tui/connections.ts | 6 +- src/vless-uri.test.ts | 2 +- src/vless-uri/index.ts | 2 +- 11 files changed, 531 insertions(+), 17 deletions(-) create mode 100644 src/connection-uri.test.ts create mode 100644 src/connection-uri.ts create mode 100644 src/hysteria2-uri.test.ts create mode 100644 src/hysteria2-uri/index.ts diff --git a/AGENTS.md b/AGENTS.md index 0d812a7..acd21e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,9 @@ - Do not implement speculative functionality that was not explicitly requested. - We currently own a narrow custom parser for the project's target Xray formats. - Prefer small, explicit parsers for the exact protocols and fields we support over generic "parse everything" logic. -- If a URI/config feature is not supported by our parser yet, fail clearly instead of guessing. +- If a URI/config feature is not supported by our parser or generated sing-box config yet, fail clearly instead of guessing by default. +- Provider links may contain extra protocol fields that are common in the wild but not fully supported by our current sing-box config generation yet. +- Document provider-link parsing support separately from guaranteed sing-box runtime support when those differ. +- For provider-link fields that we intentionally accept without applying to generated config yet, prefer explicit warnings over silent dropping, and document that behavior clearly. - Releases are published only from CI. - Locally we only create changesets; do not perform manual package releases from this repository. diff --git a/README.md b/README.md index 6b2aeef..0d99b2c 100644 --- a/README.md +++ b/README.md @@ -14,17 +14,40 @@ ## Current Limitations -### singboxctl limitations - - macOS only -- connection import currently supports `vless://` URIs only -- supported rule formats are currently `domain:...`, `domain_suffix:...`, and `ip_cidr:...` -- unsupported URI or rule features fail explicitly instead of being guessed +- Connection import currently supports a narrow subset of `vless://` and `hysteria2://` URIs +- Supported rule formats are currently `domain:...`, `domain_suffix:...`, and `ip_cidr:...` +- Unsupported URI or rule features fail explicitly instead of being guessed + +### Supported URI subset + +#### VLESS + +Currently supported: + +- `type=tcp` +- `security=none|reality` +- REALITY with `flow=xtls-rprx-vision` + +Unsupported VLESS features fail explicitly. + +#### Hysteria2 + +Currently supported: + +- `security=tls` +- optional `sni` +- `alpn=h2|h3` + +For Hysteria2 URIs, the auth value is read from the URI userinfo segment: + +`hysteria2://@example.com:443?...` + +`user:pass@` style auth is intentionally rejected. If the auth value itself contains `:`, it must be percent-encoded as `%3A`. -### Current sing-box-related subset +Provider links in the wild may also include extra Hysteria2 parameters such as `fp`. Provider-link fields are documented separately from guaranteed generated `sing-box` runtime support: if a field is not listed above in the supported subset, do not assume it is applied to `config.json` just because it appears in a provider URI. -- connection import currently supports a narrow VLESS URI subset -- currently supported VLESS URI subset: `type=tcp`, `security=none|reality`, and REALITY `flow=xtls-rprx-vision` +Unsupported Hysteria2 features fail explicitly. ## Install @@ -79,7 +102,7 @@ The current TUI includes: - `Connections` store raw Xray-compatible URIs. - `Rule Sets` store named groups of rules. The rule-set file name is the source of truth for the rule-set name. - `Profiles` select which rule sets should be active. -- `Select connection and profile` validates the selected connection with the built-in VLESS parser, writes a generated TUN config to `~/.config/singboxctl/config.json`, and refreshes the running service when needed. +- `Select connection and profile` validates the selected connection with the built-in URI parsers, writes a generated TUN config to `~/.config/singboxctl/config.json`, and refreshes the running service when needed. - `Connect in terminal` starts `sing-box` in the foreground using the currently applied `~/.config/singboxctl/config.json` and prints logs in the current terminal. This is mainly useful for debugging. - `Logs` opens or clears `/var/log/singboxctl.log` and lets you change the `sing-box` log level. - `Auto-start in background` enables or disables running `sing-box` in the background now and on future startups. diff --git a/src/connection-uri.test.ts b/src/connection-uri.test.ts new file mode 100644 index 0000000..494f136 --- /dev/null +++ b/src/connection-uri.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { parseConnectionUriToSingBoxOutbound, validateConnectionUri } from "./connection-uri.js"; + +describe("connection uri parser", () => { + it("dispatches vless URIs to the vless parser", () => { + expect( + parseConnectionUriToSingBoxOutbound( + "vless://2eaab0cc-7cef-4864-9bfe-c7c2374c5c1f@example.com:443?encryption=none&security=none&type=tcp#plain" + ) + ).toEqual({ + type: "vless", + server: "example.com", + server_port: 443, + uuid: "2eaab0cc-7cef-4864-9bfe-c7c2374c5c1f" + }); + }); + + it("dispatches hysteria2 URIs to the hysteria2 parser", () => { + expect( + parseConnectionUriToSingBoxOutbound( + "hysteria2://secret@example.com:443?security=tls&sni=example.com&fp=chrome#work" + ) + ).toEqual({ + type: "hysteria2", + server: "example.com", + server_port: 443, + password: "secret", + tls: { + enabled: true, + server_name: "example.com" + } + }); + }); + + it("rejects unsupported URI schemes", () => { + expect(() => validateConnectionUri("trojan://secret@example.com:443")).toThrow( + 'Unsupported connection URI scheme "trojan:".' + ); + }); + + it("surfaces hysteria2 warnings through the shared validator", () => { + expect( + validateConnectionUri("hysteria2://secret@example.com:443?security=tls&sni=example.com&fp=chrome#work") + ).toEqual([ + 'Hysteria2 fp="chrome" is present in the provider URI but is not supported yet in the generated sing-box config.' + ]); + }); +}); diff --git a/src/connection-uri.ts b/src/connection-uri.ts new file mode 100644 index 0000000..cae780c --- /dev/null +++ b/src/connection-uri.ts @@ -0,0 +1,48 @@ +import { FriendlyMessageError } from "./cli.js"; +import { + parseHysteria2UriToSingBoxOutbound, + validateHysteria2ConnectionUri, + type Hysteria2Outbound +} from "./hysteria2-uri/index.js"; +import { parseVlessUriToSingBoxOutbound, validateVlessConnectionUri } from "./vless-uri/index.js"; +import type { VlessOutbound } from "./vless-uri/types.js"; + +export type SupportedConnectionOutbound = Hysteria2Outbound | VlessOutbound; + +export function parseConnectionUriToSingBoxOutbound(uri: string): SupportedConnectionOutbound { + const scheme = readUriScheme(uri); + + switch (scheme) { + case "vless:": + return parseVlessUriToSingBoxOutbound(uri); + case "hysteria2:": + return parseHysteria2UriToSingBoxOutbound(uri); + default: + throw new FriendlyMessageError(`Unsupported connection URI scheme "${scheme || "(empty)"}".`); + } +} + +export function validateConnectionUri(uri: string): string[] { + const scheme = readUriScheme(uri); + + switch (scheme) { + case "vless:": + return validateVlessConnectionUri(uri); + case "hysteria2:": + return validateHysteria2ConnectionUri(uri); + default: + throw new FriendlyMessageError(`Unsupported connection URI scheme "${scheme || "(empty)"}".`); + } +} + +function readUriScheme(uri: string): string { + let url: URL; + + try { + url = new URL(uri.trim()); + } catch { + throw new FriendlyMessageError("Connection URI is not a valid URL."); + } + + return url.protocol; +} diff --git a/src/hysteria2-uri.test.ts b/src/hysteria2-uri.test.ts new file mode 100644 index 0000000..2e8e213 --- /dev/null +++ b/src/hysteria2-uri.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import { parseHysteria2UriToSingBoxOutbound, validateHysteria2ConnectionUri } from "./hysteria2-uri/index.js"; + +describe("hysteria2 uri parser", () => { + it("parses a tls hysteria2 URI into a sing-box outbound", () => { + const outbound = parseHysteria2UriToSingBoxOutbound( + "hysteria2://8f5726803bd04c1fbd022537bb5c7ca6@x.shura.dev:20117?alpn=h3&fp=chrome&security=tls&sni=x.shura.dev#x-hysteria-kolyan" + ); + + expect(outbound).toEqual({ + type: "hysteria2", + server: "x.shura.dev", + server_port: 20117, + password: "8f5726803bd04c1fbd022537bb5c7ca6", + tls: { + enabled: true, + server_name: "x.shura.dev", + alpn: ["h3"] + } + }); + }); + + it("strips IPv6 brackets from the parsed server host", () => { + const outbound = parseHysteria2UriToSingBoxOutbound( + "hysteria2://secret@[2001:db8::1]:443?security=tls&sni=example.com#ipv6" + ); + + expect(outbound).toEqual({ + type: "hysteria2", + server: "2001:db8::1", + server_port: 443, + password: "secret", + tls: { + enabled: true, + server_name: "example.com" + } + }); + }); + + it("rejects unsupported security values", () => { + expect(() => + parseHysteria2UriToSingBoxOutbound("hysteria2://secret@example.com:443?security=none&sni=example.com") + ).toThrow('Unsupported Hysteria2 security "none". Only tls is supported right now.'); + }); + + it("rejects unsupported alpn values", () => { + expect(() => + parseHysteria2UriToSingBoxOutbound("hysteria2://secret@example.com:443?alpn=h1,h3&security=tls&sni=example.com") + ).toThrow('Unsupported Hysteria2 alpn values: "h1". Only h2 and h3 are supported right now.'); + }); + + it("keeps sni optional", () => { + expect( + parseHysteria2UriToSingBoxOutbound("hysteria2://secret@example.com:443?alpn=h2,h3&security=tls&fp=chrome") + ).toEqual({ + type: "hysteria2", + server: "example.com", + server_port: 443, + password: "secret", + tls: { + enabled: true, + alpn: ["h2", "h3"] + } + }); + }); + + it("warns when fp is present but not applied to the generated config", () => { + expect( + validateHysteria2ConnectionUri( + "hysteria2://secret@example.com:443?alpn=h3&security=tls&sni=example.com&fp=chrome" + ) + ).toEqual([ + 'Hysteria2 fp="chrome" is present in the provider URI but is not supported yet in the generated sing-box config.' + ]); + }); + + it("reports all unsupported query parameters in one error", () => { + expect(() => + parseHysteria2UriToSingBoxOutbound( + "hysteria2://secret@example.com:443?foo=1&bar=2&security=tls&sni=example.com" + ) + ).toThrow('Unsupported Hysteria2 query parameters: "foo", "bar".'); + }); + + it("reports unsupported query parameters even when their value is empty", () => { + expect(() => + parseHysteria2UriToSingBoxOutbound( + "hysteria2://secret@example.com:443?obfs=&security=tls&sni=example.com" + ) + ).toThrow('Unsupported Hysteria2 query parameters: "obfs".'); + }); + + it("rejects repeated supported query parameters", () => { + expect(() => + parseHysteria2UriToSingBoxOutbound( + "hysteria2://secret@example.com:443?security=tls&security=none&sni=example.com" + ) + ).toThrow('Repeated Hysteria2 query parameters are not supported: "security".'); + }); + + it("rejects invalid percent-encoding in the password with a friendly error", () => { + expect(() => + parseHysteria2UriToSingBoxOutbound("hysteria2://abc%zz@example.com:443?security=tls&sni=example.com") + ).toThrow("Connection URI contains invalid percent-encoding in the Hysteria2 password."); + }); + + it("rejects userinfo with user:pass@", () => { + expect(() => + parseHysteria2UriToSingBoxOutbound("hysteria2://token:secret@example.com:443?security=tls&sni=example.com") + ).toThrow("Unsupported Hysteria2 userinfo format with user:pass@. Put the token before @ without ':'."); + }); + + it("rejects userinfo with token:@", () => { + expect(() => + parseHysteria2UriToSingBoxOutbound("hysteria2://token:@example.com:443?security=tls&sni=example.com") + ).toThrow("Unsupported Hysteria2 userinfo format with user:pass@. Put the token before @ without ':'."); + }); +}); diff --git a/src/hysteria2-uri/index.ts b/src/hysteria2-uri/index.ts new file mode 100644 index 0000000..d8e2964 --- /dev/null +++ b/src/hysteria2-uri/index.ts @@ -0,0 +1,248 @@ +import { FriendlyMessageError } from "../cli.js"; + +export type Hysteria2Outbound = { + password: string; + server: string; + server_port: number; + tls: { + alpn?: string[]; + enabled: true; + server_name?: string; + }; + type: "hysteria2"; +}; + +type ParsedHysteria2Uri = { + alpn: string; + duplicateQueryParameterNames: string[]; + fingerprint: string; + hasPasswordSegment: boolean; + hasInvalidPasswordEncoding: boolean; + password: string; + portText: string; + protocol: string; + queryParameterNames: string[]; + security: string; + server: string; + serverName: string; + serverPort: number; +}; + +const SUPPORTED_QUERY_PARAMETERS = new Set(["alpn", "fp", "security", "sni"]); +const SUPPORTED_ALPN_VALUES = new Set(["h2", "h3"]); + +export function parseHysteria2UriToSingBoxOutbound(uri: string): Hysteria2Outbound { + return parseHysteria2UriToSingBoxOutboundDetailed(uri).outbound; +} + +export function validateHysteria2ConnectionUri(uri: string): string[] { + return parseHysteria2UriToSingBoxOutboundDetailed(uri).warnings; +} + +function parseHysteria2UriToSingBoxOutboundDetailed(uri: string): { + outbound: Hysteria2Outbound; + warnings: string[]; +} { + const parsed = parseHysteria2Uri(uri); + const validation = validateParsedHysteria2Uri(parsed); + + if (validation.issues.length > 0) { + throw new FriendlyMessageError(formatValidationIssues(validation.issues)); + } + + const tls: Hysteria2Outbound["tls"] = { + enabled: true + }; + const alpnValues = readAlpnValues(parsed.alpn); + + if (alpnValues.length > 0) { + tls.alpn = alpnValues; + } + + if (parsed.serverName.length > 0) { + tls.server_name = parsed.serverName; + } + + return { + outbound: { + type: "hysteria2", + server: parsed.server, + server_port: parsed.serverPort, + password: parsed.password, + tls + }, + warnings: validation.warnings + }; +} + +function parseHysteria2Uri(uri: string): ParsedHysteria2Uri { + const trimmedUri = uri.trim(); + const hasPasswordSegment = hasUserinfoPasswordSegment(trimmedUri); + let url: URL; + + try { + url = new URL(trimmedUri); + } catch { + throw new FriendlyMessageError("Connection URI is not a valid URL."); + } + + const queryParameterNames = new Set(); + const queryParameterCounts = new Map(); + + for (const [name, value] of url.searchParams.entries()) { + queryParameterNames.add(name); + queryParameterCounts.set(name, (queryParameterCounts.get(name) ?? 0) + 1); + } + + let password = ""; + let hasInvalidPasswordEncoding = false; + + try { + password = decodeURIComponent(url.username).trim(); + } catch { + hasInvalidPasswordEncoding = true; + } + + return { + alpn: readQueryValue(url, "alpn"), + duplicateQueryParameterNames: Array.from(queryParameterCounts.entries()) + .filter(([, count]) => count > 1) + .map(([name]) => name), + fingerprint: readQueryValue(url, "fp"), + hasPasswordSegment, + hasInvalidPasswordEncoding, + password, + portText: url.port.trim(), + protocol: url.protocol, + queryParameterNames: Array.from(queryParameterNames), + security: readQueryValue(url, "security"), + server: url.hostname.trim().replace(/^\[|\]$/gu, ""), + serverName: readQueryValue(url, "sni"), + serverPort: Number.parseInt(url.port.trim(), 10) + }; +} + +function validateParsedHysteria2Uri(parsed: ParsedHysteria2Uri): { + issues: string[]; + warnings: string[]; +} { + const issues: string[] = []; + const warnings: string[] = []; + const repeatedSupportedQueryParameters = parsed.duplicateQueryParameterNames.filter((parameter) => + SUPPORTED_QUERY_PARAMETERS.has(parameter) + ); + const unsupportedQueryParameters = parsed.queryParameterNames.filter( + (parameter) => !SUPPORTED_QUERY_PARAMETERS.has(parameter) + ); + + if (parsed.protocol !== "hysteria2:") { + issues.push("Only hysteria2:// URIs are supported by the Hysteria2 parser."); + } + + if (parsed.hasInvalidPasswordEncoding) { + issues.push("Connection URI contains invalid percent-encoding in the Hysteria2 password."); + } + + if (parsed.hasPasswordSegment) { + issues.push("Unsupported Hysteria2 userinfo format with user:pass@. Put the token before @ without ':'."); + } + + if (parsed.password.length === 0) { + issues.push("Hysteria2 URI is missing a password."); + } + + if (parsed.server.length === 0) { + issues.push("Hysteria2 URI is missing a server host."); + } + + if (parsed.portText.length === 0) { + issues.push("Hysteria2 URI is missing a server port."); + } + + if (parsed.portText.length > 0 && (!Number.isInteger(parsed.serverPort) || parsed.serverPort <= 0 || parsed.serverPort > 65535)) { + issues.push(`Hysteria2 URI has an invalid server port: "${parsed.portText}".`); + } + + if (parsed.security !== "tls") { + issues.push( + `Unsupported Hysteria2 security "${parsed.security || "(empty)"}". Only tls is supported right now.` + ); + } + + const unsupportedAlpnValues = readAlpnValues(parsed.alpn).filter((value) => !SUPPORTED_ALPN_VALUES.has(value)); + if (unsupportedAlpnValues.length > 0) { + issues.push( + `Unsupported Hysteria2 alpn values: ${unsupportedAlpnValues.map((value) => `"${value}"`).join(", ")}. Only h2 and h3 are supported right now.` + ); + } + + if (repeatedSupportedQueryParameters.length > 0) { + issues.push( + `Repeated Hysteria2 query parameters are not supported: ${repeatedSupportedQueryParameters.map((name) => `"${name}"`).join(", ")}.` + ); + } + + if (unsupportedQueryParameters.length > 0) { + issues.push( + `Unsupported Hysteria2 query parameters: ${unsupportedQueryParameters.map((name) => `"${name}"`).join(", ")}.` + ); + } + + if (parsed.fingerprint.length > 0) { + warnings.push( + `Hysteria2 fp="${parsed.fingerprint}" is present in the provider URI but is not supported yet in the generated sing-box config.` + ); + } + + return { + issues, + warnings + }; +} + +function formatValidationIssues(issues: string[]): string { + if (issues.length === 1) { + return issues[0]; + } + + return issues.map((issue) => `- ${issue}`).join("\n"); +} + +function readQueryValue(url: URL, name: string): string { + return url.searchParams.get(name)?.trim() ?? ""; +} + +function readAlpnValues(rawAlpn: string): string[] { + if (rawAlpn.length === 0) { + return []; + } + + return rawAlpn + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0); +} + +function hasUserinfoPasswordSegment(uri: string): boolean { + const schemeSeparatorIndex = uri.indexOf("://"); + + if (schemeSeparatorIndex === -1) { + return false; + } + + const authorityStartIndex = schemeSeparatorIndex + 3; + const authorityEndCandidates = [uri.indexOf("/", authorityStartIndex), uri.indexOf("?", authorityStartIndex), uri.indexOf("#", authorityStartIndex)].filter( + (index) => index !== -1 + ); + const authorityEndIndex = + authorityEndCandidates.length > 0 ? Math.min(...authorityEndCandidates) : uri.length; + const authority = uri.slice(authorityStartIndex, authorityEndIndex); + const atIndex = authority.lastIndexOf("@"); + + if (atIndex === -1) { + return false; + } + + const userinfo = authority.slice(0, atIndex); + return userinfo.includes(":"); +} diff --git a/src/sing-box-config.test.ts b/src/sing-box-config.test.ts index f6d2640..97e01de 100644 --- a/src/sing-box-config.test.ts +++ b/src/sing-box-config.test.ts @@ -19,6 +19,9 @@ import { const VALID_VLESS_URI = "vless://2eaab0cc-7cef-4864-9bfe-c7c2374c5c1f@example.com:443?encryption=none&flow=xtls-rprx-vision&fp=ios&pbk=test-public-key&security=reality&sid=48b32b4141bb&sni=cdn.jsdelivr.net&type=tcp#work"; +const VALID_HYSTERIA2_URI = + "hysteria2://8f5726803bd04c1fbd022537bb5c7ca6@x.shura.dev:20117?alpn=h3&fp=chrome&security=tls&sni=x.shura.dev#x-hysteria-kolyan"; + const runtime = mockRuntimeDependencies(); describe("sing-box config builder", () => { @@ -130,6 +133,29 @@ describe("sing-box config builder", () => { expect(writtenConfig.outbounds[0]).not.toHaveProperty("packet_encoding"); }); + it("builds and writes a generated sing-box config for a hysteria2 connection", async () => { + await addConnection("Kolyan", VALID_HYSTERIA2_URI); + await addProfile("Office"); + + const result = await buildAndWriteGeneratedConfig("Kolyan", "Office"); + const writtenConfig = JSON.parse(await readFile(result.configPath, "utf8")) as { + outbounds: Array>; + }; + + expect(writtenConfig.outbounds[0]).toEqual({ + type: "hysteria2", + tag: "proxy", + server: "x.shura.dev", + server_port: 20117, + password: "8f5726803bd04c1fbd022537bb5c7ca6", + tls: { + enabled: true, + server_name: "x.shura.dev", + alpn: ["h3"] + } + }); + }); + it("accepts optional whitespace after the rule prefix", () => { expect(parseRuleEntry("domain_suffix: google.com")).toEqual({ action: "route", diff --git a/src/sing-box-config.ts b/src/sing-box-config.ts index 9e3dfee..dbc56e9 100644 --- a/src/sing-box-config.ts +++ b/src/sing-box-config.ts @@ -12,7 +12,7 @@ import { type ConnectionRecord, type ProfileRecord } from "./store.js"; -import { parseVlessUriToSingBoxOutbound } from "./vless-uri/index.js"; +import { parseConnectionUriToSingBoxOutbound } from "./connection-uri.js"; type ProxyRouteRule = { action: "route"; @@ -85,7 +85,7 @@ export async function buildSingBoxConfig( connection: ConnectionRecord, profile: ProfileRecord ): Promise { - const proxyOutbound = parseVlessUriToSingBoxOutbound(connection.uri); + const proxyOutbound = parseConnectionUriToSingBoxOutbound(connection.uri); const [ipv6Enabled, logLevel] = await Promise.all([getIpv6Enabled(), getLogLevel()]); const profileRules = profile.builtIn ? [] : await readProfileRules(profile); const rules = [ diff --git a/src/tui/connections.ts b/src/tui/connections.ts index 208a8d3..8ab0b5a 100644 --- a/src/tui/connections.ts +++ b/src/tui/connections.ts @@ -1,8 +1,8 @@ import type { AppContext } from "../app-context.js"; import { log } from "@clack/prompts"; import { FriendlyMessageError, promptSelect, promptText } from "../cli.js"; +import { validateConnectionUri } from "../connection-uri.js"; import { addConnection, listConnections, removeConnection, updateConnection } from "../store.js"; -import { validateConnectionUri } from "../vless-uri/index.js"; import { runChildMenuLoop } from "./menu-loop.js"; import { readConnectionNameDefault, requiredText, truncate } from "./shared.js"; @@ -59,7 +59,7 @@ export async function runConnectionsMenu(context: AppContext): Promise { async function runConnectionsAdd(): Promise { const uri = await promptText({ message: "Connection URI", - placeholder: "vless://...", + placeholder: "vless://... or hysteria2://...", validate: requiredText("Connection URI is required.") }); const warnings = await validateConnectionUri(uri); @@ -110,7 +110,7 @@ async function runConnectionsEdit(context: AppContext): Promise { const uri = await promptText({ message: "Connection URI", - placeholder: "vless://...", + placeholder: "vless://... or hysteria2://...", initialValue: connection.uri, validate: requiredText("Connection URI is required.") }); diff --git a/src/vless-uri.test.ts b/src/vless-uri.test.ts index d2079a9..28e67e3 100644 --- a/src/vless-uri.test.ts +++ b/src/vless-uri.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { parseVlessUriToSingBoxOutbound, validateConnectionUri } from "./vless-uri/index.js"; +import { parseVlessUriToSingBoxOutbound, validateVlessConnectionUri } from "./vless-uri/index.js"; describe("vless uri parser", () => { it("parses a tcp reality vision URI into a sing-box outbound", () => { diff --git a/src/vless-uri/index.ts b/src/vless-uri/index.ts index d47b37f..904d0fb 100644 --- a/src/vless-uri/index.ts +++ b/src/vless-uri/index.ts @@ -13,7 +13,7 @@ export function parseVlessUriToSingBoxOutbound(uri: string): VlessOutbound { return parseVlessUriToSingBoxOutboundDetailed(uri).outbound; } -export function validateConnectionUri(uri: string): string[] { +export function validateVlessConnectionUri(uri: string): string[] { return parseVlessUriToSingBoxOutboundDetailed(uri).warnings; } From 795d047b47a61776121ac5a582b81f05f249968f Mon Sep 17 00:00:00 2001 From: Shura Vlasov Date: Wed, 27 May 2026 21:42:16 +0300 Subject: [PATCH 2/2] Prepare minor release for hysteria2 support --- .changeset/blue-apes-knock.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/blue-apes-knock.md diff --git a/.changeset/blue-apes-knock.md b/.changeset/blue-apes-knock.md new file mode 100644 index 0000000..51aaecd --- /dev/null +++ b/.changeset/blue-apes-knock.md @@ -0,0 +1,13 @@ +--- +"singboxctl": minor +--- + +Add narrow `hysteria2://` URI support alongside the existing VLESS flow. + +This release adds: + +- a dedicated Hysteria2 URI parser and shared connection-scheme dispatch +- generated `sing-box` outbound support for a narrow Hysteria2 subset +- explicit validation for unsupported or ambiguous Hysteria2 URI fields +- user-facing warnings when provider-only fields such as `fp` are present but not supported yet in generated config +