/images/avatar.png

Break things. Fix them. Learn.

This site runs on an Intel NUC hosted at home, behind a standard fiber connection. Its main purpose is to serve as an experimentation ground for testing server configurations, automation scripts, and open source security tools.

Not a professional website — a homelab: we break things, fix them, and learn.

Migration in progress
The site is gradually migrating from Grav CMS to Hugo. Old URLs are preserved, but the visual rendering is evolving. If you spot a bug, report it.

🛠️ Tech stack

ToolRoleLink
🔒 CrowdSecCommunity IDS/IPSDashboard
☁️ CloudflareCDN · WAF · DNS · DDoSDashboard
📊 BetterStackMonitoring · Alerts · LogsStatus page
🌐 HugoStatic site generatorgohugo.io
🛡️ ModSecurityLocal WAF · OWASP CRS 4.xOWASP CRS
nginxReverse proxy · TLS 1.3nginx.org

🌟 Don’t miss

Three articles that capture the spirit of this homelab:

📚 Full documentation is in Documentation and automation scripts in Scripts.

🐛 Found a vulnerability?

If you discover a bug, misconfiguration, or security vulnerability on this server, please report it. This homelab is public and I learn from my mistakes.

📨 Responsible disclosure: www.arleo.eu/security.txt

Any contribution to improving security is welcome.

Grav IndexNow Plugin: automatically submit pages to search engines

⚡ In short

Grav has no IndexNow plugin in its official catalog. This homemade plugin fills the gap by automatically submitting modified URLs to api.indexnow.org on every page save — whether through the Grav admin or the MCP plugin — no manual intervention, no cron job, no external dependency.

Source code available on GitHub:

🧠 Why

IndexNow is an open protocol that instantly notifies compatible search engines (Bing, Yandex) when a page is created or modified. Without it, search engines wait for their next crawler pass — which can take hours or days.

CrowdSec Log Pipeline with Vector: Filtering Noise and Capturing Real Bans

⚡ In short

The initial Vector pipeline was flooding BetterStack with ~500 events/24h, of which 434 were CAPI pulls with no local monitoring value. This work reconfigures the Vector filter to keep only high-value bans (cscli) and fixes a major blind spot: actual nginx-lua bouncer bans were not appearing anywhere in BetterStack.

🧠 Why

This homelab’s security stack relies on three components working together:

  • nginx with the CrowdSec lua bouncer (lua-resty-crowdsec) for real-time request blocking
  • CrowdSec for threat detection and ban decision management
  • Vector centralizing logs to BetterStack for monitoring

After setting up the initial pipeline, two problems quickly became apparent. First, the signal was drowned in noise: out of 500 events/24h, 434 came from the hourly community CAPI pull and 66 from third-party lists — neither represents a threat detected on this infrastructure. Second, actual lua bouncer bans (real-time blocks in nginx) were not appearing anywhere in BetterStack, creating a blind spot on real security activity.

From 46 Hashes to Zero: Migrating CSP to Dynamic Nonces

⚡ In short

The original CSP listed 46 SHA-256 hashes to cover inline scripts and styles — unmanageable, fragile, and approaching Cloudflare’s 4,096 character limit. This migration to dynamic nonces reduces the CSP to ~600 characters, eliminates all manual hash maintenance, and adds structured violation reporting in BetterStack.

The Grav plugin powering this is available on GitHub: 🔌 Plugin : jmrGrav/grav-plugin-csp-nonce

🧠 Why

When implementing a strict Content Security Policy on a Grav CMS site served through Cloudflare, the naive approach is to list SHA-256 hashes for every inline script. It works, but quickly becomes unmanageable. Several compounding problems:

Normalizing nginx and CrowdSec Logs in BetterStack with Vector

⚡ In short

Two problems coexisted in BetterStack: mcp-oauth.access.log logs arrived as unreadable raw JSON, and CrowdSec logs produced visual duplicates. This work normalizes all logs so they display as structured clickable tags, with correct timestamps and without parasitic fields.

🧠 Why

BetterStack displays logs as highlighted clickable tags in the Live Tail when JSON fields are properly structured. Before this work, observation was degraded on two fronts:

  • mcp-oauth.access.log logs arrived as unreadable raw JSON (custom format incompatible with Vector’s nginx parser) — fields nginx.client, nginx.path, nginx.status were not extracted
  • CrowdSec and CF WAF logs arrived as plain text with duplicates (Ban ban | ... | Ban ban)

The goal was to normalize all logs in BetterStack to display like standard nginx logs:

Automating IP Bans with Cloudflare WAF, CrowdSec and AbuseIPDB

⚡ In short

Passive monitoring is not enough. This pipeline automates closing the loop in under 5 minutes between an attack detected by Cloudflare WAF and the effective ban of the IP in CrowdSec, its synchronization to Cloudflare, and its report to AbuseIPDB. A Python script polls the Cloudflare GraphQL API every 5 minutes, applies a 3-hit threshold, and triggers the ban with recidivist escalation.

🧠 Why

Seeing an attack in BetterStack logs after the fact does not stop the malicious IP from continuing to hammer the server. Without automation, the detection → ban loop takes hours or never closes. Cloudflare WAF actions (block, challenge, managed_challenge, jschallenge) are clear attack signals, but they remain confined to the Cloudflare dashboard — without a bridge to CrowdSec, no IP is banned locally, none is reported to the AbuseIPDB community.

Post-Mortem — Incident 522 / WAN Failover (April 8, 2026)

⚡ In short

Date: April 7-8, 2026 — Duration: ~3h (21:28 UTC → 22:31 UTC) — Severity: P1

arleo.eu was unreachable for 3 hours. The root cause was not the server, not nginx, not CrowdSec — it was an HTTPS port forwarding rule attached to generic WAN instead of explicit WAN1 on the Netgear PR60X. The daily DHCP lease renewal of the 4G modem (WAN2) triggered a NAT rebalance that broke routing to port 443.

Hugo