# v0.4.29 (2026-05-10)
## Features - Add Cline & Kilo Code tool cards - Tailscale TUN mode for stable Funnel TLS - Sort APIKEY providers by usage, collapse to top 20 ## Improvements - Local Material Symbols font (no Google Fonts) - Docker base: Bun → Node 22-alpine - MITM reads aliases from JSON cache (no native sqlite) - Stream stall timeout (2 min) in open-sse ## Fixes - Fal.ai key test: use stable models endpoint
This commit is contained in:
@@ -62,7 +62,7 @@ function collectAppPids() {
|
||||
});
|
||||
} catch { /* no processes */ }
|
||||
|
||||
// Kill cloudflared + tray binaries (giữ lock app dir)
|
||||
// Kill cloudflared + tray binaries (hold app dir lock)
|
||||
for (const procName of ["cloudflared", "tray_windows_release"]) {
|
||||
try {
|
||||
const cmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-Process ${procName} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id"`;
|
||||
|
||||
46
src/lib/mitmAliasCache.js
Normal file
46
src/lib/mitmAliasCache.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// JSON cache for mitmAlias — read by standalone MITM server (no SQLite native binding).
|
||||
// Source of truth = SQLite kv['mitmAlias']. JSON is a read-replica synced on app start
|
||||
// and after every UI write.
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR
|
||||
|| (process.platform === "win32"
|
||||
? path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "9router")
|
||||
: path.join(os.homedir(), ".9router"));
|
||||
|
||||
const CACHE_FILE = path.join(DATA_DIR, "mitm", "aliases.json");
|
||||
|
||||
function writeAtomic(data) {
|
||||
const dir = path.dirname(CACHE_FILE);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const tmp = `${CACHE_FILE}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf8");
|
||||
fs.renameSync(tmp, CACHE_FILE);
|
||||
}
|
||||
|
||||
// Sync entire mitmAlias map from DB → JSON file
|
||||
export async function syncToJson() {
|
||||
try {
|
||||
const { getMitmAlias } = await import("@/lib/db/repos/aliasRepo.js");
|
||||
const all = await getMitmAlias();
|
||||
writeAtomic(all || {});
|
||||
} catch (e) {
|
||||
console.log("[mitmAliasCache] sync failed:", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache for a single tool after UI saves to DB
|
||||
export function writeAliasForTool(tool, mappings) {
|
||||
try {
|
||||
let current = {};
|
||||
if (fs.existsSync(CACHE_FILE)) {
|
||||
try { current = JSON.parse(fs.readFileSync(CACHE_FILE, "utf8")); } catch { /* corrupted → reset */ }
|
||||
}
|
||||
current[tool] = mappings || {};
|
||||
writeAtomic(current);
|
||||
} catch (e) {
|
||||
console.log("[mitmAliasCache] write failed:", e.message);
|
||||
}
|
||||
}
|
||||
@@ -433,8 +433,22 @@ async function ensureUserOwnedDir(dir) {
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** Start tailscaled in userspace-networking mode (no root, no sudo prompt). */
|
||||
export async function startDaemonWithPassword(_sudoPasswordUnused) {
|
||||
/** Check if running daemon uses TUN mode (Funnel TLS requires TUN). */
|
||||
function isDaemonTunMode() {
|
||||
try {
|
||||
const ps = execSync(`pgrep -af "tailscaled.*${TAILSCALE_SOCKET}"`, { encoding: "utf8", timeout: 2000 }).trim();
|
||||
if (!ps) return null;
|
||||
return !ps.includes("--tun=userspace-networking");
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Start tailscaled.
|
||||
* - With sudoPassword: TUN mode (root) → Funnel TLS works
|
||||
* - Without: userspace-networking fallback (no sudo, but Funnel TLS unstable)
|
||||
* State always lives in ~/.9router/tailscale/ via --statedir.
|
||||
*/
|
||||
export async function startDaemonWithPassword(sudoPassword) {
|
||||
if (IS_WINDOWS) {
|
||||
// Windows: tailscale runs as a Windows Service. Start it then poll BackendState
|
||||
// until daemon finishes init (avoids "NoState" errors when calling funnel/up too early).
|
||||
@@ -459,64 +473,62 @@ export async function startDaemonWithPassword(_sudoPasswordUnused) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect unhealthy state: dir/files not owned by current user OR multiple daemons running.
|
||||
// Either condition blocks userspace daemon → must kill all + reclaim ownership.
|
||||
let needsRestart = false;
|
||||
try {
|
||||
const st = fs.statSync(TAILSCALE_DIR);
|
||||
if (st.uid !== process.getuid()) needsRestart = true;
|
||||
// Also check state file (the actual unhealthy resource)
|
||||
const stateFile = path.join(TAILSCALE_DIR, "tailscaled.state");
|
||||
if (fs.existsSync(stateFile) && fs.statSync(stateFile).uid !== process.getuid()) needsRestart = true;
|
||||
} catch { /* dir doesn't exist yet */ }
|
||||
const wantTun = !!sudoPassword;
|
||||
const currentMode = isDaemonTunMode(); // true=TUN, false=userspace, null=not running
|
||||
|
||||
// Detect duplicate daemons on same socket → also requires restart
|
||||
if (!needsRestart) {
|
||||
try {
|
||||
const ps = execSync(`pgrep -f "tailscaled.*${TAILSCALE_SOCKET}"`, { encoding: "utf8", timeout: 2000 }).trim();
|
||||
if (ps && ps.split("\n").length > 1) needsRestart = true;
|
||||
} catch { /* no match → ok */ }
|
||||
}
|
||||
|
||||
if (needsRestart) {
|
||||
// Kill ALL tailscaled processes (root + user duplicates). Best-effort with/without sudo.
|
||||
try { execSync("pkill -9 -x tailscaled", { stdio: "ignore", timeout: 3000 }); } catch { /* ignore */ }
|
||||
try { execSync("sudo -n pkill -9 -x tailscaled", { stdio: "ignore", timeout: 3000 }); } catch { /* ignore */ }
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
} else {
|
||||
// Check if our userspace daemon already responds
|
||||
// Daemon already running in correct mode → reuse
|
||||
if (currentMode !== null && currentMode === wantTun) {
|
||||
try {
|
||||
const bin = getTailscaleBin() || "tailscale";
|
||||
execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
env: { ...process.env, PATH: EXTENDED_PATH },
|
||||
timeout: 3000
|
||||
stdio: "ignore", windowsHide: true,
|
||||
env: { ...process.env, PATH: EXTENDED_PATH }, timeout: 3000
|
||||
});
|
||||
return; // Already running and user-owned
|
||||
} catch { /* not running, start it */ }
|
||||
return;
|
||||
} catch { /* unresponsive, restart below */ }
|
||||
}
|
||||
|
||||
// Reclaim folder ownership if a previous root daemon left it locked
|
||||
// Mode mismatch or unresponsive → kill all daemons on our socket
|
||||
try { execSync(`pkill -9 -f "tailscaled.*${TAILSCALE_SOCKET}"`, { stdio: "ignore", timeout: 3000 }); } catch { /* ignore */ }
|
||||
if (sudoPassword) {
|
||||
try { await execWithPassword(`pkill -9 -f "tailscaled.*${TAILSCALE_SOCKET}"`, sudoPassword); } catch { /* ignore */ }
|
||||
} else {
|
||||
try { execSync(`sudo -n pkill -9 -f "tailscaled.*${TAILSCALE_SOCKET}"`, { stdio: "ignore", timeout: 3000 }); } catch { /* ignore */ }
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
|
||||
// Reclaim folder ownership (previous root daemon may have locked it)
|
||||
await ensureUserOwnedDir(TAILSCALE_DIR);
|
||||
|
||||
// Userspace-networking mode: no TUN device → no root needed → no sudo prompt
|
||||
const tailscaledBin = IS_MAC ? "/usr/local/bin/tailscaled" : "tailscaled";
|
||||
const args = [
|
||||
const daemonArgs = [
|
||||
`--socket=${TAILSCALE_SOCKET}`,
|
||||
`--statedir=${TAILSCALE_DIR}`,
|
||||
"--tun=userspace-networking",
|
||||
];
|
||||
if (!wantTun) daemonArgs.push("--tun=userspace-networking");
|
||||
|
||||
const child = spawn(tailscaledBin, args, {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
cwd: os.tmpdir(),
|
||||
env: { ...process.env, PATH: EXTENDED_PATH },
|
||||
});
|
||||
child.unref();
|
||||
if (wantTun) {
|
||||
// TUN mode: spawn via sudo, password via stdin. Detached so it survives parent exit.
|
||||
const child = spawn("sudo", ["-S", tailscaledBin, ...daemonArgs], {
|
||||
detached: true,
|
||||
stdio: ["pipe", "ignore", "ignore"],
|
||||
cwd: os.tmpdir(),
|
||||
env: { ...process.env, PATH: EXTENDED_PATH },
|
||||
});
|
||||
child.stdin.write(`${sudoPassword}\n`);
|
||||
child.stdin.end();
|
||||
child.unref();
|
||||
} else {
|
||||
const child = spawn(tailscaledBin, daemonArgs, {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
cwd: os.tmpdir(),
|
||||
env: { ...process.env, PATH: EXTENDED_PATH },
|
||||
});
|
||||
child.unref();
|
||||
}
|
||||
|
||||
// Wait for daemon socket to be ready
|
||||
// Wait for socket ready
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user