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/bright-kings-visit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"singboxctl": minor
---

Add narrow `naive+https://` and `naive+quic://` URI support.

This release adds:

- a dedicated Naive URI parser and shared connection-scheme dispatch
- generated `sing-box` outbound support for a narrow Naive subset
- a `Select connection and profile` prompt for enabling Naive `udp_over_tcp` when needed
- explicit validation for unsupported or ambiguous Naive URI fields
- user-facing warnings when provider-only fields such as `padding` are present but not supported yet in generated config
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
## Current Limitations

- macOS only
- Connection import currently supports a narrow subset of `vless://` and `hysteria2://` URIs
- Connection import currently supports a narrow subset of `vless://`, `hysteria2://`, and `naive+https://` / `naive+quic://` URIs
- Supported rule formats are currently `domain:...`, `domain_suffix:...`, and `ip_cidr:...`
- Unsupported URI or rule features fail explicitly instead of being guessed

Expand Down Expand Up @@ -47,6 +47,26 @@ Provider links in the wild may also include extra Hysteria2 parameters such as `

Unsupported Hysteria2 features fail explicitly.

#### Naive

Currently supported:

- `naive+https://` and `naive+quic://`
- username and password in URI userinfo
- optional `sni`
- optional `extra-headers`
- optional generated `udp_over_tcp: true` when enabled during `Select connection and profile`

For Naive URIs, the auth values are read from the URI userinfo segment:

`naive+https://<username>:<password>@example.com:443?...`

Provider links in the wild may also include extra Naive parameters such as `padding`. 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.

Currently, `padding` is accepted with a warning and is not applied to the generated `sing-box` config.

Unsupported Naive features fail explicitly.

## Install

Install the CLI globally:
Expand Down
48 changes: 47 additions & 1 deletion src/connection-uri.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { parseConnectionUriToSingBoxOutbound, validateConnectionUri } from "./connection-uri.js";
import { isNaiveConnectionUri, parseConnectionUriToSingBoxOutbound, validateConnectionUri } from "./connection-uri.js";

describe("connection uri parser", () => {
it("dispatches vless URIs to the vless parser", () => {
Expand Down Expand Up @@ -32,6 +32,41 @@ describe("connection uri parser", () => {
});
});

it("dispatches naive URIs to the naive parser", () => {
expect(
parseConnectionUriToSingBoxOutbound("naive+https://alice:secret@example.com:443?sni=edge.example.com#work")
).toEqual({
type: "naive",
server: "example.com",
server_port: 443,
username: "alice",
password: "secret",
tls: {
enabled: true,
server_name: "edge.example.com"
}
});
});

it("enables UDP over TCP for naive URIs when requested", () => {
expect(
parseConnectionUriToSingBoxOutbound("naive+https://alice:secret@example.com:443?sni=edge.example.com#work", {
naiveUdpOverTcp: true
})
).toEqual({
type: "naive",
server: "example.com",
server_port: 443,
username: "alice",
password: "secret",
udp_over_tcp: true,
tls: {
enabled: true,
server_name: "edge.example.com"
}
});
});

it("rejects unsupported URI schemes", () => {
expect(() => validateConnectionUri("trojan://secret@example.com:443")).toThrow(
'Unsupported connection URI scheme "trojan:".'
Expand All @@ -45,4 +80,15 @@ describe("connection uri parser", () => {
'Hysteria2 fp="chrome" is present in the provider URI but is not supported yet in the generated sing-box config.'
]);
});

it("surfaces naive warnings through the shared validator", () => {
expect(validateConnectionUri("naive+https://alice:secret@example.com:443?padding=true#work")).toEqual([
'Naive padding="true" is present in the provider URI but is not supported yet in the generated sing-box config.'
]);
});

it("detects naive URI schemes", () => {
expect(isNaiveConnectionUri("naive+https://alice:secret@example.com:443")).toBe(true);
expect(isNaiveConnectionUri("vless://id@example.com:443?encryption=none&security=none&type=tcp")).toBe(false);
});
});
27 changes: 25 additions & 2 deletions src/connection-uri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,34 @@ import {
validateHysteria2ConnectionUri,
type Hysteria2Outbound
} from "./hysteria2-uri/index.js";
import {
parseNaiveUriToSingBoxOutbound,
validateNaiveConnectionUri,
withNaiveUdpOverTcp,
type NaiveOutbound
} from "./naive-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 type SupportedConnectionOutbound = Hysteria2Outbound | NaiveOutbound | VlessOutbound;
export type ConnectionGenerationOptions = {
naiveUdpOverTcp?: boolean;
};

export function parseConnectionUriToSingBoxOutbound(uri: string): SupportedConnectionOutbound {
export function parseConnectionUriToSingBoxOutbound(
uri: string,
options: ConnectionGenerationOptions = {}
): SupportedConnectionOutbound {
const scheme = readUriScheme(uri);

switch (scheme) {
case "vless:":
return parseVlessUriToSingBoxOutbound(uri);
case "hysteria2:":
return parseHysteria2UriToSingBoxOutbound(uri);
case "naive+https:":
case "naive+quic:":
return withNaiveUdpOverTcp(parseNaiveUriToSingBoxOutbound(uri), options.naiveUdpOverTcp === true);
default:
throw new FriendlyMessageError(`Unsupported connection URI scheme "${scheme || "(empty)"}".`);
}
Expand All @@ -30,6 +45,9 @@ export function validateConnectionUri(uri: string): string[] {
return validateVlessConnectionUri(uri);
case "hysteria2:":
return validateHysteria2ConnectionUri(uri);
case "naive+https:":
case "naive+quic:":
return validateNaiveConnectionUri(uri);
default:
throw new FriendlyMessageError(`Unsupported connection URI scheme "${scheme || "(empty)"}".`);
}
Expand All @@ -46,3 +64,8 @@ function readUriScheme(uri: string): string {

return url.protocol;
}

export function isNaiveConnectionUri(uri: string): boolean {
const scheme = readUriScheme(uri);
return scheme === "naive+https:" || scheme === "naive+quic:";
}
121 changes: 121 additions & 0 deletions src/naive-uri.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, expect, it } from "vitest";
import { parseNaiveUriToSingBoxOutbound, validateNaiveConnectionUri, withNaiveUdpOverTcp } from "./naive-uri/index.js";

describe("naive uri parser", () => {
it("parses a naive+https URI into a sing-box outbound", () => {
expect(
parseNaiveUriToSingBoxOutbound("naive+https://alice:secret@example.com:443?sni=edge.example.com#work")
).toEqual({
type: "naive",
server: "example.com",
server_port: 443,
username: "alice",
password: "secret",
tls: {
enabled: true,
server_name: "edge.example.com"
}
});
});

it("defaults the port to 443 when it is omitted", () => {
expect(parseNaiveUriToSingBoxOutbound("naive+https://alice:secret@example.com?sni=edge.example.com")).toEqual({
type: "naive",
server: "example.com",
server_port: 443,
username: "alice",
password: "secret",
tls: {
enabled: true,
server_name: "edge.example.com"
}
});
});

it("enables quic for naive+quic URIs", () => {
expect(parseNaiveUriToSingBoxOutbound("naive+quic://alice:secret@example.com:443?sni=edge.example.com")).toEqual({
type: "naive",
server: "example.com",
server_port: 443,
username: "alice",
password: "secret",
quic: true,
tls: {
enabled: true,
server_name: "edge.example.com"
}
});
});

it("maps extra-headers into the outbound config", () => {
expect(
parseNaiveUriToSingBoxOutbound(
"naive+https://alice:secret@example.com:443?extra-headers=Host%3A%20cdn.example.com%0D%0AX-Test%3A%201"
)
).toEqual({
type: "naive",
server: "example.com",
server_port: 443,
username: "alice",
password: "secret",
extra_headers: {
Host: "cdn.example.com",
"X-Test": "1"
},
tls: {
enabled: true,
server_name: "example.com"
}
});
});

it("warns when padding is present but not applied to the generated config", () => {
expect(validateNaiveConnectionUri("naive+https://alice:secret@example.com:443?padding=true")).toEqual([
'Naive padding="true" is present in the provider URI but is not supported yet in the generated sing-box config.'
]);
});

it("adds udp_over_tcp only when explicitly enabled", () => {
expect(
withNaiveUdpOverTcp(
parseNaiveUriToSingBoxOutbound("naive+https://alice:secret@example.com:443?sni=edge.example.com"),
true
)
).toEqual({
type: "naive",
server: "example.com",
server_port: 443,
username: "alice",
password: "secret",
udp_over_tcp: true,
tls: {
enabled: true,
server_name: "edge.example.com"
}
});
});

it("warns when padding is present with an empty value", () => {
expect(validateNaiveConnectionUri("naive+https://alice:secret@example.com:443?padding=")).toEqual([
'Naive padding="" is present in the provider URI but is not supported yet in the generated sing-box config.'
]);
});

it("rejects unsupported query parameters", () => {
expect(() => parseNaiveUriToSingBoxOutbound("naive+https://alice:secret@example.com:443?foo=1")).toThrow(
'Unsupported Naive query parameters: "foo".'
);
});

it("rejects ip hosts without sni", () => {
expect(() => parseNaiveUriToSingBoxOutbound("naive+https://alice:secret@203.0.113.10:443")).toThrow(
"Naive URI using an IP address host must include sni for TLS."
);
});

it("rejects invalid percent-encoding in the username with a friendly error", () => {
expect(() => parseNaiveUriToSingBoxOutbound("naive+https://abc%zz:secret@example.com:443?sni=edge.example.com")).toThrow(
"Connection URI contains invalid percent-encoding in the Naive username."
);
});
});
Loading