902 lines
28 KiB
JavaScript
902 lines
28 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, readdirSync, statSync, createWriteStream, unlink } 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
|
|
function isUserAdmin(user) {
|
|
if (!user) return false;
|
|
if (user.username === 'nova') return true;
|
|
if (user.groups && Array.isArray(user.groups)) {
|
|
return user.groups.some(g => g.toLowerCase().includes('admin') || g.toLowerCase().includes('host_access'));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
app.get('/api/auth/status', (req, res) => {
|
|
if (CONFIG.authDisabled) {
|
|
return res.json({ authenticated: true, user: { username: 'dev-user', displayName: 'Dev User', isAdmin: true } });
|
|
}
|
|
|
|
if (req.session.user) {
|
|
req.session.user.isAdmin = isUserAdmin(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, isAdmin: true };
|
|
return res.json({ success: true, user: req.session.user });
|
|
}
|
|
|
|
// LDAP authentication
|
|
if (CONFIG.ldap.enabled) {
|
|
try {
|
|
const user = await authenticateLDAP(username, password);
|
|
user.isAdmin = isUserAdmin(user);
|
|
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 { 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: 'SmolLM2-135M-Instruct-Q8_0.gguf', name: 'SmolLM2 135M (Pre-approved)', owned_by: 'sovereign' }
|
|
]
|
|
});
|
|
}
|
|
});
|
|
|
|
// ==================== Sovereign Branding & Admin Panel Backend ====================
|
|
|
|
const activeSessions = new Map();
|
|
|
|
const SETTINGS_PATH = join(CONFIG.dataDir, 'settings.json');
|
|
let systemSettings = {
|
|
dailyTokenQuota: 50000,
|
|
schedule: {
|
|
enabled: false,
|
|
startHour: 9,
|
|
endHour: 17
|
|
}
|
|
};
|
|
if (existsSync(SETTINGS_PATH)) {
|
|
try {
|
|
systemSettings = JSON.parse(readFileSync(SETTINGS_PATH, 'utf-8'));
|
|
} catch (err) {
|
|
console.error('Failed to parse settings.json:', err.message);
|
|
}
|
|
}
|
|
function saveSettings() {
|
|
try {
|
|
writeFileSync(SETTINGS_PATH, JSON.stringify(systemSettings, null, 2));
|
|
} catch (err) {
|
|
console.error('Failed to save settings.json:', err.message);
|
|
}
|
|
}
|
|
|
|
function requireAdmin(req, res, next) {
|
|
if (CONFIG.authDisabled) return next();
|
|
if (!req.session.user) {
|
|
return res.status(401).json({ error: 'Authentication required' });
|
|
}
|
|
if (!isUserAdmin(req.session.user)) {
|
|
return res.status(403).json({ error: 'Forbidden: Admin access required' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
// Intercept chat completions requests for token quota, active schedules, and compliance audit logging
|
|
app.post('/v1/chat/completions', requireAuth, (req, res, next) => {
|
|
const user = req.session.user?.username || 'unknown';
|
|
const model = req.body?.model || 'unknown';
|
|
const stream = req.body?.stream || false;
|
|
const inputCharCount = req.body?.messages?.reduce((acc, m) => acc + (m.content?.length || 0), 0) || 0;
|
|
const approximatePromptTokens = Math.ceil(inputCharCount / 4);
|
|
|
|
// 1. Schedule Enforcement Check
|
|
if (systemSettings.schedule?.enabled) {
|
|
const currentHour = new Date().getHours();
|
|
if (currentHour < systemSettings.schedule.startHour || currentHour >= systemSettings.schedule.endHour) {
|
|
return res.status(403).json({ error: `Inference offline. Service schedule is ${systemSettings.schedule.startHour}:00 - ${systemSettings.schedule.endHour}:00.` });
|
|
}
|
|
}
|
|
|
|
// 2. Token Quota Enforcement Check
|
|
const userStats = activeSessions.get(user) || { queriesCount: 0, totalPromptTokensEstimate: 0 };
|
|
if (userStats.totalPromptTokensEstimate >= systemSettings.dailyTokenQuota) {
|
|
return res.status(429).json({ error: `Daily token quota of ${systemSettings.dailyTokenQuota} tokens exceeded.` });
|
|
}
|
|
|
|
// 3. Write metadata to cryptographic-audit-logger staged folder /tank/audit/
|
|
const auditRecord = {
|
|
timestamp: new Date().toISOString(),
|
|
user,
|
|
action: 'chat_completion',
|
|
model,
|
|
stream,
|
|
approximatePromptTokens,
|
|
status: 'initiated'
|
|
};
|
|
|
|
try {
|
|
if (!existsSync('/tank/audit')) {
|
|
mkdirSync('/tank/audit', { recursive: true });
|
|
}
|
|
writeFileSync('/tank/audit/chat-audit.log', JSON.stringify(auditRecord) + '\n', { flag: 'a' });
|
|
} catch (err) {
|
|
console.error('[AUDIT] Failed to write query audit record:', err.message);
|
|
}
|
|
|
|
// 4. Update stats cache
|
|
userStats.queriesCount++;
|
|
userStats.totalPromptTokensEstimate += approximatePromptTokens;
|
|
userStats.lastActive = new Date().toISOString();
|
|
userStats.lastModel = model;
|
|
activeSessions.set(user, userStats);
|
|
|
|
next();
|
|
});
|
|
|
|
// Admin status (including current system network files)
|
|
app.get('/api/admin/status', requireAdmin, (req, res) => {
|
|
let networkInfo = {};
|
|
try {
|
|
if (existsSync('/etc/network/interfaces')) {
|
|
networkInfo.interfaces = readFileSync('/etc/network/interfaces', 'utf-8');
|
|
}
|
|
if (existsSync('/etc/theta42/network.json')) {
|
|
networkInfo.configured = JSON.parse(readFileSync('/etc/theta42/network.json', 'utf-8'));
|
|
}
|
|
} catch (err) {
|
|
networkInfo.error = err.message;
|
|
}
|
|
|
|
res.json({
|
|
activeModel: CONFIG.selectedModel || 'SmolLM2-135M-Instruct-Q8_0.gguf',
|
|
network: networkInfo,
|
|
settings: systemSettings,
|
|
environment: CONFIG.environment
|
|
});
|
|
});
|
|
|
|
// Network configuration changes
|
|
app.post('/api/admin/network', requireAdmin, (req, res) => {
|
|
const networkConfig = req.body;
|
|
try {
|
|
if (!existsSync('/etc/theta42')) {
|
|
mkdirSync('/etc/theta42', { recursive: true });
|
|
}
|
|
writeFileSync('/etc/theta42/network.json', JSON.stringify(networkConfig, null, 2));
|
|
|
|
const auditRecord = {
|
|
timestamp: new Date().toISOString(),
|
|
user: req.session.user?.username || 'admin',
|
|
action: 'configure_network',
|
|
details: networkConfig
|
|
};
|
|
writeFileSync('/tank/audit/chat-audit.log', JSON.stringify(auditRecord) + '\n', { flag: 'a' });
|
|
|
|
res.json({ success: true, message: 'Network configuration saved successfully.' });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to write network config: ' + err.message });
|
|
}
|
|
});
|
|
|
|
// Get log file list generated by host's cryptographic-audit-logger
|
|
app.get('/api/admin/audit-logs', requireAdmin, (req, res) => {
|
|
try {
|
|
const files = [];
|
|
if (existsSync('/tank/audit')) {
|
|
const list = readdirSync('/tank/audit');
|
|
for (const file of list) {
|
|
const stat = statSync(join('/tank/audit', file));
|
|
if (file.endsWith('.tar.gz') || file.endsWith('.sig') || file.endsWith('.log')) {
|
|
files.push({
|
|
name: file,
|
|
size: stat.size,
|
|
mtime: stat.mtime
|
|
});
|
|
}
|
|
}
|
|
}
|
|
res.json({ logs: files });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to read audit logs: ' + err.message });
|
|
}
|
|
});
|
|
|
|
// Download log file
|
|
app.get('/api/admin/audit-logs/:filename', requireAdmin, (req, res) => {
|
|
const filename = req.params.filename;
|
|
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
|
return res.status(400).json({ error: 'Invalid filename' });
|
|
}
|
|
const path = join('/tank/audit', filename);
|
|
if (!existsSync(path)) {
|
|
return res.status(404).json({ error: 'File not found' });
|
|
}
|
|
res.download(path);
|
|
});
|
|
|
|
// Pre-approved models list
|
|
app.get('/api/admin/preapproved', requireAdmin, (req, res) => {
|
|
try {
|
|
let manifest = { models: [] };
|
|
if (existsSync('/etc/theta42/models-manifest.json')) {
|
|
manifest = JSON.parse(readFileSync('/etc/theta42/models-manifest.json', 'utf-8'));
|
|
}
|
|
res.json(manifest);
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to read models manifest: ' + err.message });
|
|
}
|
|
});
|
|
|
|
// Download pre-approved model
|
|
app.post('/api/admin/models/download', requireAdmin, async (req, res) => {
|
|
const { filename, url, sha256 } = req.body;
|
|
if (!filename || !url || !sha256) {
|
|
return res.status(400).json({ error: 'Missing parameters: filename, url, and sha256 are required' });
|
|
}
|
|
|
|
res.json({ success: true, message: `Download of ${filename} started in background.` });
|
|
|
|
(async () => {
|
|
const stagingPath = join('/tank/staging', filename);
|
|
try {
|
|
if (!existsSync('/tank/staging')) {
|
|
mkdirSync('/tank/staging', { recursive: true });
|
|
}
|
|
|
|
console.log(`[DOWNLOAD] Starting download of pre-approved model ${filename} from ${url}...`);
|
|
|
|
const httpModule = url.startsWith('https') ? await import('https') : await import('http');
|
|
const fileStream = createWriteStream(stagingPath);
|
|
|
|
httpModule.get(url, (response) => {
|
|
if (response.statusCode === 302 || response.statusCode === 301) {
|
|
const redirectUrl = response.headers.location;
|
|
console.log(`[DOWNLOAD] Following redirect to: ${redirectUrl}`);
|
|
httpModule.get(redirectUrl, (redirectResponse) => {
|
|
redirectResponse.pipe(fileStream);
|
|
fileStream.on('finish', () => {
|
|
fileStream.close();
|
|
finalizeDownload(filename, sha256);
|
|
});
|
|
});
|
|
} else {
|
|
response.pipe(fileStream);
|
|
fileStream.on('finish', () => {
|
|
fileStream.close();
|
|
finalizeDownload(filename, sha256);
|
|
});
|
|
}
|
|
}).on('error', (err) => {
|
|
unlink(stagingPath, () => {});
|
|
console.error('[DOWNLOAD] Error downloading file:', err.message);
|
|
});
|
|
} catch (err) {
|
|
console.error('[DOWNLOAD] Background task failed:', err.message);
|
|
}
|
|
})();
|
|
});
|
|
|
|
function finalizeDownload(filename, sha256) {
|
|
console.log(`[DOWNLOAD] Completed download of ${filename} to staging.`);
|
|
try {
|
|
let manifest = { models: [] };
|
|
if (existsSync('/etc/theta42/models-manifest.json')) {
|
|
manifest = JSON.parse(readFileSync('/etc/theta42/models-manifest.json', 'utf-8'));
|
|
}
|
|
const exists = manifest.models.some(m => m.filename === filename);
|
|
if (!exists) {
|
|
manifest.models.push({ filename, sha256 });
|
|
writeFileSync('/etc/theta42/models-manifest.json', JSON.stringify(manifest, null, 2));
|
|
console.log(`[DOWNLOAD] Registered ${filename} in models-manifest.json.`);
|
|
}
|
|
} catch (manifestErr) {
|
|
console.error('[DOWNLOAD] Failed to update models manifest:', manifestErr.message);
|
|
}
|
|
}
|
|
|
|
// Upload custom model file to staging
|
|
app.post('/api/admin/models/upload', requireAdmin, (req, res) => {
|
|
const filename = req.headers['x-filename'];
|
|
const sha256 = req.headers['x-sha256'];
|
|
if (!filename) {
|
|
return res.status(400).json({ error: 'Missing x-filename header' });
|
|
}
|
|
|
|
if (!existsSync('/tank/staging')) {
|
|
mkdirSync('/tank/staging', { recursive: true });
|
|
}
|
|
|
|
const stagingPath = join('/tank/staging', filename);
|
|
console.log(`[UPLOAD] Uploading custom model ${filename} to staging...`);
|
|
|
|
const fileStream = createWriteStream(stagingPath);
|
|
req.pipe(fileStream);
|
|
|
|
fileStream.on('finish', () => {
|
|
fileStream.close();
|
|
console.log(`[UPLOAD] Completed upload of custom model ${filename} to staging.`);
|
|
|
|
try {
|
|
let manifest = { models: [] };
|
|
if (existsSync('/etc/theta42/models-manifest.json')) {
|
|
manifest = JSON.parse(readFileSync('/etc/theta42/models-manifest.json', 'utf-8'));
|
|
}
|
|
const exists = manifest.models.some(m => m.filename === filename);
|
|
if (!exists) {
|
|
manifest.models.push({
|
|
filename,
|
|
sha256: sha256 || "custom-hash-unverified"
|
|
});
|
|
writeFileSync('/etc/theta42/models-manifest.json', JSON.stringify(manifest, null, 2));
|
|
console.log(`[UPLOAD] Registered ${filename} in models-manifest.json.`);
|
|
}
|
|
} catch (manifestErr) {
|
|
console.error('[UPLOAD] Failed to update models manifest:', manifestErr.message);
|
|
}
|
|
|
|
res.json({ success: true, message: `Custom model ${filename} uploaded and staged successfully.` });
|
|
});
|
|
|
|
fileStream.on('error', (err) => {
|
|
unlink(stagingPath, () => {});
|
|
res.status(500).json({ error: 'Failed to write upload stream: ' + err.message });
|
|
});
|
|
});
|
|
|
|
// Update global configuration settings (quotas, schedules)
|
|
app.post('/api/admin/settings', requireAdmin, (req, res) => {
|
|
const { dailyTokenQuota, schedule } = req.body;
|
|
if (dailyTokenQuota !== undefined) systemSettings.dailyTokenQuota = parseInt(dailyTokenQuota);
|
|
if (schedule !== undefined) systemSettings.schedule = schedule;
|
|
|
|
saveSettings();
|
|
|
|
const auditRecord = {
|
|
timestamp: new Date().toISOString(),
|
|
user: req.session.user?.username || 'admin',
|
|
action: 'configure_settings',
|
|
details: systemSettings
|
|
};
|
|
writeFileSync('/tank/audit/chat-audit.log', JSON.stringify(auditRecord) + '\n', { flag: 'a' });
|
|
|
|
res.json({ success: true, settings: systemSettings });
|
|
});
|
|
|
|
// Set active model and reload AI-Core
|
|
app.post('/api/admin/models/active', requireAdmin, async (req, res) => {
|
|
const { model } = req.body;
|
|
if (!model) {
|
|
return res.status(400).json({ error: 'Missing model parameter' });
|
|
}
|
|
|
|
try {
|
|
console.log(`[RELOAD] Triggering AI-Core reload to load model: ${model}...`);
|
|
const reloadResponse = await fetch(`http://192.168.100.201:8000/v1/models/reload`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ model })
|
|
});
|
|
|
|
if (!reloadResponse.ok) {
|
|
const errorText = await reloadResponse.text();
|
|
throw new Error(`AI-Core reload failed: ${errorText}`);
|
|
}
|
|
|
|
CONFIG.selectedModel = model;
|
|
|
|
const auditRecord = {
|
|
timestamp: new Date().toISOString(),
|
|
user: req.session.user?.username || 'admin',
|
|
action: 'switch_model',
|
|
model
|
|
};
|
|
writeFileSync('/tank/audit/chat-audit.log', JSON.stringify(auditRecord) + '\n', { flag: 'a' });
|
|
|
|
res.json({ success: true, message: `Model switched to ${model} and reloaded successfully.` });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to trigger model reload: ' + err.message });
|
|
}
|
|
});
|
|
|
|
// User monitoring stats
|
|
app.get('/api/admin/monitoring', requireAdmin, (req, res) => {
|
|
const list = [];
|
|
activeSessions.forEach((stats, user) => {
|
|
list.push({
|
|
user,
|
|
queriesCount: stats.queriesCount,
|
|
totalPromptTokensEstimate: stats.totalPromptTokensEstimate,
|
|
lastActive: stats.lastActive,
|
|
lastModel: stats.lastModel
|
|
});
|
|
});
|
|
res.json({ activeSessions: list });
|
|
});
|
|
|
|
// ==================== 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(`
|
|
+---------------------------------------------------------+
|
|
| Sovereign Chat Server | Theta42 |
|
|
+---------------------------------------------------------+
|
|
| Environment: ${(conf.environment || 'production').padEnd(41)} |
|
|
| Port: ${CONFIG.port.toString().padEnd(41)} |
|
|
| Gateway: ${CONFIG.gatewayUrl.padEnd(41)} |
|
|
| LDAP: ${(CONFIG.ldap.enabled ? 'Enabled' : 'Disabled').padEnd(41)} |
|
|
| Auth: ${(CONFIG.authDisabled ? 'Disabled (dev mode)' : 'Enabled').padEnd(41)} |
|
|
| Data: ${CONFIG.dataDir.padEnd(41)} |
|
|
+---------------------------------------------------------+
|
|
`);
|
|
}); |