From cdd4fc01a7e509c45ed72e558d33b4f383f1c2d0fa2799ab8d2700bc93842582 Mon Sep 17 00:00:00 2001 From: Lixen Wraith Date: Mon, 5 Oct 2026 02:22:30 -0400 Subject: [PATCH] v0.26.1 per-client connection caps --- AGENTS.md | 5 +- README.md | 4 +- cmd/lw/auth.go | 2 +- config/logwisp.toml | 20 ++- deploy/lw-deploy.sh | 2 +- doc/README.md | 4 +- doc/architecture.md | 2 + doc/deployment.md | 2 +- doc/installation.md | 3 +- doc/mtls-auth-plan.md | 7 +- doc/networking.md | 30 ++-- doc/scram-auth-plan.md | 9 +- doc/security.md | 66 +++++-- doc/sinks.md | 18 +- doc/sources.md | 18 +- doc/todo.md | 44 +---- internal/authz/authz.go | 9 +- internal/authz/authz_test.go | 18 +- internal/authz/proxy.go | 45 ++--- internal/authz/proxy_test.go | 30 ++++ internal/authz/scram.go | 147 ++------------- internal/authz/scram_test.go | 64 +------ internal/config/config.go | 14 +- internal/config/preset.go | 2 +- internal/netacl/limit.go | 155 ++++++++++++++++ internal/netacl/netacl.go | 206 +++++++++++++++++----- internal/netacl/netacl_test.go | 168 +++++++++++++++++- internal/sink/http/http.go | 22 ++- internal/sink/http/web.go | 21 ++- internal/sink/httpchain/httpchain.go | 2 +- internal/sink/httpchain/httpchain_test.go | 2 +- internal/sink/tcp/tcp.go | 4 +- internal/sink/tcpchain/tcpchain.go | 2 +- internal/source/httpchain/httpchain.go | 8 +- internal/source/tcpchain/tcpchain.go | 4 +- test/acl-test.sh | 53 +++++- 36 files changed, 795 insertions(+), 417 deletions(-) create mode 100644 internal/netacl/limit.go diff --git a/AGENTS.md b/AGENTS.md index 29df538..382888e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,8 +82,9 @@ change touches. in `internal/config/preset.go`. - Network security has three seams: `tlsx` (TLS configs, certificates made at startup or by `lw tls`, pins, startup warnings), `authz`, and `netacl` - (address rules: a listener wraps the socket `core.Listen` returns in - `Policy.Listener`, before TLS). Listeners admit only through + (address rules and per-client limits: a listener wraps the socket + `core.Listen` returns in `Policy.Listener`, before TLS; its `Table` is the + one per-client limiter, SCRAM throttling's too). Listeners admit only through `Admit`/`AuthorizeRequest`, dialers through `Greet`/`Prepare`; `Authorize` refuses under scram, so a plugin gated on it alone fails closed. Nil policies are valid: keep call sites branch-free. diff --git a/README.md b/README.md index fbf898f..636e6a6 100644 --- a/README.md +++ b/README.md @@ -115,8 +115,8 @@ authentication designs for the rationale and what is deliberately left out. exit codes, `lw auth`; also the `lw(1)` manual, [`doc/lw.1`](doc/lw.1) - [Operations](doc/operations.md): running, monitoring, tuning, troubleshooting -- [To Do](doc/todo.md): planned work in priority order: network access - control and the PROXY protocol, packaging, smaller follow-ups +- [To Do](doc/todo.md): planned work in priority order: packaging, smaller + follow-ups A fully annotated configuration covering every option ships as [`config/logwisp.toml`](config/logwisp.toml). diff --git a/cmd/lw/auth.go b/cmd/lw/auth.go index 3feb260..43fc6a1 100644 --- a/cmd/lw/auth.go +++ b/cmd/lw/auth.go @@ -258,7 +258,7 @@ func dialPolicy(o *config.TLSOptions, host, user, passwordFile string, transport return nil, nil, err } policy, err := authz.New(&config.AuthOptions{Type: authz.MethodSCRAM, Username: user, PasswordFile: passwordFile}, - tlsCfg, authz.RoleDialer, transport) + tlsCfg, nil, authz.RoleDialer, transport) if err != nil { return nil, nil, err } diff --git a/config/logwisp.toml b/config/logwisp.toml index 9d19454..6f9e58b 100644 --- a/config/logwisp.toml +++ b/config/logwisp.toml @@ -11,8 +11,8 @@ ### sink (the [...tls] blocks below), the [...auth] blocks authenticate peers by ### certificate (mtls) or password (scram; credentials via `lw auth`), and the ### listeners' [...acl] blocks admit peers by address, or by the client an L4 -### proxy's PROXY header names. Unknown keys anywhere in this file or in any -### plugin config are rejected at startup. +### proxy's PROXY header names, and limit each client. Unknown keys anywhere in +### this file or in any plugin config are rejected at startup. ### ### Environment overrides use the LOGWISP_ prefix ### (LOGWISP_QUIET, LOGWISP_LOGGING_LEVEL, ...). Bare names are ignored. @@ -163,9 +163,10 @@ name = "default" ### ACL (beside [...tls] and [...auth] on the listeners; dialers have none) ### ### 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 (acl_limited past a limit), with a WARN +### at most once a minute. ### allow = [] addresses or CIDRs of the listener's family (either behind -### proxy_from); empty = all +### proxy_from or trusted_proxies); empty = all ### 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. @@ -173,6 +174,11 @@ name = "default" ### proxy: use it only while rolling headers out ### proxy_from = [] the proxies that may send one; a header from any ### other peer is refused +### max_connections_per_client = 0 per client (an IPv6 one by its /64); +### refused behind trusted_proxies +### requests_per_second_per_client = 0 HTTP listeners, /auth aside: past it, +### 429; behind trusted_proxies, per +### forwarded client ###============================================================================ ###============================================================================ @@ -246,6 +252,7 @@ from = "end" # "end" or "start" of a newly discovered # deny = [] # proxy_protocol = "off" # off | optional | required # proxy_from = [] # proxies that may send a PROXY header +# max_connections_per_client = 0 # per client at once; 0 = unlimited ## HTTP chain source (stdlib listener; receives NDJSON batches from upstream http_chain sinks) # [[pipelines.plugin_sources]] @@ -278,6 +285,8 @@ from = "end" # "end" or "start" of a newly discovered # deny = [] # proxy_protocol = "off" # off | optional | required # proxy_from = [] # proxies that may send a PROXY header +# max_connections_per_client = 0 # per client at once; 0 = unlimited +# requests_per_second_per_client = 0 # per client, a second's worth at once; 0 = unlimited ###============================================================================ ### Sinks (1+ required, fan-out) @@ -348,6 +357,8 @@ target = "stdout" # stdout|stderr ("split" NOT supported) # deny = [] # proxy_protocol = "off" # off | optional | required # proxy_from = [] # proxies that may send a PROXY header +# max_connections_per_client = 0 # per client at once; 0 = unlimited +# requests_per_second_per_client = 0 # per client, a second's worth at once; 0 = unlimited ## TCP sink (streaming server) # [[pipelines.plugin_sinks]] @@ -379,6 +390,7 @@ target = "stdout" # stdout|stderr ("split" NOT supported) # deny = [] # proxy_protocol = "off" # off | optional | required # proxy_from = [] # proxies that may send a PROXY header +# max_connections_per_client = 0 # per client at once; 0 = unlimited ## 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 diff --git a/deploy/lw-deploy.sh b/deploy/lw-deploy.sh index 490d434..d556141 100755 --- a/deploy/lw-deploy.sh +++ b/deploy/lw-deploy.sh @@ -120,7 +120,7 @@ Not covered by this script; edit the generated configuration instead - filters, rate limits, heartbeat, formatter flags and timestamp layouts - 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) -- acl on the listeners: address rules, PROXY headers +- acl on the listeners: address rules, PROXY headers, per-client limits - mtls identities other than the certificate CN; node_binding other than force - creating certificates: openssl or your PKI (doc/security.md#enabling-mtls) - several native instances on one host: use one per host or jail diff --git a/doc/README.md b/doc/README.md index 3e7f40f..379c534 100644 --- a/doc/README.md +++ b/doc/README.md @@ -30,8 +30,8 @@ streams, or downstream LogWisp nodes. - [CLI](cli.md): flags, signals, exit codes, `lw auth`; also the `lw(1)` manual, [`lw.1`](lw.1) - [Operations](operations.md): running, monitoring, tuning, troubleshooting -- [To Do](todo.md): planned work in priority order: per-client limits, - packaging, smaller follow-ups +- [To Do](todo.md): planned work in priority order: packaging, smaller + follow-ups A fully annotated configuration covering every option lives at [`config/logwisp.toml`](../config/logwisp.toml). diff --git a/doc/architecture.md b/doc/architecture.md index 0dd7aa9..a8a32d9 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -42,6 +42,8 @@ Package map: - `internal/tlsx`: the single seam between `TLSOptions` and `crypto/tls`. - `internal/authz`: the single seam between `AuthOptions` and the network plugins: `Admit`, `AuthorizeRequest`, `Greet`, `Prepare`. +- `internal/netacl`: a listener's `acl` block: address rules, PROXY headers + and per-client limits; its `Table` is SCRAM throttling's limiter too. - `internal/session`: session manager and per-instance proxy. - `internal/core`: shared types (`LogEntry`, `TransportEvent`), capabilities, constants. diff --git a/doc/deployment.md b/doc/deployment.md index 669eac4..7c0747f 100644 --- a/doc/deployment.md +++ b/doc/deployment.md @@ -184,7 +184,7 @@ every key. - filters, rate limits, heartbeat, formatter flags and timestamp layouts - more than one pipeline; both chain transports, or a further hop, on one node - proxy mode: browser logins through a TLS-terminating proxy -- `acl` on the listeners: address rules, PROXY headers +- `acl` on the listeners: address rules, PROXY headers, per-client limits - mtls identities other than the certificate CN; `node_binding` other than `force` - creating certificates diff --git a/doc/installation.md b/doc/installation.md index 8d17820..6015b2a 100644 --- a/doc/installation.md +++ b/doc/installation.md @@ -314,5 +314,6 @@ or SKIP (exit 77, e.g. no IPv6 loopback); only a failure fails the target. - `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, 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 + 127.0.0.14 when installed) or an L7 proxy's `X-Forwarded-For`, with + per-client connection caps, request rates and login throttling; ports 15881-15889; skips (exit 77) where 127.0.0.2 cannot be bound diff --git a/doc/mtls-auth-plan.md b/doc/mtls-auth-plan.md index b00b08a..69eda35 100644 --- a/doc/mtls-auth-plan.md +++ b/doc/mtls-auth-plan.md @@ -305,9 +305,10 @@ The remaining phase-4 items, in rough order of value: operators with existing CRL infrastructure. The allow-list covers the same ground with fewer moving parts, so this is only worth doing for a fleet whose revocation already flows through a CRL. -2. **Per-identity rate limits.** The natural follow-on now that identity exists, - and the natural home for the per-IP limiting that was also removed. Kept out - of scope here so this feature stayed reviewable. +2. **Per-identity rate limits.** The natural follow-on now that identity exists: + the `acl` block's per-client limits count addresses + ([Security](security.md#per-client-limits)), and would be keyed by identity + once a peer is authenticated. 3. **Per-client identity in the `http` sink's status output.** The endpoint reports the policy and counters, but not which identities are currently connected; session metadata has the data. diff --git a/doc/networking.md b/doc/networking.md index e77c74f..292b3f7 100644 --- a/doc/networking.md +++ b/doc/networking.md @@ -116,11 +116,10 @@ sink, and the `tcp_chain` source. `0` means unlimited. The `http_chain` source has no connection cap; it bounds work with `max_body_bytes` and `read_timeout_ms` instead. -Apart from SCRAM logins, which are throttled per address (see -[Security](security.md#throttling)), there is **no** per-IP limiting; `acl` -admits or refuses peers by address ([Security](security.md#the-acl-block)). -`flow.rate_limit` is a pipeline-wide entry rate limit, not a network-level -one — it cannot distinguish or throttle an individual peer. +Per client, the `acl` block caps connections on every listener and requests +on the HTTP ones ([Security](security.md#per-client-limits)), and SCRAM logins +are throttled ([Security](security.md#throttling)). `flow.rate_limit` is a +pipeline-wide entry rate limit: it cannot distinguish or throttle a peer. ## Keep-Alive @@ -208,13 +207,16 @@ headers, and entry encoding. - Check the port is not already bound by another pipeline in the same process. **Closed as soon as it connects, before TLS** -- The listener's `acl` refused it: `acl_denied` rises, and its log has - `Connection refused by acl` at WARN with a `reason`, once a minute at most. - The dialer sees `EOF` or `connection reset by peer` (curl: exit 52 or 56). +- The listener's `acl` refused it: `acl_denied` or `acl_limited` rises, and + its log has `Connection refused by acl` at WARN with a `reason`, once a + minute at most. The dialer sees `EOF` or `connection reset by peer` (curl: + exit 52 or 56). - `address rules` — `allow` or `deny` excludes the client: the one a 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. + - `max_connections_per_client` — the client holds that many already, or the + listener tracks 65,536 clients; HTTP keep-alive connections count too. - `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. @@ -223,7 +225,14 @@ headers, and entry encoding. the connection opened: list the proxy in `proxy_from`. - `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. - Behind `proxy_from`, `allow` and `deny` take both; `proxy_from` its own. + Behind `proxy_from` or `trusted_proxies`, `allow` and `deny` take both; + `proxy_from` its own. + +**HTTP `429 Too Many Requests` with `Retry-After`** +- `requests_per_second_per_client`: `acl_limited` rises and the log has + `Request refused by acl`, once a minute at most. A browser's page load + takes several requests; raise the rate rather than retry faster. From + `/auth` with `too many attempts`, it is SCRAM throttling (below). **TLS handshake failure** - `client didn't provide a certificate` — the listener has `client_auth = true` @@ -277,7 +286,8 @@ headers, and entry encoding. - `403` from an `http` sink in proxy mode, with `not a trusted proxy` or `X-Forwarded-Proto` in its WARN line — the request did not come from a listed proxy, or the proxy did not forward `X-Forwarded-Proto: https` and - `X-Forwarded-For`. + `X-Forwarded-For`. With `Request refused by acl` (once a minute at most) or + none, the `acl` rules refuse the forwarded client (`acl_denied`). - `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 once the failing peer stops. Peers behind one NAT, or one passthrough proxy diff --git a/doc/scram-auth-plan.md b/doc/scram-auth-plan.md index d727460..07875b6 100644 --- a/doc/scram-auth-plan.md +++ b/doc/scram-auth-plan.md @@ -174,8 +174,9 @@ one per /64), on handshake starts: a token bucket (burst 10, 1/s) refunded on success, and at most 4 unfinished exchanges. An unanswered HTTP challenge holds its slot for the `auth` handshake timeout (30 s); abandoned TCP exchanges release their slot, and their entry in the `auth` handshake table, -immediately. The address table holds 65,536 entries, drops one idle for a -minute once its challenges have expired, and fails closed when full; the SCRAM +immediately. The address table, `netacl.Table` as for the `acl` block's +per-client limits, holds 65,536 entries, drops one once its bucket has +refilled and its challenges have expired, and fails closed when full; the SCRAM server itself caps in-flight handshakes at 4,096 (`busy`). Counters: `auth_allowed` counts logins; `auth_rejected` every refusal — failed @@ -332,8 +333,8 @@ indistinguishable from an unknown user, another certificate refused and counted, hello and policy disagreeing either way, a dialer cut short by its context, an abandoned exchange freeing its handshake and limiter slots, a refusal after the exchange sent as a reason rather than a final, certificate-to-user binding for -logins and tokens); only failed attempts throttled, the limiter bounds -(reservation under concurrency, the sweep of abandoned challenges); pre-auth +logins and tokens); only failed attempts throttled (the limiter's bounds are +`netacl`'s, under the per-client limits); pre-auth input capped at 4 KiB; the decoy salt stable across restarts; a dialer refusing a challenge below its Argon2 floor; the HTTP exchange on a TLS test server (token accepted, garbage and foreign tokens `401`, forged final yields no token, diff --git a/doc/security.md b/doc/security.md index e921607..72efc0d 100644 --- a/doc/security.md +++ b/doc/security.md @@ -26,9 +26,10 @@ configure it, and — equally important — what it does not yet do. [Browsers behind a TLS-terminating proxy](#browsers-behind-a-tls-terminating-proxy). - Server pinning by dialers: certificate identity (`mtls`), bound certificate (`scram`). -- 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). +- Address rules and per-client connection and request limits on every + listener, applied before TLS to the client a PROXY header (v1 or v2) from a + listed L4 proxy names, and per request to the client an L7 proxy forwards: + see [The ACL Block](#the-acl-block). - Startup warnings for expiring certificates and risky settings: see [Startup Warnings](#startup-warnings). - Unknown configuration keys rejected: a typo in `tls`, `auth`, `acl` or a @@ -52,10 +53,11 @@ configure it, and — equally important — what it does not yet do. - Certificate revocation lists (CRL) or OCSP: revoke by editing the allow-list or credentials file. -- Per-address connection or request limits: only SCRAM logins are throttled - per address. -- Address rules for `X-Forwarded-For` clients: in the `http` sink's proxy mode - `acl` sees the L7 proxy, never the client it forwards. +- Per-identity limits: the per-client limits count addresses, not the + identities `auth` proves. +- Per-client stream caps: under HTTP/2 `max_connections_per_client` bounds a + client's connections, not the streams each carries; the request rate only + slows how fast it opens them. 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 @@ -334,26 +336,54 @@ 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 proxy_protocol = "required" # off (default), optional, required proxy_from = ["10.0.0.5"] # L4 proxies sending PROXY headers +max_connections_per_client = 4 # at once; 0 (default): no cap +requests_per_second_per_client = 10 # HTTP listeners; 0 (default): none ``` - `deny` wins; then a non-empty `allow` admits only its entries, an empty one every address `deny` does not list. - 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 - family it binds ever matches). Behind `proxy_from` they take either, as a - header may name a client of either. An entry of the other family, an + family it binds ever matches). Behind `proxy_from` or the `http` sink's + `trusted_proxies` they take either, as the proxy may name a client of + either. An entry of the other family, an 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 - stats, the `http` sink's status). A WARN names the peer, the reason and the - count, at most once a minute per listener; the refusals in between are only - counted. +- A refused connection is closed at once and counted in `acl_denied`, or + `acl_limited` past a [limit](#per-client-limits) (plugin stats, the `http` + sink's status). A WARN names the client, the reason and the counts, at most + once a minute per listener; the refusals in between are only counted. - The rules see the client: the socket peer, or the client a PROXY header - names. Behind the `http` sink's `trusted_proxies` they see the L7 proxy. + names. Behind the `http` sink's `trusted_proxies` they judge the L7 proxy + as the peer, then each request's forwarded client (`403` when refused, + counted in `acl_denied`): allow both. - 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 unknown key. +### Per-client limits + +- `max_connections_per_client` caps the connections a client holds at once, + on every listener; past it a connection is closed like a refused one. + - HTTP keep-alive connections count, idle ones too, and a browser opens up + to six; under HTTP/2 one connection carries a client's many requests. + - Behind `trusted_proxies` every connection is the proxy's, so the key + fails construction: cap clients at the proxy. +- `requests_per_second_per_client`, on the `http` sink and the `http_chain` + source, admits a second's worth of requests at once (at least one), + refilled at that rate; past it a request gets `429`, with `Retry-After` the + seconds until its next (1 at a rate of 1 or more). + - Every request counts but the SCRAM exchange (`POST /auth`), which + [throttling](#throttling) limits: stream, status, ingest and the viewer's + files, about six for a viewer's first load. +- A client is the address the rules see, an IPv6 one by its /64 and a + link-local one per link, so the hosts of one /64 (a SLAAC LAN) share a + budget; behind `trusted_proxies` the request rate counts the forwarded + client. +- Each limit tracks up to 65,536 clients, a client until its budget is whole + again, and refuses new ones when full, failing closed. SCRAM throttling + keeps its budgets the same way. + ### PROXY protocol Behind an L4 proxy that passes TLS through (nginx `stream`, HAProxy @@ -906,7 +936,8 @@ sink's stream and status endpoints stop being open to anyone who can reach the port. **`acl`** — an address check, per listener, before TLS: it narrows who can -reach the other layers, never proves who a peer is. +reach the other layers and how much each address may take, never proves who a +peer is. **Revocation** is the allow-list or the credentials file, not a CRL. Remove the identity or user and send `SIGHUP`: the reload rebuilds every pipeline, so the @@ -938,8 +969,9 @@ What each exposes when `auth.type = "none"`: (with `client_auth`, any the CA vouches for), under any node label it claims. -`max_connections` bounds concurrency on all of them but does not distinguish -callers. +`max_connections` bounds concurrency on all but the `http_chain` source; the +`acl` block's [per-client limits](#per-client-limits) bound each caller's +share. Both methods require TLS on the listener, except an `http` sink in proxy mode, where TLS ends at the trusted proxy; `mtls` also requires `client_auth`. diff --git a/doc/sinks.md b/doc/sinks.md index 5d49322..e47fd0a 100644 --- a/doc/sinks.md +++ b/doc/sinks.md @@ -179,8 +179,9 @@ Options, each as type and default: - `auth` (table): client authentication (`mtls` or `scram`); see [Security](security.md#the-auth-block). - `acl` (table): `allow` and `deny`, addresses or CIDRs admitted and refused - before TLS, and `proxy_protocol` with `proxy_from` for the client a PROXY - header names; see [Security](security.md#the-acl-block). + before TLS, `proxy_protocol` with `proxy_from` for the client a PROXY header + names, and the per-client `max_connections_per_client` and + `requests_per_second_per_client`; see [Security](security.md#the-acl-block). **Behaviour** @@ -247,13 +248,13 @@ Options, each as type and default: the compiled auth policy and acl, active client count, sink and per-client buffer sizes, connection limit, write timeout, uptime, endpoint paths, and the `total_processed` / `dropped_writes` / `rejected_clients` / `auth_rejected` / -`acl_denied` counters. +`acl_denied` / `acl_limited` counters. **Statistics**: `dropped_writes`, `rejected_clients`, `auth`, `auth_allowed`, `auth_rejected`; under `scram` also `auth_users`, `auth_throttled`, `auth_busy`, `auth_binding_mismatch` and `auth_token_lifetime_ms`, and in -proxy mode `auth_trusted_proxies`; with `acl` rules or `proxy_from` also -`acl`, `acl_denied` and `acl_proxy_headers`. +proxy mode `auth_trusted_proxies`; with a non-empty `acl` block also `acl`, +`acl_denied`, `acl_limited` and `acl_proxy_headers`. > Without an `auth` block both endpoints are unauthenticated, and the stream > response carries `Access-Control-Allow-Origin: *`, so any web origin can read @@ -307,8 +308,9 @@ Options, each as type and default: - `auth` (table): client authentication (`mtls` or `scram`); see [Security](security.md#the-auth-block). - `acl` (table): `allow` and `deny`, addresses or CIDRs admitted and refused - before TLS, and `proxy_protocol` with `proxy_from` for the client a PROXY - header names; see [Security](security.md#the-acl-block). + before TLS, `proxy_protocol` with `proxy_from` for the client a PROXY header + names, and `max_connections_per_client`; see + [Security](security.md#the-acl-block). **Behaviour** @@ -335,7 +337,7 @@ Options, each as type and default: **Statistics**: `write_errors`, `dropped_writes`, `rejected_conns`, `tls_handshake_errors`, `auth`, `auth_allowed`, `auth_rejected`; under `scram` also `auth_users`, `auth_throttled`, `auth_busy` and `auth_binding_mismatch`; -with `acl` rules or `proxy_from` also `acl`, `acl_denied` and +with a non-empty `acl` block also `acl`, `acl_denied`, `acl_limited` and `acl_proxy_headers`. --- diff --git a/doc/sources.md b/doc/sources.md index 6ce5010..1dc378c 100644 --- a/doc/sources.md +++ b/doc/sources.md @@ -215,8 +215,9 @@ Options, each as type and default: - `auth` (table): peer authentication (`mtls` or `scram`) and node binding; see [Security](security.md#the-auth-block). - `acl` (table): `allow` and `deny`, addresses or CIDRs admitted and refused - before TLS, and `proxy_protocol` with `proxy_from` for the client a PROXY - header names; see [Security](security.md#the-acl-block). + before TLS, `proxy_protocol` with `proxy_from` for the client a PROXY header + names, and `max_connections_per_client`; see + [Security](security.md#the-acl-block). For passwords instead of certificates, the `auth` block takes `type = "scram"` and a `credentials_file`, and `client_auth` becomes optional; see @@ -251,8 +252,8 @@ and a `credentials_file`, and `client_auth` becomes optional; see **Statistics**: `active_connections`, `rejected_conns`, `parse_errors`, `tls_handshake_errors`, `trust_node`, `auth`, `auth_allowed`, `auth_rejected`, `node_binding`; under `scram` also `auth_users`, `auth_throttled`, `auth_busy` -and `auth_binding_mismatch`; with `acl` rules or `proxy_from` also `acl`, -`acl_denied` and `acl_proxy_headers`. +and `auth_binding_mismatch`; with a non-empty `acl` block also `acl`, +`acl_denied`, `acl_limited` and `acl_proxy_headers`. --- @@ -304,8 +305,9 @@ Options, each as type and default: - `auth` (table): peer authentication (`mtls` or `scram`) and node binding; see [Security](security.md#the-auth-block). - `acl` (table): `allow` and `deny`, addresses or CIDRs admitted and refused - before TLS, and `proxy_protocol` with `proxy_from` for the client a PROXY - header names; see [Security](security.md#the-acl-block). + before TLS, `proxy_protocol` with `proxy_from` for the client a PROXY header + names, and the per-client `max_connections_per_client` and + `requests_per_second_per_client`; see [Security](security.md#the-acl-block). **Behaviour** @@ -332,8 +334,8 @@ Options, each as type and default: **Statistics**: `total_requests`, `rejected_requests`, `parse_errors`, `cached_sessions`, `trust_node`, `auth`, `auth_allowed`, `auth_rejected`, `node_binding`; under `scram` also `auth_users`, `auth_throttled`, `auth_busy`, -`auth_binding_mismatch` and `auth_token_lifetime_ms`; with `acl` rules or -`proxy_from` also `acl`, `acl_denied` and `acl_proxy_headers`. +`auth_binding_mismatch` and `auth_token_lifetime_ms`; with a non-empty `acl` +block also `acl`, `acl_denied`, `acl_limited` and `acl_proxy_headers`. --- diff --git a/doc/todo.md b/doc/todo.md index 9d97cd5..d25063a 100644 --- a/doc/todo.md +++ b/doc/todo.md @@ -4,45 +4,7 @@ Planned work, in order of priority. Each item names what to build, how it fits the existing seams, and how to verify it. Finished items move to the design doc they belong to. -## 1. Network access control (ACL) - -The seam exists: `internal/netacl` wraps each listener's socket before TLS, -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 -is still global. - -### 1.1 Per-client limits - -- Generalize the SCRAM limiter in `internal/authz/scram.go` (token bucket per - key, bounded table that fails closed, idle sweep, IPv6 per /64 via - `throttleKey`) into the shared per-key limiter. One mechanism (AGENTS.md): - SCRAM throttling then uses it too. -- Keys in the `acl` block: - - `max_connections_per_client` on every listener (concurrent connections per - client), applied by the netacl listener after the rules. - - `requests_per_second_per_client` on HTTP listeners (ingest, stream and - 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 - deferred item 2), keyed by identity instead of address. - -### 1.2 Address rules for forwarded clients - -- 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. - -### 1.3 Verification - -- Go tests, one per rule: the per-client connection cap; the request rate; the - limiter table failing closed; a forwarded client refused by the rules. -- `test/acl-test.sh` adds per-client connection caps, behind its stub proxy - too. - -## 2. Packaging: AUR, FreeBSD ports, Debian +## 1. Packaging: AUR, FreeBSD ports, Debian The foundation exists: - The `lw` binary name, free in Arch (official repositories and AUR), Ubuntu @@ -99,9 +61,9 @@ identity, and both skeletons download the tagged source. 6. A packaging CI job: build the AUR package in an Arch container and the port in a FreeBSD VM, run `lw --version` and `make image-check`. -## 3. Follow-ups +## 2. Follow-ups -Smaller items found along the way; each is independent of the ACL. +Smaller items found along the way, each independent of the others. 1. A late browser sees no backlog: the `http` sink keeps none, so the viewer shows entries from when it connects. A bounded replay (a `replay_lines` diff --git a/internal/authz/authz.go b/internal/authz/authz.go index 61b2b19..70e6f38 100644 --- a/internal/authz/authz.go +++ b/internal/authz/authz.go @@ -21,6 +21,7 @@ import ( "github.com/lixenwraith/logwisp/internal/chain" "github.com/lixenwraith/logwisp/internal/config" + "github.com/lixenwraith/logwisp/internal/netacl" "github.com/lixenwraith/logwisp/internal/tlsx" "github.com/lixenwraith/log" @@ -78,7 +79,8 @@ type Policy struct { allow map[string]struct{} patterns []*regexp.Regexp binding string - secrets []secretFile // reported at startup when every local user can read them + secrets []secretFile // reported at startup when every local user can read them + acl *netacl.Policy // a forwarded client's rules, in proxy mode listener *scramListener // scram listener state dialer *scramDialer // scram dialer state @@ -110,7 +112,8 @@ func (id Identity) Apply(meta map[string]any) { // New compiles an auth policy, returning (nil, nil) when auth is disabled. // tlsCfg is what tlsx built from the sibling `tls` block: a policy the // transport cannot enforce is rejected here rather than silently accepted. -func New(o *config.AuthOptions, tlsCfg *tls.Config, role Role, transport Transport) (*Policy, error) { +// acl is netacl's from the `acl` block, nil for a dialer. +func New(o *config.AuthOptions, tlsCfg *tls.Config, acl *netacl.Policy, role Role, transport Transport) (*Policy, error) { if o == nil { return nil, nil } @@ -139,7 +142,7 @@ func New(o *config.AuthOptions, tlsCfg *tls.Config, role Role, transport Transpo if err != nil { return nil, err } - p := &Policy{role: role, transport: transport, method: o.Type, binding: binding} + p := &Policy{role: role, transport: transport, method: o.Type, binding: binding, acl: acl} if o.Type == MethodMTLS { err = p.compileMTLS(o, tlsCfg) } else { diff --git a/internal/authz/authz_test.go b/internal/authz/authz_test.go index 7d8bbd6..a015ac6 100644 --- a/internal/authz/authz_test.go +++ b/internal/authz/authz_test.go @@ -66,7 +66,7 @@ func TestPeerIdentityModes(t *testing.T) { func TestNewDisabled(t *testing.T) { for _, o := range []*config.AuthOptions{nil, {}, {Type: MethodNone}} { - p, err := New(o, nil, RoleListener, TCP) + p, err := New(o, nil, nil, RoleListener, TCP) if err != nil { t.Fatalf("New(%+v) error: %v", o, err) } @@ -160,7 +160,7 @@ func TestNewValidation(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - if _, err := New(tc.auth, tc.tls, tc.role, tc.transport); err == nil || !strings.Contains(err.Error(), tc.want) { + if _, err := New(tc.auth, tc.tls, nil, tc.role, tc.transport); err == nil || !strings.Contains(err.Error(), tc.want) { t.Fatalf("error %v, want one naming %q", err, tc.want) } }) @@ -171,16 +171,16 @@ func TestNewValidation(t *testing.T) { pinned := &tls.Config{InsecureSkipVerify: true, VerifyPeerCertificate: func([][]byte, [][]*x509.Certificate) error { return nil }} for _, o := range []*config.AuthOptions{{Type: MethodMTLS}, dialer(config.AuthOptions{})} { for _, c := range []*tls.Config{{}, pinned} { - if _, err := New(o, c, RoleDialer, TCP); err != nil { + if _, err := New(o, c, nil, RoleDialer, TCP); err != nil { t.Fatalf("dialer policy %s rejected: %v", o.Type, err) } } } - if _, err := New(listener(config.AuthOptions{TokenLifetimeMS: 60000}), f.serverTLS, RoleListener, HTTP); err != nil { + if _, err := New(listener(config.AuthOptions{TokenLifetimeMS: 60000}), f.serverTLS, nil, RoleListener, HTTP); err != nil { t.Fatalf("scram listener rejected: %v", err) } // Behind proxies TLS ends at the proxy, so the hop may be plaintext - if _, err := New(listener(config.AuthOptions{TrustedProxies: proxies}), nil, RoleListener, HTTP); err != nil { + if _, err := New(listener(config.AuthOptions{TrustedProxies: proxies}), nil, nil, RoleListener, HTTP); err != nil { t.Fatalf("proxy-mode listener rejected: %v", err) } } @@ -190,7 +190,7 @@ func TestAuthorizeMatching(t *testing.T) { Type: MethodMTLS, Allow: []string{"edge-01", " edge-02 "}, AllowPatterns: []string{`^relay-\d{2}$`}, - }, mtlsListenerTLS(), RoleListener, TCP) + }, mtlsListenerTLS(), nil, RoleListener, TCP) if err != nil { t.Fatalf("New: %v", err) } @@ -232,7 +232,7 @@ func TestAuthorizeMatching(t *testing.T) { // Empty allow and allow_patterns admits any CA-vouched identity, but still // records it and still refuses a certificate with no usable identity field func TestAuthorizeUnrestricted(t *testing.T) { - p, err := New(&config.AuthOptions{Type: MethodMTLS}, mtlsListenerTLS(), RoleListener, TCP) + p, err := New(&config.AuthOptions{Type: MethodMTLS}, mtlsListenerTLS(), nil, RoleListener, TCP) if err != nil { t.Fatalf("New: %v", err) } @@ -256,7 +256,7 @@ func TestAuthorizeUnrestricted(t *testing.T) { func TestResolveNodeBindings(t *testing.T) { newChain := func(binding string) *Policy { - p, err := New(&config.AuthOptions{Type: MethodMTLS, NodeBinding: binding}, mtlsListenerTLS(), RoleChainListener, TCP) + p, err := New(&config.AuthOptions{Type: MethodMTLS, NodeBinding: binding}, mtlsListenerTLS(), nil, RoleChainListener, TCP) if err != nil { t.Fatalf("New(%q): %v", binding, err) } @@ -332,7 +332,7 @@ func TestIdentityApply(t *testing.T) { func TestVerifyConnectionPinsServer(t *testing.T) { p, err := New(&config.AuthOptions{Type: MethodMTLS, Allow: []string{"relay.internal"}}, - &tls.Config{}, RoleDialer, TCP) + &tls.Config{}, nil, RoleDialer, TCP) if err != nil { t.Fatalf("New: %v", err) } diff --git a/internal/authz/proxy.go b/internal/authz/proxy.go index a6bd354..037d4ee 100644 --- a/internal/authz/proxy.go +++ b/internal/authz/proxy.go @@ -11,6 +11,8 @@ import ( "sync" "time" + "github.com/lixenwraith/logwisp/internal/netacl" + "github.com/lixenwraith/auth" ) @@ -32,15 +34,9 @@ type proxyMode struct { func parseProxies(entries []string) (*proxyMode, error) { m := &proxyMode{} for _, e := range entries { - e = strings.TrimSpace(e) - prefix, err := netip.ParsePrefix(e) + prefix, err := netacl.Parse(e, "tcp") if err != nil { - addr, aerr := netip.ParseAddr(e) - if aerr != nil { - return nil, fmt.Errorf("auth: trusted_proxies entry %q is neither an address nor a CIDR", e) - } - addr = addr.Unmap() - prefix = netip.PrefixFrom(addr, addr.BitLen()) + return nil, fmt.Errorf("auth: trusted_proxies entry %q: %w", e, err) } m.trusted = append(m.trusted, prefix.Masked()) } @@ -51,15 +47,6 @@ func (m *proxyMode) trusts(addr netip.Addr) bool { return slices.ContainsFunc(m.trusted, func(p netip.Prefix) bool { return p.Contains(addr) }) } -// parseHop reads a forwarded or peer address, with or without a port -func parseHop(s string) (netip.Addr, bool) { - if ap, err := netip.ParseAddrPort(s); err == nil { - return ap.Addr().Unmap(), true - } - addr, err := netip.ParseAddr(strings.TrimSuffix(strings.TrimPrefix(s, "["), "]")) - return addr.Unmap(), err == nil -} - // exposedHop reports a plaintext hop from a proxy that may be on another host func (m *proxyMode) exposedHop() bool { return m.plaintext && slices.ContainsFunc(m.trusted, func(p netip.Prefix) bool { return !p.Addr().IsLoopback() }) @@ -68,25 +55,25 @@ func (m *proxyMode) exposedHop() bool { // client is the rightmost X-Forwarded-For hop that is not a trusted proxy: // hops to its left come from the client and prove nothing. When every hop is // trusted, the client sits inside a proxy range and the leftmost hop is it. -func (m *proxyMode) client(peer string, h http.Header) (string, error) { - if addr, ok := parseHop(peer); !ok || !m.trusts(addr) { - return "", fmt.Errorf("%s is not a trusted proxy", peer) +func (m *proxyMode) client(peer string, h http.Header) (netip.Addr, error) { + if addr, ok := netacl.ParseHost(peer); !ok || !m.trusts(addr) { + return netip.Addr{}, fmt.Errorf("%s is not a trusted proxy", peer) } protos := headerList(h, "X-Forwarded-Proto") if len(protos) == 0 || slices.ContainsFunc(protos, func(v string) bool { return !strings.EqualFold(v, "https") }) { - return "", fmt.Errorf("proxy forwarded X-Forwarded-Proto %q; sessions need https", strings.Join(protos, ",")) + return netip.Addr{}, fmt.Errorf("proxy forwarded X-Forwarded-Proto %q; sessions need https", strings.Join(protos, ",")) } hops := headerList(h, "X-Forwarded-For") if len(hops) == 0 { - return "", errors.New("proxy sent no client address in X-Forwarded-For") + return netip.Addr{}, errors.New("proxy sent no client address in X-Forwarded-For") } for i := len(hops) - 1; ; i-- { - addr, ok := parseHop(hops[i]) + addr, ok := netacl.ParseHost(hops[i]) if !ok { - return "", fmt.Errorf("malformed X-Forwarded-For hop %q", hops[i]) + return netip.Addr{}, fmt.Errorf("malformed X-Forwarded-For hop %q", hops[i]) } if i == 0 || !m.trusts(addr) { - return addr.String(), nil + return addr, nil } } } @@ -140,7 +127,8 @@ func (p *Policy) BehindProxy() bool { // ClientAddr is the address throttling, sessions and logs name: the socket // peer, or in proxy mode the forwarded client. A proxy-mode request from an -// untrusted peer, or one the proxy did not receive over https, is refused. +// untrusted peer, one the proxy did not receive over https, or one for a +// client the acl rules refuse, is refused. func (p *Policy) ClientAddr(r *http.Request) (string, error) { peer := remoteIP(r.RemoteAddr) if !p.BehindProxy() { @@ -151,7 +139,10 @@ func (p *Policy) ClientAddr(r *http.Request) (string, error) { p.rejected.Add(1) return "", fmt.Errorf("%w: %w", ErrRefused, err) } - return addr, nil + if err := p.acl.Forwarded(addr, r.RemoteAddr); err != nil { + return "", fmt.Errorf("%w: %w", ErrRefused, err) + } + return addr.String(), nil } // presentedToken is the bearer token or, in proxy mode, the session cookie. diff --git a/internal/authz/proxy_test.go b/internal/authz/proxy_test.go index a85ef77..e33d2f0 100644 --- a/internal/authz/proxy_test.go +++ b/internal/authz/proxy_test.go @@ -11,8 +11,10 @@ import ( "github.com/lixenwraith/logwisp/internal/chain" "github.com/lixenwraith/logwisp/internal/config" + "github.com/lixenwraith/logwisp/internal/netacl" "github.com/lixenwraith/auth" + "github.com/lixenwraith/log" ) // forwarding is the proxy's half of every request: the client and https @@ -79,6 +81,34 @@ func TestProxyModeTrustsOnlyItsProxies(t *testing.T) { } } +// In proxy mode a forwarded client passes the acl rules too, as the proxy +// passed them as the peer; a refusal counts in acl_denied +func TestForwardedClientIsRefusedByTheRules(t *testing.T) { + f := newFixture(t) + acl, err := netacl.New(&config.ACLOptions{Allow: []string{"127.0.0.1", "203.0.113.0/24"}, Deny: []string{"203.0.113.66"}}, + "127.0.0.1", netacl.HTTPProxied, log.NewLogger(), "test", "acl") + if err != nil { + t.Fatal(err) + } + l, err := New(&config.AuthOptions{Type: MethodSCRAM, CredentialsFile: f.creds, TrustedProxies: []string{"127.0.0.1"}}, + nil, acl, RoleListener, HTTP) + if err != nil { + t.Fatal(err) + } + for client, admitted := range map[string]bool{"203.0.113.7": true, "203.0.113.66": false, "198.51.100.1": false} { + r := httptest.NewRequest(http.MethodGet, "/status", nil) + r.RemoteAddr = "127.0.0.1:5000" + r.Header.Set("X-Forwarded-For", client) + r.Header.Set("X-Forwarded-Proto", "https") + if _, err := l.ClientAddr(r); (err == nil) != admitted { + t.Errorf("forwarded %s: %v", client, err) + } + } + if denied, _ := acl.Refused(); denied != 2 { + t.Fatalf("acl_denied %d, want 2", denied) + } +} + // A browser logs in unbound and gets its session only as a cookie that // scopes itself to the mount; the cookie opens the endpoints, beside the // site's own Basic auth too, until logout revokes it. Outside proxy mode diff --git a/internal/authz/scram.go b/internal/authz/scram.go index 305be29..feb1b54 100644 --- a/internal/authz/scram.go +++ b/internal/authz/scram.go @@ -15,17 +15,15 @@ import ( "mime" "net" "net/http" - "net/netip" "os" "strings" - "sync" "sync/atomic" "time" "github.com/lixenwraith/logwisp/internal/chain" "github.com/lixenwraith/logwisp/internal/config" + "github.com/lixenwraith/logwisp/internal/netacl" "github.com/lixenwraith/logwisp/internal/tlsx" - "github.com/lixenwraith/logwisp/internal/tokenbucket" "github.com/lixenwraith/auth" "github.com/lixenwraith/toml" @@ -47,8 +45,6 @@ const ( limitBurst = 10 // failed or abandoned exchanges per address before throttling limitRate = 1 // per second limitPending = 4 // unfinished exchanges per address - limitPeers = 65536 - limitIdle = time.Minute ) // The dialer's floor on the Argon2 cost a server may ask for. A hostile server @@ -194,7 +190,9 @@ type scramListener struct { lifetime time.Duration proxy *proxyMode server atomic.Pointer[auth.ScramServer] - limit limiter + // limit bounds attempts per client: failed or abandoned exchanges drain + // its bucket (successes are refunded), and limitPending may be unfinished + limit *netacl.Table throttled atomic.Uint64 busy atomic.Uint64 @@ -233,7 +231,7 @@ func (p *Policy) compileSCRAM(o *config.AuthOptions, tlsCfg *tls.Config) error { return err } } - l := &scramListener{} + l := &scramListener{limit: netacl.NewTable(limitBurst, limitRate, limitPending)} var err error if len(o.TrustedProxies) > 0 { if l.proxy, err = parseProxies(o.TrustedProxies); err != nil { @@ -307,19 +305,19 @@ func (p *Policy) begin(ip string, first json.RawMessage) (challenge auth.ServerF p.rejected.Add(1) return challenge, http.StatusBadRequest, "malformed request", fmt.Errorf("%w: malformed client-first message", ErrRefused) } - if !l.limit.start(ip) { + if !l.limit.Take(ip) { l.throttled.Add(1) return challenge, http.StatusTooManyRequests, "too many attempts", fmt.Errorf("%w: %s throttled", ErrRefused, ip) } s := l.server.Load() if s == nil { - l.limit.release(ip) + l.limit.Release(ip) l.busy.Add(1) return challenge, http.StatusServiceUnavailable, "busy", errors.New("auth: scram server is not running") } challenge, err = s.ProcessClientFirstMessage(req.Username, req.ClientNonce) if err != nil { - l.limit.release(ip) + l.limit.Release(ip) } switch { case errors.Is(err, auth.ErrSCRAMTooManyHandshakes), errors.Is(err, auth.ErrSCRAMStopped): @@ -329,7 +327,7 @@ func (p *Policy) begin(ip string, first json.RawMessage) (challenge auth.ServerF p.rejected.Add(1) return challenge, http.StatusBadRequest, "malformed request", fmt.Errorf("%w: %w", ErrRefused, err) } - l.limit.track(ip, challenge.FullNonce) + l.limit.Hold(ip, challenge.FullNonce, time.Now().Add(auth.ScramHandshakeTimeout)) return challenge, 0, "", nil } @@ -337,7 +335,7 @@ func (p *Policy) begin(ip string, first json.RawMessage) (challenge auth.ServerF // the client certificate to the user when configured. func (p *Policy) finish(ip, nonce string, step authStep, cs *tls.ConnectionState) (auth.ServerFinalMessage, error) { l := p.listener - l.limit.done(ip, nonce) + l.limit.Done(ip, nonce) s := l.server.Load() if s == nil { l.busy.Add(1) @@ -365,7 +363,7 @@ func (p *Policy) finish(ip, nonce string, step authStep, cs *tls.ConnectionState return auth.ServerFinalMessage{}, fmt.Errorf("%w: %w", ErrRefused, err) } p.allowed.Add(1) - l.limit.succeeded(ip) + l.limit.Refund(ip) return final, nil } @@ -386,7 +384,8 @@ func (p *Policy) bindCertificate(cs *tls.ConnectionState, username string) error // line for Accept. An exchange abandoned after its challenge is released from // the auth table at once rather than holding a slot for its timeout. func (p *Policy) exchangeTCP(a *Admission, cs *tls.ConnectionState) (Identity, []byte, error) { - ip := throttleKey(remoteIP(a.conn.RemoteAddr().String())) + addr, _ := netacl.ParseHost(a.conn.RemoteAddr().String()) + ip := netacl.Key(addr) challenge, _, public, err := p.begin(ip, a.Hello.Scram) if err != nil { writeStep(a.conn, authStep{Error: public}) @@ -395,7 +394,7 @@ func (p *Policy) exchangeTCP(a *Admission, cs *tls.ConnectionState) (Identity, [ settled := false defer func() { if !settled { - p.listener.limit.done(ip, challenge.FullNonce) + p.listener.limit.Done(ip, challenge.FullNonce) if s := p.listener.server.Load(); s != nil { s.ProcessClientFinalMessage(challenge.FullNonce, "") } @@ -443,7 +442,8 @@ func (p *Policy) ServeAuth(w http.ResponseWriter, r *http.Request) (Identity, er writeJSON(w, http.StatusForbidden, authStep{Error: "forbidden"}) return Identity{}, err } - ip := throttleKey(client) + addr, _ := netacl.ParseHost(client) + ip := netacl.Key(addr) // A cross-origin page cannot send JSON without a preflight nobody answers if mt, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")); mt != "application/json" { p.rejected.Add(1) @@ -900,118 +900,3 @@ func remoteIP(addr string) string { } return addr } - -// --- Throttling --- - -// throttleKey is what the limiter counts: an address, or for IPv6 its /64, -// which one host usually holds whole. Link-local too, per zone: a peer picks -// any fe80::/64 address, so per address it would escape its budget. -func throttleKey(ip string) string { - addr, err := netip.ParseAddr(ip) - if err != nil || addr.Is4() { - return ip - } - key := netip.PrefixFrom(addr, 64).Masked().String() - if zone := addr.Zone(); zone != "" { - key += "%" + zone - } - return key -} - -// limiter bounds SCRAM attempts per remote address: failed or abandoned -// exchanges drain a token bucket (successes are refunded) and at most -// limitPending may be unfinished. A full table fails closed. -type limiter struct { - mu sync.Mutex - peers map[string]*peerLimit - lastSweep time.Time -} - -type peerLimit struct { - bucket *tokenbucket.TokenBucket - pending map[string]time.Time // full nonce -> expiry - reserved int // started, challenge not yet issued - seen time.Time -} - -func (l *limiter) start(ip string) bool { - now := time.Now() - l.mu.Lock() - defer l.mu.Unlock() - if l.peers == nil { - l.peers = make(map[string]*peerLimit) - } - if now.Sub(l.lastSweep) > limitIdle/6 { - l.sweep(now) - } - pl := l.peers[ip] - if pl == nil { - if len(l.peers) >= limitPeers { - return false - } - pl = &peerLimit{bucket: tokenbucket.New(limitBurst, limitRate), pending: make(map[string]time.Time)} - l.peers[ip] = pl - } - pl.seen = now - pl.expire(now) - // Reserved under this lock: concurrent hellos cannot all pass the check - if len(pl.pending)+pl.reserved >= limitPending || !pl.bucket.Allow() { - return false - } - pl.reserved++ - return true -} - -// track turns start's reservation into the exchange's nonce -func (l *limiter) track(ip, nonce string) { - l.mu.Lock() - defer l.mu.Unlock() - if pl := l.peers[ip]; pl != nil { - pl.reserved-- - pl.pending[nonce] = time.Now().Add(auth.ScramHandshakeTimeout) - } -} - -// release frees start's reservation when no challenge was issued -func (l *limiter) release(ip string) { - l.mu.Lock() - defer l.mu.Unlock() - if pl := l.peers[ip]; pl != nil { - pl.reserved-- - } -} - -func (l *limiter) done(ip, nonce string) { - l.mu.Lock() - defer l.mu.Unlock() - if pl := l.peers[ip]; pl != nil { - delete(pl.pending, nonce) - } -} - -func (l *limiter) succeeded(ip string) { - l.mu.Lock() - defer l.mu.Unlock() - if pl := l.peers[ip]; pl != nil { - pl.bucket.Refund(1) - } -} - -func (pl *peerLimit) expire(now time.Time) { - for nonce, expiry := range pl.pending { - if now.After(expiry) { - delete(pl.pending, nonce) - } - } -} - -// sweep drops addresses idle long enough for their bucket to be full again -func (l *limiter) sweep(now time.Time) { - l.lastSweep = now - for ip, pl := range l.peers { - pl.expire(now) // an abandoned HTTP challenge has no done - if now.Sub(pl.seen) > limitIdle && len(pl.pending) == 0 && pl.reserved == 0 { - delete(l.peers, ip) - } - } -} diff --git a/internal/authz/scram_test.go b/internal/authz/scram_test.go index ebd17e3..5dcb488 100644 --- a/internal/authz/scram_test.go +++ b/internal/authz/scram_test.go @@ -13,6 +13,7 @@ import ( "net" "net/http" "net/http/httptest" + "net/netip" "os" "path/filepath" "regexp" @@ -23,6 +24,7 @@ import ( "github.com/lixenwraith/logwisp/internal/chain" "github.com/lixenwraith/logwisp/internal/config" + "github.com/lixenwraith/logwisp/internal/netacl" "github.com/lixenwraith/logwisp/internal/testutil" "github.com/lixenwraith/auth" @@ -100,7 +102,7 @@ func (f *fixture) pool(t *testing.T) *x509.CertPool { func (f *fixture) listener(t *testing.T, o config.AuthOptions, tlsCfg *tls.Config, role Role, transport Transport) *Policy { t.Helper() o.Type, o.CredentialsFile = MethodSCRAM, f.creds - p, err := New(&o, tlsCfg, role, transport) + p, err := New(&o, tlsCfg, nil, role, transport) if err != nil { t.Fatal(err) } @@ -114,7 +116,7 @@ func (f *fixture) listener(t *testing.T, o config.AuthOptions, tlsCfg *tls.Confi func (f *fixture) dialer(t *testing.T, user, password string) *Policy { t.Helper() pw := f.write(t, user+"-"+strings.ReplaceAll(password, "/", "_")+".pass", password+"\n") - p, err := New(&config.AuthOptions{Type: MethodSCRAM, Username: user, PasswordFile: pw}, f.clientTLS, RoleDialer, TCP) + p, err := New(&config.AuthOptions{Type: MethodSCRAM, Username: user, PasswordFile: pw}, f.clientTLS, nil, RoleDialer, TCP) if err != nil { t.Fatal(err) } @@ -294,11 +296,10 @@ func TestAbandonedExchangeReleasesItsSlot(t *testing.T) { if _, err := l.listener.server.Load().ProcessClientFinalMessage(step.Challenge.FullNonce, "AAAA"); !errors.Is(err, auth.ErrSCRAMInvalidNonce) { t.Fatalf("abandoned handshake still pending: %v", err) } - lim := &l.listener.limit - lim.mu.Lock() - defer lim.mu.Unlock() - if pl := lim.peers["pipe"]; pl == nil || len(pl.pending) != 0 || pl.reserved != 0 { - t.Fatalf("limiter still holds the abandoned exchange: %+v", pl) + for i := range limitPending { // a pipe's peer has no address + if !l.listener.limit.Take(netacl.Key(netip.Addr{})) { + t.Fatalf("limiter still holds the abandoned exchange: slot %d taken", i) + } } } @@ -417,15 +418,6 @@ func TestDialerRefusesACheapChallenge(t *testing.T) { } } -// Link-local peers share one budget per link, not one across every link: the -// zone names the link and stays in the key. -func TestLinkLocalBudgetIsPerLink(t *testing.T) { - a, b, other := throttleKey("fe80::1%eth0"), throttleKey("fe80::2%eth0"), throttleKey("fe80::1%eth1") - if a != b || a == other { - t.Fatalf("keys %q, %q on one link, %q on another", a, b, other) - } -} - // Only failed attempts drain an address's budget; beyond it the listener // answers "too many attempts" without running the exchange. func TestOnlyFailedAttemptsAreThrottled(t *testing.T) { @@ -447,46 +439,6 @@ func TestOnlyFailedAttemptsAreThrottled(t *testing.T) { } } -// Successes are refunded; unfinished exchanges are capped per address from -// the moment they start, so concurrent hellos cannot all pass; a full table -// refuses new addresses, and the sweep frees addresses whose challenges were -// abandoned. -func TestLimiterBounds(t *testing.T) { - var l limiter - for i := range 2 * limitBurst { - if !l.start("10.0.0.1") { - t.Fatalf("successful attempt %d throttled", i) - } - l.track("10.0.0.1", "n") - l.done("10.0.0.1", "n") - l.succeeded("10.0.0.1") - } - for i := range limitPending { - if !l.start("10.0.0.2") { - t.Fatalf("pending exchange %d throttled", i) - } - } - if l.start("10.0.0.2") { - t.Fatal("an exchange beyond the pending cap was admitted before any challenge") - } - l.release("10.0.0.2") - if !l.start("10.0.0.2") { - t.Fatal("a released reservation still counts") - } - for i := len(l.peers); i < limitPeers; i++ { - l.peers[net.IPv4(10, 1, byte(i>>8), byte(i)).String()] = &peerLimit{ - seen: time.Now(), pending: map[string]time.Time{"abandoned": time.Now().Add(time.Second)}, - } - } - if l.start("192.0.2.1") { - t.Fatal("a full table admitted a new address") - } - l.sweep(time.Now().Add(2 * limitIdle)) - if len(l.peers) != 1 { // 10.0.0.2 still holds its reservations - t.Fatalf("%d addresses left after their challenges expired, want 1", len(l.peers)) - } -} - // httpListener serves /auth and one protected endpoint over TLS func (f *fixture) httpListener(t *testing.T, p *Policy, tlsCfg *tls.Config) (*httptest.Server, *atomic.Int64) { t.Helper() diff --git a/internal/config/config.go b/internal/config/config.go index 732bcd2..4d23964 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -375,13 +375,15 @@ type AuthOptions struct { // ACLOptions is a listener's address rules, entries being addresses or CIDRs: // deny wins, then a set allow list admits only its entries. proxy_from lists -// the L4 proxies whose PROXY header names the client the rules then see. -// Validation is in internal/netacl. +// the L4 proxies whose PROXY header names the client the rules then see; the +// per-client limits count that client. Validation is in internal/netacl. type ACLOptions struct { - Allow []string `toml:"allow"` - Deny []string `toml:"deny"` - ProxyProtocol string `toml:"proxy_protocol"` // off, optional or required - ProxyFrom []string `toml:"proxy_from"` + Allow []string `toml:"allow"` + Deny []string `toml:"deny"` + ProxyProtocol string `toml:"proxy_protocol"` // off, optional or required + ProxyFrom []string `toml:"proxy_from"` + MaxConnectionsPerClient int64 `toml:"max_connections_per_client"` // 0 = unlimited + RequestsPerSecondPerClient float64 `toml:"requests_per_second_per_client"` // HTTP listeners; 0 = unlimited } // --- TLS Options --- diff --git a/internal/config/preset.go b/internal/config/preset.go index 1a75041..4d58ee2 100644 --- a/internal/config/preset.go +++ b/internal/config/preset.go @@ -41,7 +41,7 @@ var ( {Name: "hosts", List: true, Help: "names and addresses, beyond this host's, of a self or issuer certificate; ',' between them"}, } aclParams = []PresetParam{ - {Name: "allow", List: true, Help: "addresses or CIDRs that may connect, of listen's family; default: all; ',' between them"}, + {Name: "allow", List: true, Help: "addresses or CIDRs that may connect, of listen's family (either behind proxy=); default: all; ',' between them"}, {Name: "deny", List: true, Help: "addresses or CIDRs refused, allow or not; ',' between them"}, } transportParam = PresetParam{Name: "transport", Default: "tcp", Help: "tcp|http: tcp_chain or http_chain"} diff --git a/internal/netacl/limit.go b/internal/netacl/limit.go new file mode 100644 index 0000000..b122a35 --- /dev/null +++ b/internal/netacl/limit.go @@ -0,0 +1,155 @@ +package netacl + +import ( + "net/netip" + "sync" + "time" + + "github.com/lixenwraith/logwisp/internal/tokenbucket" +) + +const ( + maxClients = 65536 // per table; past it new clients are refused + slotIdle = time.Minute // without a rate: Release drops a client, the sweep only expired holds +) + +// Key is what per-client limits count: the address, or for IPv6 its /64, +// which one host usually holds whole. Link-local too, per zone: a peer picks +// any fe80::/64 address, so per address it would escape its budget. +func Key(addr netip.Addr) string { + addr = addr.Unmap() + if !addr.Is6() { + return addr.String() + } + key := netip.PrefixFrom(addr, 64).Masked().String() + if zone := addr.Zone(); zone != "" { + key += "%" + zone + } + return key +} + +// Table limits each client key: an attempt spends a token from its bucket, if +// the table has a rate, and takes a slot, if it caps slots; a slot is +// released, or held by an id until done or expired. A full table refuses new +// keys, failing closed, and a sweep drops the idle ones. +type Table struct { + burst, rate float64 + slots int + idle time.Duration // a bucket's refill: a client idle that long loses nothing when dropped + + mu sync.Mutex + clients map[string]*client + lastSweep time.Time +} + +type client struct { + bucket *tokenbucket.TokenBucket // nil without a rate + held map[string]time.Time // slots held by id, until their expiry + taken int // slots taken and not yet held or released + seen time.Time +} + +// NewTable allows each client burst attempts refilled at rate per second (0: +// no rate) and slots at once (0: no cap) +func NewTable(burst, rate float64, slots int) *Table { + t := &Table{burst: burst, rate: rate, slots: slots, idle: slotIdle, clients: make(map[string]*client)} + if rate > 0 { + t.idle = time.Duration(burst / rate * float64(time.Second)) + } + return t +} + +// Take admits an attempt by key, false when its tokens or slots are spent or +// the table is full +func (t *Table) Take(key string) bool { + now := time.Now() + t.mu.Lock() + defer t.mu.Unlock() + if now.Sub(t.lastSweep) > t.idle/6 { + t.sweep(now) + } + c := t.clients[key] + if c == nil { + if len(t.clients) >= maxClients { + return false + } + c = &client{held: make(map[string]time.Time)} + if t.rate > 0 { + c.bucket = tokenbucket.New(t.burst, t.rate) + } + t.clients[key] = c + } + c.seen = now + c.expire(now) + // Taken under this lock: concurrent attempts cannot all pass the check + if t.slots > 0 && len(c.held)+c.taken >= t.slots || c.bucket != nil && !c.bucket.Allow() { + return false + } + if t.slots > 0 { + c.taken++ + } + return true +} + +// Hold moves a slot Take took to id, until done or expired +func (t *Table) Hold(key, id string, until time.Time) { + t.mu.Lock() + defer t.mu.Unlock() + if c := t.clients[key]; c != nil { + c.taken-- + c.held[id] = until + } +} + +// Release frees a slot Take took that no id holds +func (t *Table) Release(key string) { + t.mu.Lock() + defer t.mu.Unlock() + if c := t.clients[key]; c != nil { + c.taken-- + if c.idle(time.Now(), t.idle) { + delete(t.clients, key) + } + } +} + +// Done frees the slot id holds +func (t *Table) Done(key, id string) { + t.mu.Lock() + defer t.mu.Unlock() + if c := t.clients[key]; c != nil { + delete(c.held, id) + } +} + +// Refund returns the token of an attempt that turned out not to count +func (t *Table) Refund(key string) { + t.mu.Lock() + defer t.mu.Unlock() + if c := t.clients[key]; c != nil && c.bucket != nil { + c.bucket.Refund(1) + } +} + +func (c *client) expire(now time.Time) { + for id, expiry := range c.held { + if now.After(expiry) { + delete(c.held, id) + } + } +} + +// idle reports a client holding nothing whose bucket, if any, is full again +func (c *client) idle(now time.Time, after time.Duration) bool { + return c.taken == 0 && len(c.held) == 0 && (c.bucket == nil || now.Sub(c.seen) > after) +} + +func (t *Table) sweep(now time.Time) { + t.lastSweep = now + for key, c := range t.clients { + c.expire(now) // an abandoned hold has no Done + if c.idle(now, t.idle) { + delete(t.clients, key) + } + } +} diff --git a/internal/netacl/netacl.go b/internal/netacl/netacl.go index a8ca64e..3482ee7 100644 --- a/internal/netacl/netacl.go +++ b/internal/netacl/netacl.go @@ -1,8 +1,8 @@ // Package netacl is the address seam of the network listeners, beside tlsx // and authz: a Policy reads the PROXY header of the proxies it trusts, then -// refuses a peer by its client's address, before TLS or a byte of the -// protocol is read. New returns nil when nothing is set, and every method -// tolerates a nil receiver. +// refuses a peer by its client's address, or past its client's limits, before +// TLS or a byte of the protocol is read. New returns nil when nothing is set, +// and every method tolerates a nil receiver. package netacl import ( @@ -12,7 +12,9 @@ import ( "encoding/binary" "errors" "fmt" + "math" "net" + "net/http" "net/netip" "slices" "strconv" @@ -29,7 +31,7 @@ import ( ) const ( - // warnEvery spaces the WARNs reporting refused peers; acl_denied counts all + // warnEvery spaces the WARNs reporting refusals; acl_denied and acl_limited count all warnEvery = time.Minute v1Max = 107 // the longest PROXY v1 line, CRLF included v2Max = 2048 // the longest PROXY v2 address block, TLVs included @@ -46,34 +48,48 @@ var ( netip.MustParsePrefix("fe80::/10"), netip.MustParsePrefix("::1/128"), } errScreened = errors.New("PROXY header from a peer outside proxy_from") + // ErrDenied marks a refusal by the rules, which the Policy has reported + ErrDenied = errors.New("refused by acl") // 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} ) +// Kind is what a listener serves, which decides the limits it takes and whose +// addresses its rules judge +type Kind int + +const ( + TCP Kind = iota + HTTP // takes requests_per_second_per_client + HTTPProxied // HTTP behind trusted_proxies: rules judge each forwarded client too +) + // Policy is the compiled form of config.ACLOptions for one listener type Policy struct { allow, deny, proxyFrom []netip.Prefix mode string // proxy_protocol, when proxy_from is set headerWait time.Duration // for a proxy's header: tlsx.HandshakeTimeout + conns, requests *Table // max_connections_per_client, requests_per_second_per_client logger *log.Logger component, id string - denied, headers atomic.Uint64 - epoch time.Time // a monotonic base: a stepped clock neither mutes nor floods the WARN - nextWarn atomic.Int64 // nanoseconds since epoch + denied, limited, headers atomic.Uint64 + epoch time.Time // a monotonic base: a stepped clock neither mutes nor floods the WARN + nextWarn atomic.Int64 // nanoseconds since epoch } -// New compiles the rules of a listener on host and warns of what they leave -// open. Entries are of the family core.Network gives host, as one the sockets -// 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) { +// New compiles the rules of a kind of listener on host, and warns of what they +// leave open. Entries are of the family core.Network gives host, as one the +// sockets never carry is a mistake; behind a proxy, allow and deny take both, +// as it names clients of either. +func New(o *config.ACLOptions, host string, kind Kind, l *log.Logger, component, id string) (*Policy, error) { if o == nil { return nil, nil } proxied := o.ProxyProtocol == "optional" || o.ProxyProtocol == "required" + rps := o.RequestsPerSecondPerClient 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) @@ -81,7 +97,15 @@ func New(o *config.ACLOptions, host string, l *log.Logger, component, id string) 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: + case o.MaxConnectionsPerClient < 0: + return nil, fmt.Errorf("acl: max_connections_per_client %d is negative", o.MaxConnectionsPerClient) + case !(rps >= 0) || math.IsInf(rps, 0): + return nil, fmt.Errorf("acl: requests_per_second_per_client %v is not a rate", rps) + case rps > 0 && kind == TCP: + return nil, errors.New("acl: requests_per_second_per_client applies only to HTTP listeners") + case o.MaxConnectionsPerClient > 0 && kind == HTTPProxied: + return nil, errors.New("acl: max_connections_per_client counts connections, behind trusted_proxies the proxy's; cap clients at the proxy") + case len(o.Allow)+len(o.Deny)+len(o.ProxyFrom) == 0 && o.MaxConnectionsPerClient == 0 && rps == 0: return nil, nil } network, err := core.Network(host) @@ -92,7 +116,7 @@ func New(o *config.ACLOptions, host string, l *log.Logger, component, id string) compile := func(key, network string, entries []string) ([]netip.Prefix, error) { var rules []netip.Prefix for _, e := range entries { - prefix, err := parse(e, network) + prefix, err := Parse(e, network) if err != nil { return nil, fmt.Errorf("acl: %s entry %q: %w", key, e, err) } @@ -107,7 +131,7 @@ func New(o *config.ACLOptions, host string, l *log.Logger, component, id string) if p.proxyFrom, err = compile("proxy_from", network, o.ProxyFrom); err != nil { return nil, err } - if proxied { + if proxied || kind == HTTPProxied { network = "tcp" } if p.allow, err = compile("allow", network, o.Allow); err != nil { @@ -116,11 +140,18 @@ func New(o *config.ACLOptions, host string, l *log.Logger, component, id string) if p.deny, err = compile("deny", network, o.Deny); err != nil { return nil, err } + if o.MaxConnectionsPerClient > 0 { + p.conns = NewTable(0, 0, int(o.MaxConnectionsPerClient)) + } + if rps > 0 { + p.requests = NewTable(max(rps, 1), rps, 0) // a second's worth, at least one + } p.logStartup(host, widened) return p, nil } -func parse(entry, network string) (netip.Prefix, error) { +// Parse reads an address or CIDR of network's family ("tcp": either) +func Parse(entry, network string) (netip.Prefix, error) { entry = strings.TrimSpace(entry) prefix, err := netip.ParsePrefix(entry) if err != nil { @@ -139,6 +170,15 @@ func parse(entry, network string) (netip.Prefix, error) { return prefix, nil } +// ParseHost reads an address with or without a port, an IPv4-mapped one as IPv4 +func ParseHost(s string) (netip.Addr, bool) { + if ap, err := netip.ParseAddrPort(s); err == nil { + return ap.Addr().Unmap(), true + } + addr, err := netip.ParseAddr(strings.TrimSuffix(strings.TrimPrefix(s, "["), "]")) + return addr.Unmap(), err == nil +} + func (p *Policy) logStartup(host string, widened []string) { for i := 0; i < len(widened); i += 2 { p.logger.Warn("msg", "acl entry has host bits set and matches its whole network", @@ -281,10 +321,10 @@ func (l *listener) proxied(c net.Conn) { pc := &conn{Conn: c, r: br} switch { case err != nil: - l.p.refuse(c, "PROXY header: "+err.Error()) + l.p.refuse(c, &l.p.denied, "PROXY header: "+err.Error()) return case !found && l.p.mode == "required": - l.p.refuse(c, "no PROXY header") + l.p.refuse(c, &l.p.denied, "no PROXY header") return case found: l.p.headers.Add(1) @@ -295,12 +335,27 @@ func (l *listener) proxied(c net.Conn) { l.admit(pc) } -// admit hands c to Accept if the rules admit the address it ends with +// admit hands c to Accept if the rules admit the address it ends with, and +// that client has a connection to spare, which closing c returns func (l *listener) admit(c net.Conn) { - if !l.p.admits(addrOf(c)) { - l.p.refuse(c, "address rules") + addr := addrOf(c) + if !l.p.admits(addr) { + l.p.refuse(c, &l.p.denied, "address rules") return } + if l.p.conns != nil { + key := Key(addr) + if !l.p.conns.Take(key) { + l.p.refuse(c, &l.p.limited, "max_connections_per_client") + return + } + pc, ok := c.(*conn) + if !ok { + pc = &conn{Conn: c} + } + pc.release = func() { l.p.conns.Release(key) } + c = pc + } l.deliver(c, nil) } @@ -316,36 +371,80 @@ func (l *listener) deliver(c net.Conn, err error) bool { } } -// 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 +// refuse closes c and counts it, first: a peer that sees the close sees the +// count +func (p *Policy) refuse(c net.Conn, count *atomic.Uint64, reason string) { + count.Add(1) c.Close() + p.report("Connection", fmt.Sprint(c.RemoteAddr()), PeerAddr(c), reason) +} + +// report WARNs of a refusal at most once per warnEvery; the counts keep the rest +func (p *Policy) report(what, client, peer, reason string) { now, next := int64(time.Since(p.epoch)), p.nextWarn.Load() if now < next || !p.nextWarn.CompareAndSwap(next, now+int64(warnEvery)) { return } - fields := []any{"msg", "Connection refused by acl; more within a minute are only counted", + fields := []any{"msg", what + " refused by acl; more within a minute are only counted", "component", p.component, "instance_id", p.id, - "remote_addr", fmt.Sprint(c.RemoteAddr()), + "remote_addr", client, "reason", reason, - "acl_denied", n} - if peer := PeerAddr(c); peer != "" { + "acl_denied", p.denied.Load(), + "acl_limited", p.limited.Load()} + if peer != "" { fields = append(fields, "peer_addr", peer) } p.logger.Warn(fields...) } +// Requests answers 429 to a client past requests_per_second_per_client. +// client names it in a request (nil: the socket peer); a nil Policy, or one +// without the limit, returns next. +func (p *Policy) Requests(next http.Handler, client func(*http.Request) string) http.Handler { + if p == nil || p.requests == nil { + return next + } + if client == nil { + client = func(r *http.Request) string { return r.RemoteAddr } + } + retry := strconv.FormatFloat(math.Ceil(1/p.requests.rate), 'f', 0, 64) // until a request's token is back + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + addr, _ := ParseHost(client(r)) + if !p.requests.Take(Key(addr)) { + p.limited.Add(1) + p.report("Request", client(r), ContextPeerAddr(r.Context()), "requests_per_second_per_client") + w.Header().Set("Retry-After", retry) + http.Error(w, "too many requests", http.StatusTooManyRequests) + return + } + next.ServeHTTP(w, r) + }) +} + +// Forwarded applies the rules to a client an L7 proxy, peer, forwarded, the +// proxy having passed them at connect; a refusal counts in acl_denied +func (p *Policy) Forwarded(client netip.Addr, peer string) error { + if p == nil || p.admits(client) { + return nil + } + p.denied.Add(1) + p.report("Request", client.String(), peer, "address rules") + return fmt.Errorf("forwarded client %s %w", client, ErrDenied) +} + // 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 +// outside proxy_from at the first Read. Closing it releases its client's slot. type conn struct { net.Conn - r *bufio.Reader - client net.Addr // nil keeps the socket's - screen *Policy - once sync.Once - err error + r *bufio.Reader // nil: read the socket + client net.Addr // nil keeps the socket's + screen *Policy + release func() // nil without max_connections_per_client + once sync.Once + closed sync.Once + err error } func (c *conn) Read(b []byte) (int, error) { @@ -355,15 +454,34 @@ func (c *conn) Read(b []byte) (int, error) { } if v, _ := signature(c.r); v != 0 { c.err = errScreened - c.screen.refuse(c, errScreened.Error()) + c.screen.refuse(c, &c.screen.denied, errScreened.Error()) } }) - if c.err != nil { + switch { + case c.err != nil: return 0, c.err + case c.r == nil: + return c.Conn.Read(b) } return c.r.Read(b) } +func (c *conn) Close() error { + if c.release != nil { + c.closed.Do(c.release) + } + return c.Conn.Close() +} + +// CloseWrite keeps the half-close net/http sends before dropping a connection +// with a body unread, lest the peer get a reset instead of the answer +func (c *conn) CloseWrite() error { + if cw, ok := c.Conn.(interface{ CloseWrite() error }); ok { + return cw.CloseWrite() + } + return errors.ErrUnsupported +} + func (c *conn) RemoteAddr() net.Addr { if c.client != nil { return c.client @@ -511,14 +629,21 @@ func (p *Policy) Describe() string { if len(p.proxyFrom) > 0 { s += fmt.Sprintf(" proxy_protocol=%s proxy_from=%d", p.mode, len(p.proxyFrom)) } + if p.conns != nil { + s += fmt.Sprintf(" max_connections_per_client=%d", p.conns.slots) + } + if p.requests != nil { + s += fmt.Sprintf(" requests_per_second_per_client=%g", p.requests.rate) + } return s } -func (p *Policy) Denied() uint64 { +// Refused counts the refusals by the rules and by the per-client limits +func (p *Policy) Refused() (denied, limited uint64) { if p == nil { - return 0 + return 0, 0 } - return p.denied.Load() + return p.denied.Load(), p.limited.Load() } // Stats reports the policy for a plugin's stats details, merged in with @@ -527,5 +652,6 @@ func (p *Policy) Stats() map[string]any { if p == nil { return nil } - return map[string]any{"acl": p.Describe(), "acl_denied": p.denied.Load(), "acl_proxy_headers": p.headers.Load()} + return map[string]any{"acl": p.Describe(), "acl_denied": p.denied.Load(), "acl_limited": p.limited.Load(), + "acl_proxy_headers": p.headers.Load()} } diff --git a/internal/netacl/netacl_test.go b/internal/netacl/netacl_test.go index b1e6bf0..d6bacbc 100644 --- a/internal/netacl/netacl_test.go +++ b/internal/netacl/netacl_test.go @@ -7,8 +7,11 @@ import ( "errors" "io" "net" + "net/http" + "net/http/httptest" "net/netip" "slices" + "strconv" "strings" "sync" "testing" @@ -21,7 +24,7 @@ import ( func mustNew(t *testing.T, host string, o config.ACLOptions) *Policy { t.Helper() - p, err := New(&o, host, log.NewLogger(), "test", "acl") + p, err := New(&o, host, HTTP, log.NewLogger(), "test", "acl") if err != nil { t.Fatal(err) } @@ -57,7 +60,8 @@ func TestDenyWinsThenAllowListAdmitsOnlyItsEntries(t *testing.T) { // 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, // 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. +// proxy_from, which keeps that family, or trusted_proxies, deny takes both: the +// proxy names either. func TestEntriesFollowTheListenerFamily(t *testing.T) { for _, c := range []struct { host, from, entry string @@ -78,10 +82,13 @@ func TestEntriesFollowTheListenerFamily(t *testing.T) { if c.from != "" { o.ProxyProtocol, o.ProxyFrom = "required", []string{c.from} } - if _, err := New(o, c.host, log.NewLogger(), "test", "acl"); (err == nil) != c.ok { + if _, err := New(o, c.host, HTTP, log.NewLogger(), "test", "acl"); (err == nil) != c.ok { t.Errorf("%s behind %q on %q: %v", c.entry, c.from, c.host, err) } } + if _, err := New(&config.ACLOptions{Deny: []string{"2001:db8::/32"}}, "127.0.0.1", HTTPProxied, log.NewLogger(), "test", "acl"); err != nil { + t.Errorf("IPv6 entry behind trusted_proxies: %v", err) + } } // A link-local peer carries its interface zone, which no prefix contains: the @@ -124,7 +131,7 @@ func TestProxyProtocolAndProxyFromComeTogether(t *testing.T) { {"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") + _, err := New(&config.ACLOptions{ProxyProtocol: c.mode, ProxyFrom: c.from}, "127.0.0.1", HTTP, log.NewLogger(), "test", "acl") if (err == nil) != c.ok { t.Errorf("proxy_protocol %q with proxy_from %v: %v", c.mode, c.from, err) } @@ -217,7 +224,7 @@ func TestHeaderFromUnlistedPeerIsRefused(t *testing.T) { t.Errorf("%q from an unlisted peer: read %q, %v", c.send, b, err) } } - if n := p.Denied(); n != 2 { + if n := p.denied.Load(); n != 2 { t.Fatalf("acl_denied = %d", n) } } @@ -273,11 +280,11 @@ func TestSlowHeaderHoldsOnlyItsConnection(t *testing.T) { 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()) + if got := accept(t, ln).RemoteAddr().String(); !strings.HasPrefix(got, "192.0.2.9:") || p.denied.Load() != 0 { + t.Fatalf("Accept returned %s after %d refusals", got, p.denied.Load()) } closed(t, slow) - if n := p.Denied(); n != 1 { + if n := p.denied.Load(); n != 1 { t.Fatalf("acl_denied = %d", n) } } @@ -295,6 +302,151 @@ func TestCloseReleasesHeadersInFlight(t *testing.T) { } } +// A client holds at most max_connections_per_client at once, counted by the +// address it ends with: behind proxy_from, the one its header names. Closing +// a connection returns its slot; refusals count in acl_limited. +func TestConnectionsPerClientAreCapped(t *testing.T) { + p := mustNew(t, "127.0.0.1", config.ACLOptions{MaxConnectionsPerClient: 2, ProxyProtocol: "optional", ProxyFrom: []string{"127.0.0.1"}}) + ln, dial := listening(t, p) + dial("192.0.2.1") + first := accept(t, ln) + dial("192.0.2.1") + accept(t, ln) + third := dial("127.0.0.1") + third.Write([]byte("PROXY TCP4 192.0.2.1 192.0.2.9 40000 443\r\n")) + closed(t, third) + go dial("127.0.0.1").Write([]byte("PROXY TCP4 192.0.2.2 192.0.2.9 40000 443\r\n")) + accept(t, ln) + first.Close() + dial("192.0.2.1") + accept(t, ln) + if denied, limited := p.Refused(); denied != 0 || limited != 1 { + t.Fatalf("acl_denied %d, acl_limited %d", denied, limited) + } +} + +// A capped connection still half-closes, as net/http does before dropping one +// with a body unread +func TestCappedConnectionHalfCloses(t *testing.T) { + inner, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + ln := mustNew(t, "127.0.0.1", config.ACLOptions{MaxConnectionsPerClient: 1}).Listener(inner) + t.Cleanup(func() { ln.Close() }) + peer, err := net.Dial("tcp4", inner.Addr().String()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { peer.Close() }) + if cw, ok := accept(t, ln).(interface{ CloseWrite() error }); !ok || cw.CloseWrite() != nil { + t.Fatal("the accepted connection cannot half-close") + } + closed(t, peer) +} + +// requests_per_second_per_client answers 429 to a client past its budget, a +// second's worth and at least one request, counted by the address client +// names, with the seconds until its next; refusals count in acl_limited +func TestRequestsPerClientAreRateLimited(t *testing.T) { + p := mustNew(t, "127.0.0.1", config.ACLOptions{RequestsPerSecondPerClient: 0.5}) + h := p.Requests(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), + func(r *http.Request) string { return r.Header.Get("X-Client") }) + get := func(client string) *httptest.ResponseRecorder { + w, r := httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("X-Client", client) + h.ServeHTTP(w, r) + return w + } + for i, want := range []int{http.StatusOK, http.StatusTooManyRequests} { + if got := get("192.0.2.1"); got.Code != want { + t.Fatalf("request %d: %d, want %d", i, got.Code, want) + } + } + if got := get("192.0.2.1").Header().Get("Retry-After"); got != "2" { + t.Fatalf("Retry-After %q, want 2", got) + } + if got := get("192.0.2.2:5000"); got.Code != http.StatusOK { + t.Fatalf("another client: %d", got.Code) + } + if _, limited := p.Refused(); limited != 2 { + t.Fatalf("acl_limited %d", limited) + } +} + +// Limits count an IPv6 client by its /64, which one host holds whole, and a +// link-local one per link; an IPv4-mapped client counts as IPv4 +func TestKeyGroupsOneHostsAddresses(t *testing.T) { + for _, c := range []struct { + a, b string + same bool + }{ + {"2001:db8::1", "2001:db8::2", true}, + {"2001:db8::1", "2001:db8:0:1::1", false}, + {"fe80::1%eth0", "fe80::2%eth0", true}, + {"fe80::1%eth0", "fe80::1%eth1", false}, + {"::ffff:192.0.2.1", "192.0.2.1", true}, + {"192.0.2.1", "192.0.2.2", false}, + } { + a, b := Key(netip.MustParseAddr(c.a)), Key(netip.MustParseAddr(c.b)) + if (a == b) != c.same { + t.Errorf("%s keys %q, %s keys %q", c.a, a, c.b, b) + } + } +} + +// Refunded attempts cost nothing and released ones keep their cost; slots are +// capped from the moment they are taken, so concurrent attempts cannot all +// pass; a full table refuses new clients, and the sweep frees clients whose +// holds expired or whose bucket is full again. +func TestTableBounds(t *testing.T) { + tb := NewTable(10, 1, 4) + for i := range 20 { + if !tb.Take("10.0.0.1") { + t.Fatalf("refunded attempt %d refused", i) + } + tb.Hold("10.0.0.1", strconv.Itoa(i), time.Now().Add(time.Minute)) + tb.Done("10.0.0.1", strconv.Itoa(i)) + tb.Refund("10.0.0.1") + } + for i := range 4 { + if !tb.Take("10.0.0.2") { + t.Fatalf("slot %d refused", i) + } + } + if tb.Take("10.0.0.2") { + t.Fatal("an attempt beyond the slots was admitted before any hold") + } + tb.Release("10.0.0.2") + if !tb.Take("10.0.0.2") { + t.Fatal("a released slot still counts") + } + for range 10 { + tb.Take("10.0.0.3") + tb.Release("10.0.0.3") + } + if tb.Take("10.0.0.3") { + t.Fatal("releasing its slots refilled a client's bucket") + } + for i := len(tb.clients); i < maxClients; i++ { + tb.clients[netip.AddrFrom4([4]byte{10, 1, byte(i >> 8), byte(i)}).String()] = &client{ + seen: time.Now(), held: map[string]time.Time{"abandoned": time.Now().Add(time.Second)}, + } + } + if tb.Take("192.0.2.1") { + t.Fatal("a full table admitted a new client") + } + tb.sweep(time.Now().Add(2 * tb.idle)) + if len(tb.clients) != 1 { // 10.0.0.2 still holds its slots + t.Fatalf("%d clients left after their holds expired, want 1", len(tb.clients)) + } + rate := NewTable(1, 1, 0) + rate.Take("10.0.0.4") + if rate.sweep(time.Now().Add(1100 * time.Millisecond)); len(rate.clients) != 0 { + t.Fatal("a client outlived the refill of its bucket") + } +} + // 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. diff --git a/internal/sink/http/http.go b/internal/sink/http/http.go index 259b7ca..dd9a806 100644 --- a/internal/sink/http/http.go +++ b/internal/sink/http/http.go @@ -156,11 +156,15 @@ func NewHTTPSinkPlugin( if err != nil { return nil, err } - authPolicy, err := authz.New(opts.Auth, tlsCfg, authz.RoleListener, authz.HTTP) + kind := netacl.HTTP + if opts.Auth != nil && len(opts.Auth.TrustedProxies) > 0 { + kind = netacl.HTTPProxied + } + aclPolicy, err := netacl.New(opts.ACL, opts.Host, kind, logger, "http_sink", id) if err != nil { return nil, err } - aclPolicy, err := netacl.New(opts.ACL, opts.Host, logger, "http_sink", id) + authPolicy, err := authz.New(opts.Auth, tlsCfg, aclPolicy, authz.RoleListener, authz.HTTP) if err != nil { return nil, err } @@ -264,14 +268,16 @@ func (h *HTTPSink) serve(ctx context.Context, ln net.Listener) error { // One wrapper covers stream and status, and keeps the handlers themselves // unaware of authorization; a nil policy admits every request. Login, the - // browser files and the root sit outside it; in proxy mode everything sits - // behind the proxy gate. + // browser files and the root sit outside it; all but login, which SCRAM + // throttling limits, count in the per-client request rate. In proxy mode + // everything sits behind the proxy gate. + limit := func(next http.Handler) http.Handler { return h.acl.Requests(next, clientAddr) } outer := http.NewServeMux() outer.HandleFunc(http.MethodPost+" "+chain.AuthPath, h.handleAuth) for p, file := range h.web { - outer.Handle(http.MethodGet+" "+p, file) + outer.Handle(http.MethodGet+" "+p, limit(file)) } - outer.Handle("/", h.authMiddleware(mux)) + outer.Handle("/", limit(h.authMiddleware(mux))) var handler http.Handler = outer if h.auth.BehindProxy() { handler = h.proxyGate(outer) @@ -572,6 +578,7 @@ func (h *HTTPSink) armWrite(rc *http.ResponseController) { // handleStatus provides a JSON status report func (h *HTTPSink) handleStatus(w http.ResponseWriter, r *http.Request) { + denied, limited := h.acl.Refused() status := map[string]any{ "service": "LogWisp", "version": version.Short(), @@ -599,7 +606,8 @@ func (h *HTTPSink) handleStatus(w http.ResponseWriter, r *http.Request) { "dropped_writes": h.droppedWrites.Load(), "rejected_clients": h.rejectedClients.Load(), "auth_rejected": h.auth.Rejected(), - "acl_denied": h.acl.Denied(), + "acl_denied": denied, + "acl_limited": limited, }, } diff --git a/internal/sink/http/web.go b/internal/sink/http/web.go index 7e84d1a..f7f795c 100644 --- a/internal/sink/http/web.go +++ b/internal/sink/http/web.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "embed" + "errors" "html" "net/http" "path" @@ -12,6 +13,7 @@ import ( "github.com/lixenwraith/logwisp/internal/authz" "github.com/lixenwraith/logwisp/internal/chain" "github.com/lixenwraith/logwisp/internal/config" + "github.com/lixenwraith/logwisp/internal/netacl" ) //go:embed web/*.js web/*.html web/*.css @@ -101,18 +103,21 @@ func serveWebFile(data []byte, contentType string) http.Handler { // clientKey carries the forwarded client address from proxyGate type clientKey struct{} -// proxyGate admits only the trusted proxies, and records the client they -// forward for logs and sessions. ServeAuth and AuthorizeRequest check again. +// proxyGate admits only the trusted proxies, forwarding a client the acl +// rules admit, and records that client for logs, sessions and the request +// rate. ServeAuth and AuthorizeRequest check again. func (h *HTTPSink) proxyGate(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { addr, err := h.auth.ClientAddr(r) if err != nil { - h.logger.Warn("msg", "Request refused: not through a trusted proxy over https", - "component", "http_sink", - "instance_id", h.id, - "remote_addr", r.RemoteAddr, - "path", r.URL.Path, - "error", err) + if !errors.Is(err, netacl.ErrDenied) { // reported by netacl, once a minute + h.logger.Warn("msg", "Request refused: not through a trusted proxy over https", + "component", "http_sink", + "instance_id", h.id, + "remote_addr", r.RemoteAddr, + "path", r.URL.Path, + "error", err) + } authz.Refuse(w, http.StatusForbidden) return } diff --git a/internal/sink/httpchain/httpchain.go b/internal/sink/httpchain/httpchain.go index 429f53f..90834d4 100644 --- a/internal/sink/httpchain/httpchain.go +++ b/internal/sink/httpchain/httpchain.go @@ -150,7 +150,7 @@ func NewHTTPChainSinkPlugin( if err != nil { return nil, err } - authPolicy, err := authz.New(opts.Auth, tlsCfg, authz.RoleDialer, authz.HTTP) + authPolicy, err := authz.New(opts.Auth, tlsCfg, nil, authz.RoleDialer, authz.HTTP) if err != nil { return nil, err } diff --git a/internal/sink/httpchain/httpchain_test.go b/internal/sink/httpchain/httpchain_test.go index 6aac7e6..7fd8ad4 100644 --- a/internal/sink/httpchain/httpchain_test.go +++ b/internal/sink/httpchain/httpchain_test.go @@ -80,7 +80,7 @@ func TestRefusedTokenLogsInAgainAndDeliversOnce(t *testing.T) { t.Fatal(err) } serverTLS := &tls.Config{Certificates: []tls.Certificate{serverCert}} - listener, err := authz.New(&config.AuthOptions{Type: authz.MethodSCRAM, CredentialsFile: creds}, serverTLS, authz.RoleChainListener, authz.HTTP) + listener, err := authz.New(&config.AuthOptions{Type: authz.MethodSCRAM, CredentialsFile: creds}, serverTLS, nil, authz.RoleChainListener, authz.HTTP) if err != nil { t.Fatal(err) } diff --git a/internal/sink/tcp/tcp.go b/internal/sink/tcp/tcp.go index 4024bed..8215564 100644 --- a/internal/sink/tcp/tcp.go +++ b/internal/sink/tcp/tcp.go @@ -147,11 +147,11 @@ func NewTCPSinkPlugin( if err != nil { return nil, err } - authPolicy, err := authz.New(opts.Auth, tlsCfg, authz.RoleListener, authz.TCP) + aclPolicy, err := netacl.New(opts.ACL, opts.Host, netacl.TCP, logger, "tcp_sink", id) if err != nil { return nil, err } - aclPolicy, err := netacl.New(opts.ACL, opts.Host, logger, "tcp_sink", id) + authPolicy, err := authz.New(opts.Auth, tlsCfg, aclPolicy, authz.RoleListener, authz.TCP) if err != nil { return nil, err } diff --git a/internal/sink/tcpchain/tcpchain.go b/internal/sink/tcpchain/tcpchain.go index c1a3e65..856b9f5 100644 --- a/internal/sink/tcpchain/tcpchain.go +++ b/internal/sink/tcpchain/tcpchain.go @@ -138,7 +138,7 @@ func NewTCPChainSinkPlugin( if err != nil { return nil, err } - authPolicy, err := authz.New(opts.Auth, tlsCfg, authz.RoleDialer, authz.TCP) + authPolicy, err := authz.New(opts.Auth, tlsCfg, nil, authz.RoleDialer, authz.TCP) if err != nil { return nil, err } diff --git a/internal/source/httpchain/httpchain.go b/internal/source/httpchain/httpchain.go index ca1866a..d495399 100644 --- a/internal/source/httpchain/httpchain.go +++ b/internal/source/httpchain/httpchain.go @@ -118,11 +118,11 @@ func NewHTTPChainSourcePlugin( if err != nil { return nil, err } - authPolicy, err := authz.New(opts.Auth, tlsCfg, authz.RoleChainListener, authz.HTTP) + aclPolicy, err := netacl.New(opts.ACL, opts.Host, netacl.HTTP, logger, "http_chain_source", id) if err != nil { return nil, err } - aclPolicy, err := netacl.New(opts.ACL, opts.Host, logger, "http_chain_source", id) + authPolicy, err := authz.New(opts.Auth, tlsCfg, aclPolicy, authz.RoleChainListener, authz.HTTP) if err != nil { return nil, err } @@ -192,8 +192,8 @@ func (s *HTTPChainSource) Start() error { mux := http.NewServeMux() // Method-scoped pattern: mux answers 405 with Allow header on non-POST - mux.HandleFunc(http.MethodPost+" "+s.config.IngestPath, s.handleIngest) - // Answers 404 unless the policy is scram + mux.Handle(http.MethodPost+" "+s.config.IngestPath, s.acl.Requests(http.HandlerFunc(s.handleIngest), nil)) + // Answers 404 unless the policy is scram, whose throttling limits it mux.HandleFunc(http.MethodPost+" "+chain.AuthPath, s.handleAuth) s.server = &http.Server{ diff --git a/internal/source/tcpchain/tcpchain.go b/internal/source/tcpchain/tcpchain.go index 71c787a..18d3db7 100644 --- a/internal/source/tcpchain/tcpchain.go +++ b/internal/source/tcpchain/tcpchain.go @@ -103,11 +103,11 @@ func NewTCPChainSourcePlugin( if err != nil { return nil, err } - authPolicy, err := authz.New(opts.Auth, tlsCfg, authz.RoleChainListener, authz.TCP) + aclPolicy, err := netacl.New(opts.ACL, opts.Host, netacl.TCP, logger, "tcp_chain_source", id) if err != nil { return nil, err } - aclPolicy, err := netacl.New(opts.ACL, opts.Host, logger, "tcp_chain_source", id) + authPolicy, err := authz.New(opts.Auth, tlsCfg, aclPolicy, authz.RoleChainListener, authz.TCP) if err != nil { return nil, err } diff --git a/test/acl-test.sh b/test/acl-test.sh index 631005d..355dd2d 100755 --- a/test/acl-test.sh +++ b/test/acl-test.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # logwisp acl test: each listener kind admits or refuses peers by address -# before TLS, or by the client a PROXY header names (stub proxy, nginx if -# installed), counts and warns of refusals, and throttles logins per client. +# before TLS, or by the client a PROXY header or a proxy's X-Forwarded-For +# names (stub proxy, nginx if installed), counts and warns of refusals, and +# caps connections, requests and logins per client. # 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. @@ -102,6 +103,10 @@ check "a deny list alone on a wildcard listener warns" "$(warns 'tcp,port=15881, 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 "a request rate on a tcp listener is refused" \ + "$(rejects 'tcp,host=127.0.0.1,port=15881,acl.requests_per_second_per_client=5' 'applies only to HTTP listeners')" +check "a connection cap behind trusted_proxies, which would count the proxy, is refused" \ + "$(rejects "http,host=127.0.0.1,port=15881,auth.type=scram,auth.credentials_file=$RUN/users.toml,auth.trusted_proxies=127.0.0.1,acl.max_connections_per_client=2" 'behind trusted_proxies')" 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')" @@ -134,6 +139,25 @@ check "a client the acl admits gets all 20000 lines of a finite input ($n)" "$(( check "one it denies gets nothing ($denied bytes) and is logged" \ "$( ((denied == 0)) && grep -q 'refused by acl.* remote_addr 127.0.0.2:' "$LOG/tcp.err" && echo 1 || echo 0)" +section "Behind an L7 proxy" +# proxy mode over plaintext loopback: curl is the proxy, its headers name the client +spawn web-proxied "$BIN" --source null --sink "http,host=127.0.0.1,port=15882,auth.type=scram,auth.credentials_file=$RUN/users.toml,auth.trusted_proxies=127.0.0.1,acl.deny=198.51.100.5,acl.deny=2001:db8::/32,acl.requests_per_second_per_client=1" +wait_port 15882 || abort "the proxy-mode http sink did not listen" web-proxied +# as CLIENT [PATH [CURL ARGS]]: the status of a request forwarded for CLIENT, +# GET /status by default; there 401 is past the acl, short of a login +as() { get -o /dev/null -w '%{http_code}' -H "X-Forwarded-For: $1" -H 'X-Forwarded-Proto: https' "${@:3}" "http://127.0.0.1:15882${2:-/status}"; } +codes="$(as 198.51.100.5) $(as 2001:db8::5)" +wait_until 5 logged web-proxied 'Request refused by acl.* remote_addr 198.51.100.5 .*address rules' +check "forwarded clients the acl denies, of either family, are refused per request, and logged once ($codes)" \ + "$( [[ $codes == '403 403' && $(count web-proxied 'refused by acl') == 1 ]] && echo 1 || echo 0)" +codes="$(as 198.51.100.7) $(as 198.51.100.7)" +check "a forwarded client past requests_per_second_per_client gets 429 ($codes)" "$(is "$codes" '401 429')" +code=$(as 198.51.100.8) +check "while another behind the same proxy is not ($code)" "$(is "$code" 401)" +hello=(-H 'Content-Type: application/json' -d '{"logwisp":1,"scram":{"username":"edge-01","client_nonce":"nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn"}}') +codes="$(as 198.51.100.9 /auth "${hello[@]}") $(as 198.51.100.9 /auth "${hello[@]}")" +check "the SCRAM exchange, which its throttling limits, is not counted in the rate ($codes)" "$(is "$codes" '200 200')" + section "Chain sources" # 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; } @@ -158,6 +182,7 @@ section "Behind a PROXY header" 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" +spawn tcp-capped "$BIN" --source null --sink "tcp,host=127.0.0.1,port=15889,$behind,acl.max_connections_per_client=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" \ @@ -167,11 +192,12 @@ done { 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 +for p in 15883 15886 15887 15888 15889; 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 + 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 \ + 127.0.0.11:15889,127.0.0.1:15889,1,198.51.100.1 127.0.0.12:15889,127.0.0.1:15889,2,198.51.100.2 wait_until 10 logged proxy ready || abort "the stub proxy did not start" proxy NGINX=0 if command -v nginx >/dev/null; then @@ -214,8 +240,9 @@ 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))" +# the WARN names whichever denied client came first, the stub's or nginx's 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)" + "$( (($(lines denied) == 0)) && grep -Eq 'refused by acl.* remote_addr (198.51.100.2|127.0.0.6):.*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)" @@ -223,6 +250,22 @@ else skip "nginx stream with proxy_protocol on (nginx with its stream module is not available)" fi +# tcp sink capped at two connections per client: two held, then a third and another client's +held() { timeout 2 nc "$@" 15889 /dev/null; (($? == 124)) && echo 1 || echo 0; } # NC_ARGS... ADDR +pids=() +for from in 127.0.0.11 127.0.0.11 '-s 127.0.0.2 127.0.0.1' '-s 127.0.0.2 127.0.0.1'; do + # shellcheck disable=SC2086 # NC_ARGS + timeout 6 nc $from 15889 /dev/null & + pids+=($!) +done +sleep 1 +third=$(held 127.0.0.11) other=$(held 127.0.0.12) +check "a third connection from the client a header names is closed, another client's held ($third, $other)" \ + "$( ((!third && other)) && grep -q 'refused by acl.* remote_addr 198.51.100.1:.*max_connections_per_client.*peer_addr 127.0.0.1:' "$LOG/tcp-capped.out" && echo 1 || echo 0)" +third=$(held -s 127.0.0.2 127.0.0.1) other=$(held -s 127.0.0.3 127.0.0.1) +check "and so for direct peers, outside proxy_from ($third, $other)" "$( ((!third && other)) && echo 1 || echo 0)" +wait "${pids[@]}" + # 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; }