141 lines
5.0 KiB
Markdown
141 lines
5.0 KiB
Markdown
# Caddy on host networking + loopback-published app
|
||
|
||
This doc captures *why* the production deployment puts Caddy in the host
|
||
network namespace and reaches the app over loopback, and *exactly what*
|
||
that requires in the Quadlet units and the Caddyfile.
|
||
|
||
> Note: this file lives under `docs/` at the repo root. It is **not**
|
||
> part of the Astro site (`src/pages/` + the `blog` content collection
|
||
> are what get built and served). Do not move it under `src/`.
|
||
|
||
## Why
|
||
|
||
With Podman's default port-publish path (`PublishPort=80:80` on a
|
||
user-defined bridge), inbound packets are NAT'd by netavark before they
|
||
hit the container. Caddy then sees the bridge gateway as the source
|
||
address instead of the real client. That breaks two things we care about:
|
||
|
||
- **fail2ban / nftables bans** — the `nftables-multiport` action would
|
||
populate the f2b set with the gateway's address, banning nothing
|
||
useful.
|
||
- **Coraza's audit log** — every "blocked" entry would record the same
|
||
internal address, so manual triage is impossible.
|
||
|
||
Switching Caddy to the host network namespace removes that NAT hop:
|
||
Caddy binds 80/443 directly on the host's interfaces and sees the real
|
||
client IP for both v4 and v6.
|
||
|
||
## What changed
|
||
|
||
### 1. `quadlet/dev-blog-caddy.container`
|
||
|
||
Caddy now lives in the host network namespace.
|
||
|
||
```diff
|
||
-Network=dev-blog.network
|
||
-NetworkAlias=caddy
|
||
-
|
||
-PublishPort=80:80
|
||
-PublishPort=443:443
|
||
-PublishPort=443:443/udp
|
||
-PublishPort=[::]:80:80
|
||
-PublishPort=[::]:443:443
|
||
-PublishPort=[::]:443:443/udp
|
||
+Network=host
|
||
```
|
||
|
||
`Network=host` is mutually exclusive with both `Network=dev-blog.network`
|
||
and any `PublishPort=` directives — Caddy binds 80/443 directly on the
|
||
host's interfaces, so the user-defined network and port-forwarding are
|
||
gone for this container. `CAP_NET_BIND_SERVICE` is still kept because
|
||
the container's non-root user still has to bind privileged ports.
|
||
|
||
### 2. `quadlet/dev-blog-app.container`
|
||
|
||
App stays on the user-defined network *and* publishes on host loopback
|
||
only.
|
||
|
||
```diff
|
||
-# Not exposed publicly – Caddy reverse-proxies in over the internal network.
|
||
-# PublishPort=4321:4321
|
||
+# Caddy now runs on the host network namespace, so it cannot reach the app
|
||
+# via podman DNS (`app:4321`). Publish the app port on the host's *loopback*
|
||
+# only -- it is reachable to Caddy on the host but not from outside.
|
||
+PublishPort=127.0.0.1:4321:4321
|
||
+PublishPort=[::1]:4321:4321
|
||
```
|
||
|
||
The app keeps `Network=dev-blog.network` (so it could still talk to
|
||
future sidecars on that bridge), but now also surfaces on
|
||
`127.0.0.1:4321` and `[::1]:4321`. nftables already accepts
|
||
`iif "lo" accept`, so loopback traffic is unfiltered.
|
||
|
||
### 3. `caddy/Caddyfile`
|
||
|
||
Reverse-proxy target updated.
|
||
|
||
```diff
|
||
-reverse_proxy app:4321 {
|
||
+reverse_proxy 127.0.0.1:4321 {
|
||
header_up X-Real-IP {remote_host}
|
||
header_up X-Forwarded-Proto {scheme}
|
||
}
|
||
```
|
||
|
||
`{remote_host}` is now the **real** client IP (v4 or v6) because Caddy
|
||
sees the connection unmodified, instead of the bridge gateway. That
|
||
value flows into the JSON access log (`"remote_ip"`) and Coraza's serial
|
||
audit log, which is exactly what the fail2ban filter keys on — so the
|
||
`nftables-multiport` action populates the right addresses in
|
||
`f2b-table` and bans actually work.
|
||
|
||
## Topology, before vs. after
|
||
|
||
**Before**
|
||
|
||
```
|
||
Internet ─┐
|
||
(host nft) ─► podman PublishPort NAT ─► dev-blog (bridge, dual-stack)
|
||
├─ caddy (real IP lost)
|
||
└─ app (Caddy → app:4321 via podman DNS)
|
||
```
|
||
|
||
**After**
|
||
|
||
```
|
||
Internet ─┐
|
||
(host nft) ─► caddy in host netns (real client v4/v6 preserved)
|
||
│ reverse_proxy
|
||
▼
|
||
127.0.0.1:4321 / [::1]:4321 (loopback publish)
|
||
│
|
||
dev-blog (bridge, dual-stack)
|
||
└─ app
|
||
```
|
||
|
||
## Things that did *not* need to change
|
||
|
||
- `dev-blog.network` — still a dual-stack bridge; the app still uses it
|
||
(and any future sidecar can join it).
|
||
- `nftables.conf.j2` — already accepted `tcp dport { 22, 80, 443 }` and
|
||
`udp dport { 443 }` in the `inet filter` table, plus
|
||
`iif "lo" accept`, so both Caddy's public listeners and the loopback
|
||
hop to the app are covered.
|
||
- fail2ban filter regexes, Coraza config, Ansible roles, Quadlet
|
||
`[Install]` lines.
|
||
|
||
## Operational heads-up
|
||
|
||
- `compose.yaml` (local-dev convenience) was deliberately left alone —
|
||
it still wires Caddy through the bridge with `PublishPort`, which is
|
||
fine for local iteration where preserving real client IPs doesn't
|
||
matter.
|
||
- If you ever add another container that Caddy needs to reach, follow
|
||
the same pattern: have *that* container publish to `127.0.0.1:<port>`
|
||
(and `[::1]:<port>` for v6) and reference it from the Caddyfile via
|
||
loopback.
|
||
- Because the app's loopback publish is bound to `127.0.0.1` / `[::1]`
|
||
only, it is **not** reachable from any other host on the network even
|
||
if nftables were ever flushed — the kernel itself drops non-loopback
|
||
traffic destined to those addresses.
|