vpn 0.1.2 → 0.1.3: subscription import + landing page mockup

Addon:
- Paste any https:// URL that returns a list of vless:// (either
  newline-separated or base64) and the extension fetches, decodes,
  parses, and adds every server to the dropdown. The full vless URL
  never leaves the panel — the addon holds it in its own storage and
  passes an opaque "sub-<hash>" id back for selection.
- Subscription CRUD on the addon side (listSubscriptions,
  addSubscription, refreshSubscription, removeSubscription). A refresh
  is a no-op inside the 6-hour TTL to avoid pounding the provider.
- Merges subscription servers with the baked-in three and gateway
  overlay by id; the dropdown groups them under one banner.

Site:
- silentmode.st/vpn landing page: three-plan grid (Free, Pro at $1/mo
  BCH, Max at $4/mo BCH), how-it-works four-step block, "the three
  servers" strip with per-tier availability, why-this-VPN cards, FAQ.
  Priced in USD, paid in BCH via the oracle at pay-time — same pattern
  as the marketplace's USD-listing covenant, no reintroduction of
  fiat/card processors.
This commit is contained in:
Local Dev 2026-09-22 20:59:50 +02:00
parent 1505f766c2
commit 806a976647
3 changed files with 232 additions and 5 deletions

View file

@ -1,7 +1,7 @@
{
"id": "vpn",
"name": "VPN",
"version": "0.1.2",
"version": "0.1.3",
"description": "Route Theseus's traffic through a Silent Mode VPN endpoint. Runs sing-box locally, exits at one of our servers, and switches on with one click. Paste any vless:// URL to point it at your own endpoint instead.",
"author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDJMNCA1djdjMCA1IDMuNSA5LjIgOCAxMCA0LjUtLjggOC01IDgtMTBWNWwtOC0zeiIgZmlsbD0iIzBhYzE4ZSIvPjxwYXRoIGQ9Ik05IDEybDIgMiA0LTQiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=",

View file

@ -28,6 +28,8 @@ const CACHE_SUBDIR = "bin";
const CONFIG_SUBDIR = "run";
const DOWNLOAD_TIMEOUT_MS = 5 * 60_000;
const SERVER_LIST_TTL_MS = 6 * 60 * 60_000;
const SUBSCRIPTION_TTL_MS = 6 * 60 * 60_000;
const SUBSCRIPTION_FETCH_TIMEOUT_MS = 20_000;
function platformKey() {
const p = os.platform(); // "win32" | "linux" | "darwin"
@ -235,6 +237,121 @@ module.exports = {
}
return serverList;
}
// Subscriptions — user pastes an HTTPS URL that returns a list of vless
// URLs (either newline-separated or base64-encoded newline-separated,
// whichever the operator publishes). We fetch, parse, and merge each
// entry into the server list on activation and on demand.
//
// Every subscription entry gets a stable id derived from its URL so a
// refresh does not duplicate servers when the same subscription is
// re-fetched. The full vless URL for a subscription server lives in the
// add-on's private storage — never leaves this device.
function subServerId(subLabel, index) {
// 8-char prefix of a sha256 of "<subLabel>|<index>" so the id is
// deterministic across refreshes but does not leak the subscription URL.
const h = crypto.createHash("sha256").update(`${subLabel}|${index}`).digest("hex").slice(0, 8);
return `sub-${h}`;
}
function decodeSubscriptionBody(text) {
// Two shapes are common in the wild. Try the plain-text form first —
// whitespace-separated URLs starting with vless://. If that yields
// nothing, try base64 (both standard and URL-safe alphabets, with or
// without padding).
const trimmed = String(text || "").trim();
const isPlain = /\bvless:\/\//i.test(trimmed);
if (isPlain) return trimmed;
try {
const b64 = trimmed.replace(/-/g, "+").replace(/_/g, "/");
const padded = b64 + "=".repeat((4 - b64.length % 4) % 4);
const decoded = Buffer.from(padded, "base64").toString("utf8");
if (/\bvless:\/\//i.test(decoded)) return decoded;
} catch {}
return "";
}
function parseSubscription(subLabel, body) {
const decoded = decodeSubscriptionBody(body);
if (!decoded) return [];
const urls = decoded.split(/\r?\n|[\t ]+/).map((s) => s.trim()).filter((s) => /^vless:\/\//i.test(s));
const out = [];
for (let i = 0; i < urls.length; i++) {
const url = urls[i];
let label;
try {
const u = new URL(url);
label = decodeURIComponent(u.hash.replace(/^#/, "")) || u.hostname;
} catch { continue; }
out.push({
id: subServerId(subLabel, i),
label,
flag: "🔗",
country: "",
status: "ready",
vless: url,
_sub: subLabel,
});
}
return out;
}
async function fetchSubscription(url) {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), SUBSCRIPTION_FETCH_TIMEOUT_MS);
try {
const r = await fetch(url, { signal: controller.signal, redirect: "follow" });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return await r.text();
} finally { clearTimeout(t); }
}
// Subscriptions the user has added, keyed by their URL. Value is
// { label, addedAt, fetchedAt, servers[] } — servers[].vless is what
// resolveEndpoint() reads when the user clicks Turn on for a sub server.
async function readSubscriptions() {
try { return (await api.storage.get("subscriptions", null)) || {}; }
catch { return {}; }
}
async function writeSubscriptions(subs) {
try { await api.storage.set("subscriptions", subs); } catch {}
}
async function addSubscription({ url, label }) {
const clean = String(url || "").trim();
if (!/^https?:\/\//i.test(clean)) throw new Error("subscription URL must be http:// or https://");
const body = await fetchSubscription(clean);
const nice = String(label || new URL(clean).host).trim();
const parsed = parseSubscription(nice, body);
if (!parsed.length) throw new Error("no vless:// entries in that subscription");
const subs = await readSubscriptions();
subs[clean] = { label: nice, url: clean, addedAt: (subs[clean]?.addedAt) || Date.now(), fetchedAt: Date.now(), servers: parsed };
await writeSubscriptions(subs);
api.log(`subscription "${nice}" imported: ${parsed.length} servers`);
return { count: parsed.length, label: nice };
}
async function refreshSubscription(url) {
const subs = await readSubscriptions();
const entry = subs[url];
if (!entry) throw new Error("no such subscription");
if (Date.now() - (entry.fetchedAt || 0) < SUBSCRIPTION_TTL_MS) return { skipped: true };
const body = await fetchSubscription(url);
const parsed = parseSubscription(entry.label, body);
entry.servers = parsed;
entry.fetchedAt = Date.now();
await writeSubscriptions(subs);
return { count: parsed.length };
}
async function removeSubscription(url) {
const subs = await readSubscriptions();
if (!subs[url]) return false;
delete subs[url];
await writeSubscriptions(subs);
return true;
}
async function subscriptionServers() {
const subs = await readSubscriptions();
const out = [];
for (const sub of Object.values(subs)) {
for (const s of (sub.servers || [])) out.push(s);
}
return out;
}
// On activation, hydrate from the last cached response if the gateway is
// currently unreachable; then kick off a background refresh.
(async () => {
@ -249,6 +366,15 @@ module.exports = {
serverListFetchedAt = cached.at || Date.now();
}
} catch {}
// Merge subscription servers on top so a fresh-boot panel shows them.
try {
const subServers = await subscriptionServers();
if (subServers.length) {
const byId = new Map(serverList.map((s) => [s.id, s]));
for (const s of subServers) byId.set(s.id, s);
serverList = [...byId.values()];
}
} catch {}
refreshServerList().catch(() => {});
})();
@ -298,10 +424,18 @@ module.exports = {
// Resolve either a raw vless:// URL or a serverId lookup into the URL to
// hand to sing-box. The panel usually sends { serverId } for a preset and
// { vless } for a custom paste; either shape is accepted.
function resolveEndpoint({ vless, serverId }) {
// { vless } for a custom paste; either shape is accepted. A serverId of
// the form "sub-<hash>" refers to a subscription-imported server whose
// vless URL lives in the addon's private storage, never in the panel.
async function resolveEndpoint({ vless, serverId }) {
if (vless && /^vless:\/\//i.test(vless)) return vless;
if (serverId) {
if (serverId.startsWith("sub-")) {
const subServers = await subscriptionServers();
const hit = subServers.find((s) => s.id === serverId);
if (!hit || !hit.vless) throw new Error(`no subscription server with id ${serverId}`);
return hit.vless;
}
const hit = serverList.find((s) => s.id === serverId);
if (!hit) throw new Error(`no server with id ${serverId} in the catalog`);
if (!hit.vless || hit.status === "coming-soon") {
@ -314,7 +448,7 @@ module.exports = {
async function turnOn(payload) {
if (state.running) return snapshot();
const vlessUrl = resolveEndpoint(payload || {});
const vlessUrl = await resolveEndpoint(payload || {});
const vless = parseVless(vlessUrl);
// The binary must exist and match its pinned sha256 BEFORE spawn.
let bin;
@ -373,16 +507,32 @@ module.exports = {
return snapshot();
}
// Cached subscription-server list so snapshot() stays synchronous. The
// storage read for the current subscription set is refreshed after every
// add/remove/refresh op.
let subServerCache = [];
async function refreshSubServerCache() {
try { subServerCache = await subscriptionServers(); } catch { subServerCache = []; }
}
// Warm the cache on activation once storage is ready. Not awaited on
// purpose — the panel polls status() and will pick up entries once loaded.
refreshSubServerCache();
function snapshot() {
// Trim the server catalog for the panel: never leak the raw vless URL
// (it's a credential in the free-tier model). The panel only needs the
// label, flag, country and whether the entry is usable.
const publicServers = serverList.map((s) => ({
const combined = [
...serverList,
...subServerCache.filter((s) => !serverList.find((x) => x.id === s.id)),
];
const publicServers = combined.map((s) => ({
id: s.id,
label: s.label || s.id,
flag: s.flag || "🌐",
country: s.country || "",
status: s.vless && s.status !== "coming-soon" ? "ready" : (s.status || "coming-soon"),
sub: s._sub || null,
}));
return {
running: state.running,
@ -414,8 +564,32 @@ module.exports = {
api.onMessage("refreshServers", async () => {
serverListFetchedAt = 0; // force
await refreshServerList();
await refreshSubServerCache();
return snapshot();
});
api.onMessage("listSubscriptions", async () => {
const subs = await readSubscriptions();
return Object.values(subs).map((s) => ({
url: s.url, label: s.label,
addedAt: s.addedAt, fetchedAt: s.fetchedAt,
count: (s.servers || []).length,
}));
});
api.onMessage("addSubscription", async (p) => {
const r = await addSubscription(p || {});
await refreshSubServerCache();
return { ...r, snapshot: snapshot() };
});
api.onMessage("refreshSubscription", async (p) => {
const r = await refreshSubscription(String(p && p.url || ""));
await refreshSubServerCache();
return { ...r, snapshot: snapshot() };
});
api.onMessage("removeSubscription", async (p) => {
const ok = await removeSubscription(String(p && p.url || ""));
await refreshSubServerCache();
return { ok, snapshot: snapshot() };
});
api.onMessage("clearCache", () => {
try {
for (const f of fs.readdirSync(cacheDir)) fs.unlinkSync(path.join(cacheDir, f));

View file

@ -110,6 +110,18 @@ button.btn.small { padding: 4px 9px; font-size: 11.5px; }
</div>
</div>
<div class="card">
<h2>Subscription</h2>
<p>Paste an HTTPS URL that returns a list of <code>vless://</code> servers.
The URL and its servers stay on this device.</p>
<div class="row">
<input id="subUrl" type="url" placeholder="https://provider.example/subscribe/…" style="flex:1;border:1px solid var(--line);background:var(--bg);color:var(--ink);border-radius:8px;padding:8px 10px;font:12px/1.5 ui-monospace,monospace"/>
<button class="btn small" id="subAdd">Add</button>
</div>
<div id="subList" class="kv" style="grid-template-columns:1fr auto auto;gap:6px 8px;font-size:12px"></div>
<div class="hint" id="subHint" style="text-align:left"></div>
</div>
<details class="disclosure">
<summary>Advanced</summary>
@ -309,6 +321,46 @@ $("refreshServers").addEventListener("click", async () => {
catch (e) { hint.className = "hint err"; hint.textContent = e?.message || String(e); }
});
// ---- subscription import ----
async function renderSubList() {
const subs = await SM().invoke("listSubscriptions", {});
const host = document.getElementById("subList");
if (!subs.length) { host.innerHTML = ""; return; }
host.innerHTML = subs.map((s) => {
const url = s.url.replace(/"/g, "&quot;");
return `<div style="overflow-wrap:anywhere"><b style="color:var(--ink)">${s.label}</b><div style="color:var(--dim);font-size:11px">${s.count} servers · ${s.url}</div></div>
<button class="btn small" data-sub-refresh="${url}"></button>
<button class="btn small" data-sub-remove="${url}"></button>`;
}).join("");
host.querySelectorAll("[data-sub-refresh]").forEach((b) => b.addEventListener("click", async () => {
const subHint = document.getElementById("subHint");
subHint.className = "hint"; subHint.textContent = "refreshing…";
try {
const r = await SM().invoke("refreshSubscription", { url: b.dataset.subRefresh });
subHint.textContent = r.skipped ? "recent enough, skipped" : `${r.count} servers`;
render(r.snapshot);
renderSubList();
} catch (e) { subHint.className = "hint err"; subHint.textContent = e?.message || String(e); }
}));
host.querySelectorAll("[data-sub-remove]").forEach((b) => b.addEventListener("click", async () => {
const r = await SM().invoke("removeSubscription", { url: b.dataset.subRemove });
render(r.snapshot); renderSubList();
}));
}
document.getElementById("subAdd").addEventListener("click", async () => {
const url = document.getElementById("subUrl").value.trim();
const subHint = document.getElementById("subHint");
if (!url) { subHint.className = "hint err"; subHint.textContent = "paste a URL first"; return; }
subHint.className = "hint"; subHint.textContent = "fetching…";
try {
const r = await SM().invoke("addSubscription", { url });
document.getElementById("subUrl").value = "";
subHint.className = "hint"; subHint.textContent = `added ${r.label} — ${r.count} servers`;
render(r.snapshot);
renderSubList();
} catch (e) { subHint.className = "hint err"; subHint.textContent = e?.message || String(e); }
});
$("save").addEventListener("click", saveEndpoint);
$("paste").addEventListener("click", async () => {
try { $("vless").value = await navigator.clipboard.readText(); } catch {}
@ -339,6 +391,7 @@ try {
await loadSelection();
await loadAuto();
render(await SM().invoke("status", {}));
renderSubList();
// Auto-on: if it was on before, try to bring it back with the last saved
// choice — server id if any, else custom vless. Silent failure is fine,
// the toggle stays in "off" and the hint carries the reason.