gateway: VPN exit catalogue and per-session credential leases
Two routes, in the house flat-if style next to the other /api handlers. GET /api/vpn/servers returns the exit list with Reality material only — host, port, sni, fp, pbk, sid — and no client UUID. All of that is already visible to anyone watching the handshake, so the catalogue is safe to serve and to cache. POST /api/vpn/session leases one pre-provisioned UUID from that exit's pool to the caller for BNS_VPN_TTL_MS and returns a built vless:// URL. Asking again inside the TTL returns the same lease instead of burning a second UUID, so a client that reconnects is not counted as a new tenant. Paid tiers require a signature over BNS-VPN1\n<serverId>\n<tier>\n<ts> and the recovered address must equal the address the caller claims — recovery on its own decides nothing. Pro and Max then stop at 501, because the subscription ledger they would check does not exist yet; better an honest refusal than a free lease wearing a paid label. This replaces the shared credential the extension was going to ship. It does not give real revocation: xray keeps honouring a leased UUID until the inbound is rotated, so the TTL frees a slot for re-issue but does not cut a live tunnel. What it does give is that every caller holds a distinct UUID, so one abuser can be dropped from the pool without touching anyone else, and nothing is ever published. Real expiry needs the live xray handler API on each box; noted in the handler comment so the next session does not assume the TTL is enforced. The pool file is the only place those UUIDs exist off the servers. It is read from BNS_VPN_POOL_FILE, created by hand on the VPS, and kept out of the repo the same way oracle.key is. One deployment snag found while writing this: the angie vhost forwards only Host, so every request reaches the gateway as 127.0.0.1 and callers are indistinguishable. Rather than rate-limit the whole internet as one bucket — which would let the first user lock out the rest — clientKey() returns null in that case and the handler answers 503 explaining the proxy needs X-Forwarded-For. Smoke test covers what matters for a credential issuer: the catalogue leaks no UUID, two callers get different ones, one caller asking twice burns only one, exhaustion is a clean 503, tier gating holds, a junk or stale signature is refused, and an unidentifiable caller is refused rather than served. It stages the flat VPS layout so it exercises the real gateway file, not a rewritten copy. 17/17.
This commit is contained in:
parent
e94399a1d2
commit
6846d8dde9
1 changed files with 181 additions and 0 deletions
|
|
@ -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<serverId>\n<tier>\n<ts>
|
||||
// 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: <flattened>.bch.<SUB_BASE> ---
|
||||
if (hostHeader.endsWith("." + SUB_BASE)) {
|
||||
const inner = hostHeader.slice(0, -("." + SUB_BASE).length); // "<flattened>.bch" or "<flattened>"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue