v0.26.1 per-client connection caps
This commit is contained in:
1 parent
ee01cb00fe
commit
cdd4fc01a7
36 files changed
+778
-400
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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`
|
||||
|
||||
@@ -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"
|
||||
@@ -79,6 +80,7 @@ type Policy struct {
|
||||
patterns []*regexp.Regexp
|
||||
binding string
|
||||
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 {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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"`
|
||||
MaxConnectionsPerClient int64 `toml:"max_connections_per_client"` // 0 = unlimited
|
||||
RequestsPerSecondPerClient float64 `toml:"requests_per_second_per_client"` // HTTP listeners; 0 = unlimited
|
||||
}
|
||||
|
||||
// --- TLS Options ---
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+160
-34
@@ -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
|
||||
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,35 +371,79 @@ 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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user