## Features - Xiaomi MiMo Token Plan: region selector (Singapore / China / Europe) — keys are cluster-specific - Antigravity: risk confirmation dialog before first connection - Gemini CLI: surface upstream retry delay on 429 errors ## Fixes - MITM: cannot kill process on macOS under sudo (lsof not found in PATH) - Stream: false-positive stall timeout on Claude reasoning / Kiro responses - Tunnel: cannot re-enable after disable (stuck state) - Tunnel: cloudflared error messages now include log tail for easier debugging - Language switcher: applies selected locale immediately on close (#1234) - Antigravity OAuth: metadata now matches the official client ## Improvements - Gemini CLI: bump engine to 0.34.0 - Re-hide `qwen` (OAuth EOL) and `iflow` (not ready) providers
244 lines
7.5 KiB
JavaScript
244 lines
7.5 KiB
JavaScript
import { NextResponse } from "next/server";
|
|
import { getSettings, validateApiKey } from "@/lib/localDb";
|
|
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
|
import { verifyDashboardAuthToken } from "@/lib/auth/dashboardSession";
|
|
|
|
const CLI_TOKEN_HEADER = "x-9r-cli-token";
|
|
const CLI_TOKEN_SALT = "9r-cli-auth";
|
|
|
|
let cachedCliToken = null;
|
|
async function getCliToken() {
|
|
if (!cachedCliToken) cachedCliToken = await getConsistentMachineId(CLI_TOKEN_SALT);
|
|
return cachedCliToken;
|
|
}
|
|
|
|
async function hasValidCliToken(request) {
|
|
const token = request.headers.get(CLI_TOKEN_HEADER);
|
|
if (!token) return false;
|
|
return token === await getCliToken();
|
|
}
|
|
|
|
// Public API paths — no auth required (LLM API has its own key auth inside handler).
|
|
const PUBLIC_API_PATHS = [
|
|
"/api/health",
|
|
"/api/init",
|
|
"/api/locale",
|
|
"/api/auth/login",
|
|
"/api/auth/logout",
|
|
"/api/auth/status",
|
|
"/api/auth/oidc",
|
|
"/api/version",
|
|
"/api/settings/require-login",
|
|
];
|
|
|
|
// Public top-level prefixes (LLM API endpoints with their own API key auth).
|
|
const PUBLIC_PREFIXES = ["/v1", "/v1beta", "/api/v1", "/api/v1beta"];
|
|
|
|
// Always require JWT token regardless of requireLogin setting
|
|
const ALWAYS_PROTECTED = [
|
|
"/api/shutdown",
|
|
"/api/settings/database",
|
|
"/api/version/shutdown",
|
|
"/api/version/update",
|
|
"/api/oauth/cursor/auto-import",
|
|
"/api/oauth/kiro/auto-import",
|
|
];
|
|
|
|
// Require auth, but allow through if requireLogin is disabled
|
|
const PROTECTED_API_PATHS = [
|
|
"/api/settings",
|
|
"/api/keys",
|
|
"/api/providers",
|
|
"/api/provider-nodes",
|
|
"/api/proxy-pools",
|
|
"/api/combos",
|
|
"/api/models",
|
|
"/api/usage",
|
|
"/api/oauth",
|
|
"/api/cloud",
|
|
"/api/media-providers",
|
|
"/api/pricing",
|
|
"/api/tags",
|
|
"/api/cli-tools",
|
|
"/api/mcp",
|
|
"/api/translator",
|
|
"/api/tunnel",
|
|
];
|
|
|
|
// Routes that spawn child processes or read host secrets — restrict to localhost.
|
|
const LOCAL_ONLY_PATHS = [
|
|
"/api/cli-tools/cowork-settings",
|
|
"/api/cli-tools/antigravity-mitm",
|
|
"/api/mcp/",
|
|
"/api/tunnel/tailscale-install",
|
|
"/api/tunnel/tailscale-enable",
|
|
"/api/tunnel/tailscale-disable",
|
|
"/api/tunnel/tailscale-login",
|
|
"/api/tunnel/tailscale-start-daemon",
|
|
"/api/tunnel/tailscale-check",
|
|
"/api/tunnel/enable",
|
|
"/api/tunnel/disable",
|
|
"/api/oauth/cursor/auto-import",
|
|
"/api/oauth/kiro/auto-import",
|
|
];
|
|
|
|
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
|
|
function isLoopbackHostname(h) {
|
|
if (!h) return false;
|
|
const name = h.split(":")[0].replace(/^\[|\]$/g, "").toLowerCase();
|
|
return LOOPBACK_HOSTS.has(name);
|
|
}
|
|
|
|
function isLocalRequest(request) {
|
|
if (!isLoopbackHostname(request.headers.get("host"))) return false;
|
|
const origin = request.headers.get("origin");
|
|
if (origin) {
|
|
try {
|
|
if (!isLoopbackHostname(new URL(origin).hostname)) return false;
|
|
} catch { return false; }
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function isPublicLlmApi(pathname) {
|
|
return PUBLIC_PREFIXES.some((p) => pathname === p || pathname.startsWith(`${p}/`));
|
|
}
|
|
|
|
function extractApiKey(request) {
|
|
const authHeader = request.headers.get("Authorization");
|
|
if (authHeader?.startsWith("Bearer ")) return authHeader.slice(7);
|
|
return request.headers.get("x-api-key");
|
|
}
|
|
|
|
async function hasValidApiKey(request) {
|
|
const apiKey = extractApiKey(request);
|
|
if (!apiKey) return false;
|
|
return await validateApiKey(apiKey);
|
|
}
|
|
|
|
async function canAccessPublicLlmApi(request) {
|
|
if (isLocalRequest(request)) return true;
|
|
if (await hasValidCliToken(request)) return true;
|
|
return await hasValidApiKey(request);
|
|
}
|
|
|
|
async function canAccessLocalOnlyRoute(request) {
|
|
if (await hasValidCliToken(request)) return true;
|
|
// Browser on host: loopback Host + Origin (blocks tunnel/CSRF) + JWT cookie (blocks unauth raw clients)
|
|
if (isLocalRequest(request) && await hasValidToken(request)) return true;
|
|
return false;
|
|
}
|
|
|
|
async function hasValidToken(request) {
|
|
const token = request.cookies.get("auth_token")?.value;
|
|
return await verifyDashboardAuthToken(token);
|
|
}
|
|
|
|
// Read settings directly from DB to avoid self-fetch deadlock in proxy
|
|
async function loadSettings() {
|
|
try {
|
|
return await getSettings();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function isAuthenticated(request) {
|
|
if (await hasValidToken(request)) return true;
|
|
const settings = await loadSettings();
|
|
if (settings && settings.requireLogin === false) return true;
|
|
return false;
|
|
}
|
|
|
|
function isPublicApi(pathname) {
|
|
if (isPublicLlmApi(pathname)) return true;
|
|
return PUBLIC_API_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`));
|
|
}
|
|
|
|
export const __test__ = {
|
|
isLocalRequest,
|
|
isPublicLlmApi,
|
|
extractApiKey,
|
|
canAccessPublicLlmApi,
|
|
canAccessLocalOnlyRoute,
|
|
};
|
|
|
|
export async function proxy(request) {
|
|
const { pathname } = request.nextUrl;
|
|
|
|
// Local-only gate for spawn-capable / host-secret routes.
|
|
if (LOCAL_ONLY_PATHS.some((p) => pathname.startsWith(p))) {
|
|
if (!(await canAccessLocalOnlyRoute(request))) {
|
|
return NextResponse.json({ error: "Local only: CLI token required" }, { status: 403 });
|
|
}
|
|
}
|
|
|
|
// Always protected - require valid JWT or local CLI token (machineId-based)
|
|
if (ALWAYS_PROTECTED.some((p) => pathname.startsWith(p))) {
|
|
if (await hasValidCliToken(request) || await hasValidToken(request))
|
|
return NextResponse.next();
|
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
if (isPublicLlmApi(pathname)) {
|
|
if (await canAccessPublicLlmApi(request)) return NextResponse.next();
|
|
return NextResponse.json({ error: "API key required for remote API access" }, { status: 401 });
|
|
}
|
|
|
|
// Deny-by-default for /api/* — public allow-list bypasses, everything else requires auth.
|
|
if (pathname.startsWith("/api/")) {
|
|
if (isPublicApi(pathname)) return NextResponse.next();
|
|
if (await hasValidCliToken(request) || await isAuthenticated(request))
|
|
return NextResponse.next();
|
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
// Protect all dashboard routes
|
|
if (pathname.startsWith("/dashboard")) {
|
|
let requireLogin = true;
|
|
let tunnelDashboardAccess = true;
|
|
|
|
try {
|
|
const settings = await loadSettings();
|
|
if (settings) {
|
|
requireLogin = settings.requireLogin !== false;
|
|
tunnelDashboardAccess = settings.tunnelDashboardAccess === true;
|
|
|
|
// Block tunnel/tailscale access if disabled (redirect to login)
|
|
if (!tunnelDashboardAccess) {
|
|
const host = (request.headers.get("host") || "").split(":")[0].toLowerCase();
|
|
const tunnelHost = settings.tunnelUrl ? new URL(settings.tunnelUrl).hostname.toLowerCase() : "";
|
|
const tailscaleHost = settings.tailscaleUrl ? new URL(settings.tailscaleUrl).hostname.toLowerCase() : "";
|
|
if ((tunnelHost && host === tunnelHost) || (tailscaleHost && host === tailscaleHost)) {
|
|
return NextResponse.redirect(new URL("/login", request.url));
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// On error, keep defaults (require login, block tunnel)
|
|
}
|
|
|
|
// If login not required, allow through
|
|
if (!requireLogin) return NextResponse.next();
|
|
|
|
// Verify JWT token
|
|
const token = request.cookies.get("auth_token")?.value;
|
|
if (token) {
|
|
if (await verifyDashboardAuthToken(token)) {
|
|
return NextResponse.next();
|
|
} else {
|
|
return NextResponse.redirect(new URL("/login", request.url));
|
|
}
|
|
}
|
|
|
|
return NextResponse.redirect(new URL("/login", request.url));
|
|
}
|
|
|
|
// Redirect / to /dashboard if logged in, or /dashboard if it's the root
|
|
if (pathname === "/") {
|
|
return NextResponse.redirect(new URL("/dashboard", request.url));
|
|
}
|
|
|
|
return NextResponse.next();
|
|
}
|