/** * OpenClaw WebUI - Backend Proxy Server * * Features: * - LDAP SSO Authentication * - Proxy to OpenClaw Gateway (WebSocket + HTTP) * - Session management * - Chat history persistence */ import express from 'express'; import session from 'express-session'; import { createProxyMiddleware } from 'http-proxy-middleware'; import { WebSocketServer, WebSocket } from 'ws'; import { createServer } from 'http'; import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import ldap from 'ldapjs'; import { v4 as uuidv4 } from 'uuid'; import conf from './config.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); // Configuration const CONFIG = { port: conf.server?.port || 3000, gatewayUrl: conf.gateway?.url || 'http://127.0.0.1:18789', gatewayToken: conf.gateway?.token || '', sessionSecret: conf.session?.secret || 'dev-secret', sessionMaxAge: conf.session?.maxAge || 24 * 60 * 60 * 1000, authDisabled: conf.auth?.disabled || false, ldap: { enabled: conf.auth?.ldap?.enabled || false, url: conf.auth?.ldap?.url || 'ldap://localhost:389', baseDN: conf.auth?.ldap?.baseDN || 'ou=users,dc=example,dc=com', bindDN: conf.auth?.ldap?.bindDN || '', bindPassword: conf.auth?.ldap?.bindPassword || '', searchFilter: conf.auth?.ldap?.searchFilter || '(uid={{username}})' }, dataDir: conf.data?.dir || join(__dirname, '../data'), environment: conf.environment }; // Ensure data directory exists if (!existsSync(CONFIG.dataDir)) { mkdirSync(CONFIG.dataDir, { recursive: true }); } // Initialize Express app const app = express(); const server = createServer(app); // Middleware app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Session middleware app.use(session({ secret: CONFIG.sessionSecret, resave: false, saveUninitialized: false, cookie: { secure: false, // Allow HTTP (set to true only behind HTTPS proxy) maxAge: CONFIG.sessionMaxAge } })); // CORS for development app.use((req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); if (req.method === 'OPTIONS') return res.sendStatus(200); next(); }); // ==================== LDAP Authentication ==================== async function authenticateLDAP(username, password) { return new Promise((resolve, reject) => { const client = ldap.createClient({ url: CONFIG.ldap.url, timeLimit: 10, sizeLimit: 100 }); client.on('error', (err) => { console.error('[LDAP] Connection error:', err.message); reject(new Error('LDAP connection failed')); }); // Step 1: Bind with service account (if configured) to search for user const doSearch = () => { const filter = CONFIG.ldap.searchFilter.replace('{{username}}', username); const searchOptions = { scope: 'sub', filter: filter, attributes: ['dn', 'uid', 'cn', 'mail', 'displayName', 'memberOf'] }; let userDN = null; const user = { username }; let searchComplete = false; const finishSearch = () => { if (searchComplete) return; searchComplete = true; if (!userDN) { client.destroy(); reject(new Error('User not found')); return; } // Step 2: Bind as the user to verify password client.bind(userDN, password, (err) => { if (err) { client.destroy(); reject(new Error('Invalid credentials')); return; } console.log('[LDAP] Authenticated:', username); client.destroy(); resolve(user); }); }; client.search(CONFIG.ldap.baseDN, searchOptions, (err, res) => { if (err) { client.destroy(); reject(err); return; } res.on('searchEntry', (entry) => { const pojo = entry.pojo; userDN = pojo.objectName; user.dn = pojo.objectName; for (const attr of pojo.attributes) { if (attr.type === 'uid') user.uid = attr.values[0]; if (attr.type === 'cn') user.cn = attr.values[0]; if (attr.type === 'mail') user.email = attr.values[0]; if (attr.type === 'displayName') user.displayName = attr.values[0]; if (attr.type === 'memberOf') user.groups = attr.values; } }); res.on('error', (err) => { console.error('[LDAP] Search error:', err.message); client.destroy(); reject(err); }); res.on('end', () => finishSearch()); }); }; if (CONFIG.ldap.bindDN && CONFIG.ldap.bindPassword) { client.bind(CONFIG.ldap.bindDN, CONFIG.ldap.bindPassword, (err) => { if (err) { console.error('[LDAP] Service bind failed:', err.message); client.destroy(); reject(new Error('LDAP service bind failed')); return; } doSearch(); }); } else { doSearch(); } }); } // ==================== Auth Routes ==================== // Check auth status app.get('/api/auth/status', (req, res) => { if (CONFIG.authDisabled) { return res.json({ authenticated: true, user: { username: 'dev-user', displayName: 'Dev User' } }); } if (req.session.user) { return res.json({ authenticated: true, user: req.session.user }); } res.json({ authenticated: false }); }); // Login endpoint app.post('/api/auth/login', async (req, res) => { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ error: 'Username and password required' }); } // Development bypass if (CONFIG.authDisabled) { req.session.user = { username, displayName: username }; return res.json({ success: true, user: req.session.user }); } // LDAP authentication if (CONFIG.ldap.enabled) { try { const user = await authenticateLDAP(username, password); req.session.user = user; res.json({ success: true, user }); } catch (err) { res.status(401).json({ error: 'Invalid credentials' }); } return; } // Fallback: simple password check (for development without LDAP) res.status(401).json({ error: 'LDAP not configured' }); }); // Logout endpoint app.post('/api/auth/logout', (req, res) => { req.session.destroy(); res.json({ success: true }); }); // Auth middleware for protected routes function requireAuth(req, res, next) { if (CONFIG.authDisabled) return next(); if (!req.session.user) { return res.status(401).json({ error: 'Authentication required' }); } next(); } // ==================== Chat History ==================== function getHistoryPath(userId) { return join(CONFIG.dataDir, `history-${userId}.json`); } function loadHistory(userId) { const path = getHistoryPath(userId); if (!existsSync(path)) return { conversations: [], messages: {} }; try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return { conversations: [], messages: {} }; } } function saveHistory(userId, data) { const path = getHistoryPath(userId); writeFileSync(path, JSON.stringify(data, null, 2)); } // Get conversations list app.get('/api/conversations', requireAuth, (req, res) => { const userId = req.session.user?.username || 'dev-user'; const history = loadHistory(userId); res.json(history.conversations); }); // Get messages for a conversation app.get('/api/conversations/:id/messages', requireAuth, (req, res) => { const userId = req.session.user?.username || 'dev-user'; const history = loadHistory(userId); res.json(history.messages[req.params.id] || []); }); // Create new conversation app.post('/api/conversations', requireAuth, (req, res) => { const userId = req.session.user?.username || 'dev-user'; const history = loadHistory(userId); const conv = { id: uuidv4(), title: req.body.title || 'New Chat', createdAt: Date.now(), updatedAt: Date.now() }; history.conversations.unshift(conv); history.messages[conv.id] = []; saveHistory(userId, history); res.json(conv); }); // Update conversation app.put('/api/conversations/:id', requireAuth, (req, res) => { const userId = req.session.user?.username || 'dev-user'; const history = loadHistory(userId); const conv = history.conversations.find(c => c.id === req.params.id); if (!conv) return res.status(404).json({ error: 'Not found' }); Object.assign(conv, req.body, { updatedAt: Date.now() }); saveHistory(userId, history); res.json(conv); }); // Delete conversation app.delete('/api/conversations/:id', requireAuth, (req, res) => { const userId = req.session.user?.username || 'dev-user'; const history = loadHistory(userId); history.conversations = history.conversations.filter(c => c.id !== req.params.id); delete history.messages[req.params.id]; saveHistory(userId, history); res.json({ success: true }); }); // Save message to conversation app.post('/api/conversations/:id/messages', requireAuth, (req, res) => { const userId = req.session.user?.username || 'dev-user'; const history = loadHistory(userId); if (!history.messages[req.params.id]) { history.messages[req.params.id] = []; } const msg = { id: uuidv4(), role: req.body.role, content: req.body.content, timestamp: Date.now(), ...req.body }; history.messages[req.params.id].push(msg); // Update conversation const conv = history.conversations.find(c => c.id === req.params.id); if (conv) { conv.updatedAt = Date.now(); // Auto-title from first user message if (msg.role === 'user' && history.messages[req.params.id].length === 1) { conv.title = msg.content.substring(0, 50) + (msg.content.length > 50 ? '...' : ''); } } saveHistory(userId, history); res.json(msg); }); // ==================== OpenClaw Gateway Proxy ==================== // HTTP proxy for OpenAI-compatible endpoints app.use('/v1', requireAuth, createProxyMiddleware({ target: CONFIG.gatewayUrl, changeOrigin: true, onProxyReq: (proxyReq, req, res) => { // Add auth token for OpenClaw gateway proxyReq.setHeader('Authorization', `Bearer ${CONFIG.gatewayToken}`); proxyReq.setHeader('x-openclaw-agent-id', req.headers['x-openclaw-agent-id'] || 'main'); // Re-serialize body if it was already parsed by express.json() if (req.body && Object.keys(req.body).length > 0) { const bodyData = JSON.stringify(req.body); proxyReq.setHeader('Content-Length', Buffer.byteLength(bodyData)); proxyReq.write(bodyData); } }, onProxyRes: (proxyRes, req, res) => { // Handle SSE streaming if (proxyRes.headers['content-type']?.includes('text/event-stream')) { proxyRes.headers['cache-control'] = 'no-cache'; proxyRes.headers['connection'] = 'keep-alive'; } } })); // ==================== File Upload ==================== import { createWriteStream } from 'fs'; import { tmpdir } from 'os'; const uploads = new Map(); app.post('/api/upload', requireAuth, express.raw({ type: '*/*', limit: '50mb' }), (req, res) => { const id = uuidv4(); const filename = req.query.filename || 'file'; const mimeType = req.headers['content-type'] || 'application/octet-stream'; uploads.set(id, { filename, mimeType, data: req.body, uploadedAt: Date.now() }); // Clean up old uploads (older than 1 hour) const now = Date.now(); for (const [k, v] of uploads) { if (now - v.uploadedAt > 3600000) uploads.delete(k); } res.json({ id, filename, mimeType, size: req.body.length }); }); app.get('/api/upload/:id', requireAuth, (req, res) => { const upload = uploads.get(req.params.id); if (!upload) return res.status(404).json({ error: 'Not found' }); res.setHeader('Content-Type', upload.mimeType); res.setHeader('Content-Disposition', `attachment; filename="${upload.filename}"`); res.send(upload.data); }); // Get upload metadata app.get('/api/upload/:id/meta', requireAuth, (req, res) => { const upload = uploads.get(req.params.id); if (!upload) return res.status(404).json({ error: 'Not found' }); res.json({ id: req.params.id, filename: upload.filename, mimeType: upload.mimeType, size: upload.data.length }); }); // ==================== Models & Agents ==================== app.get('/api/models', requireAuth, async (req, res) => { try { const response = await fetch(`${CONFIG.gatewayUrl}/v1/models`, { headers: { 'Authorization': `Bearer ${CONFIG.gatewayToken}` } }); const data = await response.json(); res.json(data); } catch (err) { res.json({ data: [ { id: 'main', name: 'Main', owned_by: 'openclaw' }, { id: 'huihui', name: 'HuiHui MoE', owned_by: 'openclaw' }, { id: 'gpt-oss', name: 'GPT-OSS 120B', owned_by: 'openclaw' } ] }); } }); // ==================== Static Files ==================== // Serve frontend in production if (process.env.NODE_ENV === 'production') { app.use(express.static(join(__dirname, '../dist'))); app.get('*', (req, res) => { res.sendFile(join(__dirname, '../dist/index.html')); }); } else { // Development mode - proxy to Vite app.get('/', (req, res) => { res.redirect('http://localhost:5173'); }); } // ==================== WebSocket Server ==================== const wss = new WebSocketServer({ server, path: '/ws' }); wss.on('connection', (ws, req) => { console.log('WebSocket client connected'); // Connect to OpenClaw gateway const gatewayUrl = CONFIG.gatewayUrl.replace('http', 'ws'); const gatewayWs = new WebSocket(`${gatewayUrl}/ws`); gatewayWs.on('open', () => { // Forward client messages to gateway ws.on('message', (data) => { gatewayWs.send(data); }); }); gatewayWs.on('message', (data) => { // Forward gateway messages to client if (ws.readyState === WebSocket.OPEN) { ws.send(data); } }); gatewayWs.on('error', (err) => { console.error('Gateway WS error:', err.message); }); gatewayWs.on('close', () => { if (ws.readyState === WebSocket.OPEN) { ws.close(); } }); ws.on('close', () => { gatewayWs.close(); }); ws.on('error', (err) => { console.error('Client WS error:', err.message); gatewayWs.close(); }); }); // ==================== Start Server ==================== server.listen(CONFIG.port, () => { console.log(` ╔═══════════════════════════════════════════════════════════╗ ║ OpenClaw WebUI Server ║ ╠═══════════════════════════════════════════════════════════╣ ║ Environment: ${(conf.environment || 'development').padEnd(43)}║ ║ Port: ${CONFIG.port.toString().padEnd(43)}║ ║ Gateway: ${CONFIG.gatewayUrl.padEnd(43)}║ ║ LDAP: ${(CONFIG.ldap.enabled ? 'Enabled' : 'Disabled').padEnd(43)}║ ║ Auth: ${(CONFIG.authDisabled ? 'Disabled (dev mode)' : 'Enabled').padEnd(43)}║ ║ Data: ${CONFIG.dataDir.padEnd(43)}║ ╚═══════════════════════════════════════════════════════════╝ `); });