v0.26.0 proxy headers for acl
Test / test (push) Canceled after 0s
Test / e2e (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-10-05 01:24:02 -04:00
1 parent 4af072f4e5
commit ee01cb00fe
21 files changed
+1107 -290

No files matched your search

+19 -3
View File
@@ -10,8 +10,9 @@
### available in this version. TLS is available on every network source and ### available in this version. TLS is available on every network source and
### sink (the [...tls] blocks below), the [...auth] blocks authenticate peers by ### sink (the [...tls] blocks below), the [...auth] blocks authenticate peers by
### certificate (mtls) or password (scram; credentials via `lw auth`), and the ### certificate (mtls) or password (scram; credentials via `lw auth`), and the
### listeners' [...acl] blocks admit peers by address. Unknown keys anywhere in ### listeners' [...acl] blocks admit peers by address, or by the client an L4
### this file or in any plugin config are rejected at startup. ### proxy's PROXY header names. Unknown keys anywhere in this file or in any
### plugin config are rejected at startup.
### ###
### Environment overrides use the LOGWISP_ prefix ### Environment overrides use the LOGWISP_ prefix
### (LOGWISP_QUIET, LOGWISP_LOGGING_LEVEL, ...). Bare names are ignored. ### (LOGWISP_QUIET, LOGWISP_LOGGING_LEVEL, ...). Bare names are ignored.
@@ -163,8 +164,15 @@ name = "default"
### ###
### Applied as a connection is accepted, before TLS: a refused peer is closed ### Applied as a connection is accepted, before TLS: a refused peer is closed
### at once and counted in acl_denied, with a WARN at most once a minute. ### at once and counted in acl_denied, with a WARN at most once a minute.
### allow = [] addresses or CIDRs of the listener's family; empty = all ### allow = [] addresses or CIDRs of the listener's family (either behind
### proxy_from); empty = all
### deny = [] refused, listed in allow or not ### deny = [] refused, listed in allow or not
### proxy_protocol = "off" off | optional | required: PROXY v1/v2 headers,
### read before TLS; the rules then see the client.
### optional lets a headerless stream pass as the
### proxy: use it only while rolling headers out
### proxy_from = [] the proxies that may send one; a header from any
### other peer is refused
###============================================================================ ###============================================================================
###============================================================================ ###============================================================================
@@ -236,6 +244,8 @@ from = "end" # "end" or "start" of a newly discovered
# [pipelines.plugin_sources.config.acl] # refuse peers by address, before TLS # [pipelines.plugin_sources.config.acl] # refuse peers by address, before TLS
# allow = [] # empty = every address deny does not list # allow = [] # empty = every address deny does not list
# deny = [] # deny = []
# proxy_protocol = "off" # off | optional | required
# proxy_from = [] # proxies that may send a PROXY header
## HTTP chain source (stdlib listener; receives NDJSON batches from upstream http_chain sinks) ## HTTP chain source (stdlib listener; receives NDJSON batches from upstream http_chain sinks)
# [[pipelines.plugin_sources]] # [[pipelines.plugin_sources]]
@@ -266,6 +276,8 @@ from = "end" # "end" or "start" of a newly discovered
# [pipelines.plugin_sources.config.acl] # refuse peers by address, before TLS # [pipelines.plugin_sources.config.acl] # refuse peers by address, before TLS
# allow = [] # empty = every address deny does not list # allow = [] # empty = every address deny does not list
# deny = [] # deny = []
# proxy_protocol = "off" # off | optional | required
# proxy_from = [] # proxies that may send a PROXY header
###============================================================================ ###============================================================================
### Sinks (1+ required, fan-out) ### Sinks (1+ required, fan-out)
@@ -334,6 +346,8 @@ target = "stdout" # stdout|stderr ("split" NOT supported)
# [pipelines.plugin_sinks.config.acl] # refuse peers by address, before TLS # [pipelines.plugin_sinks.config.acl] # refuse peers by address, before TLS
# allow = [] # empty = every address deny does not list # allow = [] # empty = every address deny does not list
# deny = [] # deny = []
# proxy_protocol = "off" # off | optional | required
# proxy_from = [] # proxies that may send a PROXY header
## TCP sink (streaming server) ## TCP sink (streaming server)
# [[pipelines.plugin_sinks]] # [[pipelines.plugin_sinks]]
@@ -363,6 +377,8 @@ target = "stdout" # stdout|stderr ("split" NOT supported)
# [pipelines.plugin_sinks.config.acl] # refuse peers by address, before TLS # [pipelines.plugin_sinks.config.acl] # refuse peers by address, before TLS
# allow = [] # empty = every address deny does not list # allow = [] # empty = every address deny does not list
# deny = [] # deny = []
# proxy_protocol = "off" # off | optional | required
# proxy_from = [] # proxies that may send a PROXY header
## TCP chain sink (stdlib client; forwards to downstream tcp_chain source) ## TCP chain sink (stdlib client; forwards to downstream tcp_chain source)
## Do NOT point a chain sink at a chain source in the SAME pipeline: entries ## Do NOT point a chain sink at a chain source in the SAME pipeline: entries
+1 -1
View File
@@ -120,7 +120,7 @@ Not covered by this script; edit the generated configuration instead
- filters, rate limits, heartbeat, formatter flags and timestamp layouts - filters, rate limits, heartbeat, formatter flags and timestamp layouts
- more than one pipeline; both chain transports, or a further hop, on one node - more than one pipeline; both chain transports, or a further hop, on one node
- proxy mode: browser logins through a TLS-terminating proxy (trusted_proxies) - proxy mode: browser logins through a TLS-terminating proxy (trusted_proxies)
- acl address rules on the listeners - acl on the listeners: address rules, PROXY headers
- mtls identities other than the certificate CN; node_binding other than force - mtls identities other than the certificate CN; node_binding other than force
- creating certificates: openssl or your PKI (doc/security.md#enabling-mtls) - creating certificates: openssl or your PKI (doc/security.md#enabling-mtls)
- several native instances on one host: use one per host or jail - several native instances on one host: use one per host or jail
+2 -2
View File
@@ -30,8 +30,8 @@ streams, or downstream LogWisp nodes.
- [CLI](cli.md): flags, signals, exit codes, `lw auth`; also the `lw(1)` - [CLI](cli.md): flags, signals, exit codes, `lw auth`; also the `lw(1)`
manual, [`lw.1`](lw.1) manual, [`lw.1`](lw.1)
- [Operations](operations.md): running, monitoring, tuning, troubleshooting - [Operations](operations.md): running, monitoring, tuning, troubleshooting
- [To Do](todo.md): planned work in priority order: network access control - [To Do](todo.md): planned work in priority order: per-client limits,
and the PROXY protocol, packaging, smaller follow-ups packaging, smaller follow-ups
A fully annotated configuration covering every option lives at A fully annotated configuration covering every option lives at
[`config/logwisp.toml`](../config/logwisp.toml). [`config/logwisp.toml`](../config/logwisp.toml).
+3 -2
View File
@@ -167,8 +167,9 @@ TLS is built in exactly one place, `internal/tlsx`, which exposes
Each pipeline owns a `session.Manager`. Plugins receive a `session.Proxy` Each pipeline owns a `session.Manager`. Plugins receive a `session.Proxy`
scoped to their instance id, so one plugin cannot see or remove another's scoped to their instance id, so one plugin cannot see or remove another's
sessions. A session records the remote address, creation and last-activity sessions. A session records the remote address, creation and last-activity
timestamps, and metadata — including `tls` and `tls_peer_cn` for TLS peers, and timestamps, and metadata — including `tls` and `tls_peer_cn` for TLS peers,
`auth_method` / `auth_identity` for authorized ones. `auth_method` / `auth_identity` for authorized ones, and `peer_addr`, the proxy,
for a client a PROXY header names.
Idle sessions are reaped every 5 minutes against a 30-minute idle limit. The Idle sessions are reaped every 5 minutes against a 30-minute idle limit. The
HTTP sink's broker treats a vanished session as an eviction signal and closes HTTP sink's broker treats a vanished session as an eviction signal and closes
+1 -1
View File
@@ -184,7 +184,7 @@ every key.
- filters, rate limits, heartbeat, formatter flags and timestamp layouts - filters, rate limits, heartbeat, formatter flags and timestamp layouts
- more than one pipeline; both chain transports, or a further hop, on one node - more than one pipeline; both chain transports, or a further hop, on one node
- proxy mode: browser logins through a TLS-terminating proxy - proxy mode: browser logins through a TLS-terminating proxy
- `acl` address rules on the listeners - `acl` on the listeners: address rules, PROXY headers
- mtls identities other than the certificate CN; `node_binding` other than - mtls identities other than the certificate CN; `node_binding` other than
`force` `force`
- creating certificates - creating certificates
+4 -1
View File
@@ -312,4 +312,7 @@ or SKIP (exit 77, e.g. no IPv6 loopback); only a failure fails the target.
- `deploy-test.sh`: the configurations `deploy/lw-deploy.sh` writes, deployed - `deploy-test.sh`: the configurations `deploy/lw-deploy.sh` writes, deployed
and checked; ports 15871-15879 and checked; ports 15871-15879
- `acl-test.sh`: each listener admitting or refusing peers by address before - `acl-test.sh`: each listener admitting or refusing peers by address before
TLS, with clients from 127.0.0.1 and 127.0.0.2; ports 15881-15889 TLS, with clients from 127.0.0.1 and 127.0.0.2, then by the client a PROXY
header names behind a stub proxy on 127.0.0.11-13 (and nginx `stream` on
127.0.0.14 when installed), per-client login throttling included; ports
15881-15889; skips (exit 77) where 127.0.0.2 cannot be bound
+29 -22
View File
@@ -208,13 +208,22 @@ headers, and entry encoding.
- Check the port is not already bound by another pipeline in the same process. - Check the port is not already bound by another pipeline in the same process.
**Closed as soon as it connects, before TLS** **Closed as soon as it connects, before TLS**
- The listener's `acl` refused the address: `acl_denied` rises, and its log has - The listener's `acl` refused it: `acl_denied` rises, and its log has
`Connection refused by acl` at WARN, once a minute at most. The dialer sees `Connection refused by acl` at WARN with a `reason`, once a minute at most.
`EOF` or `connection reset by peer` (curl: exit 52 or 56). The dialer sees `EOF` or `connection reset by peer` (curl: exit 52 or 56).
- Behind a proxy (an L4 passthrough, or the `http` sink's - `address rules` — `allow` or `deny` excludes the client: the one a PROXY
`auth.trusted_proxies`) every peer has the proxy's address: allow the proxy. header names (`remote_addr`, the proxy as `peer_addr`), else the socket
peer. Behind the `http` sink's `auth.trusted_proxies` that is the proxy:
allow it.
- `no PROXY header` — a peer in `proxy_from` sent none under
`proxy_protocol = "required"`: the proxy lacks `proxy_protocol on` (nginx)
or `send-proxy` (HAProxy), or the proxy's host dialed directly.
- `PROXY header: …` — malformed, too long, or incomplete after 10 s.
- `PROXY header from a peer outside proxy_from` — at the first read, after
the connection opened: list the proxy in `proxy_from`.
- `acl: allow entry "...": not of the listener's family` at startup — an IPv4 - `acl: allow entry "...": not of the listener's family` at startup — an IPv4
entry on an IPv6 `host`, or the reverse: each family's listener takes its own. entry on an IPv6 `host`, or the reverse: each family's listener takes its own.
Behind `proxy_from`, `allow` and `deny` take both; `proxy_from` its own.
**TLS handshake failure** **TLS handshake failure**
- `client didn't provide a certificate` — the listener has `client_auth = true` - `client didn't provide a certificate` — the listener has `client_auth = true`
@@ -271,9 +280,9 @@ headers, and entry encoding.
`X-Forwarded-For`. `X-Forwarded-For`.
- `too many attempts` — the address failed or abandoned logins faster than one - `too many attempts` — the address failed or abandoned logins faster than one
per second beyond a burst of 10, or has 4 unfinished; it clears within seconds per second beyond a burst of 10, or has 4 unfinished; it clears within seconds
once the failing peer stops. Peers behind one NAT or one passthrough proxy once the failing peer stops. Peers behind one NAT, or one passthrough proxy
share the budget. `busy` — 4,096 logins in flight, or the listener is that sends no PROXY header, share the budget. `busy` — 4,096 logins in
stopping. flight, or the listener is stopping.
- `authentication not enabled` — the dialer has `scram`; the listener's - `authentication not enabled` — the dialer has `scram`; the listener's
`auth.type` is `none` or `mtls`. `auth.type` is `none` or `mtls`.
- `no challenge within 10s (older logwisp, or not a scram listener)` — the - `no challenge within 10s (older logwisp, or not a scram listener)` — the
@@ -301,20 +310,18 @@ headers, and entry encoding.
proxy mode, the proxy gate. proxy mode, the proxy gate.
**Behind an L4 proxy** **Behind an L4 proxy**
- Every connection through the proxy fails: it sends a PROXY header (nginx - Every connection through the proxy fails, the listener logging the proxy's
`stream` with `proxy_protocol on`, HAProxy `send-proxy`) to a LogWisp TLS address and `first record does not look like a TLS handshake` (the `tcp`
listener, which cannot read one yet. The header lands in front of the TLS sink at DEBUG): the proxy sends a PROXY header (nginx `stream` with
handshake, which fails; the listener logs the proxy's address and `proxy_protocol on`, HAProxy `send-proxy`) to a listener without
`first record does not look like a TLS handshake` (the `tcp` sink at DEBUG). `acl.proxy_protocol`. Set it, with the proxy in `proxy_from`: see
Route LogWisp without it: see [PROXY protocol](security.md#proxy-protocol).
[Behind nginx or another proxy](security.md#behind-nginx-or-another-proxy). - All peers share one throttling budget and logs name the proxy: it sends no
- All peers share one throttling budget behind a passthrough proxy: without PROXY header, so every `scram` peer arrives from its address. One client
PROXY every `scram` peer arrives from the proxy's address. One client failing failing logins exhausts everyone's, and a fifth login while 4 are unfinished
logins exhausts everyone's, and a fifth login while 4 are unfinished (edges (edges reconnecting together after a restart) is refused; both answer
reconnecting together after a restart) is refused; both answer `too many attempts` ([Throttling](security.md#throttling)). Have the proxy
`too many attempts` ([Throttling](security.md#throttling)). send the header.
Expose the LogWisp ports directly, or keep the proxy hop on a trusted
network so only trusted clients share the budget.
**Entries not arriving over a chain link** **Entries not arriving over a chain link**
- Check the sink's `connected` statistic and its `reconnects` count. - Check the sink's `connected` statistic and its `reconnects` count.
+2 -2
View File
@@ -374,8 +374,8 @@ and plaintext-forwarded requests `403`. The existing scripts keep passing.
## Not Implemented ## Not Implemented
1. **PROXY protocol behind TLS passthrough**: planned in None. The PROXY protocol behind TLS passthrough, deferred here, shipped with
[To Do, 1.2](todo.md#12-proxy-protocol-deferred-gap-of-scram-see-scram-auth-planmd). the ACL work: [Security](security.md#proxy-protocol).
## mTLS Hardening ## mTLS Hardening
+86 -64
View File
@@ -26,7 +26,8 @@ configure it, and — equally important — what it does not yet do.
[Browsers behind a TLS-terminating proxy](#browsers-behind-a-tls-terminating-proxy). [Browsers behind a TLS-terminating proxy](#browsers-behind-a-tls-terminating-proxy).
- Server pinning by dialers: certificate identity (`mtls`), bound certificate - Server pinning by dialers: certificate identity (`mtls`), bound certificate
(`scram`). (`scram`).
- Address rules on every listener, applied before TLS: see - Address rules on every listener, applied before TLS, to the client a PROXY
header (v1 or v2) from a listed L4 proxy names: see
[The ACL Block](#the-acl-block). [The ACL Block](#the-acl-block).
- Startup warnings for expiring certificates and risky settings: see - Startup warnings for expiring certificates and risky settings: see
[Startup Warnings](#startup-warnings). [Startup Warnings](#startup-warnings).
@@ -53,11 +54,8 @@ configure it, and — equally important — what it does not yet do.
or credentials file. or credentials file.
- Per-address connection or request limits: only SCRAM logins are throttled - Per-address connection or request limits: only SCRAM logins are throttled
per address. per address.
- Address rules for clients behind a proxy: `acl` sees the proxy, never the - Address rules for `X-Forwarded-For` clients: in the `http` sink's proxy mode
client a PROXY header or `X-Forwarded-For` names. `acl` sees the L7 proxy, never the client it forwards.
- PROXY protocol: behind a proxy that passes TLS through, every peer shares the
proxy's address; see
[Behind nginx or another proxy](#behind-nginx-or-another-proxy).
Two credentials are supported. Certificates (`mtls`) are the one the transport Two credentials are supported. Certificates (`mtls`) are the one the transport
already carries: the `tls` block establishes that a peer chains to your CA, and already carries: the `tls` block establishes that a peer chains to your CA, and
@@ -334,25 +332,63 @@ peer costs no handshake and never reaches `auth`.
[pipelines.plugin_sources.config.acl] [pipelines.plugin_sources.config.acl]
allow = ["192.0.2.0/24", "198.51.100.7"] # addresses or CIDRs; empty: all allow = ["192.0.2.0/24", "198.51.100.7"] # addresses or CIDRs; empty: all
deny = ["192.0.2.66"] # refused, listed in allow or not deny = ["192.0.2.66"] # refused, listed in allow or not
proxy_protocol = "required" # off (default), optional, required
proxy_from = ["10.0.0.5"] # L4 proxies sending PROXY headers
``` ```
- `deny` wins; then a non-empty `allow` admits only its entries, an empty one - `deny` wins; then a non-empty `allow` admits only its entries, an empty one
every address `deny` does not list. every address `deny` does not list.
- Entries are of the listener's [family](networking.md#address-family): IPv4 - Entries are of the listener's [family](networking.md#address-family): IPv4
on an IPv4 listener, IPv6 on an IPv6 one, either behind a hostname (only the on an IPv4 listener, IPv6 on an IPv6 one, either behind a hostname (only the
family it binds ever matches). An entry of the other family, an IPv4-mapped family it binds ever matches). Behind `proxy_from` they take either, as a
address, one with a zone, or anything but an address or CIDR fails header may name a client of either. An entry of the other family, an
construction. A link-local peer matches without its zone. IPv4-mapped address, one with a zone, or anything but an address or CIDR
fails construction. A link-local peer matches without its zone.
- A refused connection is closed at once and counted in `acl_denied` (plugin - A refused connection is closed at once and counted in `acl_denied` (plugin
stats, the `http` sink's status). A WARN names the peer and the count, at stats, the `http` sink's status). A WARN names the peer, the reason and the
most once a minute per listener; the refusals in between are only counted. count, at most once a minute per listener; the refusals in between are only
- The rules see the socket peer: behind a proxy (an L4 passthrough, or the counted.
`http` sink's `trusted_proxies`), the proxy (see - The rules see the client: the socket peer, or the client a PROXY header
[Behind nginx or another proxy](#behind-nginx-or-another-proxy)). names. Behind the `http` sink's `trusted_proxies` they see the L7 proxy.
- The `serve` and `aggregator` presets take `allow` and `deny` list keys. - The `serve` and `aggregator` presets take `allow` and `deny` list keys.
- Dialers have no `acl`: on a `tcp_chain` or `http_chain` sink it is an - Dialers have no `acl`: on a `tcp_chain` or `http_chain` sink it is an
unknown key. unknown key.
### PROXY protocol
Behind an L4 proxy that passes TLS through (nginx `stream`, HAProxy
`mode tcp`) every peer comes from the proxy's address. With `proxy_protocol`
the proxy names the client in a PROXY header, v1 (text) or v2 (binary), sent
before the TLS ClientHello; LogWisp still ends TLS, so channel binding and
client certificates are unchanged.
- `proxy_protocol` and `proxy_from` come together; `proxy_from` holds the
proxies' addresses or CIDRs, of the listener's family. A peer with a zone
(link-local IPv6) is never a proxy.
- A peer in `proxy_from`:
- sends its header within `tlsx.HandshakeTimeout` (10 s); a malformed,
oversized (v1 over 107 bytes, a v2 block over 2048) or late one is
refused;
- under `required` is refused without a header; under `optional` it keeps
its socket address, after up to 10 s if it sends nothing at first. A
client the proxy forwards without a header then passes as the proxy, so
`optional` (which warns at startup) suits only a rollout; set `required`
once every route from `proxy_from` sends the header;
- keeps its socket address for `LOCAL` (v2) and `UNKNOWN` (v1), the proxies'
health checks, and for v2 families other than TCP over IPv4 and IPv6. TLVs
are skipped.
- Headers are read off the accept loop: a slow proxy holds only its own
connection.
- A peer not in `proxy_from` connects as itself; one that sends a header is
refused at its first read, as any peer can write one.
- The connection then reports the client (an IPv4-mapped one as IPv4) to the
rules, SCRAM [throttling](#throttling), sessions and logs. Session metadata
keeps the proxy as `peer_addr`, and a refusal's WARN names it.
- `acl_proxy_headers` counts the headers read.
- The `http` sink's proxy mode is the L7 counterpart: `trusted_proxies` trusts
`X-Forwarded-For` from a proxy that ends TLS, `proxy_from` a PROXY header
from one that passes it through.
## Startup Warnings ## Startup Warnings
Some settings work but are usually mistakes. Each plugin reports them at WARN Some settings work but are usually mistakes. Each plugin reports them at WARN
@@ -370,7 +406,9 @@ when it is constructed, so startup and every reload repeat them:
(once per path per process) (once per path per process)
- an `acl` entry with host bits set (`10.0.0.1/8`), naming the network it - an `acl` entry with host bits set (`10.0.0.1/8`), naming the network it
matches; an `acl.allow` entry of a whole family (`0.0.0.0/0`, `::/0`); a matches; an `acl.allow` entry of a whole family (`0.0.0.0/0`, `::/0`); a
wildcard listener whose `acl` has `deny` but no `allow` wildcard listener whose `acl` has `deny` but no `allow`; an `acl.proxy_from`
entry reaching beyond private, loopback and link-local addresses;
`acl.proxy_protocol = "optional"`
## Certificates made at startup ## Certificates made at startup
@@ -584,9 +622,10 @@ exchange.
### Throttling ### Throttling
Logins are throttled per remote socket address or, on an `http` sink in proxy Logins are throttled per client (an IPv6 client per /64): the socket peer, the
mode, per forwarded client (an IPv6 client per /64); forwarded headers are read client a [PROXY header](#proxy-protocol) names, or on an `http` sink in proxy
only from `trusted_proxies`. Each exchange takes a token from a bucket of 10 mode the forwarded client; forwarded headers are read only from
`trusted_proxies`. Each exchange takes a token from a bucket of 10
that refills at one per second, and a successful login gives it back, so only that refills at one per second, and a successful login gives it back, so only
failed or abandoned attempts drain it. At most 4 exchanges per address may be failed or abandoned attempts drain it. At most 4 exchanges per address may be
unfinished: an HTTP challenge never answered holds its slot for up to 30 s, a unfinished: an HTTP challenge never answered holds its slot for up to 30 s, a
@@ -595,8 +634,8 @@ TCP connection that ends mid-exchange frees it at once. A refused start answers
65,536 addresses and refuses new ones when full. Separately, at most 4,096 65,536 addresses and refuses new ones when full. Separately, at most 4,096
exchanges may be in flight per listener; beyond that, or while the plugin exchanges may be in flight per listener; beyond that, or while the plugin
stops, a login gets `busy` (HTTP `503`) and counts `auth_busy`. Peers behind stops, a login gets `busy` (HTTP `503`) and counts `auth_busy`. Peers behind
one NAT or one passthrough proxy share a bucket: there, one client can exhaust one NAT, or one passthrough proxy that sends no PROXY header, share a bucket:
every other client's logins. there, one client can exhaust every other client's logins.
### Credentials file ### Credentials file
@@ -646,9 +685,9 @@ different users.
- TLS must terminate at LogWisp, except on an `http` sink in proxy mode. Behind - TLS must terminate at LogWisp, except on an `http` sink in proxy mode. Behind
any other terminating proxy or load balancer every login fails by design; any other terminating proxy or load balancer every login fails by design;
pass TLS through instead (TCP or SNI routing). All clients then share the pass TLS through instead (TCP or SNI routing), with a
proxy's address, and so one throttling budget: see [PROXY header](#proxy-protocol) so each client keeps its own throttling
[Behind nginx or another proxy](#behind-nginx-or-another-proxy). budget.
- One process per HTTP address. Handshake state and the token key live in one - One process per HTTP address. Handshake state and the token key live in one
instance, so behind a balancer the proof or the token can reach an instance instance, so behind a balancer the proof or the token can reach an instance
that never saw the login. that never saw the login.
@@ -756,7 +795,8 @@ A proxy in front of LogWisp works at one of two layers:
overwrite `X-Forwarded-For` with the real client: the rightmost hop that is overwrite `X-Forwarded-For` with the real client: the rightmost hop that is
not a proxy is the client either way. not a proxy is the client either way.
- **L4, passing TLS through to LogWisp's own TLS** (nginx `stream` with - **L4, passing TLS through to LogWisp's own TLS** (nginx `stream` with
`ssl_preread`, HAProxy `mode tcp`): whatever needs TLS to end at LogWisp. `ssl_preread`, HAProxy `mode tcp`), with a [PROXY header](#proxy-protocol):
whatever needs TLS to end at LogWisp.
- Chain links (`tcp_chain`, `http_chain`). - Chain links (`tcp_chain`, `http_chain`).
- `lw auth stream` viewers of a `tcp` sink. - `lw auth stream` viewers of a `tcp` sink.
- An `http` sink that keeps its own TLS, and with it SCRAM channel binding - An `http` sink that keeps its own TLS, and with it SCRAM channel binding
@@ -767,7 +807,7 @@ A common shape serves both from port 443:
``` ```
:443 nginx stream: ssl_preread routes by SNI, proxy_protocol on :443 nginx stream: ssl_preread routes by SNI, proxy_protocol on
logs.example.org -> 127.0.0.1:8443 nginx http logs.example.org -> 127.0.0.1:8443 nginx http
relay.example.org -> a LogWisp TLS listener, without PROXY (below) relay.example.org -> a LogWisp TLS listener, which reads the header (below)
127.0.0.1:8443 nginx http: listens with proxy_protocol, ends TLS 127.0.0.1:8443 nginx http: listens with proxy_protocol, ends TLS
/logs/ -> 127.0.0.1:8081 LogWisp http sink, proxy mode /logs/ -> 127.0.0.1:8081 LogWisp http sink, proxy mode
``` ```
@@ -796,52 +836,40 @@ client or, when it is trusted (both on `127.0.0.1`), the hop to its left: one
the client wrote, so the client picks its own throttling address. LogWisp the client wrote, so the client picks its own throttling address. LogWisp
ignores `X-Real-IP`. ignores `X-Real-IP`.
**LogWisp's own TLS listeners take no PROXY header.** LogWisp cannot read the **LogWisp's own TLS listeners read the header** when their `acl` sets
PROXY protocol yet; it is planned first in the ACL work `proxy_protocol` and lists the stream server in `proxy_from`; one stream
([To Do, 1.2](todo.md#12-proxy-protocol-deferred-gap-of-scram-see-scram-auth-planmd)). `server` with `proxy_protocol on` then routes every name:
A stream route that sends `proxy_protocol` straight to a LogWisp TLS listener
breaks every connection: the header lands in front of the TLS handshake. nginx
sets `proxy_protocol` per stream `server`, so route LogWisp SNIs without it:
- Route 1: LogWisp gets its own stream `server` and port, without
`proxy_protocol`.
- Route 2: port 443 sends LogWisp SNIs to an internal stream `server` that
`listen`s with `proxy_protocol` and proxy_passes to LogWisp without it; that
hop consumes the header.
- Either way dialers must send the name: a `host` that is one, or
`tls.server_name` (`--server-name` for `lw auth`); an IP literal sends no SNI.
- With HAProxy, leave `send-proxy` off the LogWisp backends.
```nginx ```nginx
stream { stream {
map $ssl_preread_server_name $route { map $ssl_preread_server_name $route {
relay.example.org 127.0.0.1:10443; # route 2: the internal hop
tail.example.org 127.0.0.1:10443;
default 127.0.0.1:8443; # the http block, logs.example.org
}
map $ssl_preread_server_name $logwisp {
relay.example.org 127.0.0.1:9001; # tcp_chain source relay.example.org 127.0.0.1:9001; # tcp_chain source
tail.example.org 127.0.0.1:9002; # tcp sink, for lw auth stream tail.example.org 127.0.0.1:9002; # tcp sink, for lw auth stream
default 127.0.0.1:8443; # the http block, logs.example.org
} }
server { server {
listen 443; listen 443;
ssl_preread on; ssl_preread on;
proxy_protocol on; # on every route of this server proxy_protocol on;
proxy_pass $route; proxy_pass $route;
} }
server { # route 1: its own port, no header
listen 9443;
ssl_preread on;
proxy_pass $logwisp;
}
server { # route 2: consumes the header
listen 127.0.0.1:10443 proxy_protocol;
ssl_preread on;
proxy_pass $logwisp;
}
} }
``` ```
```toml
[pipelines.plugin_sources.config.acl] # tcp_chain source; tcp sink alike
proxy_protocol = "required"
proxy_from = ["127.0.0.1"] # the stream server
```
- HAProxy: `send-proxy` or `send-proxy-v2` on the LogWisp backends.
- A listener without `proxy_protocol` behind such a route fails every
connection: the header lands in front of its TLS handshake.
- Dialers must send the name: a `host` that is one, or `tls.server_name`
(`--server-name` for `lw auth`); an IP literal sends no SNI.
- `test/acl-test.sh --auto` runs each listener kind behind a stub proxy
sending a v1 or v2 header, and the `tcp` sink behind nginx when installed.
**Passthrough without PROXY** brings every peer from the proxy's address: **Passthrough without PROXY** brings every peer from the proxy's address:
- All peers share one SCRAM [throttling](#throttling) budget: 10 failed or - All peers share one SCRAM [throttling](#throttling) budget: 10 failed or
@@ -853,14 +881,8 @@ stream {
(`too many attempts` or `busy`) keeps sending on its token to the server it (`too many attempts` or `busy`) keeps sending on its token to the server it
pinned, retrying every few seconds, and holds its batches only once that pinned, retrying every few seconds, and holds its batches only once that
token has expired; any other refusal ends the token at once. token has expired; any other refusal ends the token at once.
- Logs and sessions name the proxy, not the peer, and `acl` rules see only the - Logs and sessions name the proxy, and `acl` rules see only the proxy: allow
proxy: allow it, not the clients. it, not the clients.
- Proxy mode is unaffected: it throttles on the forwarded client.
- Until LogWisp reads PROXY, mitigate:
- expose the chain ports directly, without the proxy, where you can;
- keep the proxy hop on a trusted network, so only trusted clients share the
budget;
- or accept the shared budget.
## What Each Layer Enforces ## What Each Layer Enforces
+8 -5
View File
@@ -179,7 +179,8 @@ Options, each as type and default:
- `auth` (table): client authentication (`mtls` or `scram`); see - `auth` (table): client authentication (`mtls` or `scram`); see
[Security](security.md#the-auth-block). [Security](security.md#the-auth-block).
- `acl` (table): `allow` and `deny`, addresses or CIDRs admitted and refused - `acl` (table): `allow` and `deny`, addresses or CIDRs admitted and refused
before TLS; see [Security](security.md#the-acl-block). before TLS, and `proxy_protocol` with `proxy_from` for the client a PROXY
header names; see [Security](security.md#the-acl-block).
**Behaviour** **Behaviour**
@@ -251,8 +252,8 @@ buffer sizes, connection limit, write timeout, uptime, endpoint paths, and the
**Statistics**: `dropped_writes`, `rejected_clients`, `auth`, `auth_allowed`, **Statistics**: `dropped_writes`, `rejected_clients`, `auth`, `auth_allowed`,
`auth_rejected`; under `scram` also `auth_users`, `auth_throttled`, `auth_rejected`; under `scram` also `auth_users`, `auth_throttled`,
`auth_busy`, `auth_binding_mismatch` and `auth_token_lifetime_ms`, and in `auth_busy`, `auth_binding_mismatch` and `auth_token_lifetime_ms`, and in
proxy mode `auth_trusted_proxies`; with `acl` rules also `acl` and proxy mode `auth_trusted_proxies`; with `acl` rules or `proxy_from` also
`acl_denied`. `acl`, `acl_denied` and `acl_proxy_headers`.
> Without an `auth` block both endpoints are unauthenticated, and the stream > Without an `auth` block both endpoints are unauthenticated, and the stream
> response carries `Access-Control-Allow-Origin: *`, so any web origin can read > response carries `Access-Control-Allow-Origin: *`, so any web origin can read
@@ -306,7 +307,8 @@ Options, each as type and default:
- `auth` (table): client authentication (`mtls` or `scram`); see - `auth` (table): client authentication (`mtls` or `scram`); see
[Security](security.md#the-auth-block). [Security](security.md#the-auth-block).
- `acl` (table): `allow` and `deny`, addresses or CIDRs admitted and refused - `acl` (table): `allow` and `deny`, addresses or CIDRs admitted and refused
before TLS; see [Security](security.md#the-acl-block). before TLS, and `proxy_protocol` with `proxy_from` for the client a PROXY
header names; see [Security](security.md#the-acl-block).
**Behaviour** **Behaviour**
@@ -333,7 +335,8 @@ Options, each as type and default:
**Statistics**: `write_errors`, `dropped_writes`, `rejected_conns`, **Statistics**: `write_errors`, `dropped_writes`, `rejected_conns`,
`tls_handshake_errors`, `auth`, `auth_allowed`, `auth_rejected`; under `scram` `tls_handshake_errors`, `auth`, `auth_allowed`, `auth_rejected`; under `scram`
also `auth_users`, `auth_throttled`, `auth_busy` and `auth_binding_mismatch`; also `auth_users`, `auth_throttled`, `auth_busy` and `auth_binding_mismatch`;
with `acl` rules also `acl` and `acl_denied`. with `acl` rules or `proxy_from` also `acl`, `acl_denied` and
`acl_proxy_headers`.
--- ---
+11 -7
View File
@@ -215,7 +215,8 @@ Options, each as type and default:
- `auth` (table): peer authentication (`mtls` or `scram`) and node binding; - `auth` (table): peer authentication (`mtls` or `scram`) and node binding;
see [Security](security.md#the-auth-block). see [Security](security.md#the-auth-block).
- `acl` (table): `allow` and `deny`, addresses or CIDRs admitted and refused - `acl` (table): `allow` and `deny`, addresses or CIDRs admitted and refused
before TLS; see [Security](security.md#the-acl-block). before TLS, and `proxy_protocol` with `proxy_from` for the client a PROXY
header names; see [Security](security.md#the-acl-block).
For passwords instead of certificates, the `auth` block takes `type = "scram"` For passwords instead of certificates, the `auth` block takes `type = "scram"`
and a `credentials_file`, and `client_auth` becomes optional; see and a `credentials_file`, and `client_auth` becomes optional; see
@@ -240,8 +241,9 @@ and a `credentials_file`, and `client_auth` becomes optional; see
leaves per-entry labels to `trust_node`, so a relay can forward other nodes' leaves per-entry labels to `trust_node`, so a relay can forward other nodes'
entries while proving its own identity. entries while proving its own identity.
- Each accepted connection gets a session recording the remote address, node - Each accepted connection gets a session recording the remote address, node
label, — under TLS — `tls` and `tls_peer_cn`, and — under auth — label, — under TLS — `tls` and `tls_peer_cn`, — under auth —
`auth_method` and `auth_identity`. `auth_method` and `auth_identity`, and behind a PROXY header the proxy as
`peer_addr`.
- A malformed entry line increments `parse_errors` and is skipped; the - A malformed entry line increments `parse_errors` and is skipped; the
connection survives. A line over 1 MiB is a protocol violation and terminates connection survives. A line over 1 MiB is a protocol violation and terminates
the connection. the connection.
@@ -249,7 +251,8 @@ and a `credentials_file`, and `client_auth` becomes optional; see
**Statistics**: `active_connections`, `rejected_conns`, `parse_errors`, **Statistics**: `active_connections`, `rejected_conns`, `parse_errors`,
`tls_handshake_errors`, `trust_node`, `auth`, `auth_allowed`, `auth_rejected`, `tls_handshake_errors`, `trust_node`, `auth`, `auth_allowed`, `auth_rejected`,
`node_binding`; under `scram` also `auth_users`, `auth_throttled`, `auth_busy` `node_binding`; under `scram` also `auth_users`, `auth_throttled`, `auth_busy`
and `auth_binding_mismatch`; with `acl` rules also `acl` and `acl_denied`. and `auth_binding_mismatch`; with `acl` rules or `proxy_from` also `acl`,
`acl_denied` and `acl_proxy_headers`.
--- ---
@@ -301,7 +304,8 @@ Options, each as type and default:
- `auth` (table): peer authentication (`mtls` or `scram`) and node binding; - `auth` (table): peer authentication (`mtls` or `scram`) and node binding;
see [Security](security.md#the-auth-block). see [Security](security.md#the-auth-block).
- `acl` (table): `allow` and `deny`, addresses or CIDRs admitted and refused - `acl` (table): `allow` and `deny`, addresses or CIDRs admitted and refused
before TLS; see [Security](security.md#the-acl-block). before TLS, and `proxy_protocol` with `proxy_from` for the client a PROXY
header names; see [Security](security.md#the-acl-block).
**Behaviour** **Behaviour**
@@ -328,8 +332,8 @@ Options, each as type and default:
**Statistics**: `total_requests`, `rejected_requests`, `parse_errors`, **Statistics**: `total_requests`, `rejected_requests`, `parse_errors`,
`cached_sessions`, `trust_node`, `auth`, `auth_allowed`, `auth_rejected`, `cached_sessions`, `trust_node`, `auth`, `auth_allowed`, `auth_rejected`,
`node_binding`; under `scram` also `auth_users`, `auth_throttled`, `auth_busy`, `node_binding`; under `scram` also `auth_users`, `auth_throttled`, `auth_busy`,
`auth_binding_mismatch` and `auth_token_lifetime_ms`; with `acl` rules also `auth_binding_mismatch` and `auth_token_lifetime_ms`; with `acl` rules or
`acl` and `acl_denied`. `proxy_from` also `acl`, `acl_denied` and `acl_proxy_headers`.
--- ---
+18 -76
View File
@@ -7,98 +7,40 @@ they belong to.
## 1. Network access control (ACL) ## 1. Network access control (ACL)
The seam exists: `internal/netacl` wraps each listener's socket before TLS, The seam exists: `internal/netacl` wraps each listener's socket before TLS,
and the `acl` block's `allow` and `deny` refuse peers by address reads the PROXY header of the L4 proxies `proxy_from` lists, and the `acl`
block's `allow` and `deny` refuse the client by address
([Security](security.md#the-acl-block)). Every limit except SCRAM throttling ([Security](security.md#the-acl-block)). Every limit except SCRAM throttling
is still global, and behind an L4 proxy every peer is the proxy. is still global.
### 1.1 The filtering listener ### 1.1 Per-client limits
- `Accept` is to read an optional PROXY header (1.2) before the address rules
and apply per-address connection caps (1.4) after them, returning a
`net.Conn` whose `RemoteAddr` is the real client.
- Everything downstream then sees the real client with no further change:
`authz.remoteIP`, SCRAM throttling, sessions, logs and `http.Request.RemoteAddr`.
- Keep the proxy's own address as `peer_addr` in session metadata for audit.
### 1.2 PROXY protocol (deferred gap of SCRAM, see scram-auth-plan.md)
- Why: behind an L4 proxy that passes TLS through (nginx `stream` with
`ssl_preread`, HAProxy `mode tcp`), every peer arrives from the proxy's
address.
- All peers share one SCRAM throttling budget, so one client can lock out
every login.
- Logs, sessions and the ACL itself see only the proxy.
- What: accept PROXY v1 (text) and v2 (binary) headers, but only from
`proxy_from` addresses or CIDRs. Two config keys in the ACL block:
- `proxy_protocol = "off" | "optional" | "required"`, default off.
- `proxy_from = [...]`.
- Rules:
- A listed peer must send the header when `required`.
- An unlisted peer that sends a header is refused, since the header is
spoofable.
- The header must arrive within a short deadline (reuse
`tlsx.HandshakeTimeout`), with a size cap: v1 at most 107 bytes, v2 with a
bounded TLV length.
- `LOCAL`/`UNKNOWN` commands (proxy health checks) keep the socket address.
- TLVs are ignored.
- The header precedes the TLS ClientHello, so SCRAM channel binding is
unaffected: LogWisp still terminates TLS.
- nginx side:
- In the `stream` server, `proxy_protocol on` on the route to LogWisp (it
sends v1).
- HAProxy: `send-proxy` or `send-proxy-v2`.
- Relation to `auth.trusted_proxies` (HTTP proxy mode):
- That list trusts `X-Forwarded-For` from a TLS-terminating L7 proxy;
`proxy_from` trusts a PROXY header from an L4 proxy.
- Keep both, with distinct names and docs. They answer different layers, and
one deployment can use both: nginx `stream` with `proxy_protocol` into an
nginx `http` block that sets `X-Forwarded-For $proxy_protocol_addr`.
### 1.3 Address rules for forwarded clients
- Rules match the real client (after PROXY), never the proxy.
- In HTTP proxy mode, `X-Forwarded-For` is applied by `authz.ClientAddr` after
the connection-level decision, so rules for forwarded HTTP clients belong in
a second, request-level check there, reusing the compiled `netacl` rules.
`authz.parseProxies` then takes netacl's address-or-CIDR parsing too.
- Startup warning: `proxy_from` covering public ranges.
### 1.4 Per-client limits
- Generalize the SCRAM limiter in `internal/authz/scram.go` (token bucket per - Generalize the SCRAM limiter in `internal/authz/scram.go` (token bucket per
key, bounded table that fails closed, idle sweep, IPv6 per /64 via key, bounded table that fails closed, idle sweep, IPv6 per /64 via
`throttleKey`) into the shared per-key limiter. One mechanism (AGENTS.md): `throttleKey`) into the shared per-key limiter. One mechanism (AGENTS.md):
SCRAM throttling then uses it too. SCRAM throttling then uses it too.
- Keys: - Keys in the `acl` block:
- `max_connections_per_client` on every listener (concurrent connections per - `max_connections_per_client` on every listener (concurrent connections per
address). client), applied by the netacl listener after the rules.
- `requests_per_second_per_client` on HTTP listeners (ingest, stream and - `requests_per_second_per_client` on HTTP listeners (ingest, stream and
status requests). status requests).
- A full table refuses new clients and counts them in `acl_limited`, beside
`acl_denied` and `acl_proxy_headers`.
- Later: per-identity limits once a peer is authenticated (the mtls plan's - Later: per-identity limits once a peer is authenticated (the mtls plan's
deferred item 2), keyed by identity instead of address. deferred item 2), keyed by identity instead of address.
### 1.5 Config, validation, docs ### 1.2 Address rules for forwarded clients
- `proxy_protocol`, `proxy_from` and the 1.4 keys join `allow` and `deny` in - In HTTP proxy mode, `X-Forwarded-For` is applied by `authz.ClientAddr` after
the `acl` block. the connection-level decision, so rules for forwarded HTTP clients belong in
- Intent rule as in `authz`: `proxy_from` without `proxy_protocol` is an error. a second, request-level check there, reusing the compiled `netacl` rules.
- Stats: `acl_proxy_headers` and `acl_limited` beside `acl_denied`. `authz.parseProxies` then takes netacl's address-or-CIDR parsing too.
- Docs: the security.md section, plus networking.md troubleshooting lines for
each refusal.
### 1.6 Verification ### 1.3 Verification
- Go tests, one per rule: - Go tests, one per rule: the per-client connection cap; the request rate; the
- PROXY v1 and v2 parse, including malformed, oversized and slow headers. limiter table failing closed; a forwarded client refused by the rules.
- A header from an unlisted peer refused; `required` without a header - `test/acl-test.sh` adds per-client connection caps, behind its stub proxy
refused; `LOCAL` kept. too.
- Per-client connection cap; the limiter table failing closed.
- The real client reaching SCRAM throttling and session metadata.
- `test/acl-test.sh`, which covers the address rules, adds:
- A Go stub proxy that sends v1 and v2 headers in front of each listener
type.
- nginx `stream` with `proxy_protocol on` when nginx is installed.
- Per-client SCRAM budgets behind one proxy address.
## 2. Packaging: AUR, FreeBSD ports, Debian ## 2. Packaging: AUR, FreeBSD ports, Debian
+5 -2
View File
@@ -374,11 +374,14 @@ type AuthOptions struct {
} }
// ACLOptions is a listener's address rules, entries being addresses or CIDRs: // ACLOptions is a listener's address rules, entries being addresses or CIDRs:
// deny wins, then a set allow list admits only its entries. Validation is in // deny wins, then a set allow list admits only its entries. proxy_from lists
// internal/netacl. // the L4 proxies whose PROXY header names the client the rules then see.
// Validation is in internal/netacl.
type ACLOptions struct { type ACLOptions struct {
Allow []string `toml:"allow"` Allow []string `toml:"allow"`
Deny []string `toml:"deny"` Deny []string `toml:"deny"`
ProxyProtocol string `toml:"proxy_protocol"` // off, optional or required
ProxyFrom []string `toml:"proxy_from"`
} }
// --- TLS Options --- // --- TLS Options ---
+377 -46
View File
@@ -1,43 +1,87 @@
// Package netacl is the address seam of the network listeners, beside tlsx // Package netacl is the address seam of the network listeners, beside tlsx
// and authz: a Policy refuses a peer by its address as the socket is // and authz: a Policy reads the PROXY header of the proxies it trusts, then
// accepted, before TLS or a byte of the protocol is read. New returns nil // refuses a peer by its client's address, before TLS or a byte of the
// when no rule is set, and every method tolerates a nil receiver. // protocol is read. New returns nil when nothing is set, and every method
// tolerates a nil receiver.
package netacl package netacl
import ( import (
"bufio"
"bytes"
"context"
"encoding/binary"
"errors"
"fmt" "fmt"
"net" "net"
"net/netip" "net/netip"
"slices" "slices"
"strconv"
"strings" "strings"
"sync"
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/lixenwraith/logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/tlsx"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
) )
// warnEvery spaces the WARNs reporting refused peers; acl_denied counts all const (
const warnEvery = time.Minute // warnEvery spaces the WARNs reporting refused peers; acl_denied counts all
warnEvery = time.Minute
v1Max = 107 // the longest PROXY v1 line, CRLF included
v2Max = 2048 // the longest PROXY v2 address block, TLVs included
)
var (
sigV1 = []byte("PROXY ")
sigV2 = []byte("\r\n\r\n\x00\r\nQUIT\n")
// local is where a proxy_from entry lies without a startup warning
local = []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("172.16.0.0/12"),
netip.MustParsePrefix("192.168.0.0/16"), netip.MustParsePrefix("127.0.0.0/8"),
netip.MustParsePrefix("169.254.0.0/16"), netip.MustParsePrefix("fc00::/7"),
netip.MustParsePrefix("fe80::/10"), netip.MustParsePrefix("::1/128"),
}
errScreened = errors.New("PROXY header from a peer outside proxy_from")
// v2Families holds the family bytes v2 defines, each with the address
// bytes taken under PROXY: TCP over IPv4 and IPv6 name the client, the
// rest keep the socket address
v2Families = map[byte]int{0x00: 0, 0x11: 12, 0x12: 0, 0x21: 36, 0x22: 0, 0x31: 0, 0x32: 0}
)
// Policy is the compiled form of config.ACLOptions for one listener // Policy is the compiled form of config.ACLOptions for one listener
type Policy struct { type Policy struct {
allow, deny []netip.Prefix allow, deny, proxyFrom []netip.Prefix
mode string // proxy_protocol, when proxy_from is set
headerWait time.Duration // for a proxy's header: tlsx.HandshakeTimeout
logger *log.Logger logger *log.Logger
component, id string component, id string
denied atomic.Uint64 denied, headers atomic.Uint64
epoch time.Time // a monotonic base: a stepped clock neither mutes nor floods the WARN epoch time.Time // a monotonic base: a stepped clock neither mutes nor floods the WARN
nextWarn atomic.Int64 // nanoseconds since epoch nextWarn atomic.Int64 // nanoseconds since epoch
} }
// New compiles the rules of a listener on host and warns of what they leave // New compiles the rules of a listener on host and warns of what they leave
// open. Each entry must be of the family core.Network gives host: one the // open. Entries are of the family core.Network gives host, as one the sockets
// listener's sockets can never carry is a mistake, not a rule. // never carry is a mistake; behind proxy_from, allow and deny take both, as
// the proxy names clients of either.
func New(o *config.ACLOptions, host string, l *log.Logger, component, id string) (*Policy, error) { func New(o *config.ACLOptions, host string, l *log.Logger, component, id string) (*Policy, error) {
if o == nil || len(o.Allow)+len(o.Deny) == 0 { if o == nil {
return nil, nil
}
proxied := o.ProxyProtocol == "optional" || o.ProxyProtocol == "required"
switch {
case !proxied && o.ProxyProtocol != "" && o.ProxyProtocol != "off":
return nil, fmt.Errorf("acl: proxy_protocol %q is none of off, optional and required", o.ProxyProtocol)
case proxied && len(o.ProxyFrom) == 0:
return nil, fmt.Errorf("acl: proxy_protocol %s needs proxy_from, the proxies that may send the header", o.ProxyProtocol)
case !proxied && len(o.ProxyFrom) > 0:
return nil, errors.New("acl: proxy_from needs proxy_protocol optional or required")
case len(o.Allow)+len(o.Deny)+len(o.ProxyFrom) == 0:
return nil, nil return nil, nil
} }
network, err := core.Network(host) network, err := core.Network(host)
@@ -45,7 +89,7 @@ func New(o *config.ACLOptions, host string, l *log.Logger, component, id string)
return nil, err return nil, err
} }
var widened []string // entry, network pairs var widened []string // entry, network pairs
compile := func(key string, entries []string) ([]netip.Prefix, error) { compile := func(key, network string, entries []string) ([]netip.Prefix, error) {
var rules []netip.Prefix var rules []netip.Prefix
for _, e := range entries { for _, e := range entries {
prefix, err := parse(e, network) prefix, err := parse(e, network)
@@ -59,11 +103,17 @@ func New(o *config.ACLOptions, host string, l *log.Logger, component, id string)
} }
return rules, nil return rules, nil
} }
p := &Policy{logger: l, component: component, id: id, epoch: time.Now()} p := &Policy{mode: o.ProxyProtocol, headerWait: tlsx.HandshakeTimeout, logger: l, component: component, id: id, epoch: time.Now()}
if p.allow, err = compile("allow", o.Allow); err != nil { if p.proxyFrom, err = compile("proxy_from", network, o.ProxyFrom); err != nil {
return nil, err return nil, err
} }
if p.deny, err = compile("deny", o.Deny); err != nil { if proxied {
network = "tcp"
}
if p.allow, err = compile("allow", network, o.Allow); err != nil {
return nil, err
}
if p.deny, err = compile("deny", network, o.Deny); err != nil {
return nil, err return nil, err
} }
p.logStartup(host, widened) p.logStartup(host, widened)
@@ -106,7 +156,22 @@ func (p *Policy) logStartup(host string, widened []string) {
"hint", "list only the networks that may connect") "hint", "list only the networks that may connect")
} }
} }
if addr, err := netip.ParseAddr(host); len(p.allow) == 0 && (host == "" || err == nil && addr.IsUnspecified()) { if p.mode == "optional" {
p.logger.Warn("msg", "acl.proxy_protocol optional: a client a proxy_from peer forwards without a header passes as the proxy",
"component", p.component,
"instance_id", p.id,
"hint", "set required once every route from proxy_from sends the header")
}
for _, q := range p.proxyFrom {
if !slices.ContainsFunc(local, func(l netip.Prefix) bool { return l.Bits() <= q.Bits() && l.Contains(q.Addr()) }) {
p.logger.Warn("msg", "acl.proxy_from reaches public addresses, any of which may name any client",
"component", p.component,
"instance_id", p.id,
"entry", q.String(),
"hint", "list only the proxies' own addresses")
}
}
if addr, err := netip.ParseAddr(host); len(p.allow) == 0 && len(p.deny) > 0 && (host == "" || err == nil && addr.IsUnspecified()) {
p.logger.Warn("msg", "Listener on every address admits all that acl.deny does not list", p.logger.Warn("msg", "Listener on every address admits all that acl.deny does not list",
"component", p.component, "component", p.component,
"instance_id", p.id, "instance_id", p.id,
@@ -114,61 +179,323 @@ func (p *Policy) logStartup(host string, widened []string) {
} }
} }
// in reports whether addr lies in a rule, matched unmapped and without its
// zone: a zoned address is in no prefix
func in(rules []netip.Prefix, addr netip.Addr) bool {
addr = addr.Unmap().WithZone("")
return slices.ContainsFunc(rules, func(q netip.Prefix) bool { return q.Contains(addr) })
}
// admits applies the rules: deny wins, then a set allow list admits only its // admits applies the rules: deny wins, then a set allow list admits only its
// entries. A peer whose address is unknown is refused. // entries. A peer whose address is unknown is refused.
func (p *Policy) admits(addr netip.Addr) bool { func (p *Policy) admits(addr netip.Addr) bool {
addr = addr.Unmap().WithZone("") // a zoned address is in no prefix return addr.IsValid() && !in(p.deny, addr) && (len(p.allow) == 0 || in(p.allow, addr))
in := func(q netip.Prefix) bool { return q.Contains(addr) }
return addr.IsValid() && !slices.ContainsFunc(p.deny, in) &&
(len(p.allow) == 0 || slices.ContainsFunc(p.allow, in))
} }
// Listener returns ln refusing the peers the rules exclude as it accepts them, // proxies reports a peer in proxy_from. A zoned one never is: a link-local
// so the plugin never sees one. Close stays ln's: a sink's shutdown flush // address is any link's to claim.
// reaches it unchanged. A nil Policy returns ln. func (p *Policy) proxies(addr netip.Addr) bool {
addr = addr.Unmap()
return slices.ContainsFunc(p.proxyFrom, func(q netip.Prefix) bool { return q.Contains(addr) })
}
func addrOf(c net.Conn) netip.Addr {
if t, ok := c.RemoteAddr().(*net.TCPAddr); ok {
return t.AddrPort().Addr()
}
return netip.Addr{}
}
// Listener returns ln deciding each peer before Accept returns it. Headers are
// read off the accept loop, so a slow proxy holds only its own connection; the
// loop starts at once, so call this in Start. A nil Policy returns ln.
func (p *Policy) Listener(ln net.Listener) net.Listener { func (p *Policy) Listener(ln net.Listener) net.Listener {
if p == nil { if p == nil {
return ln return ln
} }
return &listener{Listener: ln, p: p} ctx, cancel := context.WithCancel(context.Background())
l := &listener{Listener: ln, p: p, accepted: make(chan accepted), ctx: ctx, cancel: cancel}
go l.run()
return l
} }
type listener struct { type listener struct {
net.Listener net.Listener
p *Policy p *Policy
accepted chan accepted
ctx context.Context // done at Close
cancel context.CancelFunc
}
type accepted struct {
conn net.Conn
err error
} }
func (l *listener) Accept() (net.Conn, error) { func (l *listener) Accept() (net.Conn, error) {
for { select {
conn, err := l.Listener.Accept() case a := <-l.accepted:
if err != nil { return a.conn, a.err
return nil, err case <-l.ctx.Done():
} return nil, net.ErrClosed
var addr netip.Addr
remote := conn.RemoteAddr()
if t, ok := remote.(*net.TCPAddr); ok {
addr = t.AddrPort().Addr()
}
if l.p.admits(addr) {
return conn, nil
}
conn.Close()
l.p.refused(fmt.Sprint(remote))
} }
} }
// refused counts a refusal and WARNs at most once per warnEvery // Close also closes the connections still sending a header
func (p *Policy) refused(remote string) { func (l *listener) Close() error {
n := p.denied.Add(1) l.cancel()
return l.Listener.Close()
}
func (l *listener) run() {
for {
c, err := l.Listener.Accept()
if err != nil {
if !l.deliver(nil, err) || errors.Is(err, net.ErrClosed) {
return
}
continue
}
switch {
case l.p.proxies(addrOf(c)):
go l.proxied(c)
case len(l.p.proxyFrom) > 0:
l.admit(&conn{Conn: c, r: bufio.NewReaderSize(c, v1Max), screen: l.p})
default:
l.admit(c)
}
}
}
// proxied reads the header of a peer in proxy_from, then admits the
// connection by the client it names
func (l *listener) proxied(c net.Conn) {
stop := context.AfterFunc(l.ctx, func() { c.Close() })
c.SetReadDeadline(time.Now().Add(l.p.headerWait))
br := bufio.NewReaderSize(c, v1Max)
client, found, err := readHeader(br)
c.SetReadDeadline(time.Time{})
if !stop() {
return // closed with the listener
}
pc := &conn{Conn: c, r: br}
switch {
case err != nil:
l.p.refuse(c, "PROXY header: "+err.Error())
return
case !found && l.p.mode == "required":
l.p.refuse(c, "no PROXY header")
return
case found:
l.p.headers.Add(1)
if client.IsValid() {
pc.client = net.TCPAddrFromAddrPort(client)
}
}
l.admit(pc)
}
// admit hands c to Accept if the rules admit the address it ends with
func (l *listener) admit(c net.Conn) {
if !l.p.admits(addrOf(c)) {
l.p.refuse(c, "address rules")
return
}
l.deliver(c, nil)
}
func (l *listener) deliver(c net.Conn, err error) bool {
select {
case l.accepted <- accepted{c, err}:
return true
case <-l.ctx.Done():
if c != nil {
c.Close()
}
return false
}
}
// refuse closes c, counts it and WARNs at most once per warnEvery
func (p *Policy) refuse(c net.Conn, reason string) {
n := p.denied.Add(1) // first: a peer that sees the close sees the count
c.Close()
now, next := int64(time.Since(p.epoch)), p.nextWarn.Load() now, next := int64(time.Since(p.epoch)), p.nextWarn.Load()
if now < next || !p.nextWarn.CompareAndSwap(next, now+int64(warnEvery)) { if now < next || !p.nextWarn.CompareAndSwap(next, now+int64(warnEvery)) {
return return
} }
p.logger.Warn("msg", "Connection refused by acl; more within a minute are only counted", fields := []any{"msg", "Connection refused by acl; more within a minute are only counted",
"component", p.component, "component", p.component,
"instance_id", p.id, "instance_id", p.id,
"remote_addr", remote, "remote_addr", fmt.Sprint(c.RemoteAddr()),
"acl_denied", n) "reason", reason,
"acl_denied", n}
if peer := PeerAddr(c); peer != "" {
fields = append(fields, "peer_addr", peer)
}
p.logger.Warn(fields...)
}
// conn is read through the buffer its first bytes went to: past a PROXY
// header, whose client it reports, or under a screen refusing one from a peer
// outside proxy_from at the first Read
type conn struct {
net.Conn
r *bufio.Reader
client net.Addr // nil keeps the socket's
screen *Policy
once sync.Once
err error
}
func (c *conn) Read(b []byte) (int, error) {
c.once.Do(func() {
if c.screen == nil {
return
}
if v, _ := signature(c.r); v != 0 {
c.err = errScreened
c.screen.refuse(c, errScreened.Error())
}
})
if c.err != nil {
return 0, c.err
}
return c.r.Read(b)
}
func (c *conn) RemoteAddr() net.Addr {
if c.client != nil {
return c.client
}
return c.Conn.RemoteAddr()
}
// PeerAddr is the proxy whose PROXY header named c's client, "" when c came
// direct; c may be the *tls.Conn over the listener's connection
func PeerAddr(c net.Conn) string {
if t, ok := c.(interface{ NetConn() net.Conn }); ok {
c = t.NetConn()
}
if pc, ok := c.(*conn); ok && pc.client != nil {
return pc.Conn.RemoteAddr().String()
}
return ""
}
type peerKey struct{}
// ConnContext, as an http.Server's, carries PeerAddr to the requests of a
// connection for ContextPeerAddr
func ConnContext(ctx context.Context, c net.Conn) context.Context {
if peer := PeerAddr(c); peer != "" {
return context.WithValue(ctx, peerKey{}, peer)
}
return ctx
}
func ContextPeerAddr(ctx context.Context) string {
peer, _ := ctx.Value(peerKey{}).(string)
return peer
}
// signature reads as far as tells whether a PROXY header starts the stream:
// its version, or 0 when other bytes or none come first
func signature(br *bufio.Reader) (int, error) {
for n := 1; ; n++ {
b, err := br.Peek(n)
switch {
case len(b) == 0 || !bytes.HasPrefix(sigV1, b) && !bytes.HasPrefix(sigV2, b):
return 0, nil
case bytes.Equal(b, sigV1):
return 1, nil
case bytes.Equal(b, sigV2):
return 2, nil
case err != nil:
return 0, fmt.Errorf("signature cut short: %w", err)
}
}
}
// readHeader reads the PROXY header starting br, if one does: found is false,
// with nothing consumed, when none does. The client is invalid for LOCAL,
// UNKNOWN and the families v2 leaves to the socket address.
func readHeader(br *bufio.Reader) (client netip.AddrPort, found bool, err error) {
v, err := signature(br)
switch v {
case 1:
client, err = readV1(br)
case 2:
client, err = readV2(br)
}
return client, v != 0 && err == nil, err
}
// readV1 reads "PROXY TCP4|TCP6 src dst sport dport\r\n" or "PROXY UNKNOWN...\r\n"
func readV1(br *bufio.Reader) (netip.AddrPort, error) {
line, err := br.ReadSlice('\n')
if errors.Is(err, bufio.ErrBufferFull) {
return netip.AddrPort{}, fmt.Errorf("v1 line over %d bytes", v1Max)
} else if err != nil {
return netip.AddrPort{}, fmt.Errorf("v1 line cut short: %w", err)
}
s, crlf := strings.CutSuffix(string(line), "\r\n")
f := strings.Split(s, " ")
switch {
case !crlf:
return netip.AddrPort{}, errors.New("v1 line not ended by CRLF")
case f[1] == "UNKNOWN":
return netip.AddrPort{}, nil
case len(f) != 6 || f[1] != "TCP4" && f[1] != "TCP6":
return netip.AddrPort{}, fmt.Errorf("malformed v1 line %q", s)
}
src, err1 := netip.ParseAddr(f[2])
dst, err2 := netip.ParseAddr(f[3])
port, err3 := strconv.ParseUint(f[4], 10, 16)
_, err4 := strconv.ParseUint(f[5], 10, 16)
four := f[1] == "TCP4"
if errors.Join(err1, err2, err3, err4) != nil || src.Zone()+dst.Zone() != "" || src.Is4() != four || dst.Is4() != four {
return netip.AddrPort{}, fmt.Errorf("malformed v1 line %q", s)
}
return netip.AddrPortFrom(src.Unmap(), uint16(port)), nil
}
// readV2 reads the 16-byte prefix and the block after it, taking the address
// of TCP over IPv4 or IPv6 and skipping the rest, TLVs included
func readV2(br *bufio.Reader) (netip.AddrPort, error) {
h, err := br.Peek(16)
if err != nil {
return netip.AddrPort{}, fmt.Errorf("v2 prefix cut short: %w", err)
}
cmd, fam, n := h[12], h[13], int(binary.BigEndian.Uint16(h[14:]))
need, known := v2Families[fam]
if cmd&0xf != 1 {
need, known = 0, true // LOCAL ignores the block
}
switch {
case cmd>>4 != 2 || cmd&0xf > 1:
return netip.AddrPort{}, fmt.Errorf("v2 version and command %#x", cmd)
case !known:
return netip.AddrPort{}, fmt.Errorf("v2 family %#x", fam)
case n > v2Max:
return netip.AddrPort{}, fmt.Errorf("v2 block of %d bytes over %d", n, v2Max)
case n < need:
return netip.AddrPort{}, fmt.Errorf("v2 block of %d bytes short for family %#x", n, fam)
}
br.Discard(16)
var client netip.AddrPort
if need > 0 {
b, err := br.Peek(need)
if err != nil {
return netip.AddrPort{}, fmt.Errorf("v2 block cut short: %w", err)
}
addr, _ := netip.AddrFromSlice(b[:(need-4)/2]) // source, destination, two ports
client = netip.AddrPortFrom(addr.Unmap(), binary.BigEndian.Uint16(b[need-4:]))
}
if _, err := br.Discard(n); err != nil {
return netip.AddrPort{}, fmt.Errorf("v2 block cut short: %w", err)
}
return client, nil
} }
// Describe renders the policy for log lines, stats and the status endpoint // Describe renders the policy for log lines, stats and the status endpoint
@@ -180,7 +507,11 @@ func (p *Policy) Describe() string {
if len(p.allow) > 0 { if len(p.allow) > 0 {
allow = fmt.Sprint(len(p.allow)) allow = fmt.Sprint(len(p.allow))
} }
return fmt.Sprintf("allow=%s deny=%d", allow, len(p.deny)) s := fmt.Sprintf("allow=%s deny=%d", allow, len(p.deny))
if len(p.proxyFrom) > 0 {
s += fmt.Sprintf(" proxy_protocol=%s proxy_from=%d", p.mode, len(p.proxyFrom))
}
return s
} }
func (p *Policy) Denied() uint64 { func (p *Policy) Denied() uint64 {
@@ -196,5 +527,5 @@ func (p *Policy) Stats() map[string]any {
if p == nil { if p == nil {
return nil return nil
} }
return map[string]any{"acl": p.Describe(), "acl_denied": p.denied.Load()} return map[string]any{"acl": p.Describe(), "acl_denied": p.denied.Load(), "acl_proxy_headers": p.headers.Load()}
} }
+329 -44
View File
@@ -1,10 +1,16 @@
package netacl package netacl
import ( import (
"bufio"
"bytes"
"crypto/tls"
"errors"
"io" "io"
"net" "net"
"net/netip" "net/netip"
"slices"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
@@ -13,9 +19,9 @@ import (
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
) )
func mustNew(t *testing.T, host string, allow, deny []string) *Policy { func mustNew(t *testing.T, host string, o config.ACLOptions) *Policy {
t.Helper() t.Helper()
p, err := New(&config.ACLOptions{Allow: allow, Deny: deny}, host, log.NewLogger(), "test", "acl") p, err := New(&o, host, log.NewLogger(), "test", "acl")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -26,8 +32,8 @@ func mustNew(t *testing.T, host string, allow, deny []string) *Policy {
// one admits all not denied, an IPv4-mapped peer matches as IPv4, and a peer // one admits all not denied, an IPv4-mapped peer matches as IPv4, and a peer
// of unknown address is refused. // of unknown address is refused.
func TestDenyWinsThenAllowListAdmitsOnlyItsEntries(t *testing.T) { func TestDenyWinsThenAllowListAdmitsOnlyItsEntries(t *testing.T) {
listed := mustNew(t, "0.0.0.0", []string{"127.0.0.0/8"}, []string{"127.0.0.2"}) listed := mustNew(t, "0.0.0.0", config.ACLOptions{Allow: []string{"127.0.0.0/8"}, Deny: []string{"127.0.0.2"}})
denyOnly := mustNew(t, "0.0.0.0", nil, []string{"10.0.0.0/8"}) denyOnly := mustNew(t, "0.0.0.0", config.ACLOptions{Deny: []string{"10.0.0.0/8"}})
for _, c := range []struct { for _, c := range []struct {
p *Policy p *Policy
addr string addr string
@@ -50,68 +56,351 @@ func TestDenyWinsThenAllowListAdmitsOnlyItsEntries(t *testing.T) {
// Entries are of the family core.Network gives the listener's host, as its // Entries are of the family core.Network gives the listener's host, as its
// sockets carry no other: IPv4 on an IPv4 listener, IPv6 on an IPv6 one, // sockets carry no other: IPv4 on an IPv4 listener, IPv6 on an IPv6 one,
// either behind a hostname; never IPv4-mapped, never with a zone. // either behind a hostname; never IPv4-mapped, never with a zone. Behind
// proxy_from, which keeps that family, deny takes both: the proxy names either.
func TestEntriesFollowTheListenerFamily(t *testing.T) { func TestEntriesFollowTheListenerFamily(t *testing.T) {
for _, c := range []struct { for _, c := range []struct {
host, entry string host, from, entry string
ok bool ok bool
}{ }{
{"0.0.0.0", "10.0.0.0/8", true}, {"0.0.0.0", "", "10.0.0.0/8", true},
{"0.0.0.0", "2001:db8::/32", false}, {"0.0.0.0", "", "2001:db8::/32", false},
{"::", "2001:db8::1", true}, {"::", "", "2001:db8::1", true},
{"::", "10.0.0.1", false}, {"::", "", "10.0.0.1", false},
{"::", "::ffff:10.0.0.1", false}, {"::", "", "::ffff:10.0.0.1", false},
{"::", "fe80::1%eth0", false}, {"::", "", "fe80::1%eth0", false},
{"logs.example", "10.0.0.0/8", true}, {"logs.example", "", "10.0.0.0/8", true},
{"logs.example", "2001:db8::/32", true}, {"logs.example", "", "2001:db8::/32", true},
{"0.0.0.0", "10.0.0.5", "2001:db8::/32", true},
{"0.0.0.0", "2001:db8::5", "10.0.0.0/8", false},
} { } {
_, err := New(&config.ACLOptions{Deny: []string{c.entry}}, c.host, log.NewLogger(), "test", "acl") o := &config.ACLOptions{Deny: []string{c.entry}}
if (err == nil) != c.ok { if c.from != "" {
t.Errorf("%s on %q: %v", c.entry, c.host, err) o.ProxyProtocol, o.ProxyFrom = "required", []string{c.from}
}
if _, err := New(o, c.host, log.NewLogger(), "test", "acl"); (err == nil) != c.ok {
t.Errorf("%s behind %q on %q: %v", c.entry, c.from, c.host, err)
} }
} }
} }
// A link-local peer carries its interface zone, which no prefix contains: // A link-local peer carries its interface zone, which no prefix contains: the
// it is matched without it, or a deny of fe80::/10 would admit it. // rules match it without, or a deny of fe80::/10 would admit it. It is never
func TestZonedPeerMatchesItsPrefix(t *testing.T) { // a proxy, as any link can claim its address.
p := mustNew(t, "::", nil, []string{"fe80::/10"}) func TestZonedPeerMatchesRulesButIsNoProxy(t *testing.T) {
if p.admits(netip.MustParseAddr("fe80::1%eth0")) { p := mustNew(t, "::", config.ACLOptions{Deny: []string{"fe80::/10"}, ProxyProtocol: "required", ProxyFrom: []string{"fe80::/10"}})
t.Fatal("a zoned link-local peer passed the deny of its prefix") if zoned := netip.MustParseAddr("fe80::1%eth0"); p.admits(zoned) || p.proxies(zoned) {
t.Fatal("a zoned link-local peer passed the deny of its prefix, or as a proxy")
} }
} }
// A refused peer is closed as it is accepted and counted: the plugin's // A refused peer is closed as it is accepted and counted: the plugin's
// Accept returns the next admitted one. // Accept returns the next admitted one.
func TestRefusedPeerNeverReachesAccept(t *testing.T) { func TestRefusedPeerNeverReachesAccept(t *testing.T) {
inner := &fakeListener{conns: make(chan net.Conn, 2)} p := mustNew(t, "127.0.0.1", config.ACLOptions{Deny: []string{"192.0.2.0/24"}})
refused, refusedPeer := net.Pipe() ln, dial := listening(t, p)
admitted, _ := net.Pipe() refused := dial("192.0.2.1")
inner.conns <- remoteAt(refused, "192.0.2.1") dial("127.0.0.1")
inner.conns <- remoteAt(admitted, "127.0.0.1") if got := accept(t, ln).RemoteAddr().String(); !strings.HasPrefix(got, "127.0.0.1:") {
ln := mustNew(t, "127.0.0.1", nil, []string{"192.0.2.0/24"}).Listener(inner)
conn, err := ln.Accept()
if err != nil {
t.Fatal(err)
}
if got := conn.RemoteAddr().String(); !strings.HasPrefix(got, "127.0.0.1:") {
t.Fatalf("Accept returned %s", got) t.Fatalf("Accept returned %s", got)
} }
refusedPeer.SetReadDeadline(time.Now().Add(time.Second)) closed(t, refused)
if _, err := refusedPeer.Read(make([]byte, 1)); err != io.EOF { if n := p.Stats()["acl_denied"]; n != uint64(1) {
t.Fatalf("the refused peer reads %v, want EOF", err)
}
if n := ln.(*listener).p.Stats()["acl_denied"]; n != uint64(1) {
t.Fatalf("acl_denied = %v", n) t.Fatalf("acl_denied = %v", n)
} }
} }
// proxy_protocol and proxy_from come together: either alone is a mistake, as
// is a mode that is none of off, optional and required
func TestProxyProtocolAndProxyFromComeTogether(t *testing.T) {
for _, c := range []struct {
mode string
from []string
ok bool
}{
{"required", nil, false},
{"", []string{"127.0.0.1"}, false},
{"off", []string{"127.0.0.1"}, false},
{"always", nil, false},
{"optional", []string{"127.0.0.1"}, true},
} {
_, err := New(&config.ACLOptions{ProxyProtocol: c.mode, ProxyFrom: c.from}, "127.0.0.1", log.NewLogger(), "test", "acl")
if (err == nil) != c.ok {
t.Errorf("proxy_protocol %q with proxy_from %v: %v", c.mode, c.from, err)
}
}
}
// A v1 or v2 header names the client, IPv4-mapped as IPv4 (as throttling
// keys it), and leaves the bytes after it, TLVs skipped.
func TestHeaderNamesItsClient(t *testing.T) {
for _, c := range []struct{ header, client string }{
{"PROXY TCP4 198.51.100.1 192.0.2.1 40000 443\r\n", "198.51.100.1:40000"},
{"PROXY TCP6 2001:db8::1 2001:db8::2 40000 443\r\n", "[2001:db8::1]:40000"},
{"PROXY TCP6 ::ffff:198.51.100.1 ::ffff:192.0.2.1 40000 443\r\n", "198.51.100.1:40000"},
{v2(0x21, 0x11, block("198.51.100.1", "192.0.2.1")), "198.51.100.1:40000"},
{v2(0x21, 0x21, block("2001:db8::1", "2001:db8::2", 0x04, 0x00, 0x01, 0xff)), "[2001:db8::1]:40000"},
{v2(0x21, 0x21, block("::ffff:198.51.100.1", "::ffff:192.0.2.1")), "198.51.100.1:40000"},
} {
client, found, rest, err := parseHeader(c.header + "rest")
if err != nil || !found || client.String() != c.client || rest != "rest" {
t.Errorf("%q: client %v found %v rest %q: %v", c.header, client, found, rest, err)
}
}
}
// LOCAL, UNKNOWN and the families v2 leaves unspecified keep the socket
// address, whatever address block they carry
func TestLocalAndUnknownKeepTheSocketAddress(t *testing.T) {
for _, header := range []string{
"PROXY UNKNOWN\r\n",
"PROXY UNKNOWN " + strings.Repeat("x", 91) + "\r\n", // 107 bytes
v2(0x20, 0x11, block("198.51.100.1", "192.0.2.1")),
v2(0x20, 0x00, nil),
v2(0x21, 0x00, nil),
v2(0x21, 0x12, block("198.51.100.1", "192.0.2.1")),
v2(0x21, 0x31, make([]byte, 216)),
} {
client, found, rest, err := parseHeader(header + "rest")
if err != nil || !found || client.IsValid() || rest != "rest" {
t.Errorf("%q: client %v found %v rest %q: %v", header, client, found, rest, err)
}
}
}
// A malformed, oversized or truncated header is an error, never a client
func TestMalformedHeaderIsRefused(t *testing.T) {
inet := v2(0x21, 0x11, block("198.51.100.1", "192.0.2.1"))
for _, header := range []string{
"PROXY UNKNOWN 198.51.100.1 192.0.2.1 40000 443\n",
"PROXY UNKNOWN " + strings.Repeat("x", 92) + "\r\n",
"PROXY TCP4 2001:db8::1 192.0.2.1 40000 443\r\n",
"PROXY TCP4 198.51.100.1 2001:db8::2 40000 443\r\n",
"PROXY TCP6 198.51.100.1 192.0.2.1 40000 443\r\n",
"PROXY TCP4 198.51.100.1 192.0.2.1 70000 443\r\n",
"PROXY TCP4 198.51.100.1 192.0.2.1 40000\r\n",
"PROXY TCP6 fe80::1%eth0 fe80::2 40000 443\r\n",
"PROXY UDP4 198.51.100.1 192.0.2.1 40000 443\r\n",
"PROXY TCP4 198.51.100.1 192.0.2.1 40000 443",
"PROX",
v2(0x11, 0x11, block("198.51.100.1", "192.0.2.1")),
v2(0x22, 0x11, block("198.51.100.1", "192.0.2.1")),
v2(0x21, 0x41, block("198.51.100.1", "192.0.2.1")),
v2(0x21, 0x11, make([]byte, v2Max+1)),
v2(0x21, 0x11, block("198.51.100.1", "192.0.2.1")[:8]) + "rest",
inet[:14],
inet[:20],
} {
if client, found, _, err := parseHeader(header); err == nil || found {
t.Errorf("%q: client %v found %v, no error", header, client, found)
}
}
}
// A peer outside proxy_from that sends a header is refused at its first
// read, as the header is spoofable; its other bytes pass untouched
func TestHeaderFromUnlistedPeerIsRefused(t *testing.T) {
p := mustNew(t, "127.0.0.1", config.ACLOptions{ProxyProtocol: "optional", ProxyFrom: []string{"10.0.0.1"}})
ln, dial := listening(t, p)
for _, c := range []struct {
send string
refused bool
}{
{"PROXY TCP4 198.51.100.1 192.0.2.1 40000 443\r\n", true},
{v2(0x21, 0x11, block("198.51.100.1", "192.0.2.1")), true},
{"POST / HTTP/1.1\r\n", false},
} {
go dial("192.0.2.9").Write([]byte(c.send))
b := make([]byte, len(c.send))
_, err := io.ReadFull(accept(t, ln), b)
if (err != nil) != c.refused || !c.refused && string(b) != c.send {
t.Errorf("%q from an unlisted peer: read %q, %v", c.send, b, err)
}
}
if n := p.Denied(); n != 2 {
t.Fatalf("acl_denied = %d", n)
}
}
// Under required a peer in proxy_from must send a header; under optional
// one that sends none keeps its socket address and its bytes
func TestRequiredRefusesAListedPeerWithoutHeader(t *testing.T) {
for mode, refused := range map[string]bool{"required": true, "optional": false} {
p := mustNew(t, "127.0.0.1", config.ACLOptions{ProxyProtocol: mode, ProxyFrom: []string{"127.0.0.1"}})
ln, dial := listening(t, p)
peer := dial("127.0.0.1")
peer.Write([]byte("GET /"))
if refused {
closed(t, peer)
continue
}
conn := accept(t, ln)
b := make([]byte, 5)
if _, err := io.ReadFull(conn, b); err != nil || string(b) != "GET /" || conn.RemoteAddr().String() != "127.0.0.1:40000" {
t.Errorf("optional: %s read %q: %v", conn.RemoteAddr(), b, err)
}
}
}
// The client a header names is what the rules, RemoteAddr and the stats see;
// the proxy stays as PeerAddr, under TLS too
func TestRulesSeeTheClientAHeaderNames(t *testing.T) {
p := mustNew(t, "127.0.0.1", config.ACLOptions{ProxyProtocol: "required", ProxyFrom: []string{"127.0.0.1"}, Deny: []string{"198.51.100.2"}})
ln, dial := listening(t, p)
denied := dial("127.0.0.1")
denied.Write([]byte("PROXY TCP4 198.51.100.2 192.0.2.1 40000 443\r\n"))
go dial("127.0.0.1").Write([]byte("PROXY TCP4 198.51.100.1 192.0.2.1 40000 443\r\nhello"))
conn := accept(t, ln)
b := make([]byte, 5)
if _, err := io.ReadFull(conn, b); err != nil || string(b) != "hello" {
t.Fatalf("read %q: %v", b, err)
}
if got, peer := conn.RemoteAddr().String(), PeerAddr(tls.Server(conn, nil)); got != "198.51.100.1:40000" || peer != "127.0.0.1:40000" {
t.Fatalf("RemoteAddr %s, PeerAddr %q", got, peer)
}
closed(t, denied)
if s := p.Stats(); s["acl_proxy_headers"] != uint64(2) || s["acl_denied"] != uint64(1) {
t.Fatalf("stats %v", s)
}
}
// A header still on its way holds only its own connection, and is refused
// at the deadline
func TestSlowHeaderHoldsOnlyItsConnection(t *testing.T) {
p := mustNew(t, "127.0.0.1", config.ACLOptions{ProxyProtocol: "required", ProxyFrom: []string{"127.0.0.1"}})
p.headerWait = time.Second
ln, dial := listening(t, p)
slow := dial("127.0.0.1")
slow.Write([]byte("PROXY TCP4 198"))
dial("192.0.2.9")
if got := accept(t, ln).RemoteAddr().String(); !strings.HasPrefix(got, "192.0.2.9:") || p.Denied() != 0 {
t.Fatalf("Accept returned %s after %d refusals", got, p.Denied())
}
closed(t, slow)
if n := p.Denied(); n != 1 {
t.Fatalf("acl_denied = %d", n)
}
}
// Close also closes the connections whose header has not arrived
func TestCloseReleasesHeadersInFlight(t *testing.T) {
p := mustNew(t, "127.0.0.1", config.ACLOptions{ProxyProtocol: "required", ProxyFrom: []string{"127.0.0.1"}})
ln, dial := listening(t, p)
slow := dial("127.0.0.1")
slow.Write([]byte("PROXY "))
ln.Close()
closed(t, slow)
if _, err := ln.Accept(); !errors.Is(err, net.ErrClosed) {
t.Fatalf("Accept after Close: %v", err)
}
}
// Whatever the bytes, the parser neither panics nor reads past a header's
// bound, consumes nothing when no header starts the stream, and names a
// client only unmapped and without a zone.
func FuzzReadHeader(f *testing.F) {
for _, seed := range []string{
"PROXY TCP4 198.51.100.1 192.0.2.1 40000 443\r\nrest",
"PROXY TCP6 2001:db8::1 2001:db8::2 40000 443\r\n",
"PROXY UNKNOWN\r\n",
v2(0x21, 0x11, block("198.51.100.1", "192.0.2.1")),
v2(0x21, 0x21, block("2001:db8::1", "2001:db8::2", 0x04, 0x00, 0x01, 0xff)),
v2(0x20, 0x00, nil),
"GET / HTTP/1.1\r\n",
} {
f.Add([]byte(seed))
}
f.Fuzz(func(t *testing.T, data []byte) {
r := bytes.NewReader(data)
br := bufio.NewReaderSize(r, v1Max)
client, found, err := readHeader(br)
consumed := len(data) - br.Buffered() - r.Len()
a := client.Addr()
switch {
case consumed > 16+v2Max:
t.Fatalf("consumed %d bytes", consumed)
case err == nil && !found && consumed != 0:
t.Fatalf("consumed %d bytes with no header", consumed)
case client.IsValid() && (!found || a.Is4In6() || a.Zone() != ""):
t.Fatalf("client %v, found %v", client, found)
}
})
}
func parseHeader(s string) (client netip.AddrPort, found bool, rest string, err error) {
br := bufio.NewReaderSize(strings.NewReader(s), v1Max)
client, found, err = readHeader(br)
b, _ := io.ReadAll(br)
return client, found, string(b), err
}
// v2 is a PROXY v2 header: version and command, family, then the block
func v2(cmd, fam byte, block []byte) string {
h := append(slices.Clone(sigV2), cmd, fam, byte(len(block)>>8), byte(len(block)))
return string(append(h, block...))
}
// block is a v2 address block from src:40000 to dst:443, then tlv
func block(src, dst string, tlv ...byte) []byte {
b := append(netip.MustParseAddr(src).AsSlice(), netip.MustParseAddr(dst).AsSlice()...)
return append(append(b, 0x9c, 0x40, 0x01, 0xbb), tlv...)
}
// listening runs p's listener over a fake one; dial connects a peer from
// addr:40000 and returns its end
func listening(t *testing.T, p *Policy) (net.Listener, func(addr string) net.Conn) {
inner := &fakeListener{conns: make(chan net.Conn, 4)}
ln := p.Listener(inner)
t.Cleanup(func() { ln.Close() })
return ln, func(addr string) net.Conn {
server, client := net.Pipe()
t.Cleanup(func() { client.Close() })
inner.conns <- remoteConn{server, net.TCPAddrFromAddrPort(netip.AddrPortFrom(netip.MustParseAddr(addr), 40000))}
return client
}
}
func accept(t *testing.T, ln net.Listener) net.Conn {
t.Helper()
got := make(chan net.Conn, 1)
go func() { c, _ := ln.Accept(); got <- c }()
select {
case c := <-got:
if c == nil {
t.Fatal("Accept failed")
}
t.Cleanup(func() { c.Close() })
c.SetReadDeadline(time.Now().Add(5 * time.Second))
return c
case <-time.After(5 * time.Second):
t.Fatal("Accept returned nothing")
}
return nil
}
// closed waits for the listener to close peer's connection
func closed(t *testing.T, peer net.Conn) {
t.Helper()
peer.SetReadDeadline(time.Now().Add(5 * time.Second))
if _, err := peer.Read(make([]byte, 1)); err != io.EOF {
t.Fatalf("the peer reads %v, want EOF", err)
}
}
type fakeListener struct { type fakeListener struct {
net.Listener net.Listener
conns chan net.Conn conns chan net.Conn
once sync.Once
} }
func (f *fakeListener) Accept() (net.Conn, error) { return <-f.conns, nil } func (f *fakeListener) Accept() (net.Conn, error) {
if c, ok := <-f.conns; ok {
return c, nil
}
return nil, net.ErrClosed
}
func (f *fakeListener) Close() error { f.once.Do(func() { close(f.conns) }); return nil }
type remoteConn struct { type remoteConn struct {
net.Conn net.Conn
@@ -119,7 +408,3 @@ type remoteConn struct {
} }
func (c remoteConn) RemoteAddr() net.Addr { return c.remote } func (c remoteConn) RemoteAddr() net.Addr { return c.remote }
func remoteAt(c net.Conn, addr string) net.Conn {
return remoteConn{c, net.TCPAddrFromAddrPort(netip.AddrPortFrom(netip.MustParseAddr(addr), 40000))}
}
+4
View File
@@ -280,6 +280,7 @@ func (h *HTTPSink) serve(ctx context.Context, ln net.Listener) error {
h.server = &http.Server{ h.server = &http.Server{
Handler: handler, Handler: handler,
ReadHeaderTimeout: HTTPReadHeaderTimeout, ReadHeaderTimeout: HTTPReadHeaderTimeout,
ConnContext: netacl.ConnContext,
// WriteTimeout unset by design: SSE responses are long-lived. // WriteTimeout unset by design: SSE responses are long-lived.
// net/http bounds the TLS handshake by min(ReadHeaderTimeout, // net/http bounds the TLS handshake by min(ReadHeaderTimeout,
// ReadTimeout, WriteTimeout), so ReadHeaderTimeout covers it here. // ReadTimeout, WriteTimeout), so ReadHeaderTimeout covers it here.
@@ -434,6 +435,9 @@ func (h *HTTPSink) handleStream(w http.ResponseWriter, r *http.Request) {
meta := map[string]any{ meta := map[string]any{
"type": "http_client", "type": "http_client",
} }
if peer := netacl.ContextPeerAddr(r.Context()); peer != "" {
meta["peer_addr"] = peer
}
if r.TLS != nil { if r.TLS != nil {
meta["tls"] = true meta["tls"] = true
if cn := tlsx.PeerCN(*r.TLS); cn != "" { if cn := tlsx.PeerCN(*r.TLS); cn != "" {
+21
View File
@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"io" "io"
"maps" "maps"
"net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"slices" "slices"
@@ -347,6 +348,26 @@ func TestProxyModeRefusesDirectPeers(t *testing.T) {
} }
} }
// A PROXY header names the client a stream's session records; the proxy
// stays as its peer_addr
func TestProxiedStreamSessionKeepsTheProxy(t *testing.T) {
h, manager := newTestHTTPSink(t, map[string]any{"acl": map[string]any{"proxy_protocol": "required", "proxy_from": []any{"127.0.0.1"}}})
_, baseURL := serveTestHTTPSink(t, h)
conn, err := net.Dial("tcp4", strings.TrimPrefix(baseURL, "http://"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { conn.Close() })
fmt.Fprint(conn, "PROXY TCP4 198.51.100.7 127.0.0.1 40000 8081\r\nGET /stream HTTP/1.1\r\nHost: logwisp\r\n\r\n")
if _, err := http.ReadResponse(bufio.NewReader(conn), nil); err != nil { // its body is the stream
t.Fatal(err)
}
sessions := manager.GetActiveSessions()
if len(sessions) != 1 || sessions[0].RemoteAddr != "198.51.100.7:40000" || sessions[0].Metadata["peer_addr"] != conn.LocalAddr().String() {
t.Fatalf("sessions %+v, proxy %s", sessions, conn.LocalAddr())
}
}
// The constructor refuses what it cannot serve: the login pages outside // The constructor refuses what it cannot serve: the login pages outside
// scram's proxy mode, a viewer without the login page, a path under /auth, and // scram's proxy mode, a viewer without the login page, a path under /auth, and
// a path ServeMux or a URL would not take literally, which panicked at Start. // a path ServeMux or a URL would not take literally, which panicked at Start.
+3
View File
@@ -371,6 +371,9 @@ func (t *TCPSink) handleConn(conn net.Conn) {
"type": "tcp_client", "type": "tcp_client",
"remote_addr": remote, "remote_addr": remote,
} }
if peer := netacl.PeerAddr(conn); peer != "" {
meta["peer_addr"] = peer
}
var tlsState *tls.ConnectionState var tlsState *tls.ConnectionState
if tc, ok := conn.(*tls.Conn); ok { if tc, ok := conn.(*tls.Conn); ok {
hctx, cancel := context.WithTimeout(context.Background(), tlsx.HandshakeTimeout) hctx, cancel := context.WithTimeout(context.Background(), tlsx.HandshakeTimeout)
+6 -2
View File
@@ -200,6 +200,7 @@ func (s *HTTPChainSource) Start() error {
Handler: mux, Handler: mux,
ReadTimeout: time.Duration(s.config.ReadTimeoutMS) * time.Millisecond, ReadTimeout: time.Duration(s.config.ReadTimeoutMS) * time.Millisecond,
ReadHeaderTimeout: HTTPChainReadHeaderTimeout, ReadHeaderTimeout: HTTPChainReadHeaderTimeout,
ConnContext: netacl.ConnContext,
// TLS handshake bounded by min(ReadTimeout, ReadHeaderTimeout) // TLS handshake bounded by min(ReadTimeout, ReadHeaderTimeout)
ErrorLog: tlsx.HTTPErrorLog(s.logger, "http_chain_source"), ErrorLog: tlsx.HTTPErrorLog(s.logger, "http_chain_source"),
} }
@@ -371,7 +372,7 @@ func (s *HTTPChainSource) handleIngest(w http.ResponseWriter, r *http.Request) {
for _, entry := range entries { for _, entry := range entries {
s.publish(entry) s.publish(entry)
} }
s.proxy.UpdateActivity(s.sessionFor(remoteHost, connNode, r.TLS, ident)) s.proxy.UpdateActivity(s.sessionFor(remoteHost, connNode, netacl.ContextPeerAddr(r.Context()), r.TLS, ident))
w.Header().Set(chain.HeaderAccepted, strconv.Itoa(len(entries))) w.Header().Set(chain.HeaderAccepted, strconv.Itoa(len(entries)))
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@@ -399,7 +400,7 @@ func (s *HTTPChainSource) handleAuth(w http.ResponseWriter, r *http.Request) {
// sessionFor returns the cached session for a remote+node+identity, // sessionFor returns the cached session for a remote+node+identity,
// recreating after idle expiry. Identity is part of the key so two peers // recreating after idle expiry. Identity is part of the key so two peers
// sharing a remote address never share a session. // sharing a remote address never share a session.
func (s *HTTPChainSource) sessionFor(remoteHost, node string, cs *tls.ConnectionState, ident authz.Identity) string { func (s *HTTPChainSource) sessionFor(remoteHost, node, peer string, cs *tls.ConnectionState, ident authz.Identity) string {
key := remoteHost + "|" + node + "|" + ident.Name key := remoteHost + "|" + node + "|" + ident.Name
s.sessionsMu.Lock() s.sessionsMu.Lock()
defer s.sessionsMu.Unlock() defer s.sessionsMu.Unlock()
@@ -413,6 +414,9 @@ func (s *HTTPChainSource) sessionFor(remoteHost, node string, cs *tls.Connection
"type": "http_chain", "type": "http_chain",
"node": node, "node": node,
} }
if peer != "" {
meta["peer_addr"] = peer
}
if cs != nil { if cs != nil {
meta["tls"] = true meta["tls"] = true
if cn := tlsx.PeerCN(*cs); cn != "" { if cn := tlsx.PeerCN(*cs); cn != "" {
+3
View File
@@ -366,6 +366,9 @@ func (s *TCPChainSource) handleConn(conn net.Conn) {
"type": "tcp_chain", "type": "tcp_chain",
"node": connNode, "node": connNode,
} }
if peer := netacl.PeerAddr(conn); peer != "" {
meta["peer_addr"] = peer
}
if tlsState != nil { if tlsState != nil {
meta["tls"] = true meta["tls"] = true
if cn := tlsx.PeerCN(*tlsState); cn != "" { if cn := tlsx.PeerCN(*tlsState); cn != "" {
+175 -10
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# logwisp acl test: each listener kind admits or refuses peers by address # logwisp acl test: each listener kind admits or refuses peers by address
# before TLS, counts and warns of refusals, rejects rules it can never apply, # before TLS, or by the client a PROXY header names (stub proxy, nginx if
# and still hands a finite input whole to the clients it admits. One-shot. # installed), counts and warns of refusals, and throttles logins per client.
# Ports: 15881-15889 on 127.0.0.1. Requires: bash 5+, curl, nc; Linux. # One-shot. Ports: 15881-15889 on 127.0.0.1, the proxies' on 127.0.0.11-14.
# Requires: bash 5+, curl, nc, go; Linux.
set -u set -u
. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh" . "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
@@ -12,11 +13,72 @@ AUTH=$RUN
e2e_init "$@" e2e_init "$@"
section "Setup" section "Setup"
need curl nc need curl nc go
ports_free 15881 15882 15884 15885 15889 ports_free 15881 15882 15883 15884 15885 15886 15887 15888 15889
rm -rf "$RUN" rm -rf "$RUN"
mkdir -p "$RUN/out" "$LOG" mkdir -p "$RUN/out" "$RUN/proxy" "$LOG"
cd "$RUN" || abort "no run directory" cd "$RUN" || abort "no run directory"
# Each ROUTE is LISTEN,BACKEND,VERSION,CLIENT: a connection to LISTEN reaches
# BACKEND behind a PROXY header of VERSION naming CLIENT, with its own port
cat >"$RUN/proxy/main.go" <<'EOF'
package main
import (
"encoding/binary"
"fmt"
"io"
"log"
"net"
"net/netip"
"os"
"strings"
)
func main() {
for _, route := range os.Args[1:] {
f := strings.Split(route, ",")
ln, err := net.Listen("tcp4", f[0])
if err != nil {
log.Fatal(err)
}
go func() {
for {
c, err := ln.Accept()
if err != nil {
log.Fatal(err)
}
go relay(c, f[1], f[2], netip.MustParseAddr(f[3]))
}
}()
}
fmt.Println("ready")
select {}
}
func relay(c net.Conn, backend, version string, client netip.Addr) {
defer c.Close()
b, err := net.Dial("tcp4", backend)
if err != nil {
return
}
defer b.Close()
src := netip.AddrPortFrom(client, c.RemoteAddr().(*net.TCPAddr).AddrPort().Port())
dst := b.RemoteAddr().(*net.TCPAddr).AddrPort()
if version == "1" {
fmt.Fprintf(b, "PROXY TCP4 %s %s %d %d\r\n", src.Addr(), dst.Addr(), src.Port(), dst.Port())
} else {
h := append([]byte("\r\n\r\n\x00\r\nQUIT\n\x21\x11\x00\x0c"), src.Addr().AsSlice()...)
h = binary.BigEndian.AppendUint16(append(h, dst.Addr().AsSlice()...), src.Port())
b.Write(binary.BigEndian.AppendUint16(h, dst.Port()))
}
go func() { io.Copy(b, c); b.(*net.TCPConn).CloseWrite() }()
io.Copy(c, b)
}
EOF
if ! (cd "$RUN/proxy" && go build -o proxy main.go) >"$LOG/proxy-build.out" 2>&1; then
tail -n 5 "$LOG/proxy-build.out" | sed 's/^/ /'
abort "the stub proxy did not build"
fi
for v in $(compgen -e LOGWISP_); do unset "$v"; done for v in $(compgen -e LOGWISP_); do unset "$v"; done
export HOME=$RUN export HOME=$RUN
lw() { "$BIN" "$@"; } lw() { "$BIN" "$@"; }
@@ -25,7 +87,8 @@ get() { curl -s --noproxy '*' --max-time 3 "$@"; } # curl ARGS...: never through
get --interface 127.0.0.2 http://127.0.0.1:15889/ get --interface 127.0.0.2 http://127.0.0.1:15889/
(($? == 45)) && skip_all "this host cannot send from 127.0.0.2" (($? == 45)) && skip_all "this host cannot send from 127.0.0.2"
seq 1 300 | sed 's/^/edge line /' >"$RUN/edge.log" seq 1 300 | sed 's/^/edge line /' >"$RUN/edge.log"
add_users "$RUN/users.toml" edge-01 add_users "$RUN/users.toml" edge-01 viewer-01
echo wrong >"$RUN/wrong.pass"
section "Rules" section "Rules"
# rejects SINK PATTERN: lw --check fails on SINK with PATTERN in its error # rejects SINK PATTERN: lw --check fails on SINK with PATTERN in its error
@@ -37,6 +100,10 @@ check "an acl on a dialer is refused" "$(rejects 'tcp_chain,host=127.0.0.1,port=
check "a CIDR with host bits warns, naming the network it matches" "$(warns 'tcp,host=127.0.0.1,port=15881,acl.allow=10.0.0.1/8' 'network 10.0.0.0/8')" check "a CIDR with host bits warns, naming the network it matches" "$(warns 'tcp,host=127.0.0.1,port=15881,acl.allow=10.0.0.1/8' 'network 10.0.0.0/8')"
check "a deny list alone on a wildcard listener warns" "$(warns 'tcp,port=15881,acl.deny=10.0.0.1' 'admits all that acl.deny does not list')" check "a deny list alone on a wildcard listener warns" "$(warns 'tcp,port=15881,acl.deny=10.0.0.1' 'admits all that acl.deny does not list')"
check "an allow entry of a whole family warns" "$(warns 'tcp,port=15881,acl.allow=0.0.0.0/0' 'admits every address of its family')" check "an allow entry of a whole family warns" "$(warns 'tcp,port=15881,acl.allow=0.0.0.0/0' 'admits every address of its family')"
check "a proxy_from entry reaching public addresses warns" \
"$(warns 'tcp,host=127.0.0.1,port=15881,acl.proxy_protocol=optional,acl.proxy_from=203.0.113.0/24' 'proxy_from reaches public addresses')"
check "optional proxy_protocol warns that a headerless client passes as the proxy" \
"$(warns 'tcp,host=127.0.0.1,port=15881,acl.proxy_protocol=optional,acl.proxy_from=127.0.0.1' 'passes as the proxy')"
section "HTTP sink" section "HTTP sink"
spawn serve "$BIN" --preset "serve,path=$RUN/edge.log,listen=127.0.0.1:15881,allow=127.0.0.0/8,deny=127.0.0.2" spawn serve "$BIN" --preset "serve,path=$RUN/edge.log,listen=127.0.0.1:15881,allow=127.0.0.0/8,deny=127.0.0.2"
@@ -71,12 +138,12 @@ section "Chain sources"
# pin DAEMON: the pin_sha256 a self-signed listener logged # pin DAEMON: the pin_sha256 a self-signed listener logged
pin() { wait_until 5 logged "$1" pin_sha256 && grep -o 'pin_sha256 "sha256//[^"]*"' "$LOG/$1.out" | head -1 | cut -d'"' -f2; } pin() { wait_until 5 logged "$1" pin_sha256 && grep -o 'pin_sha256 "sha256//[^"]*"' "$LOG/$1.out" | head -1 | cut -d'"' -f2; }
received() { cat "$RUN/out/$1"/aggregate*.log 2>/dev/null | grep -c 'edge line'; } received() { cat "$RUN/out/$1"/aggregate*.log 2>/dev/null | grep -c 'edge line'; }
edge() { spawn "$1" "$BIN" --preset "edge,path=$RUN/edge.log,from=start,to=127.0.0.1:$2,transport=$3,pin=$4,user=edge-01,password_file=$RUN/edge-01.pass"; } edge() { spawn "$1" "$BIN" --preset "edge,path=$RUN/edge.log,from=start,to=$2,transport=$3,pin=$4,user=edge-01,password_file=$RUN/edge-01.pass"; }
spawn agg-allow "$BIN" --preset "aggregator,listen=127.0.0.1:15884,allow=127.0.0.1,users=$RUN/users.toml,out=$RUN/out/allow" spawn agg-allow "$BIN" --preset "aggregator,listen=127.0.0.1:15884,allow=127.0.0.1,users=$RUN/users.toml,out=$RUN/out/allow"
spawn agg-deny "$BIN" --preset "aggregator,listen=127.0.0.1:15885,transport=http,deny=127.0.0.0/8,users=$RUN/users.toml,out=$RUN/out/deny" spawn agg-deny "$BIN" --preset "aggregator,listen=127.0.0.1:15885,transport=http,deny=127.0.0.0/8,users=$RUN/users.toml,out=$RUN/out/deny"
wait_port 15884 && wait_port 15885 || abort "an aggregator did not listen" agg-allow wait_port 15884 && wait_port 15885 || abort "an aggregator did not listen" agg-allow
edge edge-allow 15884 tcp "$(pin agg-allow)" edge edge-allow 127.0.0.1:15884 tcp "$(pin agg-allow)"
edge edge-deny 15885 http "$(pin agg-deny)" edge edge-deny 127.0.0.1:15885 http "$(pin agg-deny)"
timeout 3 nc -s 127.0.0.2 127.0.0.1 15884 </dev/null timeout 3 nc -s 127.0.0.2 127.0.0.1 15884 </dev/null
wait_until 10 eval '(( $(received allow) >= 300 ))' wait_until 10 eval '(( $(received allow) >= 300 ))'
check "a tcp_chain source admits an edge its allow list names ($(received allow)), and refuses 127.0.0.2" \ check "a tcp_chain source admits an edge its allow list names ($(received allow)), and refuses 127.0.0.2" \
@@ -85,4 +152,102 @@ wait_until 10 logged agg-deny 'refused by acl'
check "an http_chain source refuses a denied edge, which delivers nothing ($(received deny))" \ check "an http_chain source refuses a denied edge, which delivers nothing ($(received deny))" \
"$(logged agg-deny 'refused by acl' && (($(received deny) == 0)) && echo 1 || echo 0)" "$(logged agg-deny 'refused by acl' && (($(received deny) == 0)) && echo 1 || echo 0)"
section "Behind a PROXY header"
# Every listener here takes headers from 127.0.0.1 only, where the proxies
# connect from; each is up before a proxy shares its port on another address
behind=acl.proxy_protocol=required,acl.proxy_from=127.0.0.1
scram="tls.enabled=true,tls.self_signed=true,auth.type=scram,auth.credentials_file=$RUN/users.toml"
spawn sink-behind "$BIN" --logging.level=info --source null --sink "http,host=127.0.0.1,port=15886,$scram,$behind,acl.deny=198.51.100.2"
for t in tcp http; do
spawn "agg-$t-behind" "$BIN" --logging.level=info \
--source "${t}_chain,host=127.0.0.1,port=$([[ $t == tcp ]] && echo 15888 || echo 15883),$scram,$behind" \
--sink "file,directory=$RUN/out/$t-behind,name=aggregate"
done
# the tcp sink sends its input once the clients connected
{ wait_until 30 test -e "$RUN/feed"; seq 1 100 | sed 's/^/behind /'; } |
lw --sink "tcp,host=127.0.0.1,port=15887,$behind,acl.deny=198.51.100.2,acl.deny=127.0.0.6" 2>"$LOG/tcp-behind.out" &
fpid=$!
for p in 15883 15886 15887 15888; do wait_port $p || abort "a listener behind the proxy did not listen on $p" sink-behind; done
spawn proxy "$RUN/proxy/proxy" 127.0.0.11:15886,127.0.0.1:15886,1,198.51.100.1 \
127.0.0.12:15886,127.0.0.1:15886,2,198.51.100.2 127.0.0.13:15886,127.0.0.1:15886,2,198.51.100.3 \
127.0.0.11:15887,127.0.0.1:15887,2,198.51.100.1 127.0.0.12:15887,127.0.0.1:15887,1,198.51.100.2 \
127.0.0.11:15888,127.0.0.1:15888,1,198.51.100.1 127.0.0.13:15883,127.0.0.1:15883,2,198.51.100.3
wait_until 10 logged proxy ready || abort "the stub proxy did not start" proxy
NGINX=0
if command -v nginx >/dev/null; then
mkdir -p "$RUN/nginx/logs"
# a dynamic stream module loads by its absolute path; a built-in has none
so=$(nginx -V 2>&1 | grep -o -- '--modules-path=[^ ]*' | cut -d= -f2)/ngx_stream_module.so
modules=$([[ -f $so ]] && echo "load_module $so;")
cat >"$RUN/nginx/nginx.conf" <<EOF
daemon off;
pid $RUN/nginx/nginx.pid;
error_log $RUN/nginx/logs/error.log;
$modules
events {}
stream {
server {
listen 127.0.0.14:15887;
proxy_pass 127.0.0.1:15887;
proxy_protocol on;
}
}
EOF
spawn nginx nginx -p "$RUN/nginx" -c "$RUN/nginx/nginx.conf"
wait_until 5 test -s "$RUN/nginx/nginx.pid" && NGINX=1
fi
# tcp sink: one client per route, then the input
listen_via() { timeout 10 nc "${@:2}" 15887 < <(sleep 12) >"$RUN/$1.out"; }
listen_via admitted 127.0.0.11 &
pids=($!)
listen_via denied 127.0.0.12 &
pids+=($!)
if ((NGINX)); then
listen_via nginx -s 127.0.0.5 127.0.0.14 &
pids+=($!)
listen_via nginx-denied -s 127.0.0.6 127.0.0.14 &
pids+=($!)
fi
sleep 1
touch "$RUN/feed"
wait "$fpid" "${pids[@]}"
lines() { grep -c '^behind ' "$RUN/$1.out"; }
check "a tcp sink streams to the client a v2 header names ($(lines admitted) lines)" "$(($(lines admitted) == 100))"
check "and refuses one a v1 header names, logging both addresses ($(lines denied) lines)" \
"$( (($(lines denied) == 0)) && grep -q 'refused by acl.* remote_addr 198.51.100.2:.*peer_addr 127.0.0.1:' "$LOG/tcp-behind.out" && echo 1 || echo 0)"
if ((NGINX)); then
check "nginx stream with proxy_protocol on names its clients ($(lines nginx) lines, $(lines nginx-denied) to a denied one)" \
"$( (($(lines nginx) == 100 && $(lines nginx-denied) == 0)) && echo 1 || echo 0)"
else
skip "nginx stream with proxy_protocol on (nginx with its stream module is not available)"
fi
# http sink: each client its own login budget behind the one proxy address
PIN=$(pin sink-behind)
token() { lw auth token --url "https://$1:15886" -u viewer-01 --password-file "$2" --pin-sha256 "$PIN" 2>&1; }
token 127.0.0.12 "$RUN/viewer-01.pass" >/dev/null
check "an http sink refuses the client a v2 header names, logging both addresses" \
"$(logged sink-behind 'refused by acl.* remote_addr 198.51.100.2:.*peer_addr 127.0.0.1:' && echo 1 || echo 0)"
for ((i = 1; i <= 30; i++)); do
token 127.0.0.11 "$RUN/wrong.pass" >/dev/null
logged sink-behind ' throttled' && break
done
check "wrong passwords throttle a client by the address its v1 header names ($i attempts)" \
"$(logged sink-behind 'Login rejected.* remote_addr 198.51.100.1:.* 198.51.100.1 throttled' && echo 1 || echo 0)"
check "while another behind the same proxy still logs in" \
"$(token 127.0.0.13 "$RUN/viewer-01.pass" >/dev/null && logged sink-behind 'Login accepted.* remote_addr 198.51.100.3:' && echo 1 || echo 0)"
# chain sources: an edge through the proxy is the client its header names
edge edge-tcp-behind 127.0.0.11:15888 tcp "$(pin agg-tcp-behind)"
edge edge-http-behind 127.0.0.13:15883 http "$(pin agg-http-behind)"
printf 'PROXY TCP4 198.51.100.9 127.0.0.1 40000 15888\r\n' | timeout 3 nc -s 127.0.0.2 127.0.0.1 15888
wait_until 10 eval '(( $(received tcp-behind) >= 300 && $(received http-behind) >= 300 ))'
check "a tcp_chain source takes an edge as the client a v1 header names ($(received tcp-behind))" \
"$( (($(received tcp-behind) == 300)) && logged agg-tcp-behind 'Chain connection established.* remote_addr 198.51.100.1:' && echo 1 || echo 0)"
check "an http_chain source takes one as the client a v2 header names ($(received http-behind))" \
"$( (($(received http-behind) == 300)) && logged agg-http-behind 'Login accepted.* remote_addr 198.51.100.3:' && echo 1 || echo 0)"
check "a header from a peer outside proxy_from is refused" \
"$(logged agg-tcp-behind 'refused by acl.* remote_addr 127.0.0.2:.* reason "PROXY header from a peer outside proxy_from"' && echo 1 || echo 0)"
summary summary