v0.26.1 per-client connection caps

This commit is contained in:
lixen committed 2026-10-05 02:22:30 -04:00
1 parent ee01cb00fe
commit cdd4fc01a7
36 files changed
+795 -417

No files matched your search

+3 -2
View File
@@ -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.
+2 -2
View File
@@ -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).
+1 -1
View File
@@ -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
}
+16 -4
View File
@@ -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
+1 -1
View File
@@ -120,7 +120,7 @@ Not covered by this script; edit the generated configuration instead
- filters, rate limits, heartbeat, formatter flags and timestamp layouts
- 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
+2 -2
View File
@@ -30,8 +30,8 @@ streams, or downstream LogWisp nodes.
- [CLI](cli.md): flags, signals, exit codes, `lw auth`; also the `lw(1)`
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).
+2
View File
@@ -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.
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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
+4 -3
View File
@@ -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.
+20 -10
View File
@@ -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
+5 -4
View File
@@ -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,
+49 -17
View File
@@ -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`.
+10 -8
View File
@@ -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`.
---
+10 -8
View File
@@ -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`.
---
+3 -41
View File
@@ -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`
+6 -3
View File
@@ -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 {
+9 -9
View File
@@ -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)
}
+18 -27
View File
@@ -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.
+30
View File
@@ -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
+16 -131
View File
@@ -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)
}
}
}
+8 -56
View File
@@ -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()
+8 -6
View File
@@ -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 ---
+1 -1
View File
@@ -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"}
+155
View File
@@ -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)
}
}
}
+166 -40
View File
@@ -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()}
}
+160 -8
View File
@@ -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.
+15 -7
View File
@@ -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,
},
}
+13 -8
View File
@@ -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
}
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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)
}
+2 -2
View File
@@ -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
}
+1 -1
View File
@@ -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
}
+4 -4
View File
@@ -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{
+2 -2
View File
@@ -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
}
+48 -5
View File
@@ -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 >/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 >/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; }