Security: Pro-Geräte Agent-Keys statt geteiltem AGENT_API_KEY (v2.8.0)

Agent v2.8.0 tauscht beim Start automatisch den geteilten Bootstrap-Key
gegen einen individuellen Per-Device-Key (POST /api/monitoring/enroll,
idempotent). Checkin/Announcements-Poll/Setup-Download/WS-Agent-Verbindungen
validieren den Key jetzt gegen den jeweiligen Hostname — ein gestohlener
Key kann sich nicht mehr als anderer Agent ausgeben (manuell verifiziert).

Alte Agents mit dem geteilten Key funktionieren während der Übergangsphase
weiter (validateAgentKey() akzeptiert beides), damit der Rollout die Fleet
nicht abrupt bricht — Migration läuft über den bestehenden Staged-Rollout
(Test → Pilot → Produktion).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 13:50:02 +02:00
parent d513992de7
commit f3bd8d0910
11 changed files with 110 additions and 21 deletions

View File

@@ -4,6 +4,37 @@ const { asyncHandler } = require('../middleware/errorHandler');
const { getPendingCommands } = require('./patch.controller');
const { getDatabase } = require('../config/database');
const { getForAgent } = require('./announcement.controller');
const { validateAgentKey } = require('../utils/agentAuth');
const crypto = require('crypto');
// POST /api/monitoring/enroll — Agent tauscht geteilten Bootstrap-Key gegen individuellen Per-Device-Key.
// Idempotent: ein bereits enrollter Agent bekommt seinen bestehenden Key einfach erneut zurück.
const enroll = asyncHandler(async (req, res) => {
const agentKey = req.headers['x-agent-key'];
const { hostname } = req.body;
if (!hostname) return res.status(400).json({ status: 'error', message: 'hostname required' });
const db = getDatabase();
const existing = db.prepare('SELECT id, agent_key FROM monitoring_agents WHERE hostname = ?').get(hostname);
const validBootstrap = agentKey === process.env.AGENT_API_KEY;
const validExisting = existing?.agent_key && agentKey === existing.agent_key;
if (!validBootstrap && !validExisting) {
return res.status(401).json({ status: 'error', message: 'Invalid agent key' });
}
let key = existing?.agent_key;
if (!key) {
key = 'itx-' + crypto.randomBytes(24).toString('hex');
if (existing) {
db.prepare('UPDATE monitoring_agents SET agent_key = ? WHERE id = ?').run(key, existing.id);
} else {
db.prepare('INSERT INTO monitoring_agents (hostname, agent_key) VALUES (?, ?)').run(hostname, key);
}
console.log(`[Enroll] Neuer Per-Device-Key ausgestellt für ${hostname}`);
}
res.json({ status: 'success', data: { agent_key: key } });
});
function extractManufacturer(modelStr) {
if (!modelStr) return null;
@@ -75,14 +106,13 @@ function syncAgentToAsset(data) {
// POST /api/monitoring/checkin — called by agent (no JWT, uses API key)
const checkin = asyncHandler(async (req, res) => {
const agentKey = req.headers['x-agent-key'];
if (!agentKey || agentKey !== process.env.AGENT_API_KEY) {
return res.status(401).json({ status: 'error', message: 'Invalid agent key' });
}
const { hostname } = req.body;
if (!hostname) {
return res.status(400).json({ status: 'error', message: 'hostname required' });
}
if (!validateAgentKey(agentKey, hostname)) {
return res.status(401).json({ status: 'error', message: 'Invalid agent key' });
}
const agent = MonitoringAgent.upsert(req.body);
@@ -133,11 +163,11 @@ const getAll = asyncHandler(async (req, res) => {
// POST /api/monitoring/announcements-poll — leichter Poll nur für Ankündigungen
const announcementsPoll = asyncHandler(async (req, res) => {
const agentKey = req.headers['x-agent-key'];
if (!agentKey || agentKey !== process.env.AGENT_API_KEY) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { hostname } = req.body;
if (!hostname) return res.status(400).json({ error: 'hostname required' });
if (!validateAgentKey(agentKey, hostname)) {
return res.status(401).json({ error: 'Unauthorized' });
}
const agent = MonitoringAgent.getByHostname(hostname);
if (!agent) return res.json({ announcements: [] });
const announcements = getForAgent(agent.id);
@@ -204,7 +234,7 @@ const downloadAnnWatcher = asyncHandler(async (req, res) => {
const downloadSetup = asyncHandler(async (req, res) => {
// Agent-Key Auth für auto-update vom Agent selbst
const agentKey = req.headers['x-agent-key'];
const isAgent = agentKey && agentKey === process.env.AGENT_API_KEY;
const isAgent = validateAgentKey(agentKey, req.query.hostname);
const isAdmin = req.user?.role_name === 'admin' || req.user?.role_name === 'super_admin';
if (!isAgent && !isAdmin) {
return res.status(401).json({ status: 'error', message: 'Unauthorized' });
@@ -242,4 +272,4 @@ const downloadSetup = asyncHandler(async (req, res) => {
res.sendFile(setupPath);
});
module.exports = { checkin, announcementsPoll, getAll, getStatistics, getById, deleteAgent, downloadScript, downloadAnnWatcher, downloadSetup };
module.exports = { checkin, announcementsPoll, getAll, getStatistics, getById, deleteAgent, downloadScript, downloadAnnWatcher, downloadSetup, enroll };

View File

@@ -449,6 +449,8 @@ async function initializeDatabase() {
`ALTER TABLE monitoring_agents ADD COLUMN defender_enabled INTEGER DEFAULT 0`,
`ALTER TABLE monitoring_agents ADD COLUMN defender_signatures_age INTEGER DEFAULT -1`,
`ALTER TABLE monitoring_agents ADD COLUMN hardware_serial TEXT`,
// Pro-Geräte Agent-Key (Security-Migration weg vom geteilten AGENT_API_KEY)
`ALTER TABLE monitoring_agents ADD COLUMN agent_key TEXT`,
// Sichere Links (Shares)
`CREATE TABLE IF NOT EXISTS shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,

View File

@@ -7,6 +7,9 @@ const { requireAdmin } = require('../middleware/roleCheck');
// Agent checkin — no JWT, uses X-Agent-Key header
router.post('/checkin', ctrl.checkin);
// Agent tauscht Bootstrap-Key gegen individuellen Per-Device-Key (Security-Migration)
router.post('/enroll', ctrl.enroll);
// Schneller Announcement-Poll (leichtgewichtig, alle 15 Sek)
router.post('/announcements-poll', ctrl.announcementsPoll);

View File

@@ -0,0 +1,18 @@
const { getDatabase } = require('../config/database');
// Übergangslogik pro-Geräte-Keys (DSGVO/Security-Migration):
// - Alte Agents (vor v2.8.0) schicken noch den einen geteilten AGENT_API_KEY → wird vorübergehend
// weiter akzeptiert, damit der Rollout nicht die ganze Fleet auf einmal bricht.
// - Migrierte Agents schicken ihren individuellen, beim Enrollment ausgestellten Key — dieser wird
// NUR für den exakt zugehörigen Hostname akzeptiert, ein gestohlener Key kann also nicht mehr
// benutzt werden, um sich als ein anderes Gerät auszugeben.
function validateAgentKey(presentedKey, hostname) {
if (!presentedKey) return false;
if (presentedKey === process.env.AGENT_API_KEY) return true;
if (!hostname) return false;
const db = getDatabase();
const row = db.prepare('SELECT agent_key FROM monitoring_agents WHERE hostname = ?').get(hostname);
return !!row?.agent_key && row.agent_key === presentedKey;
}
module.exports = { validateAgentKey };

View File

@@ -1,6 +1,7 @@
const WebSocket = require('ws');
const jwt = require('jsonwebtoken');
const { getDatabase } = require('../config/database');
const { validateAgentKey } = require('../utils/agentAuth');
// agentId -> WebSocket
const agentSockets = new Map();
@@ -43,12 +44,12 @@ function setupWebSocketServer(httpServer) {
function handleAgent(ws, url) {
const key = url.searchParams.get('key');
if (key !== process.env.AGENT_API_KEY) {
const hostname = url.searchParams.get('hostname');
if (!hostname) { ws.close(1008, 'hostname required'); return; }
if (!validateAgentKey(key, hostname)) {
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);
@@ -143,9 +144,9 @@ function handleBrowser(ws, url) {
function handleRdpAgent(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; }
if (!validateAgentKey(key, hostname)) { ws.close(1008, 'unauthorized'); return; }
const db = getDatabase();
const agent = db.prepare('SELECT id FROM monitoring_agents WHERE hostname = ?').get(hostname);