/** * Reading a proxy out of a pasted line. * * The parser itself is Rust's `parse_txt_proxies` * (`src-tauri/src/proxy_manager.rs`); both the import dialog and the add/edit * form hand their clipboard text to it rather than re-implementing the format * zoo. What is left for the frontend is the part the backend deliberately * refuses to decide: `a:b:c:d` is either `host:port:username:password` or * `username:password:host:port`, and when both middle fields parse as a port * only the user knows which. The backend reports that as `ambiguous`; the * functions below turn the user's answer back into a proxy. * * Kept free of runtime imports so `proxy-string.test.mjs` can load it directly. */ import type { ParsedProxyLine, ProxyParseResult } from "@/types"; /** * URL schemes the Rust parser accepts, mapped onto the stored proxy type. * * A Map rather than an object literal, and for the same reason as * PROTOCOL_TOKENS below: the key comes from text the user pasted, and an object * lookup answers for the whole prototype chain. `__proto__://a:1:b:2` resolved * to Object.prototype and `constructor://...` to a function, both truthy, so * `splitProxyScheme` handed a non-string `proxy_type` to the import preview. */ const PROXY_SCHEMES = new Map([ ["http", "http"], ["https", "https"], ["httpstls", "httpstls"], ["socks", "socks5"], ["socks4", "socks4"], ["socks5", "socks5"], ["ss", "ss"], ["shadowsocks", "ss"], ["vless", "vless"], ["vmess", "vmess"], ["trojan", "trojan"], ["hysteria2", "hysteria2"], ["hy2", "hysteria2"], ]); /** * Types an Xray-core sidecar carries. Their whole configuration is the share * link stored in `vless_uri`, a name from before VMess, Trojan and Hysteria2 * joined VLESS. */ const XRAY_PROXY_TYPES = new Set([ "vless", "vmess", "trojan", "hysteria2", "hy2", ]); /** Whether a stored `proxy_type` is configured by a share link. */ export function isXrayProxyType(proxyType: string): boolean { return XRAY_PROXY_TYPES.has(normalizeProxyType(proxyType)); } /** * Protocol identifiers, shortened where the raw stored type would not fit the * management table's column. Not translated: these are wire protocol names, * like the raw type that cell printed before. What the protocol MEANS for the * user belongs in the tooltip, which is translated. */ const PROTOCOL_TOKENS = new Map([["httpstls", "HTTP/TLS"]]); /** * Trim and case-fold a stored `proxy_type` before anything compares it. * * The first two steps of `canonicalProxyType` (`proxy-type.ts`), and every * reader of a stored type in this file goes through it. Skipping the trim here * while the form trimmed for its labels is what let one REST-stored `"ss "` be * Shadowsocks to the cipher field and an unencrypted hop to the warning printed * beside it, a false alarm about a proxy carrying a real cipher, and the * cheapest way to teach a user to ignore this warning. The two files cannot * import each other (both are loaded straight by `node --test`), so * `proxy-string.test.mjs` imports both and pins them in lockstep. */ function normalizeProxyType(proxyType: string): string { return proxyType.trim().toLowerCase(); } /** * The protocol label for a stored proxy: the short token when there is one, * otherwise the stored type verbatim. Empty only when the stored type is blank, * which is the one case the caller has to fill with a translated placeholder. * * A Map, not an object literal. `proxy_type` is free text, POST /v1/proxies * and `import_proxies_json` store whatever they are given, unvalidated, so an * object lookup would answer from Object.prototype: `__proto__` yielded an * object, which React refuses as a child, and rendering it threw and unmounted * the ENTIRE proxy table, hiding every proxy and every first-hop warning; * `constructor`, `toString` and `valueOf` yielded functions, which React drops, * leaving the cell blank. A Map only ever answers for keys actually put in it. */ export function proxyProtocolToken(proxyType: string): string { return PROTOCOL_TOKENS.get(normalizeProxyType(proxyType)) ?? proxyType.trim(); } /** What a line carrying no scheme is assumed to be. */ export const DEFAULT_PROXY_TYPE = "http"; /** * Proxy types whose hop from this machine to the proxy is encrypted. * * `https` is deliberately absent. It is a provider label on a plaintext CONNECT * endpoint, and Donut dials it byte-for-byte like `http` * (`proxy_server.rs::connect_to_target_via_upstream`). Listing it here would * make the UI state something untrue about the wire. */ const ENCRYPTED_FIRST_HOP = new Set([ "httpstls", "ss", // The REST API stores proxy_type verbatim, so a proxy created with // "shadowsocks" reaches the UI with that spelling and the backend tunnels it // (proxy_server.rs matches "ss" | "shadowsocks"). Without it here the guard // below returned false and the UI told the user an ENCRYPTED hop was in the // clear, the same class of wrong claim this feature exists to remove, only // inverted. "shadowsocks", ]); /** * Whether the leg from this machine to the proxy is encrypted. * * Says nothing about what the proxy operator can see: they terminate the * tunnel either way and still observe every hostname, port and timing, plus the * full contents of anything not carried over end-to-end TLS. */ const NULL_CIPHERS = new Set(["none", "plain"]); /** * Whether the hop from this machine to the proxy is encrypted. * * Shadowsocks takes the cipher as free text, and shadowsocks-crypto maps both * "none" and "plain" to CipherKind::NONE, a no-op. Keying off the type alone * therefore labelled such a proxy "First hop encrypted", coloured it safe, and * suppressed the credentials-in-clear warning, for a connection carrying * everything in the clear. A claim of safety that is wrong is worse than no * claim, so the cipher decides for Shadowsocks. */ export function isFirstHopEncrypted( proxyType: string, cipher?: string | null, shareLink?: string | null, ): boolean { const type = normalizeProxyType(proxyType); if (XRAY_PROXY_TYPES.has(type)) return isShareLinkEncrypted(shareLink ?? ""); if (!ENCRYPTED_FIRST_HOP.has(type)) return false; if (type === "ss" || type === "shadowsocks") { // Fail closed. An absent cipher is not evidence of encryption, and the // asymmetry matters: wrongly saying "in the clear" costs a needless // warning, wrongly saying "encrypted" costs the user the one thing this // label exists to tell them. const normalized = (cipher ?? "").trim().toLowerCase(); return normalized.length > 0 && !NULL_CIPHERS.has(normalized); } return true; } const NULL_VMESS_CIPHERS = new Set(["none", "zero"]); /** * Whether an Xray share link encrypts the hop to its server. * * The link decides, not the protocol: plain VLESS (`security=none`) carries * its payload in the clear, and so does VMess with the `none` or `zero` * cipher. Hysteria2 always runs over QUIC, which is TLS 1.3. A link this cannot * read is reported as not encrypted, for the same fail-closed reason as the * Shadowsocks cipher above. */ export function isShareLinkEncrypted(shareLink: string): boolean { const link = shareLink.trim(); const separator = link.indexOf("://"); if (separator === -1) return false; const scheme = link.slice(0, separator).toLowerCase(); const body = link.slice(separator + 3).split("#")[0] ?? ""; if (scheme === "hysteria2" || scheme === "hy2") return true; if (scheme === "vmess" && !body.includes("@")) { const json = decodeVmessJson(body); if (!json) return false; // Read as `uri.rs` reads it: values trimmed, and `encryption` taken as // the cipher when `scy` is absent. const field = (key: string) => String(json[key] ?? "") .trim() .toLowerCase(); const security = field("tls"); if (security === "tls" || security === "reality") return true; const cipher = field("scy") || field("encryption") || "auto"; return !NULL_VMESS_CIPHERS.has(cipher); } // Everything after the FIRST `?` is the query. A WebSocket path such as // `/ws?ed=2048` may carry a second one, and cutting there hid a later // `security=none` and called a plaintext link encrypted. const queryStart = body.indexOf("?"); const query = new URLSearchParams( queryStart === -1 ? "" : body.slice(queryStart + 1), ); const defaultSecurity = scheme === "trojan" ? "tls" : "none"; const security = (query.get("security") || defaultSecurity).toLowerCase(); if (security === "tls" || security === "reality") return true; if (scheme === "vmess") { const cipher = (query.get("encryption") || "auto").toLowerCase(); return !NULL_VMESS_CIPHERS.has(cipher); } return false; } /** v2rayN's `vmess://` body, or null when it is not one. */ function decodeVmessJson(body: string): Record | null { try { const base64 = decodeURIComponent(body) .replace(/\s/g, "") .replace(/-/g, "+") .replace(/_/g, "/"); const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); const bytes = Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)); const json: unknown = JSON.parse(new TextDecoder().decode(bytes)); return json !== null && typeof json === "object" && !Array.isArray(json) ? (json as Record) : null; } catch { return null; } } export const HOST_FIRST_FORMAT = "host:port:username:password"; export const CREDENTIALS_FIRST_FORMAT = "username:password:host:port"; /** * Separates `socks5://1.2.3.4:1080` into its scheme and body. An unknown or * absent scheme leaves the body untouched and falls back to HTTP, which is what * the backend does with a bare `host:port`. */ export function splitProxyScheme(line: string): { proxyType: string; rest: string; } { const separator = line.indexOf("://"); if (separator === -1) { return { proxyType: DEFAULT_PROXY_TYPE, rest: line }; } const proxyType = PROXY_SCHEMES.get(line.slice(0, separator).toLowerCase()); return proxyType ? { proxyType, rest: line.slice(separator + 3) } : { proxyType: DEFAULT_PROXY_TYPE, rest: line }; } /** * Builds a proxy from a four-part line once the user has said which of the two * orderings it uses. Returns null when the chosen ordering doesn't actually fit * the line, so a stale selection can't produce a proxy pointing at a password. */ export function resolveAmbiguousProxyLine( line: string, format: string, ): ParsedProxyLine | null { const trimmed = line.trim(); const { proxyType, rest } = splitProxyScheme(trimmed); const parts = rest.split(":"); if (parts.length !== 4) { return null; } const hostFirst = format === HOST_FIRST_FORMAT; if (!hostFirst && format !== CREDENTIALS_FIRST_FORMAT) { return null; } const host = hostFirst ? parts[0] : parts[2]; const port = Number.parseInt(hostFirst ? parts[1] : parts[3], 10); if (!host || !Number.isInteger(port) || port < 1 || port > 65535) { return null; } return { proxy_type: proxyType, host, port, username: hostFirst ? parts[2] : parts[0], password: hostFirst ? parts[3] : parts[1], original_line: trimmed, }; } /** * Picks the proxy to use out of a parse of pasted text. Only the first usable * line matters: the form holds one proxy, and a paste that happens to carry a * whole list should still fill it in rather than do nothing. * * Ambiguous lines resolve as `host:port:username:password`, the ordering the * import dialog offers first and the one vendors overwhelmingly ship. */ export function pickParsedProxy( results: ProxyParseResult[], ): ParsedProxyLine | null { for (const result of results) { if (result.status === "parsed") { return { proxy_type: result.proxy_type, host: result.host, port: result.port, username: result.username, password: result.password, vless_uri: result.vless_uri, original_line: result.original_line, }; } if (result.status === "ambiguous") { const resolved = resolveAmbiguousProxyLine( result.line, HOST_FIRST_FORMAT, ); if (resolved) { return resolved; } } } return null; }