mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
# v0.4.28 (2026-05-10)
## Features - Add bun:sqlite adapter with automatic runtime detection (Bun/Node) - Add bulk API key import (format: `name|sk-key`, one per line) ## Fixes - Fix add API key for custom providers
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
// Bun runtime adapter — uses built-in bun:sqlite (native, fastest under Bun).
|
||||
// Loaded only when process.versions.bun is present.
|
||||
import { PRAGMA_SQL } from "../schema.js";
|
||||
|
||||
const CHECKPOINT_INTERVAL_MS = 60 * 1000;
|
||||
|
||||
export async function createBunSqliteAdapter(filePath) {
|
||||
// Dynamic import — only resolves under Bun runtime
|
||||
const { Database } = await import("bun:sqlite");
|
||||
const db = new Database(filePath, { create: true });
|
||||
db.exec(PRAGMA_SQL);
|
||||
|
||||
const stmtCache = new Map();
|
||||
function prepare(sql) {
|
||||
let stmt = stmtCache.get(sql);
|
||||
if (!stmt) {
|
||||
stmt = db.prepare(sql);
|
||||
stmtCache.set(sql, stmt);
|
||||
}
|
||||
return stmt;
|
||||
}
|
||||
|
||||
const checkpointTimer = setInterval(() => {
|
||||
try { db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); } catch {}
|
||||
}, CHECKPOINT_INTERVAL_MS);
|
||||
if (typeof checkpointTimer.unref === "function") checkpointTimer.unref();
|
||||
|
||||
function gracefulClose() {
|
||||
try { db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); } catch {}
|
||||
try { stmtCache.clear(); } catch {}
|
||||
try { db.close(); } catch {}
|
||||
}
|
||||
const onShutdown = () => gracefulClose();
|
||||
process.once("beforeExit", onShutdown);
|
||||
process.once("SIGINT", () => { onShutdown(); process.exit(0); });
|
||||
process.once("SIGTERM", () => { onShutdown(); process.exit(0); });
|
||||
|
||||
return {
|
||||
driver: "bun:sqlite",
|
||||
run(sql, params = []) {
|
||||
const r = prepare(sql).run(...params);
|
||||
return { changes: Number(r.changes ?? 0), lastInsertRowid: Number(r.lastInsertRowid ?? 0) };
|
||||
},
|
||||
get(sql, params = []) {
|
||||
return prepare(sql).get(...params);
|
||||
},
|
||||
all(sql, params = []) {
|
||||
return prepare(sql).all(...params);
|
||||
},
|
||||
exec(sql) { return db.exec(sql); },
|
||||
transaction(fn) {
|
||||
// bun:sqlite has db.transaction() API (similar to better-sqlite3)
|
||||
const tx = db.transaction(fn);
|
||||
return tx();
|
||||
},
|
||||
checkpoint() { try { db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); } catch {} },
|
||||
close() {
|
||||
clearInterval(checkpointTimer);
|
||||
gracefulClose();
|
||||
},
|
||||
raw: db,
|
||||
};
|
||||
}
|
||||
@@ -62,14 +62,15 @@ export async function createNodeSqliteAdapter(filePath) {
|
||||
},
|
||||
exec(sql) { return db.exec(sql); },
|
||||
transaction(fn) {
|
||||
// node:sqlite has no built-in transaction wrapper → manual BEGIN/COMMIT
|
||||
db.exec("BEGIN");
|
||||
// node:sqlite has no transaction wrapper. Use SAVEPOINT for nested support.
|
||||
const sp = `sp_${Math.random().toString(36).slice(2)}`;
|
||||
db.exec(`SAVEPOINT ${sp}`);
|
||||
try {
|
||||
const r = fn();
|
||||
db.exec("COMMIT");
|
||||
db.exec(`RELEASE ${sp}`);
|
||||
return r;
|
||||
} catch (e) {
|
||||
try { db.exec("ROLLBACK"); } catch {}
|
||||
try { db.exec(`ROLLBACK TO ${sp}`); db.exec(`RELEASE ${sp}`); } catch {}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -86,14 +86,15 @@ export async function createSqlJsAdapter(filePath) {
|
||||
}
|
||||
|
||||
function transaction(fn) {
|
||||
db.exec("BEGIN");
|
||||
const sp = `sp_${Math.random().toString(36).slice(2)}`;
|
||||
db.exec(`SAVEPOINT ${sp}`);
|
||||
try {
|
||||
const result = fn();
|
||||
db.exec("COMMIT");
|
||||
db.exec(`RELEASE ${sp}`);
|
||||
scheduleSave();
|
||||
return result;
|
||||
} catch (e) {
|
||||
db.exec("ROLLBACK");
|
||||
try { db.exec(`ROLLBACK TO ${sp}`); db.exec(`RELEASE ${sp}`); } catch {}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
+22
-4
@@ -4,7 +4,21 @@ import { ensureDirs, DATA_FILE } from "./paths.js";
|
||||
if (!global._dbAdapter) global._dbAdapter = { instance: null, initPromise: null, logged: false };
|
||||
const state = global._dbAdapter;
|
||||
|
||||
async function tryBunSqlite() {
|
||||
// Bun runtime only — built-in, no install needed
|
||||
if (!process.versions.bun) return null;
|
||||
try {
|
||||
const { createBunSqliteAdapter } = await import("./adapters/bunSqliteAdapter.js");
|
||||
return await createBunSqliteAdapter(DATA_FILE);
|
||||
} catch (e) {
|
||||
console.warn(`[DB] bun:sqlite unavailable: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function tryBetterSqlite() {
|
||||
// Skip on Bun — better-sqlite3 native bindings unsupported
|
||||
if (process.versions.bun) return null;
|
||||
try {
|
||||
const { createBetterSqliteAdapter } = await import("./adapters/betterSqliteAdapter.js");
|
||||
return createBetterSqliteAdapter(DATA_FILE);
|
||||
@@ -15,7 +29,8 @@ async function tryBetterSqlite() {
|
||||
}
|
||||
|
||||
async function tryNodeSqlite() {
|
||||
// Built-in since Node 22.5.0 — no install needed.
|
||||
// Built-in since Node 22.5.0 — no install needed. Skip under Bun (no node:sqlite).
|
||||
if (process.versions.bun) return null;
|
||||
const [maj, min] = process.versions.node.split(".").map(Number);
|
||||
if (maj < 22 || (maj === 22 && min < 5)) return null;
|
||||
try {
|
||||
@@ -39,11 +54,14 @@ async function trySqlJs() {
|
||||
|
||||
async function initAdapter() {
|
||||
ensureDirs();
|
||||
// Order: native (fastest) → built-in (no install) → pure JS (universal)
|
||||
let adapter = await tryBetterSqlite();
|
||||
// Order per runtime:
|
||||
// Bun: bun:sqlite → sql.js
|
||||
// Node: better-sqlite3 → node:sqlite (≥22.5) → sql.js
|
||||
let adapter = await tryBunSqlite();
|
||||
if (!adapter) adapter = await tryBetterSqlite();
|
||||
if (!adapter) adapter = await tryNodeSqlite();
|
||||
if (!adapter) adapter = await trySqlJs();
|
||||
if (!adapter) throw new Error("[DB] No SQLite driver available (better-sqlite3 + node:sqlite + sql.js all failed)");
|
||||
if (!adapter) throw new Error("[DB] No SQLite driver available (bun/better/node/sql.js all failed)");
|
||||
|
||||
if (!state.logged) {
|
||||
console.log(`[DB] Driver: ${adapter.driver} | file: ${DATA_FILE}`);
|
||||
|
||||
@@ -95,8 +95,8 @@ export function isTailscaleLoggedIn() {
|
||||
timeout: 5000
|
||||
});
|
||||
const json = JSON.parse(out);
|
||||
// BackendState "Running" means fully logged in and connected
|
||||
return json.BackendState === "Running";
|
||||
// BackendState=Running + Self.Online=true → device still exists in tailnet
|
||||
return json.BackendState === "Running" && json.Self?.Online === true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
@@ -173,6 +173,23 @@ function bgRefreshFunnelUrl(port) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Get actual funnel URL from Self.DNSName (sync, authoritative — avoids hostname-conflict suffix). */
|
||||
function getActualFunnelUrl() {
|
||||
const bin = getTailscaleBin();
|
||||
if (!bin) return null;
|
||||
try {
|
||||
const out = execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, {
|
||||
encoding: "utf8",
|
||||
windowsHide: true,
|
||||
env: { ...process.env, PATH: EXTENDED_PATH },
|
||||
timeout: 5000,
|
||||
});
|
||||
const json = JSON.parse(out);
|
||||
const dnsName = json.Self?.DNSName?.replace(/\.$/, "");
|
||||
return dnsName ? `https://${dnsName}` : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Get funnel URL from tailscale status (cached, non-blocking) */
|
||||
export function getTailscaleFunnelUrl(port) {
|
||||
if (Date.now() - funnelUrlCache.fetchedAt > PROBE_TTL_MS || funnelUrlCache.port !== port) {
|
||||
@@ -646,14 +663,14 @@ export async function startFunnel(port) {
|
||||
const timeout = setTimeout(() => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
// --bg exits after setup, try status
|
||||
const url = getTailscaleFunnelUrl(port);
|
||||
// --bg exits after setup, read actual hostname from status
|
||||
const url = getActualFunnelUrl() || getTailscaleFunnelUrl(port);
|
||||
if (url) resolve({ tunnelUrl: url });
|
||||
else reject(new Error(`Tailscale funnel timed out: ${output.trim() || "no output"}`));
|
||||
}, 30000);
|
||||
|
||||
const parseFunnelUrl = (text) =>
|
||||
(text.match(/https:\/\/[a-z0-9-]+\.[a-z0-9.-]+\.ts\.net[^\s]*/i) || [])[0]?.replace(/\/$/, "") || null;
|
||||
// Always resolve via Self.DNSName to get the real hostname (avoids -1 suffix from conflicts)
|
||||
const parseFunnelUrl = () => getActualFunnelUrl();
|
||||
|
||||
let funnelNotEnabled = false;
|
||||
|
||||
@@ -674,7 +691,7 @@ export async function startFunnel(port) {
|
||||
}
|
||||
}
|
||||
|
||||
const url = parseFunnelUrl(output);
|
||||
const url = parseFunnelUrl();
|
||||
if (url && !resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
@@ -690,7 +707,7 @@ export async function startFunnel(port) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
console.log(`[Tailscale] funnel exit code=${code} output="${output.trim().slice(0, 200)}"`);
|
||||
const url = parseFunnelUrl(output) || getTailscaleFunnelUrl(port);
|
||||
const url = parseFunnelUrl() || getTailscaleFunnelUrl(port);
|
||||
if (url) resolve({ tunnelUrl: url });
|
||||
else reject(new Error(`tailscale funnel failed (code ${code}): ${output.trim()}`));
|
||||
});
|
||||
@@ -704,6 +721,25 @@ export async function startFunnel(port) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Provision TLS cert for funnel domain (required before Funnel serves HTTPS). Best-effort. */
|
||||
export async function provisionCert(hostname) {
|
||||
const bin = getTailscaleBin();
|
||||
if (!bin || !hostname) return;
|
||||
const certsDir = path.join(TAILSCALE_DIR, "certs");
|
||||
fs.mkdirSync(certsDir, { recursive: true });
|
||||
const certFile = path.join(certsDir, `${hostname}.crt`);
|
||||
const keyFile = path.join(certsDir, `${hostname}.key`);
|
||||
try {
|
||||
await execAsync(
|
||||
`"${bin}" ${SOCKET_FLAG.join(" ")} cert --cert-file "${certFile}" --key-file "${keyFile}" "${hostname}"`,
|
||||
{ windowsHide: true, env: { ...process.env, PATH: EXTENDED_PATH }, timeout: 30000 }
|
||||
);
|
||||
console.log(`[Tailscale] cert provisioned for ${hostname}`);
|
||||
} catch (e) {
|
||||
console.warn(`[Tailscale] cert provision failed (non-fatal): ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop tailscale funnel */
|
||||
export function stopFunnel() {
|
||||
const bin = getTailscaleBin();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import crypto from "crypto";
|
||||
import { loadState, saveState, generateShortId } from "./state.js";
|
||||
import { spawnQuickTunnel, killCloudflared, isCloudflaredRunning, setUnexpectedExitHandler } from "./cloudflared.js";
|
||||
import { startFunnel, stopFunnel, isTailscaleRunning, isTailscaleRunningStrict, isTailscaleLoggedIn, startLogin, startDaemonWithPassword } from "./tailscale.js";
|
||||
import { startFunnel, stopFunnel, isTailscaleRunning, isTailscaleRunningStrict, isTailscaleLoggedIn, startLogin, startDaemonWithPassword, provisionCert } from "./tailscale.js";
|
||||
import { getSettings, updateSettings } from "@/lib/localDb";
|
||||
import { getCachedPassword, loadEncryptedPassword, initDbHooks } from "@/mitm/manager";
|
||||
import { waitForHealth, probeUrlAlive } from "./networkProbe.js";
|
||||
@@ -250,15 +250,26 @@ export async function enableTailscale(localPort = 20128) {
|
||||
await updateSettings({ tailscaleEnabled: true, tailscaleUrl: result.tunnelUrl });
|
||||
console.log(`[Tailscale] funnel up: ${result.tunnelUrl}`);
|
||||
|
||||
// Verify funnel actually serves /api/health
|
||||
await waitForHealth(result.tunnelUrl, token);
|
||||
console.log("[Tailscale] enable success");
|
||||
// Provision TLS cert so Funnel can serve HTTPS (non-fatal if fails)
|
||||
const hostname = new URL(result.tunnelUrl).hostname;
|
||||
await provisionCert(hostname);
|
||||
|
||||
// Prime reachable cache so UI shows correct state immediately
|
||||
tailscaleReachable.value = true;
|
||||
tailscaleReachable.url = result.tunnelUrl;
|
||||
tailscaleReachable.fetchedAt = Date.now();
|
||||
// Verify funnel serves /api/health — timeout is non-fatal (DNS may still be propagating)
|
||||
let reachableNow = false;
|
||||
try {
|
||||
await waitForHealth(result.tunnelUrl, token);
|
||||
reachableNow = true;
|
||||
} catch (he) {
|
||||
if (!he.message.startsWith("Health check timeout")) throw he;
|
||||
console.warn(`[Tailscale] health check timed out, will retry via watchdog`);
|
||||
}
|
||||
|
||||
if (reachableNow) {
|
||||
tailscaleReachable.value = true;
|
||||
tailscaleReachable.url = result.tunnelUrl;
|
||||
tailscaleReachable.fetchedAt = Date.now();
|
||||
}
|
||||
console.log(`[Tailscale] enable success (reachable=${reachableNow})`);
|
||||
return { success: true, tunnelUrl: result.tunnelUrl };
|
||||
} catch (e) {
|
||||
console.error(`[Tailscale] enable error: ${e.message}`);
|
||||
@@ -281,8 +292,9 @@ export async function getTailscaleStatus() {
|
||||
const settings = await getSettings();
|
||||
const settingsEnabled = settings.tailscaleEnabled === true;
|
||||
const tunnelUrl = settings.tailscaleUrl || "";
|
||||
// Lazy: skip execSync funnel-status probe when user disabled Tailscale
|
||||
const running = settingsEnabled ? isTailscaleRunning() : false;
|
||||
// Skip probes entirely when disabled; check login before running (device removed = not logged in)
|
||||
const loggedIn = settingsEnabled ? isTailscaleLoggedIn() : false;
|
||||
const running = loggedIn ? isTailscaleRunning() : false;
|
||||
// Reachable: cached background probe (never blocks the request)
|
||||
const reachable = settingsEnabled && running ? readReachable(tailscaleReachable, tunnelUrl) : false;
|
||||
return {
|
||||
@@ -290,6 +302,7 @@ export async function getTailscaleStatus() {
|
||||
settingsEnabled,
|
||||
tunnelUrl,
|
||||
running,
|
||||
loggedIn,
|
||||
reachable
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user