/images/avatar.png

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.

🌟 Don’t miss

Three articles that capture the spirit of this homelab:

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

🛠️ Tech stack

ToolRole
OpenRestyReverse proxy · nginx + LuaJIT · TLS 1.3
🔒 CrowdSecCommunity IDS/IPS
🧱 CrowdSec AppSecInline WAF · OWASP CRS 4.x
☁️ CloudflareCDN · WAF · DNS · DDoS
🌐 HugoStatic site generator
🤖 MCP Hugo ServerMCP server to drive this site from an AI
📊 BetterStackMonitoring · Alerts · Logs

🐛 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

Reports are publicly credited in the Hall of Fame, or kept anonymous on request.

Any contribution to improving security is welcome.

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.

NUC Security Audit: ModSecurity Removed, 500 MB Recovered

In short

A security stack audit on the homelab NUC reveals redundant double WAF inspection: ModSecurity + OWASP CRS load 11,872 rules into memory despite SecRuleEngine Off, running in parallel with CrowdSec AppSec which already covers the same surface. After removing the ModSecurity nginx module and five other targeted fixes, nginx drops from ~520 MB to ~27 MB PSS. Same security, memory footprint divided by 20.


🏗️ Architecture Before the Audit

The security stack had six stacked layers:

hugo-mcp v2.0: a Python plugin-system in 200 lines

In short

hugo-mcp v2.0.0 introduces a Python plugin-system that lets anyone add hooks after each create_page / update_page / delete_page operation. 200 lines of code for the core, 3 production plugins shipped (IndexNow, Google Indexing, Cloudflare). This post explains the design, the trade-offs, the security posture, and shows how to write your own plugin in 5 minutes.

Grav → Hugo migration: 2 years of blog flipped in one day

In short

On May 9, 2026, I switched arleo.eu from Grav (PHP CMS) to Hugo (Go static site generator) in a single session. Atomic flip (≈ 0 second downtime), 22 legacy articles migrated under /posts/ with SEO aliases to preserve Google-indexed URLs, BetterStack /ping monitoring intact throughout the operation.

The code and migration script are open source: github.com/jmrGrav/grav-to-hugo-migration.

MCP Security Sprint: v1.9.0 and 10 Hardening Tasks

In short

On May 9, 2026, I delivered all 10 chantiers of the MCP security sprint that I had announced earlier in the day in a single marathon session. hugo-mcp is now at v1.9.0 (GitHub Release), commit 1404f83 GPG-signed.

Here’s the high-level recap + a pedagogical deep-dive on 2 chantiers with real value beyond my specific context: C2 token rotation and C6 internal TLS.

Recap of 10 chantiers

#ChantierImplementation
C1Rate limitingslowapi, 60 req/min per IP
C2Token rotationtokens.json + token_mgr.py CLI
C3JSON audit logsstructlog, machine-readable events
C4Strict Pydantic v2CreatePageArgs / UpdatePageArgs with constraints
C5bcrypt cost-12Tokens hashed in storage
C6NUC ↔ VM TLSEC P-256 cert, uvicorn SSL, proxy verifies the cert
C7requirements.lockSHA-256 hashes via pip-compile --generate-hashes
C8Info disclosureDocs off, generic exception handler, proxy_hide_header
C9nginx WAFPOST + application/json enforcement on /mcp, OWASP CRS active
C10Backup DRbackup.sh GPG-encrypted, 30-day retention

Full details in the CHANGELOG v1.9.0 and commit 1404f83.

Post-mortem: Cloudflare Bot Management blocked MCP webhooks

What happened
  • Date: May 9, 2026
  • Severity: P3
  • Status: Resolved
  • Impact: GitHub webhook blocked with 403 by Cloudflare Bot Management (UA python-httpx) — fixed via a triple-condition Custom Rule (host + path + IP)

In short

A freshly deployed webhook endpoint was consistently returning 403, never even reaching nginx or the application service — the cause was Cloudflare Bot Management, which treats legitimate automated traffic as suspect by default.

The symptom

I just finished a webhook endpoint in hugo-mcp-proxy that will receive notifications from GitHub on every push to the arleo.eu repo. Clean implementation: HMAC-SHA256, rate limiting, IPAddressAllow GitHub ranges in systemd.

Functional test from an external client:

$ curl -X POST https://mcp-hugo.arleo.eu/webhook/test \
    -H "Content-Type: application/json" \
    -d '{"test": true}'

Response: 403 Forbidden.

Strange. The service is running, my source IP is whitelisted, the HMAC is correct. Why 403?

Server-side investigation

NUC nginx logs:

$ sudo tail -100 /var/log/nginx/mcp-hugo.access.log | grep webhook

Empty. No request reaches nginx.

mcp-oauth-proxy logs:

$ sudo journalctl -u mcp-oauth-proxy -n 100 | grep webhook

Empty too. The request doesn’t even reach the service.

Either it’s blocked by the firewall before nginx (CrowdSec or ufw), or upstream by Cloudflare.

The truth at Cloudflare

Post-mortem: mcp-installer Regenerated Tokens

What happened
  • Date: May 9, 2026
  • Severity: P2
  • Status: Resolved (v1.2.0)
  • Impact: mcp-installer regenerated all OAuth secrets on every rerun, breaking already-registered clients — fixed via a preflight check + explicit --force-rotate-tokens flag

In short

Rerunning the mcp-installer install script on an already-provisioned host silently overwrote all in-service OAuth secrets, invalidating already-registered clients like Claude.ai.

The bug

mcp-installer is a bash script I wrote to automate the installation of mcp-oauth-proxy (FastAPI + nginx + systemd) on a new host. Standard workflow: clone, run, done.

Except when re-running the script on an already installed host (e.g. to update the version), I discovered an idempotence bug: all secrets were regenerated.

$ sudo ./install.sh
[+] Generating MCP_TOKEN...
[+] Generating CLIENT_ID...
[+] Generating CLIENT_SECRET...
[+] Generating TOKEN_SECRET...
[+] Writing /etc/mcp-oauth-proxy/.env...

If you already have a .env with tokens in service, the installer overwrites them. All already-registered OAuth clients (Claude.ai in my case) end up with invalid credentials. Connection breaks.

Why it happened

The script used this logic:

MCP_TOKEN=$(openssl rand -hex 32)
CLIENT_ID=$(openssl rand -hex 16)
CLIENT_SECRET=$(openssl rand -hex 32)
TOKEN_SECRET=$(openssl rand -hex 32)

cat > /etc/mcp-oauth-proxy/.env <<EOF
MCP_TOKEN=$MCP_TOKEN
CLIENT_ID=$CLIENT_ID
CLIENT_SECRET=$CLIENT_SECRET
TOKEN_SECRET=$TOKEN_SECRET
EOF

The “generate then write” pattern is correct for first install. But on rerun, it completely ignores the existing .env.

It’s the classic mistake: the author (me) tested the script only on a fresh host. The “reinstall on existing host” mode was never tested.

Bug reproduction

$ sudo ./install.sh
[OK] Installation complete
$ cat /etc/mcp-oauth-proxy/.env | head -1
MCP_TOKEN=<redacted-mcp-token-1>

$ sudo ./install.sh
[OK] Installation complete
$ cat /etc/mcp-oauth-proxy/.env | head -1
MCP_TOKEN=<redacted-mcp-token-2>   ← DIFFERENT

Reproducible test in 30 seconds. Almost comical I didn’t catch it sooner.

The lesson: “idempotent” is a contract, not a hope

An installation script must be idempotent by default. Meaning: ./install.sh once, twice, five times → system in the same stable state.

I had this contract in my head. I didn’t have it in the code.

The fix: pre-flight checks + explicit force flag

Hugo