v0.25.0 netacl and fixes
This commit is contained in:
1 parent
9e86d7999d
commit
4af072f4e5
30 files changed
+918
-228
No files matched your search
@@ -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
@@ -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]
|
||||||
|
|||||||
+21
-15
@@ -87,27 +87,33 @@ 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
|
||||||
check := func(kind, name string, err error) {
|
for key, bad := range map[string]map[string]any{
|
||||||
if err == nil || !strings.Contains(err.Error(), "no_such_key") {
|
`"no_such_key"`: {"no_such_key": true},
|
||||||
t.Errorf("%s %q accepted an unknown key: %v", kind, name, err)
|
`"acl`: {"acl": map[string]any{"no_such_key": true}},
|
||||||
|
} {
|
||||||
|
check := func(kind, name string, err error) {
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "unknown key "+key) {
|
||||||
|
t.Errorf("%s %q accepted %v: %v", kind, name, bad, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range plugin.ListSources() {
|
||||||
|
factory, _ := plugin.GetSource(name)
|
||||||
|
_, err := factory("typo", bad, logger, proxy)
|
||||||
|
check("source", name, err)
|
||||||
|
}
|
||||||
|
for _, name := range plugin.ListSinks() {
|
||||||
|
factory, _ := plugin.GetSink(name)
|
||||||
|
_, err := factory("typo", bad, logger, proxy)
|
||||||
|
check("sink", name, err)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
for _, name := range plugin.ListSources() {
|
|
||||||
factory, _ := plugin.GetSource(name)
|
|
||||||
_, err := factory("typo", bad, logger, proxy)
|
|
||||||
check("source", name, err)
|
|
||||||
}
|
|
||||||
for _, name := range plugin.ListSinks() {
|
|
||||||
factory, _ := plugin.GetSink(name)
|
|
||||||
_, err := factory("typo", bad, logger, proxy)
|
|
||||||
check("sink", name, err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+27
-6
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+27
-54
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
}
|
||||||
@@ -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))}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
case event, ok := <-t.input:
|
case event := <-t.input:
|
||||||
if !ok {
|
if t.append(event) {
|
||||||
t.finalFlush()
|
t.flush(ctx)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
t.append(event)
|
case <-t.done:
|
||||||
if t.batchCount >= t.config.MaxBatchCount ||
|
for len(t.input) > 0 {
|
||||||
int64(t.batch.Len()) >= t.config.MaxBatchBytes {
|
if t.append(<-t.input) {
|
||||||
if !t.flush(runCtx) {
|
t.flush(ctx)
|
||||||
t.finalFlush()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if t.batchCount > 0 {
|
||||||
|
t.flush(ctx)
|
||||||
|
}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
t.droppedBatches.Add(1)
|
||||||
|
t.logger.Warn("msg", "Chain batch dropped on shutdown",
|
||||||
// finalFlush best-effort delivers the pending batch during shutdown (single attempt)
|
"component", "http_chain_sink",
|
||||||
func (t *HTTPChainSink) finalFlush() {
|
"target", t.url,
|
||||||
if t.batchCount == 0 {
|
"entries", count)
|
||||||
return
|
|
||||||
}
|
|
||||||
fctx, cancel := context.WithTimeout(context.Background(), t.reqTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
count := t.batchCount
|
|
||||||
if _, err := t.post(fctx, t.batch.Bytes()); err != nil {
|
|
||||||
t.droppedBatches.Add(1)
|
|
||||||
t.logger.Warn("msg", "Final chain batch dropped on shutdown",
|
|
||||||
"component", "http_chain_sink",
|
|
||||||
"entries", count,
|
|
||||||
"error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t.batchesSent.Add(1)
|
|
||||||
t.totalProcessed.Add(uint64(count))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// post sends one NDJSON batch; transient=true marks retryable failures
|
// post sends one NDJSON batch; transient=true marks retryable failures
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,49 +272,53 @@ 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:
|
||||||
|
for len(t.input) > 0 {
|
||||||
|
if !t.forward(ctx, <-t.input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
return
|
return
|
||||||
case event, ok := <-t.input:
|
case event := <-t.input:
|
||||||
if !ok {
|
if !t.forward(ctx, event) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
entry, synthesized := chain.EntryFromEvent(event, t.node, t.id)
|
|
||||||
if synthesized {
|
|
||||||
t.synthesized.Add(1)
|
|
||||||
}
|
|
||||||
line, err := json.Marshal(entry)
|
|
||||||
if err != nil {
|
|
||||||
// Non-transient: drop
|
|
||||||
t.logger.Error("msg", "Failed to marshal chain entry",
|
|
||||||
"component", "tcp_chain_sink",
|
|
||||||
"error", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !t.deliver(ctx, append(line, '\n')) {
|
|
||||||
return // shutdown during retry
|
|
||||||
}
|
|
||||||
t.totalProcessed.Add(1)
|
|
||||||
t.lastProcessed.Store(time.Now())
|
|
||||||
t.proxy.UpdateActivity(t.session.ID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// forward delivers one event; false once shutdown ends the delivery
|
||||||
|
func (t *TCPChainSink) forward(ctx context.Context, event core.TransportEvent) bool {
|
||||||
|
entry, synthesized := chain.EntryFromEvent(event, t.node, t.id)
|
||||||
|
if synthesized {
|
||||||
|
t.synthesized.Add(1)
|
||||||
|
}
|
||||||
|
line, err := json.Marshal(entry)
|
||||||
|
if err != nil {
|
||||||
|
// Non-transient: drop
|
||||||
|
t.logger.Error("msg", "Failed to marshal chain entry",
|
||||||
|
"component", "tcp_chain_sink",
|
||||||
|
"error", err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !t.deliver(ctx, append(line, '\n')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
t.totalProcessed.Add(1)
|
||||||
|
t.lastProcessed.Store(time.Now())
|
||||||
|
t.proxy.UpdateActivity(t.session.ID)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// deliver writes one line, holding it across reconnects until sent or shutdown.
|
// deliver writes one line, holding it across reconnects until sent or shutdown.
|
||||||
// Backpressure during outage propagates to the pipeline dispatch drop counter.
|
// Backpressure during outage propagates to the pipeline dispatch drop counter.
|
||||||
func (t *TCPChainSink) deliver(ctx context.Context, line []byte) bool {
|
func (t *TCPChainSink) deliver(ctx context.Context, line []byte) bool {
|
||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Executable
+88
@@ -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
@@ -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
|
||||||
Reference in new issue
Block a user