diff --git a/src/gateway/public-gateway.mjs b/src/gateway/public-gateway.mjs index 0c69abe..cbc29a1 100644 --- a/src/gateway/public-gateway.mjs +++ b/src/gateway/public-gateway.mjs @@ -252,6 +252,48 @@ async function activeListings() { } const S3_ENDPOINT = process.env.BNS_S3 || "http://127.0.0.1:8500"; const SUB_BASE = (process.env.BNS_SUB_BASE || "bch.navigate.st").toLowerCase(); // subdomain suffix + +// ---------- VPN exit catalogue + session leases ---------- +// The pool file holds, per exit, the Reality parameters a client needs AND a +// list of pre-provisioned client UUIDs configured on that xray inbound. It is +// the one place those UUIDs exist outside the servers themselves: it is never +// committed, never uploaded, and never served — /api/vpn/servers returns the +// Reality material only, which is public by design, and a UUID leaves this +// process exactly once, in the response to the requester it was leased to. +// Created by hand on the VPS, same posture as oracle.key. +const VPN_POOL_FILE = process.env.BNS_VPN_POOL_FILE || _join(_dirname(fileURLToPath(import.meta.url)), "vpn-pool.json"); +const VPN_LEASES_FILE = process.env.BNS_VPN_LEASES_FILE || _join(_dirname(fileURLToPath(import.meta.url)), "vpn-leases.json"); +const VPN_TTL_MS = Number(process.env.BNS_VPN_TTL_MS || 24 * 3600 * 1000); +function readVpnPool() { + try { + if (!_existsSync(VPN_POOL_FILE)) return { servers: [] }; + const j = JSON.parse(_readFile(VPN_POOL_FILE, "utf8")); + return { servers: Array.isArray(j?.servers) ? j.servers : [] }; + } catch { return { servers: [] }; } +} +const readVpnLeases = () => readJsonList(VPN_LEASES_FILE); +const writeVpnLeases = (list) => _writeFile(VPN_LEASES_FILE, JSON.stringify(list, null, 2) + "\n", "utf8"); + +// Which exits a tier may use. Free gets one, pro two, max all three — the +// server's own `tiers` array is what decides, so re-tagging an exit is a pool +// file edit rather than a code change. +const VPN_TIERS = new Set(["free", "pro", "max"]); + +// The caller's address for rate-limiting. angie currently forwards only Host, +// so x-forwarded-for is usually absent and every request looks like 127.0.0.1. +// Returns null when we genuinely cannot tell callers apart, and the caller +// treats null as "cannot rate-limit" rather than "everyone is one bucket" — +// lumping the whole internet into a single counter would deny the feature to +// everybody the moment one person used it. Add +// proxy_set_header X-Forwarded-For $remote_addr; +// to the /api/ location in silentmode-st.angie.conf to make this work. +function clientKey(req) { + const xff = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim(); + if (xff) return xff; + const ra = req.socket?.remoteAddress || ""; + if (!ra || ra === "127.0.0.1" || ra === "::1" || ra === "::ffff:127.0.0.1") return null; + return ra; +} const aws = new AwsClient({ accessKeyId: process.env.AK, secretAccessKey: process.env.SK, service: "s3", region: "us-east-1", @@ -1932,6 +1974,145 @@ const server = http.createServer(async (req, res) => { return; } + // --- VPN exit catalogue ------------------------------------------------- + // GET /api/vpn/servers + // -> { updated, servers: [{ id, label, flag, country, host, port, sni, + // fp, pbk, sid, flow, tiers }] } + // Carries no client UUID. Everything here is Reality material a passive + // observer of the handshake already sees; the credential comes from + // /api/vpn/session and only ever reaches the one client that asked. + if (req.url === "/api/vpn/servers" || req.url.startsWith("/api/vpn/servers?")) { + res.setHeader("access-control-allow-origin", "*"); + res.setHeader("access-control-allow-methods", "GET, OPTIONS"); + res.setHeader("access-control-allow-headers", "content-type"); + res.setHeader("access-control-max-age", "86400"); + res.setHeader("content-type", "application/json"); + if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } + if (req.method !== "GET") { res.writeHead(405); res.end(JSON.stringify({ error: "GET only" })); return; } + try { + const pool = readVpnPool(); + const servers = pool.servers.map((s) => ({ + id: s.id, label: s.label || s.id, flag: s.flag || "🌐", country: s.country || "", + host: s.host, port: s.port, sni: s.sni, fp: s.fp || "chrome", + pbk: s.pbk, sid: s.sid, flow: s.flow || "xtls-rprx-vision", + tiers: Array.isArray(s.tiers) ? s.tiers : ["free"], + status: Array.isArray(s.pool) && s.pool.length ? "ready" : "no-capacity", + })); + res.setHeader("cache-control", "public, max-age=300"); + res.writeHead(200); + res.end(JSON.stringify({ updated: new Date().toISOString(), count: servers.length, servers }, null, 2)); + } catch (e) { + res.writeHead(500); res.end(JSON.stringify({ error: `vpn catalogue error: ${e.message}` })); + } + return; + } + + // --- VPN session lease -------------------------------------------------- + // POST /api/vpn/session { serverId, tier?, address?, ts?, sig? } + // -> { vless, uuid, serverId, tier, expiresAt, reused } + // + // Leases one pre-provisioned UUID from the exit's pool to this caller for + // BNS_VPN_TTL_MS. Asking again before expiry returns the SAME lease rather + // than burning another UUID, so a client that reconnects is not a new + // tenant. Paid tiers additionally require a wallet signature over + // BNS-VPN1\n\n\n + // and the recovered address must equal the `address` the caller claims — + // recovering an address is not by itself verification. + // + // Honest limit: expiry here is bookkeeping. xray keeps accepting a leased + // UUID until the inbound config is rotated, so a TTL frees the slot for + // re-issue but does not cut an existing tunnel. Real revocation needs the + // live xray handler API on each box; until then the protection is that + // every caller gets a distinct UUID, so one abuser can be burned out of + // the pool without touching anyone else. + if (req.url === "/api/vpn/session" || req.url.startsWith("/api/vpn/session?")) { + res.setHeader("access-control-allow-origin", "*"); + res.setHeader("access-control-allow-methods", "POST, OPTIONS"); + res.setHeader("access-control-allow-headers", "content-type"); + res.setHeader("content-type", "application/json"); + if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } + const fail = (code, error, extra) => { res.writeHead(code); res.end(JSON.stringify({ error, ...(extra || {}) })); }; + if (req.method !== "POST") return fail(405, "POST only"); + try { + const chunks = []; let total = 0; + for await (const c of req) { + total += c.length; + if (total > 8 * 1024) return fail(413, "body > 8 KB"); + chunks.push(c); + } + let body; + try { body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"); } + catch { return fail(400, "invalid JSON"); } + + const serverId = String(body.serverId || "").trim(); + if (!serverId) return fail(400, "serverId is required"); + const tier = String(body.tier || "free").toLowerCase(); + if (!VPN_TIERS.has(tier)) return fail(400, `tier must be one of ${[...VPN_TIERS].join(", ")}`); + + const pool = readVpnPool(); + const srv = pool.servers.find((s) => s.id === serverId); + if (!srv) return fail(404, `no exit "${serverId}"`); + const srvTiers = Array.isArray(srv.tiers) ? srv.tiers : ["free"]; + if (!srvTiers.includes(tier)) { + return fail(403, `exit "${serverId}" is not available on the ${tier} tier`, { tiers: srvTiers }); + } + + // Paid tiers: prove the wallet, then look up a subscription. The + // signature check is here and correct; the subscription ledger it + // would consult does not exist yet, so pro/max stop at 501 rather + // than quietly handing out a free lease under a paid label. + let holder = null; + if (tier !== "free") { + const claimed = String(body.address || "").trim(); + if (!claimed) return fail(400, "address is required for a paid tier"); + const ts = Number(body.ts || 0); + if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > 10 * 60 * 1000) { + return fail(401, "ts missing or more than 10 minutes off"); + } + const who = recoverSigner(String(body.sig || ""), `BNS-VPN1\n${serverId}\n${tier}\n${ts}`, claimed); + if (!who) return fail(400, "sig is not a valid 65-byte BCH message signature"); + if (who !== claimed) return fail(403, "sig does not match the claimed address", { derived: who, claimed }); + return fail(501, "paid tiers are not open yet — no subscription ledger to check against", { address: who, tier }); + } + + // Free tier is keyed to the caller's address. Without a usable one we + // cannot tell two callers apart, and handing out pool UUIDs to an + // unidentifiable stream would drain the pool for everyone. + holder = clientKey(req); + if (!holder) { + return fail(503, "cannot identify the caller, so a free lease would be unmeterable — the gateway needs X-Forwarded-For from the proxy"); + } + + const now = Date.now(); + const leases = readVpnLeases().filter((l) => l && l.expiresAt > now); + const mine = leases.find((l) => l.holder === holder && l.serverId === serverId && l.tier === tier); + const vlessOf = (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)}`; + }; + if (mine) { + res.writeHead(200); + res.end(JSON.stringify({ vless: vlessOf(mine.uuid), uuid: mine.uuid, serverId, tier, expiresAt: mine.expiresAt, reused: true })); + return; + } + + const taken = new Set(leases.filter((l) => l.serverId === serverId).map((l) => l.uuid)); + const free = (Array.isArray(srv.pool) ? srv.pool : []).find((u) => !taken.has(u)); + if (!free) return fail(503, `exit "${serverId}" is at capacity, try another`, { serverId }); + + leases.push({ uuid: free, serverId, tier, holder, issuedAt: now, expiresAt: now + VPN_TTL_MS }); + writeVpnLeases(leases); + res.writeHead(200); + res.end(JSON.stringify({ vless: vlessOf(free), uuid: free, serverId, tier, expiresAt: now + VPN_TTL_MS, reused: false })); + } catch (e) { + res.writeHead(500); res.end(JSON.stringify({ error: `vpn session error: ${e.message}` })); + } + return; + } + // --- subdomain form: .bch. --- if (hostHeader.endsWith("." + SUB_BASE)) { const inner = hostHeader.slice(0, -("." + SUB_BASE).length); // ".bch" or ""