Add: Agent v2.1.2, WebSocket Live Shell, WebRTC Remote Desktop (Beta), Announcement Push

- Agent v2.1.2: WebSocket ShellService, ACK nach WS-Ankündigungen, shown_announcements Fix
- Backend: shellServer.js mit WebSocket-Server (Shell + Announcement Push + RTC Signaling)
- Backend: patch.controller.js Fix (command_id=0 Falsy-Bug beim Auto-Update)
- Frontend: Remote Desktop Tab (WebRTC Beta) in AgentDetailPage für super_admin/admin
- Frontend: PatchManagementPage auf v2.1.2 aktualisiert
- WPF Notification: AllowsTransparency=False + kein DropShadowEffect (Dispatcher-Crash Fix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 13:31:11 +02:00
parent d584e65226
commit c23091fa6e
27 changed files with 3481 additions and 556 deletions

View File

@@ -1,4 +1,5 @@
const { getDatabase } = require('../config/database');
const { pushAnnouncementToAgent, broadcastAnnouncement, getConnectedAgents } = require('../ws/shellServer');
// Hilfsfunktion: welche Ankündigungen sind für einen Agenten relevant (nach Gruppe)?
function getForAgent(agentId) {
@@ -94,7 +95,18 @@ const create = (req, res) => {
INSERT INTO announcements (title, message, type, target_groups, target_agent_ids, created_by_user_id, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(title.trim(), message.trim(), type, JSON.stringify(target_groups), JSON.stringify(target_agent_ids), req.user.id, expires_at || null);
res.status(201).json(db.prepare('SELECT * FROM announcements WHERE id=?').get(result.lastInsertRowid));
const ann = db.prepare('SELECT * FROM announcements WHERE id=?').get(result.lastInsertRowid);
// WebSocket-Push: sofort an verbundene Agents schicken
const wsPayload = { id: ann.id, title: ann.title, message: ann.message, type: ann.type };
const agentIds = JSON.parse(ann.target_agent_ids || '[]');
if (agentIds.length > 0) {
agentIds.forEach(id => pushAnnouncementToAgent(id, [wsPayload]));
} else {
broadcastAnnouncement(wsPayload);
}
res.status(201).json(ann);
};
const update = (req, res) => {

View File

@@ -7,7 +7,10 @@ class FidoKeyController {
* GET /api/fido-keys
*/
static getAllKeys = asyncHandler(async (req, res) => {
const keys = FidoKeyService.getAllKeys();
const { user_id } = req.query;
const keys = user_id
? FidoKeyService.getKeysByUser(parseInt(user_id))
: FidoKeyService.getAllKeys();
res.json({
status: 'success',

View File

@@ -273,11 +273,14 @@ const reportCommandResult = (req, res) => {
if (!agentKey || agentKey !== process.env.AGENT_API_KEY) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { command_id, status, result } = req.body;
if (!command_id) return res.status(400).json({ error: 'command_id erforderlich' });
const command_id = req.body.command_id ?? req.body.CommandId;
const { status, result } = req.body;
if (command_id === undefined || command_id === null) return res.status(400).json({ error: 'command_id erforderlich' });
const db = getDatabase();
db.prepare("UPDATE patch_commands SET status=?, result=?, completed_at=CURRENT_TIMESTAMP WHERE id=?")
.run(status || 'done', result || null, command_id);
if (command_id !== 0) {
db.prepare("UPDATE patch_commands SET status=?, result=?, completed_at=CURRENT_TIMESTAMP WHERE id=?")
.run(status || 'done', result || null, command_id);
}
res.json({ success: true });
};
@@ -293,6 +296,37 @@ const getPendingCommands = (agentId) => {
return { pending: cmds, running };
};
// Remote Shell: POST /api/patch/shell/:agentId
const triggerShell = (req, res) => {
const { agentId } = req.params;
const { command } = req.body;
if (!command) return res.status(400).json({ error: 'command erforderlich' });
const db = getDatabase();
const result = db.prepare(
"INSERT INTO patch_commands (agent_id, command, params, triggered_by_user_id) VALUES (?,?,?,?)"
).run(parseInt(agentId), 'shell_exec', command, req.user.id);
res.status(201).json({ id: result.lastInsertRowid });
};
// Remote Shell: GET /api/patch/shell/:agentId/result/:cmdId
const getShellResult = (req, res) => {
const { cmdId } = req.params;
const db = getDatabase();
const cmd = db.prepare("SELECT id, status, result, created_at, completed_at FROM patch_commands WHERE id=?").get(parseInt(cmdId));
if (!cmd) return res.status(404).json({ error: 'Nicht gefunden' });
res.json(cmd);
};
// Remote Shell: GET /api/patch/shell/:agentId/history
const getShellHistory = (req, res) => {
const { agentId } = req.params;
const db = getDatabase();
const cmds = db.prepare(
"SELECT id, params, status, result, created_at, completed_at FROM patch_commands WHERE agent_id=? AND command='shell_exec' ORDER BY created_at DESC LIMIT 50"
).all(parseInt(agentId));
res.json(cmds);
};
module.exports = {
getGroups, createGroup, updateGroup, deleteGroup, releaseVersionToGroup, releaseVersionToAll,
upsertPolicy, deletePolicy,
@@ -300,4 +334,5 @@ module.exports = {
getOverview,
triggerCommand, triggerGroupCommand, getCommands, reportCommandResult,
getPendingCommands,
triggerShell, getShellResult, getShellHistory,
};

View File

@@ -540,6 +540,8 @@ async function initializeDatabase() {
`INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('MCOPSTN1', 'Teams Domestic Calling', 8.00)`,
`INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('Remote_', 'Remote Desktop Services', 0.00)`,
`INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('SPB', 'Microsoft 365 Business Premium', 22.00)`,
// Remote Shell — params Feld für patch_commands
`ALTER TABLE patch_commands ADD COLUMN params TEXT`,
];
for (const migration of migrations) {
try {

View File

@@ -26,4 +26,9 @@ router.post('/commands/trigger', requireAdmin, ctrl.triggerCommand);
router.post('/commands/trigger-group', requireAdmin, ctrl.triggerGroupCommand);
// (result route is before authenticateToken above)
// Remote Shell
router.post('/shell/:agentId', requireAdmin, ctrl.triggerShell);
router.get('/shell/:agentId/result/:cmdId', requireAdmin, ctrl.getShellResult);
router.get('/shell/:agentId/history', requireAdmin, ctrl.getShellHistory);
module.exports = router;

View File

@@ -1,4 +1,5 @@
const express = require('express');
const http = require('http');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
@@ -430,12 +431,17 @@ async function startServer() {
}
// Start server
app.listen(PORT, () => {
const { setupWebSocketServer } = require('./ws/shellServer');
const httpServer = http.createServer(app);
setupWebSocketServer(httpServer);
httpServer.listen(PORT, () => {
console.log('═══════════════════════════════════════════');
console.log(`✅ Server running on http://localhost:${PORT}`);
console.log(`📝 Environment: ${process.env.NODE_ENV || 'development'}`);
console.log(`🌐 API: http://localhost:${PORT}/api`);
console.log(`💚 Health: http://localhost:${PORT}/health`);
console.log(`🔌 WebSocket: ws://localhost:${PORT}/ws`);
console.log('═══════════════════════════════════════════');
console.log('');
console.log('Press CTRL+C to stop the server');

View File

@@ -3,13 +3,14 @@ const AuditLog = require('../models/AuditLog');
const { AppError } = require('../middleware/errorHandler');
class FidoKeyService {
/**
* Get all FIDO keys
*/
static getAllKeys() {
return FidoKey.getAll();
}
static getKeysByUser(userId) {
return FidoKey.getByAssignedUser(userId);
}
/**
* Get FIDO key by ID
*/

View File

@@ -0,0 +1,157 @@
const WebSocket = require('ws');
const jwt = require('jsonwebtoken');
const { getDatabase } = require('../config/database');
// agentId -> WebSocket
const agentSockets = new Map();
// agentId -> WebSocket
const browserSockets = new Map();
function setupWebSocketServer(httpServer) {
const wss = new WebSocket.Server({ server: httpServer, path: '/ws' });
wss.on('connection', (ws, req) => {
let url;
try {
url = new URL(req.url, 'http://localhost');
} catch {
ws.close(1008, 'invalid url');
return;
}
const type = url.searchParams.get('type');
if (type === 'agent') {
handleAgent(ws, url);
} else if (type === 'shell') {
handleBrowser(ws, url);
} else {
ws.close(1008, 'unknown type');
}
});
console.log('[WS] WebSocket server ready at /ws');
return wss;
}
function handleAgent(ws, url) {
const key = url.searchParams.get('key');
if (key !== process.env.AGENT_API_KEY) {
ws.close(1008, 'unauthorized');
return;
}
const hostname = url.searchParams.get('hostname');
if (!hostname) { ws.close(1008, 'hostname required'); return; }
const db = getDatabase();
const agent = db.prepare('SELECT id FROM monitoring_agents WHERE hostname = ?').get(hostname);
if (!agent) { ws.close(1008, 'agent not found'); return; }
const agentId = agent.id;
// Alte Verbindung schließen falls vorhanden
const oldWs = agentSockets.get(agentId);
if (oldWs && oldWs.readyState === WebSocket.OPEN) oldWs.close();
agentSockets.set(agentId, ws);
console.log(`[WS] Agent verbunden: ${hostname} (id=${agentId})`);
// Falls Browser bereits wartet → Shell starten + melden
const bws = browserSockets.get(agentId);
if (bws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'start_shell' }));
bws.send('\r\n\x1b[32m[Agent verbunden — Shell gestartet]\x1b[0m\r\n');
}
ws.on('message', (data) => {
const bws = browserSockets.get(agentId);
if (!bws || bws.readyState !== WebSocket.OPEN) return;
// RTC-Signaling (offer/answer/ice) → transparent weiterleiten
try {
const msg = JSON.parse(data.toString());
if (msg.type?.startsWith('rtc_')) { bws.send(data.toString()); return; }
} catch { /* kein JSON → Shell-Output */ }
bws.send(data.toString());
});
ws.on('close', () => {
agentSockets.delete(agentId);
const bws = browserSockets.get(agentId);
if (bws?.readyState === WebSocket.OPEN) {
bws.send('\r\n\x1b[31m[Agent getrennt]\x1b[0m\r\n');
}
console.log(`[WS] Agent getrennt: ${hostname}`);
});
}
function handleBrowser(ws, url) {
const token = url.searchParams.get('token');
try {
jwt.verify(token, process.env.JWT_SECRET);
} catch {
ws.close(1008, 'unauthorized');
return;
}
const agentId = parseInt(url.searchParams.get('agentId'));
if (!agentId) { ws.close(1008, 'agentId required'); return; }
browserSockets.set(agentId, ws);
const aws = agentSockets.get(agentId);
if (aws?.readyState === WebSocket.OPEN) {
aws.send(JSON.stringify({ type: 'start_shell' }));
ws.send('\x1b[32m[Verbunden]\x1b[0m\r\n');
} else {
ws.send('\x1b[33m[Warte auf Agent-Verbindung...]\x1b[0m\r\n');
}
ws.on('message', (data) => {
const aws = agentSockets.get(agentId);
if (!aws || aws.readyState !== WebSocket.OPEN) return;
// RTC-Signaling (offer/answer/ice) → transparent weiterleiten
try {
const msg = JSON.parse(data.toString());
if (msg.type?.startsWith('rtc_')) { aws.send(data.toString()); return; }
} catch { /* kein JSON → Tastatureingabe */ }
aws.send(JSON.stringify({ type: 'input', data: data.toString() }));
});
ws.on('close', () => {
browserSockets.delete(agentId);
const aws = agentSockets.get(agentId);
if (aws?.readyState === WebSocket.OPEN) {
aws.send(JSON.stringify({ type: 'stop_shell' }));
}
});
}
// Ankündigung an Agent pushen (für sofortige Zustellung)
function pushAnnouncementToAgent(agentId, announcements) {
const aws = agentSockets.get(agentId);
if (aws?.readyState === WebSocket.OPEN) {
aws.send(JSON.stringify({ type: 'announcements', announcements }));
return true;
}
return false;
}
// Ankündigung an alle verbundenen Agents pushen
function broadcastAnnouncement(announcement) {
let pushed = 0;
for (const [agentId, aws] of agentSockets.entries()) {
if (aws.readyState === WebSocket.OPEN) {
aws.send(JSON.stringify({ type: 'announcements', announcements: [announcement] }));
pushed++;
}
}
return pushed;
}
function getConnectedAgents() {
const ids = [];
for (const [agentId, ws] of agentSockets.entries()) {
if (ws.readyState === WebSocket.OPEN) ids.push(agentId);
}
return ids;
}
module.exports = { setupWebSocketServer, pushAnnouncementToAgent, broadcastAnnouncement, getConnectedAgents };