Files
hetzner-secmon-n8n/Hetzner-Secmon-Produktion.json

401 lines
18 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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": []
}