{ "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": [] }