Initial PULSE server commit with Authelia SSO integration

This commit is contained in:
2025-11-29 19:26:20 -05:00
commit dcca2b9e50
5 changed files with 1922 additions and 0 deletions

32
.gitignore vendored Normal file
View File

@ -0,0 +1,32 @@
# Node modules
node_modules/
# Environment variables (NEVER commit these!)
.env
.env.local
.env.*.local
# Database files
*.db
*.sqlite
*.sqlite3
# Logs
*.log
npm-debug.log*
logs/
# OS files
.DS_Store
Thumbs.db
# Backups
*.backup
*.bak
# Editor files
.vscode/
.idea/
*.swp
*.swo
*~

1145
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "pulse-server",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"bcryptjs": "^3.0.3",
"body-parser": "^2.2.1",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.2",
"mysql2": "^3.15.3",
"ws": "^8.18.3"
}
}

394
public/index.html Normal file
View File

@ -0,0 +1,394 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PULSE - Workflow Orchestration</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
.header {
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
margin-bottom: 30px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left h1 {
color: #667eea;
font-size: 2.5em;
margin-bottom: 5px;
}
.header-left p {
color: #666;
font-size: 1.1em;
}
.user-info {
text-align: right;
}
.user-info .name {
font-weight: 600;
color: #333;
font-size: 1.1em;
}
.user-info .email {
color: #666;
font-size: 0.9em;
}
.user-info .badge {
display: inline-block;
background: #667eea;
color: white;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.8em;
margin-top: 5px;
margin-left: 5px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.card {
background: white;
padding: 25px;
border-radius: 10px;
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
.card h3 {
color: #333;
margin-bottom: 15px;
font-size: 1.3em;
}
.status {
display: inline-block;
padding: 5px 15px;
border-radius: 20px;
font-size: 0.9em;
font-weight: 600;
margin-bottom: 5px;
}
.status.online { background: #10b981; color: white; }
.status.offline { background: #ef4444; color: white; }
.status.running { background: #3b82f6; color: white; }
.status.completed { background: #10b981; color: white; }
.status.failed { background: #ef4444; color: white; }
button {
background: #667eea;
color: white;
border: none;
padding: 12px 24px;
border-radius: 5px;
cursor: pointer;
font-size: 1em;
font-weight: 600;
transition: all 0.3s;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover {
background: #5568d3;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}
button.danger {
background: #ef4444;
}
button.danger:hover {
background: #dc2626;
}
.workflow-item, .execution-item, .worker-item {
padding: 15px;
border: 1px solid #e0e0e0;
border-radius: 5px;
margin-bottom: 10px;
background: #f9f9f9;
}
.workflow-item:hover, .execution-item:hover, .worker-item:hover {
background: #f0f0f0;
}
.workflow-name {
font-weight: 600;
color: #333;
font-size: 1.1em;
margin-bottom: 5px;
}
.workflow-desc {
color: #666;
font-size: 0.9em;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
.empty {
text-align: center;
padding: 30px;
color: #999;
}
.timestamp {
font-size: 0.85em;
color: #999;
}
.error {
background: #fee2e2;
border: 2px solid #ef4444;
color: #991b1b;
padding: 20px;
border-radius: 10px;
margin-bottom: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="header-left">
<h1>⚡ PULSE</h1>
<p>Pipelined Unified Logic & Server Engine</p>
</div>
<div class="user-info" id="userInfo">
<div class="loading">Loading user...</div>
</div>
</div>
<div id="authError" class="error" style="display: none;">
<h3>⚠️ Authentication Required</h3>
<p>Please access PULSE through <strong>https://pulse.lotusguild.org</strong> to authenticate via Authelia.</p>
</div>
<div class="grid">
<div class="card">
<h3>👥 Workers</h3>
<div id="workerStatus">
<div class="loading">Loading...</div>
</div>
</div>
<div class="card">
<h3>📋 Workflows</h3>
<div id="workflowList">
<div class="loading">Loading...</div>
</div>
</div>
<div class="card">
<h3>🚀 Recent Executions</h3>
<div id="executionList">
<div class="loading">Loading...</div>
</div>
</div>
</div>
<div class="card">
<h3>⚡ Quick Actions</h3>
<button onclick="refreshData()">🔄 Refresh Status</button>
<button onclick="alert('Workflow creation UI coming soon!')"> Create Workflow</button>
</div>
</div>
<script>
let currentUser = null;
let ws = null;
async function loadUser() {
try {
const response = await fetch('/api/user');
if (!response.ok) {
document.getElementById('authError').style.display = 'block';
document.getElementById('userInfo').innerHTML =
'<div style="color: #ef4444;">⚠️ Not authenticated</div>';
return false;
}
currentUser = await response.json();
document.getElementById('userInfo').innerHTML = `
<div class="name">${currentUser.name}</div>
<div class="email">${currentUser.email}</div>
<div>${currentUser.groups.map(g =>
`<span class="badge">${g}</span>`
).join('')}</div>
`;
return true;
} catch (error) {
console.error('Error loading user:', error);
document.getElementById('authError').style.display = 'block';
document.getElementById('userInfo').innerHTML =
'<div style="color: #ef4444;">Error loading user</div>';
return false;
}
}
async function loadWorkers() {
try {
const response = await fetch('/api/workers');
const workers = await response.json();
if (workers.length === 0) {
document.getElementById('workerStatus').innerHTML =
'<div class="empty">No workers connected</div>';
return;
}
document.getElementById('workerStatus').innerHTML = workers.map(w => `
<div class="worker-item">
<span class="status ${w.status}">${w.status}</span>
<strong>${w.name}</strong>
<div class="timestamp">Last seen: ${new Date(w.last_heartbeat).toLocaleString()}</div>
</div>
`).join('');
} catch (error) {
console.error('Error loading workers:', error);
document.getElementById('workerStatus').innerHTML =
'<div class="empty">Error loading workers</div>';
}
}
async function loadWorkflows() {
try {
const response = await fetch('/api/workflows');
const workflows = await response.json();
if (workflows.length === 0) {
document.getElementById('workflowList').innerHTML =
'<div class="empty">No workflows defined yet</div>';
return;
}
document.getElementById('workflowList').innerHTML = workflows.map(w => `
<div class="workflow-item">
<div class="workflow-name">${w.name}</div>
<div class="workflow-desc">${w.description || 'No description'}</div>
<div style="margin-top: 10px;">
<button onclick="executeWorkflow('${w.id}')">▶️ Execute</button>
${currentUser && currentUser.isAdmin ?
`<button class="danger" onclick="deleteWorkflow('${w.id}', '${w.name}')">🗑️ Delete</button>`
: ''}
</div>
</div>
`).join('');
} catch (error) {
console.error('Error loading workflows:', error);
document.getElementById('workflowList').innerHTML =
'<div class="empty">Error loading workflows</div>';
}
}
async function loadExecutions() {
try {
const response = await fetch('/api/executions');
const executions = await response.json();
if (executions.length === 0) {
document.getElementById('executionList').innerHTML =
'<div class="empty">No executions yet</div>';
return;
}
document.getElementById('executionList').innerHTML = executions.slice(0, 10).map(e => `
<div class="execution-item">
<span class="status ${e.status}">${e.status}</span>
<strong>${e.workflow_name || 'Unknown Workflow'}</strong>
<div class="timestamp">
Started by ${e.started_by} at ${new Date(e.started_at).toLocaleString()}
</div>
</div>
`).join('');
} catch (error) {
console.error('Error loading executions:', error);
document.getElementById('executionList').innerHTML =
'<div class="empty">Error loading executions</div>';
}
}
async function executeWorkflow(workflowId) {
if (!confirm('Execute this workflow?')) return;
try {
const response = await fetch('/api/executions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workflow_id: workflowId })
});
if (response.ok) {
alert('Workflow execution started!');
loadExecutions();
} else {
alert('Failed to start workflow');
}
} catch (error) {
console.error('Error executing workflow:', error);
alert('Error executing workflow');
}
}
async function deleteWorkflow(workflowId, name) {
if (!confirm(`Delete workflow "${name}"? This cannot be undone.`)) return;
try {
const response = await fetch(`/api/workflows/${workflowId}`, {
method: 'DELETE'
});
if (response.ok) {
alert('Workflow deleted');
loadWorkflows();
} else {
const data = await response.json();
alert(data.error || 'Failed to delete workflow');
}
} catch (error) {
console.error('Error deleting workflow:', error);
alert('Error deleting workflow');
}
}
function refreshData() {
loadWorkers();
loadWorkflows();
loadExecutions();
}
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${window.location.host}`);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('WebSocket message:', data);
refreshData();
};
ws.onclose = () => {
console.log('WebSocket closed, reconnecting...');
setTimeout(connectWebSocket, 5000);
};
}
// Initialize
loadUser().then((success) => {
if (success) {
refreshData();
connectWebSocket();
setInterval(refreshData, 30000); // Refresh every 30 seconds
}
});
</script>
</body>
</html>

328
server.js Normal file
View File

@ -0,0 +1,328 @@
const express = require('express');
const http = require('http');
const WebSocket = require('ws');
const mysql = require('mysql2/promise');
const crypto = require('crypto');
require('dotenv').config();
const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });
// Middleware
app.use(express.json());
app.use(express.static('public'));
// UUID generator
function generateUUID() {
return crypto.randomUUID();
}
// Database pool
const pool = mysql.createPool({
host: process.env.DB_HOST,
port: process.env.DB_PORT || 3306,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
// Initialize database tables
async function initDatabase() {
const connection = await pool.getConnection();
try {
await connection.query(`
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(36) PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
display_name VARCHAR(255),
email VARCHAR(255),
groups TEXT,
last_login TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
await connection.query(`
CREATE TABLE IF NOT EXISTS workers (
id VARCHAR(36) PRIMARY KEY,
name VARCHAR(255) UNIQUE NOT NULL,
status VARCHAR(50) NOT NULL,
last_heartbeat TIMESTAMP NULL,
api_key VARCHAR(255),
metadata JSON,
INDEX idx_status (status),
INDEX idx_heartbeat (last_heartbeat)
)
`);
await connection.query(`
CREATE TABLE IF NOT EXISTS workflows (
id VARCHAR(36) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
definition JSON NOT NULL,
created_by VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_name (name)
)
`);
await connection.query(`
CREATE TABLE IF NOT EXISTS executions (
id VARCHAR(36) PRIMARY KEY,
workflow_id VARCHAR(36) NOT NULL,
status VARCHAR(50) NOT NULL,
started_by VARCHAR(255),
started_at TIMESTAMP NULL,
completed_at TIMESTAMP NULL,
logs JSON,
FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON DELETE CASCADE,
INDEX idx_workflow (workflow_id),
INDEX idx_status (status),
INDEX idx_started (started_at)
)
`);
console.log('Database tables initialized successfully');
} catch (error) {
console.error('Database initialization error:', error);
throw error;
} finally {
connection.release();
}
}
// WebSocket connections
const clients = new Set();
wss.on('connection', (ws) => {
clients.add(ws);
ws.on('close', () => clients.delete(ws));
});
// Broadcast to all connected clients
function broadcast(data) {
clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(data));
}
});
}
// Authelia SSO Middleware
async function authenticateSSO(req, res, next) {
// Check for Authelia headers
const remoteUser = req.headers['remote-user'];
const remoteName = req.headers['remote-name'];
const remoteEmail = req.headers['remote-email'];
const remoteGroups = req.headers['remote-groups'];
if (!remoteUser) {
return res.status(401).json({
error: 'Not authenticated',
message: 'Please access this service through auth.lotusguild.org'
});
}
// Check if user is in allowed groups (admin or employee)
const groups = remoteGroups ? remoteGroups.split(',').map(g => g.trim()) : [];
const allowedGroups = ['admin', 'employee'];
const hasAccess = groups.some(g => allowedGroups.includes(g));
if (!hasAccess) {
return res.status(403).json({
error: 'Access denied',
message: 'You must be in admin or employee group'
});
}
// Store/update user in database
try {
const userId = generateUUID();
await pool.query(
`INSERT INTO users (id, username, display_name, email, groups, last_login)
VALUES (?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
display_name=VALUES(display_name),
email=VALUES(email),
groups=VALUES(groups),
last_login=NOW()`,
[userId, remoteUser, remoteName, remoteEmail, remoteGroups]
);
} catch (error) {
console.error('Error updating user:', error);
}
// Attach user info to request
req.user = {
username: remoteUser,
name: remoteName || remoteUser,
email: remoteEmail || '',
groups: groups,
isAdmin: groups.includes('admin')
};
next();
}
// Routes - All protected by SSO
app.get('/api/user', authenticateSSO, (req, res) => {
res.json(req.user);
});
app.get('/api/workflows', authenticateSSO, async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM workflows ORDER BY created_at DESC');
res.json(rows);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.post('/api/workflows', authenticateSSO, async (req, res) => {
try {
const { name, description, definition } = req.body;
const id = generateUUID();
await pool.query(
'INSERT INTO workflows (id, name, description, definition, created_by) VALUES (?, ?, ?, ?, ?)',
[id, name, description, JSON.stringify(definition), req.user.username]
);
res.json({ id, name, description, definition });
broadcast({ type: 'workflow_created', workflow_id: id });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.delete('/api/workflows/:id', authenticateSSO, async (req, res) => {
try {
// Only admins can delete workflows
if (!req.user.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
await pool.query('DELETE FROM workflows WHERE id = ?', [req.params.id]);
res.json({ success: true });
broadcast({ type: 'workflow_deleted', workflow_id: req.params.id });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/workers', authenticateSSO, async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM workers ORDER BY name');
res.json(rows);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.post('/api/workers/heartbeat', async (req, res) => {
try {
const { worker_id, name, metadata } = req.body;
const apiKey = req.headers['x-api-key'];
// Verify API key
if (apiKey !== process.env.WORKER_API_KEY) {
return res.status(401).json({ error: 'Invalid API key' });
}
await pool.query(
`INSERT INTO workers (id, name, status, last_heartbeat, api_key, metadata)
VALUES (?, ?, 'online', NOW(), ?, ?)
ON DUPLICATE KEY UPDATE
status='online',
last_heartbeat=NOW(),
metadata=VALUES(metadata)`,
[worker_id, name, apiKey, JSON.stringify(metadata)]
);
broadcast({ type: 'worker_update', worker_id, status: 'online' });
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.post('/api/executions', authenticateSSO, async (req, res) => {
try {
const { workflow_id } = req.body;
const id = generateUUID();
await pool.query(
'INSERT INTO executions (id, workflow_id, status, started_by, started_at, logs) VALUES (?, ?, ?, ?, NOW(), ?)',
[id, workflow_id, 'running', req.user.username, JSON.stringify([])]
);
broadcast({ type: 'execution_started', execution_id: id, workflow_id });
res.json({ id, workflow_id, status: 'running' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/executions', authenticateSSO, async (req, res) => {
try {
const [rows] = await pool.query(
'SELECT e.*, w.name as workflow_name FROM executions e LEFT JOIN workflows w ON e.workflow_id = w.id ORDER BY e.started_at DESC LIMIT 50'
);
res.json(rows);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/executions/:id', authenticateSSO, async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM executions WHERE id = ?', [req.params.id]);
if (rows.length === 0) {
return res.status(404).json({ error: 'Not found' });
}
res.json(rows[0]);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Health check (no auth required)
app.get('/health', async (req, res) => {
try {
await pool.query('SELECT 1');
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
database: 'connected',
auth: 'authelia-sso'
});
} catch (error) {
res.status(500).json({
status: 'error',
timestamp: new Date().toISOString(),
database: 'disconnected',
error: error.message
});
}
});
// Start server
const PORT = process.env.PORT || 8080;
const HOST = process.env.HOST || '0.0.0.0';
initDatabase().then(() => {
server.listen(PORT, HOST, () => {
console.log(`PULSE Server running on http://${HOST}:${PORT}`);
console.log(`Connected to MariaDB at ${process.env.DB_HOST}`);
console.log(`Authentication: Authelia SSO`);
console.log(`Worker API Key configured: ${process.env.WORKER_API_KEY ? 'Yes' : 'No'}`);
});
}).catch(err => {
console.error('Failed to start server:', err);
process.exit(1);
});