Brand chat as Sovereign and implement admin panel, quotas, schedules, network management, and cryptographic compliance audits
This commit is contained in:
+396
-17
@@ -13,7 +13,7 @@ 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 { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, createWriteStream, unlink } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import ldap from 'ldapjs';
|
||||
@@ -177,12 +177,22 @@ async function authenticateLDAP(username, password) {
|
||||
// ==================== 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' } });
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -199,7 +209,7 @@ app.post('/api/auth/login', async (req, res) => {
|
||||
|
||||
// Development bypass
|
||||
if (CONFIG.authDisabled) {
|
||||
req.session.user = { username, displayName: username };
|
||||
req.session.user = { username, displayName: username, isAdmin: true };
|
||||
return res.json({ success: true, user: req.session.user });
|
||||
}
|
||||
|
||||
@@ -207,6 +217,7 @@ app.post('/api/auth/login', async (req, res) => {
|
||||
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) {
|
||||
@@ -376,7 +387,6 @@ app.use('/v1', requireAuth, createProxyMiddleware({
|
||||
|
||||
// ==================== File Upload ====================
|
||||
|
||||
import { createWriteStream } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
const uploads = new Map();
|
||||
@@ -436,14 +446,383 @@ app.get('/api/models', requireAuth, async (req, res) => {
|
||||
} 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' }
|
||||
{ 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
|
||||
@@ -509,15 +888,15 @@ wss.on('connection', (ws, req) => {
|
||||
|
||||
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)}║
|
||||
╚═══════════════════════════════════════════════════════════╝
|
||||
+---------------------------------------------------------+
|
||||
| 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)} |
|
||||
+---------------------------------------------------------+
|
||||
`);
|
||||
});
|
||||
Reference in New Issue
Block a user