/** * 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'; const __dirname = dirname(fileURLToPath(import.meta.url)); // Configuration const CONFIG = { port: process.env.PORT || 3000, gatewayUrl: process.env.OPENCLAW_GATEWAY || 'http://127.0.0.1:18789', gatewayToken: process.env.OPENCLAW_TOKEN || 'a41984619a5f4b9bf9148ab6eb4abca53eb796d046cbbec5', sessionSecret: process.env.SESSION_SECRET || 'openclaw-webui-secret-change-in-production', // LDAP Configuration ldap: { url: process.env.LDAP_URL || 'ldap://localhost:389', baseDN: process.env.LDAP_BASE_DN || 'ou=users,dc=example,dc=com', bindDN: process.env.LDAP_BIND_DN || '', bindPassword: process.env.LDAP_BIND_PASSWORD || '', searchFilter: process.env.LDAP_SEARCH_FILTER || '(uid={{username}})', enabled: process.env.LDAP_ENABLED === 'true' }, // Data paths dataDir: process.env.DATA_DIR || join(__dirname, '../data'), // Disable auth for development disableAuth: process.env.DISABLE_AUTH === 'true' }; // 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: process.env.NODE_ENV === 'production', maxAge: 24 * 60 * 60 * 1000 // 24 hours } })); // 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 }); client.on('error', (err) => { reject(new Error('LDAP connection failed')); }); // Construct user DN const userDN = `uid=${username},${CONFIG.ldap.baseDN}`; client.bind(userDN, password, (err) => { if (err) { client.destroy(); reject(new Error('Invalid credentials')); return; } // Successfully authenticated - get user info const searchOptions = { scope: 'base', filter: `(uid=${username})`, attributes: ['dn', 'uid', 'cn', 'mail', 'displayName', 'memberOf'] }; client.search(userDN, searchOptions, (err, res) => { if (err) { client.destroy(); reject(err); return; } const user = { username }; res.on('searchEntry', (entry) => { user.dn = entry.object.dn; user.uid = entry.object.uid; user.cn = entry.object.cn; user.email = entry.object.mail; user.displayName = entry.object.displayName || entry.object.cn; user.groups = entry.object.memberOf || []; }); res.on('error', (err) => { client.destroy(); reject(err); }); res.on('end', () => { client.destroy(); resolve(user); }); }); }); }); } // ==================== Auth Routes ==================== // Check auth status app.get('/api/auth/status', (req, res) => { if (CONFIG.disableAuth) { 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.disableAuth) { 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.disableAuth) 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'); }, 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`); let helloReceived = false; gatewayWs.on('open', () => { // Wait for challenge and send connect ws.on('message', (data) => { // Forward client messages to gateway 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 ║ ╠═══════════════════════════════════════════════════════════╣ ║ Port: ${CONFIG.port.toString().padEnd(44)}║ ║ Gateway: ${CONFIG.gatewayUrl.padEnd(44)}║ ║ LDAP: ${(CONFIG.ldap.enabled ? 'Enabled' : 'Disabled').padEnd(44)}║ ║ Auth: ${(CONFIG.disableAuth ? 'Disabled (dev mode)' : 'Enabled').padEnd(44)}║ ╚═══════════════════════════════════════════════════════════╝ `); });