mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +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:
@@ -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)
|
# v0.4.27 (2026-05-09)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
serverExternalPackages: ["better-sqlite3"],
|
serverExternalPackages: ["better-sqlite3", "sql.js", "node:sqlite", "bun:sqlite"],
|
||||||
images: {
|
images: {
|
||||||
unoptimized: true
|
unoptimized: true
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-2
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "9router-app",
|
"name": "9router-app",
|
||||||
"version": "0.4.27",
|
"version": "0.4.28",
|
||||||
"description": "9Router web dashboard",
|
"description": "9Router web dashboard",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"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",
|
"build": "NODE_ENV=production next build --webpack",
|
||||||
"start": "NODE_ENV=production next start",
|
"start": "NODE_ENV=production next start",
|
||||||
"dev:bun": "bun --bun next dev --webpack --port 20128",
|
"dev:bun": "bun --bun next dev --webpack --port 20128",
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ export default function APIPageClient({ machineId }) {
|
|||||||
const [tsLoading, setTsLoading] = useState(false);
|
const [tsLoading, setTsLoading] = useState(false);
|
||||||
const [tsProgress, setTsProgress] = useState("");
|
const [tsProgress, setTsProgress] = useState("");
|
||||||
const [tsStatus, setTsStatus] = useState(null);
|
const [tsStatus, setTsStatus] = useState(null);
|
||||||
|
const [tsAuthUrl, setTsAuthUrl] = useState("");
|
||||||
|
const [tsAuthLabel, setTsAuthLabel] = useState("");
|
||||||
const [tsInstalled, setTsInstalled] = useState(null); // null=checking, true/false
|
const [tsInstalled, setTsInstalled] = useState(null); // null=checking, true/false
|
||||||
const [tsInstalling, setTsInstalling] = useState(false);
|
const [tsInstalling, setTsInstalling] = useState(false);
|
||||||
const [tsInstallLog, setTsInstallLog] = useState([]);
|
const [tsInstallLog, setTsInstallLog] = useState([]);
|
||||||
@@ -492,12 +494,16 @@ export default function APIPageClient({ machineId }) {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Open auth URL only when actually needed (avoids blank popup flash on success path).
|
// Show inline login button instead of auto-opening popup (browsers block popups
|
||||||
// Falls back to status message with clickable link if popup blocker prevents opening.
|
// opened after async work because the user gesture is lost).
|
||||||
const openAuthUrl = (url) => {
|
const requestUserAuth = (url, label) => {
|
||||||
const w = window.open(url, "tailscale_auth", "width=600,height=700");
|
setTsAuthUrl(url);
|
||||||
if (!w) setTsStatus({ type: "warning", message: `Popup blocked. Open manually: ${url}` });
|
setTsAuthLabel(label);
|
||||||
return w;
|
};
|
||||||
|
|
||||||
|
const clearUserAuth = () => {
|
||||||
|
setTsAuthUrl("");
|
||||||
|
setTsAuthLabel("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConnectTailscale = async () => {
|
const handleConnectTailscale = async () => {
|
||||||
@@ -506,6 +512,7 @@ export default function APIPageClient({ machineId }) {
|
|||||||
setTsLoading(true);
|
setTsLoading(true);
|
||||||
setTsStatus(null);
|
setTsStatus(null);
|
||||||
setTsProgress("Connecting...");
|
setTsProgress("Connecting...");
|
||||||
|
clearUserAuth();
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/tunnel/tailscale-enable", { method: "POST" });
|
const res = await fetch("/api/tunnel/tailscale-enable", { method: "POST" });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -519,8 +526,8 @@ export default function APIPageClient({ machineId }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.needsLogin && data.authUrl) {
|
if (data.needsLogin && data.authUrl) {
|
||||||
openAuthUrl(data.authUrl);
|
requestUserAuth(data.authUrl, "Open Login Page");
|
||||||
setTsProgress("Waiting for login...");
|
setTsProgress("Login required — click \"Open Login Page\" to continue");
|
||||||
for (let i = 0; i < 40; i++) {
|
for (let i = 0; i < 40; i++) {
|
||||||
await new Promise((r) => setTimeout(r, 3000));
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
try {
|
try {
|
||||||
@@ -528,6 +535,7 @@ export default function APIPageClient({ machineId }) {
|
|||||||
if (r2.ok) {
|
if (r2.ok) {
|
||||||
const check = await r2.json();
|
const check = await r2.json();
|
||||||
if (check.loggedIn) {
|
if (check.loggedIn) {
|
||||||
|
clearUserAuth();
|
||||||
setTsProgress("Starting funnel...");
|
setTsProgress("Starting funnel...");
|
||||||
const res2 = await fetch("/api/tunnel/tailscale-enable", { method: "POST" });
|
const res2 = await fetch("/api/tunnel/tailscale-enable", { method: "POST" });
|
||||||
const data2 = await res2.json();
|
const data2 = await res2.json();
|
||||||
@@ -546,6 +554,7 @@ export default function APIPageClient({ machineId }) {
|
|||||||
}
|
}
|
||||||
} catch { /* retry */ }
|
} catch { /* retry */ }
|
||||||
}
|
}
|
||||||
|
clearUserAuth();
|
||||||
setTsStatus({ type: "error", message: "Login timed out. Please try again." });
|
setTsStatus({ type: "error", message: "Login timed out. Please try again." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -562,18 +571,20 @@ export default function APIPageClient({ machineId }) {
|
|||||||
setTsLoading(false);
|
setTsLoading(false);
|
||||||
setTsConnecting(false);
|
setTsConnecting(false);
|
||||||
setTsProgress("");
|
setTsProgress("");
|
||||||
|
clearUserAuth();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const pollFunnelEnable = async (enableUrl) => {
|
const pollFunnelEnable = async (enableUrl) => {
|
||||||
openAuthUrl(enableUrl);
|
requestUserAuth(enableUrl, "Open Funnel Settings");
|
||||||
setTsProgress("Enable Funnel in browser, waiting...");
|
setTsProgress("Click \"Open Funnel Settings\" to enable Funnel...");
|
||||||
for (let i = 0; i < 40; i++) {
|
for (let i = 0; i < 40; i++) {
|
||||||
await new Promise((r) => setTimeout(r, 3000));
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/tunnel/tailscale-enable", { method: "POST" });
|
const res = await fetch("/api/tunnel/tailscale-enable", { method: "POST" });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (res.ok && data.success) {
|
if (res.ok && data.success) {
|
||||||
|
clearUserAuth();
|
||||||
setTsUrl(data.tunnelUrl || "");
|
setTsUrl(data.tunnelUrl || "");
|
||||||
const ok3 = await pingTsHealth(data.tunnelUrl);
|
const ok3 = await pingTsHealth(data.tunnelUrl);
|
||||||
setTsEnabled(true);
|
setTsEnabled(true);
|
||||||
@@ -582,11 +593,13 @@ export default function APIPageClient({ machineId }) {
|
|||||||
}
|
}
|
||||||
if (data.funnelNotEnabled) continue;
|
if (data.funnelNotEnabled) continue;
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
|
clearUserAuth();
|
||||||
setTsStatus({ type: "error", message: data.error });
|
setTsStatus({ type: "error", message: data.error });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch { /* retry */ }
|
} catch { /* retry */ }
|
||||||
}
|
}
|
||||||
|
clearUserAuth();
|
||||||
setTsStatus({ type: "error", message: "Timed out waiting for Funnel to be enabled." });
|
setTsStatus({ type: "error", message: "Timed out waiting for Funnel to be enabled." });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -614,8 +627,13 @@ export default function APIPageClient({ machineId }) {
|
|||||||
const handleOpenTsModal = async () => {
|
const handleOpenTsModal = async () => {
|
||||||
setTsStatus(null);
|
setTsStatus(null);
|
||||||
setTsInstallLog([]);
|
setTsInstallLog([]);
|
||||||
setShowTsModal(true);
|
const data = await checkTailscaleInstalled();
|
||||||
await checkTailscaleInstalled();
|
if (data?.installed) {
|
||||||
|
// Skip modal, connect directly when already installed
|
||||||
|
handleConnectTailscale();
|
||||||
|
} else {
|
||||||
|
setShowTsModal(true);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateKey = async () => {
|
const handleCreateKey = async () => {
|
||||||
@@ -857,8 +875,17 @@ export default function APIPageClient({ machineId }) {
|
|||||||
<span className="material-symbols-outlined animate-spin text-sm">progress_activity</span>
|
<span className="material-symbols-outlined animate-spin text-sm">progress_activity</span>
|
||||||
{tsProgress || "Connecting..."}
|
{tsProgress || "Connecting..."}
|
||||||
</div>
|
</div>
|
||||||
|
{tsAuthUrl && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
icon="open_in_new"
|
||||||
|
onClick={() => window.open(tsAuthUrl, "tailscale_auth", "width=600,height=700,noopener,noreferrer")}
|
||||||
|
>
|
||||||
|
{tsAuthLabel || "Open"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => { setTsLoading(false); setTsConnecting(false); setTsProgress(""); }}
|
onClick={() => { setTsLoading(false); setTsConnecting(false); setTsProgress(""); clearUserAuth(); }}
|
||||||
className="p-2 hover:bg-red-500/10 rounded text-red-500 transition-colors shrink-0"
|
className="p-2 hover:bg-red-500/10 rounded text-red-500 transition-colors shrink-0"
|
||||||
title="Stop"
|
title="Stop"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { useState } from "react";
|
|||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { Button, Badge, Input, Modal, Select } from "@/shared/components";
|
import { Button, Badge, Input, Modal, Select } from "@/shared/components";
|
||||||
|
|
||||||
export default function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, authType, authHint, website, proxyPools, error, onSave, onClose }) {
|
const BULK_PLACEHOLDER = `name1|sk-key1\nname2|sk-key2\nsk-key-only-auto-named`;
|
||||||
|
|
||||||
|
export default function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, authType, authHint, website, proxyPools, error, onSave, onBulkDone, onClose }) {
|
||||||
const NONE_PROXY_POOL_VALUE = "__none__";
|
const NONE_PROXY_POOL_VALUE = "__none__";
|
||||||
const isOllamaLocal = provider === "ollama-local";
|
const isOllamaLocal = provider === "ollama-local";
|
||||||
const isCookie = authType === "cookie";
|
const isCookie = authType === "cookie";
|
||||||
@@ -34,6 +36,9 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
|
|||||||
const [validating, setValidating] = useState(false);
|
const [validating, setValidating] = useState(false);
|
||||||
const [validationResult, setValidationResult] = useState(null);
|
const [validationResult, setValidationResult] = useState(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [mode, setMode] = useState("single"); // "single" | "bulk"
|
||||||
|
const [bulkText, setBulkText] = useState("");
|
||||||
|
const [bulkResult, setBulkResult] = useState(null); // { success, failed }
|
||||||
|
|
||||||
const buildProviderSpecificData = () => {
|
const buildProviderSpecificData = () => {
|
||||||
if (isOllamaLocal && formData.ollamaHostUrl.trim()) {
|
if (isOllamaLocal && formData.ollamaHostUrl.trim()) {
|
||||||
@@ -113,11 +118,70 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBulkSubmit = async () => {
|
||||||
|
const lines = bulkText.split("\n").map(l => l.trim()).filter(Boolean);
|
||||||
|
if (!lines.length) return;
|
||||||
|
setSaving(true);
|
||||||
|
setBulkResult(null);
|
||||||
|
let success = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const parts = lines[i].split("|");
|
||||||
|
const apiKey = parts.length >= 2 ? parts.slice(1).join("|").trim() : parts[0].trim();
|
||||||
|
const baseName = parts.length >= 2 ? parts[0].trim() : "Key";
|
||||||
|
const name = `${baseName} ${i + 1}`;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/providers", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ provider, apiKey, name, priority: 1, testStatus: "unknown" }),
|
||||||
|
});
|
||||||
|
if (res.ok) success++;
|
||||||
|
else failed++;
|
||||||
|
} catch {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
setBulkResult({ success, failed });
|
||||||
|
if (success > 0 && onBulkDone) onBulkDone();
|
||||||
|
};
|
||||||
|
|
||||||
if (!provider) return null;
|
if (!provider) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} title={`Add ${providerName || provider} ${credentialLabel}`} onClose={onClose}>
|
<Modal isOpen={isOpen} title={`Add ${providerName || provider} ${credentialLabel}`} onClose={onClose}>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Mode switcher */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant={mode === "single" ? "primary" : "ghost"} onClick={() => { setMode("single"); setBulkResult(null); }}>Single</Button>
|
||||||
|
<Button size="sm" variant={mode === "bulk" ? "primary" : "ghost"} onClick={() => { setMode("bulk"); setBulkResult(null); }}>Bulk Add</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === "bulk" && (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs text-text-muted">One key per line. Format: <code>name|apiKey</code> or just <code>apiKey</code> (auto-named by index).</p>
|
||||||
|
<textarea
|
||||||
|
className="w-full rounded border border-accent/30 bg-sidebar p-2 text-sm font-mono resize-y min-h-[140px] focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
placeholder={BULK_PLACEHOLDER}
|
||||||
|
value={bulkText}
|
||||||
|
onChange={(e) => setBulkText(e.target.value)}
|
||||||
|
/>
|
||||||
|
{bulkResult && (
|
||||||
|
<div className={`text-sm font-medium ${bulkResult.failed > 0 ? "text-yellow-400" : "text-green-400"}`}>
|
||||||
|
✓ {bulkResult.success} added{bulkResult.failed > 0 ? `, ✗ ${bulkResult.failed} failed` : ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={handleBulkSubmit} fullWidth disabled={saving || !bulkText.trim()}>
|
||||||
|
{saving ? "Adding..." : "Add All Keys"}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onClose} variant="ghost" fullWidth>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "single" && (<>
|
||||||
<Input
|
<Input
|
||||||
label="Name"
|
label="Name"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
@@ -278,6 +342,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
@@ -298,5 +363,6 @@ AddApiKeyModal.propTypes = {
|
|||||||
})),
|
})),
|
||||||
error: PropTypes.string,
|
error: PropTypes.string,
|
||||||
onSave: PropTypes.func.isRequired,
|
onSave: PropTypes.func.isRequired,
|
||||||
|
onBulkDone: PropTypes.func,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -936,7 +936,6 @@ export default function ProviderDetailPage() {
|
|||||||
setAddConnectionError("");
|
setAddConnectionError("");
|
||||||
setShowAddApiKeyModal(true);
|
setShowAddApiKeyModal(true);
|
||||||
}}
|
}}
|
||||||
disabled={connections.length > 0}
|
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
Add API Key
|
Add API Key
|
||||||
@@ -971,11 +970,6 @@ export default function ProviderDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{connections.length > 0 && (
|
|
||||||
<p className="text-sm text-text-muted">
|
|
||||||
Only one connection is allowed per compatible node. Add another node if you need more connections.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1190,6 +1184,7 @@ export default function ProviderDetailPage() {
|
|||||||
proxyPools={proxyPools}
|
proxyPools={proxyPools}
|
||||||
error={addConnectionError}
|
error={addConnectionError}
|
||||||
onSave={handleSaveApiKey}
|
onSave={handleSaveApiKey}
|
||||||
|
onBulkDone={fetchConnections}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setAddConnectionError("");
|
setAddConnectionError("");
|
||||||
setShowAddApiKeyModal(false);
|
setShowAddApiKeyModal(false);
|
||||||
|
|||||||
@@ -127,12 +127,6 @@ export async function POST(request) {
|
|||||||
if (!node) {
|
if (!node) {
|
||||||
return NextResponse.json({ error: "OpenAI Compatible node not found" }, { status: 404 });
|
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 = {
|
providerSpecificData = {
|
||||||
prefix: node.prefix,
|
prefix: node.prefix,
|
||||||
apiType: node.apiType,
|
apiType: node.apiType,
|
||||||
@@ -144,12 +138,6 @@ export async function POST(request) {
|
|||||||
if (!node) {
|
if (!node) {
|
||||||
return NextResponse.json({ error: "Anthropic Compatible node not found" }, { status: 404 });
|
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 = {
|
providerSpecificData = {
|
||||||
prefix: node.prefix,
|
prefix: node.prefix,
|
||||||
baseUrl: node.baseUrl,
|
baseUrl: node.baseUrl,
|
||||||
@@ -160,12 +148,6 @@ export async function POST(request) {
|
|||||||
if (!node) {
|
if (!node) {
|
||||||
return NextResponse.json({ error: "Custom Embedding node not found" }, { status: 404 });
|
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 = {
|
providerSpecificData = {
|
||||||
prefix: node.prefix,
|
prefix: node.prefix,
|
||||||
baseUrl: node.baseUrl,
|
baseUrl: node.baseUrl,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export async function POST() {
|
|||||||
const result = await enableTailscale();
|
const result = await enableTailscale();
|
||||||
return NextResponse.json(result);
|
return NextResponse.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Tailscale enable error:", error);
|
console.error("Tailscale enable error:", error.message);
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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); },
|
exec(sql) { return db.exec(sql); },
|
||||||
transaction(fn) {
|
transaction(fn) {
|
||||||
// node:sqlite has no built-in transaction wrapper → manual BEGIN/COMMIT
|
// node:sqlite has no transaction wrapper. Use SAVEPOINT for nested support.
|
||||||
db.exec("BEGIN");
|
const sp = `sp_${Math.random().toString(36).slice(2)}`;
|
||||||
|
db.exec(`SAVEPOINT ${sp}`);
|
||||||
try {
|
try {
|
||||||
const r = fn();
|
const r = fn();
|
||||||
db.exec("COMMIT");
|
db.exec(`RELEASE ${sp}`);
|
||||||
return r;
|
return r;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
try { db.exec("ROLLBACK"); } catch {}
|
try { db.exec(`ROLLBACK TO ${sp}`); db.exec(`RELEASE ${sp}`); } catch {}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -86,14 +86,15 @@ export async function createSqlJsAdapter(filePath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function transaction(fn) {
|
function transaction(fn) {
|
||||||
db.exec("BEGIN");
|
const sp = `sp_${Math.random().toString(36).slice(2)}`;
|
||||||
|
db.exec(`SAVEPOINT ${sp}`);
|
||||||
try {
|
try {
|
||||||
const result = fn();
|
const result = fn();
|
||||||
db.exec("COMMIT");
|
db.exec(`RELEASE ${sp}`);
|
||||||
scheduleSave();
|
scheduleSave();
|
||||||
return result;
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
db.exec("ROLLBACK");
|
try { db.exec(`ROLLBACK TO ${sp}`); db.exec(`RELEASE ${sp}`); } catch {}
|
||||||
throw e;
|
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 };
|
if (!global._dbAdapter) global._dbAdapter = { instance: null, initPromise: null, logged: false };
|
||||||
const state = global._dbAdapter;
|
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() {
|
async function tryBetterSqlite() {
|
||||||
|
// Skip on Bun — better-sqlite3 native bindings unsupported
|
||||||
|
if (process.versions.bun) return null;
|
||||||
try {
|
try {
|
||||||
const { createBetterSqliteAdapter } = await import("./adapters/betterSqliteAdapter.js");
|
const { createBetterSqliteAdapter } = await import("./adapters/betterSqliteAdapter.js");
|
||||||
return createBetterSqliteAdapter(DATA_FILE);
|
return createBetterSqliteAdapter(DATA_FILE);
|
||||||
@@ -15,7 +29,8 @@ async function tryBetterSqlite() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function tryNodeSqlite() {
|
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);
|
const [maj, min] = process.versions.node.split(".").map(Number);
|
||||||
if (maj < 22 || (maj === 22 && min < 5)) return null;
|
if (maj < 22 || (maj === 22 && min < 5)) return null;
|
||||||
try {
|
try {
|
||||||
@@ -39,11 +54,14 @@ async function trySqlJs() {
|
|||||||
|
|
||||||
async function initAdapter() {
|
async function initAdapter() {
|
||||||
ensureDirs();
|
ensureDirs();
|
||||||
// Order: native (fastest) → built-in (no install) → pure JS (universal)
|
// Order per runtime:
|
||||||
let adapter = await tryBetterSqlite();
|
// 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 tryNodeSqlite();
|
||||||
if (!adapter) adapter = await trySqlJs();
|
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) {
|
if (!state.logged) {
|
||||||
console.log(`[DB] Driver: ${adapter.driver} | file: ${DATA_FILE}`);
|
console.log(`[DB] Driver: ${adapter.driver} | file: ${DATA_FILE}`);
|
||||||
|
|||||||
@@ -95,8 +95,8 @@ export function isTailscaleLoggedIn() {
|
|||||||
timeout: 5000
|
timeout: 5000
|
||||||
});
|
});
|
||||||
const json = JSON.parse(out);
|
const json = JSON.parse(out);
|
||||||
// BackendState "Running" means fully logged in and connected
|
// BackendState=Running + Self.Online=true → device still exists in tailnet
|
||||||
return json.BackendState === "Running";
|
return json.BackendState === "Running" && json.Self?.Online === true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return false;
|
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) */
|
/** Get funnel URL from tailscale status (cached, non-blocking) */
|
||||||
export function getTailscaleFunnelUrl(port) {
|
export function getTailscaleFunnelUrl(port) {
|
||||||
if (Date.now() - funnelUrlCache.fetchedAt > PROBE_TTL_MS || funnelUrlCache.port !== port) {
|
if (Date.now() - funnelUrlCache.fetchedAt > PROBE_TTL_MS || funnelUrlCache.port !== port) {
|
||||||
@@ -646,14 +663,14 @@ export async function startFunnel(port) {
|
|||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
if (resolved) return;
|
if (resolved) return;
|
||||||
resolved = true;
|
resolved = true;
|
||||||
// --bg exits after setup, try status
|
// --bg exits after setup, read actual hostname from status
|
||||||
const url = getTailscaleFunnelUrl(port);
|
const url = getActualFunnelUrl() || getTailscaleFunnelUrl(port);
|
||||||
if (url) resolve({ tunnelUrl: url });
|
if (url) resolve({ tunnelUrl: url });
|
||||||
else reject(new Error(`Tailscale funnel timed out: ${output.trim() || "no output"}`));
|
else reject(new Error(`Tailscale funnel timed out: ${output.trim() || "no output"}`));
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
const parseFunnelUrl = (text) =>
|
// Always resolve via Self.DNSName to get the real hostname (avoids -1 suffix from conflicts)
|
||||||
(text.match(/https:\/\/[a-z0-9-]+\.[a-z0-9.-]+\.ts\.net[^\s]*/i) || [])[0]?.replace(/\/$/, "") || null;
|
const parseFunnelUrl = () => getActualFunnelUrl();
|
||||||
|
|
||||||
let funnelNotEnabled = false;
|
let funnelNotEnabled = false;
|
||||||
|
|
||||||
@@ -674,7 +691,7 @@ export async function startFunnel(port) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = parseFunnelUrl(output);
|
const url = parseFunnelUrl();
|
||||||
if (url && !resolved) {
|
if (url && !resolved) {
|
||||||
resolved = true;
|
resolved = true;
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
@@ -690,7 +707,7 @@ export async function startFunnel(port) {
|
|||||||
resolved = true;
|
resolved = true;
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
console.log(`[Tailscale] funnel exit code=${code} output="${output.trim().slice(0, 200)}"`);
|
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 });
|
if (url) resolve({ tunnelUrl: url });
|
||||||
else reject(new Error(`tailscale funnel failed (code ${code}): ${output.trim()}`));
|
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 */
|
/** Stop tailscale funnel */
|
||||||
export function stopFunnel() {
|
export function stopFunnel() {
|
||||||
const bin = getTailscaleBin();
|
const bin = getTailscaleBin();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { loadState, saveState, generateShortId } from "./state.js";
|
import { loadState, saveState, generateShortId } from "./state.js";
|
||||||
import { spawnQuickTunnel, killCloudflared, isCloudflaredRunning, setUnexpectedExitHandler } from "./cloudflared.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 { getSettings, updateSettings } from "@/lib/localDb";
|
||||||
import { getCachedPassword, loadEncryptedPassword, initDbHooks } from "@/mitm/manager";
|
import { getCachedPassword, loadEncryptedPassword, initDbHooks } from "@/mitm/manager";
|
||||||
import { waitForHealth, probeUrlAlive } from "./networkProbe.js";
|
import { waitForHealth, probeUrlAlive } from "./networkProbe.js";
|
||||||
@@ -250,15 +250,26 @@ export async function enableTailscale(localPort = 20128) {
|
|||||||
await updateSettings({ tailscaleEnabled: true, tailscaleUrl: result.tunnelUrl });
|
await updateSettings({ tailscaleEnabled: true, tailscaleUrl: result.tunnelUrl });
|
||||||
console.log(`[Tailscale] funnel up: ${result.tunnelUrl}`);
|
console.log(`[Tailscale] funnel up: ${result.tunnelUrl}`);
|
||||||
|
|
||||||
// Verify funnel actually serves /api/health
|
// Provision TLS cert so Funnel can serve HTTPS (non-fatal if fails)
|
||||||
await waitForHealth(result.tunnelUrl, token);
|
const hostname = new URL(result.tunnelUrl).hostname;
|
||||||
console.log("[Tailscale] enable success");
|
await provisionCert(hostname);
|
||||||
|
|
||||||
// Prime reachable cache so UI shows correct state immediately
|
// Verify funnel serves /api/health — timeout is non-fatal (DNS may still be propagating)
|
||||||
tailscaleReachable.value = true;
|
let reachableNow = false;
|
||||||
tailscaleReachable.url = result.tunnelUrl;
|
try {
|
||||||
tailscaleReachable.fetchedAt = Date.now();
|
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 };
|
return { success: true, tunnelUrl: result.tunnelUrl };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[Tailscale] enable error: ${e.message}`);
|
console.error(`[Tailscale] enable error: ${e.message}`);
|
||||||
@@ -281,8 +292,9 @@ export async function getTailscaleStatus() {
|
|||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
const settingsEnabled = settings.tailscaleEnabled === true;
|
const settingsEnabled = settings.tailscaleEnabled === true;
|
||||||
const tunnelUrl = settings.tailscaleUrl || "";
|
const tunnelUrl = settings.tailscaleUrl || "";
|
||||||
// Lazy: skip execSync funnel-status probe when user disabled Tailscale
|
// Skip probes entirely when disabled; check login before running (device removed = not logged in)
|
||||||
const running = settingsEnabled ? isTailscaleRunning() : false;
|
const loggedIn = settingsEnabled ? isTailscaleLoggedIn() : false;
|
||||||
|
const running = loggedIn ? isTailscaleRunning() : false;
|
||||||
// Reachable: cached background probe (never blocks the request)
|
// Reachable: cached background probe (never blocks the request)
|
||||||
const reachable = settingsEnabled && running ? readReachable(tailscaleReachable, tunnelUrl) : false;
|
const reachable = settingsEnabled && running ? readReachable(tailscaleReachable, tunnelUrl) : false;
|
||||||
return {
|
return {
|
||||||
@@ -290,6 +302,7 @@ export async function getTailscaleStatus() {
|
|||||||
settingsEnabled,
|
settingsEnabled,
|
||||||
tunnelUrl,
|
tunnelUrl,
|
||||||
running,
|
running,
|
||||||
|
loggedIn,
|
||||||
reachable
|
reachable
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user