/** * WebUI Module - Optional web interface for account management * * This module provides a web-based UI for: * - Dashboard with real-time model quota visualization * - Account management (add via OAuth, enable/disable, refresh, remove) * - Live server log streaming with filtering * - Claude CLI configuration editor * * Usage in server.js: * import { mountWebUI } from './webui/index.js'; * mountWebUI(app, __dirname, accountManager); */ import path from 'path'; import { readFileSync } from 'fs'; import { fileURLToPath } from 'url'; import express from 'express'; import { getPublicConfig, saveConfig, config } from '../config.js'; import { DEFAULT_PORT, ACCOUNT_CONFIG_PATH, MAX_ACCOUNTS } from '../constants.js'; import { readClaudeConfig, updateClaudeConfig, replaceClaudeConfig, getClaudeConfigPath, readPresets, savePreset, deletePreset } from '../utils/claude-config.js'; import { logger } from '../utils/logger.js'; import { getAuthorizationUrl, completeOAuthFlow, startCallbackServer } from '../auth/oauth.js'; import { loadAccounts, saveAccounts } from '../account-manager/storage.js'; // Get package version const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); let packageVersion = '1.0.0'; try { const packageJsonPath = path.join(__dirname, '../../package.json'); const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); packageVersion = packageJson.version; } catch (error) { logger.warn('[WebUI] Could not read package.json version, using default'); } // OAuth state storage (state -> { server, verifier, state, timestamp }) // Maps state ID to active OAuth flow data const pendingOAuthFlows = new Map(); /** * WebUI Helper Functions - Direct account manipulation * These functions work around AccountManager's limited API by directly * manipulating the accounts.json config file (non-invasive approach for PR) */ /** * Set account enabled/disabled state */ async function setAccountEnabled(email, enabled) { const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH); const account = accounts.find(a => a.email === email); if (!account) { throw new Error(`Account ${email} not found`); } account.enabled = enabled; await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, activeIndex); logger.info(`[WebUI] Account ${email} ${enabled ? 'enabled' : 'disabled'}`); } /** * Remove account from config */ async function removeAccount(email) { const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH); const index = accounts.findIndex(a => a.email === email); if (index === -1) { throw new Error(`Account ${email} not found`); } accounts.splice(index, 1); // Adjust activeIndex if needed const newActiveIndex = activeIndex >= accounts.length ? Math.max(0, accounts.length - 1) : activeIndex; await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, newActiveIndex); logger.info(`[WebUI] Account ${email} removed`); } /** * Add new account to config * @throws {Error} If MAX_ACCOUNTS limit is reached (for new accounts only) */ async function addAccount(accountData) { const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH); // Check if account already exists const existingIndex = accounts.findIndex(a => a.email === accountData.email); if (existingIndex !== -1) { // Update existing account accounts[existingIndex] = { ...accounts[existingIndex], ...accountData, enabled: true, isInvalid: false, invalidReason: null, addedAt: accounts[existingIndex].addedAt || new Date().toISOString() }; logger.info(`[WebUI] Account ${accountData.email} updated`); } else { // Check MAX_ACCOUNTS limit before adding new account if (accounts.length >= MAX_ACCOUNTS) { throw new Error(`Maximum of ${MAX_ACCOUNTS} accounts reached. Update maxAccounts in config to increase the limit.`); } // Add new account accounts.push({ ...accountData, enabled: true, isInvalid: false, invalidReason: null, modelRateLimits: {}, lastUsed: null, addedAt: new Date().toISOString() }); logger.info(`[WebUI] Account ${accountData.email} added`); } await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, activeIndex); } /** * Auth Middleware - Optional password protection for WebUI * Password can be set via WEBUI_PASSWORD env var or config.json */ function createAuthMiddleware() { return (req, res, next) => { const password = config.webuiPassword; if (!password) return next(); // Determine if this path should be protected const isApiRoute = req.path.startsWith('/api/'); const isAuthUrl = req.path === '/api/auth/url'; const isConfigGet = req.path === '/api/config' && req.method === 'GET'; const isProtected = (isApiRoute && !isAuthUrl && !isConfigGet) || req.path === '/account-limits' || req.path === '/health'; if (isProtected) { const providedPassword = req.headers['x-webui-password'] || req.query.password; if (providedPassword !== password) { return res.status(401).json({ status: 'error', error: 'Unauthorized: Password required' }); } } next(); }; } /** * Mount WebUI routes and middleware on Express app * @param {Express} app - Express application instance * @param {string} dirname - __dirname of the calling module (for static file path) * @param {AccountManager} accountManager - Account manager instance */ export function mountWebUI(app, dirname, accountManager) { // Apply auth middleware app.use(createAuthMiddleware()); // Serve static files from public directory app.use(express.static(path.join(dirname, '../public'))); // ========================================== // Account Management API // ========================================== /** * GET /api/accounts - List all accounts with status */ app.get('/api/accounts', async (req, res) => { try { const status = accountManager.getStatus(); res.json({ status: 'ok', accounts: status.accounts, summary: { total: status.total, available: status.available, rateLimited: status.rateLimited, invalid: status.invalid } }); } catch (error) { res.status(500).json({ status: 'error', error: error.message }); } }); /** * POST /api/accounts/:email/refresh - Refresh specific account token */ app.post('/api/accounts/:email/refresh', async (req, res) => { try { const { email } = req.params; accountManager.clearTokenCache(email); accountManager.clearProjectCache(email); res.json({ status: 'ok', message: `Token cache cleared for ${email}` }); } catch (error) { res.status(500).json({ status: 'error', error: error.message }); } }); /** * POST /api/accounts/:email/toggle - Enable/disable account */ app.post('/api/accounts/:email/toggle', async (req, res) => { try { const { email } = req.params; const { enabled } = req.body; if (typeof enabled !== 'boolean') { return res.status(400).json({ status: 'error', error: 'enabled must be a boolean' }); } await setAccountEnabled(email, enabled); // Reload AccountManager to pick up changes await accountManager.reload(); res.json({ status: 'ok', message: `Account ${email} ${enabled ? 'enabled' : 'disabled'}` }); } catch (error) { res.status(500).json({ status: 'error', error: error.message }); } }); /** * DELETE /api/accounts/:email - Remove account */ app.delete('/api/accounts/:email', async (req, res) => { try { const { email } = req.params; await removeAccount(email); // Reload AccountManager to pick up changes await accountManager.reload(); res.json({ status: 'ok', message: `Account ${email} removed` }); } catch (error) { res.status(500).json({ status: 'error', error: error.message }); } }); /** * POST /api/accounts/reload - Reload accounts from disk */ app.post('/api/accounts/reload', async (req, res) => { try { // Reload AccountManager from disk await accountManager.reload(); const status = accountManager.getStatus(); res.json({ status: 'ok', message: 'Accounts reloaded from disk', summary: status.summary }); } catch (error) { res.status(500).json({ status: 'error', error: error.message }); } }); // ========================================== // Configuration API // ========================================== /** * GET /api/config - Get server configuration */ app.get('/api/config', (req, res) => { try { const publicConfig = getPublicConfig(); res.json({ status: 'ok', config: publicConfig, version: packageVersion, note: 'Edit ~/.config/antigravity-proxy/config.json or use env vars to change these values' }); } catch (error) { logger.error('[WebUI] Error getting config:', error); res.status(500).json({ status: 'error', error: error.message }); } }); /** * POST /api/config - Update server configuration */ app.post('/api/config', (req, res) => { try { const { debug, logLevel, maxRetries, retryBaseMs, retryMaxMs, persistTokenCache, defaultCooldownMs, maxWaitBeforeErrorMs, maxAccounts, accountSelection, rateLimitDedupWindowMs, maxConsecutiveFailures, extendedCooldownMs, capacityRetryDelayMs, maxCapacityRetries } = req.body; // Only allow updating specific fields (security) const updates = {}; if (typeof debug === 'boolean') updates.debug = debug; if (logLevel && ['info', 'warn', 'error', 'debug'].includes(logLevel)) { updates.logLevel = logLevel; } if (typeof maxRetries === 'number' && maxRetries >= 1 && maxRetries <= 20) { updates.maxRetries = maxRetries; } if (typeof retryBaseMs === 'number' && retryBaseMs >= 100 && retryBaseMs <= 10000) { updates.retryBaseMs = retryBaseMs; } if (typeof retryMaxMs === 'number' && retryMaxMs >= 1000 && retryMaxMs <= 120000) { updates.retryMaxMs = retryMaxMs; } if (typeof persistTokenCache === 'boolean') { updates.persistTokenCache = persistTokenCache; } if (typeof defaultCooldownMs === 'number' && defaultCooldownMs >= 1000 && defaultCooldownMs <= 300000) { updates.defaultCooldownMs = defaultCooldownMs; } if (typeof maxWaitBeforeErrorMs === 'number' && maxWaitBeforeErrorMs >= 0 && maxWaitBeforeErrorMs <= 600000) { updates.maxWaitBeforeErrorMs = maxWaitBeforeErrorMs; } if (typeof maxAccounts === 'number' && maxAccounts >= 1 && maxAccounts <= 100) { updates.maxAccounts = maxAccounts; } if (typeof rateLimitDedupWindowMs === 'number' && rateLimitDedupWindowMs >= 1000 && rateLimitDedupWindowMs <= 30000) { updates.rateLimitDedupWindowMs = rateLimitDedupWindowMs; } if (typeof maxConsecutiveFailures === 'number' && maxConsecutiveFailures >= 1 && maxConsecutiveFailures <= 10) { updates.maxConsecutiveFailures = maxConsecutiveFailures; } if (typeof extendedCooldownMs === 'number' && extendedCooldownMs >= 10000 && extendedCooldownMs <= 300000) { updates.extendedCooldownMs = extendedCooldownMs; } if (typeof capacityRetryDelayMs === 'number' && capacityRetryDelayMs >= 500 && capacityRetryDelayMs <= 10000) { updates.capacityRetryDelayMs = capacityRetryDelayMs; } if (typeof maxCapacityRetries === 'number' && maxCapacityRetries >= 1 && maxCapacityRetries <= 10) { updates.maxCapacityRetries = maxCapacityRetries; } // Account selection strategy validation if (accountSelection && typeof accountSelection === 'object') { const validStrategies = ['sticky', 'round-robin', 'hybrid']; if (accountSelection.strategy && validStrategies.includes(accountSelection.strategy)) { updates.accountSelection = { ...(config.accountSelection || {}), strategy: accountSelection.strategy }; } } if (Object.keys(updates).length === 0) { return res.status(400).json({ status: 'error', error: 'No valid configuration updates provided' }); } const success = saveConfig(updates); if (success) { res.json({ status: 'ok', message: 'Configuration saved. Restart server to apply some changes.', updates: updates, config: getPublicConfig() }); } else { res.status(500).json({ status: 'error', error: 'Failed to save configuration file' }); } } catch (error) { logger.error('[WebUI] Error updating config:', error); res.status(500).json({ status: 'error', error: error.message }); } }); /** * POST /api/config/password - Change WebUI password */ app.post('/api/config/password', (req, res) => { try { const { oldPassword, newPassword } = req.body; // Validate input if (!newPassword || typeof newPassword !== 'string') { return res.status(400).json({ status: 'error', error: 'New password is required' }); } // If current password exists, verify old password if (config.webuiPassword && config.webuiPassword !== oldPassword) { return res.status(403).json({ status: 'error', error: 'Invalid current password' }); } // Save new password const success = saveConfig({ webuiPassword: newPassword }); if (success) { // Update in-memory config config.webuiPassword = newPassword; res.json({ status: 'ok', message: 'Password changed successfully' }); } else { throw new Error('Failed to save password to config file'); } } catch (error) { logger.error('[WebUI] Error changing password:', error); res.status(500).json({ status: 'error', error: error.message }); } }); /** * GET /api/settings - Get runtime settings */ app.get('/api/settings', async (req, res) => { try { const settings = accountManager.getSettings ? accountManager.getSettings() : {}; res.json({ status: 'ok', settings: { ...settings, port: process.env.PORT || DEFAULT_PORT } }); } catch (error) { res.status(500).json({ status: 'error', error: error.message }); } }); // ========================================== // Claude CLI Configuration API // ========================================== /** * GET /api/claude/config - Get Claude CLI configuration */ app.get('/api/claude/config', async (req, res) => { try { const claudeConfig = await readClaudeConfig(); res.json({ status: 'ok', config: claudeConfig, path: getClaudeConfigPath() }); } catch (error) { res.status(500).json({ status: 'error', error: error.message }); } }); /** * POST /api/claude/config - Update Claude CLI configuration */ app.post('/api/claude/config', async (req, res) => { try { const updates = req.body; if (!updates || typeof updates !== 'object') { return res.status(400).json({ status: 'error', error: 'Invalid config updates' }); } const newConfig = await updateClaudeConfig(updates); res.json({ status: 'ok', config: newConfig, message: 'Claude configuration updated' }); } catch (error) { res.status(500).json({ status: 'error', error: error.message }); } }); /** * POST /api/claude/config/restore - Restore Claude CLI to default (remove proxy settings) */ app.post('/api/claude/config/restore', async (req, res) => { try { const claudeConfig = await readClaudeConfig(); // Proxy-related environment variables to remove when restoring defaults const PROXY_ENV_VARS = [ 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', 'ENABLE_EXPERIMENTAL_MCP_CLI' ]; // Remove proxy-related environment variables to restore defaults if (claudeConfig.env) { for (const key of PROXY_ENV_VARS) { delete claudeConfig.env[key]; } // Remove env entirely if empty to truly restore defaults if (Object.keys(claudeConfig.env).length === 0) { delete claudeConfig.env; } } // Use replaceClaudeConfig to completely overwrite the config (not merge) const newConfig = await replaceClaudeConfig(claudeConfig); logger.info(`[WebUI] Restored Claude CLI config to defaults at ${getClaudeConfigPath()}`); res.json({ status: 'ok', config: newConfig, message: 'Claude CLI configuration restored to defaults' }); } catch (error) { logger.error('[WebUI] Error restoring Claude config:', error); res.status(500).json({ status: 'error', error: error.message }); } }); // ========================================== // Claude CLI Presets API // ========================================== /** * GET /api/claude/presets - Get all saved presets */ app.get('/api/claude/presets', async (req, res) => { try { const presets = await readPresets(); res.json({ status: 'ok', presets }); } catch (error) { res.status(500).json({ status: 'error', error: error.message }); } }); /** * POST /api/claude/presets - Save a new preset */ app.post('/api/claude/presets', async (req, res) => { try { const { name, config: presetConfig } = req.body; if (!name || typeof name !== 'string' || !name.trim()) { return res.status(400).json({ status: 'error', error: 'Preset name is required' }); } if (!presetConfig || typeof presetConfig !== 'object') { return res.status(400).json({ status: 'error', error: 'Config object is required' }); } const presets = await savePreset(name.trim(), presetConfig); res.json({ status: 'ok', presets, message: `Preset "${name}" saved` }); } catch (error) { res.status(500).json({ status: 'error', error: error.message }); } }); /** * DELETE /api/claude/presets/:name - Delete a preset */ app.delete('/api/claude/presets/:name', async (req, res) => { try { const { name } = req.params; if (!name) { return res.status(400).json({ status: 'error', error: 'Preset name is required' }); } const presets = await deletePreset(name); res.json({ status: 'ok', presets, message: `Preset "${name}" deleted` }); } catch (error) { res.status(500).json({ status: 'error', error: error.message }); } }); /** * POST /api/models/config - Update model configuration (hidden/pinned/alias) */ app.post('/api/models/config', (req, res) => { try { const { modelId, config: newModelConfig } = req.body; if (!modelId || typeof newModelConfig !== 'object') { return res.status(400).json({ status: 'error', error: 'Invalid parameters' }); } // Load current config const currentMapping = config.modelMapping || {}; // Update specific model config currentMapping[modelId] = { ...currentMapping[modelId], ...newModelConfig }; // Save back to main config const success = saveConfig({ modelMapping: currentMapping }); if (success) { // Update in-memory config reference config.modelMapping = currentMapping; res.json({ status: 'ok', modelConfig: currentMapping[modelId] }); } else { throw new Error('Failed to save configuration'); } } catch (error) { res.status(500).json({ status: 'error', error: error.message }); } }); // ========================================== // Logs API // ========================================== /** * GET /api/logs - Get log history */ app.get('/api/logs', (req, res) => { res.json({ status: 'ok', logs: logger.getHistory ? logger.getHistory() : [] }); }); /** * GET /api/logs/stream - Stream logs via SSE */ app.get('/api/logs/stream', (req, res) => { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); const sendLog = (log) => { res.write(`data: ${JSON.stringify(log)}\n\n`); }; // Send recent history if requested if (req.query.history === 'true' && logger.getHistory) { const history = logger.getHistory(); history.forEach(log => sendLog(log)); } // Subscribe to new logs if (logger.on) { logger.on('log', sendLog); } // Cleanup on disconnect req.on('close', () => { if (logger.off) { logger.off('log', sendLog); } }); }); // ========================================== // OAuth API // ========================================== /** * GET /api/auth/url - Get OAuth URL to start the flow * Uses CLI's OAuth flow (localhost:51121) instead of WebUI's port * to match Google OAuth Console's authorized redirect URIs */ app.get('/api/auth/url', async (req, res) => { try { // Clean up old flows (> 10 mins) const now = Date.now(); for (const [key, val] of pendingOAuthFlows.entries()) { if (now - val.timestamp > 10 * 60 * 1000) { pendingOAuthFlows.delete(key); } } // Generate OAuth URL using default redirect URI (localhost:51121) const { url, verifier, state } = getAuthorizationUrl(); // Start callback server on port 51121 (same as CLI) const serverPromise = startCallbackServer(state, 120000); // 2 min timeout // Store the flow data pendingOAuthFlows.set(state, { serverPromise, verifier, state, timestamp: Date.now() }); // Start async handler for the OAuth callback serverPromise .then(async (code) => { try { logger.info('[WebUI] Received OAuth callback, completing flow...'); const accountData = await completeOAuthFlow(code, verifier); // Add or update the account // Note: Don't set projectId here - it will be discovered and stored // in the refresh token via getProjectForAccount() on first use await addAccount({ email: accountData.email, refreshToken: accountData.refreshToken, source: 'oauth' }); // Reload AccountManager to pick up the new account await accountManager.reload(); logger.success(`[WebUI] Account ${accountData.email} added successfully`); } catch (err) { logger.error('[WebUI] OAuth flow completion error:', err); } finally { pendingOAuthFlows.delete(state); } }) .catch((err) => { logger.error('[WebUI] OAuth callback server error:', err); pendingOAuthFlows.delete(state); }); res.json({ status: 'ok', url }); } catch (error) { logger.error('[WebUI] Error generating auth URL:', error); res.status(500).json({ status: 'error', error: error.message }); } }); /** * Note: /oauth/callback route removed * OAuth callbacks are now handled by the temporary server on port 51121 * (same as CLI) to match Google OAuth Console's authorized redirect URIs */ logger.info('[WebUI] Mounted at /'); }