The edge
Ten requests, one edge
In one line: four layers stand between the internet and
any app, and each was tested from outside.
Everything public sits behind one reverse proxy VM. A request has to clear
four layers before it reaches an app: a geo filter in nftables covering
TCP 443, UDP 443 (HTTP/3) and TCP 80; a CrowdSec ban list enforced in both
the firewall and the proxy; a WAF running CrowdSec virtual patches and the
OWASP Core Rule Set in blocking mode; and TLS termination with credentials
redacted before anything is written to a log. Since 10-31 the proxy also
adds baseline security headers to every public app that sent none of its
own (nosniff, referrer and permissions policies, frame protection) and
strips the server banners that named the software behind it.
I sent ten requests through the edge's WAF engine, using the same API the
proxy calls, six times each. Every attack payload was blocked with a 403
before it could reach an app, with the same result in all six runs. The
table has every request, verdict, score and rule.
Watch one go through the layers on the home page.
edge · recorded run captured 10-30
All ten requests, as recorded. Rule IDs are OWASP CRS unless named. | Request | WAF | CRS score | Rules that fired |
| normal page | allowed, 200 | 0 | none |
| /.env | blocked, 403 | 5 | 930130, Virtual patch: .env access |
| /.git/config | blocked, 403 | 5 | 930130, Virtual patch: .git/config access |
| SQL injection | blocked, 403 | 10 | 942100 |
| XSS | blocked, 403 | 40 | 941100, 941110, 941160, 941390 |
| path traversal | blocked, 403 | 65 | 930100, 930110, 930120, 932160 |
| Log4Shell | blocked, 403 | 5 | 944150 |
| command injection | blocked, 403 | 30 | 930120, 932160, 932230 |
| wp-login probe | allowed, 200 | 0 | none |
| scanner user agent | blocked, 403 | 5 | 913100 |
Method: Each request sent to the edge's CrowdSec AppSec engine through the same API the reverse proxy's bouncer calls, with an RFC 5737 documentation IP as the client. 3 rounds through the alert and ban pipeline + 3 rounds diffing per-rule counters, after the Core Rule Set was moved in-band. All test alerts and bans deleted afterwards. Caddy 2.11.7, CrowdSec 1.8.1,
210 WAF rules loaded.
The one request that is allowed asks for a WordPress login page. By itself
that is not an attack payload; scanning for it is, and CrowdSec's
wordpress-scan scenario catches that pattern from the proxy logs. Four
blocked requests from one source earn a 24-hour ban, and the firewall
starts dropping that source 7.0 to 10.1 seconds after the
decision (three measured runs).
How the WAF got to blocking: the Core Rule Set ran in detection-only mode
first, scoring real traffic without blocking it. It flagged none of about
5,300 real requests, so on 10-30 it
moved to blocking. Detect, tune, then enforce is the order I would want at
work too.
The tuning was not finished. Three days after the switch, the media app on
my phone crashed every time I changed video quality. Its crash log showed
an unhandled 403: the app stops the old stream with an HTTP DELETE, and the
Core Rule Set rejects DELETE, PUT and PATCH by default. The same rule was
blocking recipe edits and request changes from outside the house. My 48-hour
false-positive check had only counted blocks in the logs, and nobody had
edited or deleted anything from outside in that window, so it found
nothing. The fix exempts that one method rule for those three methods; every
injection rule still inspects them, which the re-test confirmed (an SQL
injection sent as a DELETE is still blocked). The lesson: a false-positive
check has to send each app's write calls itself, not wait for real traffic
to find them.
Found by attacking it
Four weaknesses my own testing caught
Each of these looked fine in the configuration and broke the first time I
attacked it from the outside. All four are fixed and re-tested. For each:
what was wrong, how it showed up, the fix, and the re-test.
fail2ban · edge
The ban that never was
fail2ban logged bans and enforced none of them.
- Wrong
- The edge VM is nftables-only. fail2ban's default action calls iptables, exited with code 127, and logged "Ban" anyway. No ban on that VM had ever been enforced.
- Found
- A family member got "banned" while streaming. They were never actually blocked, and neither was anyone else.
- Fix
- nftables action for TCP and UDP; the jail counts only 401 and 403, not the 404s a media client produces after an upgrade.
- Re-test
- A phone on cellular sent five bad logins: banned in 10 s, then 0 requests reached the proxy. (10-30, one run.)
host firewall · every guest
IPv6 walked around the admin allowlist
The IPv4 allowlist was right. IPv6 skipped it.
- Wrong
- Admin ports were pinned to three hosts over IPv4. The generator put the same ports in the IPv6 LAN rule, so any phone or TV could reach SSH and the hypervisor UI over link-local IPv6.
- Found
- A firewall review that tested IPv6, not only IPv4.
- Fix
- IPv6 gets service ports only. Rolled out host by host, each with a 10-minute auto-revert armed.
- Re-test
-
From a non-admin container, hypervisor SSH and web UI over IPv4 and
IPv6: closed in 3 of 3 runs. Controls (a service port it needs, admin SSH) stayed open. (10-30)
reverse proxy · logs
The proxy was logging the keys
Credentials sat in world-readable logs. Now they are hashed or dropped before anything is written.
- Wrong
- Media clients send API keys in query strings and headers. The access log kept them verbatim in world-readable files: 1,136 media-server keys, 141 auth tokens and 4,468 admin API keys.
- Found
- A secrets audit that read the logs, not just the configs.
- Fix
- Redact at the logger (query values hashed, credential headers dropped), tighten permissions, and mask 25,888 old values in place without breaking the three tools that parse those logs.
- Re-test
- 3 requests from outside the network with fake credentials:
each logged once, 0 fake values in any log, key stored as an 8-character hash. (10-30)
geo filter · edge
HTTP/3 walked past the geo filter
The country filter covered TCP only. HTTP/3 runs over UDP.
- Wrong
- The rule matched TCP 443. The proxy also speaks HTTP/3 over UDP 443, so a client outside the allowed countries could still connect.
- Found
- Reading which ports the proxy listens on, then testing each one.
- Fix
- Filter TCP and UDP 443 plus TCP 80. Closing port 80 meant moving certificates to the DNS-01 challenge first.
- Re-test
- A phone on cellular, cut out of the allow list: TCP 443, UDP 443 and TCP 80 all dropped at the firewall. (10-30, one run.)
Inside the perimeter
Assume the edge fails
In one line: if something gets past the edge, every host
still denies by default, is hardened, and reports to a SIEM.
Every guest runs its own default-deny nftables table, loaded by its own
service, so the hypervisor is not the only thing between a service and the
network. Admin ports are reachable from three hosts. It ran in audit mode
for 46 hours before enforcing, and the enforcing rollout fired zero
auto-reverts.
All eight hosts are hardened against the CIS benchmarks in two tiers: SSH
key-only for root, account lockout, audit rules, password length over
composition rules (NIST SP 800-63B). Wazuh's configuration assessment
scored every host 40 to 43 before and 51 to 55 after.
Wazuh collects from all eight hosts. On 10-31 I planted a file in the
systemd unit directory of every one of them; all eight raised an alert
within 2 seconds. On the hypervisor I planted more on 10-30: touching sudoers raised a level 10 alert, running a binary from
/tmp a level 12, and an EICAR test file a level 12 VirusTotal hit (66 of 68
engines). The tuning goes the other way too: a monitoring agent's daily
updater runs a script from /tmp, so one rule matches only that exact
command shape. The same shape with any other script name still fires
level 12.
Supply chain
What runs here, and what I publish
Trivy scans every container image daily. Most critical CVEs in public
images need the upstream project to rebuild, so a weekly reminder became
noise. The alert is now keyed to the exact image and CVE pairs: fewer CVEs
stay quiet, a new one alerts at once. Updates and automatic rollbacks are on
the homelab page.
I scanned every commit of my four public repositories with gitleaks
8.28.0: 9,194
commits, 245 of them mine. Findings in my commits:
0. The other 24 findings
are test fixtures and saved recipe pages in the forked projects' own history.
This site has its own gate: a build fails if a private word or an inline
script reaches the output.
Seeing it
Alerts I can live with
In one line: every control above reports to one place,
and only what needs me reaches me.
A read-only collector pulls from CrowdSec, the WAF, Wazuh and the image
scanner every five minutes. What it finds is shown in levels, the way
plant control rooms do it (ISA-101): a phone widget that answers "is
anything wrong", a dashboard with one-line verdicts and a short "needs
attention" list, the SIEM's own dashboard for level 10 and up, then the raw
events. Each item carries a signed link to acknowledge it, undo that, or ban
the source. An acknowledged item still counts in the totals; it just stops
turning the tiles red. Test events get acknowledged only after the audit
trail proves where they came from.
alert routing as of 11-02
Where each kind of event goes. "Spoken" means my voice assistant says it on the PC speaker if I am at the desk, otherwise on my phone. | Event | Goes to |
| Blocked attacks, low-volume probes | Dashboard only |
| SIEM high-severity alert | Dashboard + spoken, also overnight |
| Service down, or fixed by self-heal | Dashboard + spoken, daytime |
| Daily outside-in geo check fails | Spoken |
| A data feed goes stale | Dashboard + Discord |
That routing came from getting it wrong. On 10-27 at 00:51 the assistant
woke me through Do Not Disturb to report a single probe hit. Over the next
three days: low-hit probes became visual only, speech got a quiet window
overnight unless the alert is loud, it speaks where I am (a presence
heartbeat from the PC decides desk or phone), and the phone checks Do Not
Disturb live instead of trusting a sensor that could be minutes stale.
The edge also gets tested from outside every day, not only when I think of
it. A scheduled job asks Globalping, a public network of probes, to load the
site from probes inside the allowed region (the control) and from probes
outside it. Outside probes must time out at the firewall; inside probes
must get an answer; any outside answer is spoken as a failure. First run,
11-02: 3 of 3 inside probes answered, 8 of 8 outside probes in eight
countries timed out, on HTTPS and on port 80. The probe network cannot test
HTTP/3, so that part still rests on the 10-30 phone test.
Mapped
Control by control
CIS Controls v8 safeguards and the ATT&CK techniques each control is there for. "Tested" means a recorded run with a date, not a configuration review. | Control | CIS v8 | ATT&CK | Tested |
| Geo filter, TCP + UDP | 4.4 | T1595 | daily probes since 11-02; phone 10-30 |
| CrowdSec + bouncers | 13.3, 13.8 | T1595.002, T1190 | 10-30, ban timing x3 |
| WAF: virtual patches + OWASP CRS | 13.10 | T1190 | 10-30, 10 requests x6; write methods 11-02 |
| fail2ban | 13.8 | T1110 | 10-30, phone on cellular |
| Per-guest firewall, IPv4 + IPv6 | 4.4, 13.4 | T1021.004 | 10-30, x3 |
| VPN egress, default drop | 13.4 | none | 10-30, 6 tunnel kills |
| CIS hardening | 4.1 | T1110, T1548.003 | 10-22 to 10-25, Wazuh SCA |
| Wazuh SIEM | 8.2, 8.9, 13.1 | T1548.003, T1105 | 10-30, planted events |
| Log redaction + permissions | 3.3 | T1552.001 | 10-30, x3 from outside |
| Trivy + weekly updater | 7.4, 7.5 | T1190 | daily scans; rollback forced 10-22 |
| Secrets scan + site gate | 16.12 | T1552.001 | 10-30, every commit |
| Backups, restore drills | 11.5 | T1485, T1486 | 10-22 and 10-30, timed restores |