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:
@@ -6,7 +6,7 @@ namespace ITNexusAgent;
|
|||||||
|
|
||||||
public class AgentWorker
|
public class AgentWorker
|
||||||
{
|
{
|
||||||
private const string Version = "2.7.0";
|
private const string Version = "2.8.0";
|
||||||
private const string DataDir = @"C:\ProgramData\IT Nexus Agent";
|
private const string DataDir = @"C:\ProgramData\IT Nexus Agent";
|
||||||
private const string ConfigPath = @"C:\ProgramData\IT Nexus Agent\config.json";
|
private const string ConfigPath = @"C:\ProgramData\IT Nexus Agent\config.json";
|
||||||
private const string StatusPath = @"C:\ProgramData\IT Nexus Agent\status.json";
|
private const string StatusPath = @"C:\ProgramData\IT Nexus Agent\status.json";
|
||||||
@@ -40,12 +40,24 @@ public class AgentWorker
|
|||||||
|
|
||||||
Log($"Agent v{Version} gestartet");
|
Log($"Agent v{Version} gestartet");
|
||||||
|
|
||||||
|
// Security-Migration: geteilten Bootstrap-Key gegen individuellen Per-Device-Key tauschen.
|
||||||
|
// Idempotent (Server liefert bestehenden Key erneut) — daher bei jedem Start sicher aufrufbar.
|
||||||
|
var hostname = SystemInfoService.GetHostname();
|
||||||
|
var enrolledKey = await _api.EnrollAsync(hostname);
|
||||||
|
if (!string.IsNullOrEmpty(enrolledKey) && enrolledKey != _config.AgentKey)
|
||||||
|
{
|
||||||
|
_config.AgentKey = enrolledKey;
|
||||||
|
_config.Save(ConfigPath);
|
||||||
|
_api.UpdateKey(enrolledKey);
|
||||||
|
Log("ENROLL: Per-Device-Key erhalten und gespeichert");
|
||||||
|
}
|
||||||
|
|
||||||
// WebSocket Shell-Service im Hintergrund starten
|
// WebSocket Shell-Service im Hintergrund starten
|
||||||
var shellService = new ShellService(_config.ServerUrl, _config.AgentKey, SystemInfoService.GetHostname());
|
var shellService = new ShellService(_config.ServerUrl, _config.AgentKey, hostname);
|
||||||
_ = shellService.RunAsync(_ct);
|
_ = shellService.RunAsync(_ct);
|
||||||
|
|
||||||
// WebRTC Remote Desktop Service im Hintergrund starten
|
// WebRTC Remote Desktop Service im Hintergrund starten
|
||||||
var rtcService = new RtcService(_config.ServerUrl, _config.AgentKey, SystemInfoService.GetHostname());
|
var rtcService = new RtcService(_config.ServerUrl, _config.AgentKey, hostname);
|
||||||
_ = rtcService.RunAsync(_ct);
|
_ = rtcService.RunAsync(_ct);
|
||||||
|
|
||||||
while (!_ct.IsCancellationRequested)
|
while (!_ct.IsCancellationRequested)
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
<UseWindowsForms>true</UseWindowsForms>
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
<AssemblyName>IT-Nexus-Agent</AssemblyName>
|
<AssemblyName>IT-Nexus-Agent</AssemblyName>
|
||||||
<RootNamespace>ITNexusAgent</RootNamespace>
|
<RootNamespace>ITNexusAgent</RootNamespace>
|
||||||
<Version>2.7.0</Version>
|
<Version>2.8.0</Version>
|
||||||
<AssemblyVersion>2.7.0.0</AssemblyVersion>
|
<AssemblyVersion>2.8.0.0</AssemblyVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<AllowUnsafeBlocks>false</AllowUnsafeBlocks>
|
<AllowUnsafeBlocks>false</AllowUnsafeBlocks>
|
||||||
|
|||||||
@@ -17,4 +17,9 @@ public class AgentConfig
|
|||||||
return JsonConvert.DeserializeObject<AgentConfig>(json)
|
return JsonConvert.DeserializeObject<AgentConfig>(json)
|
||||||
?? throw new Exception("Ungültige config.json");
|
?? throw new Exception("Ungültige config.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Save(string path)
|
||||||
|
{
|
||||||
|
File.WriteAllText(path, JsonConvert.SerializeObject(this, Formatting.Indented));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,25 @@ public class ApiService(string serverUrl, string agentKey)
|
|||||||
{
|
{
|
||||||
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(30) };
|
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(30) };
|
||||||
private readonly string _baseUrl = serverUrl.TrimEnd('/');
|
private readonly string _baseUrl = serverUrl.TrimEnd('/');
|
||||||
private readonly string _agentKey = agentKey;
|
private string _agentKey = agentKey;
|
||||||
|
|
||||||
|
// Nach erfolgreichem Enrollment wird der geteilte Bootstrap-Key durch den individuellen
|
||||||
|
// Per-Device-Key ersetzt — alle nachfolgenden Requests dieser Instanz nutzen ab dann den neuen Key.
|
||||||
|
public void UpdateKey(string newKey) => _agentKey = newKey;
|
||||||
|
|
||||||
|
public async Task<string?> EnrollAsync(string hostname)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var req = BuildRequest(HttpMethod.Post, "/api/monitoring/enroll", new { hostname });
|
||||||
|
var resp = await _http.SendAsync(req);
|
||||||
|
if (!resp.IsSuccessStatusCode) return null;
|
||||||
|
var body = await resp.Content.ReadAsStringAsync();
|
||||||
|
var result = JsonConvert.DeserializeAnonymousType(body, new { status = "", data = new { agent_key = "" } });
|
||||||
|
return result?.data?.agent_key;
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
private HttpRequestMessage BuildRequest(HttpMethod method, string path, object? body = null)
|
private HttpRequestMessage BuildRequest(HttpMethod method, string path, object? body = null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#define MyAppName "IT Nexus Agent"
|
#define MyAppName "IT Nexus Agent"
|
||||||
#define MyAppVersion "2.7.0"
|
#define MyAppVersion "2.8.0"
|
||||||
#define MyAppPublisher "Cereda Systems GmbH"
|
#define MyAppPublisher "Cereda Systems GmbH"
|
||||||
#define MyAppURL "https://it-nexus.cereda-systems.de"
|
#define MyAppURL "https://it-nexus.cereda-systems.de"
|
||||||
#define MyAppExeName "IT-Nexus-Agent.exe"
|
#define MyAppExeName "IT-Nexus-Agent.exe"
|
||||||
|
|||||||
@@ -4,6 +4,37 @@ const { asyncHandler } = require('../middleware/errorHandler');
|
|||||||
const { getPendingCommands } = require('./patch.controller');
|
const { getPendingCommands } = require('./patch.controller');
|
||||||
const { getDatabase } = require('../config/database');
|
const { getDatabase } = require('../config/database');
|
||||||
const { getForAgent } = require('./announcement.controller');
|
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) {
|
function extractManufacturer(modelStr) {
|
||||||
if (!modelStr) return null;
|
if (!modelStr) return null;
|
||||||
@@ -75,14 +106,13 @@ function syncAgentToAsset(data) {
|
|||||||
// POST /api/monitoring/checkin — called by agent (no JWT, uses API key)
|
// POST /api/monitoring/checkin — called by agent (no JWT, uses API key)
|
||||||
const checkin = asyncHandler(async (req, res) => {
|
const checkin = asyncHandler(async (req, res) => {
|
||||||
const agentKey = req.headers['x-agent-key'];
|
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;
|
const { hostname } = req.body;
|
||||||
if (!hostname) {
|
if (!hostname) {
|
||||||
return res.status(400).json({ status: 'error', message: 'hostname required' });
|
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);
|
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
|
// POST /api/monitoring/announcements-poll — leichter Poll nur für Ankündigungen
|
||||||
const announcementsPoll = asyncHandler(async (req, res) => {
|
const announcementsPoll = asyncHandler(async (req, res) => {
|
||||||
const agentKey = req.headers['x-agent-key'];
|
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;
|
const { hostname } = req.body;
|
||||||
if (!hostname) return res.status(400).json({ error: 'hostname required' });
|
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);
|
const agent = MonitoringAgent.getByHostname(hostname);
|
||||||
if (!agent) return res.json({ announcements: [] });
|
if (!agent) return res.json({ announcements: [] });
|
||||||
const announcements = getForAgent(agent.id);
|
const announcements = getForAgent(agent.id);
|
||||||
@@ -204,7 +234,7 @@ const downloadAnnWatcher = asyncHandler(async (req, res) => {
|
|||||||
const downloadSetup = asyncHandler(async (req, res) => {
|
const downloadSetup = asyncHandler(async (req, res) => {
|
||||||
// Agent-Key Auth für auto-update vom Agent selbst
|
// Agent-Key Auth für auto-update vom Agent selbst
|
||||||
const agentKey = req.headers['x-agent-key'];
|
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';
|
const isAdmin = req.user?.role_name === 'admin' || req.user?.role_name === 'super_admin';
|
||||||
if (!isAgent && !isAdmin) {
|
if (!isAgent && !isAdmin) {
|
||||||
return res.status(401).json({ status: 'error', message: 'Unauthorized' });
|
return res.status(401).json({ status: 'error', message: 'Unauthorized' });
|
||||||
@@ -242,4 +272,4 @@ const downloadSetup = asyncHandler(async (req, res) => {
|
|||||||
res.sendFile(setupPath);
|
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 };
|
||||||
|
|||||||
@@ -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_enabled INTEGER DEFAULT 0`,
|
||||||
`ALTER TABLE monitoring_agents ADD COLUMN defender_signatures_age INTEGER DEFAULT -1`,
|
`ALTER TABLE monitoring_agents ADD COLUMN defender_signatures_age INTEGER DEFAULT -1`,
|
||||||
`ALTER TABLE monitoring_agents ADD COLUMN hardware_serial TEXT`,
|
`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)
|
// Sichere Links (Shares)
|
||||||
`CREATE TABLE IF NOT EXISTS shares (
|
`CREATE TABLE IF NOT EXISTS shares (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ const { requireAdmin } = require('../middleware/roleCheck');
|
|||||||
// Agent checkin — no JWT, uses X-Agent-Key header
|
// Agent checkin — no JWT, uses X-Agent-Key header
|
||||||
router.post('/checkin', ctrl.checkin);
|
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)
|
// Schneller Announcement-Poll (leichtgewichtig, alle 15 Sek)
|
||||||
router.post('/announcements-poll', ctrl.announcementsPoll);
|
router.post('/announcements-poll', ctrl.announcementsPoll);
|
||||||
|
|
||||||
|
|||||||
18
backend/src/utils/agentAuth.js
Normal file
18
backend/src/utils/agentAuth.js
Normal 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 };
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
const WebSocket = require('ws');
|
const WebSocket = require('ws');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { getDatabase } = require('../config/database');
|
const { getDatabase } = require('../config/database');
|
||||||
|
const { validateAgentKey } = require('../utils/agentAuth');
|
||||||
|
|
||||||
// agentId -> WebSocket
|
// agentId -> WebSocket
|
||||||
const agentSockets = new Map();
|
const agentSockets = new Map();
|
||||||
@@ -43,12 +44,12 @@ function setupWebSocketServer(httpServer) {
|
|||||||
|
|
||||||
function handleAgent(ws, url) {
|
function handleAgent(ws, url) {
|
||||||
const key = url.searchParams.get('key');
|
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');
|
ws.close(1008, 'unauthorized');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hostname = url.searchParams.get('hostname');
|
|
||||||
if (!hostname) { ws.close(1008, 'hostname required'); return; }
|
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const agent = db.prepare('SELECT id FROM monitoring_agents WHERE hostname = ?').get(hostname);
|
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) {
|
function handleRdpAgent(ws, url) {
|
||||||
const key = url.searchParams.get('key');
|
const key = url.searchParams.get('key');
|
||||||
if (key !== process.env.AGENT_API_KEY) { ws.close(1008, 'unauthorized'); return; }
|
|
||||||
const hostname = url.searchParams.get('hostname');
|
const hostname = url.searchParams.get('hostname');
|
||||||
if (!hostname) { ws.close(1008, 'hostname required'); return; }
|
if (!hostname) { ws.close(1008, 'hostname required'); return; }
|
||||||
|
if (!validateAgentKey(key, hostname)) { ws.close(1008, 'unauthorized'); return; }
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const agent = db.prepare('SELECT id FROM monitoring_agents WHERE hostname = ?').get(hostname);
|
const agent = db.prepare('SELECT id FROM monitoring_agents WHERE hostname = ?').get(hostname);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useAuth } from '../context/AuthContext';
|
|||||||
|
|
||||||
const ANN_TYPES = { maintenance: { icon: '🔧', label: 'Wartung', color: '#f59e0b' }, warning: { icon: '⚠️', label: 'Warnung', color: '#ef4444' }, info: { icon: 'ℹ️', label: 'Info', color: '#6366f1' } };
|
const ANN_TYPES = { maintenance: { icon: '🔧', label: 'Wartung', color: '#f59e0b' }, warning: { icon: '⚠️', label: 'Warnung', color: '#ef4444' }, info: { icon: 'ℹ️', label: 'Info', color: '#6366f1' } };
|
||||||
|
|
||||||
const LATEST_AGENT_VERSION = '2.7.0';
|
const LATEST_AGENT_VERSION = '2.8.0';
|
||||||
const SEVERITY_LABELS = { critical: 'Kritisch', important: 'Wichtig', moderate: 'Moderat', low: 'Niedrig', all: 'Alle' };
|
const SEVERITY_LABELS = { critical: 'Kritisch', important: 'Wichtig', moderate: 'Moderat', low: 'Niedrig', all: 'Alle' };
|
||||||
const SEVERITY_COLORS = { critical: '#EF4444', important: '#F59E0B', moderate: '#3B82F6', low: '#6B7280', all: '#0D9488' };
|
const SEVERITY_COLORS = { critical: '#EF4444', important: '#F59E0B', moderate: '#3B82F6', low: '#6B7280', all: '#0D9488' };
|
||||||
const COMMAND_LABELS = { check_updates: '🔍 Update-Scan', install_updates: '⬇️ Installation', reboot: '🔄 Neustart', upgrade_win11: '🪟 Win 11 Upgrade', update_agent: '⬆️ Agent-Update' };
|
const COMMAND_LABELS = { check_updates: '🔍 Update-Scan', install_updates: '⬇️ Installation', reboot: '🔄 Neustart', upgrade_win11: '🪟 Win 11 Upgrade', update_agent: '⬆️ Agent-Update' };
|
||||||
|
|||||||
Reference in New Issue
Block a user