/** * Dashboard API route handlers. * All routes are under /dashboard/api/*. */ import { config, log } from '../config.js'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { getAccountList, getAccountCount, addAccountByKey, addAccountByToken, removeAccount, setAccountStatus, resetAccountErrors, updateAccountLabel, isAuthenticated, probeAccount, ensureLsForAccount, refreshCredits, refreshAllCredits, setAccountBlockedModels, setAccountTokens, setAccountTier, getAccountInternal, isLocalBindHost, maskApiKey, safeEqualString, checkLockout, failedAuthAttempt, successfulAuthAttempt, getDroughtSummary, } from '../auth.js'; import { restartLsForProxy } from '../langserver.js'; import { getLsStatus, stopLanguageServer, startLanguageServer, isLanguageServerRunning } from '../langserver.js'; import { getStats, resetStats, recordRequest } from './stats.js'; import { cacheStats, cacheClear } from '../cache.js'; import { getExperimental, setExperimental, getSystemPrompts, setSystemPrompts, resetSystemPrompt, getCredentials, setRuntimeApiKey, setRuntimeDashboardPassword, verifyPassword, getEffectiveApiKey, getEffectiveDashboardPasswordStored, } from '../runtime-config.js'; import { poolStats as convPoolStats, poolClear as convPoolClear } from '../conversation-pool.js'; import { getLogs, subscribeToLogs, unsubscribeFromLogs } from './logger.js'; import { getProxyConfig, getProxyConfigMasked, setGlobalProxy, setAccountProxy, removeProxy, getEffectiveProxy } from './proxy-config.js'; import { MODELS, MODEL_TIER_ACCESS as _TIER_TABLE, getTierModels as _getTierModels } from '../models.js'; import { windsurfLogin, refreshFirebaseToken, reRegisterWithCodeium } from './windsurf-login.js'; import { getModelAccessConfig, setModelAccessMode, setModelAccessList, addModelToList, removeModelFromList } from './model-access.js'; import { checkMessageRateLimit } from '../windsurf-api.js'; import { assertPublicUrlHost } from '../image.js'; import { validateHostFormat } from '../net-safety.js'; import { discoverWindsurfCredentials, isLoopbackAddress } from './local-windsurf.js'; import { detectDockerSelfUpdate, runDockerSelfUpdate } from './docker-self-update.js'; import { getStatus as getQuietWindowStatus, setEnabled as setQuietWindowEnabled, _runOneTick as runQuietWindowTickNow, } from './quiet-window-updater.js'; export function parseProxyUrl(proxy) { // Normalize whitespace so "socks5 127.0.0.1 1089" and // "socks5://127.0.0.1:1089" both parse correctly. const s = String(proxy).replace(/\s+/g, ' ').trim(); // Try canonical URL form first: protocol://[user:pass@]host:port // Host must not contain spaces — otherwise "http 1.2.3.4:8080" would // greedily capture "http 1.2.3.4" as the host. let m = s.match(/^(?:(\w+):\/\/)?(?:([^\s:]+):([^\s@]+)@)?([^\s:]+):(\d+)$/); // Fallback: "type host port" (space-separated, no :// and no colon) if (!m) m = s.match(/^(\w+)\s+([^\s:]+)\s+(\d+)$/); // Fallback: "type host:port" (type prefix, no ://) if (!m) m = s.match(/^(\w+)\s+([^\s:]+):(\d+)$/); if (!m) return null; if (m.length === 4) { // space-or-type-separated form: [type] host port return { type: m[1], host: m[2], port: parseInt(m[3]), username: '', password: '', }; } return { type: m[1] || 'http', host: m[4], port: parseInt(m[5]), username: m[2] || '', password: m[3] || '', }; } export function buildBatchProxyBinding(result, proxy) { const accountId = result?.account?.id || null; if (!result?.success || !proxy || !accountId) return null; const parsed = parseProxyUrl(proxy); if (!parsed) return null; return { accountId, proxy: parsed, }; } function json(res, status, body) { const data = JSON.stringify(body); res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, X-Dashboard-Password', }); res.end(data); } // v2.0.56: client IP extraction. Mirrors caller-key.js TRUST_PROXY_XFF // — we only honour X-Forwarded-For when the operator opts in. Default is // `socket.remoteAddress` so a rogue dashboard caller can't dodge the // brute-force lockout by spoofing XFF and ending up on a fresh bucket. function dashboardClientIp(req) { const remote = req?.socket?.remoteAddress || req?.connection?.remoteAddress || ''; if (process.env.TRUST_PROXY_X_FORWARDED_FOR !== '1') return remote; const fwd = String(req?.headers?.['x-forwarded-for'] || '').split(',')[0].trim(); return fwd || remote; } function checkAuth(req) { // Header-only auth. logs/stream switched from EventSource to fetch + // ReadableStream months ago, so the EventSource exception is gone and // ?pwd= query passwords would only leak into URL access logs and // browser history without any callers needing them. // // v2.0.55 (audit H1): on non-local binds we no longer fall back to // `config.apiKey` as the dashboard password. That fallback turned // every chat-API caller into a service operator (list accounts, // reveal-key, change proxy, trigger LS / docker self-update). Public // bind WITHOUT DASHBOARD_PASSWORD now fails closed; operators must // set DASHBOARD_PASSWORD explicitly. Localhost-only deployments keep // the convenience fallback so single-user `docker-compose up` doesn't // suddenly require an extra env. // // v2.0.56: dashboardPassword now comes from runtime-config (settable // from the dashboard) before falling back to env. apiKey fallback // (localhost only) also honours the runtime override. const pw = req.headers['x-dashboard-password'] || ''; const storedDashboardPw = getEffectiveDashboardPasswordStored(); if (storedDashboardPw) return verifyPassword(pw, storedDashboardPw); if (isLocalBindHost()) { const effectiveApiKey = getEffectiveApiKey(); if (effectiveApiKey) return safeEqualString(pw, effectiveApiKey); return true; } return false; } async function processWindsurfLogin({ email, password, loginProxy, autoAdd }) { if (!email || !password) { const err = new Error('ERR_EMAIL_PASSWORD_REQUIRED'); err.statusCode = 400; err.code = 'ERR_EMAIL_PASSWORD_REQUIRED'; throw err; } // Use provided proxy, or global proxy const proxy = loginProxy?.host ? loginProxy : getProxyConfig().global; const result = await windsurfLogin(email, password, proxy); // Auto-add to account pool if requested let account = null; if (autoAdd !== false) { account = addAccountByKey(result.apiKey, result.name || email); // Persist refresh token via the setter so it survives restart and // the background Firebase-renewal loop can find it. if (result.refreshToken) { setAccountTokens(account.id, { refreshToken: result.refreshToken, idToken: result.idToken }); } // Persist the per-account proxy we used for login so chat requests // also egress through the same IP, then warm up a matching LS. if (loginProxy?.host) setAccountProxy(account.id, loginProxy); ensureLsForAccount(account.id) .then(() => probeAccount(account.id)) .catch(e => log.warn(`Auto-probe failed: ${e.message}`)); } return { success: true, // When autoAdd:false, the caller is doing a one-time login to retrieve the // upstream key without storing it (e.g. external tooling that wants the // raw key) — they need the full apiKey. When autoAdd is true, the key is // already persisted in the pool and the response only echoes a masked // form (the dashboard never needs the raw key in the listing path; the // explicit reveal-key endpoint covers the rare per-account export case). ...(autoAdd === false ? { apiKey: result.apiKey } : { apiKey_masked: maskApiKey(result.apiKey) }), name: result.name, email: result.email, apiServerUrl: result.apiServerUrl, account: account ? { id: account.id, email: account.email, status: account.status } : null, }; } /** * Handle all /dashboard/api/* requests. */ export async function handleDashboardApi(method, subpath, body, req, res) { if (method === 'OPTIONS') return json(res, 204, ''); // v2.0.56: brute-force lockout — apply BEFORE the auth check so the // password comparison itself can't be used as an oracle once the IP is // banned. /auth route is exempt (unauthenticated probe used by the UI // to learn whether auth is required) but still feeds the lockout when // it serves as a credential-verification endpoint. const clientIp = dashboardClientIp(req); const lock = checkLockout(clientIp); if (lock.blocked) { res.setHeader?.('Retry-After', String(Math.ceil(lock.retryAfterMs / 1000))); return json(res, 429, { error: `Too many failed attempts. IP banned for ${Math.ceil(lock.retryAfterMs / 1000)}s.`, retryAfterMs: lock.retryAfterMs, }); } // Auth check (except for auth verification endpoint) if (subpath !== '/auth' && !checkAuth(req)) { failedAuthAttempt(clientIp); return json(res, 401, { error: 'Unauthorized. Set X-Dashboard-Password header.' }); } if (subpath !== '/auth') successfulAuthAttempt(clientIp); // ─── Auth ───────────────────────────────────────────── if (subpath === '/auth') { const storedPw = getEffectiveDashboardPasswordStored(); const effectiveApiKey = getEffectiveApiKey(); const hasSecret = !!(storedPw || effectiveApiKey); if (hasSecret) { const ok = checkAuth(req); // /auth is the credential-probe endpoint dashboards call before // showing the rest of the UI — count failures here so a brute-force // script doesn't get unlimited attempts via /auth alone. if (ok) successfulAuthAttempt(clientIp); else if (req.headers['x-dashboard-password']) failedAuthAttempt(clientIp); return json(res, 200, { required: true, valid: ok }); } // No secret configured. On localhost binds the dashboard is open; on // public binds checkAuth fails closed (see Fix 1 / Fix 3) so the UI must // know auth is required-but-unconfigurable so it can prompt the operator // to set DASHBOARD_PASSWORD or API_KEY rather than show a useless prompt. if (isLocalBindHost()) return json(res, 200, { required: false }); return json(res, 200, { required: true, valid: false, locked: true }); } // ─── Overview ───────────────────────────────────────── if (subpath === '/overview' && method === 'GET') { const stats = getStats(); return json(res, 200, { uptime: process.uptime(), startedAt: stats.startedAt, accounts: getAccountCount(), authenticated: isAuthenticated(), langServer: getLsStatus(), totalRequests: stats.totalRequests, successCount: stats.successCount, errorCount: stats.errorCount, successRate: stats.totalRequests > 0 ? ((stats.successCount / stats.totalRequests) * 100).toFixed(1) : '0.0', cache: cacheStats(), }); } // ─── Experimental features ──────────────────────────── if (subpath === '/experimental' && method === 'GET') { return json(res, 200, { flags: getExperimental(), conversationPool: convPoolStats() }); } if (subpath === '/experimental' && method === 'PUT') { const flags = setExperimental(body || {}); // Dropping the toggle should also drop any live entries so nothing // resumes against a disabled feature on the next request. if (!flags.cascadeConversationReuse) convPoolClear(); return json(res, 200, { success: true, flags }); } if (subpath === '/experimental/conversation-pool' && method === 'DELETE') { const n = convPoolClear(); return json(res, 200, { success: true, cleared: n }); } // ─── System prompts (tool reinforcement, communication) ── if (subpath === '/system-prompts' && method === 'GET') { return json(res, 200, { prompts: getSystemPrompts() }); } if (subpath === '/system-prompts' && method === 'PUT') { const prompts = setSystemPrompts(body || {}); return json(res, 200, { success: true, prompts }); } if (subpath.match(/^\/system-prompts\/[^/]+$/) && method === 'DELETE') { const key = subpath.split('/').pop(); const prompts = resetSystemPrompt(key); return json(res, 200, { success: true, prompts }); } // ─── Proxy test — try an HTTP CONNECT through the given proxy ── if (subpath === '/test-proxy' && method === 'POST') { const { host, port, username, password, type = 'http' } = body || {}; if (!host || !port) return json(res, 400, { ok: false, error: 'ERR_HOST_PORT_REQUIRED' }); const startTime = Date.now(); try { const result = await testProxy({ host, port: Number(port), username, password, type }); return json(res, 200, { ok: true, ...result, latencyMs: Date.now() - startTime }); } catch (err) { return json(res, 200, { ok: false, error: err.message, latencyMs: Date.now() - startTime }); } } // ─── v2.0.67 (#112) — Quiet-window auto-update ──────── if (subpath === '/auto-update/quiet-window' && method === 'GET') { return json(res, 200, { ok: true, ...getQuietWindowStatus() }); } if (subpath === '/auto-update/quiet-window' && method === 'PUT') { const enabled = !!body?.enabled; return json(res, 200, { ok: true, ...setQuietWindowEnabled(enabled) }); } if (subpath === '/auto-update/quiet-window/run' && method === 'POST') { // Force one tick now (operator wants to test the path without // waiting for the next minute boundary). Honours the same gates as // the periodic tick — disabled / cold-start / cooldown / busy will // still short-circuit. try { const result = await runQuietWindowTickNow(); return json(res, 200, { ok: true, result }); } catch (e) { return json(res, 500, { ok: false, error: e.message }); } } // ─── Self-update: pull latest code + restart PM2 ────── if (subpath === '/self-update/check' && method === 'GET') { try { const info = await gitStatus(); return json(res, 200, { ok: true, mode: 'git', ...info }); } catch (err) { if (isSelfUpdateUnavailableError(err)) { // Git path is unavailable (most often docker). Fall back to // docker self-update via /var/run/docker.sock if the user // mounted it into the container; otherwise report the same // "manual command" hint we did before. const docker = await detectDockerSelfUpdate(); if (docker.available) { return json(res, 200, { ok: true, mode: 'docker', image: docker.image, project: docker.project, workingDir: docker.workingDir, }); } return json(res, 200, { ok: false, available: false, reason: err.reason, error: err.code, dockerReason: docker.reason, dockerDetail: docker.detail, }); } return json(res, 200, { ok: false, error: err.message }); } } if (subpath === '/self-update' && method === 'POST') { try { const before = await gitStatus(); // Guard: working tree must be clean (ignoring untracked files like // accounts.json, stats.json, runtime-config.json which live in the // repo root but aren't checked in). If the tracked files were edited // manually (or pushed via SFTP without a corresponding commit), // `git pull --ff-only` would refuse — surface a friendly error // instead of a raw git message. const dirty = (await runGit(['status', '--porcelain', '-uno'])).trim(); if (dirty) { const allowForce = !!(body && body.forceReset); if (!allowForce) { return json(res, 200, { ok: false, dirty: true, error: 'ERR_UNCOMMITTED_CHANGES', dirtyFiles: dirty.split('\n').slice(0, 20), }); } // branch comes from `git rev-parse --abbrev-ref HEAD`; execFile // doesn't spawn a shell so metacharacters can't break out — the // regex is kept as defence-in-depth so a malformed ref can't feed // a bogus `origin/xxx` spec to `git fetch`. const safeBranch = /^[\w.\-\/]+$/.test(before.branch || '') ? before.branch : 'master'; await runGit(['fetch', 'origin', safeBranch]); await runGit(['reset', '--hard', `origin/${safeBranch}`]); } const safeBranch = /^[\w.\-\/]+$/.test(before.branch || '') ? before.branch : 'master'; // execFile can't do `2>&1`; use child_process stderr merge via // combining stdout+stderr explicitly. runGit already pipes stderr // into the Error message on failure, so for success we get just // stdout, which is what the UI displays. const pull = dirty ? 'hard-reset applied' : await runGit(['pull', 'origin', safeBranch, '--ff-only']); const after = await gitStatus(); const changed = before.commit !== after.commit; // Schedule process exit so PM2 auto-restarts us. This is far simpler // and port/env-agnostic compared to spawning update.sh (which hardcodes // PORT=3003 default). Requires PM2 autorestart: true (the default). // // v2.0.85 (#127 123cek): graceful-stop the LS pool before exit so // SIGKILL from PM2 doesn't leave orphan language_server_linux_x64 // processes holding ports. Startup-time cleanup also runs as a // backstop, but stopping cleanly here means the next process won't // even need cleanup most of the time. if (changed) { setTimeout(async () => { log.info('self-update: stopping LS pool before exit'); try { // v2.0.88 (audit H-4): use the await-and-wait variant so // SIGTERM has time to land before process.exit reparents // surviving children to init. Otherwise the new PM2-spawned // process races with an orphan LS holding the same port. const m = await import('../langserver.js'); await m.stopLanguageServerAndWait({ perProcessTimeoutMs: 1500 }); } catch (e) { log.warn(`self-update: stopLanguageServer failed: ${e.message}`); } log.info('self-update: exiting for PM2 auto-restart'); process.exit(0); }, 800); } return json(res, 200, { ok: true, changed, before: before.commit, after: after.commit, pullOutput: pull.trim(), restarting: changed, }); } catch (err) { if (isSelfUpdateUnavailableError(err)) { // Same fallback as /self-update/check: when git is unavailable // (docker), try the docker socket path. The dashboard may already // have called /self-update/check first and routed the user // straight to a docker-mode confirmation, but supporting fallback // here too keeps `POST /self-update` self-contained for scripts. const docker = await detectDockerSelfUpdate(); if (docker.available) { const result = await runDockerSelfUpdate(); return json(res, 200, { mode: 'docker', ...result }); } return json(res, 200, { ok: false, available: false, reason: err.reason, error: err.code, dockerReason: docker.reason, dockerDetail: docker.detail, }); } return json(res, 200, { ok: false, error: err.message }); } } // ─── Cache ──────────────────────────────────────────── if (subpath === '/cache' && method === 'GET') { return json(res, 200, cacheStats()); } if (subpath === '/cache' && method === 'DELETE') { cacheClear(); return json(res, 200, { success: true }); } // ─── Accounts ───────────────────────────────────────── if (subpath === '/accounts' && method === 'GET') { return json(res, 200, { accounts: getAccountList() }); } if (subpath === '/accounts' && method === 'POST') { try { if (!body.api_key && !body.token) { return json(res, 400, { error: 'Provide api_key or token' }); } let parsedProxy = null; if (body.proxy) { parsedProxy = parseProxyUrl(body.proxy); if (!parsedProxy) { return json(res, 400, { error: 'ERR_PROXY_FORMAT_INVALID' }); } try { if (config.allowPrivateProxyHosts) { await validateHostFormat(parsedProxy.host); } else { await assertPublicUrlHost(parsedProxy.host); } } catch (e) { return json(res, 400, { error: e.message || 'ERR_PROXY_INVALID' }); } } const account = body.api_key ? addAccountByKey(body.api_key, body.label) : await addAccountByToken(body.token, body.label); if (parsedProxy) { setAccountProxy(account.id, parsedProxy); ensureLsForAccount(account.id).catch(e => log.warn(`LS ensure failed: ${e.message}`)); } // Fire-and-forget probe so the UI gets tier info shortly after add probeAccount(account.id).catch(e => log.warn(`Auto-probe failed: ${e.message}`)); return json(res, 200, { success: true, account: { id: account.id, email: account.email, method: account.method, status: account.status }, ...getAccountCount(), }); } catch (err) { return json(res, 400, { error: err.message }); } } // GET /accounts/import-local-availability — v2.0.60: cheap probe so the // dashboard can hide / disable the "Import from local Windsurf" button on // public binds *before* the user clicks it. Returns the same gates the // import endpoint enforces, plus a friendly explanation. if (subpath === '/accounts/import-local-availability' && method === 'GET') { const remote = req?.socket?.remoteAddress || ''; const localBind = isLocalBindHost(); const loopback = isLoopbackAddress(remote); let available = true; let reason = ''; if (!localBind) { available = false; reason = 'public_bind'; } else if (!loopback) { available = false; reason = 'non_loopback_caller'; } return json(res, 200, { available, reason, bindHost: process.env.HOST || process.env.BIND_HOST || '0.0.0.0', remoteAddress: remote, hint: available ? '' : (reason === 'public_bind' ? '此实例绑定在公网/0.0.0.0 上 — "本地" Windsurf 是远端服务器上的,不是你电脑里的,所以这个功能被拒绝(设计如此)。要导入本机 Windsurf 凭证请用 localhost 部署。' : '只接受来自 127.0.0.1 的请求;当前调用来自 ' + (remote || '?') + '。'), }); } // GET /accounts/import-local — discover Windsurf desktop client credentials // Local-only hardening: must be bound to loopback host and remote socket // must also be loopback, so reverse proxies on public binds cannot // expose local desktop credentials. if (subpath === '/accounts/import-local' && method === 'GET') { if (!isLocalBindHost()) { log.warn('local-windsurf import refused: dashboard not bound to loopback host'); return json(res, 403, { error: 'ERR_LOCAL_IMPORT_NOT_AVAILABLE_PUBLIC_BIND' }); } const remote = req?.socket?.remoteAddress; if (!isLoopbackAddress(remote)) { log.warn(`local-windsurf import refused: non-loopback caller ${remote}`); return json(res, 403, { error: 'ERR_LOCAL_IMPORT_LOOPBACK_ONLY', message: 'Local Windsurf import only available from 127.0.0.1' }); } try { const result = await discoverWindsurfCredentials(); log.info(`local-windsurf import: found ${result.accounts.length} account(s) across ${result.sources.filter(s => s.ok).length} source(s)`); return json(res, 200, { success: true, accounts: result.accounts.map(a => ({ method: a.method, apiKey: a.apiKey, apiKeyMasked: a.apiKeyMasked, email: a.email, name: a.name, apiServerUrl: a.apiServerUrl, label: a.label, source: a.source, })), sources: result.sources, sqliteSupport: result.sqliteSupport, platform: result.platform, }); } catch (e) { log.warn(`local-windsurf import failed: ${e.message}`); return json(res, 500, { error: 'ERR_LOCAL_IMPORT_FAILED', message: e.message }); } } // POST /accounts/probe-all — probe every active account if (subpath === '/accounts/probe-all' && method === 'POST') { const list = getAccountList().filter(a => a.status === 'active'); const results = []; for (const a of list) { try { const r = await probeAccount(a.id); results.push({ id: a.id, email: a.email, tier: r?.tier || 'unknown' }); } catch (err) { results.push({ id: a.id, email: a.email, error: err.message }); } } return json(res, 200, { success: true, results }); } // POST /accounts/:id/probe — manually trigger capability probe const accountProbe = subpath.match(/^\/accounts\/([^/]+)\/probe$/); if (accountProbe && method === 'POST') { try { const result = await probeAccount(accountProbe[1]); if (!result) return json(res, 404, { error: 'Account not found' }); return json(res, 200, { success: true, ...result }); } catch (err) { return json(res, 500, { error: err.message }); } } // POST /accounts/refresh-credits — refresh every active account's balance if (subpath === '/accounts/refresh-credits' && method === 'POST') { const results = await refreshAllCredits(); return json(res, 200, { success: true, results }); } // POST /accounts/:id/refresh-credits — single-account refresh const creditRefresh = subpath.match(/^\/accounts\/([^/]+)\/refresh-credits$/); if (creditRefresh && method === 'POST') { const r = await refreshCredits(creditRefresh[1]); return json(res, r.ok ? 200 : 400, r); } // PATCH /accounts/:id const accountPatch = subpath.match(/^\/accounts\/([^/]+)$/); if (accountPatch && method === 'PATCH') { const id = accountPatch[1]; if (body.status) setAccountStatus(id, body.status); if (body.label) updateAccountLabel(id, body.label); if (body.resetErrors) resetAccountErrors(id); if (Array.isArray(body.blockedModels)) setAccountBlockedModels(id, body.blockedModels); if (body.tier) setAccountTier(id, body.tier); return json(res, 200, { success: true }); } // GET /tier-access — hardcoded FREE/PRO model entitlement tables. // The dashboard uses this to render the full per-account model grid // (every row in the tier's list is shown, blocked models are dimmed). if (subpath === '/tier-access' && method === 'GET') { return json(res, 200, { free: _TIER_TABLE.free, pro: _TIER_TABLE.pro, unknown: _TIER_TABLE.unknown, expired: _TIER_TABLE.expired, allModels: Object.keys(MODELS), }); } // DELETE /accounts/:id const accountDel = subpath.match(/^\/accounts\/([^/]+)$/); if (accountDel && method === 'DELETE') { const ok = removeAccount(accountDel[1]); return json(res, ok ? 200 : 404, { success: ok }); } // ─── Stats ──────────────────────────────────────────── if (subpath === '/stats' && method === 'GET') { return json(res, 200, getStats()); } if (subpath === '/stats' && method === 'DELETE') { resetStats(); return json(res, 200, { success: true }); } // ─── Logs ───────────────────────────────────────────── if (subpath === '/logs' && method === 'GET') { const url = new URL(req.url, 'http://localhost'); const since = parseInt(url.searchParams.get('since') || '0', 10); const level = url.searchParams.get('level') || null; return json(res, 200, { logs: getLogs(since, level) }); } // GET /logs/export — v2.0.60: download recent logs as JSONL or // pretty-text. Filterable by `type` (all / system / api), `level` // (debug/info/warn/error/all), `since` (unix ms). Designed for the // "give me logs to attach to a GitHub issue" flow so users don't have // to copy-paste from the streaming view. Returns Content-Disposition // so browsers download instead of preview. if (subpath === '/logs/export' && method === 'GET') { const url = new URL(req.url, 'http://localhost'); const type = (url.searchParams.get('type') || 'all').toLowerCase(); const level = url.searchParams.get('level') || null; const since = parseInt(url.searchParams.get('since') || '0', 10); const fmt = (url.searchParams.get('format') || 'jsonl').toLowerCase(); let entries = getLogs(since, level); if (type === 'api') { // API path: any log emitted by request handlers — Probe[id] / Chat[id] // / Cascade / ToolGuard / drought etc., plus any entry with a ctx // requestId. This is the "what happened to my request" view. entries = entries.filter(e => { if (e.ctx && (e.ctx.requestId || e.ctx.reqId)) return true; const m = e.msg || ''; return /^(?:Probe|Chat|Cascade|ToolGuard|ToolParser|drought|Workspace|Settings):|\[Probe |\[Chat /i.test(m); }); } else if (type === 'system') { // System path: everything that's NOT obviously per-request — auth // pool, LS lifecycle, cron jobs, etc. entries = entries.filter(e => { if (e.ctx && (e.ctx.requestId || e.ctx.reqId)) return false; const m = e.msg || ''; return !/^(?:Probe|Chat|Cascade|ToolGuard|ToolParser):|\[Probe |\[Chat /i.test(m); }); } const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const filename = `windsurf-api-logs-${type}-${stamp}.${fmt === 'txt' ? 'log' : 'jsonl'}`; let body; if (fmt === 'txt' || fmt === 'log') { body = entries.map(e => { const ts = new Date(e.ts).toISOString(); const ctx = e.ctx ? ' ' + Object.entries(e.ctx).map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`).join(' ') : ''; return `${ts} [${e.level.toUpperCase()}] ${e.msg}${ctx}`; }).join('\n') + '\n'; } else { body = entries.map(e => JSON.stringify(e)).join('\n') + '\n'; } res.writeHead(200, { 'Content-Type': fmt === 'txt' || fmt === 'log' ? 'text/plain; charset=utf-8' : 'application/x-ndjson; charset=utf-8', 'Content-Disposition': `attachment; filename="${filename}"`, 'Cache-Control': 'no-store', }); res.end(body); return; } if (subpath === '/logs/stream' && method === 'GET') { req.socket.setKeepAlive(true); req.setTimeout(0); res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*', 'X-Accel-Buffering': 'no', }); res.write('retry: 3000\n\n'); // Send existing logs first const existing = getLogs(); for (const entry of existing.slice(-50)) { res.write(`data: ${JSON.stringify(entry)}\n\n`); } const heartbeat = setInterval(() => { if (!res.writableEnded) res.write(': heartbeat\n\n'); }, 15000); const cb = (entry) => { if (!res.writableEnded) res.write(`data: ${JSON.stringify(entry)}\n\n`); }; subscribeToLogs(cb); req.on('close', () => { clearInterval(heartbeat); unsubscribeFromLogs(cb); }); return; } // ─── Proxy ──────────────────────────────────────────── // Always return the masked view over the API — plaintext passwords // would otherwise end up in dashboard network logs, HAR files, proxy // access logs, etc. The UI posts the sentinel back to preserve the // stored password when editing other fields (see mergePassword). // ─── Drought summary (v2.0.57 Fix 5) ────────────────── if (subpath === '/drought' && method === 'GET') { return json(res, 200, getDroughtSummary()); } // ─── Upstream endpoints (v2.0.60 — show migration status) ── // Surfaces which Windsurf upstream paths the proxy is currently // wired to talk to. Lets the operator confirm at a glance that we're // on the new register.windsurf.com / windsurf.com/_backend hosts and // that the legacy fallbacks are still in place. Read-only — the // actual switching happens automatically per-request based on // network success / 5xx response. if (subpath === '/upstream-endpoints' && method === 'GET') { return json(res, 200, { registerUser: { primary: 'register.windsurf.com/exa.seat_management_pb.SeatManagementService/RegisterUser', fallback: 'api.codeium.com/register_user/', protocol: 'Connect-RPC (primary) / REST (fallback)', migratedSince: 'v2.0.57', }, postAuth: { primary: 'windsurf.com/_backend/exa.seat_management_pb.SeatManagementService/WindsurfPostAuth', fallback: 'server.self-serve.windsurf.com/exa.seat_management_pb.SeatManagementService/WindsurfPostAuth', protocol: 'Connect-RPC', migratedSince: 'v2.0.57', }, oneTimeAuthToken: { primary: 'windsurf.com/_backend/exa.seat_management_pb.SeatManagementService/GetOneTimeAuthToken', fallback: 'server.self-serve.windsurf.com/exa.seat_management_pb.SeatManagementService/GetOneTimeAuthToken', protocol: 'Connect-RPC', migratedSince: 'v2.0.57', }, checkUserLoginMethod: { primary: 'windsurf.com/_backend/exa.seat_management_pb.SeatManagementService/CheckUserLoginMethod', fallback: 'windsurf.com/_devin-auth/connections', protocol: 'Connect-RPC', migratedSince: 'v2.0.39', }, getUserStatus: { primary: 'server.codeium.com/exa.seat_management_pb.SeatManagementService/GetUserStatus', fallback: 'server.self-serve.windsurf.com/exa.seat_management_pb.SeatManagementService/GetUserStatus', protocol: 'Connect-RPC', note: '内置 daily/weekly% 解析;wam-bundle 用的 GetPlanStatus 是同一 service 的另一个 RPC,返回字段被 GetUserStatus.planStatus 嵌套覆盖。', }, getCascadeModelConfigs: { primary: 'server.codeium.com/exa.api_server_pb.ApiServerService/GetCascadeModelConfigs', fallback: 'server.self-serve.windsurf.com/exa.api_server_pb.ApiServerService/GetCascadeModelConfigs', protocol: 'Connect-RPC', }, firebaseAuth: { primary: 'identitytoolkit.googleapis.com/v1/accounts:signInWithPassword', refreshUrl: 'securetoken.googleapis.com/v1/token', note: 'Windsurf project Firebase API key 直连 — 同 WindsurfSwitch / wam-bundle 路径。', }, }); } // ─── Credentials (v2.0.56 — runtime-rotatable API_KEY + DASHBOARD_PASSWORD) ───── // GET /settings/credentials — masked snapshot. The plaintext API key is // never returned (use revealApiKey if/when added), but we expose // - source: 'runtime' | 'env' | 'unset' // - masked: 'sk-12...3456' for the API key // - dashboardPasswordSet: bool // - dashboardPasswordSource: 'runtime' | 'env' | 'unset' if (subpath === '/settings/credentials' && method === 'GET') { const creds = getCredentials(); const effectiveApiKey = getEffectiveApiKey(); const apiKeySource = creds.apiKey ? 'runtime' : (config.apiKey ? 'env' : 'unset'); const dashboardPasswordSource = creds.dashboardPasswordHash ? 'runtime' : (config.dashboardPassword ? 'env' : 'unset'); return json(res, 200, { apiKey_masked: maskApiKey(effectiveApiKey), apiKeySource, dashboardPasswordSet: !!getEffectiveDashboardPasswordStored(), dashboardPasswordSource, }); } // PUT /settings/credentials — rotate one or both credentials. Body: // { apiKey?: string, dashboardPassword?: string } // Empty string clears the runtime override (env value takes over). // Requires the caller to re-authenticate with the NEW dashboard // password on the next request — no session cookies, so the UI just // re-prompts. We don't accept the old-password proof here because the // caller already passed dashboard auth at the top of this function. if (subpath === '/settings/credentials' && method === 'PUT') { if (!body || typeof body !== 'object') { return json(res, 400, { error: 'Body must be a JSON object' }); } const out = {}; let touched = false; if (Object.prototype.hasOwnProperty.call(body, 'apiKey')) { const v = body.apiKey; if (v != null && typeof v !== 'string') { return json(res, 400, { error: 'apiKey must be a string' }); } const trimmed = String(v ?? '').trim(); // Loose sanity: reject keys with whitespace / control chars. An // empty string is the explicit "clear runtime override" signal. if (trimmed && /[\s\x00-\x1f]/.test(trimmed)) { return json(res, 400, { error: 'apiKey must not contain whitespace or control characters' }); } if (trimmed && trimmed.length < 8) { return json(res, 400, { error: 'apiKey must be at least 8 characters' }); } setRuntimeApiKey(trimmed); out.apiKeyUpdated = true; out.apiKey_masked = maskApiKey(trimmed || getEffectiveApiKey()); touched = true; } if (Object.prototype.hasOwnProperty.call(body, 'dashboardPassword')) { const v = body.dashboardPassword; if (v != null && typeof v !== 'string') { return json(res, 400, { error: 'dashboardPassword must be a string' }); } const pw = String(v ?? ''); if (pw && pw.length < 8) { return json(res, 400, { error: 'dashboardPassword must be at least 8 characters' }); } setRuntimeDashboardPassword(pw); out.dashboardPasswordUpdated = true; touched = true; } if (!touched) { return json(res, 400, { error: 'Provide apiKey, dashboardPassword, or both' }); } log.info(`Settings: credentials rotated from ${dashboardClientIp(req) || 'unknown'} (apiKey=${!!out.apiKeyUpdated}, dashboardPassword=${!!out.dashboardPasswordUpdated})`); return json(res, 200, { success: true, ...out }); } if (subpath === '/proxy' && method === 'GET') { return json(res, 200, getProxyConfigMasked()); } if (subpath === '/proxy/global' && method === 'PUT') { // v2.0.55 (audit H3): wire this PUT through the same private-host // gate the add-account path uses, otherwise a dashboard-authenticated // caller can pin the global proxy at 127.0.0.1 / 169.254.169.254 / // any internal socket, then upstream egress flows through it. Skip // when the operator explicitly allows private hosts or when the // body has no host (clearing the global proxy via empty PUT). if (body && typeof body === 'object' && body.host && !config.allowPrivateProxyHosts) { try { await assertPublicUrlHost(body.host); } catch (e) { return json(res, 400, { error: e?.message || 'ERR_PROXY_PRIVATE_HOST' }); } } setGlobalProxy(body); return json(res, 200, { success: true, config: getProxyConfigMasked() }); } if (subpath === '/proxy/global' && method === 'DELETE') { removeProxy('global'); return json(res, 200, { success: true }); } const proxyAccount = subpath.match(/^\/proxy\/accounts\/([^/]+)$/); if (proxyAccount && method === 'PUT') { // Same H3 gate as /proxy/global PUT — per-account proxies were the // other half of the bypass. Empty body / no host = clearing the // proxy, leave it unvalidated. if (body && typeof body === 'object' && body.host && !config.allowPrivateProxyHosts) { try { await assertPublicUrlHost(body.host); } catch (e) { return json(res, 400, { error: e?.message || 'ERR_PROXY_PRIVATE_HOST' }); } } setAccountProxy(proxyAccount[1], body); // Spawn (or adopt) the LS instance for this proxy so chat routes immediately ensureLsForAccount(proxyAccount[1]).catch(e => log.warn(`LS ensure failed: ${e.message}`)); return json(res, 200, { success: true }); } if (proxyAccount && method === 'DELETE') { removeProxy('account', proxyAccount[1]); return json(res, 200, { success: true }); } // ─── Config ─────────────────────────────────────────── if (subpath === '/config' && method === 'GET') { return json(res, 200, { port: config.port, defaultModel: config.defaultModel, maxTokens: config.maxTokens, logLevel: config.logLevel, lsBinaryPath: config.lsBinaryPath, lsPort: config.lsPort, codeiumApiUrl: config.codeiumApiUrl, hasApiKey: !!config.apiKey, hasDashboardPassword: !!config.dashboardPassword, }); } // ─── Language Server binary inspect / update ───────── // GET → current LS binary stat (size, mtime, sha256 prefix). UI uses // this to show "binary installed at X, version sha:abcd1234, age 21d". if (subpath === '/langserver/binary' && method === 'GET') { const binPath = config.lsBinaryPath; try { const { statSync } = await import('node:fs'); const { createReadStream } = await import('node:fs'); const { createHash } = await import('node:crypto'); const stat = statSync(binPath); const sha = await new Promise((resolve, reject) => { const h = createHash('sha256'); createReadStream(binPath) .on('data', c => h.update(c)) .on('end', () => resolve(h.digest('hex'))) .on('error', reject); }); return json(res, 200, { ok: true, path: binPath, sizeBytes: stat.size, mtime: stat.mtime.toISOString(), sha256: sha.slice(0, 16), }); } catch (err) { return json(res, 200, { ok: false, path: binPath, error: err.code || err.message, }); } } // POST → run install-ls.sh to download the latest binary, then restart // every LS pool entry so requests pick up the new binary on next call. // Body: { url?: string } — optional override (e.g. desktop-extracted // binary URL); falls back to `install-ls.sh` auto-discovery (our // release → Exafunction). if (subpath === '/langserver/update' && method === 'POST') { const { spawn } = await import('node:child_process'); const { fileURLToPath } = await import('node:url'); const { dirname, join: pjoin } = await import('node:path'); // install-ls.sh ships at the repo root, two levels above this file // (src/dashboard/api.js). Resolving by import.meta.url avoids any // dependence on cwd, so the endpoint works whether started from /app // (Docker), the repo root, or a deeper subdir. const here = dirname(fileURLToPath(import.meta.url)); const scriptPath = pjoin(here, '..', '..', 'install-ls.sh'); if (!existsSync(scriptPath)) { return json(res, 500, { ok: false, error: `install-ls.sh not found at ${scriptPath}`, }); } const url = body && typeof body.url === 'string' ? body.url.trim() : ''; // Defence-in-depth: only allow http(s) URLs from a small allowlist of // hosts. Without this, an attacker who got past dashboard auth could // pipe an arbitrary URL into the install script and have curl write // bytes to LS_BINARY_PATH which is then chmod +x and exec'd as the // node user. Still gated by checkAuth above; this is a second layer. if (url) { let parsed; try { parsed = new URL(url); } catch { return json(res, 400, { ok: false, error: 'invalid url' }); } if (parsed.protocol !== 'https:') { return json(res, 400, { ok: false, error: 'url must be https' }); } const allowedHosts = new Set([ 'github.com', 'objects.githubusercontent.com', 'release-assets.githubusercontent.com', 'api.github.com', ]); if (!allowedHosts.has(parsed.hostname)) { return json(res, 400, { ok: false, error: `url host not allowed; permitted: ${[...allowedHosts].join(', ')}`, }); } } const args = url ? ['--url', url] : []; const env = { ...process.env, LS_INSTALL_PATH: config.lsBinaryPath, }; // Snapshot sha256 BEFORE the install. install-ls.sh will atomic- // rename the binary into place even if the download is byte- // identical to what's already there (no upstream change), so // without comparing before/after the dashboard can't tell whether // "Update LS" actually replaced anything — leading to user reports // like "LS update has no effect". Returning both lets the toast // distinguish "binary changed" from "binary already up to date". let beforeSha = null; try { const { createReadStream } = await import('node:fs'); const { createHash } = await import('node:crypto'); beforeSha = await new Promise((resolve, reject) => { const h = createHash('sha256'); createReadStream(config.lsBinaryPath) .on('data', c => h.update(c)) .on('end', () => resolve(h.digest('hex'))) .on('error', () => resolve(null)); }); } catch { /* missing or unreadable — that's fine, treat as null */ } const child = spawn('bash', [scriptPath, ...args], { env }); let stdout = ''; let stderr = ''; child.stdout.on('data', c => { stdout += c.toString(); }); child.stderr.on('data', c => { stderr += c.toString(); }); const exitCode = await new Promise(resolve => { child.on('close', resolve); child.on('error', () => resolve(-1)); }); if (exitCode !== 0) { return json(res, 200, { ok: false, exitCode, stdout: stdout.slice(-4000), stderr: stderr.slice(-4000), }); } let afterSha = null; try { const { createReadStream } = await import('node:fs'); const { createHash } = await import('node:crypto'); afterSha = await new Promise((resolve) => { const h = createHash('sha256'); createReadStream(config.lsBinaryPath) .on('data', c => h.update(c)) .on('end', () => resolve(h.digest('hex'))) .on('error', () => resolve(null)); }); } catch { /* keep null */ } const binaryChanged = !!(beforeSha && afterSha && beforeSha !== afterSha); // Restart every LS pool entry. The pool is keyed by proxy; iterating // through the live list catches per-account proxies as well as the // default no-proxy LS. Errors during restart get surfaced so the user // knows whether they need to bounce the container. const { _poolKeys, restartLsForProxy: doRestart, getProxyByKey } = await import('../langserver.js'); let restarted = 0; let restartErrors = []; try { const keys = typeof _poolKeys === 'function' ? _poolKeys() : ['default']; for (const key of keys) { try { const proxy = typeof getProxyByKey === 'function' ? getProxyByKey(key) : null; await doRestart(proxy); restarted++; } catch (e) { restartErrors.push(`${key}: ${e.message}`); } } } catch (e) { restartErrors.push(e.message); } return json(res, 200, { ok: true, stdout: stdout.slice(-4000), restarted, restartErrors, // 16-char prefix matches what /langserver/binary returns so the // dashboard can string-compare against its current shown stat. beforeSha: beforeSha ? beforeSha.slice(0, 16) : null, afterSha: afterSha ? afterSha.slice(0, 16) : null, binaryChanged, // poolEmpty distinguishes "no live LS to restart" (cold proxy, // restart will happen on next request) from "all LS restart // attempts failed" (real problem). Without this the toast counts // both as "restarted 0". poolEmpty: restarted === 0 && restartErrors.length === 0, }); } // ─── Language Server ────────────────────────────────── if (subpath === '/langserver/restart' && method === 'POST') { if (!body.confirm) { return json(res, 400, { error: 'Send { confirm: true } to restart language server' }); } stopLanguageServer(); setTimeout(async () => { try { await startLanguageServer({ binaryPath: config.lsBinaryPath, port: config.lsPort, apiServerUrl: config.codeiumApiUrl, }); } catch (e) { log.error(`Language server restart failed: ${e.message}`); } }, 2000); return json(res, 200, { success: true, message: 'Restarting language server...' }); } // ─── Models list ────────────────────────────────────── if (subpath === '/models' && method === 'GET') { const models = Object.entries(MODELS).map(([id, info]) => ({ id, name: info.name, provider: info.provider, credit: typeof info.credit === 'number' ? info.credit : null, })); return json(res, 200, { models }); } // ─── Model Access Control ────────────────────────────── if (subpath === '/model-access' && method === 'GET') { return json(res, 200, getModelAccessConfig()); } if (subpath === '/model-access' && method === 'PUT') { if (body.mode) setModelAccessMode(body.mode); if (body.list) setModelAccessList(body.list); return json(res, 200, { success: true, config: getModelAccessConfig() }); } if (subpath === '/model-access/add' && method === 'POST') { if (!body.model) return json(res, 400, { error: 'model is required' }); addModelToList(body.model); return json(res, 200, { success: true, config: getModelAccessConfig() }); } if (subpath === '/model-access/remove' && method === 'POST') { if (!body.model) return json(res, 400, { error: 'model is required' }); removeModelFromList(body.model); return json(res, 200, { success: true, config: getModelAccessConfig() }); } // ─── Windsurf Login ──────────────────────────────────── if (subpath === '/windsurf-login' && method === 'POST') { try { const { email, password, proxy: loginProxy, autoAdd } = body || {}; return json(res, 200, await processWindsurfLogin({ email, password, loginProxy, autoAdd })); } catch (err) { return json(res, err.statusCode || 400, { error: err.message, isAuthFail: !!err.isAuthFail, firebaseCode: err.firebaseCode }); } } if (subpath === '/windsurf-login/batch' && method === 'POST') { try { const { accounts, proxy: loginProxy, autoAdd } = body || {}; if (!Array.isArray(accounts) || !accounts.length) { return json(res, 400, { error: 'ERR_ACCOUNTS_REQUIRED' }); } const results = []; for (const acct of accounts) { const email = String(acct?.email || '').trim(); const password = String(acct?.password || '').trim(); try { const result = await processWindsurfLogin({ email, password, loginProxy, autoAdd }); results.push(result); } catch (err) { results.push({ success: false, email, error: err.message, isAuthFail: !!err.isAuthFail, firebaseCode: err.firebaseCode, }); } } const successCount = results.filter(r => r.success).length; const failCount = results.length - successCount; return json(res, 200, { success: true, total: results.length, successCount, failCount, results, }); } catch (err) { return json(res, 400, { error: err.message }); } } // ─── Batch proxy + account import ───────────────────── // POST /batch-import — each line: "proxy email password" or "email password" if (subpath === '/batch-import' && method === 'POST') { try { const { text, autoAdd = true } = body || {}; if (!text || typeof text !== 'string') return json(res, 400, { error: 'ERR_TEXT_REQUIRED' }); const lines = text.split('\n').map(l => l.trim()).filter(Boolean); if (!lines.length) return json(res, 400, { error: 'ERR_NO_VALID_LINES' }); const results = []; for (const line of lines) { const parts = line.split(/\s+/); let proxy = null, email, password; if (parts.length >= 3 && (parts[0].includes('://') || parts[0].includes(':'))) { proxy = parts[0]; email = parts[1]; password = parts[2]; } else if (parts.length >= 2) { email = parts[0]; password = parts[1]; } else { results.push({ success: false, email: line.slice(0, 30), error: 'ERR_FORMAT_INVALID' }); continue; } try { const loginProxy = proxy ? parseProxyUrl(proxy) : getProxyConfig().global; const result = await processWindsurfLogin({ email, password, loginProxy, autoAdd }); const binding = buildBatchProxyBinding(result, proxy); if (binding) { setAccountProxy(binding.accountId, binding.proxy); result.proxy = proxy; ensureLsForAccount(binding.accountId).catch(() => {}); } results.push(result); } catch (err) { results.push({ success: false, email, error: err.message }); } } const successCount = results.filter(r => r.success).length; return json(res, 200, { success: true, total: results.length, successCount, failCount: results.length - successCount, results }); } catch (err) { return json(res, 400, { error: err.message }); } } // ─── OAuth login (Google / GitHub via Firebase) ──────── // POST /oauth-login — accepts Firebase idToken from client-side OAuth if (subpath === '/oauth-login' && method === 'POST') { try { const { idToken, refreshToken, email, provider, autoAdd } = body; if (!idToken) return json(res, 400, { error: 'ERR_IDTOKEN_REQUIRED' }); const proxy = getProxyConfig().global; const { apiKey, name } = await reRegisterWithCodeium(idToken, proxy); let account = null; if (autoAdd !== false) { account = addAccountByKey(apiKey, name || email || provider || 'OAuth'); if (refreshToken) { setAccountTokens(account.id, { refreshToken, idToken }); } ensureLsForAccount(account.id) .then(() => probeAccount(account.id)) .catch(e => log.warn(`OAuth auto-probe failed: ${e.message}`)); } return json(res, 200, { success: true, // Same one-time-export contract as /windsurf-login: raw key returned // only when autoAdd:false (caller takes the key themselves and we do // not persist it). Otherwise mask for listings. ...(autoAdd === false ? { apiKey } : { apiKey_masked: maskApiKey(apiKey) }), name, email: email || '', account: account ? { id: account.id, email: account.email, status: account.status } : null, }); } catch (err) { return json(res, 400, { error: err.message }); } } // ─── Rate Limit Check ────────────────────────────────── // POST /accounts/:id/rate-limit — check capacity for a single account const rateLimitCheck = subpath.match(/^\/accounts\/([^/]+)\/rate-limit$/); if (rateLimitCheck && method === 'POST') { const list = getAccountList(); const acct = list.find(a => a.id === rateLimitCheck[1]); if (!acct) return json(res, 404, { error: 'Account not found' }); const secret = getAccountInternal(acct.id); try { const proxy = getEffectiveProxy(acct.id) || null; const result = await checkMessageRateLimit(secret.apiKey, proxy); return json(res, 200, { success: true, account: acct.email, ...result }); } catch (err) { return json(res, 500, { error: err.message }); } } const revealKey = subpath.match(/^\/account\/([^/]+)\/reveal-key$/); if (revealKey && method === 'POST') { const acct = getAccountInternal(revealKey[1]); if (!acct) return json(res, 404, { error: 'Account not found' }); return json(res, 200, { success: true, apiKey: acct.apiKey }); } // ─── Firebase Token Refresh ─────────────────────────────── // POST /accounts/:id/refresh-token — manually refresh Firebase token const tokenRefresh = subpath.match(/^\/accounts\/([^/]+)\/refresh-token$/); if (tokenRefresh && method === 'POST') { const acct = getAccountInternal(tokenRefresh[1]); if (!acct) return json(res, 404, { error: 'Account not found' }); if (!acct.refreshToken) return json(res, 400, { error: 'Account has no refresh token' }); try { const proxy = getEffectiveProxy(acct.id) || null; const { idToken, refreshToken: newRefresh } = await refreshFirebaseToken(acct.refreshToken, proxy); const { apiKey } = await reRegisterWithCodeium(idToken, proxy); const keyChanged = apiKey && apiKey !== acct.apiKey; // Persist the fresh credentials back onto the account. Without this, the // in-memory apiKey stays on the now-stale value until the next server // restart — every subsequent request from this account will fail auth. setAccountTokens(acct.id, { apiKey: apiKey || acct.apiKey, refreshToken: newRefresh || acct.refreshToken, idToken }); return json(res, 200, { success: true, keyChanged, email: acct.email }); } catch (err) { return json(res, 400, { error: err.message }); } } json(res, 404, { error: `Dashboard API: ${method} ${subpath} not found` }); } // ─── Proxy connectivity test ────────────────────────────── // HTTP CONNECT tunnel to api.ipify.org:443 → GET / → the returned IP is the // proxy's egress IP. Confirms the proxy works AND that auth is accepted. // ─── Self-update helpers ─────────────────────────────── // // execFile (not exec) for every invocation: no shell is spawned, so // metacharacters in any future argument source are data, not commands. // Belt-and-braces with the branch-name regex in /self-update — if a // future refactor drops the regex, execFile still denies injection. const SELF_UPDATE_UNAVAILABLE = 'ERR_SELF_UPDATE_UNAVAILABLE'; let gitExecFileForTest = null; export function setGitExecFileForTest(execFile) { gitExecFileForTest = execFile; } function makeSelfUpdateUnavailableError() { const err = new Error(SELF_UPDATE_UNAVAILABLE); err.code = SELF_UPDATE_UNAVAILABLE; err.reason = 'docker'; return err; } function isSelfUpdateUnavailableError(err) { return err?.code === SELF_UPDATE_UNAVAILABLE || err?.message === SELF_UPDATE_UNAVAILABLE; } function hasGitMetadata(cwd = process.cwd()) { return existsSync(join(cwd, '.git')); } async function getGitExecFile() { if (gitExecFileForTest) return gitExecFileForTest; const { execFile } = await import('node:child_process'); return execFile; } export function runGit(args, opts = {}) { return new Promise((resolve, reject) => { if (!hasGitMetadata(opts.cwd)) return reject(makeSelfUpdateUnavailableError()); getGitExecFile().then((execFile) => { execFile('git', args, { timeout: 30_000, maxBuffer: 1024 * 1024, ...opts }, (err, stdout, stderr) => { if (err?.code === 'ENOENT') return reject(makeSelfUpdateUnavailableError()); if (err) return reject(new Error((stderr || err.message).toString().slice(0, 500))); resolve(stdout.toString()); }); }).catch(reject); }); } async function gitStatus() { const commit = (await runGit(['rev-parse', 'HEAD'])).trim(); const branch = (await runGit(['rev-parse', '--abbrev-ref', 'HEAD'])).trim(); let remote = ''; try { await runGit(['fetch', '--quiet', 'origin']); remote = (await runGit(['rev-parse', `origin/${branch}`])).trim(); } catch {} const localMsg = (await runGit(['log', '-1', '--pretty=format:%s'])).trim(); const behind = remote && remote !== commit; const remoteMsg = behind ? (await runGit(['log', '-1', '--pretty=format:%s', remote]).catch(() => '')).trim() : ''; return { commit: commit.slice(0, 7), commitFull: commit, branch, localMessage: localMsg, remoteCommit: remote ? remote.slice(0, 7) : '', remoteMessage: remoteMsg, behind, }; } async function testProxy({ host, port, username, password, type }) { if (config.allowPrivateProxyHosts) { await validateHostFormat(host); } else { await assertPublicUrlHost(host); } const { isSocks, createSocksTunnel } = await import('../socks.js'); const tls = await import('node:tls'); const targetHost = 'api.ipify.org'; const targetPort = 443; const proxy = { host, port, username, password, type }; // Get a raw TCP socket — either via SOCKS5 or HTTP CONNECT let socket; if (isSocks(proxy)) { socket = await createSocksTunnel(proxy, targetHost, targetPort, 10000); } else { const http = await import('node:http'); socket = await new Promise((resolve, reject) => { const authHeader = username ? { 'Proxy-Authorization': 'Basic ' + Buffer.from(`${username}:${password || ''}`).toString('base64') } : {}; const req = http.request({ host, port, method: 'CONNECT', path: `${targetHost}:${targetPort}`, headers: { Host: `${targetHost}:${targetPort}`, ...authHeader }, timeout: 10000, }); req.on('connect', (res, sock) => { if (res.statusCode !== 200) { sock.destroy(); return reject(new Error(`ERR_PROXY_HTTP_ERROR:${res.statusCode}`)); } resolve(sock); }); req.on('error', (err) => reject(new Error(`ERR_CONNECTION_FAILED:${err.message}`))); req.on('timeout', () => { req.destroy(); reject(new Error('ERR_TIMEOUT')); }); req.end(); }); } // TLS handshake + GET to verify the tunnel works return new Promise((resolve, reject) => { const tlsSock = tls.connect({ socket, servername: targetHost, rejectUnauthorized: false }, () => { tlsSock.write(`GET / HTTP/1.1\r\nHost: ${targetHost}\r\nConnection: close\r\nUser-Agent: WindsurfAPI/ProxyTest\r\n\r\n`); }); const chunks = []; tlsSock.on('data', c => chunks.push(c)); tlsSock.on('end', () => { const body = Buffer.concat(chunks).toString('utf-8'); const match = body.match(/\r\n\r\n([^\r\n]+)/); const ip = match ? match[1].trim() : ''; tlsSock.destroy(); if (!ip || !/^\d+\.\d+\.\d+\.\d+$/.test(ip)) { return reject(new Error('ERR_TLS_TUNNEL_ERROR')); } resolve({ egressIp: ip, type }); }); tlsSock.on('error', (err) => reject(new Error(`ERR_TLS_FAILED:${err.message}`))); }); }