From 0ba0e735edbff8624f5e534d115d7862ba7d2591 Mon Sep 17 00:00:00 2001 From: Silent Mode Date: Sat, 3 Oct 2026 16:39:45 +0200 Subject: [PATCH] Theseus: translator talks to silentmode.st/libre + libre.x / lingua.x MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The translator client now ships with the right defaults for the actual deployment: silentmode.st/libre (ICANN, via the main cert and no new subdomain) is the primary peer; libre.x and lingua.x are registered on BNS with `p` records that reverse-proxy back to the same backend; the public LibreTranslate.com key-gated tier stays as the last-resort entry. Two wiring fixes make the BNS fallback actually usable from Theseus: 1. translatorPostOnce rewrites the request URL through targetUrlFor before fetching, so a peer whose host is a BNS name (libre.x) is dispatched via the in-process bns:// handler — Chromium's net stack has no way to resolve `.x` by itself. 2. serveBns's p-record branch now forwards the method, headers and body of the original request to the upstream, not just a GET. Without that, a POST /translate against libre.x arrived at the backend as a GET with no body and 400'd — now the proxy is actually a reverse- proxy, as the record type's name promises. Verified end-to-end against the live silentmode.st/libre instance from a fresh Theseus profile with a Spanish test page: both the direct silentmode.st/libre peer and the libre.x -> bns:// -> serveP -> upstream path translate the page and the revert path restores the originals. --- main.js | 40 ++++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/main.js b/main.js index 4843d732..94e53c15 100644 --- a/main.js +++ b/main.js @@ -529,13 +529,15 @@ const SETTINGS_DEFAULTS = { // (the libretranslate.com free tier moved behind an API key in late // 2026, several mirrors 502 at any given time), so a list beats one // endpoint: a dead mirror doesn't kill the feature, it just loses the - // round. Silent Mode's own instances come first: libre.silentmode.st - // under ICANN and libre.x on BNS (lingua.x is registered as an alias - // of libre.x — same ip record, same backend — so it's a resolution - // convenience, not another independent peer). Users can edit the list - // in Settings › General › Translate pages. + // round. Silent Mode's own instances come first: silentmode.st/libre + // under ICANN (served as a sub-path to re-use the main cert instead + // of standing up a subdomain + separate TLS) and libre.x on BNS + // (lingua.x is registered as an alias of libre.x — same ip record, + // same backend — so it's a resolution convenience, not another + // independent peer). Users can edit the list in Settings › General + // › Translate pages. translateEndpoints: [ - "https://libre.silentmode.st/translate", + "https://silentmode.st/libre/translate", "https://libre.x/translate", "https://libretranslate.com/translate", ], @@ -1983,8 +1985,25 @@ async function serveBns(request) { const base = new URL(r.p); const prefix = base.pathname === "/" ? "" : base.pathname.replace(/\/$/, ""); const target = base.origin + prefix + rawPath + url.search; + // Forward method + headers + body: a p-record is a reverse-proxy, so an + // API behind it (POST /translate, PUT, …) needs the live request as the + // caller sent it, not a bare GET. Hop-by-hop and host-rewriting headers + // are stripped — the upstream is a different origin and sees its own + // Host. + const method = request.method || "GET"; + const headers = {}; + const SKIP = new Set(["host", "connection", "content-length", "transfer-encoding", "accept-encoding"]); + try { + for (const [k, v] of request.headers.entries()) { + if (!SKIP.has(k.toLowerCase())) headers[k] = v; + } + } catch {} + const init = { method, headers, redirect: "manual" }; + if (method !== "GET" && method !== "HEAD" && request.body) { + try { init.body = Buffer.from(await request.arrayBuffer()); } catch {} + } // contentFetch so Tor covers this too (a plain fetch leaked the real IP). - const up = await contentFetch(target, { redirect: "manual" }); + const up = await contentFetch(target, init); return upstreamResponse(up, up.contentType || guessType(reqPath)); }; try { @@ -4579,11 +4598,16 @@ async function translatorPostOnce(url, texts, from, to) { format: "text", }; if (settings.translateApiKey) body.api_key = settings.translateApiKey; + // A peer whose host is a BNS name (libre.x, lingua.x, …) can't be reached + // by Chromium's net stack directly — those names aren't in ICANN DNS. Rewrite + // the request URL to bns:// so the in-process serveBns handler resolves the + // name (p-record = reverse-proxy to the upstream + path concatenation). + const target = (await targetUrlFor(url)) || url; // session.defaultSession.fetch goes through Electron's own network stack, // so Tor (session proxy) and any add-on proxy settings apply the same way // they do for a tab's fetch; Node's global fetch would bypass both. const sfetch = (session.defaultSession.fetch || fetch).bind(session.defaultSession); - const r = await sfetch(url, { + const r = await sfetch(target, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),