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
13 changes: 13 additions & 0 deletions .changeset/blue-apes-knock.md
Original file line number Diff line number Diff line change
@@ -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

5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
41 changes: 32 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<auth>@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

Expand Down Expand Up @@ -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.
48 changes: 48 additions & 0 deletions src/connection-uri.test.ts
Original file line number Diff line number Diff line change
@@ -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.'
]);
});
});
48 changes: 48 additions & 0 deletions src/connection-uri.ts
Original file line number Diff line number Diff line change
@@ -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;
}
118 changes: 118 additions & 0 deletions src/hysteria2-uri.test.ts
Original file line number Diff line number Diff line change
@@ -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 ':'.");
});
});
Loading