Files
openclaw-webui/server/index.js
Nova 5aa5ab73f3 Initial commit: OpenClaw WebUI with LDAP SSO
Features:
- Modern chat interface with streaming responses
- Multi-file upload support
- Code canvas panel for code viewing/editing
- Chat history persistence
- LDAP SSO authentication
- OpenAI-compatible API proxy to OpenClaw gateway
- Model/agent selection
- Dark theme
2026-02-25 03:11:16 +00:00

480 lines
14 KiB
JavaScript

/**
* 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)}
╚═══════════════════════════════════════════════════════════╝
`);
});