/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

Tool Role Link
🔒 CrowdSec Community IDS/IPS Dashboard
☁️ Cloudflare CDN · WAF · DNS · DDoS Dashboard
📊 BetterStack Monitoring · Alerts · Logs Status page
🌐 Hugo Static site generator gohugo.io
🛡️ ModSecurity Local WAF · OWASP CRS 4.x OWASP CRS
nginx Reverse proxy · TLS 1.3 nginx.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.

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

TL;DR

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

TL;DR

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 delivered: v1.9.0, 10 chantiers, hardened ecosystem

TL;DR

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

# Chantier Implementation
C1 Rate limiting slowapi, 60 req/min per IP
C2 Token rotation tokens.json + token_mgr.py CLI
C3 JSON audit logs structlog, machine-readable events
C4 Strict Pydantic v2 CreatePageArgs / UpdatePageArgs with constraints
C5 bcrypt cost-12 Tokens hashed in storage
C6 NUC ↔ VM TLS EC P-256 cert, uvicorn SSL, proxy verifies the cert
C7 requirements.lock SHA-256 hashes via pip-compile --generate-hashes
C8 Info disclosure Docs off, generic exception handler, proxy_hide_header
C9 nginx WAF POST + application/json enforcement on /mcp, OWASP CRS active
C10 Backup DR backup.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

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 on every rerun

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