Caddy named matchers are block-scoped

A small Caddyfile cleanup that removed eight duplicate lines.

The problem

Each tailnet-gated route in the Caddyfile was declaring the same matcher:

handle /code* {
    @tailnet header Tailscale-User-Login *
    handle @tailnet { reverse_proxy localhost:7682 }
    respond "Forbidden" 403
}

handle /ttyd* {
    @tailnet header Tailscale-User-Login *
    handle @tailnet { reverse_proxy localhost:7681 }
    respond "Forbidden" 403
}

Eight services, eight identical @tailnet header Tailscale-User-Login * declarations. Copy-paste configuration.

Named matchers are scoped to the block they're defined in

That's why each handle had its own — a matcher defined inside a handle block only exists within that block.

Matchers defined directly in the site block (outside any handle) are scoped to the whole site. Move @tailnet there and every handle can reference it:

:8080 {
    @tailnet header Tailscale-User-Login *

    handle /code* {
        handle @tailnet { reverse_proxy localhost:7682 }
        respond "Forbidden" 403
    }

    handle /ttyd* {
        handle @tailnet { reverse_proxy localhost:7681 }
        respond "Forbidden" 403
    }
}

One declaration, used everywhere. The behavior is identical — Tailscale-User-Login is injected by Tailscale on tailnet requests and stripped before Funnel traffic reaches Caddy, so it can't be spoofed.

The same scoping applies to any named matcher: @articleSlug, @root, @404markdown. If a matcher is defined at the site level, it's available in all handles. If you need a matcher to only exist inside one handle (e.g., because it shadows a site-level name), define it inside that handle.