Composable per-listener access control (bindings, listeners, access rules) #3

Closed
opened 2026-07-13 17:41:57 +00:00 by james.oates · 3 comments
Owner

Context

Came up while deploying a Tailscale-only service (Stash) behind EdgeRouter on Kyoshi. "Tailscale-only" for a regular [[services]] entry is currently achieved purely by omission — no Cloudflare Tunnel ingress rule, relying on Pi-hole DNS overrides — rather than anything EdgeRouter itself enforces. Working through this surfaced a real design gap plus a live bug, and the discussion converged on a general model worth building properly rather than a narrow patch. Design below supersedes the original, narrower version of this issue.

Current gaps

  1. allowed_ips only exists on [[admin]], not regular services. AdminEntry (middleware.rs) supports a CIDR-based allowed_ips restriction, enforced in admin_ip_middleware. The Service model/config (models.rs, routes_config.rs) has no equivalent — any regular service is reachable from wherever its routing_hosts/DNS resolves, with no source-IP restriction available at all.

  2. The existing admin IP check trusts spoofable client headers. admin_ip_middleware currently does:

    let effective_ip = request
        .headers()
        .get("CF-Connecting-IP")
        .or_else(|| request.headers().get("X-Real-IP"))
        .and_then(|v| v.to_str().ok())
        .and_then(|s| s.parse::<std::net::IpAddr>().ok())
        .unwrap_or_else(|| addr.ip());
    

    This prefers the CF-Connecting-IP/X-Real-IP headers over the real TCP peer address, only falling back to the genuine connection address if those headers are absent. Since these headers are attacker-controlled on any request that doesn't actually pass through Cloudflare (e.g. a direct connection over LAN or Tailscale), anyone bypassing Cloudflare can simply set CF-Connecting-IP: <allowed-ip> on a raw request and have it trusted. This is a live bug in production today, independent of the Stash use case.

Design: bindings, listeners, access rules

Rather than fixing (2) with a Cloudflare-IP-range allowlist (still just trusting a claim, and a list that needs maintaining), make header trust a property of architecture: which literal socket accepted the connection, not what the request claims about itself.

Key mechanism: even a single wildcard bind (0.0.0.0:443) exposes, per accepted connection, a concrete local address via local_addr() (already available on the TcpStream types EdgeRouter uses via Tokio) — the OS fills in exactly which of the host's interface IPs the incoming packet was addressed to, once a specific connection is accepted. A client cannot influence this; it's determined by OS-level routing, not anything in the request. So we don't need multiple physical listeners to distinguish "arrived via Tailscale" vs "arrived via LAN" — one socket, inspect local_addr() per connection.

Three layers:

  • Bindings — the actual OS-level socket binds. Few in number (e.g. one general 0.0.0.0:443 for public/LAN/Tailscale traffic; optionally a second, dedicated one for Cloudflare specifically, on a port/address reachable only from Docker's internal network).
  • Listeners — named, logical trust zones. Each references a parent binding plus a match rule (local IP/CIDR, and/or local port) evaluated against the accepted connection's real local address — not client-supplied data. Multiple listeners can share one binding (distinguished by local IP); a listener can also have its own dedicated binding when stronger isolation is wanted than "same socket, different local IP" gives you. Each listener optionally sets trust_forwarded_for — this should only be true for listeners structurally guaranteed to be reachable exclusively by a trusted intermediary (e.g. a Cloudflare-only binding), since that's what makes honoring CF-Connecting-IP/X-Real-IP sound: trust by construction, not by checking a claimed IP against a maintained range list.
  • Access rules — generalize allowed_ips into a composable, OR-of-rules list attached to services (and admin entries — this should unify with/replace the current admin-only mechanism rather than living alongside it). Each rule is via = "<listener-name>" plus an optional allow_ips CIDR list further restricting within that listener's trust context. Rules OR together (any matching rule grants access); the IP restriction within a rule ANDs with the listener match.

Example config

[[bindings]]
name = "https"
bind = "0.0.0.0:443"

[[listeners]]
name = "tailscale"
parent = "https"
match_local_ip = "100.71.56.28/32"

[[listeners]]
name = "lan"
parent = "https"
match_local_ip = "192.168.0.247/32"

# Dedicated binding for Cloudflare — its own socket, reachable only from
# Docker's internal network (e.g. not published to the host at all), so
# trust_forwarded_for is sound here specifically because nothing else can
# reach this binding.
[[bindings]]
name = "cloudflare-internal"
bind = "0.0.0.0:8443"

[[listeners]]
name = "cloudflare"
parent = "cloudflare-internal"
trust_forwarded_for = true

[[services]]
name = "stash"
target_host = "stash"
target_port = 9999
routing_hosts = ["stash.oates.ws"]
routing_paths = ["/*"]

[[services.access]]
via = "tailscale"
allow_ips = ["100.64.0.0/10"]   # any Tailscale device

[[services.access]]
via = "lan"
allow_ips = ["192.168.0.50/32"]  # one specific LAN device

[[services.access]]
via = "cloudflare"
allow_ips = ["203.0.113.5/32"]    # trusting CF-Connecting-IP here is sound

This composes cleanly for the motivating cases: "Tailscale, any device," "LAN, but only this one IP," "public via Cloudflare, but header-restricted to a specific remote IP" — all expressible together on one service, each only as trustworthy as its actual network path allows. It also fixes gap (2) as a natural consequence of the design rather than a separate patch: trust_forwarded_for is opt-in per listener and should only ever be set on a listener whose binding is structurally Cloudflare-only.

Scope

  • Extend service/admin config models to support access rule lists (replacing standalone allowed_ips).
  • Support multiple named bindings + listeners instead of the current single 0.0.0.0 listener.
  • Per-connection local_addr() lookup against configured listener match rules.
  • Migrate [[admin]]'s existing allowed_ips onto the same access-rule mechanism rather than keeping two parallel systems.
  • Tests: connections landing on the wrong listener are rejected regardless of claimed headers; trust_forwarded_for listeners honor the header, others never do even if present.

Additional gap found: no X-Forwarded-* / Host preservation on the upstream request

Found while debugging why Stash's thumbnails and video playback were broken behind EdgeRouter (unrelated to Stash's media — files are plain h264/aac in mp4, about as browser-compatible as it gets).

Stash builds absolute self-referential URLs (screenshot/preview/sprite/stream) from the incoming request's Host header and scheme. EdgeRouter's proxy currently does neither of:

  • preserve the original client-facing Host header when forwarding upstream, nor
  • set X-Forwarded-Host / X-Forwarded-Proto to the original values.

Confirmed via Stash's own access log and by testing directly: even when X-Forwarded-Host: stash.oates.ws / X-Forwarded-Proto: https are sent by the client, EdgeRouter does not pass them through (or Stash never sees usable ones), and the backend's request log shows Host: stash:9999, scheme http — i.e. EdgeRouter is presenting its own upstream target authority as the Host, not the original public one. Result: every asset/stream URL Stash returns points at an internal Docker hostname + port, unreachable from any client, and (since the page is HTTPS) also blocked as mixed content.

This isn't Stash-specific — any backend that self-references its own URL (a very common pattern: redirects, websocket URLs, federation, media asset URLs) will hit the same thing behind EdgeRouter today.

Ties into this issue's design because correct behavior here depends on the same listener/trust context this issue introduces: EdgeRouter should forward X-Forwarded-Proto (always https, since EdgeRouter terminates TLS) and X-Forwarded-Host reflecting the original request's Host header — populated from data EdgeRouter itself observed (the actual inbound request), never from client-supplied headers being blindly re-forwarded. This is the mirror-image of the trust_forwarded_for concept already in this issue: instead of deciding whether to trust inbound forwarded headers, EdgeRouter needs to correctly set outbound forwarded headers toward the backend.

Additional scope

  • On every proxied request, set X-Forwarded-Proto: https and X-Forwarded-Host: <original Host header> toward the backend (and preserve/pass the original Host header itself, since some backends read that directly rather than X-Forwarded-Host).
  • Test: a backend that echoes back Host/X-Forwarded-* sees the original public hostname and https, never the upstream target_host:target_port or http, regardless of which listener the request arrived on.
## Context Came up while deploying a Tailscale-only service (Stash) behind EdgeRouter on Kyoshi. "Tailscale-only" for a regular `[[services]]` entry is currently achieved purely by *omission* — no Cloudflare Tunnel ingress rule, relying on Pi-hole DNS overrides — rather than anything EdgeRouter itself enforces. Working through this surfaced a real design gap plus a live bug, and the discussion converged on a general model worth building properly rather than a narrow patch. Design below supersedes the original, narrower version of this issue. ## Current gaps 1. **`allowed_ips` only exists on `[[admin]]`, not regular services.** `AdminEntry` (`middleware.rs`) supports a CIDR-based `allowed_ips` restriction, enforced in `admin_ip_middleware`. The `Service` model/config (`models.rs`, `routes_config.rs`) has no equivalent — any regular service is reachable from wherever its `routing_hosts`/DNS resolves, with no source-IP restriction available at all. 2. **The existing admin IP check trusts spoofable client headers.** `admin_ip_middleware` currently does: ```rust let effective_ip = request .headers() .get("CF-Connecting-IP") .or_else(|| request.headers().get("X-Real-IP")) .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse::<std::net::IpAddr>().ok()) .unwrap_or_else(|| addr.ip()); ``` This *prefers* the `CF-Connecting-IP`/`X-Real-IP` headers over the real TCP peer address, only falling back to the genuine connection address if those headers are absent. Since these headers are attacker-controlled on any request that doesn't actually pass through Cloudflare (e.g. a direct connection over LAN or Tailscale), anyone bypassing Cloudflare can simply set `CF-Connecting-IP: <allowed-ip>` on a raw request and have it trusted. This is a live bug in production today, independent of the Stash use case. ## Design: bindings, listeners, access rules Rather than fixing (2) with a Cloudflare-IP-range allowlist (still just trusting a claim, and a list that needs maintaining), make header trust a property of *architecture*: which literal socket accepted the connection, not what the request claims about itself. **Key mechanism:** even a single wildcard bind (`0.0.0.0:443`) exposes, per *accepted* connection, a concrete local address via `local_addr()` (already available on the `TcpStream` types EdgeRouter uses via Tokio) — the OS fills in exactly which of the host's interface IPs the incoming packet was addressed to, once a specific connection is accepted. A client cannot influence this; it's determined by OS-level routing, not anything in the request. So we don't need multiple physical listeners to distinguish "arrived via Tailscale" vs "arrived via LAN" — one socket, inspect `local_addr()` per connection. **Three layers:** - **Bindings** — the actual OS-level socket binds. Few in number (e.g. one general `0.0.0.0:443` for public/LAN/Tailscale traffic; optionally a second, dedicated one for Cloudflare specifically, on a port/address reachable only from Docker's internal network). - **Listeners** — named, logical trust zones. Each references a parent binding plus a match rule (local IP/CIDR, and/or local port) evaluated against the *accepted* connection's real local address — not client-supplied data. Multiple listeners can share one binding (distinguished by local IP); a listener can also have its own dedicated binding when stronger isolation is wanted than "same socket, different local IP" gives you. Each listener optionally sets `trust_forwarded_for` — this should only be true for listeners structurally guaranteed to be reachable exclusively by a trusted intermediary (e.g. a Cloudflare-only binding), since that's what makes honoring `CF-Connecting-IP`/`X-Real-IP` sound: trust by construction, not by checking a claimed IP against a maintained range list. - **Access rules** — generalize `allowed_ips` into a composable, OR-of-rules list attached to services (and admin entries — this should unify with/replace the current admin-only mechanism rather than living alongside it). Each rule is `via = "<listener-name>"` plus an optional `allow_ips` CIDR list further restricting within that listener's trust context. Rules OR together (any matching rule grants access); the IP restriction within a rule ANDs with the listener match. ### Example config ```toml [[bindings]] name = "https" bind = "0.0.0.0:443" [[listeners]] name = "tailscale" parent = "https" match_local_ip = "100.71.56.28/32" [[listeners]] name = "lan" parent = "https" match_local_ip = "192.168.0.247/32" # Dedicated binding for Cloudflare — its own socket, reachable only from # Docker's internal network (e.g. not published to the host at all), so # trust_forwarded_for is sound here specifically because nothing else can # reach this binding. [[bindings]] name = "cloudflare-internal" bind = "0.0.0.0:8443" [[listeners]] name = "cloudflare" parent = "cloudflare-internal" trust_forwarded_for = true [[services]] name = "stash" target_host = "stash" target_port = 9999 routing_hosts = ["stash.oates.ws"] routing_paths = ["/*"] [[services.access]] via = "tailscale" allow_ips = ["100.64.0.0/10"] # any Tailscale device [[services.access]] via = "lan" allow_ips = ["192.168.0.50/32"] # one specific LAN device [[services.access]] via = "cloudflare" allow_ips = ["203.0.113.5/32"] # trusting CF-Connecting-IP here is sound ``` This composes cleanly for the motivating cases: "Tailscale, any device," "LAN, but only this one IP," "public via Cloudflare, but header-restricted to a specific remote IP" — all expressible together on one service, each only as trustworthy as its actual network path allows. It also fixes gap (2) as a natural consequence of the design rather than a separate patch: `trust_forwarded_for` is opt-in per listener and should only ever be set on a listener whose binding is structurally Cloudflare-only. ## Scope - Extend service/admin config models to support `access` rule lists (replacing standalone `allowed_ips`). - Support multiple named bindings + listeners instead of the current single `0.0.0.0` listener. - Per-connection `local_addr()` lookup against configured listener match rules. - Migrate `[[admin]]`'s existing `allowed_ips` onto the same access-rule mechanism rather than keeping two parallel systems. - Tests: connections landing on the wrong listener are rejected regardless of claimed headers; `trust_forwarded_for` listeners honor the header, others never do even if present. ## Additional gap found: no `X-Forwarded-*` / Host preservation on the upstream request Found while debugging why Stash's thumbnails and video playback were broken behind EdgeRouter (unrelated to Stash's media — files are plain `h264`/`aac` in `mp4`, about as browser-compatible as it gets). Stash builds absolute self-referential URLs (screenshot/preview/sprite/stream) from the incoming request's `Host` header and scheme. EdgeRouter's proxy currently does neither of: - preserve the original client-facing `Host` header when forwarding upstream, nor - set `X-Forwarded-Host` / `X-Forwarded-Proto` to the original values. Confirmed via Stash's own access log and by testing directly: even when `X-Forwarded-Host: stash.oates.ws` / `X-Forwarded-Proto: https` are sent by the client, EdgeRouter does not pass them through (or Stash never sees usable ones), and the backend's request log shows `Host: stash:9999`, scheme `http` — i.e. EdgeRouter is presenting its own upstream target authority as the Host, not the original public one. Result: every asset/stream URL Stash returns points at an internal Docker hostname + port, unreachable from any client, and (since the page is HTTPS) also blocked as mixed content. This isn't Stash-specific — any backend that self-references its own URL (a very common pattern: redirects, websocket URLs, federation, media asset URLs) will hit the same thing behind EdgeRouter today. **Ties into this issue's design** because correct behavior here depends on the same listener/trust context this issue introduces: EdgeRouter should forward `X-Forwarded-Proto` (always `https`, since EdgeRouter terminates TLS) and `X-Forwarded-Host` reflecting the original request's `Host` header — populated from data EdgeRouter itself observed (the actual inbound request), never from client-supplied headers being blindly re-forwarded. This is the mirror-image of the `trust_forwarded_for` concept already in this issue: instead of deciding whether to *trust* inbound forwarded headers, EdgeRouter needs to correctly *set* outbound forwarded headers toward the backend. ### Additional scope - On every proxied request, set `X-Forwarded-Proto: https` and `X-Forwarded-Host: <original Host header>` toward the backend (and preserve/pass the original `Host` header itself, since some backends read that directly rather than `X-Forwarded-Host`). - Test: a backend that echoes back `Host`/`X-Forwarded-*` sees the original public hostname and `https`, never the upstream `target_host:target_port` or `http`, regardless of which listener the request arrived on.
james.oates changed title from Per-service IP allowlisting, and fix header-spoofing gap in admin IP check to Composable per-listener access control (bindings, listeners, access rules) 2026-07-13 18:33:07 +00:00
Author
Owner

Forwarded-header gap fixed and deployed in v1.0.6 (both Roku and Kyoshi). Root cause was more specific than first thought: the Host/X-Forwarded-Host code only ever read the literal host header, but HTTP/2 requests (the default for any browser over TLS) carry the authority in the :authority pseudo-header instead, with no literal host header present at all — so every real browser request silently fell back to the backend's own target_host:target_port. Fixed by resolving the original host via parts.uri.authority() first, falling back to the literal header for HTTP/1.1.

While verifying the fix, also found and fixed a related spoofing gap: client-supplied X-Forwarded-Host/X-Forwarded-Proto/X-Real-IP headers were being forwarded verbatim before EdgeRouter appended its own authoritative values, so a client could inject its own header of the same name and have it survive alongside (and, for a Header.Get()-style backend, take precedence over) the real one. Now strips client-supplied values for headers EdgeRouter itself sets, same as any perimeter-terminating reverse proxy should. X-Forwarded-For still chains as intended.

Both fixes covered by regression tests (HTTP/1.1 and HTTP/2 Host preservation, and a spoofed-header test asserting only EdgeRouter's own value survives).

The bindings/listeners/access-rules design above is still open — this comment only covers the forwarded-header piece, not the broader per-listener access control this issue is about.

Forwarded-header gap fixed and deployed in v1.0.6 (both Roku and Kyoshi). Root cause was more specific than first thought: the Host/X-Forwarded-Host code only ever read the literal `host` header, but HTTP/2 requests (the default for any browser over TLS) carry the authority in the `:authority` pseudo-header instead, with no literal `host` header present at all — so every real browser request silently fell back to the backend's own target_host:target_port. Fixed by resolving the original host via `parts.uri.authority()` first, falling back to the literal header for HTTP/1.1. While verifying the fix, also found and fixed a related spoofing gap: client-supplied `X-Forwarded-Host`/`X-Forwarded-Proto`/`X-Real-IP` headers were being forwarded verbatim before EdgeRouter appended its own authoritative values, so a client could inject its own header of the same name and have it survive alongside (and, for a Header.Get()-style backend, take precedence over) the real one. Now strips client-supplied values for headers EdgeRouter itself sets, same as any perimeter-terminating reverse proxy should. `X-Forwarded-For` still chains as intended. Both fixes covered by regression tests (HTTP/1.1 and HTTP/2 Host preservation, and a spoofed-header test asserting only EdgeRouter's own value survives). The bindings/listeners/access-rules design above is still open — this comment only covers the forwarded-header piece, not the broader per-listener access control this issue is about.
Author
Owner

Cross-reference from a Playground security audit — offered as context for the trust_forwarded_for listener design, not a change request.

The trust_forwarded_for-only-on-a-structurally-isolated-binding model here is exactly the right anchor, and it lines up with how Cloudflare intends origins to establish that isolation. If it's useful when this gets built, the concrete "reachable only by the trusted intermediary" guarantees for the Cloudflare hop are, roughly strongest-first:

  • Cloudflare Tunnel (cloudflared) — origin has no inbound port at all; nothing but the tunnel can reach it. The trust_forwarded_for binding becomes moot because there's no non-Cloudflare path to it.
  • Authenticated Origin Pulls (mTLS) — Cloudflare presents a client cert; the binding rejects anything without it. Cryptographic proof of the intermediary, independent of IP.
  • Origin firewall locked to Cloudflare's published IP ranges — weakest (ranges drift, needs upkeep), but a valid form of the same idea.

Relevant to the CF-Connecting-IP handling in gap (2): that header is authoritative only because Cloudflare overwrites it on every request — but that overwrite only happens if the request actually traversed Cloudflare. So its trustworthiness reduces entirely to "can non-Cloudflare traffic reach this listener," which is precisely what a dedicated isolated binding guarantees. The header semantics and the binding design are the same guarantee viewed from two ends. Nothing to change here — just noting the design already has the right shape, and the above are the mechanisms that make a trust_forwarded_for binding sound in practice.

Cross-reference from a Playground security audit — offered as context for the `trust_forwarded_for` listener design, not a change request. The `trust_forwarded_for`-only-on-a-structurally-isolated-binding model here is exactly the right anchor, and it lines up with how Cloudflare intends origins to establish that isolation. If it's useful when this gets built, the concrete "reachable only by the trusted intermediary" guarantees for the Cloudflare hop are, roughly strongest-first: - **Cloudflare Tunnel** (`cloudflared`) — origin has no inbound port at all; nothing but the tunnel can reach it. The `trust_forwarded_for` binding becomes moot because there's no non-Cloudflare path to it. - **Authenticated Origin Pulls** (mTLS) — Cloudflare presents a client cert; the binding rejects anything without it. Cryptographic proof of the intermediary, independent of IP. - **Origin firewall locked to Cloudflare's published IP ranges** — weakest (ranges drift, needs upkeep), but a valid form of the same idea. Relevant to the `CF-Connecting-IP` handling in gap (2): that header is authoritative only *because* Cloudflare overwrites it on every request — but that overwrite only happens if the request actually traversed Cloudflare. So its trustworthiness reduces entirely to "can non-Cloudflare traffic reach this listener," which is precisely what a dedicated isolated binding guarantees. The header semantics and the binding design are the same guarantee viewed from two ends. Nothing to change here — just noting the design already has the right shape, and the above are the mechanisms that make a `trust_forwarded_for` binding sound in practice.
Author
Owner

Shipped

Implemented end-to-end and merged to main. Feature commit range: be5b732..b728117 (21 commits on top of baseline fca0b42).

What shipped:

  • Bindings / listeners / access rules — bindings are OS socket binds (TLS or plaintext); listeners are named trust zones (parent binding + optional match_local_ip CIDR, matched against the connection's real local address, never client-supplied); access rules are { via = "<listener>", allow_ips = [...] }, OR'd together.
  • Secure by default — a service, admin entry, or L4 route with zero access rules is unreachable from anywhere. No grandfathering code (ADD COLUMN access_rules TEXT NOT NULL DEFAULT '[]').
  • Applies uniformly to [[services]], [[admin]], and l4_routes.
  • Fixes the live CF-Connecting-IP / X-Real-IP spoofing bug — those headers are now only honored on a listener explicitly marked trust_forwarded_for, which is only sound because its binding (the dedicated Cloudflare-Tunnel port) is structurally unreachable by anything else.

Key commits:

  • 96da35c — migrate admin access control off allowed_ips; fix the CF-Connecting-IP trust bug
  • d1a636b + 47cb788 — generalize the accept loop into one run_binding_loop per binding (plaintext + TLS uniform); fail-fast bind + bounded graceful drain
  • ca68aca — enforce access rules on L4 passthrough before tunneling
  • 382dc77 — enforce for the admin dashboard UI and proxied services
  • 5b9ead1 — end-to-end trust-boundary tests over real sockets
  • b728117 — networking-requirement docs + .gitignore hardening

Verification: 140/140 tests pass (unsandboxed), clippy -D warnings clean, fmt clean. Whole-branch review confirmed every enforcement point fails closed.

Non-blocking follow-ups spun out into #14, #15, #16. Deployment note: match_local_ip zone separation (LAN vs Tailscale) requires the router process to see real host interface IPs — see the comments added to docker-compose.yml / edge-router.example.toml in b728117.

## Shipped ✅ Implemented end-to-end and merged to `main`. Feature commit range: `be5b732..b728117` (21 commits on top of baseline `fca0b42`). **What shipped:** - **Bindings / listeners / access rules** — bindings are OS socket binds (TLS or plaintext); listeners are named trust zones (parent binding + optional `match_local_ip` CIDR, matched against the connection's *real* local address, never client-supplied); access rules are `{ via = "<listener>", allow_ips = [...] }`, OR'd together. - **Secure by default** — a service, admin entry, or L4 route with zero access rules is unreachable from anywhere. No grandfathering code (`ADD COLUMN access_rules TEXT NOT NULL DEFAULT '[]'`). - **Applies uniformly** to `[[services]]`, `[[admin]]`, and `l4_routes`. - **Fixes the live `CF-Connecting-IP` / `X-Real-IP` spoofing bug** — those headers are now only honored on a listener explicitly marked `trust_forwarded_for`, which is only sound because its binding (the dedicated Cloudflare-Tunnel port) is structurally unreachable by anything else. **Key commits:** - `96da35c` — migrate admin access control off `allowed_ips`; fix the `CF-Connecting-IP` trust bug - `d1a636b` + `47cb788` — generalize the accept loop into one `run_binding_loop` per binding (plaintext + TLS uniform); fail-fast bind + bounded graceful drain - `ca68aca` — enforce access rules on L4 passthrough before tunneling - `382dc77` — enforce for the admin dashboard UI and proxied services - `5b9ead1` — end-to-end trust-boundary tests over real sockets - `b728117` — networking-requirement docs + `.gitignore` hardening **Verification:** 140/140 tests pass (unsandboxed), `clippy -D warnings` clean, `fmt` clean. Whole-branch review confirmed every enforcement point fails closed. Non-blocking follow-ups spun out into #14, #15, #16. Deployment note: `match_local_ip` zone separation (LAN vs Tailscale) requires the router process to see real host interface IPs — see the comments added to `docker-compose.yml` / `edge-router.example.toml` in `b728117`.
Sign in to join this conversation.
No labels
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
IsoHex/edge-router#3
No description provided.