From f9f5375e2147c2294002f5785528dc9980924c47 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:12:52 +0400 Subject: [PATCH] feat: per-profile DNS server --- e2e/coverage-map.mjs | 2 + e2e/tests/entities.test.mjs | 83 +- e2e/tests/network.test.mjs | 191 +++ e2e/tests/ui.test.mjs | 120 ++ sdk/node/src/client.mts | 10 + sdk/node/src/types.mts | 5 + sdk/node/test/requests.test.mts | 17 +- sdk/python/src/donutbrowser/client.py | 13 +- sdk/python/src/donutbrowser/models.py | 1 + sdk/python/tests/test_requests.py | 4 +- src-tauri/src/api_server.rs | 57 +- src-tauri/src/auto_updater.rs | 1 + src-tauri/src/bin/proxy_server.rs | 7 + src-tauri/src/browser.rs | 1 + src-tauri/src/browser_runner.rs | 5 + src-tauri/src/ephemeral_dirs.rs | 1 + src-tauri/src/lib.rs | 10 +- src-tauri/src/mcp_server.rs | 21 + src-tauri/src/profile/manager.rs | 46 + src-tauri/src/profile/types.rs | 4 + src-tauri/src/profile_dns.rs | 1562 ++++++++++++++++++++ src-tauri/src/profile_importer.rs | 2 + src-tauri/src/proxy_manager.rs | 23 +- src-tauri/src/proxy_runner.rs | 4 + src-tauri/src/proxy_server.rs | 187 ++- src-tauri/src/proxy_storage.rs | 10 + src-tauri/src/socks5_local.rs | 99 +- src-tauri/src/wayfern_identity_storage.rs | 1 + src-tauri/tests/donut_proxy_integration.rs | 252 +++- src/app/page.tsx | 2 + src/components/create-profile-dialog.tsx | 24 +- src/components/dns-server-field.tsx | 176 +++ src/components/profile-info-dialog.tsx | 69 + src/i18n/locales/de.json | 20 +- src/i18n/locales/en.json | 20 +- src/i18n/locales/es.json | 20 +- src/i18n/locales/fr.json | 20 +- src/i18n/locales/ja.json | 20 +- src/i18n/locales/ko.json | 20 +- src/i18n/locales/pt.json | 20 +- src/i18n/locales/ru.json | 20 +- src/i18n/locales/tr.json | 20 +- src/i18n/locales/vi.json | 20 +- src/i18n/locales/zh.json | 20 +- src/lib/backend-errors.ts | 3 + src/types.ts | 1 + 46 files changed, 3131 insertions(+), 103 deletions(-) create mode 100644 src-tauri/src/profile_dns.rs create mode 100644 src/components/dns-server-field.tsx diff --git a/e2e/coverage-map.mjs b/e2e/coverage-map.mjs index e206d1fb..e0ea9979 100644 --- a/e2e/coverage-map.mjs +++ b/e2e/coverage-map.mjs @@ -55,6 +55,7 @@ export const commandCoverage = { "update_profile_window_color", "update_profile_proxy_bypass_rules", "update_profile_dns_blocklist", + "update_profile_dns_server", "rename_profile", "detect_existing_profiles", "import_browser_profiles", @@ -175,6 +176,7 @@ export const commandCoverage = { "dns_blocklist::set_custom_dns_config", "dns_blocklist::import_custom_dns_rules", "dns_blocklist::export_custom_dns_rules", + "profile_dns::describe_dns_server", ], }, browser: { diff --git a/e2e/tests/entities.test.mjs b/e2e/tests/entities.test.mjs index 479c2d53..3a0d8c51 100644 --- a/e2e/tests/entities.test.mjs +++ b/e2e/tests/entities.test.mjs @@ -314,6 +314,48 @@ test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", a profileId: profile.id, dnsBlocklist: "light", }); + // A plain address of a known resolver is described as its encrypted + // endpoint, blank means the proxy resolves, and junk is refused. + assert.deepEqual( + await app.invoke("describe_dns_server", { value: "1.1.1.1" }), + { + transport: "https", + server: "cloudflare-dns.com", + encrypted: true, + upgraded: true, + }, + ); + assert.deepEqual( + await app.invoke("describe_dns_server", { value: "tcp://10.0.0.53" }), + { + transport: "tcp", + server: "10.0.0.53", + encrypted: false, + upgraded: false, + }, + ); + assert.equal( + await app.invoke("describe_dns_server", { value: " " }), + null, + ); + assert.match( + await app.invokeError("describe_dns_server", { + value: "udp://1.1.1.1", + }), + /DNS_SERVER_INVALID/, + ); + assert.match( + await app.invokeError("update_profile_dns_server", { + profileId: profile.id, + dnsServer: "udp://9.9.9.9", + }), + /DNS_SERVER_INVALID/, + ); + const withDns = await app.invoke("update_profile_dns_server", { + profileId: profile.id, + dnsServer: " https://dns.quad9.net/dns-query ", + }); + assert.equal(withDns.dns_server, "https://dns.quad9.net/dns-query"); await app.invoke("update_profile_clear_on_close", { profileId: profile.id, clearOnClose: true, @@ -330,6 +372,7 @@ test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", a "*.internal.example", ]); assert.equal(changed.dns_blocklist, "light"); + assert.equal(changed.dns_server, "https://dns.quad9.net/dns-query"); assert.equal(changed.clear_on_close, true); assert.deepEqual((await app.invoke("get_all_tags")).sort(), [ "alpha", @@ -579,12 +622,50 @@ test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", a }); assert.notEqual(clone.id, profile.id); assert.equal(clone.name, "Cloned Profile"); + assert.equal(clone.dns_server, "https://dns.quad9.net/dns-query"); const counts = await app.invoke("get_groups_with_profile_counts"); assert.equal(counts.find((item) => item.id === group.id).count, 2); assert.equal((await app.invoke("get_profile_groups")).length, 1); + const createDnsArgs = { + name: "DNS Profile", + browserStr: "wayfern", + version: "150.0.7871.100", + releaseType: "stable", + proxyId: null, + vpnId: null, + wayfernConfig: { fingerprint: "{}" }, + groupId: null, + ephemeral: false, + dnsBlocklist: null, + launchHook: null, + }; + assert.match( + await app.invokeError("create_browser_profile_new", { + ...createDnsArgs, + dnsServer: "quic://dns.example", + }), + /DNS_SERVER_INVALID/, + ); + assert.ok( + !(await app.invoke("list_browser_profiles")).some( + (item) => item.name === "DNS Profile", + ), + "a refused DNS server must not leave a profile behind", + ); + const dnsProfile = await app.invoke("create_browser_profile_new", { + ...createDnsArgs, + dnsServer: "9.9.9.9", + }); + assert.equal(dnsProfile.dns_server, "9.9.9.9"); + const cleared = await app.invoke("update_profile_dns_server", { + profileId: dnsProfile.id, + dnsServer: "", + }); + assert.equal(cleared.dns_server, null); + await app.invoke("delete_selected_profiles", { - profileIds: [profile.id, clone.id, imported.profile_id], + profileIds: [profile.id, clone.id, imported.profile_id, dnsProfile.id], }); assert.deepEqual(await app.invoke("list_browser_profiles"), []); await app.invoke("delete_profile_group", { groupId: group.id }); diff --git a/e2e/tests/network.test.mjs b/e2e/tests/network.test.mjs index bbfa5883..bbfcf648 100644 --- a/e2e/tests/network.test.mjs +++ b/e2e/tests/network.test.mjs @@ -730,6 +730,197 @@ test("VLESS Reality persists, imports, routes through Xray-core, records traffic } }); +// The page only the browser asks for: Donut's own exit-IP probes use other +// hosts, so a line naming this one could only come from the browser. +const DNS_TEST_PAGE = "https://icanhazip.com/"; + +test("a profile DNS server resolves through the proxy, which sees addresses only", async () => { + const vlessUri = process.env.DONUT_E2E_VLESS_URI; + const accessLog = process.env.DONUT_E2E_XRAY_ACCESS_LOG; + assert.ok(vlessUri, "The network harness must provide a VLESS Reality URI"); + assert.ok(accessLog, "The network harness must provide an Xray access log"); + const residentialSettings = proxySettings( + process.env.RESIDENTIAL_PROXY_URL_ONE_HTTP, + "HTTP", + ); + + const app = appFromEnvironment("network-dns-server", { + seedVersionCache: false, + wayfernTermsAccepted: false, + }); + let apiPort; + let activeCdp; + let profile; + try { + const prepared = await prepareWayfern( + app, + process.env.DONUT_E2E_PROJECT_ROOT, + ); + if (!app.session) await app.start(); + if (!(await app.invoke("check_wayfern_terms_accepted"))) { + await app.invoke("accept_wayfern_terms"); + await app.restart(); + } + + const vless = await app.invoke("create_stored_proxy", { + name: "DNS VLESS", + proxySettings: { + proxy_type: "vless", + host: "ignored.invalid", + port: 1, + vless_uri: vlessUri, + }, + }); + profile = await app.invoke("create_browser_profile_new", { + name: "DNS Server Profile", + browserStr: "wayfern", + version: prepared.version, + releaseType: "stable", + proxyId: vless.id, + vpnId: null, + wayfernConfig: { + fingerprint: null, + randomize_fingerprint_on_launch: false, + geoip: false, + }, + groupId: null, + ephemeral: false, + dnsBlocklist: null, + dnsServer: "1.1.1.1", + launchHook: null, + }); + assert.equal(profile.dns_server, "1.1.1.1"); + + const settings = await app.invoke("get_app_settings"); + const saved = await app.invoke("save_app_settings", { + settings: { + ...settings, + api_enabled: true, + api_port: 0, + api_token: null, + }, + }); + apiPort = await app.invoke("start_api_server", { port: 0 }); + const base = `http://127.0.0.1:${apiPort}`; + const exitIpOf = (cdp, description) => + cdp.waitFor( + `(() => { + const value = document.body?.innerText?.trim() ?? ""; + return /^[0-9a-f:.]+$/i.test(value) ? value : false; + })()`, + { timeoutMs: 45_000, description }, + ); + + const logBefore = (await readFile(accessLog, "utf8").catch(() => "")) + .length; + let launched = await runProfile( + app, + base, + saved.api_token, + profile.id, + DNS_TEST_PAGE, + ); + activeCdp = launched.cdp; + const vlessExit = await exitIpOf(activeCdp, "exit IP through VLESS"); + assert.ok(isIP(vlessExit), `Unexpected exit IP response: ${vlessExit}`); + + // `1.1.1.1` was upgraded to Cloudflare's DNS over HTTPS, and the queries + // crossed the proxy: the server logged a tunnel to 1.1.1.1:443. The page's + // own tunnel went to the address the resolver returned, so the proxy + // never saw the name. + const lines = await app.waitFor( + async () => { + const fresh = (await readFile(accessLog, "utf8")) + .slice(logBefore) + .split("\n") + .filter((line) => line.includes("accepted")); + return fresh.some((line) => line.includes("tcp:1.1.1.1:443")) + ? fresh + : false; + }, + { timeoutMs: 15_000, description: "DoH query in the Xray access log" }, + ); + assert.equal( + lines.some((line) => line.includes("icanhazip.com")), + false, + `the proxy was given the name: ${lines.join("\n")}`, + ); + assert.ok( + lines.some((line) => /tcp:\[?[0-9a-f:.]+\]?:443/i.test(line)), + `no tunnel to a resolved address: ${lines.join("\n")}`, + ); + await stopProfile(app, base, saved.api_token, profile.id, activeCdp); + activeCdp = null; + + // A commercial HTTP proxy with an explicit encrypted resolver that needs + // HTTP/2. + const residential = await app.invoke("create_stored_proxy", { + name: "DNS Residential", + proxySettings: residentialSettings, + }); + await app.invoke("update_profile_proxy", { + profileId: profile.id, + proxyId: residential.id, + }); + const updated = await app.invoke("update_profile_dns_server", { + profileId: profile.id, + dnsServer: "https://dns.quad9.net/dns-query", + }); + assert.equal(updated.dns_server, "https://dns.quad9.net/dns-query"); + launched = await runProfile( + app, + base, + saved.api_token, + profile.id, + DNS_TEST_PAGE, + ); + activeCdp = launched.cdp; + const residentialExit = await exitIpOf( + activeCdp, + "exit IP through the residential proxy", + ); + assert.ok(isIP(residentialExit)); + await stopProfile(app, base, saved.api_token, profile.id, activeCdp); + activeCdp = null; + + // The worker log names the transport, never the server or a destination. + const workerLogs = ( + await Promise.all( + ( + await readdir(path.join(app.root, "tmp")) + ) + .filter( + (file) => file.startsWith("donut-proxy-") && file.endsWith(".log"), + ) + .map((file) => readFile(path.join(app.root, "tmp", file), "utf8")), + ) + ).join("\n"); + assert.match(workerLogs, /Worker started kind=proxy .*dns=https /); + assert.equal(workerLogs.includes("dns.quad9.net"), false); + assert.equal(workerLogs.includes("icanhazip.com"), false); + await assertProxyWorkerLogsRedacted(app, [residentialSettings]); + } catch (error) { + await app.capture("failure"); + throw error; + } finally { + activeCdp?.close(); + if (app.session) { + if (apiPort) await app.invoke("stop_api_server").catch(() => {}); + if (profile) { + const latest = ( + await app.invoke("list_browser_profiles").catch(() => []) + ).find((candidate) => candidate.id === profile.id); + if (latest?.process_id) { + await app + .invoke("kill_browser_profile", { profile: latest }) + .catch(() => {}); + } + } + } + await app.close(); + } +}); + test("visible UI creates and assigns profiles, groups, proxies, VPNs, extensions, and extension groups", async () => { const httpSettings = proxySettings( process.env.RESIDENTIAL_PROXY_URL_ONE_HTTP, diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs index 76530928..395d3d20 100644 --- a/e2e/tests/ui.test.mjs +++ b/e2e/tests/ui.test.mjs @@ -328,6 +328,49 @@ test("profile creation stays compact and saves basic, protected, and ephemeral p "#profile-dns-blocklist", en.dnsBlocklist.normal, ); + // The DNS field reads the value back from the backend: a value it cannot + // use blocks creation, a plain address says it is not encrypted, and a + // preset is encrypted. + const dnsStatus = (state) => + app.waitFor( + () => + app.execute( + `return document.querySelector('[data-slot="dns-server-status"]')?.dataset.state === arguments[0];`, + [state], + ), + { description: `DNS server status ${state}` }, + ); + const createEnabled = () => + app.execute( + `return [...document.querySelectorAll('[role="dialog"] button')].some(button => button.textContent.trim() === arguments[0] && !button.disabled);`, + [en.common.buttons.create], + ); + await dnsStatus("default"); + await app.waitForText(en.dnsServer.summary.default); + await app.fillSelector("#profile-dns-server", "udp://1.1.1.1"); + await dnsStatus("invalid"); + await app.waitForText(en.backendErrors.dnsServerInvalid); + await app.waitFor(async () => !(await createEnabled()), { + description: "an invalid DNS server blocks creation", + }); + await app.fillSelector("#profile-dns-server", "10.0.0.53"); + await dnsStatus("valid"); + await app.waitForText( + en.dnsServer.summary.tcp.replace("{{server}}", "10.0.0.53"), + ); + await app.clickText("Quad9", { roles: ["button"] }); + await app.waitForText( + en.dnsServer.summary.https.replace("{{server}}", "dns.quad9.net"), + ); + assert.equal( + await app.execute( + `return document.querySelector('#profile-dns-server').value;`, + ), + "https://dns.quad9.net/dns-query", + ); + await app.waitFor(createEnabled, { + description: "a valid DNS server allows creation", + }); await chooseSelectOption( app, "#profile-extension-group", @@ -409,6 +452,10 @@ test("profile creation stays compact and saves basic, protected, and ephemeral p assert.equal(protectedRequest.proxyId, proxy.id); assert.equal(protectedRequest.vpnId, undefined); assert.equal(protectedProfile.dns_blocklist, "normal"); + assert.equal( + protectedProfile.dns_server, + "https://dns.quad9.net/dns-query", + ); assert.equal( protectedProfile.launch_hook, "https://example.com/hooks/created", @@ -444,6 +491,12 @@ test("profile creation stays compact and saves basic, protected, and ephemeral p ), en.dnsBlocklist.none, ); + assert.equal( + await app.execute( + `return document.querySelector('#profile-dns-server').value;`, + ), + "", + ); assert.equal( await app.execute( `return document.querySelector('#profile-extension-group').textContent.trim();`, @@ -4075,6 +4128,73 @@ test("a freshly paid account is welcomed once and walked to its plan tips", asyn }); }); +test("the profile network section saves, refuses and clears a DNS server", async () => { + await withApp("ui-dns-server", async (app) => { + const profile = await createUiProfile(app, "Profile DNS Server"); + await app.waitForText(profile.name); + const stored = async () => + (await app.invoke("list_browser_profiles")).find( + (item) => item.id === profile.id, + ).dns_server ?? null; + const field = '[data-slot="dns-server-field"] input'; + const saveEnabled = () => + app.execute( + `return [...document.querySelectorAll('[data-slot="dns-server-field"] button[type="submit"]')].some((button) => !button.disabled);`, + ); + + await app.clickSelector( + `[data-slot="profile-inspect-trigger"][data-profile-id="${profile.id}"]`, + ); + await app.clickSelector( + '[data-slot="profile-info-section"][data-section="network"]', + ); + await app.waitForText(en.profileInfo.sectionDesc.network); + await app.waitForText(en.dnsServer.summary.default); + + await app.fillSelector(field, "1.1.1.1"); + await app.waitForText( + `${en.dnsServer.summary.https.replace("{{server}}", "cloudflare-dns.com")} ${en.dnsServer.summary.upgraded}`, + ); + await app.capture("profile-dns-server-upgraded"); + await app.clickText(en.common.buttons.save, { exact: true }); + await app.waitFor(async () => (await stored()) === "1.1.1.1", { + description: "the saved DNS server", + }); + await app.waitFor( + () => + app.execute( + `return !document.querySelector('[data-slot="dns-server-field"] button[type="submit"]');`, + ), + { description: "nothing left to save" }, + ); + + await app.fillSelector(field, "not a server"); + await app.waitForText(en.backendErrors.dnsServerInvalid); + assert.equal(await saveEnabled(), false); + assert.equal(await stored(), "1.1.1.1"); + + await app.fillSelector(field, "tcp://1.1.1.1"); + await app.waitForText( + en.dnsServer.summary.tcp.replace("{{server}}", "1.1.1.1"), + ); + await app.capture("profile-dns-server-plain"); + // Typed away rather than cleared, so the field sees real input events. + await app.fillSelector(field, "x"); + await app.session.sendKeys( + await app.execute("return document.querySelector(arguments[0]);", [ + field, + ]), + "\uE003", + ); + await app.waitForText(en.dnsServer.summary.default); + await app.clickText(en.common.buttons.save, { exact: true }); + await app.waitFor(async () => (await stored()) === null, { + description: "the cleared DNS server", + }); + await app.clickSelector('[data-slot="profile-info-close"]'); + }); +}); + test("Pro users can edit and save launch arguments in each profile", async () => { await withApp( "ui-launch-args", diff --git a/sdk/node/src/client.mts b/sdk/node/src/client.mts index 3c0ecc94..de870dc6 100644 --- a/sdk/node/src/client.mts +++ b/sdk/node/src/client.mts @@ -278,6 +278,14 @@ export class DonutClient { tags?: string[]; ephemeral?: boolean; temporary?: boolean; + /** + * DNS server to resolve names with, through the profile's proxy or VPN: + * `https://host/path`, `tls://host[:port]`, `tcp://host[:port]` or a bare + * `host[:port]`. A bare address of a well-known resolver such as + * `"1.1.1.1"` uses its DNS over HTTPS endpoint. Omit it to let the proxy + * resolve names. + */ + dns_server?: string; }): Promise { return this.#request("POST", "/v1/profiles", body({ ...request })); } @@ -305,6 +313,8 @@ export class DonutClient { /** `"Disabled"`, `"Regular"` or `"Encrypted"`. */ sync_mode?: string; clear_on_close?: boolean; + /** Same formats as on create; `""` lets the proxy resolve names again. */ + dns_server?: string; }, ): Promise { return this.#request("PUT", `/v1/profiles/${segment(profileId)}`, body({ ...request })); diff --git a/sdk/node/src/types.mts b/sdk/node/src/types.mts index 3e605438..d60cf263 100644 --- a/sdk/node/src/types.mts +++ b/sdk/node/src/types.mts @@ -38,6 +38,11 @@ export interface ApiProfile { is_running: boolean; proxy_bypass_rules: string[]; vpn_id?: string | null; + /** + * The DNS server the profile resolves names with, through its own proxy or + * VPN. `null` when the proxy resolves them. + */ + dns_server?: string | null; extension_group_id?: string | null; ephemeral: boolean; temporary: boolean; diff --git a/sdk/node/test/requests.test.mts b/sdk/node/test/requests.test.mts index fc409042..5eb68842 100644 --- a/sdk/node/test/requests.test.mts +++ b/sdk/node/test/requests.test.mts @@ -62,10 +62,23 @@ const CASES: Case[] = [ }, { method: "updateProfile", - args: ["p1", { name: "Renamed", proxy_id: "", clear_on_close: false }], + args: [ + "p1", + { + name: "Renamed", + proxy_id: "", + clear_on_close: false, + dns_server: "9.9.9.9", + }, + ], verb: "PUT", path: "/v1/profiles/p1", - body: { name: "Renamed", proxy_id: "", clear_on_close: false }, + body: { + name: "Renamed", + proxy_id: "", + clear_on_close: false, + dns_server: "9.9.9.9", + }, operation: "PUT /v1/profiles/{id}", }, { diff --git a/sdk/python/src/donutbrowser/client.py b/sdk/python/src/donutbrowser/client.py index 1cfa9ec9..9133c80b 100644 --- a/sdk/python/src/donutbrowser/client.py +++ b/sdk/python/src/donutbrowser/client.py @@ -288,12 +288,17 @@ class DonutClient: tags: Optional[Sequence[str]] = None, ephemeral: Optional[bool] = None, temporary: Optional[bool] = None, + dns_server: Optional[str] = None, ) -> ApiProfileResponse: """POST /v1/profiles ``browser`` must be ``"wayfern"``; anything else is refused with 400. ``version`` must already be downloaded, so omit it (or pass - ``"latest"``) to take the newest local build. + ``"latest"``) to take the newest local build. ``dns_server`` resolves + names through the profile's proxy or VPN: ``https://host/path``, + ``tls://host[:port]``, ``tcp://host[:port]`` or a bare ``host[:port]``; + a bare address of a well-known resolver such as ``"1.1.1.1"`` uses its + DNS over HTTPS endpoint. """ return self._request( "POST", @@ -311,6 +316,7 @@ class DonutClient: tags=list(tags) if tags is not None else None, ephemeral=ephemeral, temporary=temporary, + dns_server=dns_server, ), ) @@ -330,12 +336,14 @@ class DonutClient: proxy_bypass_rules: Optional[Sequence[str]] = None, sync_mode: Optional[str] = None, clear_on_close: Optional[bool] = None, + dns_server: Optional[str] = None, ) -> ApiProfileResponse: """PUT /v1/profiles/{id} A profile's browser engine is fixed at creation, so there is no ``browser`` argument. Pass ``proxy_id=""`` or ``vpn_id=""`` to detach - one; leaving either unset changes nothing. + one; leaving either unset changes nothing. ``dns_server=""`` lets the + proxy resolve names again. """ return self._request( "PUT", @@ -355,6 +363,7 @@ class DonutClient: ), sync_mode=sync_mode, clear_on_close=clear_on_close, + dns_server=dns_server, ), ) diff --git a/sdk/python/src/donutbrowser/models.py b/sdk/python/src/donutbrowser/models.py index 1856c1e3..e8be9ca9 100644 --- a/sdk/python/src/donutbrowser/models.py +++ b/sdk/python/src/donutbrowser/models.py @@ -117,6 +117,7 @@ class ApiProfile(_ApiProfileRequired, total=False): last_launch: int group_id: str vpn_id: str + dns_server: str extension_group_id: str host_os: str fingerprint_os: str diff --git a/sdk/python/tests/test_requests.py b/sdk/python/tests/test_requests.py index ed7f78ce..eb5e4c18 100644 --- a/sdk/python/tests/test_requests.py +++ b/sdk/python/tests/test_requests.py @@ -58,10 +58,10 @@ CASES: List[Case] = [ ( "update_profile", ("p1",), - {"name": "Renamed", "proxy_id": "", "clear_on_close": False}, + {"name": "Renamed", "proxy_id": "", "clear_on_close": False, "dns_server": "9.9.9.9"}, "PUT", "/v1/profiles/p1", - {"name": "Renamed", "proxy_id": "", "clear_on_close": False}, + {"name": "Renamed", "proxy_id": "", "clear_on_close": False, "dns_server": "9.9.9.9"}, {}, "/v1/profiles/{id}", ), diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 947ca869..783fb7bc 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -46,6 +46,9 @@ pub struct ApiProfile { pub is_running: bool, pub proxy_bypass_rules: Vec, pub vpn_id: Option, + /// The DNS server the profile resolves names with, through its own proxy + /// or VPN. `null` when the proxy resolves them. + pub dns_server: Option, /// Extension group loaded into the browser at launch. Settable via /// `PUT /v1/profiles/{id}`; exposed here so a caller can read back what it /// set instead of having to go through the desktop app. @@ -98,6 +101,7 @@ impl From<&crate::profile::types::BrowserProfile> for ApiProfile { is_running: profile.process_id.is_some(), proxy_bypass_rules: profile.proxy_bypass_rules.clone(), vpn_id: profile.vpn_id.clone(), + dns_server: profile.dns_server.clone(), extension_group_id: profile.extension_group_id.clone(), ephemeral: profile.ephemeral, temporary: profile.temporary, @@ -156,6 +160,14 @@ pub struct CreateProfileRequest { /// its browser stops, and is swept at startup if it outlived a crash. /// Defaults to false. pub temporary: Option, + /// DNS server to resolve names with, through the profile's proxy or VPN: + /// `https://host/path` (DNS over HTTPS), `tls://host[:port]` (DNS over TLS), + /// `tcp://host[:port]` (plain DNS), or a bare `host[:port]`. A bare address + /// of a well-known public resolver such as `1.1.1.1` uses its DNS over + /// HTTPS endpoint. Omit it, send `null`, or send an empty string to let the + /// proxy resolve names. Anything else is rejected with 400. + #[serde(default)] + pub dns_server: Option, } #[derive(Debug, Serialize, Deserialize, ToSchema)] @@ -182,6 +194,10 @@ pub struct UpdateProfileRequest { /// Wipe browsing data (keeping extensions and bookmarks) when the browser /// exits. Rejected (400) for ephemeral or password-protected profiles. pub clear_on_close: Option, + /// Omitted or `null` leaves the DNS server unchanged; an empty string + /// clears it, so the proxy resolves names again. Same formats as on create. + /// Takes effect at the next launch. + pub dns_server: Option, } #[derive(Clone)] @@ -1465,7 +1481,7 @@ async fn get_profile( request_body = CreateProfileRequest, responses( (status = 200, description = "Profile created successfully", body = ApiProfileResponse), - (status = 400, description = "Invalid browser, invalid wayfern_config, or no downloaded version available"), + (status = 400, description = "Invalid browser, invalid wayfern_config, invalid dns_server, or no downloaded version available"), (status = 401, description = "Unauthorized"), (status = 402, description = "Selected proxy requires payment, or a cross-OS fingerprint or custom launch arguments require Pro"), (status = 429, description = "Hourly profile generation limit reached; the body carries retryAfterSeconds"), @@ -1583,6 +1599,7 @@ async fn create_profile( request.group_id.clone(), request.ephemeral.unwrap_or(false) || request.temporary.unwrap_or(false), None, + request.dns_server.clone(), request.launch_hook.clone(), ) .await @@ -1673,6 +1690,11 @@ async fn update_profile( "Cannot set both proxy_id and vpn_id".to_string(), )); } + // Checked before any field changes, so a bad value leaves the profile as it was. + if let Some(dns_server) = &request.dns_server { + crate::profile_dns::normalize_dns_server(Some(dns_server.clone())) + .map_err(manager_error_response)?; + } // Update profile fields if let Some(new_name) = request.name { @@ -1794,6 +1816,12 @@ async fn update_profile( } } + if let Some(dns_server) = request.dns_server { + if let Err(e) = profile_manager.update_profile_dns_server(&id, Some(dns_server)) { + return Err(manager_error_response(e)); + } + } + // Return updated profile get_profile(Path(id), State(state)) .await @@ -5620,6 +5648,33 @@ mod tests { } } + #[test] + fn openapi_documents_the_profile_dns_server() { + let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes"); + for schema in ["CreateProfileRequest", "UpdateProfileRequest", "ApiProfile"] { + let property = &spec["components"]["schemas"][schema]["properties"]["dns_server"]; + assert_eq!( + property["type"], + serde_json::json!(["string", "null"]), + "{schema}.dns_server must be a nullable string, got: {property}" + ); + assert!( + !schema_required(&spec, schema) + .iter() + .any(|f| f == "dns_server"), + "{schema}.dns_server must be optional" + ); + } + let create = + spec["components"]["schemas"]["CreateProfileRequest"]["properties"]["dns_server"].to_string(); + assert!( + create.contains("https://") && create.contains("tls://") && create.contains("empty string") + ); + let update = + spec["components"]["schemas"]["UpdateProfileRequest"]["properties"]["dns_server"].to_string(); + assert!(update.contains("empty string")); + } + #[test] fn openapi_optional_fields_are_not_required() { let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes"); diff --git a/src-tauri/src/auto_updater.rs b/src-tauri/src/auto_updater.rs index 44451d8c..1e1e2d90 100644 --- a/src-tauri/src/auto_updater.rs +++ b/src-tauri/src/auto_updater.rs @@ -692,6 +692,7 @@ mod tests { created_by_id: None, created_by_email: None, dns_blocklist: None, + dns_server: None, password_protected: false, clear_on_close: false, created_at: None, diff --git a/src-tauri/src/bin/proxy_server.rs b/src-tauri/src/bin/proxy_server.rs index 1a84ba54..9159f2f0 100644 --- a/src-tauri/src/bin/proxy_server.rs +++ b/src-tauri/src/bin/proxy_server.rs @@ -160,6 +160,11 @@ async fn main() { .num_args(0) .help("Treat --blocklist-file as an allowlist (block all domains not listed)"), ) + .arg( + Arg::new("dns-server") + .long("dns-server") + .help("Resolve destinations with this DNS server, through the upstream: https://host/path, tls://host[:port], tcp://host[:port] or host[:port]"), + ) .arg( Arg::new("no-domain-history") .long("no-domain-history") @@ -303,6 +308,7 @@ async fn main() { .unwrap_or_default(); let blocklist_file = start_matches.get_one::("blocklist-file").cloned(); let dns_allowlist_mode = start_matches.get_flag("dns-allowlist-mode"); + let dns_server = start_matches.get_one::("dns-server").cloned(); let local_protocol = start_matches.get_one::("local-protocol").cloned(); let record_domains = !start_matches.get_flag("no-domain-history"); @@ -313,6 +319,7 @@ async fn main() { bypass_rules, blocklist_file, dns_allowlist_mode, + dns_server, local_protocol, record_domains, ) diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs index 161d0c15..5ac93c50 100644 --- a/src-tauri/src/browser.rs +++ b/src-tauri/src/browser.rs @@ -735,6 +735,7 @@ mod tests { created_by_id: None, created_by_email: None, dns_blocklist: None, + dns_server: None, password_protected: false, clear_on_close: false, created_at: None, diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index dea765db..d668a190 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -361,6 +361,10 @@ impl BrowserRunner { }); emit_launch_stage(profile, "network", None); + // Checked before any worker starts. A setting the worker cannot use would + // otherwise fail the launch late, as an untranslated worker error. + let dns_server = crate::profile_dns::normalize_dns_server(profile.dns_server.clone()) + .map_err(|e| -> Box { e.into() })?; // Always start a local proxy for Wayfern (for traffic monitoring and geoip support) let mut upstream_proxy = self .resolve_launch_proxy(profile) @@ -526,6 +530,7 @@ impl BrowserRunner { profile.proxy_bypass_rules.clone(), blocklist_file, dns_allowlist_mode, + dns_server.as_deref(), Self::records_site_history(profile), // Wayfern (Chromium) uses a local SOCKS5 proxy so QUIC and WebRTC // UDP can be routed through it (via SOCKS5 UDP ASSOCIATE) without diff --git a/src-tauri/src/ephemeral_dirs.rs b/src-tauri/src/ephemeral_dirs.rs index 9c2bf8e1..4a898abe 100644 --- a/src-tauri/src/ephemeral_dirs.rs +++ b/src-tauri/src/ephemeral_dirs.rs @@ -508,6 +508,7 @@ mod tests { created_by_id: None, created_by_email: None, dns_blocklist: None, + dns_server: None, password_protected: false, clear_on_close: false, created_at: None, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index de568e4d..5565f6d7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -108,6 +108,7 @@ pub mod log_streak; mod platform_browser; mod process_report; mod profile; +mod profile_dns; mod profile_generation_limiter; mod profile_import; mod profile_importer; @@ -165,9 +166,9 @@ use browser_runner::{ use profile::manager::{ check_browser_status, clone_profile, create_browser_profile_new, delete_profile, list_browser_profiles, rename_profile, update_profile_clear_on_close, - update_profile_dns_blocklist, update_profile_launch_hook, update_profile_note, - update_profile_proxy, update_profile_proxy_bypass_rules, update_profile_tags, update_profile_vpn, - update_profile_window_color, update_wayfern_config, + update_profile_dns_blocklist, update_profile_dns_server, update_profile_launch_hook, + update_profile_note, update_profile_proxy, update_profile_proxy_bypass_rules, + update_profile_tags, update_profile_vpn, update_profile_window_color, update_wayfern_config, }; use profile::trash::{ @@ -1866,6 +1867,7 @@ async fn generate_sample_fingerprint( created_by_id: None, created_by_email: None, dns_blocklist: None, + dns_server: None, password_protected: false, clear_on_close: false, created_at: None, @@ -3324,6 +3326,8 @@ pub fn run_with_builder( update_profile_window_color, update_profile_proxy_bypass_rules, update_profile_dns_blocklist, + update_profile_dns_server, + profile_dns::describe_dns_server, check_browser_status, kill_browser_profile, rename_profile, diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index e549d34c..7672339f 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -2548,6 +2548,10 @@ impl McpServer { "temporary": { "type": "boolean", "description": "Create a profile for one run: implies ephemeral, is deleted when its browser stops, and is swept at startup if it outlived a crash (default: false)" + }, + "dns_server": { + "type": "string", + "description": "Optional. DNS server the profile resolves names with, through its own proxy or VPN: https://host/path (DNS over HTTPS), tls://host[:port] (DNS over TLS), tcp://host[:port] (plain DNS) or a bare host[:port]. A bare address of a well-known public resolver such as 1.1.1.1 uses its DNS over HTTPS endpoint. If the server does not answer, connections fail instead of falling back to the proxy's resolver. Omit it to let the proxy resolve names." } }, "required": ["name", "browser"] @@ -2662,6 +2666,10 @@ impl McpServer { "clear_on_close": { "type": "boolean", "description": "Wipe browsing data (keeping extensions and bookmarks) when the browser exits. Not available for ephemeral or password-protected profiles." + }, + "dns_server": { + "type": "string", + "description": "DNS server the profile resolves names with, through its own proxy or VPN: https://host/path (DNS over HTTPS), tls://host[:port] (DNS over TLS), tcp://host[:port] (plain DNS) or a bare host[:port]. A bare address of a well-known public resolver such as 1.1.1.1 uses its DNS over HTTPS endpoint. If the server does not answer, connections fail instead of falling back to the proxy's resolver. Empty string to let the proxy resolve names again. Takes effect at the next launch." } }, "required": ["profile_id"] @@ -5296,6 +5304,10 @@ impl McpServer { group_id, ephemeral, None, + arguments + .get("dns_server") + .and_then(|v| v.as_str()) + .map(str::to_string), launch_hook, ) .await @@ -5490,6 +5502,15 @@ impl McpServer { })?; } + if let Some(dns_server) = arguments.get("dns_server").and_then(|v| v.as_str()) { + pm.update_profile_dns_server(profile_id, Some(dns_server.to_string())) + .map_err(|e| McpError { + code: -32602, + message: format!("Failed to update DNS server: {e}"), + data: None, + })?; + } + Ok(serde_json::json!({ "content": [{ "type": "text", diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index 544d0ea7..affaf504 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -196,6 +196,7 @@ impl ProfileManager { group_id: Option, ephemeral: bool, dns_blocklist: Option, + dns_server: Option, launch_hook: Option, ) -> Result> { if name.trim().is_empty() { @@ -205,6 +206,7 @@ impl ProfileManager { .into(), ); } + let dns_server = crate::profile_dns::normalize_dns_server(dns_server)?; // Normalize before the mutual-exclusion check, not per caller: REST, MCP, // the importer and the Tauri commands all funnel through here, and a client @@ -376,6 +378,7 @@ impl ProfileManager { created_by_id: None, created_by_email: None, dns_blocklist: None, + dns_server: None, password_protected: false, clear_on_close: false, created_at: None, @@ -495,6 +498,7 @@ impl ProfileManager { created_by_id: None, created_by_email: None, dns_blocklist, + dns_server, password_protected: false, clear_on_close: false, created_at: Some( @@ -1428,6 +1432,33 @@ impl ProfileManager { Ok(profile) } + /// Takes effect at the next launch: a running browser keeps the worker it + /// started with. + pub fn update_profile_dns_server( + &self, + profile_id: &str, + dns_server: Option, + ) -> Result> { + let dns_server = crate::profile_dns::normalize_dns_server(dns_server)?; + let mut profile = self.find_profile(profile_id)?; + if profile.dns_server == dns_server { + return Ok(profile); + } + + profile.dns_server = dns_server; + profile.updated_at = Some(crate::proxy_manager::now_secs()); + + self.save_profile(&profile)?; + + crate::sync::queue_profile_sync_if_eligible(&profile); + + if let Err(e) = events::emit_empty("profiles-changed") { + log::warn!("Event emit failed event=profiles-changed err=\"{e}\""); + } + + Ok(profile) + } + /// Trash several profiles at once. Every profile is checked before any of /// them moves, so one running browser blocks the whole batch instead of /// leaving it half done. @@ -1540,6 +1571,7 @@ impl ProfileManager { created_by_id: None, created_by_email: None, dns_blocklist: source.dns_blocklist, + dns_server: source.dns_server, password_protected: false, clear_on_close: false, created_at: Some( @@ -2444,6 +2476,7 @@ pub async fn create_browser_profile_with_group( group_id: Option, ephemeral: bool, dns_blocklist: Option, + dns_server: Option, launch_hook: Option, ) -> Result { let profile_manager = ProfileManager::instance(); @@ -2460,6 +2493,7 @@ pub async fn create_browser_profile_with_group( group_id, ephemeral, dns_blocklist, + dns_server, launch_hook, ) .await @@ -2603,6 +2637,16 @@ pub fn update_profile_dns_blocklist( .map_err(|e| format!("Failed to update DNS blocklist: {e}")) } +#[tauri::command] +pub fn update_profile_dns_server( + profile_id: String, + dns_server: Option, +) -> Result { + ProfileManager::instance() + .update_profile_dns_server(&profile_id, dns_server) + .map_err(|e| crate::wrap_backend_error(e, "Failed to update DNS server")) +} + #[tauri::command] pub async fn check_browser_status( app_handle: tauri::AppHandle, @@ -2641,6 +2685,7 @@ pub async fn create_browser_profile_new( group_id: Option, ephemeral: Option, dns_blocklist: Option, + dns_server: Option, launch_hook: Option, ) -> Result { let fingerprint_os = wayfern_config.as_ref().and_then(|c| c.os.as_deref()); @@ -2670,6 +2715,7 @@ pub async fn create_browser_profile_new( group_id, ephemeral.unwrap_or(false), dns_blocklist, + dns_server, launch_hook, ) .await diff --git a/src-tauri/src/profile/types.rs b/src-tauri/src/profile/types.rs index ec037e14..443d53c6 100644 --- a/src-tauri/src/profile/types.rs +++ b/src-tauri/src/profile/types.rs @@ -75,6 +75,10 @@ pub struct BrowserProfile { pub created_by_email: Option, #[serde(default)] pub dns_blocklist: Option, + /// The DNS server this profile resolves names with, through its own proxy + /// or VPN (see `profile_dns`). `None` lets the proxy resolve them. + #[serde(default)] + pub dns_server: Option, /// True when the on-disk profile dir is encrypted with a per-profile password. /// Decryption goes to a RAM-backed ephemeral dir, never to disk. #[serde(default)] diff --git a/src-tauri/src/profile_dns.rs b/src-tauri/src/profile_dns.rs new file mode 100644 index 00000000..180d51f1 --- /dev/null +++ b/src-tauri/src/profile_dns.rs @@ -0,0 +1,1562 @@ +//! The DNS server a profile resolves names with. +//! +//! Blank keeps the old behavior: the proxy resolves every name. When a profile +//! names a server, its local proxy worker resolves each destination with it and +//! asks the upstream for the address instead of the name. The queries travel +//! the profile's own route (proxy, VLESS worker or VPN worker), so the resolver +//! sees the proxy's exit address, its answers match the proxy's location, and +//! nothing new reaches the local network. +//! +//! Encryption is the default wherever the server offers it: a plain address of +//! a well-known public resolver is upgraded to that resolver's DNS over HTTPS +//! endpoint on the same addresses, and `tcp://` keeps it plain on purpose. A +//! server that does not answer fails the connection. Falling back to the +//! proxy's resolver would quietly undo what the person chose. + +use crate::proxy_server::BoxedAsyncStream; +use http_body_util::{BodyExt, Full, Limited}; +use hyper::body::Bytes; +use hyper::{Request, StatusCode}; +use hyper_util::rt::{TokioExecutor, TokioIo}; +use serde::Serialize; +use std::collections::HashMap; +use std::net::{IpAddr, SocketAddr}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use url::Url; + +const DOH_PATH: &str = "/dns-query"; +const MAX_INPUT_LEN: usize = 512; + +/// Public resolvers that serve DNS over HTTPS on the same addresses as their +/// plain DNS, at `/dns-query`, with a certificate for `name`. Every address +/// here was checked to answer over HTTP/2; Quad9 and Mullvad refuse HTTP/1.1. +struct KnownResolver { + name: &'static str, + addresses: &'static [&'static str], +} + +const KNOWN_RESOLVERS: &[KnownResolver] = &[ + KnownResolver { + name: "cloudflare-dns.com", + addresses: &[ + "1.1.1.1", + "1.0.0.1", + "2606:4700:4700::1111", + "2606:4700:4700::1001", + ], + }, + KnownResolver { + name: "security.cloudflare-dns.com", + addresses: &[ + "1.1.1.2", + "1.0.0.2", + "2606:4700:4700::1112", + "2606:4700:4700::1002", + ], + }, + KnownResolver { + name: "family.cloudflare-dns.com", + addresses: &[ + "1.1.1.3", + "1.0.0.3", + "2606:4700:4700::1113", + "2606:4700:4700::1003", + ], + }, + KnownResolver { + name: "dns.google", + addresses: &[ + "8.8.8.8", + "8.8.4.4", + "2001:4860:4860::8888", + "2001:4860:4860::8844", + ], + }, + KnownResolver { + name: "dns.quad9.net", + addresses: &["9.9.9.9", "149.112.112.112", "2620:fe::fe", "2620:fe::9"], + }, + KnownResolver { + name: "dns10.quad9.net", + addresses: &["9.9.9.10", "149.112.112.10"], + }, + KnownResolver { + name: "dns11.quad9.net", + addresses: &["9.9.9.11", "149.112.112.11"], + }, + KnownResolver { + name: "dns.mullvad.net", + addresses: &["194.242.2.2", "2a07:e340::2"], + }, + KnownResolver { + name: "adblock.dns.mullvad.net", + addresses: &["194.242.2.3"], + }, + KnownResolver { + name: "base.dns.mullvad.net", + addresses: &["194.242.2.4"], + }, + KnownResolver { + name: "extended.dns.mullvad.net", + addresses: &["194.242.2.5"], + }, + KnownResolver { + name: "family.dns.mullvad.net", + addresses: &["194.242.2.6"], + }, + KnownResolver { + name: "all.dns.mullvad.net", + addresses: &["194.242.2.9"], + }, + KnownResolver { + name: "dns.adguard-dns.com", + addresses: &["94.140.14.14", "94.140.15.15"], + }, + KnownResolver { + name: "family.adguard-dns.com", + addresses: &["94.140.14.15", "94.140.15.16"], + }, + KnownResolver { + name: "unfiltered.adguard-dns.com", + addresses: &["94.140.14.140", "94.140.14.141"], + }, + KnownResolver { + name: "doh.opendns.com", + addresses: &[ + "208.67.222.222", + "208.67.220.220", + "2620:119:35::35", + "2620:119:53::53", + ], + }, +]; + +fn known_by_address(ip: IpAddr) -> Option<&'static KnownResolver> { + KNOWN_RESOLVERS.iter().find(|known| { + known + .addresses + .iter() + .any(|address| address.parse::().ok() == Some(ip)) + }) +} + +fn known_by_name(name: &str) -> Option<&'static KnownResolver> { + KNOWN_RESOLVERS + .iter() + .find(|known| known.name.eq_ignore_ascii_case(name)) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum DnsTransport { + /// Plain DNS over TCP. TCP and not UDP because it has to cross the proxy. + Tcp, + /// DNS over TLS (RFC 7858). + Tls, + /// DNS over HTTPS (RFC 8484). + Https, +} + +impl DnsTransport { + pub fn as_str(self) -> &'static str { + match self { + DnsTransport::Tcp => "tcp", + DnsTransport::Tls => "tls", + DnsTransport::Https => "https", + } + } +} + +/// A parsed profile DNS server. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DnsServer { + pub transport: DnsTransport, + /// What the route dials: an IP literal, or a name the route resolves. + dial_host: String, + port: u16, + /// The name the certificate must carry; also the DoH authority. + tls_name: String, + /// The DoH request target, with any query string. + path: String, + /// A plain address that now uses the provider's encrypted endpoint. + upgraded: bool, +} + +/// What the person typed is not a DNS server Donut can use. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InvalidDnsServer; + +/// How the UI describes a server, so the person sees what the field means. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct DnsServerSummary { + pub transport: DnsTransport, + /// Host, plus the port when it is not the transport's default. + pub server: String, + pub encrypted: bool, + pub upgraded: bool, +} + +fn bracketed(host: &str) -> String { + if host.parse::().is_ok() { + format!("[{host}]") + } else { + host.to_string() + } +} + +fn valid_hostname(host: &str) -> bool { + let host = host.strip_suffix('.').unwrap_or(host); + !host.is_empty() + && host.len() <= 253 + && host.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + }) +} + +/// An IP literal or a valid host name, lowercased and without the brackets +/// of an IPv6 literal. +fn checked_host(host: &str) -> Result { + let host = host + .strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(host); + if let Ok(ip) = host.parse::() { + return Ok(ip.to_string()); + } + if valid_hostname(host) { + Ok(host.trim_end_matches('.').to_ascii_lowercase()) + } else { + Err(InvalidDnsServer) + } +} + +fn known_for(host: &str) -> Option<&'static KnownResolver> { + match host.parse::() { + Ok(ip) => known_by_address(ip), + Err(_) => known_by_name(host), + } +} + +impl DnsServer { + /// Accepts `https://host/path` (DNS over HTTPS), `tls://host[:port]` (DNS + /// over TLS), `tcp://host[:port]` (plain DNS), and a bare `host[:port]`. + /// A bare address of a well-known resolver uses its DNS over HTTPS endpoint. + pub fn parse(input: &str) -> Result { + let input = input.trim(); + // RFC 8484 servers publish URI templates ending in `{?dns}`. Queries go + // as POST, so the template variable is not needed. + let input = input.strip_suffix("{?dns}").unwrap_or(input); + if input.is_empty() + || input.len() > MAX_INPUT_LEN + || input.chars().any(|c| c.is_whitespace() || c.is_control()) + { + return Err(InvalidDnsServer); + } + + let Some((scheme, _)) = input.split_once("://") else { + return Self::parse_bare(input); + }; + let url = Url::parse(input).map_err(|_| InvalidDnsServer)?; + if !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() { + return Err(InvalidDnsServer); + } + let host = checked_host(url.host_str().ok_or(InvalidDnsServer)?)?; + let bare_path = matches!(url.path(), "" | "/") && url.query().is_none(); + let known = known_for(&host); + // A known name is dialed at its published address, so reaching the + // resolver does not depend on the proxy's own resolver working. + let dial_host = match known { + Some(known) if host.parse::().is_err() => known.addresses[0].to_string(), + _ => host.clone(), + }; + // A known address is checked against its provider's name: that is what + // its certificate carries for certain. + let tls_name = known.map_or_else(|| host.clone(), |known| known.name.to_string()); + + match scheme.to_ascii_lowercase().as_str() { + "https" => { + let mut path = match url.path() { + "" | "/" => DOH_PATH.to_string(), + path => path.to_string(), + }; + if let Some(query) = url.query() { + path.push('?'); + path.push_str(query); + } + Ok(Self { + transport: DnsTransport::Https, + dial_host, + port: url.port().unwrap_or(443), + tls_name, + path, + upgraded: false, + }) + } + "tls" if bare_path => Ok(Self { + transport: DnsTransport::Tls, + dial_host, + port: url.port().unwrap_or(853), + tls_name, + path: String::new(), + upgraded: false, + }), + "tcp" if bare_path => Ok(Self { + transport: DnsTransport::Tcp, + port: url.port().unwrap_or(53), + tls_name: host.clone(), + dial_host: host, + path: String::new(), + upgraded: false, + }), + _ => Err(InvalidDnsServer), + } + } + + fn parse_bare(input: &str) -> Result { + let (host, port) = if let Ok(ip) = input.parse::() { + (ip.to_string(), None) + } else if let Ok(address) = input.parse::() { + (address.ip().to_string(), Some(address.port())) + } else if input.starts_with('[') && input.ends_with(']') { + (checked_host(input)?, None) + } else { + match input.rsplit_once(':') { + Some((host, port)) => ( + checked_host(host)?, + Some(port.parse::().map_err(|_| InvalidDnsServer)?), + ), + None => (checked_host(input)?, None), + } + }; + if port == Some(0) { + return Err(InvalidDnsServer); + } + + if matches!(port, None | Some(53)) { + if let Some(known) = known_for(&host) { + let dial_host = if host.parse::().is_ok() { + host + } else { + known.addresses[0].to_string() + }; + return Ok(Self { + transport: DnsTransport::Https, + dial_host, + port: 443, + tls_name: known.name.to_string(), + path: DOH_PATH.to_string(), + upgraded: true, + }); + } + } + Ok(Self { + transport: DnsTransport::Tcp, + tls_name: host.clone(), + dial_host: host, + port: port.unwrap_or(53), + path: String::new(), + upgraded: false, + }) + } + + pub fn summary(&self) -> DnsServerSummary { + let (host, default_port) = match self.transport { + DnsTransport::Tcp => (&self.dial_host, 53), + DnsTransport::Tls => (&self.tls_name, 853), + DnsTransport::Https => (&self.tls_name, 443), + }; + let server = if self.port == default_port { + host.clone() + } else { + format!("{}:{}", bracketed(host), self.port) + }; + DnsServerSummary { + transport: self.transport, + server, + encrypted: self.transport != DnsTransport::Tcp, + upgraded: self.upgraded, + } + } + + fn authority(&self) -> String { + if self.port == 443 { + bracketed(&self.tls_name) + } else { + format!("{}:{}", bracketed(&self.tls_name), self.port) + } + } +} + +/// The stored form of a DNS server setting: trimmed, with blank as `None`. +/// Rejects anything `DnsServer::parse` cannot use, as `DNS_SERVER_INVALID`. +pub fn normalize_dns_server(value: Option) -> Result, String> { + let Some(value) = value + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + else { + return Ok(None); + }; + DnsServer::parse(&value).map_err(|_| crate::backend_error("DNS_SERVER_INVALID"))?; + Ok(Some(value)) +} + +/// What a DNS server setting will do, for the field that edits it. `None` for +/// blank: the proxy keeps resolving names. +#[tauri::command] +pub fn describe_dns_server(value: String) -> Result, String> { + if value.trim().is_empty() { + return Ok(None); + } + DnsServer::parse(&value) + .map(|server| Some(server.summary())) + .map_err(|_| crate::backend_error("DNS_SERVER_INVALID")) +} + +// --- DNS messages --------------------------------------------------------- + +const TYPE_A: u16 = 1; +const TYPE_AAAA: u16 = 28; +const TYPE_OPT: u16 = 41; +const CLASS_IN: u16 = 1; +const RCODE_NOERROR: u8 = 0; +const RCODE_NXDOMAIN: u8 = 3; +const EDNS_UDP_SIZE: u16 = 1232; +const EDNS_PADDING: u16 = 12; +/// RFC 8467: pad encrypted queries to a multiple of 128 octets, so their +/// length does not tell an observer which name was asked for. +const PAD_BLOCK: usize = 128; +const MAX_MESSAGE: usize = 65535; + +fn encode_name(out: &mut Vec, name: &str) -> Option<()> { + if name.is_empty() { + return None; + } + let start = out.len(); + for label in name.split('.') { + let bytes = label.as_bytes(); + if bytes.is_empty() + || bytes.len() > 63 + || !bytes + .iter() + .all(|b| b.is_ascii_alphanumeric() || *b == b'-' || *b == b'_') + { + return None; + } + out.push(bytes.len() as u8); + out.extend_from_slice(bytes); + } + out.push(0); + (out.len() - start <= 255).then_some(()) +} + +/// A recursive query for one name and type. No client subnet is ever sent; +/// `pad` adds an EDNS(0) padding option. +fn encode_query(id: u16, name: &str, qtype: u16, pad: bool) -> Option> { + let mut out = Vec::with_capacity(PAD_BLOCK); + out.extend_from_slice(&id.to_be_bytes()); + out.extend_from_slice(&0x0100u16.to_be_bytes()); // RD + out.extend_from_slice(&1u16.to_be_bytes()); // QDCOUNT + out.extend_from_slice(&0u16.to_be_bytes()); // ANCOUNT + out.extend_from_slice(&0u16.to_be_bytes()); // NSCOUNT + out.extend_from_slice(&u16::from(pad).to_be_bytes()); // ARCOUNT + encode_name(&mut out, name)?; + out.extend_from_slice(&qtype.to_be_bytes()); + out.extend_from_slice(&CLASS_IN.to_be_bytes()); + if pad { + // OPT record (11 octets) with one padding option (4 octets + padding). + let unpadded = out.len() + 11 + 4; + let padding = (PAD_BLOCK - unpadded % PAD_BLOCK) % PAD_BLOCK; + out.push(0); + out.extend_from_slice(&TYPE_OPT.to_be_bytes()); + out.extend_from_slice(&EDNS_UDP_SIZE.to_be_bytes()); + out.extend_from_slice(&0u32.to_be_bytes()); + out.extend_from_slice(&((4 + padding) as u16).to_be_bytes()); + out.extend_from_slice(&EDNS_PADDING.to_be_bytes()); + out.extend_from_slice(&(padding as u16).to_be_bytes()); + out.resize(out.len() + padding, 0); + } + Some(out) +} + +#[derive(Debug, PartialEq, Eq)] +struct Reply { + rcode: u8, + addresses: Vec, + /// Lowest TTL in the answer section, in seconds. + ttl: u32, +} + +fn be16(packet: &[u8], at: usize) -> Result { + packet + .get(at..at + 2) + .map(|b| u16::from_be_bytes([b[0], b[1]])) + .ok_or_else(|| "truncated reply".to_string()) +} + +fn skip_name(packet: &[u8], mut pos: usize) -> Result { + for _ in 0..128 { + let len = *packet.get(pos).ok_or("truncated name")? as usize; + match len & 0xC0 { + 0x00 if len == 0 => return Ok(pos + 1), + 0x00 => pos += 1 + len, + 0xC0 => { + packet.get(pos + 1).ok_or("truncated name")?; + return Ok(pos + 2); + } + _ => return Err("malformed name".to_string()), + } + } + Err("malformed name".to_string()) +} + +fn parse_reply(id: u16, qtype: u16, packet: &[u8]) -> Result { + if be16(packet, 0)? != id { + return Err("reply to another query".to_string()); + } + let flags = be16(packet, 2)?; + if flags & 0x8000 == 0 { + return Err("not a reply".to_string()); + } + if flags & 0x0200 != 0 { + return Err("truncated reply".to_string()); + } + let rcode = (flags & 0x000F) as u8; + let questions = be16(packet, 4)?; + let answers = be16(packet, 6)?; + let mut pos = 12; + for _ in 0..questions { + pos = skip_name(packet, pos)? + 4; + } + let mut addresses = Vec::new(); + let mut ttl = u32::MAX; + for _ in 0..answers { + pos = skip_name(packet, pos)?; + let rtype = be16(packet, pos)?; + let class = be16(packet, pos + 2)?; + let record_ttl = (u32::from(be16(packet, pos + 4)?) << 16) | u32::from(be16(packet, pos + 6)?); + let len = usize::from(be16(packet, pos + 8)?); + pos += 10; + let data = packet.get(pos..pos + len).ok_or("truncated record")?; + pos += len; + if class != CLASS_IN { + continue; + } + // The CNAME records of the chain bound how long the address is good for. + ttl = ttl.min(record_ttl); + let address = match (rtype, data.len()) { + (TYPE_A, 4) if rtype == qtype => IpAddr::from([data[0], data[1], data[2], data[3]]), + (TYPE_AAAA, 16) if rtype == qtype => { + let octets: [u8; 16] = data.try_into().map_err(|_| "malformed record")?; + IpAddr::from(octets) + } + _ => continue, + }; + if !addresses.contains(&address) { + addresses.push(address); + } + } + Ok(Reply { + rcode, + ttl: if addresses.is_empty() { 0 } else { ttl }, + addresses, + }) +} + +// --- Resolver --------------------------------------------------------------- + +/// One exchange once connected. Dialing has its own bound in the route. +const QUERY_TIMEOUT: Duration = Duration::from_secs(10); +const TLS_TIMEOUT: Duration = Duration::from_secs(10); +/// Very short TTLs would cost a query through the proxy per connection. +const MIN_TTL: Duration = Duration::from_secs(30); +const MAX_TTL: Duration = Duration::from_secs(30 * 60); +const NEGATIVE_TTL: Duration = Duration::from_secs(30); +/// Lets a burst of connections to one name share a failure instead of each +/// waiting out its own timeout. +const FAILURE_TTL: Duration = Duration::from_secs(2); +const MAX_CACHED: usize = 4096; +const MAX_IDLE: usize = 4; +const IDLE_FOR: Duration = Duration::from_secs(120); +const MAX_QUERIES: usize = 8; + +static SERVER_HEALTH: crate::log_streak::Streak = + crate::log_streak::Streak::new(module_path!(), "Profile DNS server query"); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolveError { + /// The server answered: the name has no address. + NotFound, + /// No usable answer: the server is unreachable, refused, or failed. + Failed(String), +} + +impl std::fmt::Display for ResolveError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ResolveError::NotFound => write!(f, "the profile DNS server has no address for the name"), + ResolveError::Failed(e) => write!(f, "the profile DNS server did not answer: {e}"), + } + } +} + +impl std::error::Error for ResolveError {} + +/// True when a dial failed only because the name does not exist. That is an +/// answer, not a fault: a filtering resolver gives it for every blocked name. +pub fn is_missing_name(error: &(dyn std::error::Error + 'static)) -> bool { + matches!( + error.downcast_ref::(), + Some(ResolveError::NotFound) + ) +} + +struct Cached { + outcome: Result, ResolveError>, + until: Instant, +} + +enum Conn { + /// TCP or TLS, one length-prefixed exchange at a time. + Stream(BoxedAsyncStream), + Http1(hyper::client::conn::http1::SendRequest>), + /// Multiplexed: one connection carries every concurrent query. + Http2(hyper::client::conn::http2::SendRequest>), +} + +impl Conn { + fn is_closed(&self) -> bool { + match self { + Conn::Stream(_) => false, + Conn::Http1(sender) => sender.is_closed(), + Conn::Http2(sender) => sender.is_closed(), + } + } +} + +struct Inner { + server: DnsServer, + upstream: Option, + cache: Mutex>, + /// One lookup per name at a time; the others wait and read the cache. + pending: Mutex>>>, + idle: Mutex>, + queries: tokio::sync::Semaphore, +} + +/// Resolves a worker's destinations with its profile's DNS server, over the +/// worker's own upstream. Cheap to clone: clones share the cache and the +/// connections. +#[derive(Clone)] +pub struct ProfileResolver { + inner: Arc, +} + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(PoisonError::into_inner) +} + +async fn tls_handshake( + stream: BoxedAsyncStream, + name: &str, + alpn: &[&str], +) -> Result, String> { + let mut builder = native_tls::TlsConnector::builder(); + if !alpn.is_empty() { + builder.request_alpns(alpn); + } + let connector = tokio_native_tls::TlsConnector::from(builder.build().map_err(|e| e.to_string())?); + tokio::time::timeout(TLS_TIMEOUT, connector.connect(name, stream)) + .await + .map_err(|_| "TLS handshake timed out".to_string())? + .map_err(|e| format!("TLS handshake failed: {e}")) +} + +impl ProfileResolver { + /// `upstream` is the worker's upstream URL; `None` dials the server directly. + pub fn new(server: DnsServer, upstream: Option) -> Self { + Self { + inner: Arc::new(Inner { + server, + upstream: upstream.filter(|u| u != "DIRECT"), + cache: Mutex::new(HashMap::new()), + pending: Mutex::new(HashMap::new()), + idle: Mutex::new(Vec::new()), + queries: tokio::sync::Semaphore::new(MAX_QUERIES), + }), + } + } + + pub fn transport(&self) -> DnsTransport { + self.inner.server.transport + } + + /// Open the connection to the server while the browser is still starting, + /// so the first page does not wait for the tunnel and the TLS handshake. A + /// failure here is not logged: the first real query reports it. + pub async fn warm_up(&self) { + if let Ok(conn) = self.connect().await { + self.put_idle(conn); + } + } + + /// Addresses for `host`, IPv4 first. An IP literal is returned as is. + pub async fn resolve(&self, host: &str) -> Result, ResolveError> { + let host = host + .strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(host); + if let Ok(ip) = host.parse::() { + return Ok(vec![ip]); + } + let name = host.trim_end_matches('.').to_ascii_lowercase(); + if let Some(outcome) = self.cached(&name) { + return outcome; + } + + let gate = lock(&self.inner.pending) + .entry(name.clone()) + .or_default() + .clone(); + let outcome = { + let _turn = gate.lock().await; + match self.cached(&name) { + Some(outcome) => outcome, + None => { + let (outcome, ttl) = self.lookup(&name).await; + self.store(&name, outcome.clone(), ttl); + outcome + } + } + }; + lock(&self.inner.pending).remove(&name); + outcome + } + + fn cached(&self, name: &str) -> Option, ResolveError>> { + let cache = lock(&self.inner.cache); + let entry = cache.get(name)?; + (entry.until > Instant::now()).then(|| entry.outcome.clone()) + } + + fn store(&self, name: &str, outcome: Result, ResolveError>, ttl: Duration) { + let mut cache = lock(&self.inner.cache); + if cache.len() >= MAX_CACHED { + let now = Instant::now(); + cache.retain(|_, entry| entry.until > now); + if cache.len() >= MAX_CACHED { + cache.clear(); + } + } + cache.insert( + name.to_string(), + Cached { + outcome, + until: Instant::now() + ttl, + }, + ); + } + + /// A first, then AAAA only when the name has no IPv4 address: most exits + /// reach IPv4, and one query is the common case. + async fn lookup(&self, name: &str) -> (Result, ResolveError>, Duration) { + for qtype in [TYPE_A, TYPE_AAAA] { + match self.query(name, qtype).await { + Ok(reply) if reply.rcode == RCODE_NOERROR && !reply.addresses.is_empty() => { + let ttl = Duration::from_secs(u64::from(reply.ttl)).clamp(MIN_TTL, MAX_TTL); + return (Ok(reply.addresses), ttl); + } + Ok(reply) if reply.rcode == RCODE_NOERROR => continue, + Ok(reply) if reply.rcode == RCODE_NXDOMAIN => { + return (Err(ResolveError::NotFound), NEGATIVE_TTL) + } + Ok(reply) => { + return ( + Err(ResolveError::Failed(format!( + "server answered rcode={}", + reply.rcode + ))), + FAILURE_TTL, + ) + } + Err(e) => return (Err(e), FAILURE_TTL), + } + } + (Err(ResolveError::NotFound), NEGATIVE_TTL) + } + + async fn query(&self, name: &str, qtype: u16) -> Result { + let transport = self.inner.server.transport; + // RFC 8484 asks for ID 0 over HTTPS; elsewhere the ID matches the reply. + let id = if transport == DnsTransport::Https { + 0 + } else { + rand::random::() + }; + let Some(message) = encode_query(id, name, qtype, transport != DnsTransport::Tcp) else { + return Err(ResolveError::NotFound); + }; + let _slot = self + .inner + .queries + .acquire() + .await + .map_err(|_| ResolveError::Failed("resolver closed".to_string()))?; + + let result = self.exchange_with_retry(&message).await; + let result = result.and_then(|answer| parse_reply(id, qtype, &answer)); + // The transport is on the worker's start line. + match &result { + Ok(_) => SERVER_HEALTH.succeeded(), + Err(e) => SERVER_HEALTH.failed(e), + } + result.map_err(ResolveError::Failed) + } + + /// A pooled connection may have been closed by the server while it sat + /// idle, so a failure on one is retried once on a fresh connection. + async fn exchange_with_retry(&self, message: &[u8]) -> Result, String> { + let mut pooled = self.take_idle(); + loop { + let reused = pooled.is_some(); + let conn = match pooled.take() { + Some(conn) => conn, + None => self.connect().await?, + }; + match tokio::time::timeout( + QUERY_TIMEOUT, + Self::exchange(&self.inner.server, conn, message), + ) + .await + { + Ok(Ok((answer, conn))) => { + self.put_idle(conn); + return Ok(answer); + } + Ok(Err(_)) | Err(_) if reused => continue, + Ok(Err(e)) => return Err(e), + Err(_) => return Err("query timed out".to_string()), + } + } + } + + fn take_idle(&self) -> Option { + let mut idle = lock(&self.inner.idle); + idle.retain(|(conn, since)| since.elapsed() < IDLE_FOR && !conn.is_closed()); + if let Some((Conn::Http2(sender), _)) = idle.iter().find(|(c, _)| matches!(c, Conn::Http2(_))) { + return Some(Conn::Http2(sender.clone())); + } + idle.pop().map(|(conn, _)| conn) + } + + fn put_idle(&self, conn: Conn) { + let mut idle = lock(&self.inner.idle); + if matches!(conn, Conn::Http2(_)) { + // The pool keeps one handle to a multiplexed connection: the one that + // just answered. The handle it replaces may be to a connection that + // failed and made the caller dial this one. + if let Some(entry) = idle.iter_mut().find(|(c, _)| matches!(c, Conn::Http2(_))) { + *entry = (conn, Instant::now()); + return; + } + } + if idle.len() < MAX_IDLE { + idle.push((conn, Instant::now())); + } + } + + async fn connect(&self) -> Result { + let server = &self.inner.server; + let stream = crate::proxy_server::dial_route( + &server.dial_host, + server.port, + self.inner.upstream.as_deref(), + false, + ) + .await + .map_err(|e| e.to_string())?; + match server.transport { + DnsTransport::Tcp => Ok(Conn::Stream(stream)), + DnsTransport::Tls => Ok(Conn::Stream(Box::new( + tls_handshake(stream, &server.tls_name, &[]).await?, + ))), + DnsTransport::Https => { + let tls = tls_handshake(stream, &server.tls_name, &["h2", "http/1.1"]).await?; + let h2 = + matches!(tls.get_ref().negotiated_alpn(), Ok(Some(ref protocol)) if protocol == b"h2"); + let io = TokioIo::new(tls); + if h2 { + let (sender, connection) = + hyper::client::conn::http2::handshake(TokioExecutor::new(), io) + .await + .map_err(|e| format!("HTTP/2 handshake failed: {e}"))?; + tokio::spawn(async move { + let _ = connection.await; + }); + Ok(Conn::Http2(sender)) + } else { + let (sender, connection) = hyper::client::conn::http1::handshake(io) + .await + .map_err(|e| format!("HTTP/1.1 handshake failed: {e}"))?; + tokio::spawn(async move { + let _ = connection.await; + }); + Ok(Conn::Http1(sender)) + } + } + } + } + + async fn exchange( + server: &DnsServer, + conn: Conn, + message: &[u8], + ) -> Result<(Vec, Conn), String> { + match conn { + Conn::Stream(mut stream) => { + let mut framed = Vec::with_capacity(message.len() + 2); + framed.extend_from_slice(&(message.len() as u16).to_be_bytes()); + framed.extend_from_slice(message); + stream.write_all(&framed).await.map_err(|e| e.to_string())?; + stream.flush().await.map_err(|e| e.to_string())?; + let mut len = [0u8; 2]; + stream + .read_exact(&mut len) + .await + .map_err(|e| e.to_string())?; + let mut answer = vec![0u8; usize::from(u16::from_be_bytes(len))]; + stream + .read_exact(&mut answer) + .await + .map_err(|e| e.to_string())?; + Ok((answer, Conn::Stream(stream))) + } + Conn::Http1(mut sender) => { + sender.ready().await.map_err(|e| e.to_string())?; + let request = doh_request(server, message, false)?; + let response = sender + .send_request(request) + .await + .map_err(|e| e.to_string())?; + Ok((read_doh_response(response).await?, Conn::Http1(sender))) + } + Conn::Http2(mut sender) => { + sender.ready().await.map_err(|e| e.to_string())?; + let request = doh_request(server, message, true)?; + let response = sender + .send_request(request) + .await + .map_err(|e| e.to_string())?; + Ok((read_doh_response(response).await?, Conn::Http2(sender))) + } + } + } +} + +/// HTTP/2 takes the authority from the URI; HTTP/1.1 from `Host`, with the +/// origin-form target every server accepts. +fn doh_request( + server: &DnsServer, + message: &[u8], + absolute: bool, +) -> Result>, String> { + let authority = server.authority(); + let builder = if absolute { + Request::post(format!("https://{authority}{}", server.path)) + } else { + Request::post(server.path.as_str()).header(hyper::header::HOST, authority) + }; + builder + .header(hyper::header::CONTENT_TYPE, "application/dns-message") + .header(hyper::header::ACCEPT, "application/dns-message") + .body(Full::new(Bytes::copy_from_slice(message))) + .map_err(|e| e.to_string()) +} + +async fn read_doh_response( + response: hyper::Response, +) -> Result, String> { + if response.status() != StatusCode::OK { + return Err(format!( + "server answered HTTP {}", + response.status().as_u16() + )); + } + let is_dns_message = response + .headers() + .get(hyper::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| { + v.trim() + .to_ascii_lowercase() + .starts_with("application/dns-message") + }); + if !is_dns_message { + return Err("server answered with something other than a DNS message".to_string()); + } + let body = Limited::new(response.into_body(), MAX_MESSAGE) + .collect() + .await + .map_err(|e| e.to_string())?; + Ok(body.to_bytes().to_vec()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::net::TcpListener; + + fn summary(input: &str) -> DnsServerSummary { + DnsServer::parse(input).unwrap().summary() + } + + #[test] + fn a_known_plain_address_is_upgraded_to_its_encrypted_endpoint() { + for input in [ + "1.1.1.1", + " 1.1.1.1 ", + "1.1.1.1:53", + "[2606:4700:4700::1111]:53", + ] { + let server = DnsServer::parse(input).unwrap(); + assert_eq!(server.transport, DnsTransport::Https, "{input}"); + assert_eq!(server.tls_name, "cloudflare-dns.com"); + assert_eq!(server.port, 443); + assert_eq!(server.path, "/dns-query"); + assert!(server.upgraded); + } + // The address the person chose is the one dialed. + assert_eq!(DnsServer::parse("1.0.0.1").unwrap().dial_host, "1.0.0.1"); + assert_eq!(summary("9.9.9.9").server, "dns.quad9.net"); + assert_eq!(summary("dns.quad9.net").server, "dns.quad9.net"); + assert_eq!( + DnsServer::parse("dns.quad9.net").unwrap().dial_host, + "9.9.9.9" + ); + } + + #[test] + fn an_explicit_scheme_or_port_is_never_upgraded() { + let plain = DnsServer::parse("tcp://1.1.1.1").unwrap(); + assert_eq!(plain.transport, DnsTransport::Tcp); + assert_eq!(plain.port, 53); + assert!(!plain.summary().encrypted); + + let other_port = DnsServer::parse("1.1.1.1:5353").unwrap(); + assert_eq!(other_port.transport, DnsTransport::Tcp); + assert_eq!(other_port.summary().server, "1.1.1.1:5353"); + } + + #[test] + fn an_unknown_plain_address_stays_plain_dns_over_tcp() { + let server = DnsServer::parse("10.0.0.53").unwrap(); + assert_eq!(server.transport, DnsTransport::Tcp); + assert_eq!( + summary("10.0.0.53"), + DnsServerSummary { + transport: DnsTransport::Tcp, + server: "10.0.0.53".to_string(), + encrypted: false, + upgraded: false, + } + ); + assert_eq!(summary("2001:db8::53").server, "2001:db8::53"); + assert_eq!(summary("[2001:db8::53]:5353").server, "[2001:db8::53]:5353"); + assert_eq!( + summary("Resolver.Example:5353").server, + "resolver.example:5353" + ); + } + + #[test] + fn https_urls_keep_their_path_and_query() { + let server = DnsServer::parse("https://dns.example/abc123?x=1").unwrap(); + assert_eq!(server.transport, DnsTransport::Https); + assert_eq!(server.path, "/abc123?x=1"); + assert_eq!(server.dial_host, "dns.example"); + assert_eq!(server.tls_name, "dns.example"); + + let template = DnsServer::parse("https://dns.example/dns-query{?dns}").unwrap(); + assert_eq!(template.path, "/dns-query"); + assert_eq!( + DnsServer::parse("https://dns.example").unwrap().path, + "/dns-query" + ); + assert_eq!( + summary("https://dns.example:8443/q").server, + "dns.example:8443" + ); + // A known provider by name is dialed at its address and keeps its name. + let known = DnsServer::parse("https://dns.mullvad.net/dns-query").unwrap(); + assert_eq!(known.dial_host, "194.242.2.2"); + assert_eq!(known.tls_name, "dns.mullvad.net"); + assert!(!known.upgraded); + } + + #[test] + fn tls_addresses_default_to_port_853() { + let server = DnsServer::parse("tls://1.1.1.1").unwrap(); + assert_eq!(server.transport, DnsTransport::Tls); + assert_eq!(server.port, 853); + assert_eq!(server.dial_host, "1.1.1.1"); + assert_eq!(server.tls_name, "cloudflare-dns.com"); + assert_eq!(summary("tls://dns.example:8853").server, "dns.example:8853"); + } + + #[test] + fn unusable_input_is_rejected() { + for input in [ + "", + " ", + "udp://1.1.1.1", + "quic://dns.example", + "sdns://AgcAAAAAAAAA", + "https://user:secret@dns.example/dns-query", + "https://dns.example/dns-query#x", + "tls://dns.example/path", + "tcp://1.1.1.1/?q", + "1.1.1.1:0", + "1.1.1.1:99999", + "dns example", + "-bad.example", + "dns..example", + "dnś.example", + "https://", + ] { + assert_eq!(DnsServer::parse(input), Err(InvalidDnsServer), "{input:?}"); + } + assert_eq!( + DnsServer::parse(&format!("https://dns.example/{}", "a".repeat(600))), + Err(InvalidDnsServer) + ); + } + + #[test] + fn normalize_trims_and_maps_blank_to_none() { + assert_eq!(normalize_dns_server(None), Ok(None)); + assert_eq!(normalize_dns_server(Some(" ".to_string())), Ok(None)); + assert_eq!( + normalize_dns_server(Some(" 9.9.9.9 ".to_string())), + Ok(Some("9.9.9.9".to_string())) + ); + let err = normalize_dns_server(Some("udp://9.9.9.9".to_string())).unwrap_err(); + assert!(err.contains("DNS_SERVER_INVALID")); + assert_eq!(describe_dns_server(" ".to_string()), Ok(None)); + assert!(describe_dns_server("bogus value".to_string()).is_err()); + } + + #[test] + fn every_known_resolver_address_parses() { + for known in KNOWN_RESOLVERS { + assert!(valid_hostname(known.name)); + for address in known.addresses { + let ip: IpAddr = address.parse().unwrap(); + assert_eq!(known_by_address(ip).map(|k| k.name), Some(known.name)); + } + } + } + + #[test] + fn encrypted_queries_are_padded_to_the_block_size() { + for name in [ + "a.b", + "example.com", + &"x".repeat(63), + "www.a-much-longer-name.example.org", + ] { + let padded = encode_query(0, name, TYPE_A, true).unwrap(); + assert_eq!(padded.len() % PAD_BLOCK, 0, "{name}"); + let plain = encode_query(7, name, TYPE_A, false).unwrap(); + assert_eq!(plain.len(), 12 + name.len() + 2 + 4); + assert_eq!(&plain[10..12], &[0, 0], "no OPT record when unpadded"); + } + assert!(encode_query(0, "", TYPE_A, false).is_none()); + assert!(encode_query(0, "bad name.example", TYPE_A, false).is_none()); + assert!(encode_query(0, &format!("{}.example", "x".repeat(64)), TYPE_A, false).is_none()); + } + + /// A reply to `query` carrying `records` as (type, ttl, rdata), the first + /// owner name compressed to the question. + fn reply_for(query: &[u8], rcode: u8, records: &[(u16, u32, Vec)]) -> Vec { + let question_end = skip_name(query, 12).unwrap() + 4; + let mut out = query[..question_end].to_vec(); + out[2] = 0x81; + out[3] = 0x80 | rcode; + out[6..8].copy_from_slice(&(records.len() as u16).to_be_bytes()); + out[10..12].copy_from_slice(&[0, 0]); + for (rtype, ttl, data) in records { + out.extend_from_slice(&[0xC0, 12]); + out.extend_from_slice(&rtype.to_be_bytes()); + out.extend_from_slice(&CLASS_IN.to_be_bytes()); + out.extend_from_slice(&ttl.to_be_bytes()); + out.extend_from_slice(&(data.len() as u16).to_be_bytes()); + out.extend_from_slice(data); + } + out + } + + #[test] + fn replies_yield_the_addresses_of_the_asked_type_and_the_lowest_ttl() { + let query = encode_query(0x1234, "example.com", TYPE_A, false).unwrap(); + let cname = vec![3, b'w', b'w', b'w', 0xC0, 12]; + let reply = reply_for( + &query, + 0, + &[ + (5, 60, cname), + (TYPE_A, 300, vec![93, 184, 215, 14]), + (TYPE_A, 300, vec![93, 184, 215, 14]), + (TYPE_AAAA, 10, vec![0; 16]), + (TYPE_A, 120, vec![10, 0, 0, 1]), + ], + ); + let parsed = parse_reply(0x1234, TYPE_A, &reply).unwrap(); + assert_eq!( + parsed.addresses, + vec![ + IpAddr::from([93, 184, 215, 14]), + IpAddr::from([10, 0, 0, 1]) + ] + ); + assert_eq!(parsed.rcode, RCODE_NOERROR); + assert_eq!(parsed.ttl, 10); + + assert!(parse_reply(0x9999, TYPE_A, &reply).is_err(), "wrong id"); + assert!(parse_reply(0x1234, TYPE_A, &reply[..reply.len() - 2]).is_err()); + assert!( + parse_reply(0x1234, TYPE_A, &query).is_err(), + "a query is not a reply" + ); + let mut truncated = reply.clone(); + truncated[2] |= 0x02; + assert!(parse_reply(0x1234, TYPE_A, &truncated).is_err()); + + let nxdomain = reply_for(&query, RCODE_NXDOMAIN, &[]); + let parsed = parse_reply(0x1234, TYPE_A, &nxdomain).unwrap(); + assert_eq!(parsed.rcode, RCODE_NXDOMAIN); + assert!(parsed.addresses.is_empty()); + } + + #[test] + fn a_compression_loop_is_rejected_not_followed() { + let mut packet = vec![0u8; 12]; + packet[4..6].copy_from_slice(&1u16.to_be_bytes()); + packet.extend_from_slice(&[0x01, b'a']); + packet.extend(std::iter::repeat_n(0x01, 300)); + assert!(skip_name(&packet, 12).is_err()); + assert!(skip_name(&[0x40, 0, 0], 0).is_err()); + } + + type Answer = (u8, Vec<(u16, u32, Vec)>); + + fn query_name(query: &[u8]) -> (String, u16) { + let mut name = String::new(); + let mut pos = 12; + while query[pos] != 0 { + let len = query[pos] as usize; + if !name.is_empty() { + name.push('.'); + } + name.push_str(std::str::from_utf8(&query[pos + 1..pos + 1 + len]).unwrap()); + pos += 1 + len; + } + (name, u16::from_be_bytes([query[pos + 1], query[pos + 2]])) + } + + /// A DNS-over-TCP server that answers with `answer(name, qtype)` and counts + /// the queries it receives. + async fn start_dns_server( + answer: fn(&str, u16) -> Answer, + ) -> (u16, Arc) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let queries = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counter = queries.clone(); + tokio::spawn(async move { + while let Ok((mut stream, _)) = listener.accept().await { + let counter = counter.clone(); + tokio::spawn(async move { + loop { + let mut len = [0u8; 2]; + if stream.read_exact(&mut len).await.is_err() { + return; + } + let mut query = vec![0u8; usize::from(u16::from_be_bytes(len))]; + if stream.read_exact(&mut query).await.is_err() { + return; + } + counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let (name, qtype) = query_name(&query); + let (rcode, records) = answer(&name, qtype); + let reply = reply_for(&query, rcode, &records); + let mut framed = (reply.len() as u16).to_be_bytes().to_vec(); + framed.extend_from_slice(&reply); + if stream.write_all(&framed).await.is_err() { + return; + } + } + }); + } + }); + (port, queries) + } + + fn tcp_resolver(port: u16) -> ProfileResolver { + let server = DnsServer::parse(&format!("tcp://127.0.0.1:{port}")).unwrap(); + ProfileResolver::new(server, None) + } + + #[tokio::test] + async fn resolves_over_tcp_and_serves_repeats_from_the_cache() { + let (port, queries) = start_dns_server(|name, qtype| match (name, qtype) { + ("site.test", TYPE_A) => (0, vec![(TYPE_A, 600, vec![192, 0, 2, 7])]), + ("site.test", _) => (0, Vec::new()), + _ => (RCODE_NXDOMAIN, Vec::new()), + }) + .await; + let resolver = tcp_resolver(port); + + let first = resolver.resolve("Site.Test.").await.unwrap(); + assert_eq!(first, vec![IpAddr::from([192, 0, 2, 7])]); + let concurrent: Vec<_> = + futures_util::future::join_all((0..8).map(|_| resolver.resolve("site.test"))).await; + assert!(concurrent.iter().all(|r| r.as_deref() == Ok(&first[..]))); + assert_eq!(queries.load(std::sync::atomic::Ordering::SeqCst), 1); + + assert_eq!( + resolver.resolve("missing.test").await, + Err(ResolveError::NotFound) + ); + assert_eq!( + resolver.resolve("192.0.2.99").await, + Ok(vec![IpAddr::from([192, 0, 2, 99])]) + ); + assert_eq!( + resolver.resolve("[2001:db8::1]").await, + Ok(vec!["2001:db8::1".parse().unwrap()]) + ); + assert_eq!(queries.load(std::sync::atomic::Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn a_name_without_ipv4_is_asked_for_ipv6() { + let (port, queries) = start_dns_server(|_, qtype| match qtype { + TYPE_AAAA => ( + 0, + vec![( + TYPE_AAAA, + 600, + vec![0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], + )], + ), + _ => (0, Vec::new()), + }) + .await; + let resolver = tcp_resolver(port); + assert_eq!( + resolver.resolve("v6only.test").await, + Ok(vec!["2001:db8::1".parse().unwrap()]) + ); + assert_eq!(queries.load(std::sync::atomic::Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn a_server_failure_is_not_read_as_a_missing_name() { + let (port, _) = start_dns_server(|_, _| (2, Vec::new())).await; + assert!(matches!( + tcp_resolver(port).resolve("site.test").await, + Err(ResolveError::Failed(_)) + )); + } + + #[tokio::test] + async fn an_unreachable_server_fails_closed() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + // On [::1], not 127.0.0.1: the direct dial backs off per host for the + // whole process, and the other tests here dial 127.0.0.1 in parallel. + let server = DnsServer::parse(&format!("tcp://[::1]:{port}")).unwrap(); + assert!(matches!( + ProfileResolver::new(server, None) + .resolve("site.test") + .await, + Err(ResolveError::Failed(_)) + )); + } + + #[tokio::test] + async fn the_warm_connection_carries_the_first_query() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let accepted = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counter = accepted.clone(); + tokio::spawn(async move { + while let Ok((mut stream, _)) = listener.accept().await { + counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + tokio::spawn(async move { + let mut len = [0u8; 2]; + while stream.read_exact(&mut len).await.is_ok() { + let mut query = vec![0u8; usize::from(u16::from_be_bytes(len))]; + stream.read_exact(&mut query).await.unwrap(); + let reply = reply_for(&query, 0, &[(TYPE_A, 600, vec![192, 0, 2, 9])]); + let mut framed = (reply.len() as u16).to_be_bytes().to_vec(); + framed.extend_from_slice(&reply); + stream.write_all(&framed).await.unwrap(); + } + }); + } + }); + let resolver = tcp_resolver(port); + resolver.warm_up().await; + assert_eq!( + resolver.resolve("warm.test").await, + Ok(vec![IpAddr::from([192, 0, 2, 9])]) + ); + assert_eq!(accepted.load(std::sync::atomic::Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn a_server_that_closed_an_idle_connection_is_redialed() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + // Answers exactly one query per connection, then closes it. + tokio::spawn(async move { + while let Ok((mut stream, _)) = listener.accept().await { + tokio::spawn(async move { + let mut len = [0u8; 2]; + stream.read_exact(&mut len).await.unwrap(); + let mut query = vec![0u8; usize::from(u16::from_be_bytes(len))]; + stream.read_exact(&mut query).await.unwrap(); + let reply = reply_for(&query, 0, &[(TYPE_A, 600, vec![192, 0, 2, 1])]); + let mut framed = (reply.len() as u16).to_be_bytes().to_vec(); + framed.extend_from_slice(&reply); + stream.write_all(&framed).await.unwrap(); + }); + } + }); + let resolver = tcp_resolver(port); + assert!(resolver.resolve("one.test").await.is_ok()); + assert!(resolver.resolve("two.test").await.is_ok()); + } + + /// A plain-text DoH endpoint: checks the request the way a real server + /// would and answers every A query with 192.0.2.53. + async fn doh_service( + request: Request, + ) -> Result>, std::convert::Infallible> { + let well_formed = request.method() == hyper::Method::POST + && request.uri().path() == "/dns-query" + && request + .headers() + .get(hyper::header::CONTENT_TYPE) + .map(|v| v.as_bytes()) + == Some(b"application/dns-message".as_slice()) + && (request.version() == hyper::Version::HTTP_2 + || request.headers().contains_key(hyper::header::HOST)); + let query = request.into_body().collect().await.unwrap().to_bytes(); + if !well_formed || query.len() % PAD_BLOCK != 0 { + let mut response = hyper::Response::new(Full::new(Bytes::new())); + *response.status_mut() = StatusCode::BAD_REQUEST; + return Ok(response); + } + let reply = reply_for(&query, 0, &[(TYPE_A, 300, vec![192, 0, 2, 53])]); + Ok( + hyper::Response::builder() + .header(hyper::header::CONTENT_TYPE, "application/dns-message") + .body(Full::new(Bytes::from(reply))) + .unwrap(), + ) + } + + #[tokio::test] + async fn doh_queries_are_well_formed_over_http1_and_http2() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + tokio::spawn(async move { + let _ = hyper_util::server::conn::auto::Builder::new(TokioExecutor::new()) + .serve_connection( + TokioIo::new(stream), + hyper::service::service_fn(doh_service), + ) + .await; + }); + } + }); + let server = DnsServer::parse("https://doh.test/dns-query").unwrap(); + let query = encode_query(0, "example.com", TYPE_A, true).unwrap(); + + let stream = tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .unwrap(); + let (sender, connection) = hyper::client::conn::http1::handshake(TokioIo::new(stream)) + .await + .unwrap(); + tokio::spawn(connection); + let (answer, conn) = ProfileResolver::exchange(&server, Conn::Http1(sender), &query) + .await + .unwrap(); + let reply = parse_reply(0, TYPE_A, &answer).unwrap(); + assert_eq!(reply.addresses, vec![IpAddr::from([192, 0, 2, 53])]); + // The same HTTP/1.1 connection carries the next query. + let (answer, _) = ProfileResolver::exchange(&server, conn, &query) + .await + .unwrap(); + assert!(parse_reply(0, TYPE_A, &answer).is_ok()); + + let stream = tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .unwrap(); + let (sender, connection) = + hyper::client::conn::http2::handshake(TokioExecutor::new(), TokioIo::new(stream)) + .await + .unwrap(); + tokio::spawn(connection); + let (answer, _) = ProfileResolver::exchange(&server, Conn::Http2(sender), &query) + .await + .unwrap(); + let reply = parse_reply(0, TYPE_A, &answer).unwrap(); + assert_eq!(reply.addresses, vec![IpAddr::from([192, 0, 2, 53])]); + } + + /// Real public resolvers over every transport. Needs the internet, so it + /// runs only when asked: `cargo test profile_dns -- --ignored`. + #[tokio::test] + #[ignore] + async fn live_public_resolvers_answer_over_every_transport() { + for input in [ + "1.1.1.1", + "9.9.9.9", + "194.242.2.2", + "8.8.8.8", + "94.140.14.14", + "208.67.222.222", + "https://dns.quad9.net/dns-query", + "https://cloudflare-dns.com/dns-query", + "tls://1.1.1.1", + "tls://dns.quad9.net", + "tcp://1.1.1.1", + ] { + let resolver = ProfileResolver::new(DnsServer::parse(input).unwrap(), None); + let addresses = resolver.resolve("example.com").await; + assert!( + addresses.as_ref().is_ok_and(|a| !a.is_empty()), + "{input}: {addresses:?}" + ); + assert_eq!( + resolver.resolve("does-not-exist.invalid").await, + Err(ResolveError::NotFound), + "{input}" + ); + } + } +} diff --git a/src-tauri/src/profile_importer.rs b/src-tauri/src/profile_importer.rs index 8655167f..01b9497e 100644 --- a/src-tauri/src/profile_importer.rs +++ b/src-tauri/src/profile_importer.rs @@ -1000,6 +1000,7 @@ impl ProfileImporter { created_by_id: None, created_by_email: None, dns_blocklist: None, + dns_server: None, password_protected: false, clear_on_close: false, created_at: None, @@ -1081,6 +1082,7 @@ impl ProfileImporter { created_by_id: None, created_by_email: None, dns_blocklist: None, + dns_server: None, password_protected: false, clear_on_close: false, created_at: Some( diff --git a/src-tauri/src/proxy_manager.rs b/src-tauri/src/proxy_manager.rs index 0183971b..24cfa41d 100644 --- a/src-tauri/src/proxy_manager.rs +++ b/src-tauri/src/proxy_manager.rs @@ -86,6 +86,8 @@ pub struct ProxyInfo { // Optional profile ID to which this proxy instance is logically tied pub profile_id: Option, pub blocklist_file: Option, + #[serde(default)] + pub dns_server: Option, } // Proxy check result cache @@ -1953,6 +1955,7 @@ impl ProxyManager { bypass_rules: Vec, blocklist_file: Option, dns_allowlist_mode: bool, + dns_server: Option<&str>, record_domains: bool, // Protocol the local worker serves the browser: "socks5" (Wayfern). Reflected in // the returned ProxySettings.proxy_type so the caller formats the right local proxy URL scheme. @@ -1983,7 +1986,8 @@ impl ProxyManager { let is_same_upstream = existing.upstream_type == desired_type && existing.upstream_host == desired_host - && existing.upstream_port == desired_port; + && existing.upstream_port == desired_port + && existing.dns_server.as_deref() == dns_server; if is_same_upstream { // Settings match - can reuse existing proxy @@ -2020,7 +2024,8 @@ impl ProxyManager { let is_same_upstream = existing.upstream_type == desired_type && existing.upstream_host == desired_host - && existing.upstream_port == desired_port; + && existing.upstream_port == desired_port + && existing.dns_server.as_deref() == dns_server; if is_same_upstream { // Check if profile_id matches @@ -2101,6 +2106,10 @@ impl ProxyManager { } } + if let Some(dns_server) = dns_server { + proxy_cmd = proxy_cmd.arg("--dns-server").arg(dns_server); + } + if !record_domains { proxy_cmd = proxy_cmd.arg("--no-domain-history"); } @@ -2154,6 +2163,7 @@ impl ProxyManager { local_port, profile_id: profile_id.map(|s| s.to_string()), blocklist_file: blocklist_file.clone(), + dns_server: dns_server.map(str::to_string), }; // Wait for the local proxy port to be ready to accept connections @@ -2968,6 +2978,7 @@ mod tests { local_port: (8000 + i) as u16, profile_id: None, blocklist_file: None, + dns_server: None, }; // Add proxy @@ -3237,6 +3248,7 @@ mod tests { local_port: port, profile_id: profile_id.map(|s| s.to_string()), blocklist_file: None, + dns_server: None, } } @@ -3632,6 +3644,7 @@ mod tests { bypass_rules: Vec::new(), blocklist_file: None, dns_allowlist_mode: false, + dns_server: None, local_protocol: None, browser_pid: None, browser_pid_start_time: None, @@ -3648,6 +3661,7 @@ mod tests { bypass_rules: Vec::new(), blocklist_file: None, dns_allowlist_mode: false, + dns_server: None, local_protocol: None, browser_pid: None, browser_pid_start_time: None, @@ -3692,6 +3706,7 @@ mod tests { bypass_rules: vec!["*.local".to_string(), "192.168.*".to_string()], blocklist_file: None, dns_allowlist_mode: false, + dns_server: None, local_protocol: None, browser_pid: None, browser_pid_start_time: None, @@ -3827,6 +3842,7 @@ mod tests { local_port: 9201, profile_id: Some("profile_alpha".to_string()), blocklist_file: None, + dns_server: None, }; let info_b = ProxyInfo { id: "px_shared_b".to_string(), @@ -3837,6 +3853,7 @@ mod tests { local_port: 9202, profile_id: Some("profile_beta".to_string()), blocklist_file: None, + dns_server: None, }; pm.insert_active_proxy(3001, info_a); @@ -4373,6 +4390,7 @@ mod tests { bypass_rules: Vec::new(), blocklist_file: None, dns_allowlist_mode: false, + dns_server: None, local_protocol: None, browser_pid: None, browser_pid_start_time: None, @@ -4601,6 +4619,7 @@ mod tests { local_port: 9300 + i as u16, profile_id: Some(format!("profile_{ptype}")), blocklist_file: None, + dns_server: None, }; pm.insert_active_proxy(4000 + i as u32, info); } diff --git a/src-tauri/src/proxy_runner.rs b/src-tauri/src/proxy_runner.rs index cd56b539..51b53108 100644 --- a/src-tauri/src/proxy_runner.rs +++ b/src-tauri/src/proxy_runner.rs @@ -271,6 +271,7 @@ pub async fn start_proxy_process( None, false, None, + None, true, ) .await @@ -284,9 +285,11 @@ pub async fn start_proxy_process_with_profile( bypass_rules: Vec, blocklist_file: Option, dns_allowlist_mode: bool, + dns_server: Option, local_protocol: Option, record_domains: bool, ) -> Result> { + let dns_server = crate::profile_dns::normalize_dns_server(dns_server)?; ensure_sidecar_version().await?; let id = generate_proxy_id(); @@ -304,6 +307,7 @@ pub async fn start_proxy_process_with_profile( .with_bypass_rules(bypass_rules) .with_blocklist_file(blocklist_file) .with_dns_allowlist_mode(dns_allowlist_mode) + .with_dns_server(dns_server) .with_local_protocol(local_protocol) .with_record_domains(record_domains); save_proxy_config(&config)?; diff --git a/src-tauri/src/proxy_server.rs b/src-tauri/src/proxy_server.rs index aef9f3ca..af0bb958 100644 --- a/src-tauri/src/proxy_server.rs +++ b/src-tauri/src/proxy_server.rs @@ -264,6 +264,7 @@ async fn handle_request( upstream_url: Option, bypass_matcher: BypassMatcher, blocklist_matcher: BlocklistMatcher, + resolver: Option, ) -> Result>, Infallible> { // CONNECT cannot be tunneled on the hyper path: hyper owns the connection // and would keep parsing the post-200 tunnel bytes (TLS) as HTTP. This is @@ -283,7 +284,14 @@ async fn handle_request( } // Handle regular HTTP requests - handle_http(req, upstream_url, bypass_matcher, blocklist_matcher).await + handle_http( + req, + upstream_url, + bypass_matcher, + blocklist_matcher, + resolver, + ) + .await } /// Extract percent-decoded (username, password) from the upstream URL. @@ -775,13 +783,6 @@ async fn handle_http_via_socks4( req: Request, upstream_url: &str, ) -> Result>, Infallible> { - // Extract domain for traffic tracking - let domain = req - .uri() - .host() - .map(|h| h.to_string()) - .unwrap_or_else(|| "unknown".to_string()); - // Parse upstream SOCKS4 proxy URL let upstream = match Url::parse(upstream_url) { Ok(url) => url, @@ -890,7 +891,27 @@ async fn handle_http_via_socks4( return Ok(response); } - // Now send the HTTP request through the SOCKS4 connection + forward_http_over_stream(req, socks_stream, "SOCKS4").await +} + +/// Send a plain-HTTP request over a stream that already reaches the origin (a +/// SOCKS4 tunnel, or a tunnel to an address from the profile DNS server) and +/// buffer the response. `via` names the path in warnings. +async fn forward_http_over_stream( + req: Request, + mut stream: S, + via: &str, +) -> Result>, Infallible> { + // Extract domain for traffic tracking + let domain = req + .uri() + .host() + .map(|h| h.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let target_uri = req.uri().clone(); + let target_host = target_uri.host().unwrap_or("localhost"); + let target_port = target_uri.port_u16().unwrap_or(80); + // Build HTTP request line let method = req.method().as_str(); let path = target_uri @@ -948,9 +969,9 @@ async fn handle_http_via_socks4( http_request.push_str("\r\n"); // Send HTTP request - if let Err(e) = socks_stream.write_all(http_request.as_bytes()).await { + if let Err(e) = stream.write_all(http_request.as_bytes()).await { warn_upstream_failure( - "HTTP request send via SOCKS4 failed", + &format!("HTTP request send via {via} failed"), &e.to_string(), Some(&domain), ); @@ -964,9 +985,9 @@ async fn handle_http_via_socks4( // Send body if present if !body_bytes.is_empty() { - if let Err(e) = socks_stream.write_all(&body_bytes).await { + if let Err(e) = stream.write_all(&body_bytes).await { warn_upstream_failure( - "HTTP body send via SOCKS4 failed", + &format!("HTTP body send via {via} failed"), &e.to_string(), Some(&domain), ); @@ -984,13 +1005,17 @@ async fn handle_http_via_socks4( // permit) forever. let buffered = match tokio::time::timeout( PLAIN_HTTP_EXCHANGE_TIMEOUT, - read_http_response_buffer(&mut socks_stream), + read_http_response_buffer(&mut stream), ) .await { Ok(buffer) => buffer, Err(_) => { - warn_upstream_failure("HTTP response via SOCKS4 failed", "timed out", None); + warn_upstream_failure( + &format!("HTTP response via {via} failed"), + "timed out", + None, + ); let mut response = Response::new(Full::new(Bytes::from("Upstream response timed out"))); *response.status_mut() = StatusCode::GATEWAY_TIMEOUT; return Ok(response); @@ -1001,7 +1026,7 @@ async fn handle_http_via_socks4( // browser a complete-looking short response, so fail the request instead. if buffered.truncated { warn_upstream_failure( - "HTTP response via SOCKS4 failed", + &format!("HTTP response via {via} failed"), "exceeded the buffer cap; truncated body not forwarded", None, ); @@ -1039,7 +1064,7 @@ async fn handle_http_via_socks4( BufferedBody::Dechunked(body) => body, BufferedBody::BrokenChunks => { warn_upstream_failure( - "HTTP response via SOCKS4 failed", + &format!("HTTP response via {via} failed"), "chunked body did not decode", None, ); @@ -1219,6 +1244,7 @@ async fn handle_http( upstream_url: Option, bypass_matcher: BypassMatcher, blocklist_matcher: BlocklistMatcher, + resolver: Option, ) -> Result>, Infallible> { // Extract domain for traffic tracking let domain = req @@ -1244,6 +1270,43 @@ async fn handle_http( let should_bypass = bypass_matcher.should_bypass(&domain); + // reqwest would hand the name to the proxy, so with a profile DNS server + // the request goes through a tunnel to the address it resolved. + if let Some(resolver) = resolver.as_ref().filter(|_| !should_bypass) { + let port = req.uri().port_u16().unwrap_or(80); + let tunnel = connect_to_target_via_upstream( + &domain, + port, + upstream_url.as_deref(), + &bypass_matcher, + Some(resolver), + ) + .await + .map_err(|e| { + ( + crate::profile_dns::is_missing_name(e.as_ref()), + e.to_string(), + ) + }); + return match tunnel { + Ok(stream) => forward_http_over_stream(req, stream, "the profile DNS server").await, + Err((missing_name, e)) => { + if missing_name { + log::debug!("HTTP request to a name the profile DNS server does not know"); + } else { + warn_upstream_failure( + "HTTP request via the profile DNS server failed", + &e, + Some(&domain), + ); + } + let mut response = Response::new(Full::new(Bytes::from(format!("Request failed: {e}")))); + *response.status_mut() = StatusCode::BAD_GATEWAY; + Ok(response) + } + }; + } + // Handle proxy types that reqwest doesn't support natively if !should_bypass { if let Some(ref upstream) = upstream_url { @@ -1478,6 +1541,7 @@ pub async fn handle_proxy_connection( upstream_url: Option, bypass_matcher: BypassMatcher, blocklist_matcher: BlocklistMatcher, + resolver: Option, ) { let _ = stream.set_nodelay(true); @@ -1559,14 +1623,19 @@ pub async fn handle_proxy_connection( upstream_url, bypass_matcher, blocklist_matcher, + resolver.as_ref(), ) .await { - warn_upstream_failure( - "CONNECT tunnel failed", - &e.to_string(), - target_host.as_deref(), - ); + if crate::profile_dns::is_missing_name(e.as_ref()) { + log::debug!("CONNECT to a name the profile DNS server does not know"); + } else { + warn_upstream_failure( + "CONNECT tunnel failed", + &e.to_string(), + target_host.as_deref(), + ); + } } return; } @@ -1585,6 +1654,7 @@ pub async fn handle_proxy_connection( upstream_url.clone(), bypass_matcher.clone(), blocklist_matcher.clone(), + resolver.clone(), ) }); @@ -1865,14 +1935,31 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box None, + Some(value) => match crate::profile_dns::DnsServer::parse(value) { + Ok(server) => Some(crate::profile_dns::ProfileResolver::new( + server, + upstream_url.clone(), + )), + Err(_) => return Err("Invalid DNS server in proxy config".into()), + }, + }; + + // `dns` names the transport only: a DoH path can carry an account id. log::info!( - "Worker started kind=proxy id={} pid={} listen=127.0.0.1:{actual_port} serve={local_protocol} upstream={} profile={} blocklist={} bypass_rules={} record_domains={} connections={connection_limit} elapsed_ms={}", + "Worker started kind=proxy id={} pid={} listen=127.0.0.1:{actual_port} serve={local_protocol} upstream={} profile={} dns={} blocklist={} bypass_rules={} record_domains={} connections={connection_limit} elapsed_ms={}", config.id, std::process::id(), upstream_url .as_deref() .map_or_else(|| "direct".to_string(), redacted_upstream), config.profile_id.as_deref().unwrap_or("-"), + resolver + .as_ref() + .map_or("route", |r| r.transport().as_str()), match &config.blocklist_file { Some(_) => format!( "{}:{}", @@ -1886,6 +1973,10 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box Result<(), Box, bypass_matcher: BypassMatcher, blocklist_matcher: BlocklistMatcher, + resolver: Option<&crate::profile_dns::ProfileResolver>, ) -> Result<(), Box> { // Parse the CONNECT request from the buffer let request_str = String::from_utf8_lossy(&request_buffer); @@ -1991,6 +2087,7 @@ async fn handle_connect_from_buffer( target_port, upstream_url.as_deref(), &bypass_matcher, + resolver, ) .await?; @@ -2381,13 +2478,42 @@ async fn tls_wrap_upstream_hop( /// TLS-wrapped CONNECT, SOCKS4/5, Shadowsocks) is dialed in exactly one place. /// Returns a `BoxedAsyncStream` so the caller can tunnel over any upstream /// uniformly. +/// +/// With a profile DNS server, the name is resolved here and the upstream is +/// asked for the address. A bypassed host goes direct and keeps the system +/// resolver: bypass rules name local and intranet hosts that a public +/// resolver cannot answer for. pub(crate) async fn connect_to_target_via_upstream( target_host: &str, target_port: u16, upstream_url: Option<&str>, bypass_matcher: &BypassMatcher, + resolver: Option<&crate::profile_dns::ProfileResolver>, ) -> Result> { let should_bypass = bypass_matcher.should_bypass(target_host); + let Some(resolver) = resolver.filter(|_| !should_bypass) else { + return dial_route(target_host, target_port, upstream_url, should_bypass).await; + }; + let addresses = resolver.resolve(target_host).await?; + // A second address covers an exit that cannot reach the first one. + let mut last_error = String::new(); + for address in addresses.iter().take(2) { + match dial_route(&address.to_string(), target_port, upstream_url, false).await { + Ok(stream) => return Ok(stream), + Err(e) => last_error = e.to_string(), + } + } + Err(last_error.into()) +} + +/// Dial `target_host:target_port` over the route as given, with no name +/// resolution of its own: direct when `bypass` is set or there is no upstream. +pub(crate) async fn dial_route( + target_host: &str, + target_port: u16, + upstream_url: Option<&str>, + should_bypass: bool, +) -> Result> { // Helper: configure outbound TCP to match browser TCP fingerprint let configure_tcp = |stream: &TcpStream| { let _ = stream.set_nodelay(true); @@ -2509,8 +2635,12 @@ pub(crate) async fn connect_to_target_via_upstream( ) .map_err(|e| format!("Invalid Shadowsocks config: {e}"))?; - let target_addr = - shadowsocks::relay::Address::DomainNameAddress(target_host.to_string(), target_port); + let target_addr = match target_host.parse::() { + Ok(ip) => shadowsocks::relay::Address::SocketAddress(SocketAddr::new(ip, target_port)), + Err(_) => { + shadowsocks::relay::Address::DomainNameAddress(target_host.to_string(), target_port) + } + }; let stream = tokio::time::timeout( UPSTREAM_DIAL_TIMEOUT, @@ -3240,6 +3370,7 @@ this line has no colon\r\n\ 443, Some(&upstream), &matcher, + None, ) .await; }); @@ -3343,7 +3474,7 @@ this line has no colon\r\n\ let matcher = BypassMatcher::new(&[]); let result = tokio::time::timeout( std::time::Duration::from_secs(20), - connect_to_target_via_upstream("example.com", 443, Some(&upstream), &matcher), + connect_to_target_via_upstream("example.com", 443, Some(&upstream), &matcher, None), ) .await .expect("the dial must not hang"); diff --git a/src-tauri/src/proxy_storage.rs b/src-tauri/src/proxy_storage.rs index 0136476d..f661a6f4 100644 --- a/src-tauri/src/proxy_storage.rs +++ b/src-tauri/src/proxy_storage.rs @@ -20,6 +20,10 @@ pub struct ProxyConfig { /// only reach domains in the file; everything else is blocked. #[serde(default)] pub dns_allowlist_mode: bool, + /// The profile's own DNS server (see `profile_dns`). `None` leaves name + /// resolution to the upstream. + #[serde(default)] + pub dns_server: Option, /// Protocol the local worker serves to the browser: "socks5" (Wayfern/Chromium so QUIC and /// WebRTC UDP can be proxied without leaking the real IP). Independent of /// `upstream_url`, which is the real upstream proxy/VPN this worker dials. @@ -63,6 +67,7 @@ impl ProxyConfig { bypass_rules: Vec::new(), blocklist_file: None, dns_allowlist_mode: false, + dns_server: None, local_protocol: None, browser_pid: None, browser_pid_start_time: None, @@ -95,6 +100,11 @@ impl ProxyConfig { self } + pub fn with_dns_server(mut self, dns_server: Option) -> Self { + self.dns_server = dns_server; + self + } + pub fn with_local_protocol(mut self, local_protocol: Option) -> Self { self.local_protocol = local_protocol; self diff --git a/src-tauri/src/socks5_local.rs b/src-tauri/src/socks5_local.rs index 4f095a35..86d7c5ef 100644 --- a/src-tauri/src/socks5_local.rs +++ b/src-tauri/src/socks5_local.rs @@ -19,6 +19,7 @@ //! datagram via [`UdpRelayContext`]. Without that, QUIC and WebRTC would be an //! unfiltered, unmetered side channel around the TCP filter. +use crate::profile_dns::ProfileResolver; use crate::proxy_server::{ connect_to_target_via_upstream, redacted_upstream, tunnel_streams, BlocklistMatcher, BypassMatcher, @@ -37,6 +38,7 @@ use url::Url; const REP_SUCCEEDED: u8 = 0x00; const REP_GENERAL_FAILURE: u8 = 0x01; const REP_NOT_ALLOWED: u8 = 0x02; +const REP_HOST_UNREACHABLE: u8 = 0x04; const REP_COMMAND_NOT_SUPPORTED: u8 = 0x07; // SOCKS5 commands (RFC 1928 §4). @@ -209,6 +211,7 @@ pub async fn handle_socks5_connection( upstream_url: Option, bypass_matcher: BypassMatcher, blocklist_matcher: BlocklistMatcher, + resolver: Option, ) { let _ = stream.set_nodelay(true); @@ -235,11 +238,12 @@ pub async fn handle_socks5_connection( upstream_url, bypass_matcher, blocklist_matcher, + resolver, ) .await; } CMD_UDP_ASSOCIATE => { - handle_udp_associate(stream, upstream_url, blocklist_matcher).await; + handle_udp_associate(stream, upstream_url, blocklist_matcher, resolver).await; } other => { log::debug!("SOCKS5 unsupported command cmd={other:#04x}"); @@ -384,6 +388,7 @@ async fn handle_connect( upstream_url: Option, bypass_matcher: BypassMatcher, blocklist_matcher: BlocklistMatcher, + resolver: Option, ) { if blocklist_matcher.is_blocked(&host) { log::debug!("Blocklist blocked SOCKS5 CONNECT host={host}"); @@ -404,24 +409,36 @@ async fn handle_connect( // Resolve to the target stream, logging and dropping the (non-Send) dial // error inside the match arm so it is never held across the await below. - let target = - match connect_to_target_via_upstream(&host, port, upstream_url.as_deref(), &bypass_matcher) - .await - { - Ok(t) => Some(t), - Err(e) => { - crate::proxy_server::warn_upstream_failure( - "SOCKS5 CONNECT failed", - &e.to_string(), - Some(&host), - ); - None - } - }; + let target = match connect_to_target_via_upstream( + &host, + port, + upstream_url.as_deref(), + &bypass_matcher, + resolver.as_ref(), + ) + .await + { + Ok(t) => Ok(t), + Err(e) if crate::profile_dns::is_missing_name(e.as_ref()) => { + log::debug!("SOCKS5 CONNECT to a name the profile DNS server does not know host={host}"); + Err(REP_HOST_UNREACHABLE) + } + Err(e) => { + crate::proxy_server::warn_upstream_failure( + "SOCKS5 CONNECT failed", + &e.to_string(), + Some(&host), + ); + Err(REP_GENERAL_FAILURE) + } + }; - let Some(target) = target else { - let _ = send_reply(&mut stream, REP_GENERAL_FAILURE, unspecified()).await; - return; + let target = match target { + Ok(target) => target, + Err(reply) => { + let _ = send_reply(&mut stream, reply, unspecified()).await; + return; + } }; if send_reply(&mut stream, REP_SUCCEEDED, unspecified()) @@ -443,6 +460,7 @@ async fn handle_udp_associate( mut control: TcpStream, upstream_url: Option, blocklist_matcher: BlocklistMatcher, + resolver: Option, ) { let mode = udp_mode(upstream_url.as_deref()); @@ -495,7 +513,14 @@ async fn handle_udp_associate( return; } log::debug!("SOCKS5 UDP ASSOCIATE relaying mode=direct relay={relay_addr}"); - run_udp_relay_direct(control, relay, out, UdpRelayContext::new(blocklist_matcher)).await; + run_udp_relay_direct( + control, + relay, + out, + UdpRelayContext::new(blocklist_matcher), + resolver, + ) + .await; } UdpMode::Socks5Upstream => { // Establish the upstream association FIRST; if the upstream refuses UDP, @@ -524,6 +549,7 @@ async fn handle_udp_associate( relay, datagram, UdpRelayContext::new(blocklist_matcher), + resolver, ) .await; } @@ -640,6 +666,7 @@ async fn run_udp_relay_direct( relay: UdpSocket, out: UdpSocket, mut ctx: UdpRelayContext, + resolver: Option, ) { let mut client_addr: Option = None; let mut from_client = vec![0u8; UDP_BUF]; @@ -672,7 +699,7 @@ async fn run_udp_relay_direct( // Resolve after the blocklist check so a blocked host is never even // looked up, and count bytes against the resolved peer so replies from // it are attributed back to this host. - let dst = match resolve_addr(&header.dst).await { + let dst = match resolve_addr(&header.dst, resolver.as_ref()).await { Some(d) => d, None => continue, }; @@ -702,6 +729,7 @@ async fn run_udp_relay_socks5( relay: UdpSocket, datagram: SocksDatagram, mut ctx: UdpRelayContext, + resolver: Option, ) { let mut client_addr: Option = None; let mut from_client = vec![0u8; UDP_BUF]; @@ -729,9 +757,19 @@ async fn run_udp_relay_socks5( log::debug!("Blocklist blocked SOCKS5 UDP datagram host={host}"); continue; } - let peer_key = addrkind_key(&header.dst); + // With a profile DNS server the upstream gets the address, as on TCP. + let dst = match (header.dst, resolver.as_ref()) { + (AddrKind::Domain(domain, port), Some(resolver)) => { + match resolve_addr(&AddrKind::Domain(domain, port), Some(resolver)).await { + Some(address) => AddrKind::Ip(address), + None => continue, + } + } + (dst, _) => dst, + }; + let peer_key = addrkind_key(&dst); let payload = from_client[header.data_offset..n].to_vec(); - if datagram.send_to(&payload, header.dst).await.is_ok() { + if datagram.send_to(&payload, dst).await.is_ok() { ctx.record_sent(&host, peer_key, payload.len() as u64); } } @@ -753,11 +791,18 @@ async fn run_udp_relay_socks5( ctx.flush(); } -/// Resolve a UDP destination to a concrete socket address for direct relay. -async fn resolve_addr(addr: &AddrKind) -> Option { - match addr { - AddrKind::Ip(s) => Some(*s), - AddrKind::Domain(domain, port) => tokio::net::lookup_host(format!("{domain}:{port}")) +/// Resolve a UDP destination to a concrete socket address, with the profile +/// DNS server when there is one and the system resolver otherwise. +async fn resolve_addr(addr: &AddrKind, resolver: Option<&ProfileResolver>) -> Option { + match (addr, resolver) { + (AddrKind::Ip(s), _) => Some(*s), + (AddrKind::Domain(domain, port), Some(resolver)) => resolver + .resolve(domain) + .await + .ok()? + .first() + .map(|ip| SocketAddr::new(*ip, *port)), + (AddrKind::Domain(domain, port), None) => tokio::net::lookup_host(format!("{domain}:{port}")) .await .ok() .and_then(|mut it| it.next()), diff --git a/src-tauri/src/wayfern_identity_storage.rs b/src-tauri/src/wayfern_identity_storage.rs index 8aac8f4c..e2b00153 100644 --- a/src-tauri/src/wayfern_identity_storage.rs +++ b/src-tauri/src/wayfern_identity_storage.rs @@ -588,6 +588,7 @@ mod tests { created_by_id: None, created_by_email: None, dns_blocklist: None, + dns_server: None, password_protected: false, clear_on_close: false, created_at: None, diff --git a/src-tauri/tests/donut_proxy_integration.rs b/src-tauri/tests/donut_proxy_integration.rs index cfc16443..8b95b1fa 100644 --- a/src-tauri/tests/donut_proxy_integration.rs +++ b/src-tauri/tests/donut_proxy_integration.rs @@ -639,6 +639,7 @@ async fn test_xray_reality_chain_and_local_traffic_monitoring( Vec::new(), None, false, + None, Some("http".to_string()), true, ) @@ -1728,11 +1729,23 @@ async fn test_no_bypass_rules_all_through_upstream( /// Start a minimal SOCKS5 proxy that tunnels connections to the real destination. /// Returns (port, JoinHandle). async fn start_mock_socks5_server() -> (u16, tokio::task::JoinHandle<()>) { + let (port, _, handle) = start_recording_socks5_server().await; + (port, handle) +} + +type SeenTargets = std::sync::Arc>>; + +/// `start_mock_socks5_server`, also recording every requested target as +/// `host:port`, exactly as the client named it. +async fn start_recording_socks5_server() -> (u16, SeenTargets, tokio::task::JoinHandle<()>) { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); + let seen = SeenTargets::default(); + let recorder = seen.clone(); let handle = tokio::spawn(async move { while let Ok((mut client, _)) = listener.accept().await { + let recorder = recorder.clone(); tokio::spawn(async move { use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -1778,6 +1791,10 @@ async fn start_mock_socks5_server() -> (u16, tokio::task::JoinHandle<()>) { } _ => return, }; + recorder + .lock() + .unwrap() + .push(format!("{target_host}:{target_port}")); // Connect to target let target = @@ -1810,7 +1827,7 @@ async fn start_mock_socks5_server() -> (u16, tokio::task::JoinHandle<()>) { }); sleep(Duration::from_millis(100)).await; - (port, handle) + (port, seen, handle) } /// Test that a SOCKS5 upstream proxy works end-to-end through donut-proxy. @@ -2146,6 +2163,7 @@ async fn start_lifecycle_worker( Vec::new(), None, false, + None, Some("http".to_string()), true, ) @@ -2569,3 +2587,235 @@ async fn test_xray_cli_contract() -> Result<(), Box (u16, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = tokio::spawn(async move { + while let Ok((mut stream, _)) = listener.accept().await { + tokio::spawn(async move { + loop { + let mut len = [0u8; 2]; + if stream.read_exact(&mut len).await.is_err() { + return; + } + let mut query = vec![0u8; usize::from(u16::from_be_bytes(len))]; + if stream.read_exact(&mut query).await.is_err() { + return; + } + let mut end = 12; + while query[end] != 0 { + end += 1 + query[end] as usize; + } + let qtype = u16::from_be_bytes([query[end + 1], query[end + 2]]); + let mut reply = query[..end + 5].to_vec(); + reply[2] = 0x81; + reply[3] = 0x80; + reply[10..12].copy_from_slice(&[0, 0]); + if qtype == 1 { + reply[6..8].copy_from_slice(&1u16.to_be_bytes()); + reply.extend_from_slice(&[0xC0, 12, 0, 1, 0, 1, 0, 0, 0, 60, 0, 4, 127, 0, 0, 1]); + } + let mut framed = (reply.len() as u16).to_be_bytes().to_vec(); + framed.extend_from_slice(&reply); + if stream.write_all(&framed).await.is_err() { + return; + } + } + }); + } + }); + (port, handle) +} + +async fn start_worker_with_dns_server( + binary_path: &std::path::PathBuf, + socks_port: u16, + dns_server: &str, + local_protocol: &str, +) -> Result<(String, u16), Box> { + let output = TestUtils::execute_command( + binary_path, + &[ + "proxy", + "start", + "--host", + "127.0.0.1", + "--proxy-port", + &socks_port.to_string(), + "--type", + "socks5", + "--dns-server", + dns_server, + "--local-protocol", + local_protocol, + ], + ) + .await?; + if !output.status.success() { + return Err( + format!( + "Proxy start failed: {}", + String::from_utf8_lossy(&output.stderr) + ) + .into(), + ); + } + let config: Value = serde_json::from_str(&String::from_utf8(output.stdout)?)?; + let id = config["id"].as_str().unwrap().to_string(); + let port = config["localPort"].as_u64().unwrap() as u16; + if !wait_for_port_open(port, Duration::from_secs(5)).await { + return Err("worker did not start listening".into()); + } + Ok((id, port)) +} + +/// Everything the stream sends until it closes, or what arrived in 10s. +async fn read_until_closed(stream: &mut TcpStream) -> String { + let mut response = Vec::new(); + let _ = tokio::time::timeout(Duration::from_secs(10), stream.read_to_end(&mut response)).await; + String::from_utf8_lossy(&response).to_string() +} + +/// A profile DNS server resolves every destination, and both the query and +/// the connection go through the upstream: the upstream sees addresses only, +/// never the name. A server that does not answer fails the request closed. +#[tokio::test] +#[serial] +async fn test_profile_dns_server_resolves_through_the_upstream( +) -> Result<(), Box> { + let binary_path = setup_test().await?; + let mut tracker = ProxyTestTracker::new(binary_path.clone()); + let (target_port, target_handle) = start_mock_http_server("PROFILE-DNS-TARGET").await; + let (dns_port, dns_handle) = start_mock_dns_server().await; + let (socks_port, seen, socks_handle) = start_recording_socks5_server().await; + let name = "profile-dns.test"; + let dns_server = format!("tcp://127.0.0.1:{dns_port}"); + let request = + format!("GET / HTTP/1.1\r\nHost: {name}:{target_port}\r\nConnection: close\r\n\r\n"); + + // HTTP front end, plain HTTP. + let (id, http_port) = + start_worker_with_dns_server(&binary_path, socks_port, &dns_server, "http").await?; + tracker.track_proxy(id); + let mut stream = TcpStream::connect(("127.0.0.1", http_port)).await?; + stream + .write_all( + format!( + "GET http://{name}:{target_port}/ HTTP/1.1\r\nHost: {name}:{target_port}\r\nConnection: close\r\n\r\n" + ) + .as_bytes(), + ) + .await?; + let response = read_until_closed(&mut stream).await; + assert!( + response.contains("PROFILE-DNS-TARGET"), + "plain HTTP must reach the resolved address, got: {response}" + ); + + // HTTP front end, CONNECT. + let mut stream = TcpStream::connect(("127.0.0.1", http_port)).await?; + stream + .write_all( + format!("CONNECT {name}:{target_port} HTTP/1.1\r\nHost: {name}:{target_port}\r\n\r\n") + .as_bytes(), + ) + .await?; + let mut established = [0u8; 39]; + stream.read_exact(&mut established).await?; + assert!(String::from_utf8_lossy(&established).starts_with("HTTP/1.1 200")); + stream.write_all(request.as_bytes()).await?; + let response = read_until_closed(&mut stream).await; + assert!( + response.contains("PROFILE-DNS-TARGET"), + "CONNECT must reach the resolved address, got: {response}" + ); + + // SOCKS5 front end, as Wayfern uses it. + let (id, socks5_local_port) = + start_worker_with_dns_server(&binary_path, socks_port, &dns_server, "socks5").await?; + tracker.track_proxy(id); + let mut stream = TcpStream::connect(("127.0.0.1", socks5_local_port)).await?; + stream.write_all(&[0x05, 0x01, 0x00]).await?; + let mut method = [0u8; 2]; + stream.read_exact(&mut method).await?; + assert_eq!(method, [0x05, 0x00]); + let mut connect = vec![0x05, 0x01, 0x00, 0x03, name.len() as u8]; + connect.extend_from_slice(name.as_bytes()); + connect.extend_from_slice(&target_port.to_be_bytes()); + stream.write_all(&connect).await?; + let mut reply = [0u8; 10]; + stream.read_exact(&mut reply).await?; + assert_eq!(reply[1], 0x00, "SOCKS5 CONNECT to the name must succeed"); + stream.write_all(request.as_bytes()).await?; + let response = read_until_closed(&mut stream).await; + assert!( + response.contains("PROFILE-DNS-TARGET"), + "SOCKS5 CONNECT must reach the resolved address, got: {response}" + ); + + let targets = seen.lock().unwrap().clone(); + assert!( + targets.contains(&format!("127.0.0.1:{dns_port}")), + "the DNS query must travel through the upstream: {targets:?}" + ); + assert!( + targets.contains(&format!("127.0.0.1:{target_port}")), + "the upstream must be asked for the resolved address: {targets:?}" + ); + assert!( + !targets.iter().any(|target| target.contains(name)), + "the upstream must never see the name: {targets:?}" + ); + + // A DNS server that does not answer: no fallback to the upstream's resolver. + let closed = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let closed_port = closed.local_addr()?.port(); + drop(closed); + let (id, failing_port) = start_worker_with_dns_server( + &binary_path, + socks_port, + &format!("tcp://127.0.0.1:{closed_port}"), + "http", + ) + .await?; + tracker.track_proxy(id); + let mut stream = TcpStream::connect(("127.0.0.1", failing_port)).await?; + stream + .write_all( + format!( + "GET http://{name}:{target_port}/ HTTP/1.1\r\nHost: {name}:{target_port}\r\nConnection: close\r\n\r\n" + ) + .as_bytes(), + ) + .await?; + let response = read_until_closed(&mut stream).await; + assert!( + response.starts_with("HTTP/1.1 502") && !response.contains("PROFILE-DNS-TARGET"), + "a dead DNS server must fail the request, got: {response}" + ); + assert!( + !seen + .lock() + .unwrap() + .iter() + .any(|target| target.contains(name)), + "a dead DNS server must not hand the name to the upstream" + ); + + // An unusable value is refused before a worker starts. + let output = TestUtils::execute_command( + &binary_path, + &["proxy", "start", "--dns-server", "udp://1.1.1.1"], + ) + .await?; + assert!(!output.status.success(), "udp:// must be rejected"); + + tracker.cleanup_all().await; + target_handle.abort(); + dns_handle.abort(); + socks_handle.abort(); + Ok(()) +} diff --git a/src/app/page.tsx b/src/app/page.tsx index b4995829..a52fc2e5 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1152,6 +1152,7 @@ export default function Home() { extensionGroupId?: string; ephemeral?: boolean; dnsBlocklist?: string; + dnsServer?: string; launchHook?: string; password?: string; }) => { @@ -1173,6 +1174,7 @@ export default function Home() { : undefined), ephemeral: profileData.ephemeral, dnsBlocklist: profileData.dnsBlocklist, + dnsServer: profileData.dnsServer, launchHook: profileData.launchHook, }, ); diff --git a/src/components/create-profile-dialog.tsx b/src/components/create-profile-dialog.tsx index 7603808a..40bf0ecb 100644 --- a/src/components/create-profile-dialog.tsx +++ b/src/components/create-profile-dialog.tsx @@ -19,6 +19,7 @@ import { LuShieldCheck, LuTriangleAlert, } from "react-icons/lu"; +import { DnsServerField } from "@/components/dns-server-field"; import { LoadingButton } from "@/components/loading-button"; import { ProfileGlyph } from "@/components/profile-glyph"; import { ProxyFormDialog } from "@/components/proxy-form-dialog"; @@ -111,6 +112,7 @@ interface CreateProfileDialogProps { extensionGroupId?: string; ephemeral?: boolean; dnsBlocklist?: string; + dnsServer?: string; launchHook?: string; password?: string; }) => Promise; @@ -142,6 +144,8 @@ export function CreateProfileDialog({ const [osPopoverOpen, setOsPopoverOpen] = useState(false); const [showProxyForm, setShowProxyForm] = useState(false); const [dnsBlocklist, setDnsBlocklist] = useState(""); + const [dnsServer, setDnsServer] = useState(""); + const [dnsServerValid, setDnsServerValid] = useState(true); const [launchHook, setLaunchHook] = useState(""); const [wayfernConfig, setWayfernConfig] = useState(defaultWayfernConfig); @@ -269,7 +273,8 @@ export function CreateProfileDialog({ !creatableVersion || downloading || isCreating || - hourlyCapReached; + hourlyCapReached || + !dnsServerValid; const selectedVpn = selectedProxyId?.startsWith("vpn-") ? vpnConfigs.find((vpn) => vpn.id === selectedProxyId.slice(4)) : undefined; @@ -300,6 +305,7 @@ export function CreateProfileDialog({ setOsPopoverOpen(false); setShowProxyForm(false); setDnsBlocklist(""); + setDnsServer(""); setLaunchHook(""); setSelectedExtensionGroupId(undefined); setReleaseTypes({}); @@ -344,6 +350,7 @@ export function CreateProfileDialog({ extensionGroupId: selectedExtensionGroupId, ephemeral, dnsBlocklist: dnsBlocklist || undefined, + dnsServer: dnsServer.trim() || undefined, launchHook: launchHook.trim() || undefined, password: enablePassword && !ephemeral ? password : undefined, }); @@ -984,6 +991,21 @@ export function CreateProfileDialog({ )} +
+ + +
{creatableVersion && (

diff --git a/src/components/dns-server-field.tsx b/src/components/dns-server-field.tsx new file mode 100644 index 00000000..5b78a1d9 --- /dev/null +++ b/src/components/dns-server-field.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { invoke } from "@tauri-apps/api/core"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { LuLock, LuLockOpen, LuTriangleAlert } from "react-icons/lu"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { translateBackendError } from "@/lib/backend-errors"; +import { cn } from "@/lib/utils"; + +/** `DnsServerSummary` in `src-tauri/src/profile_dns.rs`. */ +interface DnsServerSummary { + transport: "https" | "tls" | "tcp"; + server: string; + encrypted: boolean; + upgraded: boolean; +} + +type DnsServerState = + | { kind: "default" } + | { kind: "valid"; summary: DnsServerSummary } + | { kind: "invalid"; message: string }; + +/** + * Encrypted resolvers that keep no query logs, offered as one click each. + * Provider names, not copy: they are the same in every language. + */ +const DNS_SERVER_PRESETS = [ + { name: "Quad9", value: "https://dns.quad9.net/dns-query" }, + { name: "Mullvad", value: "https://dns.mullvad.net/dns-query" }, + { name: "Cloudflare", value: "https://cloudflare-dns.com/dns-query" }, +] as const; + +/** The backend's reading of `value`; `null` until the first answer. */ +function useDnsServerState(value: string): DnsServerState | null { + const { t } = useTranslation(); + const [state, setState] = React.useState(null); + + React.useEffect(() => { + let current = true; + const timer = window.setTimeout( + () => { + invoke("describe_dns_server", { value }) + .then((summary) => { + if (!current) return; + setState( + summary ? { kind: "valid", summary } : { kind: "default" }, + ); + }) + .catch((error: unknown) => { + if (!current) return; + setState({ + kind: "invalid", + message: translateBackendError(t, error), + }); + }); + }, + value.trim() ? 200 : 0, + ); + return () => { + current = false; + window.clearTimeout(timer); + }; + }, [value, t]); + + return state; +} + +interface DnsServerFieldProps { + id: string; + value: string; + onChange: (value: string) => void; + /** False while the value cannot be saved. */ + onValidityChange?: (valid: boolean) => void; + disabled?: boolean; + /** Rendered after the input, for example a save button. */ + action?: React.ReactNode; +} + +export function DnsServerField({ + id, + value, + onChange, + onValidityChange, + disabled, + action, +}: DnsServerFieldProps) { + const { t } = useTranslation(); + const statusId = React.useId(); + const state = useDnsServerState(value); + const invalid = state?.kind === "invalid"; + + React.useEffect(() => { + onValidityChange?.(!invalid); + }, [invalid, onValidityChange]); + + let tone = "text-muted-foreground"; + let icon: React.ReactNode = null; + let message = ""; + if (state?.kind === "default") { + message = t("dnsServer.summary.default"); + } else if (state?.kind === "invalid") { + tone = "text-destructive-text"; + icon = ; + message = state.message; + } else if (state?.kind === "valid") { + const { summary } = state; + if (summary.encrypted) { + icon = ; + message = t( + summary.transport === "https" + ? "dnsServer.summary.https" + : "dnsServer.summary.tls", + { server: summary.server }, + ); + if (summary.upgraded) { + message = `${message} ${t("dnsServer.summary.upgraded")}`; + } + } else { + tone = "text-warning-text"; + icon = ; + message = t("dnsServer.summary.tcp", { server: summary.server }); + } + } + + return ( +

+
+ onChange(event.target.value)} + placeholder={t("dnsServer.placeholder")} + disabled={disabled} + spellCheck={false} + autoCapitalize="off" + autoCorrect="off" + autoComplete="off" + aria-invalid={invalid || undefined} + aria-describedby={statusId} + /> + {action} +
+

+ {icon} + {message} +

+
+ + {t("dnsServer.presets")} + + {DNS_SERVER_PRESETS.map((preset) => ( + + ))} +
+
+ ); +} diff --git a/src/components/profile-info-dialog.tsx b/src/components/profile-info-dialog.tsx index 5af3836c..8d4d5d21 100644 --- a/src/components/profile-info-dialog.tsx +++ b/src/components/profile-info-dialog.tsx @@ -39,6 +39,7 @@ import { LuUsers, LuX, } from "react-icons/lu"; +import { DnsServerField } from "@/components/dns-server-field"; import { ProfileHandoffStatus } from "@/components/profile-handoff-status"; import { ProfileLaunchActivity } from "@/components/profile-launch-activity"; import { ProfileMetadataCard } from "@/components/profile-metadata-card"; @@ -1750,12 +1751,45 @@ function NetworkSectionInline({ const [vpnId, setVpnId] = React.useState( profile.vpn_id ?? null, ); + const dnsServerId = React.useId(); + const [savedDnsServer, setSavedDnsServer] = React.useState( + profile.dns_server ?? "", + ); + const [dnsServer, setDnsServer] = React.useState(profile.dns_server ?? ""); + const [dnsServerValid, setDnsServerValid] = React.useState(true); + const dnsServerChanged = dnsServer.trim() !== savedDnsServer; React.useEffect(() => { setProxyId(profile.proxy_id ?? null); setVpnId(profile.vpn_id ?? null); }, [profile.proxy_id, profile.vpn_id]); + React.useEffect(() => { + setSavedDnsServer(profile.dns_server ?? ""); + setDnsServer(profile.dns_server ?? ""); + }, [profile.dns_server]); + + const onDnsServerSave = async () => { + if (!dnsServerChanged || !dnsServerValid) return; + setIsSaving(true); + setError(null); + try { + const updated = await invoke( + "update_profile_dns_server", + { + profileId: profile.id, + dnsServer: dnsServer.trim() || null, + }, + ); + setSavedDnsServer(updated.dns_server ?? ""); + setDnsServer(updated.dns_server ?? ""); + } catch (e) { + setError(translateBackendError(t as never, e)); + } finally { + setIsSaving(false); + } + }; + const onProxyChange = async (value: string) => { const nextId = value === "__none__" ? null : value; setIsSaving(true); @@ -1858,6 +1892,41 @@ function NetworkSectionInline({
+
{ + event.preventDefault(); + void onDnsServerSave(); + }} + > + +
+ + {t("common.buttons.save")} + + ) + } + /> +
+
+ {error &&

{error}

} ); diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index c4994712..47480b5b 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1442,7 +1442,8 @@ "cookieCount": "Gespeicherte Cookies", "localDataTransfer": "Lokale Datenübertragung", "created": "Erstellt", - "windowColor": "Fensterfarbe" + "windowColor": "Fensterfarbe", + "dns": "DNS" }, "values": { "none": "Keine", @@ -1501,7 +1502,7 @@ }, "sectionDesc": { "fingerprint": "Legen Sie fest, wie dieses Profil für Fingerprinting-Skripte erscheint.", - "network": "Verwalten Sie den Proxy oder das VPN, über das dieses Profil ins Internet geht.", + "network": "Verwalten Sie den Proxy oder das VPN, über das dieses Profil ins Internet geht, und den DNS-Server, mit dem es Namen auflöst.", "cookies": "Cookies für dieses Profil importieren, kopieren oder löschen.", "extensions": "Legen Sie fest, welche Erweiterungen beim Start dieses Profils geladen werden.", "sync": "Legen Sie fest, wie dieses Profil auf Ihre anderen Geräte gespiegelt wird.", @@ -1705,6 +1706,18 @@ "custom.allowedOnlyLabel": "Erlaubte Domains (nur diese)", "custom.allowedOnlyHint": "Subdomains einer aufgeführten Domain sind ebenfalls erlaubt. Eine leere Liste deaktiviert die Filterung." }, + "dnsServer": { + "label": "DNS-Server", + "placeholder": "DNS-Adresse eingeben oder leer lassen", + "presets": "Verschlüsselte Vorlagen:", + "summary": { + "default": "Der Proxy löst Namen auf. Legen Sie einen Server fest, wenn der Proxy manche Websites nicht auflösen kann.", + "https": "Verschlüsselt mit DNS over HTTPS ({{server}}). Anfragen laufen über den Proxy oder das VPN dieses Profils. Antwortet der Server nicht, laden keine Seiten.", + "tls": "Verschlüsselt mit DNS over TLS ({{server}}). Anfragen laufen über den Proxy oder das VPN dieses Profils. Antwortet der Server nicht, laden keine Seiten.", + "tcp": "Nicht verschlüsselt: einfaches DNS an {{server}}. Der Proxy kann die Antworten mitlesen und ändern. Antwortet der Server nicht, laden keine Seiten.", + "upgraded": "Diese Adresse bietet auch verschlüsseltes DNS, daher verwendet Donut es. Beginnen Sie mit tcp://, um einfaches DNS zu behalten." + } + }, "vpns": { "form": { "titleEdit": "VPN bearbeiten", @@ -2427,7 +2440,8 @@ "feedbackInvalid": "Das Feedback wurde nicht angenommen. Prüfen Sie die Nachricht und versuchen Sie es erneut.", "feedbackRateLimited": "Sie haben in kurzer Zeit viel Feedback gesendet. Versuchen Sie es in ein paar Minuten erneut.", "feedbackSendFailed": "Das Feedback konnte nicht gesendet werden. Versuchen Sie es später erneut.", - "feedbackLogsUnreadable": "Die Protokolle konnten nicht gelesen werden. Schalten Sie „Aktuelle Protokolle anhängen“ aus und senden Sie erneut." + "feedbackLogsUnreadable": "Die Protokolle konnten nicht gelesen werden. Schalten Sie „Aktuelle Protokolle anhängen“ aus und senden Sie erneut.", + "dnsServerInvalid": "Dieser DNS-Server ist ungültig. Geben Sie eine IP-Adresse oder einen Hostnamen ein, tls://host für DNS over TLS oder eine https://-URL für DNS over HTTPS." }, "rail": { "profiles": "Profile", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 0c648c78..45c10861 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1442,7 +1442,8 @@ "cookieCount": "Cookies stored", "localDataTransfer": "Local data transfer", "created": "Created", - "windowColor": "Window color" + "windowColor": "Window color", + "dns": "DNS" }, "values": { "none": "None", @@ -1501,7 +1502,7 @@ }, "sectionDesc": { "fingerprint": "Configure how this profile appears to fingerprinting scripts.", - "network": "Manage the proxy or VPN this profile uses to reach the internet.", + "network": "Manage the proxy or VPN this profile uses to reach the internet, and the DNS server it resolves names with.", "cookies": "Import, copy, or wipe cookies for this profile.", "extensions": "Choose which extensions load when this profile launches.", "sync": "Configure how this profile is mirrored to your other devices.", @@ -1705,6 +1706,18 @@ "custom.allowedOnlyLabel": "Allowed domains (only these)", "custom.allowedOnlyHint": "Subdomains of a listed domain are allowed too. An empty list disables filtering." }, + "dnsServer": { + "label": "DNS server", + "placeholder": "Enter a DNS address or leave blank", + "presets": "Encrypted presets:", + "summary": { + "default": "The proxy resolves names. Set a server if the proxy cannot resolve some sites.", + "https": "Encrypted with DNS over HTTPS ({{server}}). Queries go through this profile's proxy or VPN. If the server does not answer, pages do not load.", + "tls": "Encrypted with DNS over TLS ({{server}}). Queries go through this profile's proxy or VPN. If the server does not answer, pages do not load.", + "tcp": "Not encrypted: plain DNS to {{server}}. The proxy can read and change the answers. If the server does not answer, pages do not load.", + "upgraded": "This address also serves encrypted DNS, so Donut uses it. Start with tcp:// to keep plain DNS." + } + }, "vpns": { "form": { "titleEdit": "Edit VPN", @@ -2427,7 +2440,8 @@ "feedbackInvalid": "The feedback was not accepted. Check the message and try again.", "feedbackRateLimited": "You sent a lot of feedback in a short time. Try again in a few minutes.", "feedbackSendFailed": "The feedback could not be sent. Try again later.", - "feedbackLogsUnreadable": "The logs could not be read. Turn off \"Include recent logs\" and send again." + "feedbackLogsUnreadable": "The logs could not be read. Turn off \"Include recent logs\" and send again.", + "dnsServerInvalid": "This DNS server is not valid. Enter an IP address or host name, tls://host for DNS over TLS, or an https:// URL for DNS over HTTPS." }, "rail": { "profiles": "Profiles", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index bf555a21..04034c18 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1448,7 +1448,8 @@ "cookieCount": "Cookies guardadas", "localDataTransfer": "Transferencia de datos local", "created": "Creado", - "windowColor": "Color de ventana" + "windowColor": "Color de ventana", + "dns": "DNS" }, "values": { "none": "Ninguno", @@ -1507,7 +1508,7 @@ }, "sectionDesc": { "fingerprint": "Configura cómo aparece este perfil para los scripts de fingerprinting.", - "network": "Administra el proxy o la VPN que usa este perfil.", + "network": "Administra el proxy o la VPN que usa este perfil y el servidor DNS con el que resuelve los nombres.", "cookies": "Importa, copia o borra cookies de este perfil.", "extensions": "Elige las extensiones que se cargan con este perfil.", "sync": "Configura cómo se replica este perfil entre tus dispositivos.", @@ -1711,6 +1712,18 @@ "custom.allowedOnlyLabel": "Dominios permitidos (solo estos)", "custom.allowedOnlyHint": "También se permiten los subdominios de un dominio de la lista. Una lista vacía desactiva el filtrado." }, + "dnsServer": { + "label": "Servidor DNS", + "placeholder": "Introduce una dirección DNS o déjalo en blanco", + "presets": "Opciones cifradas:", + "summary": { + "default": "El proxy resuelve los nombres. Indica un servidor si el proxy no puede resolver algunos sitios.", + "https": "Cifrado con DNS sobre HTTPS ({{server}}). Las consultas pasan por el proxy o la VPN de este perfil. Si el servidor no responde, las páginas no cargan.", + "tls": "Cifrado con DNS sobre TLS ({{server}}). Las consultas pasan por el proxy o la VPN de este perfil. Si el servidor no responde, las páginas no cargan.", + "tcp": "Sin cifrar: DNS simple a {{server}}. El proxy puede leer y cambiar las respuestas. Si el servidor no responde, las páginas no cargan.", + "upgraded": "Esta dirección también ofrece DNS cifrado, así que Donut lo usa. Empieza con tcp:// para mantener DNS simple." + } + }, "vpns": { "form": { "titleEdit": "Editar VPN", @@ -2437,7 +2450,8 @@ "feedbackInvalid": "Los comentarios no se aceptaron. Revisa el mensaje e inténtalo de nuevo.", "feedbackRateLimited": "Enviaste muchos comentarios en poco tiempo. Inténtalo de nuevo en unos minutos.", "feedbackSendFailed": "No se pudieron enviar los comentarios. Inténtalo más tarde.", - "feedbackLogsUnreadable": "No se pudieron leer los registros. Desactiva «Incluir registros recientes» y envía de nuevo." + "feedbackLogsUnreadable": "No se pudieron leer los registros. Desactiva «Incluir registros recientes» y envía de nuevo.", + "dnsServerInvalid": "Este servidor DNS no es válido. Introduce una dirección IP o un nombre de host, tls://host para DNS sobre TLS o una URL https:// para DNS sobre HTTPS." }, "rail": { "profiles": "Perfiles", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index d7403e86..543eaea5 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1448,7 +1448,8 @@ "cookieCount": "Cookies stockés", "localDataTransfer": "Transfert de données local", "created": "Créé le", - "windowColor": "Couleur de la fenêtre" + "windowColor": "Couleur de la fenêtre", + "dns": "DNS" }, "values": { "none": "Aucun", @@ -1507,7 +1508,7 @@ }, "sectionDesc": { "fingerprint": "Configurez l’apparence de ce profil pour les scripts de fingerprinting.", - "network": "Gérez le proxy ou le VPN utilisé par ce profil.", + "network": "Gérez le proxy ou le VPN utilisé par ce profil, ainsi que le serveur DNS qui résout ses noms.", "cookies": "Importer, copier ou effacer les cookies de ce profil.", "extensions": "Choisissez les extensions à charger avec ce profil.", "sync": "Configurez la réplication de ce profil entre vos appareils.", @@ -1711,6 +1712,18 @@ "custom.allowedOnlyLabel": "Domaines autorisés (uniquement ceux-ci)", "custom.allowedOnlyHint": "Les sous-domaines d'un domaine listé sont aussi autorisés. Une liste vide désactive le filtrage." }, + "dnsServer": { + "label": "Serveur DNS", + "placeholder": "Saisissez une adresse DNS ou laissez vide", + "presets": "Préréglages chiffrés :", + "summary": { + "default": "Le proxy résout les noms. Définissez un serveur si le proxy ne parvient pas à résoudre certains sites.", + "https": "Chiffré avec DNS over HTTPS ({{server}}). Les requêtes passent par le proxy ou le VPN de ce profil. Si le serveur ne répond pas, les pages ne se chargent pas.", + "tls": "Chiffré avec DNS over TLS ({{server}}). Les requêtes passent par le proxy ou le VPN de ce profil. Si le serveur ne répond pas, les pages ne se chargent pas.", + "tcp": "Non chiffré : DNS simple vers {{server}}. Le proxy peut lire et modifier les réponses. Si le serveur ne répond pas, les pages ne se chargent pas.", + "upgraded": "Cette adresse propose aussi un DNS chiffré, Donut l'utilise donc. Commencez par tcp:// pour garder un DNS simple." + } + }, "vpns": { "form": { "titleEdit": "Modifier le VPN", @@ -2437,7 +2450,8 @@ "feedbackInvalid": "L'avis n'a pas été accepté. Vérifiez le message et réessayez.", "feedbackRateLimited": "Vous avez envoyé beaucoup d'avis en peu de temps. Réessayez dans quelques minutes.", "feedbackSendFailed": "L'avis n'a pas pu être envoyé. Réessayez plus tard.", - "feedbackLogsUnreadable": "Les journaux n'ont pas pu être lus. Désactivez « Joindre les journaux récents » et renvoyez." + "feedbackLogsUnreadable": "Les journaux n'ont pas pu être lus. Désactivez « Joindre les journaux récents » et renvoyez.", + "dnsServerInvalid": "Ce serveur DNS n'est pas valide. Saisissez une adresse IP ou un nom d'hôte, tls://hôte pour DNS over TLS, ou une URL https:// pour DNS over HTTPS." }, "rail": { "profiles": "Profils", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 31e8d34b..75e41690 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1442,7 +1442,8 @@ "cookieCount": "保存された Cookie", "localDataTransfer": "ローカルデータ転送量", "created": "作成日", - "windowColor": "ウィンドウの色" + "windowColor": "ウィンドウの色", + "dns": "DNS" }, "values": { "none": "なし", @@ -1501,7 +1502,7 @@ }, "sectionDesc": { "fingerprint": "フィンガープリント対策スクリプトに対するこのプロファイルの表示を設定します。", - "network": "このプロファイルが使用するプロキシまたは VPN を管理します。", + "network": "このプロファイルが使用するプロキシまたは VPN と、名前解決に使う DNS サーバーを管理します。", "cookies": "このプロファイルの Cookie をインポート、コピー、消去します。", "extensions": "このプロファイル起動時に読み込む拡張機能を選択します。", "sync": "他のデバイス間でのこのプロファイルの同期方法を設定します。", @@ -1705,6 +1706,18 @@ "custom.allowedOnlyLabel": "許可するドメイン(これらのみ)", "custom.allowedOnlyHint": "リスト内ドメインのサブドメインも許可されます。リストが空の場合はフィルタリングが無効になります。" }, + "dnsServer": { + "label": "DNS サーバー", + "placeholder": "DNS アドレスを入力するか、空欄のままにします", + "presets": "暗号化プリセット:", + "summary": { + "default": "プロキシが名前を解決します。プロキシで解決できないサイトがある場合は、サーバーを設定してください。", + "https": "DNS over HTTPS で暗号化されています({{server}})。クエリはこのプロファイルのプロキシまたは VPN を経由します。サーバーが応答しない場合、ページは読み込まれません。", + "tls": "DNS over TLS で暗号化されています({{server}})。クエリはこのプロファイルのプロキシまたは VPN を経由します。サーバーが応答しない場合、ページは読み込まれません。", + "tcp": "暗号化されていません: {{server}} への通常の DNS です。プロキシは応答を読み取り、変更できます。サーバーが応答しない場合、ページは読み込まれません。", + "upgraded": "このアドレスは暗号化 DNS にも対応しているため、Donut はそれを使用します。通常の DNS を使うには先頭に tcp:// を付けてください。" + } + }, "vpns": { "form": { "titleEdit": "VPN を編集", @@ -2427,7 +2440,8 @@ "feedbackInvalid": "フィードバックを受け付けられませんでした。メッセージを確認して、もう一度お試しください。", "feedbackRateLimited": "短時間に多くのフィードバックが送信されました。数分後にもう一度お試しください。", "feedbackSendFailed": "フィードバックを送信できませんでした。しばらくしてからもう一度お試しください。", - "feedbackLogsUnreadable": "ログを読み込めませんでした。「最近のログを添付」をオフにして、もう一度送信してください。" + "feedbackLogsUnreadable": "ログを読み込めませんでした。「最近のログを添付」をオフにして、もう一度送信してください。", + "dnsServerInvalid": "この DNS サーバーは無効です。IP アドレスまたはホスト名、DNS over TLS の場合は tls://host、DNS over HTTPS の場合は https:// URL を入力してください。" }, "rail": { "profiles": "プロファイル", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index ac60ff32..18e8e9bd 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -1442,7 +1442,8 @@ "cookieCount": "저장된 쿠키", "localDataTransfer": "로컬 데이터 전송", "created": "생성일", - "windowColor": "창 색상" + "windowColor": "창 색상", + "dns": "DNS" }, "values": { "none": "없음", @@ -1501,7 +1502,7 @@ }, "sectionDesc": { "fingerprint": "이 프로필이 핑거프린팅 스크립트에 어떻게 표시되는지 구성합니다.", - "network": "이 프로필이 인터넷에 도달하는 데 사용하는 프록시 또는 VPN을 관리합니다.", + "network": "이 프로필이 인터넷에 도달하는 데 사용하는 프록시 또는 VPN과 이름 확인에 사용하는 DNS 서버를 관리합니다.", "cookies": "이 프로필의 쿠키를 가져오거나, 복사하거나, 지웁니다.", "extensions": "이 프로필이 실행될 때 어떤 확장 프로그램이 로드될지 선택합니다.", "sync": "이 프로필이 다른 기기에 어떻게 미러링되는지 구성합니다.", @@ -1705,6 +1706,18 @@ "custom.allowedOnlyLabel": "허용 도메인 (이것만)", "custom.allowedOnlyHint": "목록에 있는 도메인의 하위 도메인도 허용됩니다. 목록이 비어 있으면 필터링이 비활성화됩니다." }, + "dnsServer": { + "label": "DNS 서버", + "placeholder": "DNS 주소를 입력하거나 비워 두세요", + "presets": "암호화 프리셋:", + "summary": { + "default": "프록시가 이름을 확인합니다. 프록시가 일부 사이트를 확인하지 못하면 서버를 설정하세요.", + "https": "DNS over HTTPS로 암호화됨 ({{server}}). 쿼리는 이 프로필의 프록시 또는 VPN을 거칩니다. 서버가 응답하지 않으면 페이지가 로드되지 않습니다.", + "tls": "DNS over TLS로 암호화됨 ({{server}}). 쿼리는 이 프로필의 프록시 또는 VPN을 거칩니다. 서버가 응답하지 않으면 페이지가 로드되지 않습니다.", + "tcp": "암호화되지 않음: {{server}}(으)로의 일반 DNS입니다. 프록시가 응답을 읽고 변경할 수 있습니다. 서버가 응답하지 않으면 페이지가 로드되지 않습니다.", + "upgraded": "이 주소는 암호화된 DNS도 제공하므로 Donut이 이를 사용합니다. 일반 DNS를 유지하려면 tcp://로 시작하세요." + } + }, "vpns": { "form": { "titleEdit": "VPN 편집", @@ -2427,7 +2440,8 @@ "feedbackInvalid": "피드백이 접수되지 않았습니다. 메시지를 확인하고 다시 시도하세요.", "feedbackRateLimited": "짧은 시간에 피드백을 너무 많이 보냈습니다. 몇 분 후에 다시 시도하세요.", "feedbackSendFailed": "피드백을 보낼 수 없습니다. 나중에 다시 시도하세요.", - "feedbackLogsUnreadable": "로그를 읽을 수 없습니다. '최근 로그 첨부'를 끄고 다시 보내세요." + "feedbackLogsUnreadable": "로그를 읽을 수 없습니다. '최근 로그 첨부'를 끄고 다시 보내세요.", + "dnsServerInvalid": "유효하지 않은 DNS 서버입니다. IP 주소 또는 호스트 이름, DNS over TLS의 경우 tls://host, DNS over HTTPS의 경우 https:// URL을 입력하세요." }, "rail": { "profiles": "프로필", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 052742e7..3d1ec36c 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1448,7 +1448,8 @@ "cookieCount": "Cookies armazenados", "localDataTransfer": "Transferência de dados local", "created": "Criado em", - "windowColor": "Cor da janela" + "windowColor": "Cor da janela", + "dns": "DNS" }, "values": { "none": "Nenhum", @@ -1507,7 +1508,7 @@ }, "sectionDesc": { "fingerprint": "Configure como este perfil aparece para scripts de fingerprinting.", - "network": "Gerencie o proxy ou VPN usado por este perfil.", + "network": "Gerencie o proxy ou VPN usado por este perfil e o servidor DNS com que ele resolve nomes.", "cookies": "Importe, copie ou apague cookies deste perfil.", "extensions": "Escolha quais extensões carregar com este perfil.", "sync": "Configure como este perfil é espelhado entre seus dispositivos.", @@ -1711,6 +1712,18 @@ "custom.allowedOnlyLabel": "Domínios permitidos (apenas estes)", "custom.allowedOnlyHint": "Subdomínios de um domínio listado também são permitidos. Uma lista vazia desativa a filtragem." }, + "dnsServer": { + "label": "Servidor DNS", + "placeholder": "Digite um endereço DNS ou deixe em branco", + "presets": "Predefinições criptografadas:", + "summary": { + "default": "O proxy resolve os nomes. Defina um servidor se o proxy não conseguir resolver alguns sites.", + "https": "Criptografado com DNS sobre HTTPS ({{server}}). As consultas passam pelo proxy ou VPN deste perfil. Se o servidor não responder, as páginas não carregam.", + "tls": "Criptografado com DNS sobre TLS ({{server}}). As consultas passam pelo proxy ou VPN deste perfil. Se o servidor não responder, as páginas não carregam.", + "tcp": "Sem criptografia: DNS simples para {{server}}. O proxy pode ler e alterar as respostas. Se o servidor não responder, as páginas não carregam.", + "upgraded": "Este endereço também oferece DNS criptografado, então o Donut o usa. Comece com tcp:// para manter o DNS simples." + } + }, "vpns": { "form": { "titleEdit": "Editar VPN", @@ -2437,7 +2450,8 @@ "feedbackInvalid": "O feedback não foi aceito. Verifique a mensagem e tente novamente.", "feedbackRateLimited": "Você enviou muito feedback em pouco tempo. Tente novamente em alguns minutos.", "feedbackSendFailed": "Não foi possível enviar o feedback. Tente novamente mais tarde.", - "feedbackLogsUnreadable": "Não foi possível ler os logs. Desative \"Incluir logs recentes\" e envie novamente." + "feedbackLogsUnreadable": "Não foi possível ler os logs. Desative \"Incluir logs recentes\" e envie novamente.", + "dnsServerInvalid": "Este servidor DNS não é válido. Digite um endereço IP ou nome de host, tls://host para DNS sobre TLS ou uma URL https:// para DNS sobre HTTPS." }, "rail": { "profiles": "Perfis", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 91499c09..dd4ff169 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1454,7 +1454,8 @@ "cookieCount": "Хранится Cookie", "localDataTransfer": "Локальный трафик", "created": "Создан", - "windowColor": "Цвет окна" + "windowColor": "Цвет окна", + "dns": "DNS" }, "values": { "none": "Нет", @@ -1513,7 +1514,7 @@ }, "sectionDesc": { "fingerprint": "Настройте, как этот профиль выглядит для скриптов отпечатков.", - "network": "Управляйте прокси или VPN, используемым этим профилем.", + "network": "Управляйте прокси или VPN, используемым этим профилем, и DNS-сервером, через который он разрешает имена.", "cookies": "Импортируйте, копируйте или удаляйте cookie этого профиля.", "extensions": "Выберите, какие расширения загружать с этим профилем.", "sync": "Настройте репликацию этого профиля между устройствами.", @@ -1717,6 +1718,18 @@ "custom.allowedOnlyLabel": "Разрешённые домены (только эти)", "custom.allowedOnlyHint": "Поддомены указанных доменов также разрешены. Пустой список отключает фильтрацию." }, + "dnsServer": { + "label": "DNS-сервер", + "placeholder": "Введите адрес DNS или оставьте пустым", + "presets": "Зашифрованные варианты:", + "summary": { + "default": "Имена разрешает прокси. Укажите сервер, если прокси не может разрешить некоторые сайты.", + "https": "Зашифровано: DNS over HTTPS ({{server}}). Запросы идут через прокси или VPN этого профиля. Если сервер не отвечает, страницы не загружаются.", + "tls": "Зашифровано: DNS over TLS ({{server}}). Запросы идут через прокси или VPN этого профиля. Если сервер не отвечает, страницы не загружаются.", + "tcp": "Без шифрования: обычный DNS к {{server}}. Прокси может читать и изменять ответы. Если сервер не отвечает, страницы не загружаются.", + "upgraded": "Этот адрес также поддерживает зашифрованный DNS, поэтому Donut использует его. Начните с tcp://, чтобы оставить обычный DNS." + } + }, "vpns": { "form": { "titleEdit": "Изменить VPN", @@ -2447,7 +2460,8 @@ "feedbackInvalid": "Отзыв не принят. Проверьте сообщение и попробуйте снова.", "feedbackRateLimited": "Вы отправили много отзывов за короткое время. Попробуйте снова через несколько минут.", "feedbackSendFailed": "Не удалось отправить отзыв. Попробуйте позже.", - "feedbackLogsUnreadable": "Не удалось прочитать логи. Выключите «Приложить последние логи» и отправьте снова." + "feedbackLogsUnreadable": "Не удалось прочитать логи. Выключите «Приложить последние логи» и отправьте снова.", + "dnsServerInvalid": "Недопустимый DNS-сервер. Введите IP-адрес или имя хоста, tls://хост для DNS over TLS или URL https:// для DNS over HTTPS." }, "rail": { "profiles": "Профили", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index d0d07fef..55bd4fdf 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -1442,7 +1442,8 @@ "cookieCount": "Saklanan çerezler", "localDataTransfer": "Yerel veri aktarımı", "created": "Oluşturulma", - "windowColor": "Pencere rengi" + "windowColor": "Pencere rengi", + "dns": "DNS" }, "values": { "none": "Yok", @@ -1501,7 +1502,7 @@ }, "sectionDesc": { "fingerprint": "Bu profilin parmak izi tarama betiklerine nasıl görüneceğini yapılandırın.", - "network": "Bu profilin internete erişmek için kullandığı proxy veya VPN'i yönetin.", + "network": "Bu profilin internete erişmek için kullandığı proxy veya VPN'i ve adları çözümlediği DNS sunucusunu yönetin.", "cookies": "Bu profil için çerezleri içe aktarın, kopyalayın veya silin.", "extensions": "Bu profil başlatıldığında hangi uzantıların yükleneceğini seçin.", "sync": "Bu profilin diğer cihazlarınıza nasıl yansıtılacağını yapılandırın.", @@ -1705,6 +1706,18 @@ "custom.allowedOnlyLabel": "İzin verilen alan adları (yalnızca bunlar)", "custom.allowedOnlyHint": "Listelenen bir alan adının alt alan adlarına da izin verilir. Boş liste filtrelemeyi devre dışı bırakır." }, + "dnsServer": { + "label": "DNS sunucusu", + "placeholder": "Bir DNS adresi girin veya boş bırakın", + "presets": "Şifreli hazır ayarlar:", + "summary": { + "default": "Adları proxy çözümler. Proxy bazı siteleri çözemiyorsa bir sunucu belirleyin.", + "https": "DNS over HTTPS ile şifreli ({{server}}). Sorgular bu profilin proxy'si veya VPN'i üzerinden gider. Sunucu yanıt vermezse sayfalar yüklenmez.", + "tls": "DNS over TLS ile şifreli ({{server}}). Sorgular bu profilin proxy'si veya VPN'i üzerinden gider. Sunucu yanıt vermezse sayfalar yüklenmez.", + "tcp": "Şifresiz: {{server}} adresine düz DNS. Proxy yanıtları okuyabilir ve değiştirebilir. Sunucu yanıt vermezse sayfalar yüklenmez.", + "upgraded": "Bu adres şifreli DNS de sunduğu için Donut onu kullanır. Düz DNS kullanmak için tcp:// ile başlayın." + } + }, "vpns": { "form": { "titleEdit": "VPN'i Düzenle", @@ -2427,7 +2440,8 @@ "feedbackInvalid": "Geri bildirim kabul edilmedi. Mesajı kontrol edip tekrar deneyin.", "feedbackRateLimited": "Kısa sürede çok fazla geri bildirim gönderdiniz. Birkaç dakika sonra tekrar deneyin.", "feedbackSendFailed": "Geri bildirim gönderilemedi. Daha sonra tekrar deneyin.", - "feedbackLogsUnreadable": "Günlükler okunamadı. \"Son günlükleri ekle\" seçeneğini kapatıp tekrar gönderin." + "feedbackLogsUnreadable": "Günlükler okunamadı. \"Son günlükleri ekle\" seçeneğini kapatıp tekrar gönderin.", + "dnsServerInvalid": "Bu DNS sunucusu geçerli değil. Bir IP adresi veya ana bilgisayar adı, DNS over TLS için tls://host ya da DNS over HTTPS için bir https:// URL'si girin." }, "rail": { "profiles": "Profiller", diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json index 6f9d059c..778f81f8 100644 --- a/src/i18n/locales/vi.json +++ b/src/i18n/locales/vi.json @@ -1442,7 +1442,8 @@ "cookieCount": "Cookie đã lưu", "localDataTransfer": "Truyền dữ liệu cục bộ", "created": "Đã tạo", - "windowColor": "Màu cửa sổ" + "windowColor": "Màu cửa sổ", + "dns": "DNS" }, "values": { "none": "Không có", @@ -1501,7 +1502,7 @@ }, "sectionDesc": { "fingerprint": "Cấu hình cách profile này hiển thị với các script vân tay.", - "network": "Quản lý proxy hoặc VPN mà profile này sử dụng để truy cập internet.", + "network": "Quản lý proxy hoặc VPN mà profile này sử dụng để truy cập internet và máy chủ DNS dùng để phân giải tên miền.", "cookies": "Nhập, sao chép hoặc xóa cookie cho profile này.", "extensions": "Chọn tiện ích nào được tải khi profile này khởi chạy.", "sync": "Cấu hình cách profile này được phản chiếu sang các thiết bị khác của bạn.", @@ -1705,6 +1706,18 @@ "custom.allowedOnlyLabel": "Tên miền được phép (chỉ những tên này)", "custom.allowedOnlyHint": "Tên miền phụ của tên miền trong danh sách cũng được phép. Danh sách trống sẽ tắt bộ lọc." }, + "dnsServer": { + "label": "Máy chủ DNS", + "placeholder": "Nhập địa chỉ DNS hoặc để trống", + "presets": "Cài đặt sẵn được mã hóa:", + "summary": { + "default": "Proxy phân giải tên miền. Đặt một máy chủ nếu proxy không phân giải được một số trang.", + "https": "Được mã hóa bằng DNS over HTTPS ({{server}}). Truy vấn đi qua proxy hoặc VPN của profile này. Nếu máy chủ không phản hồi, trang sẽ không tải.", + "tls": "Được mã hóa bằng DNS over TLS ({{server}}). Truy vấn đi qua proxy hoặc VPN của profile này. Nếu máy chủ không phản hồi, trang sẽ không tải.", + "tcp": "Không mã hóa: DNS thường tới {{server}}. Proxy có thể đọc và thay đổi câu trả lời. Nếu máy chủ không phản hồi, trang sẽ không tải.", + "upgraded": "Địa chỉ này cũng hỗ trợ DNS mã hóa nên Donut sử dụng nó. Bắt đầu bằng tcp:// để giữ DNS thường." + } + }, "vpns": { "form": { "titleEdit": "Chỉnh sửa VPN", @@ -2427,7 +2440,8 @@ "feedbackInvalid": "Phản hồi không được chấp nhận. Hãy kiểm tra tin nhắn và thử lại.", "feedbackRateLimited": "Bạn đã gửi nhiều phản hồi trong thời gian ngắn. Hãy thử lại sau vài phút.", "feedbackSendFailed": "Không thể gửi phản hồi. Hãy thử lại sau.", - "feedbackLogsUnreadable": "Không thể đọc nhật ký. Hãy tắt \"Đính kèm nhật ký gần đây\" rồi gửi lại." + "feedbackLogsUnreadable": "Không thể đọc nhật ký. Hãy tắt \"Đính kèm nhật ký gần đây\" rồi gửi lại.", + "dnsServerInvalid": "Máy chủ DNS này không hợp lệ. Nhập địa chỉ IP hoặc tên máy chủ, tls://host cho DNS over TLS, hoặc URL https:// cho DNS over HTTPS." }, "rail": { "profiles": "Profile", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index b1ae4c05..ba720688 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1442,7 +1442,8 @@ "cookieCount": "存储的 Cookie", "localDataTransfer": "本地数据传输", "created": "创建时间", - "windowColor": "窗口颜色" + "windowColor": "窗口颜色", + "dns": "DNS" }, "values": { "none": "无", @@ -1501,7 +1502,7 @@ }, "sectionDesc": { "fingerprint": "配置此配置文件如何对指纹脚本显示。", - "network": "管理此配置文件使用的代理或 VPN。", + "network": "管理此配置文件使用的代理或 VPN,以及用于解析域名的 DNS 服务器。", "cookies": "导入、复制或清除此配置文件的 Cookie。", "extensions": "选择启动此配置文件时加载的扩展。", "sync": "配置此配置文件如何在设备间同步。", @@ -1705,6 +1706,18 @@ "custom.allowedOnlyLabel": "允许的域名(仅限这些)", "custom.allowedOnlyHint": "列表中域名的子域名也会被允许。列表为空时将禁用过滤。" }, + "dnsServer": { + "label": "DNS 服务器", + "placeholder": "输入 DNS 地址或留空", + "presets": "加密预设:", + "summary": { + "default": "由代理解析域名。如果代理无法解析某些网站,请设置服务器。", + "https": "已通过 DNS over HTTPS 加密({{server}})。查询经由此配置文件的代理或 VPN。如果服务器无响应,页面将无法加载。", + "tls": "已通过 DNS over TLS 加密({{server}})。查询经由此配置文件的代理或 VPN。如果服务器无响应,页面将无法加载。", + "tcp": "未加密:发往 {{server}} 的普通 DNS。代理可以读取并篡改应答。如果服务器无响应,页面将无法加载。", + "upgraded": "此地址也提供加密 DNS,因此 Donut 会使用它。以 tcp:// 开头可保留普通 DNS。" + } + }, "vpns": { "form": { "titleEdit": "编辑 VPN", @@ -2427,7 +2440,8 @@ "feedbackInvalid": "反馈未被接受。请检查消息后重试。", "feedbackRateLimited": "你在短时间内发送了大量反馈。请几分钟后再试。", "feedbackSendFailed": "无法发送反馈。请稍后再试。", - "feedbackLogsUnreadable": "无法读取日志。请关闭“附上最近的日志”后重新发送。" + "feedbackLogsUnreadable": "无法读取日志。请关闭“附上最近的日志”后重新发送。", + "dnsServerInvalid": "此 DNS 服务器无效。请输入 IP 地址或主机名、用于 DNS over TLS 的 tls://host,或用于 DNS over HTTPS 的 https:// URL。" }, "rail": { "profiles": "配置文件", diff --git a/src/lib/backend-errors.ts b/src/lib/backend-errors.ts index b35726b1..eba91e61 100644 --- a/src/lib/backend-errors.ts +++ b/src/lib/backend-errors.ts @@ -24,6 +24,7 @@ export type BackendErrorCode = | "INVALID_PROFILE_ID" | "PASSWORD_TOO_SHORT" | "INVALID_LAUNCH_HOOK_URL" + | "DNS_SERVER_INVALID" | "COOKIE_DB_LOCKED" | "COOKIE_DB_UNAVAILABLE" | "COOKIE_IMPORT_BROWSER_RUNNING" @@ -358,6 +359,8 @@ export function translateBackendError(t: TFunction, err: unknown): string { } case "INVALID_LAUNCH_HOOK_URL": return t("backendErrors.invalidLaunchHookUrl"); + case "DNS_SERVER_INVALID": + return t("backendErrors.dnsServerInvalid"); case "COOKIE_DB_LOCKED": return t("backendErrors.cookieDbLocked"); case "COOKIE_DB_UNAVAILABLE": diff --git a/src/types.ts b/src/types.ts index 9ab7bd33..949a3351 100644 --- a/src/types.ts +++ b/src/types.ts @@ -46,6 +46,7 @@ export interface BrowserProfile { * profiles created before this field existed. */ created_at?: number; dns_blocklist?: string; + dns_server?: string | null; password_protected?: boolean; }