{ "name": "E - Security report (solo)", "nodes": [ { "parameters": { "httpMethod": "POST", "path": "security-report-solo", "responseMode": "onReceived", "options": {} }, "name": "Start", "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [ 0, 300 ] }, { "parameters": { "method": "GET", "url": "http://forgejo:3000/api/v1/repos/opsadmin/selfhostedops-stack/raw/agents/skills/security-analyst-solo.md", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "options": { "response": { "response": { "responseFormat": "text" } } } }, "name": "Load skill", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 220, 300 ], "credentials": { "httpHeaderAuth": { "id": "REPLACE_WITH_YOUR_CREDENTIAL_ID", "name": "Forgejo API" } } }, { "parameters": { "jsCode": "\n// The skill file is markdown with a small frontmatter block (name, description,\n// tools). The frontmatter is for humans and for the README; the model gets\n// everything below it.\nfunction body(md) { return String(md ?? '').replace(/^---[\\s\\S]*?\\n---\\s*/, '').trim(); }\n\nconst b = $('Start').first().json.body ?? {};\nconst started = new Date().toISOString();\nreturn [{ json: {\n variant: 'solo',\n label: String(b.label ?? ''),\n deliver: b.deliver !== false,\n started,\n system: body($json.data),\n task: 'Write the daily security report for the last 24 hours. Current time: ' + started\n + '. Call every tool once, copy the numbers, then reply with the JSON object only.',\n}}];\n" }, "name": "Build prompts", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 440, 300 ] }, { "parameters": { "promptType": "define", "text": "={{ $json.task }}", "hasOutputParser": false, "options": { "systemMessage": "={{ $json.system }}", "maxIterations": 12, "returnIntermediateSteps": true, "enableStreaming": false } }, "name": "Security analyst", "type": "@n8n/n8n-nodes-langchain.agent", "typeVersion": 2.2, "position": [ 660, 300 ] }, { "parameters": { "model": "qwen2.5:3b", "options": { "temperature": 0, "numCtx": 8192, "keepAlive": "30m", "think": false } }, "name": "Ollama over the mesh", "type": "@n8n/n8n-nodes-langchain.lmChatOllama", "typeVersion": 1, "position": [ 620, 520 ], "credentials": { "ollamaApi": { "id": "REPLACE_WITH_YOUR_CREDENTIAL_ID", "name": "Ollama (home lab over mesh)" } } }, { "parameters": { "toolDescription": "fail2ban status of the sshd jail: currently failed, total failed, currently banned, total banned, banned IPs. Read only, no arguments.", "method": "GET", "url": "http://ops-readonly:8098/read/f2b_status?jail=sshd", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth" }, "name": "f2b_status_sshd", "type": "@n8n/n8n-nodes-langchain.toolHttpRequest", "typeVersion": 1.1, "position": [ 760, 560 ], "credentials": { "httpHeaderAuth": { "id": "REPLACE_WITH_YOUR_CREDENTIAL_ID", "name": "ops-readonly token" } } }, { "parameters": { "toolDescription": "fail2ban status of the caddy-probes jail (web probes): currently failed, total failed, currently banned, total banned, banned IPs. Read only, no arguments.", "method": "GET", "url": "http://ops-readonly:8098/read/f2b_status?jail=caddy-probes", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth" }, "name": "f2b_status_caddy", "type": "@n8n/n8n-nodes-langchain.toolHttpRequest", "typeVersion": 1.1, "position": [ 930, 560 ], "credentials": { "httpHeaderAuth": { "id": "REPLACE_WITH_YOUR_CREDENTIAL_ID", "name": "ops-readonly token" } } }, { "parameters": { "toolDescription": "Every IP address fail2ban is banning right now, per jail. Read only, no arguments.", "method": "GET", "url": "http://ops-readonly:8098/read/f2b_banned", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth" }, "name": "f2b_banned", "type": "@n8n/n8n-nodes-langchain.toolHttpRequest", "typeVersion": 1.1, "position": [ 1100, 560 ], "credentials": { "httpHeaderAuth": { "id": "REPLACE_WITH_YOUR_CREDENTIAL_ID", "name": "ops-readonly token" } } }, { "parameters": { "toolDescription": "Summary of the sshd journal for the last 24 hours: login failures, invalid users, distinct source IPs, top 5 usernames, top 5 source IPs. Read only, no arguments.", "method": "GET", "url": "http://ops-readonly:8098/read/sshd_failed_summary?n=5", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth" }, "name": "sshd_failed_summary", "type": "@n8n/n8n-nodes-langchain.toolHttpRequest", "typeVersion": 1.1, "position": [ 1270, 560 ], "credentials": { "httpHeaderAuth": { "id": "REPLACE_WITH_YOUR_CREDENTIAL_ID", "name": "ops-readonly token" } } }, { "parameters": { "toolDescription": "Caddy access log for the last 24 hours: total requests, requests per status code, rejected 4xx, rate limited 429, server errors 5xx, distinct client IPs. Read only, no arguments.", "method": "GET", "url": "http://ops-readonly:8098/read/caddy_status_counts", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth" }, "name": "caddy_status_counts", "type": "@n8n/n8n-nodes-langchain.toolHttpRequest", "typeVersion": 1.1, "position": [ 1440, 560 ], "credentials": { "httpHeaderAuth": { "id": "REPLACE_WITH_YOUR_CREDENTIAL_ID", "name": "ops-readonly token" } } }, { "parameters": { "toolDescription": "The n request paths that were rejected most often (status 400 or higher) in the Caddy access log of the last 24 hours. One argument: n, how many entries, use 5.", "method": "GET", "url": "http://ops-readonly:8098/read/caddy_top_paths?n={n}", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "placeholderDefinitions": { "values": [ { "name": "n", "description": "how many entries, a whole number from 1 to 20, use 5", "type": "number" } ] } }, "name": "caddy_top_paths", "type": "@n8n/n8n-nodes-langchain.toolHttpRequest", "typeVersion": 1.1, "position": [ 1610, 560 ], "credentials": { "httpHeaderAuth": { "id": "REPLACE_WITH_YOUR_CREDENTIAL_ID", "name": "ops-readonly token" } } }, { "parameters": { "toolDescription": "The n client IPs with the most rejected requests (status 400 or higher) in the Caddy access log of the last 24 hours. One argument: n, how many entries, use 5.", "method": "GET", "url": "http://ops-readonly:8098/read/caddy_top_ips?n={n}", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "placeholderDefinitions": { "values": [ { "name": "n", "description": "how many entries, a whole number from 1 to 20, use 5", "type": "number" } ] } }, "name": "caddy_top_ips", "type": "@n8n/n8n-nodes-langchain.toolHttpRequest", "typeVersion": 1.1, "position": [ 1780, 560 ], "credentials": { "httpHeaderAuth": { "id": "REPLACE_WITH_YOUR_CREDENTIAL_ID", "name": "ops-readonly token" } } }, { "parameters": { "jsCode": "\nfunction readObject(s) {\n if (s && typeof s === 'object') return s;\n const t = String(s ?? '');\n const a = t.indexOf('{');\n const b = t.lastIndexOf('}');\n if (a === -1 || b <= a) return {};\n for (let end = b; end > a; end = t.lastIndexOf('}', end - 1)) {\n try { return JSON.parse(t.slice(a, end + 1)); } catch (e) { /* keep trying */ }\n }\n return {};\n}\n\n// Deterministic. The model wrote a report; this node only checks that every\n// field the schema demands is there and has the right type. It never fixes a\n// number, it only marks what is missing, so a gap stays visible in the wiki.\nconst REQUIRED = {\n 'ssh.login_failures_24h': 'number', 'ssh.invalid_user_24h': 'number',\n 'ssh.distinct_source_ips_24h': 'number', 'ssh.currently_banned': 'number',\n 'ssh.total_banned': 'number', 'ssh.top_usernames': 'array', 'ssh.top_source_ips': 'array',\n 'web.requests_24h': 'number', 'web.rejected_4xx_24h': 'number',\n 'web.rate_limited_429_24h': 'number', 'web.server_errors_5xx_24h': 'number',\n 'web.distinct_client_ips_24h': 'number', 'web.currently_banned': 'number',\n 'web.total_banned': 'number', 'web.top_probe_paths': 'array', 'web.top_probe_ips': 'array',\n 'headline': 'string', 'assessment': 'string', 'recommendations': 'array',\n};\nfunction get(o, path) { return path.split('.').reduce((x, k) => (x && typeof x === 'object') ? x[k] : undefined, o); }\nfunction typeOf(v) { return Array.isArray(v) ? 'array' : (v === null ? 'null' : typeof v); }\n\nconst raw = String($json.output ?? '');\nconst report = readObject(raw);\nconst missing = [];\nfor (const [path, type] of Object.entries(REQUIRED)) {\n if (typeOf(get(report, path)) !== type) missing.push(path);\n}\nconst meta = $('Build prompts').first().json;\nreturn [{ json: {\n report, missing, valid: missing.length === 0,\n agent_raw: raw.slice(0, 4000),\n variant: meta.variant, label: meta.label, deliver: meta.deliver, started: meta.started,\n}}];\n" }, "name": "Check report", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1020, 300 ] }, { "parameters": { "jsCode": "\n// Everything here came from a log a stranger can write to, or from the model.\n// Strings are cut to length and stripped of newlines before they become a\n// title (Outline takes 100 characters, episode 9) or a line in the document.\nfunction safe(s, n) { return String(s ?? '').replace(/\\s+/g, ' ').trim().slice(0, n); }\nfunction num(v) { return (typeof v === 'number') ? String(v) : 'missing'; }\nfunction list(v) { return Array.isArray(v) && v.length ? v.map(x => safe(x, 80)).join(', ') : (Array.isArray(v) ? '(none)' : 'missing'); }\n\nconst d = $json;\nconst r = d.report ?? {};\nconst ssh = r.ssh ?? {};\nconst web = r.web ?? {};\nconst stamp = new Date().toISOString().replace('T', ' ').replace(/\\..*/, ' UTC');\nconst title = safe('Security report ' + stamp + ' (' + d.variant + ')', 100);\nconst recs = Array.isArray(r.recommendations) ? r.recommendations.map(x => '- ' + safe(x, 200)) : ['- missing'];\nconst text = [\n '**' + (safe(r.headline, 200) || 'headline missing') + '**',\n '',\n safe(r.assessment, 800) || 'assessment missing',\n '',\n '## SSH (last 24 h)',\n '- Login failures: ' + num(ssh.login_failures_24h),\n '- Invalid users: ' + num(ssh.invalid_user_24h),\n '- Distinct source IPs: ' + num(ssh.distinct_source_ips_24h),\n '- Top usernames: ' + list(ssh.top_usernames),\n '- Top source IPs: ' + list(ssh.top_source_ips),\n '- fail2ban sshd: banned now ' + num(ssh.currently_banned) + ', banned total ' + num(ssh.total_banned),\n '',\n '## Web (last 24 h)',\n '- Requests: ' + num(web.requests_24h),\n '- Rejected (4xx): ' + num(web.rejected_4xx_24h),\n '- Rate limited (429): ' + num(web.rate_limited_429_24h),\n '- Server errors (5xx): ' + num(web.server_errors_5xx_24h),\n '- Distinct client IPs: ' + num(web.distinct_client_ips_24h),\n '- Top probe paths: ' + list(web.top_probe_paths),\n '- Top probe IPs: ' + list(web.top_probe_ips),\n '- fail2ban caddy-probes: banned now ' + num(web.currently_banned) + ', banned total ' + num(web.total_banned),\n '',\n '## Recommendations',\n ...recs,\n '',\n '---',\n '',\n 'Schema check: ' + (d.valid ? 'complete' : 'missing ' + d.missing.join(', ')),\n '',\n 'Written by the security report crew (' + d.variant + '). The model read counters through a read-only executor; it never had a shell.',\n].join('\\n');\nreturn [{ json: { title, deliver: d.deliver, valid: d.valid, missing: d.missing,\n headline: safe(r.headline, 200), variant: d.variant, label: d.label,\n payload: { title, text, collectionId: 'REPLACE_WITH_YOUR_OUTLINE_COLLECTION_ID', publish: true } } }];\n" }, "name": "Build report note", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1240, 300 ] }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "loose", "version": 2 }, "conditions": [ { "id": "c1", "leftValue": "={{ $json.deliver }}", "rightValue": "", "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} }, "name": "Deliver?", "type": "n8n-nodes-base.if", "typeVersion": 2, "position": [ 1460, 300 ] }, { "parameters": { "method": "POST", "url": "http://outline:3000/api/documents.create", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "sendBody": true, "contentType": "json", "specifyBody": "json", "jsonBody": "={{ JSON.stringify($json.payload) }}", "options": {} }, "name": "Write report note", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 1680, 200 ], "credentials": { "httpHeaderAuth": { "id": "REPLACE_WITH_YOUR_CREDENTIAL_ID", "name": "Outline API" } } }, { "parameters": { "method": "POST", "url": "http://ntfy/alerts", "authentication": "genericCredentialType", "genericAuthType": "httpBasicAuth", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "X-Title", "value": "={{ $('Build report note').first().json.title }}" }, { "name": "X-Priority", "value": "3" } ] }, "sendBody": true, "contentType": "raw", "rawContentType": "text/plain", "body": "={{ $('Build report note').first().json.headline }}\nNote: https://wiki.ops.example.com{{ $json.data.url }}", "options": {} }, "name": "Push the result", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 1900, 200 ], "credentials": { "httpBasicAuth": { "id": "REPLACE_WITH_YOUR_CREDENTIAL_ID", "name": "ntfy (n8n user)" } } }, { "parameters": {}, "name": "Not delivered (measurement run)", "type": "n8n-nodes-base.noOp", "typeVersion": 1, "position": [ 1680, 400 ] } ], "connections": { "f2b_status_sshd": { "ai_tool": [ [ { "node": "Security analyst", "type": "ai_tool", "index": 0 } ] ] }, "f2b_status_caddy": { "ai_tool": [ [ { "node": "Security analyst", "type": "ai_tool", "index": 0 } ] ] }, "f2b_banned": { "ai_tool": [ [ { "node": "Security analyst", "type": "ai_tool", "index": 0 } ] ] }, "sshd_failed_summary": { "ai_tool": [ [ { "node": "Security analyst", "type": "ai_tool", "index": 0 } ] ] }, "caddy_status_counts": { "ai_tool": [ [ { "node": "Security analyst", "type": "ai_tool", "index": 0 } ] ] }, "caddy_top_paths": { "ai_tool": [ [ { "node": "Security analyst", "type": "ai_tool", "index": 0 } ] ] }, "caddy_top_ips": { "ai_tool": [ [ { "node": "Security analyst", "type": "ai_tool", "index": 0 } ] ] }, "Ollama over the mesh": { "ai_languageModel": [ [ { "node": "Security analyst", "type": "ai_languageModel", "index": 0 } ] ] }, "Start": { "main": [ [ { "node": "Load skill", "type": "main", "index": 0 } ] ] }, "Load skill": { "main": [ [ { "node": "Build prompts", "type": "main", "index": 0 } ] ] }, "Build prompts": { "main": [ [ { "node": "Security analyst", "type": "main", "index": 0 } ] ] }, "Security analyst": { "main": [ [ { "node": "Check report", "type": "main", "index": 0 } ] ] }, "Check report": { "main": [ [ { "node": "Build report note", "type": "main", "index": 0 } ] ] }, "Build report note": { "main": [ [ { "node": "Deliver?", "type": "main", "index": 0 } ] ] }, "Deliver?": { "main": [ [ { "node": "Write report note", "type": "main", "index": 0 } ], [ { "node": "Not delivered (measurement run)", "type": "main", "index": 0 } ] ] }, "Write report note": { "main": [ [ { "node": "Push the result", "type": "main", "index": 0 } ] ] } }, "settings": { "executionOrder": "v1" } }