diff --git a/CHANGELOG.md b/CHANGELOG.md index 56c2b99..f66dfbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +# 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 + # v0.4.27 (2026-05-09) ## Features diff --git a/next.config.mjs b/next.config.mjs index 31644a3..99d5557 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,7 +1,7 @@ /** @type {import('next').NextConfig} */ const nextConfig = { output: "standalone", - serverExternalPackages: ["better-sqlite3"], + serverExternalPackages: ["better-sqlite3", "sql.js", "node:sqlite", "bun:sqlite"], images: { unoptimized: true }, diff --git a/package.json b/package.json index 2efa813..8e47141 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "9router-app", - "version": "0.4.27", + "version": "0.4.28", "description": "9Router web dashboard", "private": true, "scripts": { - "dev": "next dev --webpack --hostname 0.0.0.0 --port 20128", + "dev": "next dev --webpack --port 20128", "build": "NODE_ENV=production next build --webpack", "start": "NODE_ENV=production next start", "dev:bun": "bun --bun next dev --webpack --port 20128", diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index 57a2287..cb18377 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -72,6 +72,8 @@ export default function APIPageClient({ machineId }) { const [tsLoading, setTsLoading] = useState(false); const [tsProgress, setTsProgress] = useState(""); const [tsStatus, setTsStatus] = useState(null); + const [tsAuthUrl, setTsAuthUrl] = useState(""); + const [tsAuthLabel, setTsAuthLabel] = useState(""); const [tsInstalled, setTsInstalled] = useState(null); // null=checking, true/false const [tsInstalling, setTsInstalling] = useState(false); const [tsInstallLog, setTsInstallLog] = useState([]); @@ -492,12 +494,16 @@ export default function APIPageClient({ machineId }) { return false; }; - // Open auth URL only when actually needed (avoids blank popup flash on success path). - // Falls back to status message with clickable link if popup blocker prevents opening. - const openAuthUrl = (url) => { - const w = window.open(url, "tailscale_auth", "width=600,height=700"); - if (!w) setTsStatus({ type: "warning", message: `Popup blocked. Open manually: ${url}` }); - return w; + // Show inline login button instead of auto-opening popup (browsers block popups + // opened after async work because the user gesture is lost). + const requestUserAuth = (url, label) => { + setTsAuthUrl(url); + setTsAuthLabel(label); + }; + + const clearUserAuth = () => { + setTsAuthUrl(""); + setTsAuthLabel(""); }; const handleConnectTailscale = async () => { @@ -506,6 +512,7 @@ export default function APIPageClient({ machineId }) { setTsLoading(true); setTsStatus(null); setTsProgress("Connecting..."); + clearUserAuth(); try { const res = await fetch("/api/tunnel/tailscale-enable", { method: "POST" }); const data = await res.json(); @@ -519,8 +526,8 @@ export default function APIPageClient({ machineId }) { } if (data.needsLogin && data.authUrl) { - openAuthUrl(data.authUrl); - setTsProgress("Waiting for login..."); + requestUserAuth(data.authUrl, "Open Login Page"); + setTsProgress("Login required — click \"Open Login Page\" to continue"); for (let i = 0; i < 40; i++) { await new Promise((r) => setTimeout(r, 3000)); try { @@ -528,6 +535,7 @@ export default function APIPageClient({ machineId }) { if (r2.ok) { const check = await r2.json(); if (check.loggedIn) { + clearUserAuth(); setTsProgress("Starting funnel..."); const res2 = await fetch("/api/tunnel/tailscale-enable", { method: "POST" }); const data2 = await res2.json(); @@ -546,6 +554,7 @@ export default function APIPageClient({ machineId }) { } } catch { /* retry */ } } + clearUserAuth(); setTsStatus({ type: "error", message: "Login timed out. Please try again." }); return; } @@ -562,18 +571,20 @@ export default function APIPageClient({ machineId }) { setTsLoading(false); setTsConnecting(false); setTsProgress(""); + clearUserAuth(); } }; const pollFunnelEnable = async (enableUrl) => { - openAuthUrl(enableUrl); - setTsProgress("Enable Funnel in browser, waiting..."); + requestUserAuth(enableUrl, "Open Funnel Settings"); + setTsProgress("Click \"Open Funnel Settings\" to enable Funnel..."); for (let i = 0; i < 40; i++) { await new Promise((r) => setTimeout(r, 3000)); try { const res = await fetch("/api/tunnel/tailscale-enable", { method: "POST" }); const data = await res.json(); if (res.ok && data.success) { + clearUserAuth(); setTsUrl(data.tunnelUrl || ""); const ok3 = await pingTsHealth(data.tunnelUrl); setTsEnabled(true); @@ -582,11 +593,13 @@ export default function APIPageClient({ machineId }) { } if (data.funnelNotEnabled) continue; if (data.error) { + clearUserAuth(); setTsStatus({ type: "error", message: data.error }); return; } } catch { /* retry */ } } + clearUserAuth(); setTsStatus({ type: "error", message: "Timed out waiting for Funnel to be enabled." }); }; @@ -614,8 +627,13 @@ export default function APIPageClient({ machineId }) { const handleOpenTsModal = async () => { setTsStatus(null); setTsInstallLog([]); - setShowTsModal(true); - await checkTailscaleInstalled(); + const data = await checkTailscaleInstalled(); + if (data?.installed) { + // Skip modal, connect directly when already installed + handleConnectTailscale(); + } else { + setShowTsModal(true); + } }; const handleCreateKey = async () => { @@ -857,8 +875,17 @@ export default function APIPageClient({ machineId }) { progress_activity {tsProgress || "Connecting..."} + {tsAuthUrl && ( + + )} - {connections.length > 0 && ( -
- Only one connection is allowed per compatible node. Add another node if you need more connections. -
- )} )} @@ -1190,6 +1184,7 @@ export default function ProviderDetailPage() { proxyPools={proxyPools} error={addConnectionError} onSave={handleSaveApiKey} + onBulkDone={fetchConnections} onClose={() => { setAddConnectionError(""); setShowAddApiKeyModal(false); diff --git a/src/app/api/providers/route.js b/src/app/api/providers/route.js index 878a3aa..779bbcd 100644 --- a/src/app/api/providers/route.js +++ b/src/app/api/providers/route.js @@ -127,12 +127,6 @@ export async function POST(request) { if (!node) { return NextResponse.json({ error: "OpenAI Compatible node not found" }, { status: 404 }); } - - const existingConnections = await getProviderConnections({ provider }); - if (existingConnections.length > 0) { - return NextResponse.json({ error: "Only one connection is allowed for this OpenAI Compatible node" }, { status: 400 }); - } - providerSpecificData = { prefix: node.prefix, apiType: node.apiType, @@ -144,12 +138,6 @@ export async function POST(request) { if (!node) { return NextResponse.json({ error: "Anthropic Compatible node not found" }, { status: 404 }); } - - const existingConnections = await getProviderConnections({ provider }); - if (existingConnections.length > 0) { - return NextResponse.json({ error: "Only one connection is allowed for this Anthropic Compatible node" }, { status: 400 }); - } - providerSpecificData = { prefix: node.prefix, baseUrl: node.baseUrl, @@ -160,12 +148,6 @@ export async function POST(request) { if (!node) { return NextResponse.json({ error: "Custom Embedding node not found" }, { status: 404 }); } - - const existingConnections = await getProviderConnections({ provider }); - if (existingConnections.length > 0) { - return NextResponse.json({ error: "Only one connection is allowed for this Custom Embedding node" }, { status: 400 }); - } - providerSpecificData = { prefix: node.prefix, baseUrl: node.baseUrl, diff --git a/src/app/api/tunnel/tailscale-enable/route.js b/src/app/api/tunnel/tailscale-enable/route.js index aee60f5..2115776 100644 --- a/src/app/api/tunnel/tailscale-enable/route.js +++ b/src/app/api/tunnel/tailscale-enable/route.js @@ -6,7 +6,7 @@ export async function POST() { const result = await enableTailscale(); return NextResponse.json(result); } catch (error) { - console.error("Tailscale enable error:", error); + console.error("Tailscale enable error:", error.message); return NextResponse.json({ error: error.message }, { status: 500 }); } } diff --git a/src/lib/db/adapters/bunSqliteAdapter.js b/src/lib/db/adapters/bunSqliteAdapter.js new file mode 100644 index 0000000..764304a --- /dev/null +++ b/src/lib/db/adapters/bunSqliteAdapter.js @@ -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, + }; +} diff --git a/src/lib/db/adapters/nodeSqliteAdapter.js b/src/lib/db/adapters/nodeSqliteAdapter.js index 1b9c16d..9948449 100644 --- a/src/lib/db/adapters/nodeSqliteAdapter.js +++ b/src/lib/db/adapters/nodeSqliteAdapter.js @@ -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; } }, diff --git a/src/lib/db/adapters/sqljsAdapter.js b/src/lib/db/adapters/sqljsAdapter.js index adc422d..613798e 100644 --- a/src/lib/db/adapters/sqljsAdapter.js +++ b/src/lib/db/adapters/sqljsAdapter.js @@ -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; } } diff --git a/src/lib/db/driver.js b/src/lib/db/driver.js index d3f0937..050514d 100644 --- a/src/lib/db/driver.js +++ b/src/lib/db/driver.js @@ -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}`); diff --git a/src/lib/tunnel/tailscale.js b/src/lib/tunnel/tailscale.js index c00676e..0951eab 100644 --- a/src/lib/tunnel/tailscale.js +++ b/src/lib/tunnel/tailscale.js @@ -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(); diff --git a/src/lib/tunnel/tunnelManager.js b/src/lib/tunnel/tunnelManager.js index 56ece18..b95a32d 100644 --- a/src/lib/tunnel/tunnelManager.js +++ b/src/lib/tunnel/tunnelManager.js @@ -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 }; }