From 3cb5081bdb9a20a9bab1cde1aaac0849efdd9e4a Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 27 Sep 2026 20:58:24 +0200 Subject: [PATCH] vpn: sing-box on both ends, modern config schema, rebuild URL from catalogue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs, all found by driving the add-on in a real Theseus and watching the egress IP rather than reasoning about it. 1. The generated config used pre-1.11 schema. `sniff` on an inbound and the `block` outbound type were deprecated in sing-box 1.11 and REMOVED in 1.13, so 1.14.1 refused the whole file and exited 1. Routing is now a bare `final`; rule `action` semantics changed in 1.12 and the explicit inbound→outbound rule was never needed. 2. xray-core 26.3.27's REALITY would not complete a handshake with a sing-box client — and, after ruling out keys (three derivations, a fresh pair used verbatim), shortIds (explicit and empty), clock skew, dest reachability, TLS 1.3/X25519 on the dest, and xtls-rprx-vision, not with a correctly configured xray client either. sing-box against sing-box works first try. The exits now run sing-box, which is what the add-on already ships to every client, so there is no longer a cross-implementation surface at all. Migration script included; it keeps the port, the SNI and the existing uuid pool and only changes the Reality keypair. Worth recording separately: xray's REALITY inbound field is `dest`, not sing-box's `target`. That was wrong too, independently. 3. leaseEndpoint cached the full vless URL. The Reality key and short id live inside that URL, so re-keying an exit left every client failing against a stale copy for the whole 24h lease. It now caches only the uuid and rebuilds the URL from the current catalogue entry, so a re-key takes effect as soon as the catalogue refreshes. Verified in Theseus over CDP: baseline 80.187.100.105, tunnel up 81.31.210.65 (the sm-1 exit), off restores the baseline, and sm-3 is correctly refused to a free-tier caller. --- bundled-addons/vpn/index.js | 38 +++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/bundled-addons/vpn/index.js b/bundled-addons/vpn/index.js index 286361f1..5e024fb8 100644 --- a/bundled-addons/vpn/index.js +++ b/bundled-addons/vpn/index.js @@ -88,6 +88,14 @@ function parseVless(url) { // Minimal sing-box outbound config for VLESS+Reality, plus a SOCKS5 inbound // on 127.0.0.1: that Theseus's session proxy points at. +// +// Schema notes, learned the hard way against a real binary: `sniff` on an +// inbound and the `block` outbound type are legacy fields, deprecated in +// sing-box 1.11 and REMOVED in 1.13 — leaving them in makes 1.13+ refuse the +// whole config with "legacy inbound fields are deprecated". Routing is a bare +// `final` here rather than an explicit inbound→outbound rule, because +// everything entering the SOCKS inbound is meant to leave through the tunnel +// and rule `action` semantics also changed in 1.12. function buildSingBoxConfig(vless, socksPort) { return { log: { level: "warn", timestamp: true }, @@ -97,7 +105,6 @@ function buildSingBoxConfig(vless, socksPort) { tag: "in-socks", listen: "127.0.0.1", listen_port: socksPort, - sniff: true, }, ], outbounds: [ @@ -123,12 +130,8 @@ function buildSingBoxConfig(vless, socksPort) { : { enabled: true, server_name: vless.sni || vless.address }, }, { type: "direct", tag: "out-direct" }, - { type: "block", tag: "out-block" }, ], - route: { - final: "out-vless", - rules: [{ inbound: ["in-socks"], outbound: "out-vless" }], - }, + route: { final: "out-vless" }, }; } @@ -428,11 +431,26 @@ module.exports = { // returns the same lease for a repeat caller anyway, but not re-asking on // every toggle keeps the round trip off the common path and means a brief // gateway outage does not break an already-working exit. + // Build a vless:// URL from the CURRENT catalogue entry plus a leased + // uuid. Deliberately not from a cached URL: the Reality key and short id + // live in that URL, so an exit that re-keys would keep failing against a + // stale cached copy until the lease expired. Rebuilding each time means a + // re-key is picked up as soon as the catalogue refreshes. + function vlessFor(srv, uuid) { + const q = new URLSearchParams({ + type: "tcp", security: "reality", flow: srv.flow || "xtls-rprx-vision", + pbk: srv.pbk, sid: srv.sid, sni: srv.sni, fp: srv.fp || "chrome", + }); + return `vless://${uuid}@${srv.host}:${srv.port}?${q}#${encodeURIComponent(srv.label || srv.id)}`; + } + async function leaseEndpoint(srv) { const cacheKey = `__lease:${srv.id}`; try { const cached = await api.storage.get(cacheKey, null); - if (cached && cached.vless && cached.expiresAt > Date.now() + 60_000) return cached.vless; + if (cached && cached.uuid && cached.expiresAt > Date.now() + 60_000) { + return vlessFor(srv, cached.uuid); + } } catch {} const controller = new AbortController(); const t = setTimeout(() => controller.abort(), 20_000); @@ -451,10 +469,10 @@ module.exports = { let j; try { j = JSON.parse(body); } catch { throw new Error(`key issuer returned non-JSON (${body.slice(0, 80)}…)`); } - if (!r.ok || !j.vless) throw new Error(j.error || `key issuer said HTTP ${r.status}`); - try { await api.storage.set(cacheKey, { vless: j.vless, expiresAt: j.expiresAt || 0 }); } catch {} + if (!r.ok || !j.uuid) throw new Error(j.error || `key issuer said HTTP ${r.status}`); + try { await api.storage.set(cacheKey, { uuid: j.uuid, expiresAt: j.expiresAt || 0 }); } catch {} api.log(`leased a session on ${srv.id}${j.reused ? " (reused)" : ""}`); - return j.vless; + return vlessFor(srv, j.uuid); } // Resolve either a raw vless:// URL or a serverId lookup into the URL to