Theseus: translator talks to silentmode.st/libre + libre.x / lingua.x

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.
This commit is contained in:
Silent Mode 2026-10-03 16:39:45 +02:00
parent c74d2183e3
commit 0ba0e735ed

40
main.js
View file

@ -529,13 +529,15 @@ const SETTINGS_DEFAULTS = {
// (the libretranslate.com free tier moved behind an API key in late // (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 // 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 // endpoint: a dead mirror doesn't kill the feature, it just loses the
// round. Silent Mode's own instances come first: libre.silentmode.st // round. Silent Mode's own instances come first: silentmode.st/libre
// under ICANN and libre.x on BNS (lingua.x is registered as an alias // under ICANN (served as a sub-path to re-use the main cert instead
// of libre.x — same ip record, same backend — so it's a resolution // of standing up a subdomain + separate TLS) and libre.x on BNS
// convenience, not another independent peer). Users can edit the list // (lingua.x is registered as an alias of libre.x — same ip record,
// in Settings › General › Translate pages. // same backend — so it's a resolution convenience, not another
// independent peer). Users can edit the list in Settings › General
// › Translate pages.
translateEndpoints: [ translateEndpoints: [
"https://libre.silentmode.st/translate", "https://silentmode.st/libre/translate",
"https://libre.x/translate", "https://libre.x/translate",
"https://libretranslate.com/translate", "https://libretranslate.com/translate",
], ],
@ -1983,8 +1985,25 @@ async function serveBns(request) {
const base = new URL(r.p); const base = new URL(r.p);
const prefix = base.pathname === "/" ? "" : base.pathname.replace(/\/$/, ""); const prefix = base.pathname === "/" ? "" : base.pathname.replace(/\/$/, "");
const target = base.origin + prefix + rawPath + url.search; 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). // 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)); return upstreamResponse(up, up.contentType || guessType(reqPath));
}; };
try { try {
@ -4579,11 +4598,16 @@ async function translatorPostOnce(url, texts, from, to) {
format: "text", format: "text",
}; };
if (settings.translateApiKey) body.api_key = settings.translateApiKey; 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, // session.defaultSession.fetch goes through Electron's own network stack,
// so Tor (session proxy) and any add-on proxy settings apply the same way // 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. // they do for a tab's fetch; Node's global fetch would bypass both.
const sfetch = (session.defaultSession.fetch || fetch).bind(session.defaultSession); const sfetch = (session.defaultSession.fetch || fetch).bind(session.defaultSession);
const r = await sfetch(url, { const r = await sfetch(target, {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),