/** * Auth API Proxy — Gateway-side reverse proxy for auth.ai-talk.live * * Eliminates CSP connect-src patching by proxying browser requests * through a same-origin plugin HTTP route: * * Browser: fetch('/plugins/clawlink/api/agents') * → Gateway plugin-http stage * → this handler * → fetch('https://auth.ai-talk.live/api/agents') * → response back to browser * * Registered via api.registerHttpRoute() in index.ts. * See: docs/audit/022-A-loader-audit-v1.md §4.7–4.10 */ import type { IncomingMessage, ServerResponse } from 'node:http'; import fs from 'node:fs'; import nodePath from 'node:path'; import { logger } from '../util/logger.js'; const AUTH_UPSTREAM = 'https://auth.ai-talk.live'; const PROXY_TIMEOUT_MS = 15_000; const MAX_BODY_BYTES = 64 * 1024; // 64KB — tagline API body is ~100 bytes // ── Gateway base URL detection ────────────────────────────────────────── // Captured from the first incoming HTTP request's Host header. // Handles Docker port mapping: container port ≠ host port. let _gatewayBaseUrl: string | undefined; /** Returns the gateway base URL as seen by the browser (e.g. http://localhost:18794). */ export function getGatewayBaseUrl(): string { return _gatewayBaseUrl ?? `http://localhost:18794`; } // ── Handler factory ───────────────────────────────────────────────────── export function createAuthProxyHandler() { return async (req: IncomingMessage, res: ServerResponse): Promise => { const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); // Only handle /plugins/clawlink/* paths if (!url.pathname.startsWith('/plugins/clawlink/')) return false; // Capture the gateway's external base URL from the browser's request. // Reads standard reverse-proxy headers for correct protocol/host detection: // - Local: http://localhost:18794 // - Docker: http://127.0.0.1:18794 (port mapped) // - Nginx: https://oc6.clawlink.club (X-Forwarded-Proto/Host) if (!_gatewayBaseUrl && req.headers.host) { const proto = (req.headers['x-forwarded-proto'] as string)?.split(',')[0]?.trim() || 'http'; const host = (req.headers['x-forwarded-host'] as string)?.split(',')[0]?.trim() || req.headers.host; _gatewayBaseUrl = `${proto}://${host}`; logger.info(`[proxy] Gateway base URL detected: ${_gatewayBaseUrl}`); } // ── T4: Serve local media files from /tmp/openclaw/clawlink-media/ ── // Files downloaded from remote agents via C2C are stored here. // Tool result references them as MEDIA:http://localhost:PORT/plugins/clawlink/media/{filename} // to pass through the framework's filterToolResultMediaUrls (http URLs bypass whitelist check). if (url.pathname.startsWith('/plugins/clawlink/media/')) { return serveLocalMedia(url.pathname, res); } // /plugins/clawlink/api/agents/search → /api/agents/search // /plugins/clawlink/avatars/oc1.png → /avatars/oc1.png const targetPath = url.pathname.replace('/plugins/clawlink', ''); const targetUrl = `${AUTH_UPSTREAM}${targetPath}${url.search}`; const method = req.method ?? 'GET'; logger.info(`[proxy] ${method} ${url.pathname} → ${targetUrl}`); try { // Read request body for non-GET methods let body: string | undefined; if (method !== 'GET' && method !== 'HEAD') { body = await readBody(req, MAX_BODY_BYTES); } // Forward to upstream with timeout const upstream = await fetch(targetUrl, { method, headers: buildUpstreamHeaders(req), body, signal: AbortSignal.timeout(PROXY_TIMEOUT_MS), }); // Relay response (binary-safe for images) res.statusCode = upstream.status; const contentType = upstream.headers.get('content-type'); if (contentType) res.setHeader('Content-Type', contentType); const buf = Buffer.from(await upstream.arrayBuffer()); res.end(buf); logger.info(`[proxy] ${method} ${url.pathname} → ${upstream.status} (${buf.length} bytes)`); } catch (err) { const msg = (err as Error).message; logger.warn(`[proxy] ${method} ${targetUrl} failed: ${msg}`); if (!res.headersSent) { res.statusCode = 502; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ error: 'proxy upstream failed', detail: msg })); } } return true; }; } // ── T4: Local media serve ──────────────────────────────────────────────── const MEDIA_DIR = '/tmp/openclaw/clawlink-media'; /** MIME type lookup for common media extensions. */ const MIME_MAP: Record = { '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.m4a': 'audio/mp4', '.mp4': 'video/mp4', '.webm': 'video/webm', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.pdf': 'application/pdf', '.txt': 'text/plain', }; /** * Serve a file from the local clawlink-media directory. * Used by T4 remote file transfer — tool result references files as * MEDIA:http://localhost:PORT/plugins/clawlink/media/{filename} * * @returns true (request handled) */ function serveLocalMedia(pathname: string, res: ServerResponse): true { // /plugins/clawlink/media/{filename} → {filename} const raw = pathname.replace('/plugins/clawlink/media/', ''); const fileName = decodeURIComponent(raw); // Security: block path traversal, null bytes, and directory components if (!fileName || fileName.includes('..') || fileName.includes('/') || fileName.includes('\\') || fileName.includes('\0')) { logger.warn(`[proxy/media] Blocked unsafe path: ${raw}`); res.statusCode = 400; res.end('Bad request'); return true; } const filePath = nodePath.join(MEDIA_DIR, fileName); // Double-check: resolved path must stay within MEDIA_DIR if (!nodePath.resolve(filePath).startsWith(nodePath.resolve(MEDIA_DIR) + nodePath.sep) && nodePath.resolve(filePath) !== nodePath.resolve(MEDIA_DIR)) { logger.warn(`[proxy/media] Path escape attempt: ${fileName} → ${filePath}`); res.statusCode = 400; res.end('Bad request'); return true; } if (!fs.existsSync(filePath)) { logger.debug(`[proxy/media] File not found: ${filePath}`); res.statusCode = 404; res.end('Not found'); return true; } try { const ext = nodePath.extname(fileName).toLowerCase(); const contentType = MIME_MAP[ext] ?? 'application/octet-stream'; const stat = fs.statSync(filePath); res.setHeader('Content-Type', contentType); res.setHeader('Content-Length', stat.size); // Allow browser to cache media for 1 hour res.setHeader('Cache-Control', 'private, max-age=3600'); const stream = fs.createReadStream(filePath); stream.pipe(res); stream.on('error', (err) => { logger.error(`[proxy/media] Stream error: ${err.message} file=${fileName}`); if (!res.headersSent) { res.statusCode = 500; res.end('Internal error'); } }); logger.info(`[proxy/media] Serving ${fileName} (${contentType}, ${stat.size} bytes)`); } catch (err) { logger.error(`[proxy/media] Error serving ${fileName}: ${(err as Error).message}`); if (!res.headersSent) { res.statusCode = 500; res.end('Internal error'); } } return true; } // ── Helpers ────────────────────────────────────────────────────────────── /** * Build headers for the upstream request. * Forwards Content-Type and Authorization (needed by tagline API). */ function buildUpstreamHeaders(req: IncomingMessage): Record { const headers: Record = {}; const ct = req.headers['content-type']; if (ct) headers['Content-Type'] = ct; // Transparent forwarding of Authorization header // (tagline API in agent-book.js sends Bearer token from clawlink-auth.json) const auth = req.headers['authorization']; if (auth) headers['Authorization'] = auth; return headers; } /** * Read the request body with a size limit. */ function readBody(req: IncomingMessage, maxBytes: number): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; let total = 0; req.on('data', (chunk: Buffer) => { total += chunk.length; if (total > maxBytes) { req.destroy(); reject(new Error(`Request body exceeds ${maxBytes} bytes`)); return; } chunks.push(chunk); }); req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); req.on('error', reject); }); }