commit b313c3c2b6eee6067b8a67f879c068e756d2de34 Author: Joachim Hummel Date: Sat Oct 31 14:25:27 2026 +0000 Initial commit - Hetzner SecMon diff --git a/Hetzner-Secmon-Produktion.json b/Hetzner-Secmon-Produktion.json new file mode 100644 index 0000000..692c029 --- /dev/null +++ b/Hetzner-Secmon-Produktion.json @@ -0,0 +1,400 @@ +{ + "name": "Hetzner-Secmon-Produktion", + "nodes": [ + { + "parameters": { + "url": "https://api.hetzner.cloud/v1/servers/actions", + "authentication": "genericCredentialType", + "genericAuthType": "httpBearerAuth", + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "per_page", + "value": "50" + }, + { + "name": "sort", + "value": "id:desc" + } + ] + }, + "options": { + "timeout": 10000 + } + }, + "id": "83538a61-d7e8-45a5-84c0-a9d573443207", + "name": "Hetzner Aktionen", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 224, + 80 + ], + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 2000, + "credentials": { + "httpBearerAuth": { + "id": "0QTgAYwEIMRwFwY3", + "name": "API-Token-Hetzner-Produktion" + } + } + }, + { + "parameters": { + "url": "https://api.hetzner.cloud/v1/servers", + "authentication": "genericCredentialType", + "genericAuthType": "httpBearerAuth", + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "per_page", + "value": "50" + } + ] + }, + "options": { + "timeout": 10000 + } + }, + "id": "5112d4e0-8133-4aa2-adc6-3a1b42557edd", + "name": "Hetzner Server", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 448, + 80 + ], + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 2000, + "credentials": { + "httpBearerAuth": { + "id": "0QTgAYwEIMRwFwY3", + "name": "API-Token-Hetzner-Produktion" + } + } + }, + { + "parameters": { + "url": "https://api.hetzner.cloud/v1/firewalls", + "authentication": "genericCredentialType", + "genericAuthType": "httpBearerAuth", + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "per_page", + "value": "50" + } + ] + }, + "options": { + "timeout": 10000 + } + }, + "id": "235dc45c-5438-427a-aeb9-7055db8f7166", + "name": "Hetzner Firewalls", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 672, + 80 + ], + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 2000, + "credentials": { + "httpBearerAuth": { + "id": "0QTgAYwEIMRwFwY3", + "name": "API-Token-Hetzner-Produktion" + } + } + }, + { + "parameters": { + "url": "https://api.hetzner.cloud/v1/ssh_keys", + "authentication": "genericCredentialType", + "genericAuthType": "httpBearerAuth", + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "per_page", + "value": "50" + } + ] + }, + "options": { + "timeout": 10000 + } + }, + "id": "004d5ed1-c140-456d-920b-de3d001fe01b", + "name": "Hetzner SSH Keys", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 880, + 80 + ], + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 2000, + "credentials": { + "httpBearerAuth": { + "id": "0QTgAYwEIMRwFwY3", + "name": "API-Token-Hetzner-Produktion" + } + } + }, + { + "parameters": { + "jsCode": "// Hetzner Security Watchdog – Erkennung + Outbox (at-least-once).\n// 1) ALLE Eingaben validieren, BEVOR der State angefasst wird.\n// 2) Änderungen -> state.outbox; Snapshot wird im selben Lauf aktualisiert.\n// 3) Ausgabe = komplette Outbox. Erst der Bestätigungs-Node entfernt zugestellte Einträge.\nconst state = $getWorkflowStaticData('global');\n\nconst listOf = (body, field, strict) => {\n if (!body || !Array.isArray(body[field])) throw new Error(`Hetzner-Antwort ohne '${field}'-Array`);\n const p = body.meta?.pagination;\n if (strict && p && (p.next_page != null || (p.total_entries != null && p.total_entries > body[field].length)))\n throw new Error(`${field}: Liste paginiert/unvollständig – Abbruch ohne State-Änderung`);\n return body[field];\n};\nconst actions = listOf($('Hetzner Aktionen').first().json, 'actions', false);\nconst servers = listOf($('Hetzner Server').first().json, 'servers', false);\nconst firewalls = listOf($('Hetzner Firewalls').first().json, 'firewalls', true);\nconst sshKeys = listOf($('Hetzner SSH Keys').first().json, 'ssh_keys', true);\nfor (const k of sshKeys) if (k.id == null) throw new Error('SSH-Key-Eintrag ohne ID');\n\n// ---------- Ab hier wird State verändert ----------\nstate.outbox = Array.isArray(state.outbox) ? state.outbox : [];\nconst now = new Date().toISOString();\nlet seq = 0;\nconst enqueue = (alertId, title, message, priority) => {\n if (state.outbox.some(a => a.alertId === alertId)) return;\n state.outbox.push({ alertId, title, message, priority, created: now, attempts: 0 });\n};\nconst uid = tag => `${tag}-${Date.now()}-${seq++}`;\nconst J = x => JSON.stringify(x);\n\n// ===== 1. Server-Aktionen =====\n// Hetzner-Command-Namen (nicht Endpoint-Namen!): poweroff = stop_server, shutdown = shutdown_server\nconst LABELS = {\n reboot_server: 'Neustart', reset_server: 'Hard-Reset', start_server: 'Server eingeschaltet',\n stop_server: 'Ausgeschaltet (Power-Off)', shutdown_server: 'Herunterfahren (ACPI)',\n enable_rescue: 'Rescue-Modus aktiviert', disable_rescue: 'Rescue-Modus deaktiviert',\n request_console: 'Remote-Konsole angefordert', rebuild_server: 'Server neu installiert',\n reset_password: 'Root-Passwort zurückgesetzt', change_protection: 'Schutz geändert',\n create_image: 'Snapshot/Image erstellt', attach_iso: 'ISO eingehängt', detach_iso: 'ISO ausgehängt'\n};\nconst CRIT = /rescue|console|password|rebuild|create_image|attach_iso/;\nconst names = Object.fromEntries(servers.map(s => [String(s.id), s.name]));\nconst ids = actions.map(a => String(a.id));\nif (!state.initialized) {\n state.seen = ids.slice(0, 1000);\n state.initialized = true;\n} else {\n const seen = new Set(state.seen || []);\n const fresh = actions.filter(a => !seen.has(String(a.id)));\n if (actions.length >= 50 && fresh.length === actions.length)\n enqueue(`actions-overflow-${ids[0]}`, 'Hetzner Alarm: Aktionen evtl. verpasst',\n `Alle ${actions.length} abgerufenen Aktionen sind neu. Ältere Aktionen liegen evtl. außerhalb des Abfragefensters – Hetzner-Konsole prüfen.`, 8);\n for (const a of [...fresh].reverse()) {\n if (!Object.hasOwn(LABELS, a.command)) continue;\n const srv = (a.resources || []).filter(r => r.type === 'server')\n .map(r => `${names[String(r.id)] || 'Server'} (#${r.id})`).join(', ') || 'Unbekannter Server';\n const label = LABELS[a.command];\n enqueue(`action-${a.id}`, `Hetzner Alarm: ${label}`,\n `Aktion: ${label}\\nServer: ${srv}\\nStatus: ${a.status || '?'}\\nZeit UTC: ${a.started || '?'}\\nAction ID: ${a.id}\\nAPI-Befehl: ${a.command}\\nHinweis: API-Aktion erkannt, kein Beweis für unbefugten Zugriff.`,\n CRIT.test(a.command) ? 10 : 8);\n }\n state.seen = [...new Set([...ids, ...(state.seen || [])])].slice(0, 1000);\n}\n\n// ===== 2. Firewalls =====\nconst normRule = r => ({direction:r.direction, protocol:r.protocol, port:r.port ?? null,\n source_ips:[...(r.source_ips||[])].sort(), destination_ips:[...(r.destination_ips||[])].sort(),\n description:r.description || ''});\nconst fwSnap = {};\nfor (const f of firewalls) {\n const rules = (f.rules||[]).map(normRule).sort((x,y)=>J(x).localeCompare(J(y)));\n const assigned = (f.applied_to||[]).map(v=>({type:v.type, server_id:v.server?.id ?? null,\n label_selector:v.label_selector?.selector ?? null})).sort((x,y)=>J(x).localeCompare(J(y)));\n fwSnap[String(f.id)] = {id:f.id, name:f.name, rules, assigned};\n}\nconst fmtRule = r => `${r.direction.toUpperCase()} ${r.protocol.toUpperCase()} Port ${r.port||'alle'} | Quell-IP: ${r.source_ips.join(', ')||'-'} | Ziel-IP: ${r.destination_ips.join(', ')||'-'}${r.description?' | '+r.description:''}`;\nif (!state.hetznerFirewallInitialized) {\n state.hetznerFirewallInitialized = true;\n} else {\n const old = state.hetznerFirewallSnapshot || {};\n const fw = (t, m) => enqueue(uid('fw'), 'Hetzner Firewall: ' + t, m, 10);\n for (const id of new Set([...Object.keys(old), ...Object.keys(fwSnap)])) {\n const b = old[id], a = fwSnap[id];\n if (!b) { fw('Neue Firewall', `${a.name} (#${id}) wurde angelegt.\\nRegeln:\\n${a.rules.map(fmtRule).join('\\n') || '(keine)'}`); continue; }\n if (!a) { fw('Firewall gelöscht', `${b.name} (#${id}) wurde gelöscht.`); continue; }\n const oR = new Set(b.rules.map(J)), nR = new Set(a.rules.map(J));\n const add = a.rules.filter(x => !oR.has(J(x))), rem = b.rules.filter(x => !nR.has(J(x)));\n if (add.length || rem.length)\n fw('Regeln geändert', `Firewall: ${a.name} (#${id})\\n${add.map(r=>'+ '+fmtRule(r)).join('\\n')}\\n${rem.map(r=>'- '+fmtRule(r)).join('\\n')}`.trim());\n if (J(b.assigned) !== J(a.assigned))\n fw('Zuweisungen geändert', `Firewall: ${a.name} (#${id})\\nVorher: ${J(b.assigned)}\\nNachher: ${J(a.assigned)}`);\n if (b.name !== a.name) fw('Umbenannt', `#${id}: ${b.name} -> ${a.name}`);\n }\n}\nstate.hetznerFirewallSnapshot = fwSnap;\n\n// ===== 3. SSH-Keys =====\nconst keySnap = {};\nfor (const k of sshKeys) keySnap[String(k.id)] = {\n name: k.name || '(ohne Namen)', fingerprint: k.fingerprint || '(kein Fingerprint)',\n public_key: k.public_key || '', labels: k.labels || {}, created: k.created || ''\n};\nconst canonLabels = o => J(Object.entries(o || {}).sort(([x],[y]) => x.localeCompare(y)));\nif (!state.hetznerSSHKeysInitialized) {\n state.hetznerSSHKeysInitialized = true;\n} else {\n const prev = state.hetznerSSHKeysSnapshot || {};\n const sk = (t, m) => enqueue(uid('ssh'), 'Hetzner SSH-Key: ' + t, m, 10);\n for (const id of new Set([...Object.keys(prev), ...Object.keys(keySnap)])) {\n const b = prev[id], a = keySnap[id];\n if (!b) sk('NEU angelegt', `Name: ${a.name}\\nID: ${id}\\nFingerprint: ${a.fingerprint}\\nErstellt UTC: ${a.created || '?'}\\nAchtung: Unbekannten Key sofort prüfen.`);\n else if (!a) sk('GELÖSCHT', `Name: ${b.name}\\nID: ${id}\\nFingerprint: ${b.fingerprint}\\nSchlüssel aus Hetzner-Projekt entfernt.`);\n else {\n const d = [];\n if (b.name !== a.name) d.push(`Name: ${b.name} -> ${a.name}`);\n if (b.fingerprint !== a.fingerprint) d.push(`Fingerprint: ${b.fingerprint} -> ${a.fingerprint}`);\n if (b.public_key !== a.public_key) d.push('Public Key geändert');\n if (canonLabels(b.labels) !== canonLabels(a.labels)) d.push('Labels geändert');\n if (d.length) sk('GEÄNDERT', `Key ID: ${id}\\n${d.join('\\n')}`);\n }\n }\n}\nstate.hetznerSSHKeysSnapshot = keySnap;\n\n// ===== Outbox ausgeben =====\nconst MAX_ATTEMPTS = 1440; // ~24 h bei 1-Minuten-Intervall\nstate.outbox = state.outbox.filter(a => a.attempts < MAX_ATTEMPTS).slice(-200);\nfor (const a of state.outbox) a.attempts++;\nreturn state.outbox.map(a => ({ json: {\n alertId: a.alertId,\n title: a.attempts > 1 ? `${a.title} [verzögert, Versuch ${a.attempts}]` : a.title,\n message: a.attempts > 1 ? `${a.message}\\nErkannt UTC: ${a.created}` : a.message,\n priority: a.priority\n}}));\n" + }, + "id": "adf0c62f-cf48-4ed6-8b9e-1fb35c6ff9ad", + "name": "Änderungen erkennen", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1104, + 80 + ], + "notes": "Validiert alle API-Antworten vor State-Änderung. Neue Alarme landen in staticData.outbox und werden bis zur bestätigten Zustellung jede Minute erneut ausgegeben." + }, + { + "parameters": { + "method": "POST", + "url": "https://notify.example.de/message", + "authentication": "genericCredentialType", + "genericAuthType": "httpBearerAuth", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ title: $json.title, message: $json.message, priority: $json.priority }) }}", + "options": { + "timeout": 10000 + } + }, + "id": "46f6c063-5eed-41fd-9488-e56b35d2b615", + "name": "Gotify Warnung", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1328, + 80 + ], + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 3000, + "credentials": { + "httpBearerAuth": { + "id": "fBjDrQFrS2vUmHw2", + "name": "Gotify-Token" + } + }, + "onError": "continueErrorOutput" + }, + { + "parameters": { + "jsCode": "// Entfernt nur erfolgreich an Gotify zugestellte Einträge aus der Outbox.\nconst state = $getWorkflowStaticData('global');\nconst delivered = new Set(\n $input.all().map((_, i) => $('Änderungen erkennen').itemMatching(i)?.json?.alertId).filter(Boolean)\n);\nstate.outbox = (state.outbox || []).filter(a => !delivered.has(a.alertId));\nreturn [{ json: { delivered: delivered.size, pending: state.outbox.length } }];\n" + }, + "id": "057d1dbb-6a45-4fc0-a2c1-f12bcd3fa2a7", + "name": "Zustellung bestätigen", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1552, + 0 + ] + }, + { + "parameters": {}, + "id": "4909d4ce-ef55-453b-92c5-8040b553c5f3", + "name": "Error Trigger", + "type": "n8n-nodes-base.errorTrigger", + "typeVersion": 1, + "position": [ + 432, + 272 + ] + }, + { + "parameters": { + "jsCode": "// Error Trigger -> Gotify. Drosselung: gleicher Workflow+Node+Fehler max. 1x pro 15 min.\nconst state = $getWorkflowStaticData('global');\nconst d = $input.first().json;\nconst wfName = d.workflow?.name || '?';\nconst node = d.execution?.lastNodeExecuted || d.trigger?.error?.node?.name || '?';\nconst msg = String(d.execution?.error?.message || d.trigger?.error?.message || 'unbekannter Fehler').slice(0, 400);\nconst key = `${d.workflow?.id}|${node}|${msg}`;\nstate.last = state.last || {};\nconst now = Date.now();\nfor (const [k, t] of Object.entries(state.last)) if (now - t > 24 * 3600e3) delete state.last[k];\nif (state.last[key] && now - state.last[key] < 15 * 60e3) return [];\nstate.last[key] = now;\nreturn [{ json: {\n title: `n8n Workflow-Fehler: ${wfName}`,\n message: `Workflow: ${wfName}\\nNode: ${node}\\nFehler: ${msg}\\nModus: ${d.execution?.mode || '?'}\\nExecution: ${d.execution?.url || d.execution?.id || '-'}\\nZeit UTC: ${new Date().toISOString()}`,\n priority: /hetzner|watchdog|security/i.test(wfName) ? 9 : 6\n}}];\n" + }, + "id": "668dfb8c-a198-4c2f-95bf-7c01efa01469", + "name": "Fehler formatieren", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 656, + 272 + ] + }, + { + "parameters": { + "method": "POST", + "url": "https://notify.example.de/message", + "authentication": "genericCredentialType", + "genericAuthType": "httpBearerAuth", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ title: $json.title, message: $json.message, priority: $json.priority }) }}", + "options": { + "timeout": 10000 + } + }, + "id": "75bb1fa0-4ebe-499e-be64-02555e986e1f", + "name": "Gotify Fehler", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 880, + 272 + ], + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 5000, + "credentials": { + "httpBearerAuth": { + "id": "fBjDrQFrS2vUmHw2", + "name": "Gotify-Token" + } + } + }, + { + "parameters": { + "rule": { + "interval": [ + { + "field": "minutes" + } + ] + } + }, + "id": "83ac8c29-7917-4ae5-aad7-b6896dbd095e", + "name": "Alle 5 Minuten", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [ + 0, + 80 + ] + } + ], + "pinData": {}, + "connections": { + "Hetzner Aktionen": { + "main": [ + [ + { + "node": "Hetzner Server", + "type": "main", + "index": 0 + } + ] + ] + }, + "Hetzner Server": { + "main": [ + [ + { + "node": "Hetzner Firewalls", + "type": "main", + "index": 0 + } + ] + ] + }, + "Hetzner Firewalls": { + "main": [ + [ + { + "node": "Hetzner SSH Keys", + "type": "main", + "index": 0 + } + ] + ] + }, + "Hetzner SSH Keys": { + "main": [ + [ + { + "node": "Änderungen erkennen", + "type": "main", + "index": 0 + } + ] + ] + }, + "Änderungen erkennen": { + "main": [ + [ + { + "node": "Gotify Warnung", + "type": "main", + "index": 0 + } + ] + ] + }, + "Gotify Warnung": { + "main": [ + [ + { + "node": "Zustellung bestätigen", + "type": "main", + "index": 0 + } + ] + ] + }, + "Error Trigger": { + "main": [ + [ + { + "node": "Fehler formatieren", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fehler formatieren": { + "main": [ + [ + { + "node": "Gotify Fehler", + "type": "main", + "index": 0 + } + ] + ] + }, + "Alle 5 Minuten": { + "main": [ + [ + { + "node": "Hetzner Aktionen", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": true, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate" + }, + "versionId": "46972dd5-b363-43a6-b05c-6e0a1d487406", + "meta": { + "instanceId": "34" + }, + "nodeGroups": [], + "id": "LaAEQrLaPcrMhQOK", + "tags": [] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..204700d --- /dev/null +++ b/README.md @@ -0,0 +1,159 @@ +# Hetzner Secmon – Projektbezogenes Security-Monitoring mit n8n + +> Überwachung sicherheitsrelevanter Änderungen in Hetzner-Cloud-Projekten, mit Zustandsvergleich und Gotify-Alarmierung. + +![Hetzner SecMon – Security-Monitoring mit n8n](pictures/Hetzner-SecMon.webp) + +## Warum ich dieses Projekt gebaut habe + +Ausgangspunkt war ein konkreter Sicherheitsvorfall in meiner Hetzner-Cloud-Umgebung: Ein Angreifer hatte Zugriff auf einen API-Token erlangt. In der anschließenden Untersuchung musste ich Aktivitäten nachvollziehen und betroffene Server absichern. Dabei wurde deutlich, dass die reine nachträgliche Kontrolle über die Hetzner-Konsole für meine Anforderungen nicht ausreicht. + +Ich wollte insbesondere wissen, **wann in einem Projekt ein Server neu gestartet, abgeschaltet, neu installiert oder in den Rescue-Modus versetzt wird**, ob **Firewall-Regeln beziehungsweise Zuweisungen verändert** werden und ob jemand **SSH-Schlüssel hinzufügt, entfernt oder modifiziert**. Diese Ereignisse können legitime Administrationsarbeiten sein – aber auch Indikatoren für einen unberechtigten Zugriff. + +Deshalb habe ich einen eigenständigen Monitor mit **n8n**, der über die Hetzner Cloud API regelmäßig den aktuellen Zustand abfragt, Veränderungen erkennt und mir über **Gotify** meldet. Die Überwachung wird je Hetzner-Projekt beziehungsweise Umgebung mit entsprechendem API-Zugang betrieben. Wichtig war mir dabei nicht nur die Erkennung, sondern auch, dass Benachrichtigungen bei vorübergehenden Zustellproblemen nicht sofort verloren gehen. + +**Grundgedanke:** Sicherheitsrelevante Änderungen sollen mir zeitnah auffallen, statt erst bei einer späteren manuellen Kontrolle. + +## Funktionsübersicht + +| Bereich | Erkennung / Verhalten | +|---|---| +| Serveraktionen | Neustart, Reset, Ein-/Ausschalten, Herunterfahren, Rescue-Modus, Remote-Konsole, Rebuild, Root-Passwort-Reset, Schutzänderung, Image-Erstellung und ISO-Mount/-Unmount | +| Firewalls | Neue/gelöschte Firewalls, geänderte Regeln, Server-/Label-Zuweisungen und Umbenennungen | +| SSH-Schlüssel | Neue/gelöschte Keys, Änderungen an Namen, Fingerprint, Public Key oder Labels | +| Benachrichtigungen | Gotify-Nachrichten mit Ereignisdetails und Priorität | +| Zustellung | Outbox mit erneutem Versand bis zur Bestätigung, innerhalb definierter Grenzen | +| Fehler | Separater Pfad zur Gotify-Fehlermeldung, sofern korrekt als Error Workflow konfiguriert | + +## Architektur + +```text +Schedule Trigger + | + v +Hetzner API: Serveraktionen + | + v +Hetzner API: Serverliste + | + v +Hetzner API: Firewalls + | + v +Hetzner API: SSH-Keys + | + v +Änderungen erkennen + | (n8n Workflow Static Data: + | Snapshots + gesehene Aktionen + Outbox) + v +Gotify Warnung + | + v +Zustellung bestätigen + +Error Trigger -> Fehler formatieren -> Gotify Fehler +``` + +Der Workflow ist im exportierten Zustand **aktiv**. Die API-Aufrufe laufen nacheinander und verwenden HTTP Bearer Authentication über n8n-Credentials. HTTP-Timeout: jeweils 10 Sekunden; für die API-Abfragen sind zwei Versuche konfiguriert. Die Gotify-Requests haben maximal drei Versuche. + +## So funktioniert die Erkennung + +### 1. Serveraktionen + +Der Workflow fragt `GET /v1/servers/actions` mit `per_page=50` und absteigender Action-ID ab. IDs bereits bekannter Aktionen werden in `state.seen` gehalten (maximal 1.000). Neue Aktionen mit einem im Code bekannten Kommando erzeugen einen Alarm. Die Serverliste dient dabei zur Zuordnung von IDs zu Servernamen. + +Besonders hoch priorisiert sind Aktionen im Zusammenhang mit Rescue, Konsole, Passwort, Rebuild, Image-Erstellung und ISO-Einhängen. + +**Hinweis:** Beim ersten Lauf werden vorhandene Action-IDs nur als Ausgangszustand gespeichert. Es werden dafür keine rückwirkenden Benachrichtigungen erstellt. Wenn alle 50 eingelesenen Aktionen neu sind, gibt es einen zusätzlichen Warnhinweis auf mögliche Lücken. + +### 2. Firewall-Überwachung + +Die aktuelle Firewall-Konfiguration wird normalisiert und als Snapshot gespeichert. Hierbei werden Regeln und Zuweisungen unabhängig von ihrer Listenreihenfolge verglichen. Daraus lassen sich unter anderem folgende Änderungen erkennen: + +- Neue oder gelöschte Firewall +- Hinzugefügte oder entfernte Firewall-Regel +- Geänderte Regelparameter wie Richtung, Protokoll, Port, Quell-/Ziel-IP oder Beschreibung +- Geänderte Zuordnung zu Servern oder Label-Selektoren +- Umbenennung einer Firewall + +Änderungen werden in einer Alarmmeldung mit den jeweils erkannten Differenzen ausgegeben. Der erste Lauf erstellt nur die Vergleichsbasis. + +### 3. SSH-Key-Überwachung + +Der Workflow speichert je SSH-Key-ID Name, Fingerprint, öffentlichen Schlüssel, Labels und Erstellungszeitpunkt. Er meldet neu angelegte, gelöschte oder veränderte Keys. Auch hier erzeugt der Initiallauf zunächst einen Snapshot ohne Änderungsalarm. + +### 4. Outbox und Zustellbestätigung + +Neue Alarme werden zunächst in `state.outbox` gespeichert. Bei jedem Lauf werden die noch ausstehenden Alarme erneut an Gotify übergeben. Erst der Node **„Zustellung bestätigen“** entfernt Einträge, die über den erfolgreichen Gotify-Ausgang gelaufen sind. + +Dieses Muster nähert eine *At-least-once-Zustellung* an: Bei zeitweiligen Übertragungsfehlern können Meldungen wiederholt werden. Eine garantierte einmalige Zustellung gibt es nicht; Duplikate sind möglich. Außerdem gelten ausdrücklich Grenzen: maximal 200 Outbox-Einträge und maximal 1.440 Ausgabeversuche pro Eintrag. Danach können Alarme verworfen werden. + +### 5. Workflow-Fehler + +Der separate Pfad `Error Trigger -> Fehler formatieren -> Gotify Fehler` erstellt Fehlermeldungen mit Node, Fehlermeldung und Ausführungsreferenz. Identische Fehler werden anhand von Workflow, Node und Meldung für 15 Minuten gedrosselt. + +**Wichtig:** Ein Error Trigger allein stellt noch nicht sicher, dass Fehler dieses Workflows an diesen Pfad gelangen. Die n8n-Error-Workflow-Konfiguration und die tatsächliche Funktion müssen separat überprüft werden. + +## Installation und Konfiguration + +1. Die Workflow-JSON in n8n importieren. +2. Ein Hetzner Cloud API-Credential vom Typ **HTTP Bearer Auth** für das zu überwachende Projekt hinterlegen und den vier Hetzner-HTTP-Nodes zuweisen. Der Token benötigt Lesezugriff auf die betroffenen Ressourcen; die konkret passenden Berechtigungen sind in Hetzner zu prüfen. +3. Ein Gotify-Credential für die beiden Benachrichtigungs-Nodes konfigurieren und die Gotify-Server-URL auf die eigene Umgebung anpassen. +4. Den Schedule prüfen und das gewünschte Intervall festlegen. +5. Den Workflow einmal ausführen, damit die Ausgangssnapshots erstellt werden. +6. Danach kontrollierte Teständerungen in Hetzner auslösen: beispielsweise eine Firewall-Regel ändern, einen Test-SSH-Key anlegen und eine Serveraktion durchführen. Prüfen, ob die Meldungen tatsächlich ankommen. +7. Fehlerpfad und Verhalten bei vorübergehend unerreichbarem Gotify gezielt testen. + +**Mehrere Projekte:** Die Hetzner Cloud API liefert projektbezogene Daten. Für eine getrennte Überwachung verschiedener Umgebungen können separate Workflow-Instanzen mit jeweils passendem Projekt-Credential betrieben werden. Credentials und Zustände dürfen dabei nicht versehentlich zwischen Projekten vermischt werden. + +## Datenhaltung + +Die Implementierung verwendet **n8n Workflow Static Data** (`$getWorkflowStaticData('global')`). Darin werden unter anderem gespeichert: + +- Bereits bekannte Server-Action-IDs +- Firewall-Snapshot +- SSH-Key-Snapshot +- Outbox mit Alarm-ID, Titel, Nachricht, Priorität, Erstellungszeitpunkt und Anzahl der Versuche +- Zeitpunkte für die Drosselung wiederholter Workflow-Fehler + +Eine externe Datenbank oder n8n Data Table wird im bereitgestellten Export **nicht** verwendet. Die gespeicherten Werte dienen zum Vergleich zwischen Workflow-Ausführungen. Ein verlässlicher Betrieb setzt voraus, dass Static Data erhalten bleibt und Workflow-Ausführungen nicht unkontrolliert parallel laufen. + +## Aktuelle Grenzen und wichtige Prüfungen + +Diese Punkte sind keine theoretische Funktionsbeschreibung, sondern ergeben sich aus dem vorliegenden Workflow-Export: + +1. **Intervall:** Der Trigger heißt „Alle 5 Minuten“, seine Regel lautet aber lediglich `field: minutes` ohne expliziten Wert `5`. In n8n ist damit typischerweise ein einminütiger Rhythmus gemeint. Das tatsächliche Intervall muss im Editor geprüft werden. Auch die Code-Kommentare gehen von einer Minute aus. +2. **API-Paginierung:** Serveraktionen werden mit maximal 50 Ergebnissen pro Abruf eingelesen; weitere Seiten werden nicht nachgeladen. Bei hoher Aktivität können Ereignisse außerhalb dieses Fensters fehlen. Für Firewalls und SSH-Keys wird bei erkennbar unvollständiger Paginierung abgebrochen; Serverlisten werden nicht vollständigkeitsgeprüft. +3. **Zeitverzögerung:** Das Verfahren ist Polling, keine Echtzeit-Eventübertragung. Änderungen zwischen zwei Abfragen können unentdeckt bleiben, insbesondere wenn sie vor der nächsten Abfrage wieder rückgängig gemacht wurden. +4. **Outbox-Begrenzung:** Die maximale Länge von 200 Alarmen und die Grenze von 1.440 Versuchen können bei längerem Ausfall zu Verlust führen. „At least once“ ist deshalb keine uneingeschränkte Garantie. +5. **Keine revisionssichere Historie:** Die Static Data stellen keine manipulationssicheren Audit-Logs dar. Es gibt im Export auch keine dauerhaft gespeicherte Ereignishistorie außerhalb der aktuellen Zustände. +6. **Kein Nachweis der Ursache:** Eine API-Aktion belegt nicht automatisch einen Angriff. Die Auswertung identifiziert weder sicher die handelnde Person noch eindeutig, ob eine Aktion legitim war. +7. **Fehlerüberwachung:** Ein Ausfall von n8n selbst oder seiner Infrastruktur kann auch den Alarmweg verhindern. Der separate Error-Trigger-Pfad ist entsprechend zu prüfen. +8. **Zustelllogik:** Zu testen ist, ob die Zuordnung der erfolgreichen Gotify-Items zu ihren ursprünglichen `alertId`-Werten bei Teilfehlern und mehreren gleichzeitigen Alarmen zuverlässig funktioniert. + +## Sicherheitsmaßnahmen + +- Hetzner-Token **niemals im README oder in Git hinterlegen**; nur n8n-Credentials verwenden. +- Token möglichst mit minimal notwendigen Rechten und getrennt pro Projekt führen. +- n8n, Gotify und ihre Zugangsdaten gegen unberechtigten Zugriff absichern. +- Änderungen durch kontrollierte Tests regelmäßig verifizieren; besonders Firewall- und SSH-Key-Änderungen. +- Für höhere Verlässlichkeit einen dauerhaften Ereignisspeicher, Monitoring der letzten erfolgreichen Ausführung und einen unabhängigen Alarmweg ergänzen. +- Bei Verdacht auf Token-Kompromittierung die betroffenen Zugangsdaten widerrufen/rotieren und die Hetzner-Aktivitäten unabhängig untersuchen. + +## Geplanter Ausbau + +- Vollständige API-Paginierung für alle relevanten Ressourcen +- Dauerhafte Historie und differenzierter Zustandsvergleich in einer Datenbank beziehungsweise n8n Data Tables +- Überwachung des letzten erfolgreichen Pollings (Heartbeat/Dead-Man-Switch) +- Tests für Zustellfehler, gleichzeitige Änderungen und Wiederanlauf +- Optional zweiter Alarmkanal unabhängig von Gotify +- Vereinheitlichte Auswertung mehrerer Projekte in einer zentralen Übersicht + +## Einordnung + +**Hetzner Secmon ist eine zusätzliche Detektions- und Alarmierungsschicht, kein Ersatz für Hetzners eigene Protokolle, restriktive Berechtigungen, Backups oder Incident Response.** Das Projekt entstand aus einem realen Sicherheitsbedarf und verfolgt einen pragmatischen Ansatz: vorhandene APIs und selbst betriebene Infrastruktur nutzen, Änderungen sichtbar machen und den manuellen Kontrollaufwand reduzieren. + +## Technischer Stand + +Dokumentiert anhand des bereitgestellten n8n-Exports `Hetzner-Secmon-Produktion.json` vom 31. Oktober 2026. Die README beschreibt die implementierten Funktionen sowie die aus dem Code erkennbaren Grenzen; sie bestätigt keine erfolgreich durchgeführten End-to-End-Tests. diff --git a/pictures/Hetzner-SecMon.webp b/pictures/Hetzner-SecMon.webp new file mode 100644 index 0000000..5b64499 Binary files /dev/null and b/pictures/Hetzner-SecMon.webp differ