networking
Write and review network configuration for self-hosted Linux infrastructure: what an nftables ruleset, a resolver zone, a reverse-proxy site, a forward-auth chain, a WireGuard or Tailscale tunnel, or
SKILL.mdplugins/infrastructure/skills/networking/SKILL.md
GitHubThree biases decide most calls:
- Evaluation order belongs to the engine, not the file. nftables runs every base chain on a hook by priority and
only
dropis final; Caddy sorts directives by a built-in table; nginx discards inheritedproxy_set_headerthe moment one is set lower; Traefik ranks routers by rule length; Authelia and cloudflared take the first match. Read the engine's order before trusting the file's. - Who is trusted is a list, and every tool ships a different one. Caddy trusts no proxy for
X-Forwarded-*, HAProxy and Cloudflare append the client's, Unbound refuses every client but localhost, AdGuard Home on0.0.0.0answers the internet, Avahi reflects to every interface, Tailscale on Linux accepts no routes, NetBird pushes them unapproved. Name the list in the config; never assume its direction. - Every filter has a second path around it. IPv6 beside an
iprule in aninettable; a published container port beside the hostinputchain; a flowtable beside the later hooks; a reachable origin beside Cloudflare Access; an established connection beside a CrowdSec ban; a default-bridge container beside the LAN resolver. Close the second path or state that it is open.
Filtering
acceptends only the current base chain;dropends the ruleset. A permissive table added beside the one Proxmox, Docker, or firewalld installs loads clean and changes nothing. Anatchain at priority -200 or below runs before conntrack and translates nothing.ippayload expressions match IPv4 only, also insideinet.ip protocol tcppasses every IPv6 packet;meta l4proto tcpmatches both and skips IPv6 extension headers.flush rulesetdeletes every table on the host, Docker's, firewalld's, and netavark's included, and no daemon notices until restart.flush table <family> <name>is the scoped form.nft -c -fparses; it knows nothing about interface names, runtime-populated sets, or a higher-priority table that drops first.- A published Docker port never traverses the host
inputchain; it is DNAT and passesforward. Operator rules go inDOCKER-USERon the iptables backend. Under the nftables backend (Engine 29.0, experimental) there is noDOCKER-USERchain and no jump to it; a daemon switched between backends keeps the stale jump. Engine 28.0 blocks direct-routed access to unpublished ports through therawtable. - On OPNsense and pfSense the order is floating, then group, then interface, and
quickfirst-match is the default. A rule change does not touch the state table, so a new deny does nothing to a live session until states are reset, and the default deny logs nothing unless told to.
Read [${CLAUDE_SKILL_DIR}/references/filtering.md] when writing or reading an nftables ruleset, a host that also runs
Docker, Podman, firewalld, or ufw, or an OPNsense or pfSense rule; it carries the evaluation, loading, coexistence, and
appliance rules and the kernel versions each feature needs.
Addressing and routing
- A Linux bridge with
vlan_filtering 0forwards tagged frames on every port. That is the VLAN leak on a hypervisor bridge. With filtering on, a port with no PVID drops every untagged frame, and turning filtering on before adding the VLANs drops the management session. - The effective
rp_filteris the maximum ofconf/alland the interface, and systemd's shipped sysctl file sets every present interface to 2 while excludingconf/all, so a write toallsucceeds and changes nothing. An rp_filter drop shows intcpdumpand never in conntrack. accept_raat its default stops processing router advertisements when forwarding is turned on; a router that gets its WAN address from RA needs value 2. A dual-stack host prefers IPv4 over a ULA destination (RFC 6724), so a ULA-only service never exercises its IPv6 rules.- A path-MTU black hole is a connection that establishes and stalls on volume. The fix is passing ICMPv6 type 2 and
ICMP fragmentation-needed on the path, and MSS clamping in
forwardwithrt mtufor both directions. KeepConfiguration=defaults tono, so restarting systemd-networkd drops DHCP addresses and routes with the SSH session on them;netplan tryreverts after 120 seconds and its own man page says to check whether it did.
Read [${CLAUDE_SKILL_DIR}/references/routing.md] when writing a VLAN, bridge, route, policy rule, sysctl, MTU, IPv6,
networkd, netplan, or DHCP setting; it carries the bridge, reverse-path, policy-routing, IPv6, and manager rules.
DNS
- Docker rewrites a container's
resolv.conf. Loopback nameservers inherited from the host are removed, and when nothing is left Google's resolvers are substituted, so on a systemd-resolved host every default-bridge container bypasses the LAN filter. A--dns 127.0.0.53override resolves inside the container's namespace and always fails. - The resolved stub owns
127.0.0.53:53by default, and only the stub-mode symlink routes clients through resolved; uplink mode bypasses it. Another daemon binding the wildcard on port 53 fails untilDNSStubListener=no. - Unbound refuses every client but localhost until
access-controlallows it, listens on localhost untilinterface:names another, and changes interfaces only on restart.home.arpais a default static zone, so a zone or forwarder under it is shadowed until declared with another type ornodefault;do-not-query-localhostdefaults toyes, so a forwarder on127.0.0.1gets SERVFAIL. - Pi-hole v6 is a different surface from v5. Every container variable was renamed to
FTLCONF_<section>_<key>, nineteen were removed, an unknown one is ignored, an env-supplied setting is read-only in the UI,/etc/dnsmasq.dis ignored unlessmisc.etc_dnsmasq_dis set, and the web server falls back to 8080 when 80 is taken. FTL 6.3 renameddns.domaintodns.domain.name. - AdGuard Home overwrites an edit made to
AdGuardHome.yamlwhile running,allowed_clientsset makesdisallowed_clientsignored, andbind_hosts: ['0.0.0.0']with neither is an open resolver whose first signal is an abuse notice. - Private naming:
.localis mDNS and some hosts never ask the LAN resolver for it;home.arpais unsigned by design; no public CA issues for.lan,.home,.internal, or any unregistered suffix, so internal TLS needs a registered domain or a private CA. A wildcard covers one label and not the bare domain. - Avahi's reflector reflects to every interface it joined, and it joins every interface except loopback and
point-to-point unless
allow-interfacesscopes it.
Read [${CLAUDE_SKILL_DIR}/references/dns.md] when touching resolved, a container's DNS, search domains, private
naming, caching, or mDNS, and [${CLAUDE_SKILL_DIR}/references/resolvers.md] when writing Unbound, Pi-hole, AdGuard
Home, dnsmasq, CoreDNS, or Knot Resolver configuration; the second carries the per-daemon defaults, the local-zone
types, the renamed keys, and the upstream syntax.
Proxies and TLS
- Header trust differs per proxy. Caddy discards inbound
X-Forwarded-*unless the sender is intrusted_proxies, and even then parsesX-Forwarded-Forleft to right unlesstrusted_proxies_strictis set; Traefik strips unlessforwardedHeaders.trustedIPsnames the sender; nginx's realip module replaces nothing untilset_real_ip_fromis set; HAProxy'soption forwardforand Cloudflare append the client's value. ReadCF-Connecting-IPbehind Cloudflare, never the firstX-Forwarded-For. - Caddy sorts directives by a built-in table,
handleblocks are mutually exclusive, a path matcher is exact, and a site block never cascades.auto_https offdoes not make a domain site block serve HTTP; the address does.tls client_authwith notrust_pooldemands a certificate and verifies nothing. Acaddy reloadwith an unchanged file is a no-op, andSIGUSR1is ignored once the admin API has been used. - Traefik v3 rejects v2 rule syntax unless
core.defaultRuleSyntax: v2, itself deprecated.certificatesResolversin a dynamic file is ignored and the default self-signed certificate is served; a router with noentryPointsattaches to all of them; priority is rule length;exposedByDefaultistrue; a header whose name aliases to the same backend variable passes through by default. Renewal is computed fromacme.certificatesDuration, never from the certificate. - nginx
proxy_set_headerat any level discards every inherited one,proxy_passwith a URI replaces the matched prefix and without one passes the request URI unchanged, and a literal upstream hostname is resolved once at start unlessresolve(open source from 1.27.3) or a variable plusresolveris used. - ACME as written: HTTP-01 is port 80 only, validation prefers AAAA and retries IPv4 only on a timeout, a wildcard
is one label and not the apex, DNS-01 propagation waits default near zero, and rate limits are token buckets a retry
loop drains. ARI overrides the client's renewal window. The lifetime schedule, the profile facts, and the rate-limit
numbers are in the devops skill's
references/secrets.md.
Read [${CLAUDE_SKILL_DIR}/references/caddy.md], [${CLAUDE_SKILL_DIR}/references/traefik.md], or
[${CLAUDE_SKILL_DIR}/references/nginx.md] when writing that proxy's configuration; read
[${CLAUDE_SKILL_DIR}/references/acme.md] when a challenge, wildcard, propagation, profile, or client storage setting
is in play; read [${CLAUDE_SKILL_DIR}/references/cloudflare.md] when Cloudflare or a cloudflared tunnel is in the
path, because Full mode validates nothing on the origin and a dashboard-managed tunnel ignores the local ingress file.
Identity at the proxy
- The backend must receive identity headers from the proxy only, and the proxy must strip inbound ones explicitly.
Caddy's
forward_auth copy_headerscopies a header only when the auth response carries it, and Traefik replaces the canonical dash form only, soX_Auth_Userfrom a client reaches a CGI or PHP backend as the same variable. Trusting a whole Docker network is not enough; Authelia's page says any container on it can then authenticate to any application. - Authelia's endpoint depends on the proxy:
/api/authz/forward-authfor Traefik, Caddy, and HAProxy,/api/authz/auth-requestfor nginx./api/verifyanswers as theLegacyimplementation in 4.38 and later, so a stale config keeps working while options added in 4.38 and later cannot be used with it. Access control is first-match withdefault_policydeny;resourcesare unanchored regular expressions; a host not covered bysession.cookiesis denied automatically. - authentik trusts forwarded headers only from
AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS(2026.8 and later), private ranges by default, replaced rather than extended by a set value. Only/outpost.goauthentik.io/*is routed to the outpost, and each proxy needs a second route for it or the login callback 404s.
Read [${CLAUDE_SKILL_DIR}/references/forward-auth.md] when wiring Authelia, authentik, or oauth2-proxy to a proxy; it
carries the per-proxy header lists, the rule-ordering traps, the secrets rules, and the injection advisories.
Tunnels
AllowedIPsis the outbound route and the inbound source ACL in one field, and two peers on one interface claiming the same prefix resolve silently in favor of the later one. A freshlatest handshakeproves key agreement and nothing about routes.- A
/0in any peer'sAllowedIPswithTableunset switches wg-quick to policy routing and supersedes the host's default route the momentupreturns; the/1split does not get that treatment and blackholes instead.Table = offis the setting for a routing daemon over the tunnel. - wg-quick does not default MTU to 1420; it computes the smallest endpoint route MTU minus 80, took the largest
before wireguard-tools v1.0.20250521, and Debian 13 and Ubuntu 24.04 ship the 2021 release.
SaveConfig = truerewrites the file on everydown, keeping hooks and dropping comments.DNS =callsresolvconfand nothing else. - Tailscale on Linux accepts no advertised routes until
--accept-routes, an advertised route reaches nobody until approved,tailscale upwith any flag demands the complete set and--resetsilently defaults the rest,autogroup:internetis the only thing that authorizes exit-node use, and tagging a device disables its key expiry. Headscale rejects a client below its capability floor, blocks minor-version skips from 0.29, and redefined*to the tailnet ranges in the same release. - A
cloudflaredtunnel managed from the dashboard ignores the local ingress file, the last local rule must be a catch-all, and a tunnel leaves a host with a public address as reachable as before.
Read [${CLAUDE_SKILL_DIR}/references/wireguard.md] when writing a WireGuard or wg-quick file or routing a daemon over
it, and [${CLAUDE_SKILL_DIR}/references/tailscale.md] when writing a Tailscale policy or up invocation, a Headscale
configuration, or a NetBird route; cloudflared is in the cloudflare reference above.
Detection and remediation
- Suricata's shipped
HOME_NETis the three RFC 1918 ranges, so a sensor on a public prefix loads every rule and alerts on nothing. Rules ship asalert; IPS drops nothing withoutdrop.conf.suricata-updatedoes not reload the engine unless--reload-commandis set. Offloads that merge frames (GRO, LRO, TSO) must be off on the capture interface, AF_PACKET IPS needs a symmetric interface pair and drops nothing on a box that routes instead of bridges, and an NFQUEUE with no listener drops the link when Suricata stops. - CrowdSec's firewall component creates its rules once at startup, so a ruleset flush or a firewalld reload removes
the enforcement while decision syncs keep logging success;
nftables_hooksomitted meansinputonly; a banned IP with an established connection stays connected; behind Cloudflare it blocks nothing because it sees only Cloudflare's addresses. The ban duration is inprofiles.yaml, not the scenario.cscli dashboardis gone since 1.7.0 and a container without a data volume exits 0. - fail2ban's
banactiondefaulted toiptables-multiportthrough 1.1.0 and fails silently on an nftables-only host; 1.1.1 moved the default tonftables. The sshd filter matched nothing after OpenSSH 9.8'ssshd-sessionrename until 1.1.1;ignoreipis commented out, andignoreselfnever covers the operator's remote address.
Read [${CLAUDE_SKILL_DIR}/references/suricata.md] when writing a Suricata config or ruleset or enabling IPS, and
[${CLAUDE_SKILL_DIR}/references/remediation.md] when configuring CrowdSec, a remediation component, or fail2ban.
Application
When writing network configuration, apply these rules silently; do not narrate a rule while following it. Where the target's existing configuration contradicts one, follow it for the change at hand and flag the divergence once. Name the tool version an edit assumes when the syntax differs across a boundary named here: Pi-hole v6, Traefik v3, Docker Engine 28 and 29, wireguard-tools v1.0.20250521, Headscale 0.28 and 0.29, CrowdSec 1.7, fail2ban 1.1.1, Unbound 1.23.
When reviewing network configuration, cite the line, state what it does once loaded, and give the corrected line inline. Do not lecture.
Integration
The devops skill governs the change: what it must carry before apply, how it is applied, and the evidence that
closes it, including the read-only effect checks for a firewall, a resolver, a certificate, and a WireGuard peer in its
references/effect.md. This skill governs what a ruleset, zone, tunnel, or proxy configuration does once loaded, and
wins on that question. The containers skill owns container networking modes and Compose; the host-firewall side of a
published port is here. The proxmox skill owns the hypervisor bridge setting; Linux bridge semantics are here. The
coding skill governs the shape of the work. All are active at once.