From 638c1f92d227eba6ed9ff3fe65ad26332fae56bc Mon Sep 17 00:00:00 2001 From: Shura Vlasov Date: Sat, 30 May 2026 20:06:26 +0300 Subject: [PATCH] Add naive outbound support --- .changeset/bright-kings-visit.md | 13 ++ README.md | 22 ++- src/connection-uri.test.ts | 48 +++++- src/connection-uri.ts | 27 ++- src/naive-uri.test.ts | 121 ++++++++++++++ src/naive-uri/index.ts | 273 +++++++++++++++++++++++++++++++ src/select-and-apply.test.ts | 19 ++- src/select-and-apply.ts | 22 ++- src/sing-box-config.test.ts | 52 +++++- src/sing-box-config.ts | 12 +- src/store.ts | 51 +++++- src/tui/select-and-apply.ts | 28 +++- 12 files changed, 661 insertions(+), 27 deletions(-) create mode 100644 .changeset/bright-kings-visit.md create mode 100644 src/naive-uri.test.ts create mode 100644 src/naive-uri/index.ts diff --git a/.changeset/bright-kings-visit.md b/.changeset/bright-kings-visit.md new file mode 100644 index 0000000..e2d4f7d --- /dev/null +++ b/.changeset/bright-kings-visit.md @@ -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 diff --git a/README.md b/README.md index adc0a4e..7232bfb 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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://:@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: diff --git a/src/connection-uri.test.ts b/src/connection-uri.test.ts index 494f136..865d830 100644 --- a/src/connection-uri.test.ts +++ b/src/connection-uri.test.ts @@ -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", () => { @@ -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:".' @@ -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); + }); }); diff --git a/src/connection-uri.ts b/src/connection-uri.ts index cae780c..3deb981 100644 --- a/src/connection-uri.ts +++ b/src/connection-uri.ts @@ -4,12 +4,24 @@ 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) { @@ -17,6 +29,9 @@ export function parseConnectionUriToSingBoxOutbound(uri: string): SupportedConne 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)"}".`); } @@ -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)"}".`); } @@ -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:"; +} diff --git a/src/naive-uri.test.ts b/src/naive-uri.test.ts new file mode 100644 index 0000000..7276ab0 --- /dev/null +++ b/src/naive-uri.test.ts @@ -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." + ); + }); +}); diff --git a/src/naive-uri/index.ts b/src/naive-uri/index.ts new file mode 100644 index 0000000..5140bb3 --- /dev/null +++ b/src/naive-uri/index.ts @@ -0,0 +1,273 @@ +import { isIP } from "node:net"; +import { FriendlyMessageError } from "../cli.js"; + +export type NaiveOutbound = { + extra_headers?: Record; + password: string; + quic?: true; + server: string; + server_port: number; + tls: { + enabled: true; + server_name: string; + }; + type: "naive"; + udp_over_tcp?: true; + username: string; +}; + +type ParsedNaiveUri = { + duplicateQueryParameterNames: string[]; + extraHeaders: string; + hasInvalidPasswordEncoding: boolean; + hasInvalidUsernameEncoding: boolean; + hasPaddingParameter: boolean; + padding: string; + password: string; + portText: string; + protocol: string; + queryParameterNames: string[]; + server: string; + serverName: string; + serverPort: number; + username: string; +}; + +const DEFAULT_HTTPS_PORT = 443; +const SUPPORTED_QUERY_PARAMETERS = new Set(["extra-headers", "padding", "sni"]); + +export function parseNaiveUriToSingBoxOutbound(uri: string): NaiveOutbound { + return parseNaiveUriToSingBoxOutboundDetailed(uri).outbound; +} + +export function validateNaiveConnectionUri(uri: string): string[] { + return parseNaiveUriToSingBoxOutboundDetailed(uri).warnings; +} + +export function withNaiveUdpOverTcp(outbound: NaiveOutbound, enabled: boolean): NaiveOutbound { + if (enabled) { + return { + ...outbound, + udp_over_tcp: true + }; + } + + const { udp_over_tcp: _ignored, ...outboundWithoutUdpOverTcp } = outbound; + return outboundWithoutUdpOverTcp; +} + +function parseNaiveUriToSingBoxOutboundDetailed(uri: string): { + outbound: NaiveOutbound; + warnings: string[]; +} { + const parsed = parseNaiveUri(uri); + const validation = validateParsedNaiveUri(parsed); + + if (validation.issues.length > 0) { + throw new FriendlyMessageError(formatValidationIssues(validation.issues)); + } + + const outbound: NaiveOutbound = { + type: "naive", + server: parsed.server, + server_port: parsed.portText.length > 0 ? parsed.serverPort : DEFAULT_HTTPS_PORT, + username: parsed.username, + password: parsed.password, + tls: { + enabled: true, + server_name: parsed.serverName.length > 0 ? parsed.serverName : parsed.server + } + }; + + if (parsed.protocol === "naive+quic:") { + outbound.quic = true; + } + + const extraHeaders = parseExtraHeaders(parsed.extraHeaders); + + if (extraHeaders) { + outbound.extra_headers = extraHeaders; + } + + return { + outbound, + warnings: validation.warnings + }; +} + +function parseNaiveUri(uri: string): ParsedNaiveUri { + let url: URL; + + try { + url = new URL(uri.trim()); + } catch { + throw new FriendlyMessageError("Connection URI is not a valid URL."); + } + + const queryParameterNames = new Set(); + const queryParameterCounts = new Map(); + + for (const [name] of url.searchParams.entries()) { + queryParameterNames.add(name); + queryParameterCounts.set(name, (queryParameterCounts.get(name) ?? 0) + 1); + } + + let username = ""; + let password = ""; + let hasInvalidUsernameEncoding = false; + let hasInvalidPasswordEncoding = false; + + try { + username = decodeURIComponent(url.username).trim(); + } catch { + hasInvalidUsernameEncoding = true; + } + + try { + password = decodeURIComponent(url.password).trim(); + } catch { + hasInvalidPasswordEncoding = true; + } + + return { + duplicateQueryParameterNames: Array.from(queryParameterCounts.entries()) + .filter(([, count]) => count > 1) + .map(([name]) => name), + extraHeaders: readQueryValue(url, "extra-headers"), + hasInvalidPasswordEncoding, + hasInvalidUsernameEncoding, + hasPaddingParameter: url.searchParams.has("padding"), + padding: readQueryValue(url, "padding"), + password, + portText: url.port.trim(), + protocol: url.protocol, + queryParameterNames: Array.from(queryParameterNames), + server: url.hostname.trim().replace(/^\[|\]$/gu, ""), + serverName: readQueryValue(url, "sni"), + serverPort: Number.parseInt(url.port.trim(), 10), + username + }; +} + +function validateParsedNaiveUri(parsed: ParsedNaiveUri): { + 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 !== "naive+https:" && parsed.protocol !== "naive+quic:") { + issues.push('Only naive+https:// and naive+quic:// URIs are supported by the Naive parser.'); + } + + if (parsed.hasInvalidUsernameEncoding) { + issues.push("Connection URI contains invalid percent-encoding in the Naive username."); + } + + if (parsed.hasInvalidPasswordEncoding) { + issues.push("Connection URI contains invalid percent-encoding in the Naive password."); + } + + if (parsed.username.length === 0) { + issues.push("Naive URI is missing a username."); + } + + if (parsed.password.length === 0) { + issues.push("Naive URI is missing a password."); + } + + if (parsed.server.length === 0) { + issues.push("Naive URI is missing a server host."); + } + + if ( + parsed.portText.length > 0 && + (!Number.isInteger(parsed.serverPort) || parsed.serverPort <= 0 || parsed.serverPort > 65535) + ) { + issues.push(`Naive URI has an invalid server port: "${parsed.portText}".`); + } + + if (parsed.server.length > 0 && isIP(parsed.server) !== 0 && parsed.serverName.length === 0) { + issues.push("Naive URI using an IP address host must include sni for TLS."); + } + + if (repeatedSupportedQueryParameters.length > 0) { + issues.push( + `Repeated Naive query parameters are not supported: ${repeatedSupportedQueryParameters.map((name) => `"${name}"`).join(", ")}.` + ); + } + + if (unsupportedQueryParameters.length > 0) { + issues.push( + `Unsupported Naive query parameters: ${unsupportedQueryParameters.map((name) => `"${name}"`).join(", ")}.` + ); + } + + if (parsed.hasPaddingParameter) { + warnings.push( + `Naive padding="${parsed.padding}" is present in the provider URI but is not supported yet in the generated sing-box config.` + ); + } + + return { + issues, + warnings + }; +} + +function parseExtraHeaders(rawExtraHeaders: string): Record | undefined { + if (rawExtraHeaders.length === 0) { + return undefined; + } + + const headers: Record = {}; + + for (const rawLine of rawExtraHeaders.split(/\r\n/gu)) { + const line = rawLine.trim(); + + if (line.length === 0) { + continue; + } + + const separatorIndex = line.indexOf(":"); + + if (separatorIndex <= 0) { + throw new FriendlyMessageError( + `Unsupported Naive extra-headers entry "${line}". Use "Header: Value" lines separated by CRLF.` + ); + } + + const name = line.slice(0, separatorIndex).trim(); + const value = line.slice(separatorIndex + 1).trim(); + + if (name.length === 0) { + throw new FriendlyMessageError("Naive extra-headers contains an empty header name."); + } + + if (Object.hasOwn(headers, name)) { + throw new FriendlyMessageError(`Repeated Naive extra-headers entry "${name}" is not supported.`); + } + + headers[name] = value; + } + + return Object.keys(headers).length > 0 ? headers : undefined; +} + +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() ?? ""; +} diff --git a/src/select-and-apply.test.ts b/src/select-and-apply.test.ts index 984fce6..13f198b 100644 --- a/src/select-and-apply.test.ts +++ b/src/select-and-apply.test.ts @@ -40,7 +40,8 @@ describe("select-and-apply module", () => { }); expect(await getActiveSelection()).toEqual({ connectionName: "Work", - profileName: "Office" + profileName: "Office", + naiveUdpOverTcp: false }); const configJson = JSON.parse(await readFile(selection.configPath, "utf8")) as { @@ -127,7 +128,21 @@ describe("select-and-apply module", () => { expect(await readFile(getGeneratedConfigPath(), "utf8")).toBe(previousConfigJson); expect(await getActiveSelection()).toEqual({ connectionName: "Work", - profileName: "Office" + profileName: "Office", + naiveUdpOverTcp: false + }); + }); + + it("returns the saved naive UDP over TCP choice in the active selection", async () => { + await addConnection("Naive", "naive+https://alice:secret@example.com:443?sni=edge.example.com"); + await addProfile("Office"); + + await selectAndApplyByName("Naive", "Office", runtime, { naiveUdpOverTcp: true }); + + expect(await getActiveSelection()).toEqual({ + connectionName: "Naive", + profileName: "Office", + naiveUdpOverTcp: true }); }); }); diff --git a/src/select-and-apply.ts b/src/select-and-apply.ts index f519433..76d8b61 100644 --- a/src/select-and-apply.ts +++ b/src/select-and-apply.ts @@ -2,14 +2,18 @@ import type { RuntimeDependencies } from "./app-context.js"; import { type ActiveSelectionRuntimeResult, applyActiveSelection, + getConnection, getActiveConnectionName, getActiveProfileName, + getNaiveUdpOverTcpEnabled, listConnections, listProfiles, } from "./store.js"; +import { isNaiveConnectionUri, type ConnectionGenerationOptions } from "./connection-uri.js"; export type ActiveSelection = { connectionName?: string; + naiveUdpOverTcp: boolean; profileName?: string; }; @@ -23,9 +27,10 @@ export type SelectAndApplyResult = ActiveSelectionRuntimeResult & { export async function selectAndApplyByName( connectionName: string, profileName: string, - runtimeDependencies: RuntimeDependencies + runtimeDependencies: RuntimeDependencies, + options: ConnectionGenerationOptions = {} ): Promise { - const result = await applyActiveSelection(connectionName, profileName, runtimeDependencies); + const result = await applyActiveSelection(connectionName, profileName, runtimeDependencies, options); if (!result.activeSelectionComplete || !result.configPath) { throw new Error("Invariant violation: active selection runtime finalization did not produce config.json."); @@ -44,14 +49,16 @@ export async function selectAndApplyByName( } export async function getActiveSelection(): Promise { - const [connectionName, profileName] = await Promise.all([ + const [connectionName, profileName, naiveUdpOverTcp] = await Promise.all([ getActiveConnectionName(), - getActiveProfileName() + getActiveProfileName(), + getNaiveUdpOverTcpEnabled() ]); return { connectionName, - profileName + profileName, + naiveUdpOverTcp }; } @@ -66,3 +73,8 @@ export async function listSelectableOptions(): Promise<{ profiles: profiles.map((profile) => ({ name: profile.name })) }; } + +export async function isNaiveConnectionSelection(connectionName: string): Promise { + const connection = await getConnection(connectionName); + return isNaiveConnectionUri(connection.uri); +} diff --git a/src/sing-box-config.test.ts b/src/sing-box-config.test.ts index 97e01de..922d6a7 100644 --- a/src/sing-box-config.test.ts +++ b/src/sing-box-config.test.ts @@ -21,6 +21,7 @@ const VALID_VLESS_URI = const VALID_HYSTERIA2_URI = "hysteria2://8f5726803bd04c1fbd022537bb5c7ca6@x.shura.dev:20117?alpn=h3&fp=chrome&security=tls&sni=x.shura.dev#x-hysteria-kolyan"; +const VALID_NAIVE_URI = "naive+https://alice:secret@example.com:443?sni=edge.example.com#work"; const runtime = mockRuntimeDependencies(); @@ -134,10 +135,10 @@ describe("sing-box config builder", () => { }); it("builds and writes a generated sing-box config for a hysteria2 connection", async () => { - await addConnection("Kolyan", VALID_HYSTERIA2_URI); + await addConnection("HysterTest", VALID_HYSTERIA2_URI); await addProfile("Office"); - const result = await buildAndWriteGeneratedConfig("Kolyan", "Office"); + const result = await buildAndWriteGeneratedConfig("HysterTest", "Office"); const writtenConfig = JSON.parse(await readFile(result.configPath, "utf8")) as { outbounds: Array>; }; @@ -156,6 +157,53 @@ describe("sing-box config builder", () => { }); }); + it("builds and writes a generated sing-box config for a naive connection", async () => { + await addConnection("Naive", VALID_NAIVE_URI); + await addProfile("Office"); + + const result = await buildAndWriteGeneratedConfig("Naive", "Office"); + const writtenConfig = JSON.parse(await readFile(result.configPath, "utf8")) as { + outbounds: Array>; + }; + + expect(writtenConfig.outbounds[0]).toEqual({ + type: "naive", + tag: "proxy", + server: "example.com", + server_port: 443, + username: "alice", + password: "secret", + tls: { + enabled: true, + server_name: "edge.example.com" + } + }); + }); + + it("adds udp_over_tcp to a naive outbound only when requested", async () => { + await addConnection("Naive", VALID_NAIVE_URI); + await addProfile("Office"); + + const result = await buildAndWriteGeneratedConfig("Naive", "Office", { naiveUdpOverTcp: true }); + const writtenConfig = JSON.parse(await readFile(result.configPath, "utf8")) as { + outbounds: Array>; + }; + + expect(writtenConfig.outbounds[0]).toEqual({ + type: "naive", + tag: "proxy", + server: "example.com", + server_port: 443, + username: "alice", + password: "secret", + udp_over_tcp: true, + tls: { + enabled: true, + server_name: "edge.example.com" + } + }); + }); + 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 dbc56e9..6b685fd 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 { parseConnectionUriToSingBoxOutbound } from "./connection-uri.js"; +import { parseConnectionUriToSingBoxOutbound, type ConnectionGenerationOptions } from "./connection-uri.js"; type ProxyRouteRule = { action: "route"; @@ -73,19 +73,21 @@ export type GeneratedConfigResult = { export async function buildAndWriteGeneratedConfig( connectionName: string, - profileName: string + profileName: string, + options: ConnectionGenerationOptions = {} ): Promise { const [connection, profile] = await Promise.all([getConnection(connectionName), getProfile(profileName)]); - const config = await buildSingBoxConfig(connection, profile); + const config = await buildSingBoxConfig(connection, profile, options); const configPath = await writeGeneratedConfig(config); return { config, configPath }; } export async function buildSingBoxConfig( connection: ConnectionRecord, - profile: ProfileRecord + profile: ProfileRecord, + options: ConnectionGenerationOptions = {} ): Promise { - const proxyOutbound = parseConnectionUriToSingBoxOutbound(connection.uri); + const proxyOutbound = parseConnectionUriToSingBoxOutbound(connection.uri, options); const [ipv6Enabled, logLevel] = await Promise.all([getIpv6Enabled(), getLogLevel()]); const profileRules = profile.builtIn ? [] : await readProfileRules(profile); const rules = [ diff --git a/src/store.ts b/src/store.ts index 524582f..ebaed38 100644 --- a/src/store.ts +++ b/src/store.ts @@ -3,6 +3,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import type { RuntimeDependencies } from "./app-context.js"; import { FriendlyMessageError } from "./cli.js"; +import { isNaiveConnectionUri, type ConnectionGenerationOptions } from "./connection-uri.js"; export type ConnectionRecord = { name: string; @@ -39,6 +40,7 @@ type AppState = { activeProfileName?: string; ipv6Enabled?: boolean; logLevel?: LogLevel; + naiveUdpOverTcp?: boolean; serviceIntent?: boolean; }; @@ -408,7 +410,15 @@ export async function getActiveConnectionName(): Promise { return (await readState()).activeConnectionName; } -export async function setActiveSelection(connectionName: string, profileName: string): Promise { +export async function getNaiveUdpOverTcpEnabled(): Promise { + return (await readState()).naiveUdpOverTcp === true; +} + +export async function setActiveSelection( + connectionName: string, + profileName: string, + options: ConnectionGenerationOptions = {} +): Promise { const connection = await readConnectionIfExists(connectionName); const profile = await readProfileIfExists(profileName); @@ -423,6 +433,7 @@ export async function setActiveSelection(connectionName: string, profileName: st const state = await readState(); state.activeConnectionName = connectionName; state.activeProfileName = profileName; + applyConnectionGenerationOptionsToState(state, connection.uri, options); await writeState(state); } @@ -539,12 +550,15 @@ export async function finalizeActiveSelectionRuntime( export async function applyActiveSelection( connectionName: string, profileName: string, - runtimeDependencies: RuntimeDependencies + runtimeDependencies: RuntimeDependencies, + options: ConnectionGenerationOptions = {} ): Promise { - const configPath = await buildGeneratedConfigForSelection(connectionName, profileName); + const configPath = await buildGeneratedConfigForSelection(connectionName, profileName, options); const state = await readState(); + const connection = await getConnection(connectionName); state.activeConnectionName = connectionName; state.activeProfileName = profileName; + applyConnectionGenerationOptionsToState(state, connection.uri, options); await writeState(state); const restartedService = await restartInstalledServiceIfNeeded(runtimeDependencies); @@ -563,7 +577,12 @@ async function synchronizeRuntimeForSelection( profileName: string, runtimeDependencies: RuntimeDependencies ): Promise { - const configPath = await buildGeneratedConfigForSelection(connectionName, profileName); + const state = await readState(); + const configPath = await buildGeneratedConfigForSelection( + connectionName, + profileName, + buildConnectionGenerationOptionsFromState(state) + ); const restartedService = await restartInstalledServiceIfNeeded(runtimeDependencies); return { @@ -578,10 +597,11 @@ async function synchronizeRuntimeForSelection( async function buildGeneratedConfigForSelection( connectionName: string, - profileName: string + profileName: string, + options: ConnectionGenerationOptions = {} ): Promise { const { buildAndWriteGeneratedConfig } = await import("./sing-box-config.js"); - const { configPath } = await buildAndWriteGeneratedConfig(connectionName, profileName); + const { configPath } = await buildAndWriteGeneratedConfig(connectionName, profileName, options); return configPath; } @@ -948,6 +968,25 @@ async function writeState(state: AppState): Promise { await writeJson(getStatePath(), state); } +function applyConnectionGenerationOptionsToState( + state: AppState, + connectionUri: string, + options: ConnectionGenerationOptions +): void { + if (isNaiveConnectionUri(connectionUri)) { + state.naiveUdpOverTcp = options.naiveUdpOverTcp === true; + return; + } + + delete state.naiveUdpOverTcp; +} + +function buildConnectionGenerationOptionsFromState(state: AppState): ConnectionGenerationOptions { + return { + naiveUdpOverTcp: state.naiveUdpOverTcp === true + }; +} + async function invalidateGeneratedConfigAndStopServiceIfNeeded(runtimeDependencies: RuntimeDependencies): Promise<{ disabledService: boolean; removedGeneratedConfig: boolean; diff --git a/src/tui/select-and-apply.ts b/src/tui/select-and-apply.ts index b08af26..8476c88 100644 --- a/src/tui/select-and-apply.ts +++ b/src/tui/select-and-apply.ts @@ -1,7 +1,12 @@ import type { AppContext } from "../app-context.js"; import { log } from "@clack/prompts"; -import { FriendlyMessageError, promptSelect } from "../cli.js"; -import { getActiveSelection, listSelectableOptions, selectAndApplyByName } from "../select-and-apply.js"; +import { FriendlyMessageError, promptConfirm, promptSelect } from "../cli.js"; +import { + getActiveSelection, + isNaiveConnectionSelection, + listSelectableOptions, + selectAndApplyByName +} from "../select-and-apply.js"; import { FULL_TUNNEL_PROFILE_NAME } from "../store.js"; import { runAndLogRuntimeRefresh } from "./shared.js"; @@ -19,6 +24,7 @@ export async function runSelectAndApplyFlow(context: AppContext): Promise const currentSelection = await getActiveSelection(); const currentConnectionName = currentSelection.connectionName; const currentProfileName = currentSelection.profileName; + const currentNaiveUdpOverTcp = currentSelection.naiveUdpOverTcp; const connectionName = await promptSelect( connections.map((connection) => ({ @@ -43,9 +49,25 @@ export async function runSelectAndApplyFlow(context: AppContext): Promise "Choose a profile" ); + const naiveUdpOverTcp = await promptNaiveUdpOverTcpIfNeeded(connectionName, currentNaiveUdpOverTcp); + await runAndLogRuntimeRefresh({ - run: () => selectAndApplyByName(connectionName, profileName, context.service), + run: () => selectAndApplyByName(connectionName, profileName, context.service, { naiveUdpOverTcp }), success: (selection) => `Applied connection "${selection.connectionName}" with profile "${selection.profileName}" and wrote ${selection.configPath}.` }); } + +async function promptNaiveUdpOverTcpIfNeeded( + connectionName: string, + currentNaiveUdpOverTcp: boolean +): Promise { + if (!(await isNaiveConnectionSelection(connectionName))) { + return undefined; + } + + return promptConfirm({ + message: "Enable UDP over TCP for this Naive connection?", + initialValue: currentNaiveUdpOverTcp + }); +}