The public traefik.pivoine.art path round-trips through the host's
published port, and Docker's hairpin NAT there rewrites the source IP
before it reaches vpn-only's allowlist, so pulsenode's own request was
getting a 403 despite falcon_network being allowlisted. Port 8080 is
not published to the host, so it's reachable only from other
falcon_network containers - no TLS/vpn-only needed, and it's not
exposed externally.
Switches traefik.pivoine.art routing from a standalone file-provider
router (dynamic/dashboard.yaml) to docker-compose labels on traefik's
own service - the same pattern every other stack already uses for its
own routing, rather than a one-off exception. Traefik discovers itself
just like any other container on falcon_network. Both routers point
at the built-in api@internal service via .service= instead of a
loadbalancer target, since there's no backend container port to route
to. Also dropped the (PathPrefix(`/api`) || PathPrefix(`/dashboard`))
condition - the whole traefik.pivoine.art host is dedicated to this,
so a bare Host() match is simpler and no less correct.
Also fixes why VPN access still failed: traefik.pivoine.art was never
added to Headscale's MagicDNS extra_records, so even a tailnet-
connected browser resolved it via public DNS and hit vpn-only from a
non-tailnet source IP - same root cause as the earlier
pulsenode.pivoine.art fix, just missed for this hostname.
Previous attempt tried a separate internal-only :8080 entrypoint for
pulsenode to reach the API directly - that turned out to 404 no
matter what (--api.dashboard=true alone didn't register a working
router there, and adding --api=true plus an explicit entrypoint still
didn't produce a matching router; verified against a local traefik:v3
container before giving up on that path rather than keep guessing
against the live one).
Cleaner approach: expose it through the same web/web-secure
entrypoints everything else already uses, via a new file-provider
router (traefik/dynamic/dashboard.yaml) matching Traefik's own
documented self-referencing pattern - Host(`traefik.pivoine.art`) &&
(PathPrefix(`/api`) || PathPrefix(`/dashboard`)) routed to the built-in
api@internal service, gated by vpn-only same as pulsenode's own router.
vpn-only's sourceRange now also includes falcon_network's own subnet
(172.18.0.0/16) alongside the tailnet ranges - a container on our own
internal docker network is as trusted as a tailnet peer, and without
this pulsenode's own request to the API (a container-to-container
call, not a VPN-sourced one) would get the same 403 a random internet
visitor would. pulsenode's traefik widget now points at
https://traefik.pivoine.art/api instead of the internal :8080 attempt.
--api.dashboard=true alone wasn't enough - the API returned 404 on
every path after enabling it. Traefik apparently doesn't reliably
auto-create the default "traefik" entrypoint the way older docs
describe; defining it explicitly is the documented, reliable way to
get the API/dashboard router actually listening. Still not in ports:,
so still only reachable from other containers on falcon_network.
- traefik/compose.yml: flip --api.dashboard=true so Traefik's internal
API is reachable. No entrypoint named "traefik" is defined, so
Traefik auto-creates its default one on :8080 - not published to the
host (only 80/443 are), so it's reachable only from other containers
on falcon_network, i.e. exactly (and only) pulsenode.
- pulsenode/config.yml: adds a system widget and swaps the plain
Traefik docker card for the real traefik widget (router/entrypoint/
middleware view via that now-enabled API), both at the top of
Services.
Verified locally before deploying: a plain container (no pid: host,
no /proc,/sys mounts) already reports host-accurate CPU count, memory
total, and root disk usage via systeminformation, since Docker doesn't
namespace /proc/stat, /proc/meminfo, or overlayfs df by default. No
privilege escalation needed for the system widget after all - tested
side by side against the real host's nproc/free/df output. Also
confirmed the merged config.yml validates against the app's own zod
schema, not just as YAML.
falcon-vpn-only was defined inline in falcon.yaml even though it's a
generic ipAllowList restriction with nothing falcon-specific about it
- security.yaml is where the other reusable middlewares (security-
headers, no-index, rate-limit) already live. Renamed to vpn-only to
match their bare naming style now that it's not tied to one file, and
updated falcon.yaml's two router references to vpn-only@file (same
cross-file pattern already used there for security-headers@file).
Also applies it to the pulsenode stack: it's a dashboard surfacing
container names/health for every service here, and there's no auth in
front of it yet, so gating it to the tailnet is a real mitigation for
that gap rather than leaving it open on the public hostname.
Add a Traefik IP allowlist so *.falcon.pivoine.art rejects any source
outside the Tailscale/Headscale CIDR ranges, even though DNS still
resolves publicly. Pair it with a headscale MagicDNS override so
tailnet clients resolve the hostname straight to the VPS's tailscale
IP and route correctly.
Requires Docker's userland-proxy disabled on the VPS host
(/etc/docker/daemon.json) so Traefik sees real client source IPs
instead of the docker bridge gateway — done manually, not tracked
in this repo.
74.7.243.196 was crawling valknar/home SVG icon tree at 1.8 req/s.
- Denylist the IP in fail2ban dynamic config
- Add rate-limit@file middleware to Gitea's Traefik router to throttle
future scrapers on public repos
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
v0.7.1 caused 403 on all routes. v0.9.0 renamed whitelist→allowlist,
changed ip to a comma-separated string, and added enabled/statuscode fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Registers tomMoulard/fail2ban v0.7.1 as an experimental plugin and
applies it globally on the web-secure entrypoint alongside security-headers.
Bans IPs after 5 failures within 10 minutes for 3 hours; whitelists
RFC1918 ranges to protect internal/Docker traffic.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Migrated 11 services from monolithic docker-compose project into independent stacks,
each with dedicated databases, minimal .env configuration, and bind-mount data volumes.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>