| |
| |
| |
| |
|
|
| 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) { |
| |
| |
| const s = String(proxy).replace(/\s+/g, ' ').trim(); |
| |
| |
| |
| let m = s.match(/^(?:(\w+):\/\/)?(?:([^\s:]+):([^\s@]+)@)?([^\s:]+):(\d+)$/); |
| |
| if (!m) m = s.match(/^(\w+)\s+([^\s:]+)\s+(\d+)$/); |
| |
| if (!m) m = s.match(/^(\w+)\s+([^\s:]+):(\d+)$/); |
| if (!m) return null; |
| if (m.length === 4) { |
| |
| 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); |
| } |
|
|
| |
| |
| |
| |
| 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) { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| const proxy = loginProxy?.host ? loginProxy : getProxyConfig().global; |
| const result = await windsurfLogin(email, password, proxy); |
|
|
| |
| let account = null; |
| if (autoAdd !== false) { |
| account = addAccountByKey(result.apiKey, result.name || email); |
| |
| |
| if (result.refreshToken) { |
| setAccountTokens(account.id, { refreshToken: result.refreshToken, idToken: result.idToken }); |
| } |
| |
| |
| 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, |
| |
| |
| |
| |
| |
| |
| ...(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, |
| }; |
| } |
|
|
| |
| |
| |
| export async function handleDashboardApi(method, subpath, body, req, res) { |
| if (method === 'OPTIONS') return json(res, 204, ''); |
|
|
| |
| |
| |
| |
| |
| 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, |
| }); |
| } |
|
|
| |
| if (subpath !== '/auth' && !checkAuth(req)) { |
| failedAuthAttempt(clientIp); |
| return json(res, 401, { error: 'Unauthorized. Set X-Dashboard-Password header.' }); |
| } |
| if (subpath !== '/auth') successfulAuthAttempt(clientIp); |
|
|
| |
| if (subpath === '/auth') { |
| const storedPw = getEffectiveDashboardPasswordStored(); |
| const effectiveApiKey = getEffectiveApiKey(); |
| const hasSecret = !!(storedPw || effectiveApiKey); |
| if (hasSecret) { |
| const ok = checkAuth(req); |
| |
| |
| |
| if (ok) successfulAuthAttempt(clientIp); |
| else if (req.headers['x-dashboard-password']) failedAuthAttempt(clientIp); |
| return json(res, 200, { required: true, valid: ok }); |
| } |
| |
| |
| |
| |
| if (isLocalBindHost()) return json(res, 200, { required: false }); |
| return json(res, 200, { required: true, valid: false, locked: true }); |
| } |
|
|
| |
| 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(), |
| }); |
| } |
|
|
| |
| if (subpath === '/experimental' && method === 'GET') { |
| return json(res, 200, { flags: getExperimental(), conversationPool: convPoolStats() }); |
| } |
| if (subpath === '/experimental' && method === 'PUT') { |
| const flags = setExperimental(body || {}); |
| |
| |
| 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 }); |
| } |
|
|
| |
| 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 }); |
| } |
|
|
| |
| 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 }); |
| } |
| } |
|
|
| |
| 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') { |
| |
| |
| |
| |
| try { |
| const result = await runQuietWindowTickNow(); |
| return json(res, 200, { ok: true, result }); |
| } catch (e) { |
| return json(res, 500, { ok: false, error: e.message }); |
| } |
| } |
|
|
| |
| 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)) { |
| |
| |
| |
| |
| 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(); |
| |
| |
| |
| |
| |
| |
| 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), |
| }); |
| } |
| |
| |
| |
| |
| 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'; |
| |
| |
| |
| |
| const pull = dirty ? 'hard-reset applied' : await runGit(['pull', 'origin', safeBranch, '--ff-only']); |
| const after = await gitStatus(); |
| const changed = before.commit !== after.commit; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if (changed) { |
| setTimeout(async () => { |
| log.info('self-update: stopping LS pool before exit'); |
| try { |
| |
| |
| |
| |
| 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)) { |
| |
| |
| |
| |
| |
| 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 }); |
| } |
| } |
|
|
| |
| if (subpath === '/cache' && method === 'GET') { |
| return json(res, 200, cacheStats()); |
| } |
| if (subpath === '/cache' && method === 'DELETE') { |
| cacheClear(); |
| return json(res, 200, { success: true }); |
| } |
|
|
| |
| 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}`)); |
| } |
|
|
| |
| 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 }); |
| } |
| } |
|
|
| |
| |
| |
| |
| 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 || '?') + '。'), |
| }); |
| } |
|
|
| |
| |
| |
| |
| 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 }); |
| } |
| } |
|
|
| |
| 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 }); |
| } |
|
|
| |
| 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 }); |
| } |
| } |
|
|
| |
| if (subpath === '/accounts/refresh-credits' && method === 'POST') { |
| const results = await refreshAllCredits(); |
| return json(res, 200, { success: true, results }); |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| 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 }); |
| } |
|
|
| |
| |
| |
| 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), |
| }); |
| } |
|
|
| |
| const accountDel = subpath.match(/^\/accounts\/([^/]+)$/); |
| if (accountDel && method === 'DELETE') { |
| const ok = removeAccount(accountDel[1]); |
| return json(res, ok ? 200 : 404, { success: ok }); |
| } |
|
|
| |
| if (subpath === '/stats' && method === 'GET') { |
| return json(res, 200, getStats()); |
| } |
|
|
| if (subpath === '/stats' && method === 'DELETE') { |
| resetStats(); |
| return json(res, 200, { success: true }); |
| } |
|
|
| |
| 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) }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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') { |
| |
| |
| |
| 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') { |
| |
| |
| 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'); |
|
|
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| if (subpath === '/drought' && method === 'GET') { |
| return json(res, 200, getDroughtSummary()); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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 路径。', |
| }, |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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, |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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(); |
| |
| |
| 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') { |
| |
| |
| |
| |
| |
| |
| 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') { |
| |
| |
| |
| 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); |
| |
| 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 }); |
| } |
|
|
| |
| 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, |
| }); |
| } |
|
|
| |
| |
| |
| 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, |
| }); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| 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'); |
| |
| |
| |
| |
| 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() : ''; |
| |
| |
| |
| |
| |
| 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, |
| }; |
| |
| |
| |
| |
| |
| |
| |
| 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 { } |
|
|
| 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 { } |
| const binaryChanged = !!(beforeSha && afterSha && beforeSha !== afterSha); |
| |
| |
| |
| |
| 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, |
| |
| |
| beforeSha: beforeSha ? beforeSha.slice(0, 16) : null, |
| afterSha: afterSha ? afterSha.slice(0, 16) : null, |
| binaryChanged, |
| |
| |
| |
| |
| poolEmpty: restarted === 0 && restartErrors.length === 0, |
| }); |
| } |
|
|
| |
| 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...' }); |
| } |
|
|
| |
| 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 }); |
| } |
|
|
| |
| 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() }); |
| } |
|
|
| |
| 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 }); |
| } |
| } |
|
|
| |
| |
| 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 }); |
| } |
| } |
|
|
| |
| |
| 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, |
| |
| |
| |
| ...(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 }); |
| } |
| } |
|
|
| |
| |
| 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 }); |
| } |
|
|
| |
| |
| 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; |
| |
| |
| |
| 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` }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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 }; |
|
|
| |
| 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(); |
| }); |
| } |
|
|
| |
| 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}`))); |
| }); |
| } |
|
|