# 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:
decolua
2026-05-10 08:44:14 +07:00
parent b39eb61c33
commit 530dc9cb3b
14 changed files with 282 additions and 71 deletions
+44 -8
View 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();
+23 -10
View File
@@ -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
};
}