Initial commit - Hetzner SecMon
This commit is contained in:
commit
b313c3c2b6
3 files changed
+559
No files matched your search
@@ -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), ...ObjecLine truncated
|
||||||
|
},
|
||||||
|
"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": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Hetzner Secmon – Projektbezogenes Security-Monitoring mit n8n
|
||||||
|
|
||||||
|
> Überwachung sicherheitsrelevanter Änderungen in Hetzner-Cloud-Projekten, mit Zustandsvergleich und Gotify-Alarmierung.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Reference in new issue
Block a user