diff --git a/AGENTS.md b/AGENTS.md index ee21246..29df538 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,11 +80,13 @@ change touches. table whose groups (`auth`, `tls`, `preset`) share one flag mechanism; `config.Load` takes the parsed `config.Args`. Presets are rows of one table in `internal/config/preset.go`. -- Network security has two seams: `tlsx` (TLS configs, certificates made at - startup or by `lw tls`, pins, startup warnings) and - `authz`. 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. +- 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 + `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. - A plugin starts goroutines in `Start`, never in its constructor (the SCRAM server too: `Policy.Start`/`Close`): a rejected reload discards constructed plugins without calling `Stop`. Reload is the only rotation path for diff --git a/cmd/lw/cli_test.go b/cmd/lw/cli_test.go index 06b2109..675819d 100644 --- a/cmd/lw/cli_test.go +++ b/cmd/lw/cli_test.go @@ -123,9 +123,9 @@ func TestEveryPresetBuilds(t *testing.T) { samples := map[string]string{ "pipe": "pipe,format=txt", "tail": "tail,path=" + dir, - "serve": "serve,path=" + filepath.Join(dir, "*.log") + ",listen=127.0.0.1:15841,tls=self,users=" + users, + "serve": "serve,path=" + filepath.Join(dir, "*.log") + ",listen=127.0.0.1:15841,tls=self,users=" + users + ",allow=127.0.0.0/8,deny=127.0.0.2", "edge": "edge,to=127.0.0.1:15842,transport=http,pin=" + pin + ",user=edge-01,password_file=" + pass, - "aggregator": "aggregator,listen=127.0.0.1:15843,users=" + users + ",out=" + filepath.Join(dir, "out"), + "aggregator": "aggregator,listen=127.0.0.1:15843,users=" + users + ",out=" + filepath.Join(dir, "out") + ",allow=10.0.0.0/8,allow=127.0.0.1", } for _, p := range config.Presets() { sample, ok := samples[p.Name] diff --git a/cmd/lw/reload_test.go b/cmd/lw/reload_test.go index 43b6439..c05b570 100644 --- a/cmd/lw/reload_test.go +++ b/cmd/lw/reload_test.go @@ -87,27 +87,33 @@ func TestInvalidReloadLeavesStatusReporterRunning(t *testing.T) { } // Every registered plugin decodes through the checked decoder, so a typo in -// any plugin's config, security tables included, refuses to start. +// any plugin's config, security tables included, refuses to start: a typo in +// a listener's acl table, and an acl table on a plugin that listens on nothing. func TestEveryPluginRejectsUnknownKeys(t *testing.T) { testLogger(t) manager := session.NewManager(time.Hour) t.Cleanup(manager.Stop) proxy := session.NewProxy(manager, "typo") - bad := map[string]any{"no_such_key": true} - check := func(kind, name string, err error) { - if err == nil || !strings.Contains(err.Error(), "no_such_key") { - t.Errorf("%s %q accepted an unknown key: %v", kind, name, err) + // the error names the key: acl.no_such_key on a listener, acl elsewhere + for key, bad := range map[string]map[string]any{ + `"no_such_key"`: {"no_such_key": true}, + `"acl`: {"acl": map[string]any{"no_such_key": true}}, + } { + check := func(kind, name string, err error) { + if err == nil || !strings.Contains(err.Error(), "unknown key "+key) { + t.Errorf("%s %q accepted %v: %v", kind, name, bad, err) + } + } + for _, name := range plugin.ListSources() { + factory, _ := plugin.GetSource(name) + _, err := factory("typo", bad, logger, proxy) + check("source", name, err) + } + for _, name := range plugin.ListSinks() { + factory, _ := plugin.GetSink(name) + _, err := factory("typo", bad, logger, proxy) + check("sink", name, err) } - } - for _, name := range plugin.ListSources() { - factory, _ := plugin.GetSource(name) - _, err := factory("typo", bad, logger, proxy) - check("source", name, err) - } - for _, name := range plugin.ListSinks() { - factory, _ := plugin.GetSink(name) - _, err := factory("typo", bad, logger, proxy) - check("sink", name, err) } } diff --git a/config/logwisp.toml b/config/logwisp.toml index 43b0bc2..b57f949 100644 --- a/config/logwisp.toml +++ b/config/logwisp.toml @@ -6,12 +6,12 @@ ### Commented values are the built-in defaults unless marked "example". ### Uncommenting a default is a no-op. ### -### NOTE: network access control (ACL), http/tcp ingest sources, and -### http_client/tcp_client sinks are not available in this version. TLS is -### available on every network source and sink (the [...tls] blocks below), and -### the [...auth] blocks authenticate peers by certificate (mtls) or password -### (scram; credentials via `lw auth`). Unknown keys anywhere in this file -### or in any plugin config are rejected at startup. +### NOTE: http/tcp ingest sources and http_client/tcp_client sinks are not +### available in this version. TLS is available on every network source and +### 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. 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. @@ -158,6 +158,15 @@ name = "default" ### (addresses or CIDRs); browsers log in through them ###============================================================================ +###============================================================================ +### 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. +### allow = [] addresses or CIDRs of the listener's family; empty = all +### deny = [] refused, listed in allow or not +###============================================================================ + ###============================================================================ ### Sources (1+ required) ###============================================================================ @@ -224,6 +233,9 @@ from = "end" # "end" or "start" of a newly discovered # allow_patterns = [] # mtls: RE2, anchor them yourself # node_binding = "force" # none | assert | force; overrides trust_node # credentials_file = "" # scram: written by `lw auth add-user` +# [pipelines.plugin_sources.config.acl] # refuse peers by address, before TLS +# allow = [] # empty = every address deny does not list +# deny = [] ## HTTP chain source (stdlib listener; receives NDJSON batches from upstream http_chain sinks) # [[pipelines.plugin_sources]] @@ -251,6 +263,9 @@ from = "end" # "end" or "start" of a newly discovered # node_binding = "force" # none | assert | force; overrides trust_node # credentials_file = "" # scram: written by `lw auth add-user` # token_lifetime_ms = 0 # scram bearer tokens, 10 s to 24 h; 0 = 15 minutes +# [pipelines.plugin_sources.config.acl] # refuse peers by address, before TLS +# allow = [] # empty = every address deny does not list +# deny = [] ###============================================================================ ### Sinks (1+ required, fan-out) @@ -316,6 +331,9 @@ target = "stdout" # stdout|stderr ("split" NOT supported) # credentials_file = "" # scram: log in with `lw auth token` # token_lifetime_ms = 0 # scram bearer tokens, 10 s to 24 h; 0 = 15 minutes # trusted_proxies = [] # scram: proxies ending the browsers' TLS; tls may then be off +# [pipelines.plugin_sinks.config.acl] # refuse peers by address, before TLS +# allow = [] # empty = every address deny does not list +# deny = [] ## TCP sink (streaming server) # [[pipelines.plugin_sinks]] @@ -342,6 +360,9 @@ target = "stdout" # stdout|stderr ("split" NOT supported) # allow = [] # mtls: exact identities; empty = any the CA issued # allow_patterns = [] # mtls: RE2, anchor them yourself # credentials_file = "" # scram: readers use `lw auth stream` +# [pipelines.plugin_sinks.config.acl] # refuse peers by address, before TLS +# allow = [] # empty = every address deny does not list +# deny = [] ## TCP chain sink (stdlib client; forwards to downstream tcp_chain source) ## Do NOT point a chain sink at a chain source in the SAME pipeline: entries diff --git a/deploy/lw-deploy.sh b/deploy/lw-deploy.sh index 6cb8f60..e36d499 100755 --- a/deploy/lw-deploy.sh +++ b/deploy/lw-deploy.sh @@ -120,7 +120,7 @@ Not covered by this script; edit the generated configuration instead - filters, rate limits, heartbeat, formatter flags and timestamp layouts - more than one pipeline; both chain transports, or a further hop, on one node - proxy mode: browser logins through a TLS-terminating proxy (trusted_proxies) -- network ACLs: not implemented in LogWisp yet +- acl address rules on the listeners - mtls identities other than the certificate CN; node_binding other than force - creating certificates: openssl or your PKI (doc/security.md#enabling-mtls) - several native instances on one host: use one per host or jail diff --git a/doc/cli.md b/doc/cli.md index db92c61..d02f8b2 100644 --- a/doc/cli.md +++ b/doc/cli.md @@ -162,11 +162,11 @@ Pipelines over stdin and stdout are under [Usage Patterns](#usage-patterns). ## Presets `--preset NAME,key=value,...` takes the [SPEC](#pipelines) syntax. A list -key (`hosts`, `proxy`) takes several values, repeated (`hosts=a,hosts=b`) or -`,`-separated; any other key takes one. `lw preset NAME [--key value ...]` -prints the pipeline a preset expands to, ready for a configuration file; its -flags are the keys, spelled as in the SPEC (`--password_file`), and `-u` is -`--user`; +key (`hosts`, `proxy`, `allow`, `deny`) takes several values, repeated +(`hosts=a,hosts=b`) or `,`-separated; any other key takes one. +`lw preset NAME [--key value ...]` prints the pipeline a preset expands to, +ready for a configuration file; its flags are the keys, spelled as in the SPEC +(`--password_file`), and `-u` is `--user`; `lw preset NAME -h` lists its keys and defaults, `lw preset` the presets. An unknown key fails and lists the valid ones. A `path` is a file, a directory (its files) or a glob; a preset that takes one reads stdin without it, and @@ -183,6 +183,8 @@ unknown key fails and lists the valid ones. A `path` is a file, a directory startup), `issuer` (one signed by `issuer_cert` and `issuer_key`), or `files` (`cert` and `key`); `hosts` adds names to a made certificate - without `users` the viewer needs no login, over plain http too + - `allow` and `deny`: addresses or CIDRs, the sink's + [acl](security.md#the-acl-block) - `users`: a credentials file; readers then log in with SCRAM, and browsers need `proxy` and `viewer=true`: the login page and viewer behind the TLS-terminating proxies `proxy` names, since a browser cannot bind its @@ -198,6 +200,7 @@ unknown key fails and lists the valid ones. A `path` is a file, a directory - `listen` (`0.0.0.0:9000`), `transport` (`tcp`), `format` (`json`) - `tls`: `self` (default), `issuer` or `files`, as for `serve`; never `off` - authenticate by `users` (SCRAM) or `client_ca` (mTLS): one is required + - `allow` and `deny`, as for `serve` - `out`: a directory for `aggregate*.log` files; default stdout ```bash @@ -349,9 +352,9 @@ values from the file, the environment or flags win. finishes when all its sources have ended, and once every pipeline has finished lw shuts down gracefully and exits 0. Only a console source ends on its own: file and network sources run until a signal, and so does a pipeline -holding one. Every sink writes what is queued before the exit; the `http` -and `tcp` sinks give each connected client `write_timeout_ms`, at most 2 s, -to take it. +holding one. Every sink writes what is queued before the exit; a network +sink gives its clients or its downstream its `write_timeout_ms` +(`request_timeout_ms` for `http_chain`), at most 2 s, to take it. ## Usage Patterns diff --git a/doc/deployment.md b/doc/deployment.md index 0a015e1..02b35f8 100644 --- a/doc/deployment.md +++ b/doc/deployment.md @@ -184,7 +184,7 @@ every key. - filters, rate limits, heartbeat, formatter flags and timestamp layouts - more than one pipeline; both chain transports, or a further hop, on one node - proxy mode: browser logins through a TLS-terminating proxy -- network ACLs: not implemented in LogWisp yet +- `acl` address rules on the listeners - mtls identities other than the certificate CN; `node_binding` other than `force` - creating certificates diff --git a/doc/installation.md b/doc/installation.md index c77473f..fae045b 100644 --- a/doc/installation.md +++ b/doc/installation.md @@ -289,6 +289,7 @@ or SKIP (exit 77, e.g. no IPv6 loopback); only a failure fails the target. 15831-15832 scram-proxy-test.sh test/run-proxy/ 15851-15855 ipv6-test.sh test/run-ipv6/ 15871-15879 deploy-test.sh test/run-deploy/ +15881-15889 acl-test.sh test/run/acl/ ``` - `chain-test.sh`: two edges into a relay, one pipeline per chain transport; @@ -310,3 +311,5 @@ or SKIP (exit 77, e.g. no IPv6 loopback); only a failure fails the target. loopback - `deploy-test.sh`: the configurations `deploy/lw-deploy.sh` writes, deployed and checked; ports 15871-15879 +- `acl-test.sh`: each listener admitting or refusing peers by address before + TLS, with clients from 127.0.0.1 and 127.0.0.2; ports 15881-15889 diff --git a/doc/lw.1 b/doc/lw.1 index a9d96e3..5d99d1d 100644 --- a/doc/lw.1 +++ b/doc/lw.1 @@ -301,7 +301,7 @@ runs without a configuration. .It Cm tail path Op Cm from , format Follow files to standard output, like .Ql tail \-F . -.It Cm serve Oo Cm path , listen , format , tls , users , proxy , viewer Oc +.It Cm serve Oo Cm path , listen , format , tls , users , proxy , viewer , allow , deny Oc Serve a live HTTP stream on .Cm listen .Pq default Li 127.0.0.1:8080 . @@ -324,6 +324,10 @@ only behind a TLS-terminating proxy named by with .Cm viewer=true , since a browser cannot bind its login to the TLS channel. +.Cm allow +and +.Cm deny +list the addresses or CIDRs the listener admits and refuses. .It Cm edge to Oo Cm path , transport , ca | pin , user , password_file , cert , key , node Oc Forward to an aggregator at .Cm to @@ -340,7 +344,7 @@ or a client .Cm cert and .Cm key . -.It Cm aggregator Oo Cm listen , transport , tls , users , client_ca , out , format Oc +.It Cm aggregator Oo Cm listen , transport , tls , users , client_ca , out , format , allow , deny Oc Receive from edges on .Cm listen .Pq default Li 0.0.0.0:9000 @@ -351,7 +355,12 @@ authenticated by the credentials file or the client certificates .Cm client_ca vouches for, and write to standard output or to files in -.Cm out . +.Cm out ; +.Cm allow +and +.Cm deny +as for +.Cm serve . .El .Ss Certificate commands .Nm diff --git a/doc/networking.md b/doc/networking.md index 9801e76..44506f1 100644 --- a/doc/networking.md +++ b/doc/networking.md @@ -117,9 +117,10 @@ 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 and no IP -allow/deny list. `flow.rate_limit` is a pipeline-wide entry rate limit, not a -network-level one — it cannot distinguish or throttle an individual peer. +[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. ## Keep-Alive @@ -206,6 +207,15 @@ headers, and entry encoding. may resolve to either. - 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 the address: `acl_denied` rises, and its log has + `Connection refused by acl` at WARN, once a minute at most. The dialer sees + `EOF` or `connection reset by peer` (curl: exit 52 or 56). +- Behind a proxy (an L4 passthrough, or the `http` sink's + `auth.trusted_proxies`) every peer has the proxy's address: allow the proxy. +- `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. + **TLS handshake failure** - `client didn't provide a certificate` — the listener has `client_auth = true` and the dialer has no `cert_file`/`key_file`. diff --git a/doc/operations.md b/doc/operations.md index 8748462..c6fe3f2 100644 --- a/doc/operations.md +++ b/doc/operations.md @@ -17,10 +17,10 @@ lw exits on its own only at the end of input: once every pipeline has finished, a pipeline finishing when all its sources have ended. Only a console source ends, when stdin does; file and network sources run until a signal, and lw shuts down gracefully, exit 0, either way. -Every sink writes what is queued before the exit; the `http` and `tcp` sinks -give each client `write_timeout_ms`, at most 2 s, to take it. A reload stops -the old sinks the same way, so a client that stopped reading delays it by -that much. See +Every sink writes what is queued before the exit; a network sink gives its +clients or its downstream its `write_timeout_ms` (`request_timeout_ms` for +`http_chain`), at most 2 s, to take it. A reload stops the old sinks the same +way, so a stalled client or downstream delays it by that much. See [CLI](cli.md#built-in-defaults). There is no built-in daemon mode. Run LogWisp in the foreground under a diff --git a/doc/security.md b/doc/security.md index 8bba7f9..ead4f24 100644 --- a/doc/security.md +++ b/doc/security.md @@ -26,10 +26,12 @@ 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: 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` or a table path - fails startup. +- Unknown configuration keys rejected: a typo in `tls`, `auth`, `acl` or a + table path fails startup. - Configuration input hardened (lixenwraith/config v0.2.2 and lixenwraith/toml v0.1.3, reviewed with adversarial tests and fuzzing like `auth`): @@ -49,8 +51,10 @@ 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. -- IP allow/deny lists, per-IP connection or request limits: only SCRAM logins - are throttled per address. +- Per-address connection or request limits: only SCRAM logins are throttled + per address. +- Address rules for clients behind a proxy: `acl` sees the proxy, never the + client a PROXY header or `X-Forwarded-For` names. - PROXY protocol: behind a proxy that passes TLS through, every peer shares the proxy's address; see [Behind nginx or another proxy](#behind-nginx-or-another-proxy). @@ -319,6 +323,36 @@ Misconfiguration fails at plugin construction, before the pipeline starts: Errors read like `auth: type "mtls" requires tls.client_auth`. +## The ACL Block + +`acl`, beside `tls` and `auth` on the four listeners (`tcp` and `http` sinks, +`tcp_chain` and `http_chain` sources), refuses peers by address as the socket +is accepted: before the TLS handshake or a byte of the protocol, so a refused +peer costs no handshake and never reaches `auth`. + +```toml +[pipelines.plugin_sources.config.acl] +allow = ["192.0.2.0/24", "198.51.100.7"] # addresses or CIDRs; empty: all +deny = ["192.0.2.66"] # refused, listed in allow or not +``` + +- `deny` 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). 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 and the count, at + most once a minute per listener; the refusals in between are only counted. +- The rules see the socket peer: behind a proxy (an L4 passthrough, or the + `http` sink's `trusted_proxies`), the proxy (see + [Behind nginx or another proxy](#behind-nginx-or-another-proxy)). +- 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. + ## Startup Warnings Some settings work but are usually mistakes. Each plugin reports them at WARN @@ -334,6 +368,9 @@ when it is constructed, so startup and every reload repeat them: - a `key_file`, `issuer_key_file`, `credentials_file` or `password_file` every local user can read (once per path per process) +- an `acl` entry with host bits set (`10.0.0.1/8`), naming the network it + matches; an `acl.allow` entry of a whole family (`0.0.0.0/0`, `::/0`); a + wildcard listener whose `acl` has `deny` but no `allow` ## Certificates made at startup @@ -816,7 +853,8 @@ stream { (`too many attempts` or `busy`) keeps sending on its token to the server it pinned, retrying every few seconds, and holds its batches only once that token has expired; any other refusal ends the token at once. -- Logs and sessions name the proxy, not the peer. +- Logs and sessions name the proxy, not the peer, and `acl` rules see only the + proxy: allow it, not the clients. - Proxy mode is unaffected: it throttles on the forwarded client. - Until LogWisp reads PROXY, mitigate: - expose the chain ports directly, without the proxy, where you can; @@ -845,6 +883,9 @@ compromised edge cannot attribute its entries to another host, and the `http` 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. + **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 change takes effect on the next connection and existing ones are dropped by the @@ -856,7 +897,8 @@ would add. ## Surfaces Without Access Control An `auth` block (`mtls` or `scram`) closes each of these. Without one, bind them -to a trusted interface or front them with an authenticating proxy. +to a trusted interface, front them with an authenticating proxy, or narrow them +to known networks with `acl.allow`. What each exposes when `auth.type = "none"`: diff --git a/doc/sinks.md b/doc/sinks.md index b67647e..5754ae1 100644 --- a/doc/sinks.md +++ b/doc/sinks.md @@ -178,6 +178,8 @@ Options, each as type and default: - `tls` (table): listener TLS; see [Security](security.md). - `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; see [Security](security.md#the-acl-block). **Behaviour** @@ -241,15 +243,16 @@ Options, each as type and default: - HTTP/2 is negotiated via ALPN when TLS is enabled; plaintext is HTTP/1.1. **Status endpoint** returns service and version identity, host, port, TLS flag, -the compiled auth policy, 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` -counters. +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. **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`. +proxy mode `auth_trusted_proxies`; with `acl` rules also `acl` and +`acl_denied`. > Without an `auth` block both endpoints are unauthenticated, and the stream > response carries `Access-Control-Allow-Origin: *`, so any web origin can read @@ -302,6 +305,8 @@ Options, each as type and default: - `tls` (table): listener TLS. - `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; see [Security](security.md#the-acl-block). **Behaviour** @@ -327,7 +332,8 @@ 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`. +also `auth_users`, `auth_throttled`, `auth_busy` and `auth_binding_mismatch`; +with `acl` rules also `acl` and `acl_denied`. --- @@ -391,6 +397,9 @@ Options, each as type and default: exponential backoff plus ±20 % jitter between attempts. The delay keeps growing until a link outlives `backoff_min_ms`, so a source that drops every link right after accepting it is not retried in a tight loop. +- On shutdown (a reload too) the sink delivers what is queued, connecting + first if it must, within `write_timeout_ms`, at most 2 s; then the link + closes and what is left is lost. - A source never writes once a link is up, so the sink watches each link: a line from the source is a refusal (a sink without `scram` facing a `scram` source), logged at WARN as `Chain link refused`; EOF ends the link at once @@ -477,7 +486,9 @@ Options, each as type and default: - Redirects are never followed; a `3xx` is permanent too. Following one would resend the batch wherever the response points, plaintext `http` included. - HTTP/2 is off by design; batched NDJSON POSTs gain nothing from it. -- On shutdown a single best-effort flush of the pending batch is attempted. +- On shutdown (a reload too) the sink delivers what is queued and batched + within `request_timeout_ms`, at most 2 s; each batch still undelivered then + is dropped and logged at WARN. **Statistics**: `target`, `node`, `tls`, `auth`, `batches_sent`, `request_errors`, `dropped_batches`, `synthesized`; under `scram` also diff --git a/doc/sources.md b/doc/sources.md index fdea872..211d41e 100644 --- a/doc/sources.md +++ b/doc/sources.md @@ -214,6 +214,8 @@ Options, each as type and default: - `tls` (table): listener TLS; see [Security](security.md). - `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; 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 @@ -247,7 +249,7 @@ 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`. +and `auth_binding_mismatch`; with `acl` rules also `acl` and `acl_denied`. --- @@ -298,6 +300,8 @@ Options, each as type and default: - `tls` (table): listener TLS; see [Security](security.md). - `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; see [Security](security.md#the-acl-block). **Behaviour** @@ -324,7 +328,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`. +`auth_binding_mismatch` and `auth_token_lifetime_ms`; with `acl` rules also +`acl` and `acl_denied`. --- diff --git a/doc/todo.md b/doc/todo.md index f91aaf6..0081e80 100644 --- a/doc/todo.md +++ b/doc/todo.md @@ -6,29 +6,19 @@ they belong to. ## 1. Network access control (ACL) -The next feature push. Today a listener decides *who* a peer is (`tls`, `auth`); -nothing decides *where* a peer may connect from, and every limit except SCRAM -throttling is global. The ACL adds address rules and per-client limits at the -connection level, below TLS, so a refused peer costs no handshake. +The seam exists: `internal/netacl` wraps each listener's socket before TLS, +and the `acl` block's `allow` and `deny` refuse peers by address +([Security](security.md#the-acl-block)). Every limit except SCRAM throttling +is still global, and behind an L4 proxy every peer is the proxy. -### 1.1 One seam: a filtering listener +### 1.1 The filtering listener -- New package `internal/netacl`, the third network seam beside `tlsx` and - `authz` (AGENTS.md lists the seams; add it there). -- `netacl.New(opts *config.ACLOptions, role) (*Policy, error)` compiles the - block; a nil policy is valid and transparent, like `authz`. -- `(*Policy).Listener(ln net.Listener) net.Listener` wraps the raw listener - before TLS: - - TCP plugins wrap it before `tls.NewListener`. - - HTTP plugins wrap it before `Serve`/`ServeTLS`. -- `Accept` reads an optional PROXY header (1.2), decides allow or deny (1.3), - applies per-address connection caps (1.4), and returns a `net.Conn` whose - `RemoteAddr` is the real client. +- `Accept` is to read an optional PROXY header (1.2) before the address rules + and apply per-address connection caps (1.4) after them, returning a + `net.Conn` whose `RemoteAddr` is the real client. - Everything downstream then sees the real client with no further change: `authz.remoteIP`, SCRAM throttling, sessions, logs and `http.Request.RemoteAddr`. - Keep the proxy's own address as `peer_addr` in session metadata for audit. -- A denied connection is closed before any byte is read or written, with a - counter (`acl_denied`) and a rate-limited WARN. ### 1.2 PROXY protocol (deferred gap of SCRAM, see scram-auth-plan.md) @@ -64,20 +54,14 @@ connection level, below TLS, so a refused peer costs no handshake. one deployment can use both: nginx `stream` with `proxy_protocol` into an nginx `http` block that sets `X-Forwarded-For $proxy_protocol_addr`. -### 1.3 Address rules +### 1.3 Address rules for forwarded clients -- `allow = [CIDR...]`, `deny = [CIDR...]`, IPv4 and IPv6 (strict per family, - as the listeners are). -- Order: deny wins, then allow; an empty allow list admits everyone not denied. - Rules match the real client (after PROXY), never the proxy. - - In HTTP proxy mode, `X-Forwarded-For` is applied by `authz.ClientAddr` after - the connection-level decision. - - Address rules for forwarded HTTP clients therefore belong in a second, - request-level check inside `authz.ClientAddr`: reuse the compiled `netacl` - rules there rather than duplicating them. -- Startup warnings (in `LogStartup` style): an allow list containing - `0.0.0.0/0` or `::/0`, `proxy_from` covering public ranges, a listener on a - wildcard host with no `allow`. +- In HTTP proxy mode, `X-Forwarded-For` is applied by `authz.ClientAddr` after + the connection-level decision, so rules for forwarded HTTP clients belong in + a second, request-level check there, reusing the compiled `netacl` rules. + `authz.parseProxies` then takes netacl's address-or-CIDR parsing too. +- Startup warning: `proxy_from` covering public ranges. ### 1.4 Per-client limits @@ -95,15 +79,11 @@ connection level, below TLS, so a refused peer costs no handshake. ### 1.5 Config, validation, docs -- Config shape: `[...config.acl]` beside `tls` and `auth` on the four listener - plugins. - - Decode through `config.Scan`, so unknown keys fail. - - Dialers have no ACL block. -- Intent rule as in `authz`: an ACL key on a dialer, or `proxy_from` without - `proxy_protocol`, is an error. -- Stats: `acl_denied`, `acl_proxy_headers`, `acl_limited`, merged into the - plugin stats like `authz.Stats`. -- Docs: a security.md section, plus networking.md troubleshooting lines for +- `proxy_protocol`, `proxy_from` and the 1.4 keys join `allow` and `deny` in + the `acl` block. +- Intent rule as in `authz`: `proxy_from` without `proxy_protocol` is an error. +- Stats: `acl_proxy_headers` and `acl_limited` beside `acl_denied`. +- Docs: the security.md section, plus networking.md troubleshooting lines for each refusal. ### 1.6 Verification @@ -112,15 +92,13 @@ connection level, below TLS, so a refused peer costs no handshake. - PROXY v1 and v2 parse, including malformed, oversized and slow headers. - A header from an unlisted peer refused; `required` without a header refused; `LOCAL` kept. - - Deny over allow; IPv6 rules. - Per-client connection cap; the limiter table failing closed. - The real client reaching SCRAM throttling and session metadata. -- `test/acl-test.sh`: +- `test/acl-test.sh`, which covers the address rules, adds: - A Go stub proxy that sends v1 and v2 headers in front of each listener type. - nginx `stream` with `proxy_protocol on` when nginx is installed. - - Denied and allowed clients; per-client SCRAM budgets behind one proxy - address. + - Per-client SCRAM budgets behind one proxy address. ## 2. Packaging: AUR, FreeBSD ports, Debian @@ -183,21 +161,16 @@ identity, and both skeletons download the tagged source. Smaller items found along the way; each is independent of the ACL. -1. Chain sinks at the end of a finite input. The `tcp_chain` sink's `Stop` - closes its loop without draining its queue, and the `http_chain` sink makes - one best-effort flush, so `seq 1 N | lw --preset edge,...` may lose the - tail. Verify with a finite stdin into each and an aggregator counting lines; - give both a bounded drain like `sink.FlushBound`. -2. A late browser sees no backlog: the `http` sink keeps none, so the viewer +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` option, sent after `event: connected`) would fill it. The viewer also hides a `503` from `max_connections` behind "retrying"; the status JSON it already fetches could name it. -3. Level detection. `source.ExtractLogLevel` needs a delimiter after the name +2. Level detection. `source.ExtractLogLevel` needs a delimiter after the name (`ERROR:`, `[WARN]`, ` INFO `), so `warning disk full` or `DBG x` get no level, hence no color. Match the names as words, as the console sink's painter does, with one table for both. -4. Command line: +3. Command line: - a positional word at the top level (`lw tail`, `lw -t FILE`) still reaches config with an unclear message; name it ("unexpected argument X; the file is -c FILE"); @@ -207,11 +180,11 @@ Smaller items found along the way; each is independent of the ACL. - `lw tls cert --host` could become `--hosts` (keeping `--host`), to match `tls.hosts` and the preset key; - top-level usage errors exit 1, subcommand ones 2. -5. `GET /favicon.ico` answers 404; the logo could serve as the icon once the +4. `GET /favicon.ico` answers 404; the logo could serve as the icon once the pages' CSP allows `img-src 'self'`. -6. TLS: the generated-certificate key is one per process, so a pin taken from a +5. TLS: the generated-certificate key is one per process, so a pin taken from a `self_signed` listener also matches an `issuer` listener of the same process; document it or key per listener. Pins are checked in `VerifyPeerCertificate`, which Go skips on resumption: guard it if a dialer ever keeps a `ClientSessionCache`. -7. `core.ShutdownTimeout` is unused. +6. `core.ShutdownTimeout` is unused. diff --git a/internal/config/config.go b/internal/config/config.go index 902c57f..478767c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -216,6 +216,7 @@ type TCPChainSourceOptions struct { HelloTimeoutMS int64 `toml:"hello_timeout_ms"` // preamble deadline TrustNode bool `toml:"trust_node"` // false: force node label from remote address Auth *AuthOptions `toml:"auth"` + ACL *ACLOptions `toml:"acl"` } // HTTPChainSourceOptions defines settings for a stdlib HTTP listener ingesting @@ -230,6 +231,7 @@ type HTTPChainSourceOptions struct { ReadTimeoutMS int64 `toml:"read_timeout_ms"` // full request read deadline TrustNode bool `toml:"trust_node"` // false: force node label from remote address Auth *AuthOptions `toml:"auth"` + ACL *ACLOptions `toml:"acl"` } // --- Sink Options --- @@ -277,6 +279,7 @@ type TCPSinkOptions struct { KeepAlivePeriodMS int64 `toml:"keep_alive_period_ms"` MaxConnections int64 `toml:"max_connections"` // 0 = unlimited Auth *AuthOptions `toml:"auth"` + ACL *ACLOptions `toml:"acl"` } // HTTPSinkOptions defines settings for an HTTP SSE server sink @@ -293,6 +296,7 @@ type HTTPSinkOptions struct { LoginPage bool `toml:"login_page"` // GET /auth/login, needs auth.trusted_proxies ViewerPage bool `toml:"viewer_page"` // GET /auth/view, needs login_page Auth *AuthOptions `toml:"auth"` + ACL *ACLOptions `toml:"acl"` } // TCPChainSinkOptions defines settings for a stdlib TCP client forwarding @@ -369,6 +373,14 @@ type AuthOptions struct { PasswordFile string `toml:"password_file"` } +// ACLOptions is a listener's address rules, entries being addresses or CIDRs: +// deny wins, then a set allow list admits only its entries. Validation is in +// internal/netacl. +type ACLOptions struct { + Allow []string `toml:"allow"` + Deny []string `toml:"deny"` +} + // --- TLS Options --- // TLSOptions is one shape for both roles. Listeners present cert_file and diff --git a/internal/config/preset.go b/internal/config/preset.go index 0fc3ee0..1a75041 100644 --- a/internal/config/preset.go +++ b/internal/config/preset.go @@ -40,6 +40,10 @@ var ( {Name: "issuer_key", Help: "CA key file, for tls=issuer"}, {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: "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"} ) @@ -60,13 +64,13 @@ var presets = []Preset{ return nil }}, {"serve", "Serve files or standard input live: a browser viewer at /, SSE at /stream", - append([]PresetParam{pathParam, fromParam, formatParam("json"), + slices.Concat([]PresetParam{pathParam, fromParam, formatParam("json"), {Name: "listen", Default: "127.0.0.1:8080", Help: "HOST:PORT; [IPV6]:PORT"}, {Name: "tls", Default: "off", Help: "off|self|issuer|files: a certificate made at startup (self-signed or from issuer_*), or files"}, {Name: "users", Help: "credentials file (lw auth add-user): readers log in with SCRAM"}, {Name: "proxy", List: true, Help: "addresses or CIDRs of the TLS-terminating proxies browsers come through; ',' between them"}, {Name: "viewer", Default: "false", Help: "true: the login page and viewer for users, behind proxy; without users the viewer is always on"}, - }, tlsParams...), + }, aclParams, tlsParams), func(p *PipelineConfig, v map[string]string) error { p.PluginSources = pathOrStdin(v) sink, err := listener(v, "off") @@ -145,7 +149,7 @@ var presets = []Preset{ return nil }}, {"aggregator", "Receive from edges over TLS, authenticated, to files or standard output", - append([]PresetParam{ + slices.Concat([]PresetParam{ {Name: "listen", Default: "0.0.0.0:9000", Help: "HOST:PORT; [IPV6]:PORT; [::] for IPv6 only"}, transportParam, {Name: "tls", Default: "self", Help: "self|issuer|files: a certificate made at startup (self-signed or from issuer_*), or files"}, @@ -153,7 +157,7 @@ var presets = []Preset{ {Name: "client_ca", Help: "CA file that verifies edge certificates (mTLS)"}, {Name: "out", Help: "directory for the received entries; default: standard output"}, formatParam("json"), - }, tlsParams...), + }, aclParams, tlsParams), func(p *PipelineConfig, v map[string]string) error { typ, err := chainType(v["transport"]) if err != nil { @@ -268,14 +272,22 @@ var tlsModes = map[string]struct { "files": {needs: map[string]string{"cert": "cert_file", "key": "key_file"}}, } -// listener starts a listening plugin's options: host, port and tls, whose -// mode decides which certificate keys apply. +// listener starts a listening plugin's options: host, port, acl and tls, +// whose mode decides which certificate keys apply. func listener(v map[string]string, def string) (map[string]any, error) { host, port, err := splitAddr("listen", v["listen"]) if err != nil { return nil, err } - opts := map[string]any{"host": host, "port": port} + opts, acl := map[string]any{"host": host, "port": port}, map[string]any{} + for _, k := range []string{"allow", "deny"} { + if v[k] != "" { + acl[k] = list(v[k]) + } + } + if len(acl) > 0 { + opts["acl"] = acl + } mode := cmp.Or(v["tls"], def) m, ok := tlsModes[mode] if !ok { diff --git a/internal/netacl/netacl.go b/internal/netacl/netacl.go new file mode 100644 index 0000000..7dd63ea --- /dev/null +++ b/internal/netacl/netacl.go @@ -0,0 +1,200 @@ +// Package netacl is the address seam of the network listeners, beside tlsx +// and authz: a Policy refuses a peer by its address as the socket is +// accepted, before TLS or a byte of the protocol is read. New returns nil +// when no rule is set, and every method tolerates a nil receiver. +package netacl + +import ( + "fmt" + "net" + "net/netip" + "slices" + "strings" + "sync/atomic" + "time" + + "github.com/lixenwraith/logwisp/internal/config" + "github.com/lixenwraith/logwisp/internal/core" + + "github.com/lixenwraith/log" +) + +// warnEvery spaces the WARNs reporting refused peers; acl_denied counts all +const warnEvery = time.Minute + +// Policy is the compiled form of config.ACLOptions for one listener +type Policy struct { + allow, deny []netip.Prefix + logger *log.Logger + component, id string + + denied 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. Each entry must be of the family core.Network gives host: one the +// listener's sockets can never carry is a mistake, not a rule. +func New(o *config.ACLOptions, host string, l *log.Logger, component, id string) (*Policy, error) { + if o == nil || len(o.Allow)+len(o.Deny) == 0 { + return nil, nil + } + network, err := core.Network(host) + if err != nil { + return nil, err + } + var widened []string // entry, network pairs + compile := func(key string, entries []string) ([]netip.Prefix, error) { + var rules []netip.Prefix + for _, e := range entries { + prefix, err := parse(e, network) + if err != nil { + return nil, fmt.Errorf("acl: %s entry %q: %w", key, e, err) + } + if m := prefix.Masked(); m != prefix { + widened = append(widened, e, m.String()) + } + rules = append(rules, prefix.Masked()) + } + return rules, nil + } + p := &Policy{logger: l, component: component, id: id, epoch: time.Now()} + if p.allow, err = compile("allow", o.Allow); err != nil { + return nil, err + } + if p.deny, err = compile("deny", o.Deny); err != nil { + return nil, err + } + p.logStartup(host, widened) + return p, nil +} + +func parse(entry, network string) (netip.Prefix, error) { + entry = strings.TrimSpace(entry) + prefix, err := netip.ParsePrefix(entry) + if err != nil { + addr, aerr := netip.ParseAddr(entry) + if aerr != nil || addr.Zone() != "" { + return netip.Prefix{}, fmt.Errorf("neither an address nor a CIDR (and no zone)") + } + prefix = netip.PrefixFrom(addr, addr.BitLen()) + } + switch a := prefix.Addr(); { + case a.Is4In6(): + return netip.Prefix{}, fmt.Errorf("IPv4-mapped; write it as IPv4") + case network == "tcp4" && !a.Is4(), network == "tcp6" && a.Is4(): + return netip.Prefix{}, fmt.Errorf("not of the listener's family (%s)", network) + } + return prefix, 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", + "component", p.component, + "instance_id", p.id, + "entry", widened[i], + "network", widened[i+1]) + } + for _, q := range p.allow { + if q.Bits() == 0 { + p.logger.Warn("msg", "acl.allow admits every address of its family", + "component", p.component, + "instance_id", p.id, + "entry", q.String(), + "hint", "list only the networks that may connect") + } + } + if addr, err := netip.ParseAddr(host); len(p.allow) == 0 && (host == "" || err == nil && addr.IsUnspecified()) { + p.logger.Warn("msg", "Listener on every address admits all that acl.deny does not list", + "component", p.component, + "instance_id", p.id, + "hint", "set acl.allow to the networks that may connect") + } +} + +// admits applies the rules: deny wins, then a set allow list admits only its +// entries. A peer whose address is unknown is refused. +func (p *Policy) admits(addr netip.Addr) bool { + addr = addr.Unmap().WithZone("") // a zoned address is in no prefix + in := func(q netip.Prefix) bool { return q.Contains(addr) } + return addr.IsValid() && !slices.ContainsFunc(p.deny, in) && + (len(p.allow) == 0 || slices.ContainsFunc(p.allow, in)) +} + +// Listener returns ln refusing the peers the rules exclude as it accepts them, +// so the plugin never sees one. Close stays ln's: a sink's shutdown flush +// reaches it unchanged. A nil Policy returns ln. +func (p *Policy) Listener(ln net.Listener) net.Listener { + if p == nil { + return ln + } + return &listener{Listener: ln, p: p} +} + +type listener struct { + net.Listener + p *Policy +} + +func (l *listener) Accept() (net.Conn, error) { + for { + conn, err := l.Listener.Accept() + if err != nil { + return nil, err + } + var addr netip.Addr + remote := conn.RemoteAddr() + if t, ok := remote.(*net.TCPAddr); ok { + addr = t.AddrPort().Addr() + } + if l.p.admits(addr) { + return conn, nil + } + conn.Close() + l.p.refused(fmt.Sprint(remote)) + } +} + +// refused counts a refusal and WARNs at most once per warnEvery +func (p *Policy) refused(remote string) { + n := p.denied.Add(1) + now, next := int64(time.Since(p.epoch)), p.nextWarn.Load() + if now < next || !p.nextWarn.CompareAndSwap(next, now+int64(warnEvery)) { + return + } + p.logger.Warn("msg", "Connection refused by acl; more within a minute are only counted", + "component", p.component, + "instance_id", p.id, + "remote_addr", remote, + "acl_denied", n) +} + +// Describe renders the policy for log lines, stats and the status endpoint +func (p *Policy) Describe() string { + if p == nil { + return "none" + } + allow := "any" + if len(p.allow) > 0 { + allow = fmt.Sprint(len(p.allow)) + } + return fmt.Sprintf("allow=%s deny=%d", allow, len(p.deny)) +} + +func (p *Policy) Denied() uint64 { + if p == nil { + return 0 + } + return p.denied.Load() +} + +// Stats reports the policy for a plugin's stats details, merged in with +// maps.Copy beside authz's. A nil Policy reports nothing. +func (p *Policy) Stats() map[string]any { + if p == nil { + return nil + } + return map[string]any{"acl": p.Describe(), "acl_denied": p.denied.Load()} +} diff --git a/internal/netacl/netacl_test.go b/internal/netacl/netacl_test.go new file mode 100644 index 0000000..85800a1 --- /dev/null +++ b/internal/netacl/netacl_test.go @@ -0,0 +1,125 @@ +package netacl + +import ( + "io" + "net" + "net/netip" + "strings" + "testing" + "time" + + "github.com/lixenwraith/logwisp/internal/config" + + "github.com/lixenwraith/log" +) + +func mustNew(t *testing.T, host string, allow, deny []string) *Policy { + t.Helper() + p, err := New(&config.ACLOptions{Allow: allow, Deny: deny}, host, log.NewLogger(), "test", "acl") + if err != nil { + t.Fatal(err) + } + return p +} + +// Deny wins over allow, a set allow list admits only its entries, an empty +// one admits all not denied, an IPv4-mapped peer matches as IPv4, and a peer +// of unknown address is refused. +func TestDenyWinsThenAllowListAdmitsOnlyItsEntries(t *testing.T) { + listed := mustNew(t, "0.0.0.0", []string{"127.0.0.0/8"}, []string{"127.0.0.2"}) + denyOnly := mustNew(t, "0.0.0.0", nil, []string{"10.0.0.0/8"}) + for _, c := range []struct { + p *Policy + addr string + want bool + }{ + {listed, "127.0.0.1", true}, + {listed, "127.0.0.2", false}, + {listed, "192.0.2.1", false}, + {denyOnly, "10.1.2.3", false}, + {denyOnly, "192.0.2.1", true}, + {denyOnly, "::ffff:10.1.2.3", false}, + {denyOnly, "", false}, + } { + addr, _ := netip.ParseAddr(c.addr) + if got := c.p.admits(addr); got != c.want { + t.Errorf("%s under %s: admitted %v", c.addr, c.p.Describe(), got) + } + } +} + +// 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. +func TestEntriesFollowTheListenerFamily(t *testing.T) { + for _, c := range []struct { + host, entry string + ok bool + }{ + {"0.0.0.0", "10.0.0.0/8", true}, + {"0.0.0.0", "2001:db8::/32", false}, + {"::", "2001:db8::1", true}, + {"::", "10.0.0.1", false}, + {"::", "::ffff:10.0.0.1", false}, + {"::", "fe80::1%eth0", false}, + {"logs.example", "10.0.0.0/8", true}, + {"logs.example", "2001:db8::/32", true}, + } { + _, err := New(&config.ACLOptions{Deny: []string{c.entry}}, c.host, log.NewLogger(), "test", "acl") + if (err == nil) != c.ok { + t.Errorf("%s on %q: %v", c.entry, c.host, err) + } + } +} + +// A link-local peer carries its interface zone, which no prefix contains: +// it is matched without it, or a deny of fe80::/10 would admit it. +func TestZonedPeerMatchesItsPrefix(t *testing.T) { + p := mustNew(t, "::", nil, []string{"fe80::/10"}) + if p.admits(netip.MustParseAddr("fe80::1%eth0")) { + t.Fatal("a zoned link-local peer passed the deny of its prefix") + } +} + +// A refused peer is closed as it is accepted and counted: the plugin's +// Accept returns the next admitted one. +func TestRefusedPeerNeverReachesAccept(t *testing.T) { + inner := &fakeListener{conns: make(chan net.Conn, 2)} + refused, refusedPeer := net.Pipe() + admitted, _ := net.Pipe() + inner.conns <- remoteAt(refused, "192.0.2.1") + inner.conns <- remoteAt(admitted, "127.0.0.1") + ln := mustNew(t, "127.0.0.1", nil, []string{"192.0.2.0/24"}).Listener(inner) + conn, err := ln.Accept() + if err != nil { + t.Fatal(err) + } + if got := conn.RemoteAddr().String(); !strings.HasPrefix(got, "127.0.0.1:") { + t.Fatalf("Accept returned %s", got) + } + refusedPeer.SetReadDeadline(time.Now().Add(time.Second)) + if _, err := refusedPeer.Read(make([]byte, 1)); err != io.EOF { + t.Fatalf("the refused peer reads %v, want EOF", err) + } + if n := ln.(*listener).p.Stats()["acl_denied"]; n != uint64(1) { + t.Fatalf("acl_denied = %v", n) + } +} + +type fakeListener struct { + net.Listener + conns chan net.Conn +} + +func (f *fakeListener) Accept() (net.Conn, error) { return <-f.conns, nil } + +type remoteConn struct { + net.Conn + remote net.Addr +} + +func (c remoteConn) RemoteAddr() net.Addr { return c.remote } + +func remoteAt(c net.Conn, addr string) net.Conn { + return remoteConn{c, net.TCPAddrFromAddrPort(netip.AddrPortFrom(netip.MustParseAddr(addr), 40000))} +} diff --git a/internal/sink/http/http.go b/internal/sink/http/http.go index 4ad4663..f41979c 100644 --- a/internal/sink/http/http.go +++ b/internal/sink/http/http.go @@ -21,6 +21,7 @@ import ( "github.com/lixenwraith/logwisp/internal/chain" "github.com/lixenwraith/logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/core" + "github.com/lixenwraith/logwisp/internal/netacl" "github.com/lixenwraith/logwisp/internal/plugin" "github.com/lixenwraith/logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/sink" @@ -78,6 +79,7 @@ type HTTPSink struct { // Authorization auth *authz.Policy + acl *netacl.Policy web map[string]http.Handler // GET paths of the browser files and the root // Runtime. Stop closes flush; the broker then moves what remains of its @@ -158,6 +160,10 @@ func NewHTTPSinkPlugin( if err != nil { return nil, err } + aclPolicy, err := netacl.New(opts.ACL, opts.Host, logger, "http_sink", id) + if err != nil { + return nil, err + } switch { case (opts.LoginPage || opts.ViewerPage) && !authPolicy.BehindProxy(): return nil, errors.New("login_page and viewer_page apply to scram behind auth.trusted_proxies, where browsers log in; without auth or under mtls the viewer is always served") @@ -185,6 +191,7 @@ func NewHTTPSinkPlugin( keepalive: core.StreamKeepaliveInterval, tlsConfig: tlsCfg, auth: authPolicy, + acl: aclPolicy, web: web, } h.lastProcessed.Store(time.Time{}) @@ -199,6 +206,7 @@ func NewHTTPSinkPlugin( "tls", tlsCfg != nil, "mtls", tlsCfg != nil && tlsCfg.ClientAuth == tls.RequireAndVerifyClientCert, "auth", authPolicy.Describe(), + "acl", aclPolicy.Describe(), "login_page", opts.LoginPage, "viewer_page", web[chain.AuthPath+"/view"] != nil) tlsx.LogWarnings(logger, "http_sink", id, opts.TLS, true) @@ -244,6 +252,7 @@ func (h *HTTPSink) serve(ctx context.Context, ln net.Listener) error { ln.Close() return err } + ln = h.acl.Listener(ln) mux := http.NewServeMux() // Method-scoped patterns: mux answers 405 with Allow header on non-GET mux.HandleFunc(http.MethodGet+" "+exact(h.config.StreamPath), h.handleStream) @@ -569,6 +578,7 @@ func (h *HTTPSink) handleStatus(w http.ResponseWriter, r *http.Request) { "port": h.config.Port, "tls": h.tlsConfig != nil, "auth": h.auth.Describe(), + "acl": h.acl.Describe(), "active_clients": h.activeClients.Load(), "buffer_size": h.config.BufferSize, "client_buffer_size": h.config.ClientBufferSize, @@ -585,6 +595,7 @@ 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(), }, } @@ -611,6 +622,7 @@ func (h *HTTPSink) GetStats() sink.SinkStats { }, } maps.Copy(details, h.auth.Stats()) + maps.Copy(details, h.acl.Stats()) return sink.SinkStats{ ID: h.id, diff --git a/internal/sink/httpchain/httpchain.go b/internal/sink/httpchain/httpchain.go index 3d15a10..429f53f 100644 --- a/internal/sink/httpchain/httpchain.go +++ b/internal/sink/httpchain/httpchain.go @@ -256,8 +256,8 @@ func (t *HTTPChainSink) Start(ctx context.Context) error { return nil } -// Stop terminates the loop. Worst case: one in-flight request timeout plus -// one final-flush request timeout. +// Stop delivers what is queued and batched within sink.FlushBound, then ends +// the loop func (t *HTTPChainSink) Stop() { t.logger.Info("msg", "Stopping HTTP chain sink", "component", "http_chain_sink", @@ -305,49 +305,41 @@ func (t *HTTPChainSink) GetStats() sink.SinkStats { func (t *HTTPChainSink) runLoop(ctx context.Context) { defer t.wg.Done() - // Fold done channel into a context for request/backoff interruption - runCtx, cancel := context.WithCancel(ctx) + // Stop leaves sink.FlushBound to deliver the queue and the batch, so a + // finite input arrives whole; a request or backoff in flight then ends, + // and every later flush drops its batch at once + ctx, cancel := sink.FlushContext(ctx, t.done, t.reqTimeout) defer cancel() - go func() { - select { - case <-t.done: - cancel() - case <-runCtx.Done(): - } - }() ticker := time.NewTicker(time.Duration(t.config.FlushIntervalMS) * time.Millisecond) defer ticker.Stop() for { select { - case <-runCtx.Done(): - t.finalFlush() - return case <-ticker.C: - if t.batchCount > 0 && !t.flush(runCtx) { - t.finalFlush() - return + if t.batchCount > 0 { + t.flush(ctx) } - case event, ok := <-t.input: - if !ok { - t.finalFlush() - return + case event := <-t.input: + if t.append(event) { + t.flush(ctx) } - t.append(event) - if t.batchCount >= t.config.MaxBatchCount || - int64(t.batch.Len()) >= t.config.MaxBatchBytes { - if !t.flush(runCtx) { - t.finalFlush() - return + case <-t.done: + for len(t.input) > 0 { + if t.append(<-t.input) { + t.flush(ctx) } } + if t.batchCount > 0 { + t.flush(ctx) + } + return } } } -// append serializes one event into the pending batch -func (t *HTTPChainSink) append(event core.TransportEvent) { +// append serializes one event into the pending batch; true once it is full +func (t *HTTPChainSink) append(event core.TransportEvent) bool { entry, synthesized := chain.EntryFromEvent(event, t.node, t.id) if synthesized { t.synthesized.Add(1) @@ -358,17 +350,17 @@ func (t *HTTPChainSink) append(event core.TransportEvent) { t.logger.Error("msg", "Failed to marshal chain entry", "component", "http_chain_sink", "error", err) - return + return false } t.batch.Write(line) t.batch.WriteByte('\n') t.batchCount++ + return t.batchCount >= t.config.MaxBatchCount || int64(t.batch.Len()) >= t.config.MaxBatchBytes } -// flush delivers the pending batch, retrying transient failures with backoff. -// Returns false when shutdown interrupts delivery; undelivered batch is dropped -// by finalFlush semantics (batch already consumed here). -func (t *HTTPChainSink) flush(ctx context.Context) bool { +// flush delivers the pending batch, retrying transient failures with backoff +// until shutdown drops it +func (t *HTTPChainSink) flush(ctx context.Context) { body := bytes.Clone(t.batch.Bytes()) count := t.batchCount t.batch.Reset() @@ -377,8 +369,7 @@ func (t *HTTPChainSink) flush(ctx context.Context) bool { failures := 0 for { if failures > 0 && !t.waitBackoff(ctx, failures) { - t.droppedBatches.Add(1) - return false + break } transient, err := t.post(ctx, body) if err == nil { @@ -386,7 +377,7 @@ func (t *HTTPChainSink) flush(ctx context.Context) bool { t.totalProcessed.Add(uint64(count)) t.lastProcessed.Store(time.Now()) t.proxy.UpdateActivity(t.session.ID) - return true + return } t.requestErrors.Add(1) if !transient { @@ -396,11 +387,10 @@ func (t *HTTPChainSink) flush(ctx context.Context) bool { "target", t.url, "entries", count, "error", err) - return true + return } if ctx.Err() != nil { - t.droppedBatches.Add(1) - return false + break } failures++ t.logger.Warn("msg", "Chain batch delivery failed", @@ -409,27 +399,11 @@ func (t *HTTPChainSink) flush(ctx context.Context) bool { "attempt", failures, "error", err) } -} - -// finalFlush best-effort delivers the pending batch during shutdown (single attempt) -func (t *HTTPChainSink) finalFlush() { - if t.batchCount == 0 { - return - } - fctx, cancel := context.WithTimeout(context.Background(), t.reqTimeout) - defer cancel() - - count := t.batchCount - if _, err := t.post(fctx, t.batch.Bytes()); err != nil { - t.droppedBatches.Add(1) - t.logger.Warn("msg", "Final chain batch dropped on shutdown", - "component", "http_chain_sink", - "entries", count, - "error", err) - return - } - t.batchesSent.Add(1) - t.totalProcessed.Add(uint64(count)) + t.droppedBatches.Add(1) + t.logger.Warn("msg", "Chain batch dropped on shutdown", + "component", "http_chain_sink", + "target", t.url, + "entries", count) } // post sends one NDJSON batch; transient=true marks retryable failures diff --git a/internal/sink/httpchain/httpchain_test.go b/internal/sink/httpchain/httpchain_test.go index 5b3196f..6aac7e6 100644 --- a/internal/sink/httpchain/httpchain_test.go +++ b/internal/sink/httpchain/httpchain_test.go @@ -1,8 +1,10 @@ package httpchain import ( + "bytes" "context" "crypto/tls" + "io" "net" "net/http" "net/http/httptest" @@ -121,7 +123,8 @@ func TestRefusedTokenLogsInAgainAndDeliversOnce(t *testing.T) { } sink := created.(*HTTPChainSink) sink.append(core.TransportEvent{Time: time.Now(), Payload: []byte("entry")}) - if !sink.flush(t.Context()) || delivered.Load() != 1 || sink.droppedBatches.Load() != 0 { + sink.flush(t.Context()) + if delivered.Load() != 1 || sink.droppedBatches.Load() != 0 { t.Fatalf("delivered %d, dropped %d", delivered.Load(), sink.droppedBatches.Load()) } if logins := listener.Stats()["auth_allowed"]; logins != uint64(2) { @@ -188,7 +191,8 @@ func TestLinkOverIPv6LoopbackWithTLS(t *testing.T) { sink.append(core.TransportEvent{Time: time.Now(), Payload: []byte("over IPv6")}) ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) defer cancel() - if !sink.flush(ctx) || sink.droppedBatches.Load() != 0 { + sink.flush(ctx) + if sink.droppedBatches.Load() != 0 { t.Fatalf("batch not delivered: %d request errors", sink.requestErrors.Load()) } select { @@ -200,3 +204,50 @@ func TestLinkOverIPv6LoopbackWithTLS(t *testing.T) { t.Fatal("the source published nothing") } } + +// Stop delivers what is queued and batched, so the end of a finite input +// reaches the source whole, and returns at the flush bound when the source +// stops answering. +func TestStopDeliversTheQueueWithinTheBound(t *testing.T) { + const n = 250 // three batches of the default 100 + manager := session.NewManager(time.Hour) + t.Cleanup(manager.Stop) + // stop queues n entries, starts and stops a sink, and returns how long + // Stop took and how many entries an answering source received + stop := func(answering bool) (time.Duration, int64) { + var received atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + if !answering { + <-r.Context().Done() // the sink gave up + return + } + received.Add(int64(bytes.Count(body, []byte("\n")))) + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(srv.Close) + _, port, _ := net.SplitHostPort(srv.Listener.Addr().String()) + p, _ := strconv.ParseInt(port, 10, 64) + created, err := NewHTTPChainSinkPlugin("fwd", map[string]any{"host": "127.0.0.1", "port": p}, + log.NewLogger(), session.NewProxy(manager, "fwd")) + if err != nil { + t.Fatal(err) + } + sink := created.(*HTTPChainSink) + for range n { + sink.Input() <- core.TransportEvent{Time: time.Now(), Payload: []byte("entry")} + } + if err := sink.Start(t.Context()); err != nil { + t.Fatal(err) + } + start := time.Now() + sink.Stop() + return time.Since(start), received.Load() + } + if _, got := stop(true); got != n { + t.Fatalf("the source received %d of %d entries", got, n) + } + if d, _ := stop(false); d > 3500*time.Millisecond { + t.Fatalf("Stop took %v past a 2 s flush bound", d) + } +} diff --git a/internal/sink/sink.go b/internal/sink/sink.go index 2d7aa31..8799692 100644 --- a/internal/sink/sink.go +++ b/internal/sink/sink.go @@ -36,9 +36,9 @@ type SinkStats struct { Details map[string]any } -// FlushBound is how long a network sink's Stop gives its clients to take -// what is queued: write_timeout_ms, at most core.SinkFlushTimeout, which a -// reload also waits out for a client that stopped reading. +// FlushBound is how long a network sink's Stop gives what is queued to reach +// its clients or its downstream: its write or request timeout, at most +// core.SinkFlushTimeout, which a reload also waits out for a stalled peer. func FlushBound(writeTimeout time.Duration) time.Duration { if writeTimeout > 0 && writeTimeout < core.SinkFlushTimeout { return writeTimeout @@ -46,6 +46,24 @@ func FlushBound(writeTimeout time.Duration) time.Duration { return core.SinkFlushTimeout } +// FlushContext is ctx ending FlushBound(timeout) after done closes: a sink +// that dials runs its loop under it, so Stop leaves its queue that long. +func FlushContext(ctx context.Context, done <-chan struct{}, timeout time.Duration) (context.Context, context.CancelFunc) { + ctx, cancel := context.WithCancel(ctx) + go func() { + select { + case <-done: + select { + case <-time.After(FlushBound(timeout)): + case <-ctx.Done(): + } + case <-ctx.Done(): + } + cancel() + }() + return ctx, cancel +} + // Drain takes the payloads queued in input without waiting: a network sink's // last events, which each client then writes on its own. func Drain(input <-chan core.TransportEvent) [][]byte { diff --git a/internal/sink/tcp/tcp.go b/internal/sink/tcp/tcp.go index 18758b4..06a2308 100644 --- a/internal/sink/tcp/tcp.go +++ b/internal/sink/tcp/tcp.go @@ -16,6 +16,7 @@ import ( "github.com/lixenwraith/logwisp/internal/authz" "github.com/lixenwraith/logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/core" + "github.com/lixenwraith/logwisp/internal/netacl" "github.com/lixenwraith/logwisp/internal/plugin" "github.com/lixenwraith/logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/sink" @@ -74,6 +75,7 @@ type TCPSink struct { // Authorization auth *authz.Policy + acl *netacl.Policy // Runtime. Stop closes flush; the broadcast loop then moves what remains // of its input to tail and closes flushed; each writer writes its queue @@ -149,6 +151,10 @@ func NewTCPSinkPlugin( if err != nil { return nil, err } + aclPolicy, err := netacl.New(opts.ACL, opts.Host, logger, "tcp_sink", id) + if err != nil { + return nil, err + } t := &TCPSink{ id: id, @@ -166,6 +172,7 @@ func NewTCPSinkPlugin( writeTimeout: time.Duration(opts.WriteTimeoutMS) * time.Millisecond, tlsConfig: tlsCfg, auth: authPolicy, + acl: aclPolicy, } t.lastProcessed.Store(time.Time{}) @@ -176,7 +183,8 @@ func NewTCPSinkPlugin( "port", opts.Port, "tls", tlsCfg != nil, "mtls", tlsCfg != nil && tlsCfg.ClientAuth == tls.RequireAndVerifyClientCert, - "auth", authPolicy.Describe()) + "auth", authPolicy.Describe(), + "acl", aclPolicy.Describe()) tlsx.LogWarnings(logger, "tcp_sink", id, opts.TLS, true) authPolicy.LogStartup(logger, "tcp_sink", id, false) return t, nil @@ -214,6 +222,7 @@ func (t *TCPSink) listen() (net.Listener, error) { if err != nil { return nil, err } + ln = t.acl.Listener(ln) if t.tlsConfig != nil { ln = tls.NewListener(ln, t.tlsConfig) } @@ -556,6 +565,7 @@ func (t *TCPSink) GetStats() sink.SinkStats { "tls_handshake_errors": t.tlsHandshakeErrors.Load(), } maps.Copy(details, t.auth.Stats()) + maps.Copy(details, t.acl.Stats()) return sink.SinkStats{ ID: t.id, diff --git a/internal/sink/tcpchain/tcpchain.go b/internal/sink/tcpchain/tcpchain.go index 72487c4..c1a3e65 100644 --- a/internal/sink/tcpchain/tcpchain.go +++ b/internal/sink/tcpchain/tcpchain.go @@ -219,8 +219,7 @@ func (t *TCPChainSink) Start(ctx context.Context) error { return nil } -// Stop terminates the forwarding loop. Worst-case latency: one write timeout -// plus one backoff wait (both interruptible or bounded). +// Stop delivers what is queued within sink.FlushBound, then ends the loop func (t *TCPChainSink) Stop() { t.logger.Info("msg", "Stopping TCP chain sink", "component", "tcp_chain_sink", @@ -273,49 +272,53 @@ func (t *TCPChainSink) runLoop(ctx context.Context) { defer t.wg.Done() defer t.closeConn() - // Fold done into the context, so a connect or exchange in flight ends on Stop - ctx, cancel := context.WithCancel(ctx) + // Stop leaves sink.FlushBound to deliver the queue, so a finite input + // arrives whole; a connect, exchange or link still open then ends + ctx, cancel := sink.FlushContext(ctx, t.done, t.writeTimeout) defer cancel() - go func() { - select { - case <-t.done: - cancel() - case <-ctx.Done(): - } - }() for { select { case <-ctx.Done(): return case <-t.done: + for len(t.input) > 0 { + if !t.forward(ctx, <-t.input) { + return + } + } return - case event, ok := <-t.input: - if !ok { + case event := <-t.input: + if !t.forward(ctx, event) { return } - entry, synthesized := chain.EntryFromEvent(event, t.node, t.id) - if synthesized { - t.synthesized.Add(1) - } - line, err := json.Marshal(entry) - if err != nil { - // Non-transient: drop - t.logger.Error("msg", "Failed to marshal chain entry", - "component", "tcp_chain_sink", - "error", err) - continue - } - if !t.deliver(ctx, append(line, '\n')) { - return // shutdown during retry - } - t.totalProcessed.Add(1) - t.lastProcessed.Store(time.Now()) - t.proxy.UpdateActivity(t.session.ID) } } } +// forward delivers one event; false once shutdown ends the delivery +func (t *TCPChainSink) forward(ctx context.Context, event core.TransportEvent) bool { + entry, synthesized := chain.EntryFromEvent(event, t.node, t.id) + if synthesized { + t.synthesized.Add(1) + } + line, err := json.Marshal(entry) + if err != nil { + // Non-transient: drop + t.logger.Error("msg", "Failed to marshal chain entry", + "component", "tcp_chain_sink", + "error", err) + return true + } + if !t.deliver(ctx, append(line, '\n')) { + return false + } + t.totalProcessed.Add(1) + t.lastProcessed.Store(time.Now()) + t.proxy.UpdateActivity(t.session.ID) + return true +} + // deliver writes one line, holding it across reconnects until sent or shutdown. // Backpressure during outage propagates to the pipeline dispatch drop counter. func (t *TCPChainSink) deliver(ctx context.Context, line []byte) bool { @@ -399,11 +402,14 @@ func (t *TCPChainSink) connect(ctx context.Context) error { return err } // A source never writes once a link is up: a line is a refusal this sink - // could not otherwise see, EOF a close. Stop writing into either. + // could not otherwise see, EOF a close. Stop writing into either. The end + // of ctx closes the link too, cutting short a write in flight. + stop := context.AfterFunc(ctx, func() { conn.Close() }) t.wg.Add(1) go func() { defer t.wg.Done() err := authz.AwaitClose(r) + stop() conn.Close() if errors.Is(err, authz.ErrRefused) { t.logger.Warn("msg", "Chain link refused", @@ -450,7 +456,5 @@ func (t *TCPChainSink) waitBackoff(ctx context.Context, failures int) bool { return true case <-ctx.Done(): return false - case <-t.done: - return false } } diff --git a/internal/sink/tcpchain/tcpchain_test.go b/internal/sink/tcpchain/tcpchain_test.go index fca7f8b..f9e68e2 100644 --- a/internal/sink/tcpchain/tcpchain_test.go +++ b/internal/sink/tcpchain/tcpchain_test.go @@ -2,8 +2,10 @@ package tcpchain import ( "bufio" + "fmt" "net" "strconv" + "strings" "sync/atomic" "testing" "time" @@ -66,3 +68,72 @@ func TestRefusingSourceIsRetriedWithBackoff(t *testing.T) { t.Fatalf("%d connections in 1.5 s against a refusing source", n) } } + +// Stop delivers what is queued, so the end of a finite input reaches the +// source whole, and returns at the flush bound when the source stops reading. +func TestStopDeliversTheQueueWithinTheBound(t *testing.T) { + const n = 2000 // of 8 KiB: more than a stalled source's socket buffers hold + padding := strings.Repeat("x", 8192) + manager := session.NewManager(time.Hour) + t.Cleanup(manager.Stop) + // stop queues n entries, starts and stops a sink, and returns how long + // Stop took and how many entries a reading source received + stop := func(reading bool) (time.Duration, int) { + ln, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ln.Close() }) + lines := make(chan int, 1) + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() + if !reading { + <-t.Context().Done() + return + } + count, scanner := -1, bufio.NewScanner(conn) // -1: the hello + scanner.Buffer(nil, 64*1024) + for scanner.Scan() { + count++ + } + lines <- count + }() + _, port, _ := net.SplitHostPort(ln.Addr().String()) + p, _ := strconv.ParseInt(port, 10, 64) + created, err := NewTCPChainSinkPlugin("fwd", map[string]any{ + "host": "127.0.0.1", "port": p, "buffer_size": int64(n), // writes time out at 5 s + }, log.NewLogger(), session.NewProxy(manager, "fwd")) + if err != nil { + t.Fatal(err) + } + sink := created.(*TCPChainSink) + for i := range n { + sink.Input() <- core.TransportEvent{Time: time.Now(), Payload: fmt.Appendf(nil, "entry %d %s", i, padding)} + } + if err := sink.Start(t.Context()); err != nil { + t.Fatal(err) + } + start := time.Now() + sink.Stop() + d := time.Since(start) + if !reading { + return d, 0 + } + select { + case got := <-lines: + return d, got + case <-time.After(5 * time.Second): + return d, 0 + } + } + if _, got := stop(true); got != n { + t.Fatalf("the source received %d of %d entries", got, n) + } + if d, _ := stop(false); d > 3500*time.Millisecond { + t.Fatalf("Stop took %v past a 2 s flush bound", d) + } +} diff --git a/internal/source/httpchain/httpchain.go b/internal/source/httpchain/httpchain.go index 9c614fc..29e87fb 100644 --- a/internal/source/httpchain/httpchain.go +++ b/internal/source/httpchain/httpchain.go @@ -19,6 +19,7 @@ import ( "github.com/lixenwraith/logwisp/internal/chain" "github.com/lixenwraith/logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/core" + "github.com/lixenwraith/logwisp/internal/netacl" "github.com/lixenwraith/logwisp/internal/plugin" "github.com/lixenwraith/logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/source" @@ -59,6 +60,7 @@ type HTTPChainSource struct { // Authorization auth *authz.Policy + acl *netacl.Policy // Session cache: one session per remote host + node + authenticated identity sessions map[string]string // key -> sessionID @@ -120,6 +122,10 @@ func NewHTTPChainSourcePlugin( if err != nil { return nil, err } + aclPolicy, err := netacl.New(opts.ACL, opts.Host, logger, "http_chain_source", id) + if err != nil { + return nil, err + } s := &HTTPChainSource{ id: id, @@ -131,6 +137,7 @@ func NewHTTPChainSourcePlugin( logger: logger, tlsConfig: tlsCfg, auth: authPolicy, + acl: aclPolicy, } s.lastEntryTime.Store(time.Time{}) @@ -142,7 +149,8 @@ func NewHTTPChainSourcePlugin( "ingest_path", opts.IngestPath, "tls", tlsCfg != nil, "mtls", tlsCfg != nil && tlsCfg.ClientAuth == tls.RequireAndVerifyClientCert, - "auth", authPolicy.Describe()) + "auth", authPolicy.Describe(), + "acl", aclPolicy.Describe()) tlsx.LogWarnings(logger, "http_chain_source", id, opts.TLS, true) authPolicy.LogStartup(logger, "http_chain_source", id, opts.TrustNode) return s, nil @@ -180,6 +188,7 @@ func (s *HTTPChainSource) Start() error { s.auth.Close() return fmt.Errorf("listen %s: %w", addr, err) } + ln = s.acl.Listener(ln) mux := http.NewServeMux() // Method-scoped pattern: mux answers 405 with Allow header on non-POST @@ -265,6 +274,7 @@ func (s *HTTPChainSource) GetStats() source.SourceStats { "trust_node": s.config.TrustNode, } maps.Copy(details, s.auth.Stats()) + maps.Copy(details, s.acl.Stats()) return source.SourceStats{ ID: s.id, diff --git a/internal/source/tcpchain/tcpchain.go b/internal/source/tcpchain/tcpchain.go index 0c1a44d..4e6cf1a 100644 --- a/internal/source/tcpchain/tcpchain.go +++ b/internal/source/tcpchain/tcpchain.go @@ -17,6 +17,7 @@ import ( "github.com/lixenwraith/logwisp/internal/chain" "github.com/lixenwraith/logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/core" + "github.com/lixenwraith/logwisp/internal/netacl" "github.com/lixenwraith/logwisp/internal/plugin" "github.com/lixenwraith/logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/source" @@ -55,6 +56,7 @@ type TCPChainSource struct { // Authorization auth *authz.Policy + acl *netacl.Policy mu sync.RWMutex ctx context.Context @@ -105,6 +107,10 @@ func NewTCPChainSourcePlugin( if err != nil { return nil, err } + aclPolicy, err := netacl.New(opts.ACL, opts.Host, logger, "tcp_chain_source", id) + if err != nil { + return nil, err + } s := &TCPChainSource{ id: id, @@ -116,6 +122,7 @@ func NewTCPChainSourcePlugin( logger: logger, tlsConfig: tlsCfg, auth: authPolicy, + acl: aclPolicy, } s.lastEntryTime.Store(time.Time{}) @@ -126,7 +133,8 @@ func NewTCPChainSourcePlugin( "port", opts.Port, "tls", tlsCfg != nil, "mtls", tlsCfg != nil && tlsCfg.ClientAuth == tls.RequireAndVerifyClientCert, - "auth", authPolicy.Describe()) + "auth", authPolicy.Describe(), + "acl", aclPolicy.Describe()) tlsx.LogWarnings(logger, "tcp_chain_source", id, opts.TLS, true) authPolicy.LogStartup(logger, "tcp_chain_source", id, opts.TrustNode) return s, nil @@ -166,6 +174,7 @@ func (s *TCPChainSource) Start() error { s.auth.Close() return fmt.Errorf("listen %s: %w", addr, err) } + ln = s.acl.Listener(ln) if s.tlsConfig != nil { ln = tls.NewListener(ln, s.tlsConfig) } @@ -226,6 +235,7 @@ func (s *TCPChainSource) GetStats() source.SourceStats { "trust_node": s.config.TrustNode, } maps.Copy(details, s.auth.Stats()) + maps.Copy(details, s.acl.Stats()) return source.SourceStats{ ID: s.id, diff --git a/test/acl-test.sh b/test/acl-test.sh new file mode 100755 index 0000000..97410a7 --- /dev/null +++ b/test/acl-test.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# logwisp acl test: each listener kind admits or refuses peers by address +# before TLS, counts and warns of refusals, rejects rules it can never apply, +# and still hands a finite input whole to the clients it admits. One-shot. +# Ports: 15881-15889 on 127.0.0.1. Requires: bash 5+, curl, nc; Linux. + +set -u +. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh" + +RUN=$E2E_DIR/run/acl +AUTH=$RUN +e2e_init "$@" + +section "Setup" +need curl nc +ports_free 15881 15882 15884 15885 15889 +rm -rf "$RUN" +mkdir -p "$RUN/out" "$LOG" +cd "$RUN" || abort "no run directory" +for v in $(compgen -e LOGWISP_); do unset "$v"; done +export HOME=$RUN +lw() { "$BIN" "$@"; } +get() { curl -s --noproxy '*' --max-time 3 "$@"; } # curl ARGS...: never through a proxy +# exit 45: curl could not bind the address; 7, it bound and found 15889 closed +get --interface 127.0.0.2 http://127.0.0.1:15889/ +(($? == 45)) && skip_all "this host cannot send from 127.0.0.2" +seq 1 300 | sed 's/^/edge line /' >"$RUN/edge.log" +add_users "$RUN/users.toml" edge-01 + +section "Rules" +# rejects SINK PATTERN: lw --check fails on SINK with PATTERN in its error +rejects() { ! lw --check --source null --sink "$1" >/dev/null 2>"$LOG/check.err" && grep -q -- "$2" "$LOG/check.err" && echo 1 || echo 0; } +# warns SINK PATTERN: lw --check builds SINK and WARNs PATTERN +warns() { lw --check --source null --sink "$1" 2>&1 >/dev/null | grep ' WARN ' | grep -q -- "$2" && echo 1 || echo 0; } +check "an IPv4 entry on an IPv6 listener is refused" "$(rejects 'tcp,host=::1,port=15881,acl.allow=10.0.0.0/8' "not of the listener's family")" +check "an acl on a dialer is refused" "$(rejects 'tcp_chain,host=127.0.0.1,port=15881,acl.deny=10.0.0.1' 'unknown key "acl"')" +check "a CIDR with host bits warns, naming the network it matches" "$(warns 'tcp,host=127.0.0.1,port=15881,acl.allow=10.0.0.1/8' 'network 10.0.0.0/8')" +check "a deny list alone on a wildcard listener warns" "$(warns 'tcp,port=15881,acl.deny=10.0.0.1' 'admits all that acl.deny does not list')" +check "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')" + +section "HTTP sink" +spawn serve "$BIN" --preset "serve,path=$RUN/edge.log,listen=127.0.0.1:15881,allow=127.0.0.0/8,deny=127.0.0.2" +wait_port 15881 || abort "the serve preset did not listen" serve +check "a client the allow list names reads the status" "$(get http://127.0.0.1:15881/status | grep -q '"acl":"allow=1 deny=1"' && echo 1 || echo 0)" +get --interface 127.0.0.2 -o /dev/null http://127.0.0.1:15881/status +rc=$? +check "a denied client is closed before a byte of HTTP (curl exit $rc)" "$( ((rc == 52 || rc == 56)) && echo 1 || echo 0)" +get --interface 127.0.0.2 http://127.0.0.1:15881/status +get --interface 127.0.0.2 http://127.0.0.1:15881/status +check "the status counts every refusal" "$(get http://127.0.0.1:15881/status | grep -q '"acl_denied":3' && echo 1 || echo 0)" +check "and the log warns of them once ($(count serve 'refused by acl'))" \ + "$( [[ $(count serve 'refused by acl') == 1 ]] && logged serve 'remote_addr 127.0.0.2:' && echo 1 || echo 0)" + +section "TCP sink" +# the lines of a finite input, once the clients had time to connect +seq 1 20000 | sed 's/^/flush /' >"$RUN/flush.in" +{ wait_port 15882; sleep 1; cat "$RUN/flush.in"; } | + lw --sink "tcp,host=127.0.0.1,port=15882,acl.allow=127.0.0.1,buffer_size=20000,client_buffer_size=20000" 2>"$LOG/tcp.err" & +fpid=$! +wait_port 15882 +tcp_read 15882 15 >"$RUN/flush.out" & +rpid=$! +denied=$(timeout 3 nc -s 127.0.0.2 127.0.0.1 15882 /dev/null | grep -c 'edge line'; } +edge() { spawn "$1" "$BIN" --preset "edge,path=$RUN/edge.log,from=start,to=127.0.0.1:$2,transport=$3,pin=$4,user=edge-01,password_file=$RUN/edge-01.pass"; } +spawn agg-allow "$BIN" --preset "aggregator,listen=127.0.0.1:15884,allow=127.0.0.1,users=$RUN/users.toml,out=$RUN/out/allow" +spawn agg-deny "$BIN" --preset "aggregator,listen=127.0.0.1:15885,transport=http,deny=127.0.0.0/8,users=$RUN/users.toml,out=$RUN/out/deny" +wait_port 15884 && wait_port 15885 || abort "an aggregator did not listen" agg-allow +edge edge-allow 15884 tcp "$(pin agg-allow)" +edge edge-deny 15885 http "$(pin agg-deny)" +timeout 3 nc -s 127.0.0.2 127.0.0.1 15884 = 300 ))' +check "a tcp_chain source admits an edge its allow list names ($(received allow)), and refuses 127.0.0.2" \ + "$( (($(received allow) == 300)) && logged agg-allow 'refused by acl.* remote_addr 127.0.0.2:' && echo 1 || echo 0)" +wait_until 10 logged agg-deny 'refused by acl' +check "an http_chain source refuses a denied edge, which delivers nothing ($(received deny))" \ + "$(logged agg-deny 'refused by acl' && (($(received deny) == 0)) && echo 1 || echo 0)" + +summary diff --git a/test/preset-test.sh b/test/preset-test.sh index b9417ef..a1938ce 100755 --- a/test/preset-test.sh +++ b/test/preset-test.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # logwisp preset test: colors, presets, --dump, lw tls, listeners that make # their certificate (self-signed and pinned, or issued), the serve preset's -# browser viewer, and the http and tcp sinks writing a finite input whole. +# browser viewer, and the network sinks delivering a finite input whole. # One-shot: both modes run the checks. # Ports: 15890-15896 on 127.0.0.1. # Requires: bash 5+, coreutils, curl, openssl. Linux dev host only. @@ -114,5 +114,13 @@ wait "$fpid" n=$(grep -c '^data: flush ' "$RUN/flush-http.out") check "an http stream gets all 20000 lines, then the disconnect event ($n)" \ "$( ((n == 20000)) && tail -3 "$RUN/flush-http.out" | grep -q '^event: disconnect' && echo 1 || echo 0)" +# 1000 lines: the chain sink's queue, which holds them until the link is up +flushed() { cat "$RUN/out/$1"/aggregate*.log 2>/dev/null | grep -c '"flush '; } +seq 1 1000 | sed 's/^/flush /' | lw --preset "edge,to=127.0.0.1:15893,pin=$PIN,user=edge-01,password_file=$RUN/edge-01.pass" 2>"$LOG/flush-tcp-chain.err" +wait_until 5 eval '(( $(flushed self) >= 1000 ))' +check "a tcp_chain edge delivers all 1000 lines before it exits ($(flushed self))" "$(( $(flushed self) == 1000 ))" +seq 1 1000 | sed 's/^/flush /' | lw --preset "edge,to=127.0.0.1:15894,transport=http,ca=$RUN/pki/ca.crt,user=edge-01,password_file=$RUN/edge-01.pass" 2>"$LOG/flush-http-chain.err" +wait_until 5 eval '(( $(flushed issuer) >= 1000 ))' +check "an http_chain edge delivers all 1000 lines before it exits ($(flushed issuer))" "$(( $(flushed issuer) == 1000 ))" summary