v0.25.0 netacl and fixes
Test / test (push) Canceled after 0s
Test / e2e (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-10-04 23:43:52 -04:00
1 parent 9e86d7999d
commit 4af072f4e5
30 files changed
+894 -204

No files matched your search

+7 -5
View File
@@ -80,11 +80,13 @@ change touches.
table whose groups (`auth`, `tls`, `preset`) share one flag mechanism; table whose groups (`auth`, `tls`, `preset`) share one flag mechanism;
`config.Load` takes the parsed `config.Args`. Presets are rows of one table `config.Load` takes the parsed `config.Args`. Presets are rows of one table
in `internal/config/preset.go`. in `internal/config/preset.go`.
- Network security has two seams: `tlsx` (TLS configs, certificates made at - Network security has three seams: `tlsx` (TLS configs, certificates made at
startup or by `lw tls`, pins, startup warnings) and startup or by `lw tls`, pins, startup warnings), `authz`, and `netacl`
`authz`. Listeners admit only through `Admit`/`AuthorizeRequest`, dialers (address rules: a listener wraps the socket `core.Listen` returns in
through `Greet`/`Prepare`; `Authorize` refuses under scram, so a plugin gated `Policy.Listener`, before TLS). Listeners admit only through
on it alone fails closed. Nil policies are valid: keep call sites branch-free. `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 - A plugin starts goroutines in `Start`, never in its constructor (the SCRAM
server too: `Policy.Start`/`Close`): a rejected reload discards constructed server too: `Policy.Start`/`Close`): a rejected reload discards constructed
plugins without calling `Stop`. Reload is the only rotation path for plugins without calling `Stop`. Reload is the only rotation path for
+2 -2
View File
@@ -123,9 +123,9 @@ func TestEveryPresetBuilds(t *testing.T) {
samples := map[string]string{ samples := map[string]string{
"pipe": "pipe,format=txt", "pipe": "pipe,format=txt",
"tail": "tail,path=" + dir, "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, "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() { for _, p := range config.Presets() {
sample, ok := samples[p.Name] sample, ok := samples[p.Name]
+10 -4
View File
@@ -87,16 +87,21 @@ func TestInvalidReloadLeavesStatusReporterRunning(t *testing.T) {
} }
// Every registered plugin decodes through the checked decoder, so a typo in // 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) { func TestEveryPluginRejectsUnknownKeys(t *testing.T) {
testLogger(t) testLogger(t)
manager := session.NewManager(time.Hour) manager := session.NewManager(time.Hour)
t.Cleanup(manager.Stop) t.Cleanup(manager.Stop)
proxy := session.NewProxy(manager, "typo") proxy := session.NewProxy(manager, "typo")
bad := map[string]any{"no_such_key": true} // 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) { check := func(kind, name string, err error) {
if err == nil || !strings.Contains(err.Error(), "no_such_key") { if err == nil || !strings.Contains(err.Error(), "unknown key "+key) {
t.Errorf("%s %q accepted an unknown key: %v", kind, name, err) t.Errorf("%s %q accepted %v: %v", kind, name, bad, err)
} }
} }
for _, name := range plugin.ListSources() { for _, name := range plugin.ListSources() {
@@ -110,6 +115,7 @@ func TestEveryPluginRejectsUnknownKeys(t *testing.T) {
check("sink", name, err) check("sink", name, err)
} }
} }
}
// A misspelled spec key reaches the plugin as the nested key it names, so the // A misspelled spec key reaches the plugin as the nested key it names, so the
// sink refuses to build instead of starting without the TLS it was asked for. // sink refuses to build instead of starting without the TLS it was asked for.
+27 -6
View File
@@ -6,12 +6,12 @@
### Commented values are the built-in defaults unless marked "example". ### Commented values are the built-in defaults unless marked "example".
### Uncommenting a default is a no-op. ### Uncommenting a default is a no-op.
### ###
### NOTE: network access control (ACL), http/tcp ingest sources, and ### NOTE: http/tcp ingest sources and http_client/tcp_client sinks are not
### http_client/tcp_client sinks are not available in this version. TLS is ### available in this version. TLS is available on every network source and
### available on every network source and sink (the [...tls] blocks below), and ### sink (the [...tls] blocks below), the [...auth] blocks authenticate peers by
### the [...auth] blocks authenticate peers by certificate (mtls) or password ### certificate (mtls) or password (scram; credentials via `lw auth`), and the
### (scram; credentials via `lw auth`). Unknown keys anywhere in this file ### listeners' [...acl] blocks admit peers by address. Unknown keys anywhere in
### or in any plugin config are rejected at startup. ### this file or in any plugin config are rejected at startup.
### ###
### Environment overrides use the LOGWISP_ prefix ### Environment overrides use the LOGWISP_ prefix
### (LOGWISP_QUIET, LOGWISP_LOGGING_LEVEL, ...). Bare names are ignored. ### (LOGWISP_QUIET, LOGWISP_LOGGING_LEVEL, ...). Bare names are ignored.
@@ -158,6 +158,15 @@ name = "default"
### (addresses or CIDRs); browsers log in through them ### (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) ### Sources (1+ required)
###============================================================================ ###============================================================================
@@ -224,6 +233,9 @@ from = "end" # "end" or "start" of a newly discovered
# allow_patterns = [] # mtls: RE2, anchor them yourself # allow_patterns = [] # mtls: RE2, anchor them yourself
# node_binding = "force" # none | assert | force; overrides trust_node # node_binding = "force" # none | assert | force; overrides trust_node
# credentials_file = "" # scram: written by `lw auth add-user` # 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) ## HTTP chain source (stdlib listener; receives NDJSON batches from upstream http_chain sinks)
# [[pipelines.plugin_sources]] # [[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 # node_binding = "force" # none | assert | force; overrides trust_node
# credentials_file = "" # scram: written by `lw auth add-user` # credentials_file = "" # scram: written by `lw auth add-user`
# token_lifetime_ms = 0 # scram bearer tokens, 10 s to 24 h; 0 = 15 minutes # 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) ### 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` # credentials_file = "" # scram: log in with `lw auth token`
# token_lifetime_ms = 0 # scram bearer tokens, 10 s to 24 h; 0 = 15 minutes # 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 # 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) ## TCP sink (streaming server)
# [[pipelines.plugin_sinks]] # [[pipelines.plugin_sinks]]
@@ -342,6 +360,9 @@ target = "stdout" # stdout|stderr ("split" NOT supported)
# allow = [] # mtls: exact identities; empty = any the CA issued # allow = [] # mtls: exact identities; empty = any the CA issued
# allow_patterns = [] # mtls: RE2, anchor them yourself # allow_patterns = [] # mtls: RE2, anchor them yourself
# credentials_file = "" # scram: readers use `lw auth stream` # 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) ## TCP chain sink (stdlib client; forwards to downstream tcp_chain source)
## Do NOT point a chain sink at a chain source in the SAME pipeline: entries ## Do NOT point a chain sink at a chain source in the SAME pipeline: entries
+1 -1
View File
@@ -120,7 +120,7 @@ Not covered by this script; edit the generated configuration instead
- filters, rate limits, heartbeat, formatter flags and timestamp layouts - filters, rate limits, heartbeat, formatter flags and timestamp layouts
- more than one pipeline; both chain transports, or a further hop, on one node - more than one pipeline; both chain transports, or a further hop, on one node
- proxy mode: browser logins through a TLS-terminating proxy (trusted_proxies) - proxy mode: browser logins through a TLS-terminating proxy (trusted_proxies)
- 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 - mtls identities other than the certificate CN; node_binding other than force
- creating certificates: openssl or your PKI (doc/security.md#enabling-mtls) - creating certificates: openssl or your PKI (doc/security.md#enabling-mtls)
- several native instances on one host: use one per host or jail - several native instances on one host: use one per host or jail
+11 -8
View File
@@ -162,11 +162,11 @@ Pipelines over stdin and stdout are under [Usage Patterns](#usage-patterns).
## Presets ## Presets
`--preset NAME,key=value,...` takes the [SPEC](#pipelines) syntax. A list `--preset NAME,key=value,...` takes the [SPEC](#pipelines) syntax. A list
key (`hosts`, `proxy`) takes several values, repeated (`hosts=a,hosts=b`) or key (`hosts`, `proxy`, `allow`, `deny`) takes several values, repeated
`,`-separated; any other key takes one. `lw preset NAME [--key value ...]` (`hosts=a,hosts=b`) or `,`-separated; any other key takes one.
prints the pipeline a preset expands to, ready for a configuration file; its `lw preset NAME [--key value ...]` prints the pipeline a preset expands to,
flags are the keys, spelled as in the SPEC (`--password_file`), and `-u` is ready for a configuration file; its flags are the keys, spelled as in the SPEC
`--user`; (`--password_file`), and `-u` is `--user`;
`lw preset NAME -h` lists its keys and defaults, `lw preset` the presets. An `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 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 (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 startup), `issuer` (one signed by `issuer_cert` and `issuer_key`), or
`files` (`cert` and `key`); `hosts` adds names to a made certificate `files` (`cert` and `key`); `hosts` adds names to a made certificate
- without `users` the viewer needs no login, over plain http too - 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 - `users`: a credentials file; readers then log in with SCRAM, and browsers
need `proxy` and `viewer=true`: the login page and viewer behind the need `proxy` and `viewer=true`: the login page and viewer behind the
TLS-terminating proxies `proxy` names, since a browser cannot bind its 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`) - `listen` (`0.0.0.0:9000`), `transport` (`tcp`), `format` (`json`)
- `tls`: `self` (default), `issuer` or `files`, as for `serve`; never `off` - `tls`: `self` (default), `issuer` or `files`, as for `serve`; never `off`
- authenticate by `users` (SCRAM) or `client_ca` (mTLS): one is required - 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 - `out`: a directory for `aggregate*.log` files; default stdout
```bash ```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 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 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 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` holding one. Every sink writes what is queued before the exit; a network
and `tcp` sinks give each connected client `write_timeout_ms`, at most 2 s, sink gives its clients or its downstream its `write_timeout_ms`
to take it. (`request_timeout_ms` for `http_chain`), at most 2 s, to take it.
## Usage Patterns ## Usage Patterns
+1 -1
View File
@@ -184,7 +184,7 @@ every key.
- filters, rate limits, heartbeat, formatter flags and timestamp layouts - filters, rate limits, heartbeat, formatter flags and timestamp layouts
- more than one pipeline; both chain transports, or a further hop, on one node - more than one pipeline; both chain transports, or a further hop, on one node
- proxy mode: browser logins through a TLS-terminating proxy - proxy mode: browser logins through a TLS-terminating proxy
- network ACLs: not implemented in LogWisp yet - `acl` address rules on the listeners
- mtls identities other than the certificate CN; `node_binding` other than - mtls identities other than the certificate CN; `node_binding` other than
`force` `force`
- creating certificates - creating certificates
+3
View File
@@ -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/ 15831-15832 scram-proxy-test.sh test/run-proxy/
15851-15855 ipv6-test.sh test/run-ipv6/ 15851-15855 ipv6-test.sh test/run-ipv6/
15871-15879 deploy-test.sh test/run-deploy/ 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; - `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 loopback
- `deploy-test.sh`: the configurations `deploy/lw-deploy.sh` writes, deployed - `deploy-test.sh`: the configurations `deploy/lw-deploy.sh` writes, deployed
and checked; ports 15871-15879 and checked; ports 15871-15879
- `acl-test.sh`: each listener admitting or refusing peers by address before
TLS, with clients from 127.0.0.1 and 127.0.0.2; ports 15881-15889
+12 -3
View File
@@ -301,7 +301,7 @@ runs without a configuration.
.It Cm tail path Op Cm from , format .It Cm tail path Op Cm from , format
Follow files to standard output, like Follow files to standard output, like
.Ql tail \-F . .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 Serve a live HTTP stream on
.Cm listen .Cm listen
.Pq default Li 127.0.0.1:8080 . .Pq default Li 127.0.0.1:8080 .
@@ -324,6 +324,10 @@ only behind a TLS-terminating proxy named by
with with
.Cm viewer=true , .Cm viewer=true ,
since a browser cannot bind its login to the TLS channel. 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 .It Cm edge to Oo Cm path , transport , ca | pin , user , password_file , cert , key , node Oc
Forward to an aggregator at Forward to an aggregator at
.Cm to .Cm to
@@ -340,7 +344,7 @@ or a client
.Cm cert .Cm cert
and and
.Cm key . .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 Receive from edges on
.Cm listen .Cm listen
.Pq default Li 0.0.0.0:9000 .Pq default Li 0.0.0.0:9000
@@ -351,7 +355,12 @@ authenticated by the
credentials file or the client certificates credentials file or the client certificates
.Cm client_ca .Cm client_ca
vouches for, and write to standard output or to files in 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 .El
.Ss Certificate commands .Ss Certificate commands
.Nm .Nm
+13 -3
View File
@@ -117,9 +117,10 @@ The `http_chain` source has no connection cap; it bounds work with
`max_body_bytes` and `read_timeout_ms` instead. `max_body_bytes` and `read_timeout_ms` instead.
Apart from SCRAM logins, which are throttled per address (see Apart from SCRAM logins, which are throttled per address (see
[Security](security.md#throttling)), there is **no** per-IP limiting and no IP [Security](security.md#throttling)), there is **no** per-IP limiting; `acl`
allow/deny list. `flow.rate_limit` is a pipeline-wide entry rate limit, not a admits or refuses peers by address ([Security](security.md#the-acl-block)).
network-level one — it cannot distinguish or throttle an individual peer. `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 ## Keep-Alive
@@ -206,6 +207,15 @@ headers, and entry encoding.
may resolve to either. may resolve to either.
- Check the port is not already bound by another pipeline in the same process. - Check the port is not already bound by another pipeline in the same process.
**Closed as soon as it connects, before TLS**
- 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** **TLS handshake failure**
- `client didn't provide a certificate` — the listener has `client_auth = true` - `client didn't provide a certificate` — the listener has `client_auth = true`
and the dialer has no `cert_file`/`key_file`. and the dialer has no `cert_file`/`key_file`.
+4 -4
View File
@@ -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 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 source ends, when stdin does; file and network sources run until a signal, and
lw shuts down gracefully, exit 0, either way. lw shuts down gracefully, exit 0, either way.
Every sink writes what is queued before the exit; the `http` and `tcp` sinks Every sink writes what is queued before the exit; a network sink gives its
give each client `write_timeout_ms`, at most 2 s, to take it. A reload stops clients or its downstream its `write_timeout_ms` (`request_timeout_ms` for
the old sinks the same way, so a client that stopped reading delays it by `http_chain`), at most 2 s, to take it. A reload stops the old sinks the same
that much. See way, so a stalled client or downstream delays it by that much. See
[CLI](cli.md#built-in-defaults). [CLI](cli.md#built-in-defaults).
There is no built-in daemon mode. Run LogWisp in the foreground under a There is no built-in daemon mode. Run LogWisp in the foreground under a
+48 -6
View File
@@ -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). [Browsers behind a TLS-terminating proxy](#browsers-behind-a-tls-terminating-proxy).
- Server pinning by dialers: certificate identity (`mtls`), bound certificate - Server pinning by dialers: certificate identity (`mtls`), bound certificate
(`scram`). (`scram`).
- Address rules on every listener, applied before TLS: see
[The ACL Block](#the-acl-block).
- Startup warnings for expiring certificates and risky settings: see - Startup warnings for expiring certificates and risky settings: see
[Startup Warnings](#startup-warnings). [Startup Warnings](#startup-warnings).
- Unknown configuration keys rejected: a typo in `tls`, `auth` or a table path - Unknown configuration keys rejected: a typo in `tls`, `auth`, `acl` or a
fails startup. table path fails startup.
- Configuration input hardened (lixenwraith/config v0.2.2 and - Configuration input hardened (lixenwraith/config v0.2.2 and
lixenwraith/toml v0.1.3, reviewed with adversarial tests and fuzzing like lixenwraith/toml v0.1.3, reviewed with adversarial tests and fuzzing like
`auth`): `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 - Certificate revocation lists (CRL) or OCSP: revoke by editing the allow-list
or credentials file. or credentials file.
- IP allow/deny lists, per-IP connection or request limits: only SCRAM logins - Per-address connection or request limits: only SCRAM logins are throttled
are throttled per address. 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 protocol: behind a proxy that passes TLS through, every peer shares the
proxy's address; see proxy's address; see
[Behind nginx or another proxy](#behind-nginx-or-another-proxy). [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`. 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 ## Startup Warnings
Some settings work but are usually mistakes. Each plugin reports them at WARN Some settings work but are usually mistakes. Each plugin reports them at WARN
@@ -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 - a `key_file`, `issuer_key_file`, `credentials_file` or `password_file` every
local user can read local user can read
(once per path per process) (once per path per process)
- an `acl` entry with host bits set (`10.0.0.1/8`), naming the network it
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 ## Certificates made at startup
@@ -816,7 +853,8 @@ stream {
(`too many attempts` or `busy`) keeps sending on its token to the server it (`too many attempts` or `busy`) keeps sending on its token to the server it
pinned, retrying every few seconds, and holds its batches only once that pinned, retrying every few seconds, and holds its batches only once that
token has expired; any other refusal ends the token at once. token has expired; any other refusal ends the token at once.
- Logs and sessions name the proxy, not the peer. - 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. - Proxy mode is unaffected: it throttles on the forwarded client.
- Until LogWisp reads PROXY, mitigate: - Until LogWisp reads PROXY, mitigate:
- expose the chain ports directly, without the proxy, where you can; - 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 sink's stream and status endpoints stop being open to anyone who can reach the
port. 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 **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 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 change takes effect on the next connection and existing ones are dropped by the
@@ -856,7 +897,8 @@ would add.
## Surfaces Without Access Control ## Surfaces Without Access Control
An `auth` block (`mtls` or `scram`) closes each of these. Without one, bind them 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"`: What each exposes when `auth.type = "none"`:
+18 -7
View File
@@ -178,6 +178,8 @@ Options, each as type and default:
- `tls` (table): listener TLS; see [Security](security.md). - `tls` (table): listener TLS; see [Security](security.md).
- `auth` (table): client authentication (`mtls` or `scram`); see - `auth` (table): client authentication (`mtls` or `scram`); see
[Security](security.md#the-auth-block). [Security](security.md#the-auth-block).
- `acl` (table): `allow` and `deny`, addresses or CIDRs admitted and refused
before TLS; see [Security](security.md#the-acl-block).
**Behaviour** **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. - 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, **Status endpoint** returns service and version identity, host, port, TLS flag,
the compiled auth policy, active client count, sink and per-client buffer sizes, the compiled auth policy and acl, active client count, sink and per-client
connection limit, write timeout, uptime, endpoint paths, and the buffer sizes, connection limit, write timeout, uptime, endpoint paths, and the
`total_processed` / `dropped_writes` / `rejected_clients` / `auth_rejected` `total_processed` / `dropped_writes` / `rejected_clients` / `auth_rejected` /
counters. `acl_denied` counters.
**Statistics**: `dropped_writes`, `rejected_clients`, `auth`, `auth_allowed`, **Statistics**: `dropped_writes`, `rejected_clients`, `auth`, `auth_allowed`,
`auth_rejected`; under `scram` also `auth_users`, `auth_throttled`, `auth_rejected`; under `scram` also `auth_users`, `auth_throttled`,
`auth_busy`, `auth_binding_mismatch` and `auth_token_lifetime_ms`, and in `auth_busy`, `auth_binding_mismatch` and `auth_token_lifetime_ms`, and in
proxy mode `auth_trusted_proxies`. proxy mode `auth_trusted_proxies`; with `acl` rules also `acl` and
`acl_denied`.
> Without an `auth` block both endpoints are unauthenticated, and the stream > Without an `auth` block both endpoints are unauthenticated, and the stream
> response carries `Access-Control-Allow-Origin: *`, so any web origin can read > response carries `Access-Control-Allow-Origin: *`, so any web origin can read
@@ -302,6 +305,8 @@ Options, each as type and default:
- `tls` (table): listener TLS. - `tls` (table): listener TLS.
- `auth` (table): client authentication (`mtls` or `scram`); see - `auth` (table): client authentication (`mtls` or `scram`); see
[Security](security.md#the-auth-block). [Security](security.md#the-auth-block).
- `acl` (table): `allow` and `deny`, addresses or CIDRs admitted and refused
before TLS; see [Security](security.md#the-acl-block).
**Behaviour** **Behaviour**
@@ -327,7 +332,8 @@ Options, each as type and default:
**Statistics**: `write_errors`, `dropped_writes`, `rejected_conns`, **Statistics**: `write_errors`, `dropped_writes`, `rejected_conns`,
`tls_handshake_errors`, `auth`, `auth_allowed`, `auth_rejected`; under `scram` `tls_handshake_errors`, `auth`, `auth_allowed`, `auth_rejected`; under `scram`
also `auth_users`, `auth_throttled`, `auth_busy` and `auth_binding_mismatch`. also `auth_users`, `auth_throttled`, `auth_busy` and `auth_binding_mismatch`;
with `acl` rules also `acl` and `acl_denied`.
--- ---
@@ -391,6 +397,9 @@ Options, each as type and default:
exponential backoff plus ±20 % jitter between attempts. The delay keeps exponential backoff plus ±20 % jitter between attempts. The delay keeps
growing until a link outlives `backoff_min_ms`, so a source that drops every 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. 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 - 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` 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 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 - Redirects are never followed; a `3xx` is permanent too. Following one would
resend the batch wherever the response points, plaintext `http` included. resend the batch wherever the response points, plaintext `http` included.
- HTTP/2 is off by design; batched NDJSON POSTs gain nothing from it. - 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`, **Statistics**: `target`, `node`, `tls`, `auth`, `batches_sent`,
`request_errors`, `dropped_batches`, `synthesized`; under `scram` also `request_errors`, `dropped_batches`, `synthesized`; under `scram` also
+7 -2
View File
@@ -214,6 +214,8 @@ Options, each as type and default:
- `tls` (table): listener TLS; see [Security](security.md). - `tls` (table): listener TLS; see [Security](security.md).
- `auth` (table): peer authentication (`mtls` or `scram`) and node binding; - `auth` (table): peer authentication (`mtls` or `scram`) and node binding;
see [Security](security.md#the-auth-block). see [Security](security.md#the-auth-block).
- `acl` (table): `allow` and `deny`, addresses or CIDRs admitted and refused
before TLS; see [Security](security.md#the-acl-block).
For passwords instead of certificates, the `auth` block takes `type = "scram"` For passwords instead of certificates, the `auth` block takes `type = "scram"`
and a `credentials_file`, and `client_auth` becomes optional; see and a `credentials_file`, and `client_auth` becomes optional; see
@@ -247,7 +249,7 @@ and a `credentials_file`, and `client_auth` becomes optional; see
**Statistics**: `active_connections`, `rejected_conns`, `parse_errors`, **Statistics**: `active_connections`, `rejected_conns`, `parse_errors`,
`tls_handshake_errors`, `trust_node`, `auth`, `auth_allowed`, `auth_rejected`, `tls_handshake_errors`, `trust_node`, `auth`, `auth_allowed`, `auth_rejected`,
`node_binding`; under `scram` also `auth_users`, `auth_throttled`, `auth_busy` `node_binding`; under `scram` also `auth_users`, `auth_throttled`, `auth_busy`
and `auth_binding_mismatch`. 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). - `tls` (table): listener TLS; see [Security](security.md).
- `auth` (table): peer authentication (`mtls` or `scram`) and node binding; - `auth` (table): peer authentication (`mtls` or `scram`) and node binding;
see [Security](security.md#the-auth-block). see [Security](security.md#the-auth-block).
- `acl` (table): `allow` and `deny`, addresses or CIDRs admitted and refused
before TLS; see [Security](security.md#the-acl-block).
**Behaviour** **Behaviour**
@@ -324,7 +328,8 @@ Options, each as type and default:
**Statistics**: `total_requests`, `rejected_requests`, `parse_errors`, **Statistics**: `total_requests`, `rejected_requests`, `parse_errors`,
`cached_sessions`, `trust_node`, `auth`, `auth_allowed`, `auth_rejected`, `cached_sessions`, `trust_node`, `auth`, `auth_allowed`, `auth_rejected`,
`node_binding`; under `scram` also `auth_users`, `auth_throttled`, `auth_busy`, `node_binding`; under `scram` also `auth_users`, `auth_throttled`, `auth_busy`,
`auth_binding_mismatch` and `auth_token_lifetime_ms`. `auth_binding_mismatch` and `auth_token_lifetime_ms`; with `acl` rules also
`acl` and `acl_denied`.
--- ---
+26 -53
View File
@@ -6,29 +6,19 @@ they belong to.
## 1. Network access control (ACL) ## 1. Network access control (ACL)
The next feature push. Today a listener decides *who* a peer is (`tls`, `auth`); The seam exists: `internal/netacl` wraps each listener's socket before TLS,
nothing decides *where* a peer may connect from, and every limit except SCRAM and the `acl` block's `allow` and `deny` refuse peers by address
throttling is global. The ACL adds address rules and per-client limits at the ([Security](security.md#the-acl-block)). Every limit except SCRAM throttling
connection level, below TLS, so a refused peer costs no handshake. 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 - `Accept` is to read an optional PROXY header (1.2) before the address rules
`authz` (AGENTS.md lists the seams; add it there). and apply per-address connection caps (1.4) after them, returning a
- `netacl.New(opts *config.ACLOptions, role) (*Policy, error)` compiles the `net.Conn` whose `RemoteAddr` is the real client.
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.
- Everything downstream then sees the real client with no further change: - Everything downstream then sees the real client with no further change:
`authz.remoteIP`, SCRAM throttling, sessions, logs and `http.Request.RemoteAddr`. `authz.remoteIP`, SCRAM throttling, sessions, logs and `http.Request.RemoteAddr`.
- Keep the proxy's own address as `peer_addr` in session metadata for audit. - 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) ### 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 one deployment can use both: nginx `stream` with `proxy_protocol` into an
nginx `http` block that sets `X-Forwarded-For $proxy_protocol_addr`. 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. - Rules match the real client (after PROXY), never the proxy.
- In HTTP proxy mode, `X-Forwarded-For` is applied by `authz.ClientAddr` after - In HTTP proxy mode, `X-Forwarded-For` is applied by `authz.ClientAddr` after
the connection-level decision. the connection-level decision, so rules for forwarded HTTP clients belong in
- Address rules for forwarded HTTP clients therefore belong in a second, a second, request-level check there, reusing the compiled `netacl` rules.
request-level check inside `authz.ClientAddr`: reuse the compiled `netacl` `authz.parseProxies` then takes netacl's address-or-CIDR parsing too.
rules there rather than duplicating them. - Startup warning: `proxy_from` covering public ranges.
- 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`.
### 1.4 Per-client limits ### 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 ### 1.5 Config, validation, docs
- Config shape: `[...config.acl]` beside `tls` and `auth` on the four listener - `proxy_protocol`, `proxy_from` and the 1.4 keys join `allow` and `deny` in
plugins. the `acl` block.
- Decode through `config.Scan`, so unknown keys fail. - Intent rule as in `authz`: `proxy_from` without `proxy_protocol` is an error.
- Dialers have no ACL block. - Stats: `acl_proxy_headers` and `acl_limited` beside `acl_denied`.
- Intent rule as in `authz`: an ACL key on a dialer, or `proxy_from` without - Docs: the security.md section, plus networking.md troubleshooting lines for
`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
each refusal. each refusal.
### 1.6 Verification ### 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. - PROXY v1 and v2 parse, including malformed, oversized and slow headers.
- A header from an unlisted peer refused; `required` without a header - A header from an unlisted peer refused; `required` without a header
refused; `LOCAL` kept. refused; `LOCAL` kept.
- Deny over allow; IPv6 rules.
- Per-client connection cap; the limiter table failing closed. - Per-client connection cap; the limiter table failing closed.
- The real client reaching SCRAM throttling and session metadata. - 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 - A Go stub proxy that sends v1 and v2 headers in front of each listener
type. type.
- nginx `stream` with `proxy_protocol on` when nginx is installed. - nginx `stream` with `proxy_protocol on` when nginx is installed.
- Denied and allowed clients; per-client SCRAM budgets behind one proxy - Per-client SCRAM budgets behind one proxy address.
address.
## 2. Packaging: AUR, FreeBSD ports, Debian ## 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. 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` 1. A late browser sees no backlog: the `http` sink keeps none, so the viewer
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
shows entries from when it connects. A bounded replay (a `replay_lines` shows entries from when it connects. A bounded replay (a `replay_lines`
option, sent after `event: connected`) would fill it. The viewer also hides option, sent after `event: connected`) would fill it. The viewer also hides
a `503` from `max_connections` behind "retrying"; the status JSON it already a `503` from `max_connections` behind "retrying"; the status JSON it already
fetches could name it. 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 (`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 level, hence no color. Match the names as words, as the console sink's
painter does, with one table for both. 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 - 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 config with an unclear message; name it ("unexpected argument X; the file
is -c 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 - `lw tls cert --host` could become `--hosts` (keeping `--host`), to match
`tls.hosts` and the preset key; `tls.hosts` and the preset key;
- top-level usage errors exit 1, subcommand ones 2. - 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'`. 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 `self_signed` listener also matches an `issuer` listener of the same
process; document it or key per listener. Pins are checked in process; document it or key per listener. Pins are checked in
`VerifyPeerCertificate`, which Go skips on resumption: guard it if a dialer `VerifyPeerCertificate`, which Go skips on resumption: guard it if a dialer
ever keeps a `ClientSessionCache`. ever keeps a `ClientSessionCache`.
7. `core.ShutdownTimeout` is unused. 6. `core.ShutdownTimeout` is unused.
+12
View File
@@ -216,6 +216,7 @@ type TCPChainSourceOptions struct {
HelloTimeoutMS int64 `toml:"hello_timeout_ms"` // preamble deadline HelloTimeoutMS int64 `toml:"hello_timeout_ms"` // preamble deadline
TrustNode bool `toml:"trust_node"` // false: force node label from remote address TrustNode bool `toml:"trust_node"` // false: force node label from remote address
Auth *AuthOptions `toml:"auth"` Auth *AuthOptions `toml:"auth"`
ACL *ACLOptions `toml:"acl"`
} }
// HTTPChainSourceOptions defines settings for a stdlib HTTP listener ingesting // 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 ReadTimeoutMS int64 `toml:"read_timeout_ms"` // full request read deadline
TrustNode bool `toml:"trust_node"` // false: force node label from remote address TrustNode bool `toml:"trust_node"` // false: force node label from remote address
Auth *AuthOptions `toml:"auth"` Auth *AuthOptions `toml:"auth"`
ACL *ACLOptions `toml:"acl"`
} }
// --- Sink Options --- // --- Sink Options ---
@@ -277,6 +279,7 @@ type TCPSinkOptions struct {
KeepAlivePeriodMS int64 `toml:"keep_alive_period_ms"` KeepAlivePeriodMS int64 `toml:"keep_alive_period_ms"`
MaxConnections int64 `toml:"max_connections"` // 0 = unlimited MaxConnections int64 `toml:"max_connections"` // 0 = unlimited
Auth *AuthOptions `toml:"auth"` Auth *AuthOptions `toml:"auth"`
ACL *ACLOptions `toml:"acl"`
} }
// HTTPSinkOptions defines settings for an HTTP SSE server sink // 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 LoginPage bool `toml:"login_page"` // GET /auth/login, needs auth.trusted_proxies
ViewerPage bool `toml:"viewer_page"` // GET /auth/view, needs login_page ViewerPage bool `toml:"viewer_page"` // GET /auth/view, needs login_page
Auth *AuthOptions `toml:"auth"` Auth *AuthOptions `toml:"auth"`
ACL *ACLOptions `toml:"acl"`
} }
// TCPChainSinkOptions defines settings for a stdlib TCP client forwarding // TCPChainSinkOptions defines settings for a stdlib TCP client forwarding
@@ -369,6 +373,14 @@ type AuthOptions struct {
PasswordFile string `toml:"password_file"` 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 --- // --- TLS Options ---
// TLSOptions is one shape for both roles. Listeners present cert_file and // TLSOptions is one shape for both roles. Listeners present cert_file and
+19 -7
View File
@@ -40,6 +40,10 @@ var (
{Name: "issuer_key", Help: "CA key file, for tls=issuer"}, {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"}, {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"} transportParam = PresetParam{Name: "transport", Default: "tcp", Help: "tcp|http: tcp_chain or http_chain"}
) )
@@ -60,13 +64,13 @@ var presets = []Preset{
return nil return nil
}}, }},
{"serve", "Serve files or standard input live: a browser viewer at /, SSE at /stream", {"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: "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: "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: "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: "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"}, {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 { func(p *PipelineConfig, v map[string]string) error {
p.PluginSources = pathOrStdin(v) p.PluginSources = pathOrStdin(v)
sink, err := listener(v, "off") sink, err := listener(v, "off")
@@ -145,7 +149,7 @@ var presets = []Preset{
return nil return nil
}}, }},
{"aggregator", "Receive from edges over TLS, authenticated, to files or standard output", {"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"}, {Name: "listen", Default: "0.0.0.0:9000", Help: "HOST:PORT; [IPV6]:PORT; [::] for IPv6 only"},
transportParam, transportParam,
{Name: "tls", Default: "self", Help: "self|issuer|files: a certificate made at startup (self-signed or from issuer_*), or files"}, {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: "client_ca", Help: "CA file that verifies edge certificates (mTLS)"},
{Name: "out", Help: "directory for the received entries; default: standard output"}, {Name: "out", Help: "directory for the received entries; default: standard output"},
formatParam("json"), formatParam("json"),
}, tlsParams...), }, aclParams, tlsParams),
func(p *PipelineConfig, v map[string]string) error { func(p *PipelineConfig, v map[string]string) error {
typ, err := chainType(v["transport"]) typ, err := chainType(v["transport"])
if err != nil { if err != nil {
@@ -268,14 +272,22 @@ var tlsModes = map[string]struct {
"files": {needs: map[string]string{"cert": "cert_file", "key": "key_file"}}, "files": {needs: map[string]string{"cert": "cert_file", "key": "key_file"}},
} }
// listener starts a listening plugin's options: host, port and tls, whose // listener starts a listening plugin's options: host, port, acl and tls,
// mode decides which certificate keys apply. // whose mode decides which certificate keys apply.
func listener(v map[string]string, def string) (map[string]any, error) { func listener(v map[string]string, def string) (map[string]any, error) {
host, port, err := splitAddr("listen", v["listen"]) host, port, err := splitAddr("listen", v["listen"])
if err != nil { if err != nil {
return nil, err 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) mode := cmp.Or(v["tls"], def)
m, ok := tlsModes[mode] m, ok := tlsModes[mode]
if !ok { if !ok {
+200
View File
@@ -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()}
}
+125
View File
@@ -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))}
}
+12
View File
@@ -21,6 +21,7 @@ import (
"github.com/lixenwraith/logwisp/internal/chain" "github.com/lixenwraith/logwisp/internal/chain"
"github.com/lixenwraith/logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/netacl"
"github.com/lixenwraith/logwisp/internal/plugin" "github.com/lixenwraith/logwisp/internal/plugin"
"github.com/lixenwraith/logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/session"
"github.com/lixenwraith/logwisp/internal/sink" "github.com/lixenwraith/logwisp/internal/sink"
@@ -78,6 +79,7 @@ type HTTPSink struct {
// Authorization // Authorization
auth *authz.Policy auth *authz.Policy
acl *netacl.Policy
web map[string]http.Handler // GET paths of the browser files and the root 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 // Runtime. Stop closes flush; the broker then moves what remains of its
@@ -158,6 +160,10 @@ func NewHTTPSinkPlugin(
if err != nil { if err != nil {
return nil, err return nil, err
} }
aclPolicy, err := netacl.New(opts.ACL, opts.Host, logger, "http_sink", id)
if err != nil {
return nil, err
}
switch { switch {
case (opts.LoginPage || opts.ViewerPage) && !authPolicy.BehindProxy(): 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") 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, keepalive: core.StreamKeepaliveInterval,
tlsConfig: tlsCfg, tlsConfig: tlsCfg,
auth: authPolicy, auth: authPolicy,
acl: aclPolicy,
web: web, web: web,
} }
h.lastProcessed.Store(time.Time{}) h.lastProcessed.Store(time.Time{})
@@ -199,6 +206,7 @@ func NewHTTPSinkPlugin(
"tls", tlsCfg != nil, "tls", tlsCfg != nil,
"mtls", tlsCfg != nil && tlsCfg.ClientAuth == tls.RequireAndVerifyClientCert, "mtls", tlsCfg != nil && tlsCfg.ClientAuth == tls.RequireAndVerifyClientCert,
"auth", authPolicy.Describe(), "auth", authPolicy.Describe(),
"acl", aclPolicy.Describe(),
"login_page", opts.LoginPage, "login_page", opts.LoginPage,
"viewer_page", web[chain.AuthPath+"/view"] != nil) "viewer_page", web[chain.AuthPath+"/view"] != nil)
tlsx.LogWarnings(logger, "http_sink", id, opts.TLS, true) 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() ln.Close()
return err return err
} }
ln = h.acl.Listener(ln)
mux := http.NewServeMux() mux := http.NewServeMux()
// Method-scoped patterns: mux answers 405 with Allow header on non-GET // Method-scoped patterns: mux answers 405 with Allow header on non-GET
mux.HandleFunc(http.MethodGet+" "+exact(h.config.StreamPath), h.handleStream) 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, "port": h.config.Port,
"tls": h.tlsConfig != nil, "tls": h.tlsConfig != nil,
"auth": h.auth.Describe(), "auth": h.auth.Describe(),
"acl": h.acl.Describe(),
"active_clients": h.activeClients.Load(), "active_clients": h.activeClients.Load(),
"buffer_size": h.config.BufferSize, "buffer_size": h.config.BufferSize,
"client_buffer_size": h.config.ClientBufferSize, "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(), "dropped_writes": h.droppedWrites.Load(),
"rejected_clients": h.rejectedClients.Load(), "rejected_clients": h.rejectedClients.Load(),
"auth_rejected": h.auth.Rejected(), "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.auth.Stats())
maps.Copy(details, h.acl.Stats())
return sink.SinkStats{ return sink.SinkStats{
ID: h.id, ID: h.id,
+36 -62
View File
@@ -256,8 +256,8 @@ func (t *HTTPChainSink) Start(ctx context.Context) error {
return nil return nil
} }
// Stop terminates the loop. Worst case: one in-flight request timeout plus // Stop delivers what is queued and batched within sink.FlushBound, then ends
// one final-flush request timeout. // the loop
func (t *HTTPChainSink) Stop() { func (t *HTTPChainSink) Stop() {
t.logger.Info("msg", "Stopping HTTP chain sink", t.logger.Info("msg", "Stopping HTTP chain sink",
"component", "http_chain_sink", "component", "http_chain_sink",
@@ -305,49 +305,41 @@ func (t *HTTPChainSink) GetStats() sink.SinkStats {
func (t *HTTPChainSink) runLoop(ctx context.Context) { func (t *HTTPChainSink) runLoop(ctx context.Context) {
defer t.wg.Done() defer t.wg.Done()
// Fold done channel into a context for request/backoff interruption // Stop leaves sink.FlushBound to deliver the queue and the batch, so a
runCtx, cancel := context.WithCancel(ctx) // 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() defer cancel()
go func() {
select {
case <-t.done:
cancel()
case <-runCtx.Done():
}
}()
ticker := time.NewTicker(time.Duration(t.config.FlushIntervalMS) * time.Millisecond) ticker := time.NewTicker(time.Duration(t.config.FlushIntervalMS) * time.Millisecond)
defer ticker.Stop() defer ticker.Stop()
for { for {
select { select {
case <-runCtx.Done():
t.finalFlush()
return
case <-ticker.C: case <-ticker.C:
if t.batchCount > 0 && !t.flush(runCtx) { if t.batchCount > 0 {
t.finalFlush() t.flush(ctx)
}
case event := <-t.input:
if t.append(event) {
t.flush(ctx)
}
case <-t.done:
for len(t.input) > 0 {
if t.append(<-t.input) {
t.flush(ctx)
}
}
if t.batchCount > 0 {
t.flush(ctx)
}
return return
} }
case event, ok := <-t.input:
if !ok {
t.finalFlush()
return
}
t.append(event)
if t.batchCount >= t.config.MaxBatchCount ||
int64(t.batch.Len()) >= t.config.MaxBatchBytes {
if !t.flush(runCtx) {
t.finalFlush()
return
}
}
}
} }
} }
// append serializes one event into the pending batch // append serializes one event into the pending batch; true once it is full
func (t *HTTPChainSink) append(event core.TransportEvent) { func (t *HTTPChainSink) append(event core.TransportEvent) bool {
entry, synthesized := chain.EntryFromEvent(event, t.node, t.id) entry, synthesized := chain.EntryFromEvent(event, t.node, t.id)
if synthesized { if synthesized {
t.synthesized.Add(1) t.synthesized.Add(1)
@@ -358,17 +350,17 @@ func (t *HTTPChainSink) append(event core.TransportEvent) {
t.logger.Error("msg", "Failed to marshal chain entry", t.logger.Error("msg", "Failed to marshal chain entry",
"component", "http_chain_sink", "component", "http_chain_sink",
"error", err) "error", err)
return return false
} }
t.batch.Write(line) t.batch.Write(line)
t.batch.WriteByte('\n') t.batch.WriteByte('\n')
t.batchCount++ 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. // flush delivers the pending batch, retrying transient failures with backoff
// Returns false when shutdown interrupts delivery; undelivered batch is dropped // until shutdown drops it
// by finalFlush semantics (batch already consumed here). func (t *HTTPChainSink) flush(ctx context.Context) {
func (t *HTTPChainSink) flush(ctx context.Context) bool {
body := bytes.Clone(t.batch.Bytes()) body := bytes.Clone(t.batch.Bytes())
count := t.batchCount count := t.batchCount
t.batch.Reset() t.batch.Reset()
@@ -377,8 +369,7 @@ func (t *HTTPChainSink) flush(ctx context.Context) bool {
failures := 0 failures := 0
for { for {
if failures > 0 && !t.waitBackoff(ctx, failures) { if failures > 0 && !t.waitBackoff(ctx, failures) {
t.droppedBatches.Add(1) break
return false
} }
transient, err := t.post(ctx, body) transient, err := t.post(ctx, body)
if err == nil { if err == nil {
@@ -386,7 +377,7 @@ func (t *HTTPChainSink) flush(ctx context.Context) bool {
t.totalProcessed.Add(uint64(count)) t.totalProcessed.Add(uint64(count))
t.lastProcessed.Store(time.Now()) t.lastProcessed.Store(time.Now())
t.proxy.UpdateActivity(t.session.ID) t.proxy.UpdateActivity(t.session.ID)
return true return
} }
t.requestErrors.Add(1) t.requestErrors.Add(1)
if !transient { if !transient {
@@ -396,11 +387,10 @@ func (t *HTTPChainSink) flush(ctx context.Context) bool {
"target", t.url, "target", t.url,
"entries", count, "entries", count,
"error", err) "error", err)
return true return
} }
if ctx.Err() != nil { if ctx.Err() != nil {
t.droppedBatches.Add(1) break
return false
} }
failures++ failures++
t.logger.Warn("msg", "Chain batch delivery failed", t.logger.Warn("msg", "Chain batch delivery failed",
@@ -409,27 +399,11 @@ func (t *HTTPChainSink) flush(ctx context.Context) bool {
"attempt", failures, "attempt", failures,
"error", err) "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.droppedBatches.Add(1)
t.logger.Warn("msg", "Final chain batch dropped on shutdown", t.logger.Warn("msg", "Chain batch dropped on shutdown",
"component", "http_chain_sink", "component", "http_chain_sink",
"entries", count, "target", t.url,
"error", err) "entries", count)
return
}
t.batchesSent.Add(1)
t.totalProcessed.Add(uint64(count))
} }
// post sends one NDJSON batch; transient=true marks retryable failures // post sends one NDJSON batch; transient=true marks retryable failures
+53 -2
View File
@@ -1,8 +1,10 @@
package httpchain package httpchain
import ( import (
"bytes"
"context" "context"
"crypto/tls" "crypto/tls"
"io"
"net" "net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -121,7 +123,8 @@ func TestRefusedTokenLogsInAgainAndDeliversOnce(t *testing.T) {
} }
sink := created.(*HTTPChainSink) sink := created.(*HTTPChainSink)
sink.append(core.TransportEvent{Time: time.Now(), Payload: []byte("entry")}) 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()) t.Fatalf("delivered %d, dropped %d", delivered.Load(), sink.droppedBatches.Load())
} }
if logins := listener.Stats()["auth_allowed"]; logins != uint64(2) { 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")}) sink.append(core.TransportEvent{Time: time.Now(), Payload: []byte("over IPv6")})
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel() 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()) t.Fatalf("batch not delivered: %d request errors", sink.requestErrors.Load())
} }
select { select {
@@ -200,3 +204,50 @@ func TestLinkOverIPv6LoopbackWithTLS(t *testing.T) {
t.Fatal("the source published nothing") 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)
}
}
+21 -3
View File
@@ -36,9 +36,9 @@ type SinkStats struct {
Details map[string]any Details map[string]any
} }
// FlushBound is how long a network sink's Stop gives its clients to take // FlushBound is how long a network sink's Stop gives what is queued to reach
// what is queued: write_timeout_ms, at most core.SinkFlushTimeout, which a // its clients or its downstream: its write or request timeout, at most
// reload also waits out for a client that stopped reading. // core.SinkFlushTimeout, which a reload also waits out for a stalled peer.
func FlushBound(writeTimeout time.Duration) time.Duration { func FlushBound(writeTimeout time.Duration) time.Duration {
if writeTimeout > 0 && writeTimeout < core.SinkFlushTimeout { if writeTimeout > 0 && writeTimeout < core.SinkFlushTimeout {
return writeTimeout return writeTimeout
@@ -46,6 +46,24 @@ func FlushBound(writeTimeout time.Duration) time.Duration {
return core.SinkFlushTimeout 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 // Drain takes the payloads queued in input without waiting: a network sink's
// last events, which each client then writes on its own. // last events, which each client then writes on its own.
func Drain(input <-chan core.TransportEvent) [][]byte { func Drain(input <-chan core.TransportEvent) [][]byte {
+11 -1
View File
@@ -16,6 +16,7 @@ import (
"github.com/lixenwraith/logwisp/internal/authz" "github.com/lixenwraith/logwisp/internal/authz"
"github.com/lixenwraith/logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/netacl"
"github.com/lixenwraith/logwisp/internal/plugin" "github.com/lixenwraith/logwisp/internal/plugin"
"github.com/lixenwraith/logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/session"
"github.com/lixenwraith/logwisp/internal/sink" "github.com/lixenwraith/logwisp/internal/sink"
@@ -74,6 +75,7 @@ type TCPSink struct {
// Authorization // Authorization
auth *authz.Policy auth *authz.Policy
acl *netacl.Policy
// Runtime. Stop closes flush; the broadcast loop then moves what remains // Runtime. Stop closes flush; the broadcast loop then moves what remains
// of its input to tail and closes flushed; each writer writes its queue // of its input to tail and closes flushed; each writer writes its queue
@@ -149,6 +151,10 @@ func NewTCPSinkPlugin(
if err != nil { if err != nil {
return nil, err return nil, err
} }
aclPolicy, err := netacl.New(opts.ACL, opts.Host, logger, "tcp_sink", id)
if err != nil {
return nil, err
}
t := &TCPSink{ t := &TCPSink{
id: id, id: id,
@@ -166,6 +172,7 @@ func NewTCPSinkPlugin(
writeTimeout: time.Duration(opts.WriteTimeoutMS) * time.Millisecond, writeTimeout: time.Duration(opts.WriteTimeoutMS) * time.Millisecond,
tlsConfig: tlsCfg, tlsConfig: tlsCfg,
auth: authPolicy, auth: authPolicy,
acl: aclPolicy,
} }
t.lastProcessed.Store(time.Time{}) t.lastProcessed.Store(time.Time{})
@@ -176,7 +183,8 @@ func NewTCPSinkPlugin(
"port", opts.Port, "port", opts.Port,
"tls", tlsCfg != nil, "tls", tlsCfg != nil,
"mtls", tlsCfg != nil && tlsCfg.ClientAuth == tls.RequireAndVerifyClientCert, "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) tlsx.LogWarnings(logger, "tcp_sink", id, opts.TLS, true)
authPolicy.LogStartup(logger, "tcp_sink", id, false) authPolicy.LogStartup(logger, "tcp_sink", id, false)
return t, nil return t, nil
@@ -214,6 +222,7 @@ func (t *TCPSink) listen() (net.Listener, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
ln = t.acl.Listener(ln)
if t.tlsConfig != nil { if t.tlsConfig != nil {
ln = tls.NewListener(ln, t.tlsConfig) ln = tls.NewListener(ln, t.tlsConfig)
} }
@@ -556,6 +565,7 @@ func (t *TCPSink) GetStats() sink.SinkStats {
"tls_handshake_errors": t.tlsHandshakeErrors.Load(), "tls_handshake_errors": t.tlsHandshakeErrors.Load(),
} }
maps.Copy(details, t.auth.Stats()) maps.Copy(details, t.auth.Stats())
maps.Copy(details, t.acl.Stats())
return sink.SinkStats{ return sink.SinkStats{
ID: t.id, ID: t.id,
+25 -21
View File
@@ -219,8 +219,7 @@ func (t *TCPChainSink) Start(ctx context.Context) error {
return nil return nil
} }
// Stop terminates the forwarding loop. Worst-case latency: one write timeout // Stop delivers what is queued within sink.FlushBound, then ends the loop
// plus one backoff wait (both interruptible or bounded).
func (t *TCPChainSink) Stop() { func (t *TCPChainSink) Stop() {
t.logger.Info("msg", "Stopping TCP chain sink", t.logger.Info("msg", "Stopping TCP chain sink",
"component", "tcp_chain_sink", "component", "tcp_chain_sink",
@@ -273,27 +272,32 @@ func (t *TCPChainSink) runLoop(ctx context.Context) {
defer t.wg.Done() defer t.wg.Done()
defer t.closeConn() defer t.closeConn()
// Fold done into the context, so a connect or exchange in flight ends on Stop // Stop leaves sink.FlushBound to deliver the queue, so a finite input
ctx, cancel := context.WithCancel(ctx) // arrives whole; a connect, exchange or link still open then ends
ctx, cancel := sink.FlushContext(ctx, t.done, t.writeTimeout)
defer cancel() defer cancel()
go func() {
select {
case <-t.done:
cancel()
case <-ctx.Done():
}
}()
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return
case <-t.done: case <-t.done:
return for len(t.input) > 0 {
case event, ok := <-t.input: if !t.forward(ctx, <-t.input) {
if !ok {
return return
} }
}
return
case event := <-t.input:
if !t.forward(ctx, event) {
return
}
}
}
}
// 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) entry, synthesized := chain.EntryFromEvent(event, t.node, t.id)
if synthesized { if synthesized {
t.synthesized.Add(1) t.synthesized.Add(1)
@@ -304,16 +308,15 @@ func (t *TCPChainSink) runLoop(ctx context.Context) {
t.logger.Error("msg", "Failed to marshal chain entry", t.logger.Error("msg", "Failed to marshal chain entry",
"component", "tcp_chain_sink", "component", "tcp_chain_sink",
"error", err) "error", err)
continue return true
} }
if !t.deliver(ctx, append(line, '\n')) { if !t.deliver(ctx, append(line, '\n')) {
return // shutdown during retry return false
} }
t.totalProcessed.Add(1) t.totalProcessed.Add(1)
t.lastProcessed.Store(time.Now()) t.lastProcessed.Store(time.Now())
t.proxy.UpdateActivity(t.session.ID) t.proxy.UpdateActivity(t.session.ID)
} return true
}
} }
// deliver writes one line, holding it across reconnects until sent or shutdown. // deliver writes one line, holding it across reconnects until sent or shutdown.
@@ -399,11 +402,14 @@ func (t *TCPChainSink) connect(ctx context.Context) error {
return err return err
} }
// A source never writes once a link is up: a line is a refusal this sink // 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) t.wg.Add(1)
go func() { go func() {
defer t.wg.Done() defer t.wg.Done()
err := authz.AwaitClose(r) err := authz.AwaitClose(r)
stop()
conn.Close() conn.Close()
if errors.Is(err, authz.ErrRefused) { if errors.Is(err, authz.ErrRefused) {
t.logger.Warn("msg", "Chain link refused", t.logger.Warn("msg", "Chain link refused",
@@ -450,7 +456,5 @@ func (t *TCPChainSink) waitBackoff(ctx context.Context, failures int) bool {
return true return true
case <-ctx.Done(): case <-ctx.Done():
return false return false
case <-t.done:
return false
} }
} }
+71
View File
@@ -2,8 +2,10 @@ package tcpchain
import ( import (
"bufio" "bufio"
"fmt"
"net" "net"
"strconv" "strconv"
"strings"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
@@ -66,3 +68,72 @@ func TestRefusingSourceIsRetriedWithBackoff(t *testing.T) {
t.Fatalf("%d connections in 1.5 s against a refusing source", n) 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)
}
}
+11 -1
View File
@@ -19,6 +19,7 @@ import (
"github.com/lixenwraith/logwisp/internal/chain" "github.com/lixenwraith/logwisp/internal/chain"
"github.com/lixenwraith/logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/netacl"
"github.com/lixenwraith/logwisp/internal/plugin" "github.com/lixenwraith/logwisp/internal/plugin"
"github.com/lixenwraith/logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/session"
"github.com/lixenwraith/logwisp/internal/source" "github.com/lixenwraith/logwisp/internal/source"
@@ -59,6 +60,7 @@ type HTTPChainSource struct {
// Authorization // Authorization
auth *authz.Policy auth *authz.Policy
acl *netacl.Policy
// Session cache: one session per remote host + node + authenticated identity // Session cache: one session per remote host + node + authenticated identity
sessions map[string]string // key -> sessionID sessions map[string]string // key -> sessionID
@@ -120,6 +122,10 @@ func NewHTTPChainSourcePlugin(
if err != nil { if err != nil {
return nil, err return nil, err
} }
aclPolicy, err := netacl.New(opts.ACL, opts.Host, logger, "http_chain_source", id)
if err != nil {
return nil, err
}
s := &HTTPChainSource{ s := &HTTPChainSource{
id: id, id: id,
@@ -131,6 +137,7 @@ func NewHTTPChainSourcePlugin(
logger: logger, logger: logger,
tlsConfig: tlsCfg, tlsConfig: tlsCfg,
auth: authPolicy, auth: authPolicy,
acl: aclPolicy,
} }
s.lastEntryTime.Store(time.Time{}) s.lastEntryTime.Store(time.Time{})
@@ -142,7 +149,8 @@ func NewHTTPChainSourcePlugin(
"ingest_path", opts.IngestPath, "ingest_path", opts.IngestPath,
"tls", tlsCfg != nil, "tls", tlsCfg != nil,
"mtls", tlsCfg != nil && tlsCfg.ClientAuth == tls.RequireAndVerifyClientCert, "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) tlsx.LogWarnings(logger, "http_chain_source", id, opts.TLS, true)
authPolicy.LogStartup(logger, "http_chain_source", id, opts.TrustNode) authPolicy.LogStartup(logger, "http_chain_source", id, opts.TrustNode)
return s, nil return s, nil
@@ -180,6 +188,7 @@ func (s *HTTPChainSource) Start() error {
s.auth.Close() s.auth.Close()
return fmt.Errorf("listen %s: %w", addr, err) return fmt.Errorf("listen %s: %w", addr, err)
} }
ln = s.acl.Listener(ln)
mux := http.NewServeMux() mux := http.NewServeMux()
// Method-scoped pattern: mux answers 405 with Allow header on non-POST // 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, "trust_node": s.config.TrustNode,
} }
maps.Copy(details, s.auth.Stats()) maps.Copy(details, s.auth.Stats())
maps.Copy(details, s.acl.Stats())
return source.SourceStats{ return source.SourceStats{
ID: s.id, ID: s.id,
+11 -1
View File
@@ -17,6 +17,7 @@ import (
"github.com/lixenwraith/logwisp/internal/chain" "github.com/lixenwraith/logwisp/internal/chain"
"github.com/lixenwraith/logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/netacl"
"github.com/lixenwraith/logwisp/internal/plugin" "github.com/lixenwraith/logwisp/internal/plugin"
"github.com/lixenwraith/logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/session"
"github.com/lixenwraith/logwisp/internal/source" "github.com/lixenwraith/logwisp/internal/source"
@@ -55,6 +56,7 @@ type TCPChainSource struct {
// Authorization // Authorization
auth *authz.Policy auth *authz.Policy
acl *netacl.Policy
mu sync.RWMutex mu sync.RWMutex
ctx context.Context ctx context.Context
@@ -105,6 +107,10 @@ func NewTCPChainSourcePlugin(
if err != nil { if err != nil {
return nil, err return nil, err
} }
aclPolicy, err := netacl.New(opts.ACL, opts.Host, logger, "tcp_chain_source", id)
if err != nil {
return nil, err
}
s := &TCPChainSource{ s := &TCPChainSource{
id: id, id: id,
@@ -116,6 +122,7 @@ func NewTCPChainSourcePlugin(
logger: logger, logger: logger,
tlsConfig: tlsCfg, tlsConfig: tlsCfg,
auth: authPolicy, auth: authPolicy,
acl: aclPolicy,
} }
s.lastEntryTime.Store(time.Time{}) s.lastEntryTime.Store(time.Time{})
@@ -126,7 +133,8 @@ func NewTCPChainSourcePlugin(
"port", opts.Port, "port", opts.Port,
"tls", tlsCfg != nil, "tls", tlsCfg != nil,
"mtls", tlsCfg != nil && tlsCfg.ClientAuth == tls.RequireAndVerifyClientCert, "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) tlsx.LogWarnings(logger, "tcp_chain_source", id, opts.TLS, true)
authPolicy.LogStartup(logger, "tcp_chain_source", id, opts.TrustNode) authPolicy.LogStartup(logger, "tcp_chain_source", id, opts.TrustNode)
return s, nil return s, nil
@@ -166,6 +174,7 @@ func (s *TCPChainSource) Start() error {
s.auth.Close() s.auth.Close()
return fmt.Errorf("listen %s: %w", addr, err) return fmt.Errorf("listen %s: %w", addr, err)
} }
ln = s.acl.Listener(ln)
if s.tlsConfig != nil { if s.tlsConfig != nil {
ln = tls.NewListener(ln, s.tlsConfig) ln = tls.NewListener(ln, s.tlsConfig)
} }
@@ -226,6 +235,7 @@ func (s *TCPChainSource) GetStats() source.SourceStats {
"trust_node": s.config.TrustNode, "trust_node": s.config.TrustNode,
} }
maps.Copy(details, s.auth.Stats()) maps.Copy(details, s.auth.Stats())
maps.Copy(details, s.acl.Stats())
return source.SourceStats{ return source.SourceStats{
ID: s.id, ID: s.id,
+88
View File
@@ -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 | wc -c)
wait "$rpid" "$fpid"
n=$(grep -c '^flush ' "$RUN/flush.out")
check "a client the acl admits gets all 20000 lines of a finite input ($n)" "$((n == 20000))"
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 "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; }
received() { cat "$RUN/out/$1"/aggregate*.log 2>/dev/null | grep -c 'edge line'; }
edge() { spawn "$1" "$BIN" --preset "edge,path=$RUN/edge.log,from=start,to=127.0.0.1:$2,transport=$3,pin=$4,user=edge-01,password_file=$RUN/edge-01.pass"; }
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 </dev/null
wait_until 10 eval '(( $(received allow) >= 300 ))'
check "a tcp_chain source admits an edge its allow list names ($(received allow)), and refuses 127.0.0.2" \
"$( (($(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
+9 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# logwisp preset test: colors, presets, --dump, lw tls, listeners that make # logwisp preset test: colors, presets, --dump, lw tls, listeners that make
# their certificate (self-signed and pinned, or issued), the serve preset's # 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. # One-shot: both modes run the checks.
# Ports: 15890-15896 on 127.0.0.1. # Ports: 15890-15896 on 127.0.0.1.
# Requires: bash 5+, coreutils, curl, openssl. Linux dev host only. # 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") n=$(grep -c '^data: flush ' "$RUN/flush-http.out")
check "an http stream gets all 20000 lines, then the disconnect event ($n)" \ 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)" "$( ((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 summary