v0.17.0 mtls added to network chain, sinks, and sources

This commit is contained in:
2026-08-29 18:44:44 -04:00
parent 5fbd5c71cf
commit dd665bb339
26 changed files with 2293 additions and 488 deletions
+1
View File
@@ -14,3 +14,4 @@ build.sh
catalog.txt catalog.txt
combined.txt combined.txt
test/run/ test/run/
test/run-mtls/
+12 -9
View File
@@ -60,18 +60,21 @@ as if the entries were local. Entries keep a `node` label identifying their
origin across any number of hops. Chain sinks reconnect automatically with origin across any number of hops. Chain sinks reconnect automatically with
exponential backoff and jitter. exponential backoff and jitter.
### Transport security ### Transport security and authentication
- TLS 1.2/1.3 on every network source and sink, listener and dialer alike - TLS 1.2/1.3 on every network source and sink, listener and dialer alike
- Mutual TLS: listeners can require and verify client certificates; dialers can - Mutual TLS: listeners can require and verify client certificates; dialers can
present a client identity present a client identity
- Authorization by certificate identity: an `auth` block admits named peers
(exact or RE2) rather than everything the CA issued, gates the `http` sink's
stream and status endpoints, and lets a dialer pin the server it talks to
- Node binding: a chain source can label entries from the sender's certificate
instead of from what the sender claims, so origin attribution is not forgeable
mTLS is currently a CA-wide membership check — any certificate the configured CA See [Security](doc/security.md) for configuration and the exact boundary, and
issued is accepted, and the peer's Common Name is recorded but not used for the [mTLS authentication design](doc/mtls-auth-plan.md) for the rationale and
authorization. See [Security](doc/security.md) for the exact boundary and what is deliberately left out. Password, token, and SCRAM authentication were
[the mTLS authentication plan](doc/mtls-auth-plan.md) for the proposed work. removed during the restructure and are not currently available.
Password, token, and SCRAM authentication were removed during the restructure
and are not currently available.
## Documentation ## Documentation
@@ -86,8 +89,8 @@ and are not currently available.
| [Formatters](doc/formatters.md) | Output shaping and sanitization | | [Formatters](doc/formatters.md) | Output shaping and sanitization |
| [Chaining](doc/chaining.md) | Multi-node topologies and the chain wire protocol | | [Chaining](doc/chaining.md) | Multi-node topologies and the chain wire protocol |
| [Networking](doc/networking.md) | Listeners, dialers, timeouts, connection limits | | [Networking](doc/networking.md) | Listeners, dialers, timeouts, connection limits |
| [Security](doc/security.md) | TLS and mTLS configuration, threat model, current limits | | [Security](doc/security.md) | TLS, mTLS, and peer authorization; threat model and current limits |
| [mTLS Authentication Plan](doc/mtls-auth-plan.md) | Design for certificate-based authorization | | [mTLS Authentication](doc/mtls-auth-plan.md) | Design and rationale for certificate-based authorization |
| [CLI](doc/cli.md) | Flags, signals, exit codes | | [CLI](doc/cli.md) | Flags, signals, exit codes |
| [Operations](doc/operations.md) | Running, monitoring, tuning, troubleshooting | | [Operations](doc/operations.md) | Running, monitoring, tuning, troubleshooting |
+63 -4
View File
@@ -6,10 +6,11 @@
### 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: authentication (password/token/SCRAM), network access control (ACL), ### NOTE: password/token/SCRAM authentication, network access control (ACL),
### http/tcp ingest sources, and http_client/tcp_client sinks were removed in ### http/tcp ingest sources, and http_client/tcp_client sinks were removed in
### the restructure and are not available in this version. TLS and mTLS ARE ### the restructure and are not available in this version. TLS and mTLS ARE
### available on every network source and sink; see the [...tls] blocks below. ### available on every network source and sink; see the [...tls] blocks below,
### and the [...auth] blocks to authorize peers by certificate identity.
### ###
### Environment overrides are currently read WITHOUT the LOGWISP_ prefix ### Environment overrides are currently read WITHOUT the LOGWISP_ prefix
### (QUIET, LOGGING_LEVEL, ...). LOGWISP_CONFIG_FILE and LOGWISP_CONFIG_DIR ### (QUIET, LOGGING_LEVEL, ...). LOGWISP_CONFIG_FILE and LOGWISP_CONFIG_DIR
@@ -106,8 +107,32 @@ name = "default"
### insecure_skip_verify = false Dialer: disable verification (never in prod) ### insecure_skip_verify = false Dialer: disable verification (never in prod)
### min_version = "1.3" "1.2" or "1.3". No max_version, no cipher_suites. ### min_version = "1.3" "1.2" or "1.3". No max_version, no cipher_suites.
### ###
### Any certificate signed by client_ca_file is accepted; the peer CN is ### TLS alone is a CA membership check: ANY certificate the CA signed is
### recorded but not used for authorization. See doc/security.md. ### accepted. Add an [...auth] block to decide WHICH of them may connect.
###============================================================================
###============================================================================
### AUTH (shared shape; sits beside [...tls] on every network source and sink)
###
### Listeners (tcp/http sinks, tcp_chain/http_chain sources):
### authorize the client certificate. type = "mtls" REQUIRES tls.enabled and
### tls.client_auth. On the http sink it gates stream_path AND status_path.
### Chain sources additionally bind the node label to the identity.
### Dialers (tcp_chain/http_chain sinks):
### pin the server identity. type = "mtls" REQUIRES tls.enabled and forbids
### tls.insecure_skip_verify.
###
### type = "none" none | mtls
### identity = "cn" cn | san_dns | san_uri | san_email
### allow = [] Exact identities. Empty allow AND allow_patterns
### admits any identity the CA vouches for (logged WARN).
### allow_patterns = [] RE2 patterns; anchor them yourself (^...$)
### node_binding = "" Chain sources only, default "force" under mtls:
### none - trust_node governs, as before
### assert - declared label must equal the identity;
### per-entry node labels still follow trust_node
### force - label AND every entry take the identity
### Overrides trust_node. See doc/security.md.
###============================================================================ ###============================================================================
###============================================================================ ###============================================================================
@@ -166,6 +191,12 @@ check_interval_ms = 100 # Directory rescan interval (min 10)
# client_auth = true # client_auth = true
# client_ca_file = "/etc/logwisp/tls/client-ca.crt" # client_ca_file = "/etc/logwisp/tls/client-ca.crt"
# min_version = "1.3" # min_version = "1.3"
# [pipelines.plugin_sources.config.auth] # authorize senders by certificate
# type = "none" # none | mtls; mtls requires client_auth
# identity = "cn" # cn | san_dns | san_uri | san_email
# allow = [] # exact identities; empty = any the CA issued
# allow_patterns = [] # RE2, anchor them yourself
# node_binding = "force" # none | assert | force; overrides trust_node
## 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]]
@@ -185,6 +216,12 @@ check_interval_ms = 100 # Directory rescan interval (min 10)
# key_file = "/etc/logwisp/tls/server.key" # key_file = "/etc/logwisp/tls/server.key"
# client_auth = true # client_auth = true
# client_ca_file = "/etc/logwisp/tls/client-ca.crt" # client_ca_file = "/etc/logwisp/tls/client-ca.crt"
# [pipelines.plugin_sources.config.auth] # authorize senders by certificate
# type = "none" # none | mtls; mtls requires client_auth
# identity = "cn" # cn | san_dns | san_uri | san_email
# allow = [] # exact identities; empty = any the CA issued
# allow_patterns = [] # RE2, anchor them yourself
# node_binding = "force" # none | assert | force; overrides trust_node
###============================================================================ ###============================================================================
### Sinks (1+ required, fan-out) ### Sinks (1+ required, fan-out)
@@ -237,6 +274,11 @@ target = "stdout" # stdout|stderr ("split" NOT supported)
# key_file = "/etc/logwisp/tls/server.key" # key_file = "/etc/logwisp/tls/server.key"
# client_auth = false # client_auth = false
# client_ca_file = "" # client_ca_file = ""
# [pipelines.plugin_sinks.config.auth] # gates BOTH stream_path and status_path
# type = "none" # none | mtls; mtls requires client_auth
# identity = "cn" # cn | san_dns | san_uri | san_email
# allow = [] # exact identities; empty = any the CA issued
# allow_patterns = [] # RE2, anchor them yourself
## TCP sink (streaming server, IPv4 clients only) ## TCP sink (streaming server, IPv4 clients only)
# [[pipelines.plugin_sinks]] # [[pipelines.plugin_sinks]]
@@ -255,6 +297,13 @@ target = "stdout" # stdout|stderr ("split" NOT supported)
# enabled = true # example # enabled = true # example
# cert_file = "/etc/logwisp/tls/server.crt" # cert_file = "/etc/logwisp/tls/server.crt"
# key_file = "/etc/logwisp/tls/server.key" # key_file = "/etc/logwisp/tls/server.key"
# client_auth = true
# client_ca_file = "/etc/logwisp/tls/client-ca.crt"
# [pipelines.plugin_sinks.config.auth] # authorize stream readers
# type = "none" # none | mtls; mtls requires client_auth
# identity = "cn" # cn | san_dns | san_uri | san_email
# allow = [] # exact identities; empty = any the CA issued
# allow_patterns = [] # RE2, anchor them yourself
## 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
@@ -281,6 +330,11 @@ target = "stdout" # stdout|stderr ("split" NOT supported)
# cert_file = "/etc/logwisp/tls/client.crt" # cert_file = "/etc/logwisp/tls/client.crt"
# key_file = "/etc/logwisp/tls/client.key" # key_file = "/etc/logwisp/tls/client.key"
# min_version = "1.3" # min_version = "1.3"
# [pipelines.plugin_sinks.config.auth] # pin the downstream server's identity
# type = "none" # none | mtls; mtls requires tls.enabled
# identity = "cn" # cn | san_dns | san_uri | san_email
# allow = [] # exact identities; empty = any the CA issued
# allow_patterns = [] # RE2, anchor them yourself
## HTTP chain sink (stdlib client; batched NDJSON POST to downstream http_chain source) ## HTTP chain sink (stdlib client; batched NDJSON POST to downstream http_chain source)
# [[pipelines.plugin_sinks]] # [[pipelines.plugin_sinks]]
@@ -303,3 +357,8 @@ target = "stdout" # stdout|stderr ("split" NOT supported)
# ca_file = "/etc/logwisp/tls/ca.crt" # ca_file = "/etc/logwisp/tls/ca.crt"
# cert_file = "/etc/logwisp/tls/client.crt" # cert_file = "/etc/logwisp/tls/client.crt"
# key_file = "/etc/logwisp/tls/client.key" # key_file = "/etc/logwisp/tls/client.key"
# [pipelines.plugin_sinks.config.auth] # pin the downstream server's identity
# type = "none" # none | mtls; mtls requires tls.enabled
# identity = "cn" # cn | san_dns | san_uri | san_email
# allow = [] # exact identities; empty = any the CA issued
# allow_patterns = [] # RE2, anchor them yourself
+10 -5
View File
@@ -18,8 +18,8 @@ streams, or downstream LogWisp nodes.
| [Formatters](formatters.md) | Output shaping and sanitization | | [Formatters](formatters.md) | Output shaping and sanitization |
| [Chaining](chaining.md) | Multi-node topologies and the chain wire protocol | | [Chaining](chaining.md) | Multi-node topologies and the chain wire protocol |
| [Networking](networking.md) | Listeners, dialers, timeouts, connection limits | | [Networking](networking.md) | Listeners, dialers, timeouts, connection limits |
| [Security](security.md) | TLS and mTLS configuration, threat model, current limits | | [Security](security.md) | TLS, mTLS, and peer authorization; threat model and current limits |
| [mTLS Authentication Plan](mtls-auth-plan.md) | Design for certificate-based authorization | | [mTLS Authentication](mtls-auth-plan.md) | Design and rationale for certificate-based authorization |
| [CLI](cli.md) | Flags, signals, exit codes | | [CLI](cli.md) | Flags, signals, exit codes |
| [Operations](operations.md) | Running, monitoring, tuning, troubleshooting | | [Operations](operations.md) | Running, monitoring, tuning, troubleshooting |
@@ -55,12 +55,17 @@ endpoint), `tcp` (broadcast server), `null`, and the chain forwarders
- `raw`, `txt`, and `json` formatting with selectable sanitizer policies - `raw`, `txt`, and `json` formatting with selectable sanitizer policies
- Optional flow-level heartbeat entries - Optional flow-level heartbeat entries
### Transport security ### Transport security and authentication
- TLS 1.2/1.3 on every network source and sink, listener and dialer alike - TLS 1.2/1.3 on every network source and sink, listener and dialer alike
- Mutual TLS: listeners can require and verify client certificates; dialers can - Mutual TLS: listeners can require and verify client certificates; dialers can
present a client identity. See [Security](security.md) for what this does and present a client identity
does not currently give you. - Authorization by certificate identity, per listener: named peers rather than
everything the CA issued, with the `http` sink's endpoints gated too
- Node binding, so a chain source labels entries from the sender's certificate
rather than from what the sender claims
See [Security](security.md) for what each layer does and does not give you.
## Quick Start ## Quick Start
+7 -4
View File
@@ -147,15 +147,18 @@ TLS is built in exactly one place, `internal/tlsx`, which exposes
Each pipeline owns a `session.Manager`. Plugins receive a `session.Proxy` Each pipeline owns a `session.Manager`. Plugins receive a `session.Proxy`
scoped to their instance id, so one plugin cannot see or remove another's scoped to their instance id, so one plugin cannot see or remove another's
sessions. A session records the remote address, creation and last-activity sessions. A session records the remote address, creation and last-activity
timestamps, and metadata — including `tls` and `tls_peer_cn` for TLS peers. timestamps, and metadata — including `tls` and `tls_peer_cn` for TLS peers, and
`auth_method` / `auth_identity` for authorized ones.
Idle sessions are reaped every 5 minutes against a 30-minute idle limit. The Idle sessions are reaped every 5 minutes against a 30-minute idle limit. The
HTTP sink's broker treats a vanished session as an eviction signal and closes HTTP sink's broker treats a vanished session as an eviction signal and closes
the corresponding SSE client. the corresponding SSE client.
Session metadata is currently bookkeeping only: nothing in the pipeline makes Authorization decisions do not read session metadata — they are made from the
an authorization decision from it. Closing that gap is the subject of the handshake by `internal/authz`, at the point of connection or request, and their
[mTLS authentication plan](mtls-auth-plan.md). outcome is *recorded* in the session. That ordering matters: a session exists
only for a peer that was already admitted. See the
[mTLS authentication design](mtls-auth-plan.md).
## Configuration Reload ## Configuration Reload
+30 -6
View File
@@ -45,15 +45,29 @@ Chained entries carry a `node` label identifying where they originated.
Relays preserve `node`, so a label survives any number of hops and identifies Relays preserve `node`, so a label survives any number of hops and identifies
the original producer rather than the last relay. the original producer rather than the last relay.
Under mTLS the source can instead bind the label to the sender's certificate,
which overrides `trust_node` entirely:
| `auth.node_binding` | Connection label | Per-entry `node` field |
|---------------------|------------------|------------------------|
| `none` | `trust_node` governs | `trust_node` governs |
| `assert` | Must equal the certificate identity, or the peer is rejected | `trust_node` governs |
| `force` (default under `mtls`) | The certificate identity | Overwritten with the identity |
Pick `force` at an ingest boundary you do not trust — it is the only setting
where a compromised edge cannot mislabel its entries, including through the
per-entry `node` field. Pick `assert` on a relay-to-relay hop, where the relay
should prove its own identity but the origin labels it forwards must survive.
See [Security](security.md#node-binding).
Formatters render node identity as a syslog-style prefix on the source field: Formatters render node identity as a syslog-style prefix on the source field:
`edge-01/app.log`. In JSON output the node therefore appears inside the source `edge-01/app.log`. In JSON output the node therefore appears inside the source
field, not as a separate top-level key. field, not as a separate top-level key.
> `trust_node = true` means an authenticated peer can claim **any** node label, > `trust_node = true` with no `auth` block means any peer the CA vouches for can
> including one belonging to another host. On an untrusted network use > claim **any** node label, including one belonging to another host. On an
> `trust_node = false`, or read the > untrusted network set `auth.type = "mtls"` with `node_binding = "force"`;
> [mTLS authentication plan](mtls-auth-plan.md), which proposes binding the > `trust_node = false` is the fallback when certificates are not an option.
> label to the peer's certificate identity.
## Wire Protocol ## Wire Protocol
@@ -150,6 +164,9 @@ enabled = true
ca_file = "/etc/logwisp/tls/ca.crt" ca_file = "/etc/logwisp/tls/ca.crt"
cert_file = "/etc/logwisp/tls/edge-01.crt" cert_file = "/etc/logwisp/tls/edge-01.crt"
key_file = "/etc/logwisp/tls/edge-01.key" key_file = "/etc/logwisp/tls/edge-01.key"
[pipelines.plugin_sinks.config.auth]
type = "mtls"
allow = ["relay.internal"] # pin the relay, not just its hostname
``` ```
**Relay** — ingest, keep errors only, archive and stream: **Relay** — ingest, keep errors only, archive and stream:
@@ -172,13 +189,16 @@ type = "tcp_chain"
[pipelines.plugin_sources.config] [pipelines.plugin_sources.config]
host = "0.0.0.0" host = "0.0.0.0"
port = 15801 port = 15801
trust_node = true
[pipelines.plugin_sources.config.tls] [pipelines.plugin_sources.config.tls]
enabled = true enabled = true
cert_file = "/etc/logwisp/tls/relay.crt" cert_file = "/etc/logwisp/tls/relay.crt"
key_file = "/etc/logwisp/tls/relay.key" key_file = "/etc/logwisp/tls/relay.key"
client_auth = true client_auth = true
client_ca_file = "/etc/logwisp/tls/ca.crt" client_ca_file = "/etc/logwisp/tls/ca.crt"
[pipelines.plugin_sources.config.auth]
type = "mtls"
allow = ["edge-01", "edge-02"]
node_binding = "force" # entries are labelled from the certificate
[[pipelines.plugin_sinks]] [[pipelines.plugin_sinks]]
id = "archive" id = "archive"
@@ -195,6 +215,10 @@ host = "127.0.0.1"
port = 8080 port = 8080
``` ```
Entries arriving on this relay are labelled `edge-01` or `edge-02` because that
is what their certificates say, regardless of the `node` each edge configured.
`test/mtls-chain-test.sh` builds exactly this shape against a throwaway PKI.
## Operational Notes ## Operational Notes
- **Formatting is a relay decision.** Because chain links carry structured - **Formatting is a relay decision.** Because chain links carry structured
+219 -214
View File
@@ -1,46 +1,50 @@
# Implementation Plan: mTLS as Authentication # mTLS as Authentication
**Status:** proposal, not implemented. **Status:** implemented. Phases 13 of the original proposal, plus dialer-side
**Scope:** turn the existing transport-level mutual TLS into a real server identity pinning from phase 4, are in the tree and covered by
authentication and authorization mechanism. `test/mtls-chain-test.sh`. The remaining phase-4 items are listed under
[Not Implemented](#not-implemented).
**Scope:** turn transport-level mutual TLS into an authentication and
authorization mechanism.
## Problem ## Problem
LogWisp already does mutual TLS at the transport layer. A listener with LogWisp already did mutual TLS at the transport layer. A listener with
`client_auth = true` refuses any peer that cannot present a certificate `client_auth = true` refused any peer that could not present a certificate
chaining to `client_ca_file`, and `internal/tlsx` already extracts the peer's chaining to `client_ca_file`, and `internal/tlsx` extracted the peer's Common
Common Name and stashes it in session metadata as `tls_peer_cn`. Name into session metadata as `tls_peer_cn`.
Nothing reads it back. The result is a CA-wide membership check with no notion Nothing read it back. The result was a CA-wide membership check with no notion
of *which* peer connected: of *which* peer connected:
1. **No per-identity authorization.** Every certificate the CA issues is 1. **No per-identity authorization.** Every certificate the CA issued was
equivalent. There is no way to say "only `edge-01` and `edge-02` may write to equivalent. There was no way to say "only `edge-01` and `edge-02` may write
this ingest port", so one CA cannot serve several trust domains, and to this ingest port", so one CA could not serve several trust domains, and
withdrawing one peer means rotating the CA bundle for all of them. withdrawing one peer meant rotating the CA bundle for all of them.
2. **Node labels are unauthenticated.** With `trust_node = true` (the default) a 2. **Node labels were unauthenticated.** With `trust_node = true` (the default)
peer declares its own `node` label in the chain hello or the a peer declares its own `node` label in the chain hello or the
`X-Logwisp-Node` header. Any certificate holder can claim any label, `X-Logwisp-Node` header. Any certificate holder could claim any label,
including another host's, and every downstream consumer will attribute those including another host's, and every downstream consumer would attribute
entries accordingly. The only current defence, `trust_node = false`, replaces those entries accordingly. The only defence, `trust_node = false`, replaced
the label with a remote address — unforgeable, but useless for identifying a the label with a remote address — unforgeable, but useless for identifying a
host behind NAT or a load balancer. host behind NAT or a load balancer.
3. **The `http` sink has no authentication at all**, even with TLS on. Its 3. **The `http` sink had no authentication at all**, even with TLS on. Its
stream and status endpoints are readable by anyone who can reach the port. stream and status endpoints were readable by anyone who could reach the port.
Password, token, and SCRAM authentication were removed during the plugin/flow Password, token, and SCRAM authentication were removed during the plugin/flow
restructure and the move to standard-library networking. Certificates are the restructure and the move to standard-library networking. Certificates are the
one credential the current transport already carries, which makes mTLS the one credential the transport already carries, which made mTLS the cheapest path
cheapest path back to authenticated peers. back to authenticated peers.
## Goals ## Goals
- Authorize peers by certificate identity, per listener. - Authorize peers by certificate identity, per listener.
- Bind the chain `node` label to the authenticated identity, so origin - Bind the chain `node` label to the authenticated identity, so origin
attribution is trustworthy. attribution is trustworthy.
- Gate the `http` sink's endpoints on client certificates. - Gate the `http` sink's endpoints on client certificates.
- Make identity visible in sessions, statistics, and logs. - Make identity visible in sessions, statistics, and logs.
- Change nothing for existing configurations that omit the new block. - Change nothing for existing configurations that omit the new block.
## Non-Goals ## Non-Goals
@@ -50,7 +54,7 @@ cheapest path back to authenticated peers.
structs) stay reserved. structs) stay reserved.
- IP allow/deny lists and per-peer rate limits. Related, but a separate feature - IP allow/deny lists and per-peer rate limits. Related, but a separate feature
with its own config surface. with its own config surface.
- OCSP. See [Revocation](#revocation) for what is proposed instead. - OCSP. See [Revocation](#revocation) for what is done instead.
- Authorization *within* a stream — the unit of decision is a connection (TCP) - Authorization *within* a stream — the unit of decision is a connection (TCP)
or a request (HTTP), never an individual entry. or a request (HTTP), never an individual entry.
@@ -61,7 +65,7 @@ cheapest path back to authenticated peers.
The authenticated identity is a single string derived from the peer's verified The authenticated identity is a single string derived from the peer's verified
leaf certificate. Because `tls.RequireAndVerifyClientCert` has already validated leaf certificate. Because `tls.RequireAndVerifyClientCert` has already validated
the chain, signature, and validity window by the time we look, extraction is the chain, signature, and validity window by the time we look, extraction is
pure field selection. pure field selection`tlsx.PeerIdentity`.
| `identity` mode | Source | Notes | | `identity` mode | Source | Notes |
|-----------------|--------|-------| |-----------------|--------|-------|
@@ -73,15 +77,15 @@ pure field selection.
An empty identity is a rejection, not an empty match: a certificate with no An empty identity is a rejection, not an empty match: a certificate with no
usable identity field cannot satisfy any policy. usable identity field cannot satisfy any policy.
Identities are not secrets, so ordinary string comparison is fine; there is no Identities are not secrets, so ordinary string comparison is used; there is no
timing side channel worth defending here. timing side channel worth defending here.
### Configuration ### Configuration
A new `auth` table sits beside `tls` in every network plugin's `config`. Keeping An `auth` table sits beside `tls` in every network plugin's `config`. Keeping it
it separate from `tls` matters: TLS answers "is this channel private and is the separate from `tls` matters: TLS answers "is this channel private and does the
peer chained to a CA", auth answers "may *this* peer do *this*", and a later peer chain to a CA", auth answers "may *this* peer do *this*", and a later
non-certificate method should be able to reuse the block. non-certificate method can reuse the block.
```toml ```toml
[pipelines.plugin_sources.config.auth] [pipelines.plugin_sources.config.auth]
@@ -94,63 +98,77 @@ node_binding = "force" # none | assert | force
| Option | Type | Default | Meaning | | Option | Type | Default | Meaning |
|--------|------|---------|---------| |--------|------|---------|---------|
| `type` | string | `none` | `none` preserves today's behaviour exactly; `mtls` enables the policy | | `type` | string | `none` | `none` preserves pre-auth behaviour exactly; `mtls` enables the policy |
| `identity` | string | `cn` | Which certificate field is the identity | | `identity` | string | `cn` | Which certificate field is the identity |
| `allow` | []string | `[]` | Exact identity matches | | `allow` | []string | `[]` | Exact identity matches |
| `allow_patterns` | []string | `[]` | RE2 patterns matched against the identity | | `allow_patterns` | []string | `[]` | RE2 patterns matched against the identity |
| `node_binding` | string | `force` when `type = "mtls"` | See below | | `node_binding` | string | `force` when `type = "mtls"` | Chain sources only; see below |
Empty `allow` **and** empty `allow_patterns` under `type = "mtls"` means "any Empty `allow` **and** empty `allow_patterns` under `type = "mtls"` means "any
identity the CA vouches for" — that is, today's behaviour, but with the identity identity the CA vouches for" — that is, the pre-auth behaviour, but with the
now recorded and node binding available. It is a deliberate, documented default identity now recorded and node binding available. It is a deliberate, documented
rather than a silent deny-all, and startup logs say so plainly. default rather than a silent deny-all, and the plugin logs a WARN at startup
saying so.
`node_binding` applies only to the chain sources, where a `node` label is `node_binding` applies only to the chain sources, where a `node` label is
declared: declared. Setting it on any other plugin is a configuration error.
| Value | Behaviour | | Value | Connection label | Per-entry `node` field |
|-------|-----------| |-------|------------------|------------------------|
| `none` | `trust_node` governs, as today | | `none` | `trust_node` governs, as before | `trust_node` governs |
| `assert` | The declared label must equal the identity; a mismatch is rejected | | `assert` | Must equal the identity; a mismatch or an omission is rejected | `trust_node` governs |
| `force` | The declared label is ignored and the identity is used | | `force` | The declared label is ignored and the identity is used | Overwritten with the identity |
`force` is the default under `type = "mtls"` because it is the only setting The split between `assert` and `force` is what makes both worth having:
where a misconfigured or hostile edge cannot mislabel its entries. `assert`
exists for operators who want the mismatch to be loud rather than silently
corrected. `node_binding` overrides `trust_node`; when both are set, the
constructor logs that `trust_node` is being ignored.
For the `tcp` and `http` sinks, which have no node concept, `node_binding` is - **`force`** is for an ingest boundary that does not trust its peer. Every
ignored. entry is relabeled, so a compromised edge cannot smuggle a foreign origin
through the per-entry `node` field either. It is the default under
`type = "mtls"` because it is the only setting where a misconfigured or
hostile edge cannot mislabel its entries.
- **`assert`** is for a relay-to-relay hop. The relay must prove *its own*
identity — a mismatch is loud rather than silently corrected — but the entries
it forwards keep the origin labels stamped at the first hop, so multi-hop
attribution survives.
`node_binding` overrides `trust_node`; when binding is active the constructor
logs that `trust_node` is being ignored.
Dialer-side plugins (`tcp_chain` and `http_chain` sinks) accept the same block Dialer-side plugins (`tcp_chain` and `http_chain` sinks) accept the same block
to pin the *server's* identity beyond hostname verification. This is deferred to to pin the *server's* identity beyond hostname verification. There
phase 4 and does nothing before then. `node_binding` does not apply, and `tls.insecure_skip_verify` is rejected:
identity read from an unverified chain is a claim, not a fact.
### Validation ### Validation
At plugin construction, before anything binds: At plugin construction, before anything binds:
- `type = "mtls"` on a listener requires `tls.enabled = true` and - `type = "mtls"` on a listener requires `tls.enabled = true` and
`tls.client_auth = true`. Silently accepting an auth policy the transport `tls.client_auth = true`; on a dialer it requires `tls.enabled = true` and
cannot enforce is the failure mode worth designing out. forbids `tls.insecure_skip_verify`. Silently accepting an auth policy the
transport cannot enforce is the failure mode worth designing out.
- `identity` must be one of the four modes. - `identity` must be one of the four modes.
- Every entry in `allow_patterns` must compile. - Every entry in `allow_patterns` must compile.
- `node_binding` must be one of the three values. - `node_binding` must be one of the three values, and must be absent or `none`
outside the chain sources.
Errors follow existing style: `auth: type "mtls" requires tls.client_auth`. Errors follow existing style: `auth: type "mtls" requires tls.client_auth`.
### New package: `internal/authz` ### The `internal/authz` package
```go ```go
package authz package authz
// Policy is the compiled form of config.AuthOptions. // Policy is the compiled form of config.AuthOptions.
type Policy struct { /* mode, identity selector, exact set, patterns, binding */ } type Policy struct { /* role, identity mode, exact set, patterns, binding, counters */ }
// Role selects the validation and behavior appropriate to the call site.
const ( RoleListener Role = iota; RoleChainListener; RoleDialer )
// New compiles a policy. Returns (nil, nil) when auth is disabled, matching // New compiles a policy. Returns (nil, nil) when auth is disabled, matching
// the tlsx.Server / tlsx.Client convention so callers can nil-check. // the tlsx.Server / tlsx.Client convention. tlsOpts is the sibling `tls`
func New(o *config.AuthOptions) (*Policy, error) // block, so an unenforceable policy fails here rather than at run time.
func New(o *config.AuthOptions, tlsOpts *config.TLSOptions, role Role) (*Policy, error)
// Identity is the outcome of a successful authorization. // Identity is the outcome of a successful authorization.
type Identity struct { type Identity struct {
@@ -158,211 +176,198 @@ type Identity struct {
Method string // "mtls" Method string // "mtls"
} }
// Apply stamps an identity onto session metadata.
func (id Identity) Apply(meta map[string]any)
// Authorize extracts and checks the peer identity from a completed handshake. // Authorize extracts and checks the peer identity from a completed handshake.
func (p *Policy) Authorize(cs *tls.ConnectionState) (Identity, error) func (p *Policy) Authorize(cs *tls.ConnectionState) (Identity, error)
// ResolveNode applies node_binding to a declared label. // VerifyConnection is assignable to tls.Config.VerifyConnection on a dialer.
func (p *Policy) ResolveNode(declared string, id Identity) (string, error) func (p *Policy) VerifyConnection(cs tls.ConnectionState) error
// ResolveNode applies node_binding to the label a peer declared.
func (p *Policy) ResolveNode(declared, fallback string, trustNode bool, id Identity) (string, error)
// TrustsEntryNode reports whether per-entry node labels survive the policy.
func (p *Policy) TrustsEntryNode(trustNode bool) bool
// Stats reports counters for the sink/source stats map. // Stats reports counters for the sink/source stats map.
func (p *Policy) Stats() map[string]any func (p *Policy) Stats() map[string]any
``` ```
This mirrors `internal/tlsx`: one small package that is the single seam between This mirrors `internal/tlsx`: one small package that is the single seam between
declarative config and a cross-cutting concern, with `(nil, nil)` for the declarative config and a cross-cutting concern. `New` returns `(nil, nil)` for
disabled case so every call site is a nil check rather than a branch on config. the disabled case and **every method tolerates a nil receiver**, so a call site
reads identically whether or not auth is configured — no nil checks, no branch
on config:
```go
id, err := s.auth.Authorize(tlsState) // nil policy: (zero Identity, nil)
if err != nil { /* reject */ }
```
### Enforcement Points ### Enforcement Points
Each plugin already has the right spot, and in two cases the code says so.
**`tcp_chain` source** (`internal/source/tcpchain/tcpchain.go`, `handleConn`) **`tcp_chain` source** (`internal/source/tcpchain/tcpchain.go`, `handleConn`)
Today: handshake → read hello → decode → resolve node from `trust_node` Handshake → **authorize** read hello → `ResolveNode`create session. The
create session. Insert authorization between the handshake and the hello read, authorization sits between the handshake and the hello read, so an unauthorized
so an unauthorized peer never gets a preamble parsed on its behalf, and replace peer never gets a preamble parsed on its behalf. `chain.DecodeEntry` is then
the node resolution with `Policy.ResolveNode`. called with `auth.TrustsEntryNode(trust_node)` rather than `trust_node` itself.
```go
if tlsState != nil && s.authPolicy != nil {
id, err := s.authPolicy.Authorize(*tlsState)
if err != nil {
s.authRejected.Add(1)
s.logger.Warn("msg", "Connection rejected by auth policy",
"component", "tcp_chain_source", "remote_addr", remote, "error", err)
return // deferred cleanup closes conn
}
ident = id
}
// ... read and decode hello ...
connNode, err = s.authPolicy.ResolveNode(hello.Node, ident)
```
**`http_chain` source** (`internal/source/httpchain/httpchain.go`, `handleIngest`) **`http_chain` source** (`internal/source/httpchain/httpchain.go`, `handleIngest`)
Per request, from `r.TLS`, before the body is read — an unauthorized sender Per request, from `r.TLS`, before the body is read — an unauthorized sender does
should not get to stream 8 MiB into the process. Rejection is `403`, distinct not get to stream `max_body_bytes` into the process. Rejection is `403`,
from the `400` used for protocol errors, so a sender can tell "you are not distinct from the `400` used for protocol errors, so a sender can tell "you are
allowed" from "your batch was malformed". `ResolveNode` then governs the not allowed" from "your batch was malformed". `ResolveNode` then governs the
`X-Logwisp-Node` header exactly as it governs the TCP hello. `X-Logwisp-Node` header exactly as it governs the TCP hello. The session cache
key includes the identity, so two peers sharing a remote address never share a
session.
**`tcp` sink** (`internal/sink/tcp/tcp.go`, `handleConn`) **`tcp` sink** (`internal/sink/tcp/tcp.go`, `handleConn`)
There is already a comment marking the place: *"Password-auth extension point: After the explicit handshake, before the session is created and the client is
preamble verification runs in handleConn post-handshake, pre-registration."* registered — so an unauthorized peer never appears in the client map and never
Authorization goes precisely there — after the explicit handshake, before the receives a broadcast.
session is created and the client is registered — so an unauthorized peer never
appears in the client map and never receives a broadcast.
**`http` sink** (`internal/sink/http/http.go`, `Start`) **`http` sink** (`internal/sink/http/http.go`, `authMiddleware`)
Also already marked: *"Auth extension point: wrap mux with auth middleware once A middleware around the mux covers both the stream and the status endpoint with
credentials land, e.g. handler = authMiddleware(cfg)(handler)."* A middleware one wrapper and keeps the handlers themselves unaware of authorization. The
around the mux covers both the stream and the status endpoint with one wrapper, authorized identity is passed down through the request context for session
and keeps the handlers themselves unaware of authorization. metadata. Rejections are `403` with no body detail — the status endpoint leaks
host, port, and throughput counters, so a rejection should not leak policy shape
on top of that.
```go **`tcp_chain` / `http_chain` sinks** (dialers)
var handler http.Handler = mux
if h.authPolicy != nil {
handler = authMiddleware(h.authPolicy, h.logger)(handler)
}
```
The middleware rejects with `403` and no body detail — the status endpoint The policy is installed as `tls.Config.VerifyConnection`, which runs after the
leaks host, port, and throughput counters, so a rejection should not leak policy standard chain and hostname checks. A server whose identity the policy rejects
shape on top of it. fails the handshake itself rather than the first write, and the chain sink's
existing backoff loop handles it as any other connect failure.
### Capabilities ### Capabilities
`core.CapAuth` is currently derived from `tlsConfig.ClientAuth`. It should `core.CapAuth` now means "this plugin authorizes peers" — it is derived from the
reflect the auth policy instead: policy, not from `tlsConfig.ClientAuth`. Transport-level mTLS without a policy
still reports `CapTLS`, the `mtls=true` field on the startup log line, and the
`tls` statistic.
```go `Pipeline.initSourceCapabilities` and `initSinkCapabilities` treat this as a
if s.authPolicy != nil { cross-cutting check: a plugin advertising `CapAuth` without `CapTLS` is a
caps = append(caps, core.CapAuth) contradiction and fails pipeline construction rather than starting.
}
```
`Pipeline.initSourceCapabilities` and `initSinkCapabilities` treat `CapAuth` as
a no-op placeholder today. They become the natural place for a cross-cutting
check: a plugin advertising `CapAuth` without `CapTLS` is a contradiction and
should fail pipeline construction rather than start.
### Observability ### Observability
Every authorization decision must be visible, because a silent deny is Every authorization decision is visible, because a silent deny is
indistinguishable from a network fault at 3am. indistinguishable from a network fault at 3am.
- **Session metadata** gains `auth_method` and `auth_identity` alongside the - **Session metadata** gains `auth_method` and `auth_identity` alongside the
existing `tls` and `tls_peer_cn`. existing `tls` and `tls_peer_cn`.
- **Statistics** gain `auth_enabled`, `auth_rejected`, and `node_binding` in the - **Statistics** gain `auth`, `auth_identity` (the mode), `auth_unrestricted`,
`details` map of every affected source and sink, so rejections show up in the `auth_allowed`, `auth_rejected`, and — on chain sources — `node_binding`, in
status reporter and the `http` sink's status endpoint. the `details` map of every affected source and sink. They surface in the
- **Logs** record a WARN per rejection with the remote address, the extracted status reporter and in the `http` sink's status endpoint.
identity (or the reason extraction failed), and the policy that rejected it. - **Logs** record a WARN per rejection with the remote address and the reason.
Accepted connections log the identity at INFO on the chain sources and at The startup line carries a rendered policy summary
DEBUG on the sinks, matching each plugin's existing verbosity. (`auth="mtls identity=cn allow=[1 exact, 0 pattern(s)] node_binding=force"`),
a WARN when the allow list is empty, and an INFO when node binding overrides
`trust_node`.
### Revocation ### Revocation
Certificate revocation is deliberately handled by the allow-list rather than by Certificate revocation is handled by the allow-list rather than by CRL or OCSP:
CRL or OCSP:
1. Remove the identity from `allow` / `allow_patterns`. 1. Remove the identity from `allow` / `allow_patterns`.
2. `kill -HUP`. 2. `kill -HUP`.
The reload path already rebuilds every pipeline, so the policy takes effect on The reload path rebuilds every pipeline, so the policy takes effect on the next
the next connection and existing connections are dropped by the rebuild itself. connection and existing connections are dropped by the rebuild itself. This is
This is one moving part instead of three, it needs no network calls on the one moving part instead of three, it needs no network calls on the handshake
handshake path, and it is exact — no window between revocation and the next CRL path, and it is exact — no window between revocation and the next CRL
publication. publication.
A CRL file loaded next to `client_ca_file` and re-read on reload is a reasonable
phase-4 addition for operators with existing CRL infrastructure. OCSP stapling
is out of scope: it adds a network dependency to the handshake path for a
system whose entire design is "never block".
## Phases
### Phase 1 — Identity and policy (no enforcement)
- `internal/config`: add `AuthOptions` plus an `Auth *AuthOptions` field to the
four listener option structs.
- `internal/tlsx`: add `PeerIdentity(cs tls.ConnectionState, mode string) string`
beside the existing `PeerCN`.
- `internal/authz`: new package — `Policy`, `New`, `Authorize`, `ResolveNode`,
`Stats`.
- Unit tests: identity extraction per mode, exact and pattern matching, empty
policy, malformed patterns, node binding in all three modes.
Nothing behaves differently yet, which makes this phase safe to merge alone.
### Phase 2 — Chain sources
- Wire `authz` into `tcp_chain` and `http_chain` sources at the points above.
- Replace the `trust_node` node resolution with `ResolveNode`.
- Add validation, capabilities, statistics, and session metadata.
- Add `test/mtls-chain-test.sh`, modelled on `test/chain-test.sh`: generate a
CA, a server certificate, and two client certificates with `openssl`; assert
that an allowed identity delivers entries, a non-allowed identity is rejected,
a peer with no certificate fails the handshake, and a peer declaring another
node's label is rejected under `assert` and corrected under `force`.
This phase alone closes the node-spoofing hole, which is the sharpest of the
three problems.
### Phase 3 — Sinks
- `tcp` sink: authorize in `handleConn` before registration.
- `http` sink: `authMiddleware` around the mux, covering stream and status.
- Extend the test script to cover both.
### Phase 4 — Optional hardening
- Dialer-side server identity pinning for the chain sinks.
- CRL file support alongside `client_ca_file`, re-read on reload.
- Certificate expiry warnings at startup and on reload — a leaf expiring inside
30 days logged at WARN, since nothing warns today.
- Surface `tls_peer_cn` / `auth_identity` in the `http` sink's status output for
connected clients.
## Compatibility ## Compatibility
No configuration breaks. Omitting the `auth` block, or setting No configuration breaks. Omitting the `auth` block, or setting `type = "none"`,
`type = "none"`, reproduces current behaviour byte for byte: `Policy` is nil, reproduces the previous behaviour exactly: `Policy` is nil, every call site
every call site short-circuits, and `trust_node` continues to govern node short-circuits, and `trust_node` continues to govern node labels.
labels.
The one behavioural note for adopters: turning on `type = "mtls"` defaults The one behavioural note for adopters: turning on `type = "mtls"` defaults
`node_binding` to `force`, so entries from a peer whose certificate identity `node_binding` to `force`, so entries from a peer whose certificate identity
differs from its configured `node` label will be relabelled. That is the point differs from its configured `node` label will be relabelled. That is the point
of the feature, but it will move data between labels in a dashboard, so the of the feature, but it moves data between labels in a dashboard, so plan for it.
release note should say it in those terms. Use `node_binding = "assert"` on relay-to-relay hops where upstream origin
labels must survive.
## Estimated Cost ## Verification
| Phase | Files touched | Rough size | `test/mtls-chain-test.sh` builds a full PKI with `openssl` and exercises both
|-------|--------------|-----------| target topologies end to end:
| 1 | `config/config.go`, `config/validate.go`, `tlsx/tlsx.go`, new `authz/` + tests | ~400 lines |
| 2 | Two chain sources, new test script | ~200 lines |
| 3 | `sink/tcp`, `sink/http`, test script extension | ~150 lines |
| 4 | `tlsx`, both chain sinks, `sink/http` status | ~250 lines |
Phases 12 are the security-relevant core; phase 3 closes the read-side ```
exposure; phase 4 is discretionary. ./test/mtls-chain-test.sh --auto
```
## Open Questions Scenario 1 — chained instances, client authenticating with mTLS:
1. **Should an empty allow-list deny instead of allow?** Deny-by-default is the - an authorized edge (`edge-01`) delivers entries through both the `tcp_chain`
safer instinct, but it makes `type = "mtls"` with no list a footgun that and `http_chain` ingest ports into a file sink
silently drops all traffic. The proposal is allow-with-a-loud-startup-log; - `node_binding = "force"` overrides the label the sender configured
the alternative is requiring a non-empty list and erroring at construction, - an identity outside the allow list (`edge-99`) is refused, even while claiming
which is arguably better and costs one line. to be `edge-01`
2. **Should `identity` accept a list of modes** (try `san_uri`, fall back to - a peer presenting no certificate fails the handshake
`cn`)? Simpler as a single mode; heterogeneous PKI is the argument against. - a dialer that pins a server identity the relay does not hold refuses to
3. **Per-identity rate limits.** The natural follow-on once identity exists, and connect, even though the server certificate chains to the trusted CA
the natural home for the per-IP limiting that was also removed. Deliberately
out of scope here so this feature stays reviewable. Scenario 2 — a viewer client reading a streaming sink over mTLS:
4. **Whether `assert` should reject or warn-and-correct.** As proposed it
rejects, which is unambiguous but turns a certificate/config mismatch into an - an authorized viewer streams from the `tcp` sink and from the `http` sink's
outage. `force` is the forgiving option, and it is the default. SSE endpoint, and reads `/status`
- a CA-valid but unauthorized viewer gets nothing from the `tcp` sink and `403`
from both `http` sink endpoints
- a client with no certificate fails the handshake
- the status endpoint reports the policy and its rejection count
`test/chain-test.sh` and `test/chain-aggregate-test.sh` continue to pass
unchanged, which is the regression check for the auth-disabled path.
## Not Implemented
The remaining phase-4 items, in rough order of value:
1. **CRL file support** alongside `client_ca_file`, re-read on reload, for
operators with existing CRL infrastructure. The allow-list covers the same
ground with fewer moving parts, so this is only worth doing for a fleet whose
revocation already flows through a CRL.
2. **Certificate expiry warnings** at startup and on reload — a leaf expiring
inside 30 days logged at WARN. Nothing warns today; expiry shows up as a
handshake failure.
3. **Per-identity rate limits.** The natural follow-on now that identity exists,
and the natural home for the per-IP limiting that was also removed. Kept out
of scope here so this feature stayed reviewable.
4. **Per-client identity in the `http` sink's status output.** The endpoint
reports the policy and counters, but not which identities are currently
connected; session metadata has the data.
5. **A list of `identity` modes** (try `san_uri`, fall back to `cn`) for
heterogeneous PKI. A single mode is simpler and covers a uniform CA.
## Decisions Taken
Four questions were left open by the proposal. What was chosen, and why:
1. **An empty allow-list allows rather than denies.** Deny-by-default is the
safer instinct, but `type = "mtls"` with no list is a legitimate
configuration — "any peer this CA issued, but bind the node labels" — and
node binding alone is worth enabling without enumerating every node.
Erroring on it would force operators to list their whole fleet to get
trustworthy attribution. The compromise is a WARN at startup naming the
condition and the fix.
2. **`identity` takes a single mode.** Simpler, and a uniform CA is the common
case. Listed above as a possible extension.
3. **Per-identity rate limits are out of scope**, as proposed.
4. **`assert` rejects rather than warns and corrects.** A certificate/config
mismatch under `assert` is an outage, which is the point: `force` is the
forgiving option and it is the default, so an operator reaches for `assert`
precisely when they want the mismatch to be loud.
+23
View File
@@ -168,12 +168,35 @@ headers, and entry encoding.
- Handshake failures appear as WARN with the remote address, and increment - Handshake failures appear as WARN with the remote address, and increment
`tls_handshake_errors`. `tls_handshake_errors`.
**Rejected after a successful handshake**
- `auth: identity "..." is not allowed` — the certificate is valid and chains to
the CA, but the identity is not in `auth.allow` / `auth.allow_patterns`. On
TCP the connection is closed; on HTTP the answer is `403`.
- `auth: peer certificate carries no <mode> identity``auth.identity` names a
field the certificate does not populate, e.g. `san_dns` on a CN-only leaf.
- `auth: node_binding "assert": declared node "..." does not match identity`
the sender's `node` option and its certificate disagree. Fix one, or use
`node_binding = "force"` to let the certificate win silently.
- On a dialer, the same message inside `Chain connect failed` means the
*server* was refused: its certificate identity is not in the sink's
`auth.allow`.
- Rejections appear as WARN and increment `auth_rejected`.
**Entries not arriving over a chain link** **Entries not arriving over a chain link**
- Check the sink's `connected` statistic and its `reconnects` count. - Check the sink's `connected` statistic and its `reconnects` count.
- Check the source's `auth_rejected` — an allow-list miss looks exactly like a
network fault from the sender's side.
- Check the source's `parse_errors` — a version skew shows up here. - Check the source's `parse_errors` — a version skew shows up here.
- On `http_chain`, remember entries wait up to `flush_interval_ms` before a - On `http_chain`, remember entries wait up to `flush_interval_ms` before a
batch is sent. batch is sent.
**Entries arriving under an unexpected node label**
- `auth.node_binding` defaults to `force` when `auth.type = "mtls"`, which
relabels every entry with the sender's certificate identity. If a dashboard
suddenly shows a different label, that is why. Use `node_binding = "assert"`
to keep upstream origin labels on relay-to-relay hops, or `"none"` to leave
`trust_node` in charge.
**Clients connect but see nothing** **Clients connect but see nothing**
- The pipeline may be filtering everything out; check `flow.filters` stats. - The pipeline may be filtering everything out; check `flow.filters` stats.
- The rate limiter may be dropping everything; check `rate_limiter` stats. - The rate limiter may be dropping everything; check `rate_limiter` stats.
+8 -6
View File
@@ -268,12 +268,14 @@ then `kill -HUP`. Automate the expiry check; nothing in LogWisp warns you.
**Access review** **Access review**
With mTLS, any certificate signed by the configured `client_ca_file` is With `tls` alone, any certificate signed by the configured `client_ca_file` is
accepted — there is no per-identity allow-list, so "access review" means accepted, so "access review" means reviewing what your CA has issued. Add an
reviewing what your CA has issued. Peer Common Names are recorded in session `auth` block with an explicit `allow` list and the review becomes the config
metadata but are not surfaced in statistics and are not used for authorization. file itself: the identities listed there are the ones that can connect, and
See [Security](security.md) and the removing one plus a `SIGHUP` is the revocation path. Authorized identities are
[mTLS authentication plan](mtls-auth-plan.md). recorded in session metadata as `auth_identity`; rejections are counted in
`auth_rejected` and logged at WARN. See
[Security](security.md#the-auth-block).
**Secret leakage** **Secret leakage**
+199 -46
View File
@@ -10,16 +10,20 @@ configure it, and — equally important — what it does not yet do.
| TLS 1.2 / 1.3 on all network sources and sinks | Implemented | | TLS 1.2 / 1.3 on all network sources and sinks | Implemented |
| Server certificate verification by dialers | Implemented | | Server certificate verification by dialers | Implemented |
| Mutual TLS (client certificate required and verified) | Implemented at the transport layer | | Mutual TLS (client certificate required and verified) | Implemented at the transport layer |
| Peer identity (certificate CN) recorded per session | Implemented | | Peer identity recorded per session | Implemented |
| Authorization from peer identity (CN allow-lists, node binding) | **Not implemented** — see [mtls-auth-plan.md](mtls-auth-plan.md) | | Authorization from peer identity (allow-lists, node binding) | Implemented — see [The Auth Block](#the-auth-block) |
| Authentication on the `http` sink's stream and status endpoints | Implemented, via the auth block |
| Server identity pinning by dialers | Implemented, via the auth block |
| Certificate revocation lists (CRL) or OCSP | **Not implemented** — revoke by editing the allow-list |
| Password, token, or SCRAM authentication | **Removed**; not currently available | | Password, token, or SCRAM authentication | **Removed**; not currently available |
| IP allow/deny lists, per-IP connection or request limits | **Not implemented** | | IP allow/deny lists, per-IP connection or request limits | **Not implemented** |
| Authentication on the `http` sink's stream and status endpoints | **Not implemented** |
Earlier releases carried basic-auth, bearer-token, and SCRAM authentication. Earlier releases carried basic-auth, bearer-token, and SCRAM authentication.
Those were removed during the move to the plugin/flow architecture and the Those were removed during the move to the plugin/flow architecture and the
switch to standard-library networking. Only certificate-based transport switch to standard-library networking. Certificates are the one credential the
security survived that transition. transport still carries, so they are what authentication is built on: the `tls`
block establishes that a peer chains to your CA, and the `auth` block decides
which peers that CA vouches for may actually do what.
## The TLS Block ## The TLS Block
@@ -76,6 +80,130 @@ Misconfiguration fails at plugin construction, before the pipeline starts:
certificates certificates
- a `min_version` that is neither `"1.2"` nor `"1.3"` - a `min_version` that is neither `"1.2"` nor `"1.3"`
## The Auth Block
TLS answers "is this channel private, and does the peer chain to a CA". Auth
answers "may *this* peer do *this*". They are separate blocks because they are
separate questions, and because a later non-certificate method should be able to
reuse the second one.
```toml
[pipelines.plugin_sources.config.auth] # or plugin_sinks.config.auth
type = "none" # none | mtls
identity = "cn" # cn | san_dns | san_uri | san_email
allow = []
allow_patterns = []
node_binding = "force" # chain sources only
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `type` | string | `none` | `none` ignores the whole block; `mtls` authorizes by certificate identity |
| `identity` | string | `cn` | Which certificate field carries the identity |
| `allow` | []string | `[]` | Exact identities to admit |
| `allow_patterns` | []string | `[]` | RE2 patterns matched against the identity; anchor them yourself |
| `node_binding` | string | `force` under `mtls` | Chain sources only: `none`, `assert`, or `force` |
**Roles by plugin:**
| Plugin | Role | Decides |
|--------|------|---------|
| `tcp_chain` source, `http_chain` source | Listener | Which senders may ingest, and what node label their entries carry |
| `tcp` sink, `http` sink | Listener | Which clients may read the stream (and, on `http`, the status endpoint) |
| `tcp_chain` sink, `http_chain` sink | Dialer | Which server identity to accept, beyond hostname verification |
### Identity
The identity is one string pulled from the peer's verified leaf certificate.
The handshake has already checked the chain, signature, and validity window, so
this is pure field selection.
| Mode | Source | Typical use |
|------|--------|-------------|
| `cn` (default) | `Subject.CommonName` | Matches the existing `tls_peer_cn` metadata |
| `san_dns` | first DNS SAN | Host identities |
| `san_uri` | first URI SAN | SPIFFE-style IDs |
| `san_email` | first email SAN | Operator identities |
A certificate with no usable value in the chosen field is rejected. An empty
identity is a refusal, not an empty match.
### The allow list
`allow` is an exact-match set; `allow_patterns` holds RE2 patterns. An identity
passes if it appears in either.
Leaving **both** empty under `type = "mtls"` admits any identity the CA vouches
for. That is deliberate — it is how you enable node binding without enumerating
a whole fleet — but it is announced rather than silent:
```
WARN msg="Auth policy admits any identity the configured CA vouches for"
component=tcp_chain_source instance_id=in_tcp
hint="set auth.allow or auth.allow_patterns to authorize named peers"
```
Anchor your patterns. `allow_patterns = ["edge-\\d{2}"]` matches
`evil-edge-01-impostor`; `["^edge-\\d{2}$"]` does not.
### Node binding
`node_binding` applies only to the chain sources, and it overrides `trust_node`.
| Value | Connection label | Per-entry `node` field |
|-------|------------------|------------------------|
| `none` | `trust_node` governs | `trust_node` governs |
| `assert` | Must equal the identity; a mismatch or an omission is rejected | `trust_node` governs |
| `force` | Ignored; the identity is used | Overwritten with the identity |
Use **`force`** on an ingest boundary you do not trust. Every entry is
relabelled, so a compromised edge cannot smuggle a foreign origin through the
per-entry `node` field either. It is the default under `type = "mtls"`.
Use **`assert`** on a relay-to-relay hop. The relay must prove its own identity —
a mismatch fails loudly instead of being silently corrected — but the entries it
forwards keep the origin labels stamped at the first hop, so multi-hop
attribution survives.
When binding is active the source says so at startup:
```
INFO msg="Node labels bound to peer identity; trust_node is ignored"
component=tcp_chain_source node_binding=force trust_node=true
```
### Dialer-side pinning
On a chain sink, the same block pins the *server's* identity. Hostname
verification already proves the server holds a certificate valid for the address
you dialed; pinning additionally requires that certificate to name an identity
you listed.
```toml
[pipelines.plugin_sinks.config.auth]
type = "mtls"
allow = ["relay.internal"]
```
The check runs as part of the handshake, so a server the policy rejects never
receives an entry — the sink's normal backoff loop handles it like any other
connect failure. `insecure_skip_verify` is refused alongside `type = "mtls"`:
an identity read from an unverified chain is a claim, not a fact.
### Validation
Misconfiguration fails at plugin construction, before the pipeline starts:
- `type = "mtls"` on a listener without `tls.enabled` **and** `tls.client_auth`
- `type = "mtls"` on a dialer without `tls.enabled`, or with
`tls.insecure_skip_verify`
- an `identity` that is not one of the four modes
- an `allow_patterns` entry that does not compile
- a `node_binding` that is not one of the three values, or one set on a plugin
that has no node concept
Errors read like `auth: type "mtls" requires tls.client_auth`.
## Enabling mTLS ## Enabling mTLS
### 1. Generate a CA and certificates ### 1. Generate a CA and certificates
@@ -119,8 +247,17 @@ key_file = "/etc/logwisp/tls/relay.key"
client_auth = true client_auth = true
client_ca_file = "/etc/logwisp/tls/ca.crt" client_ca_file = "/etc/logwisp/tls/ca.crt"
min_version = "1.3" min_version = "1.3"
[pipelines.plugin_sources.config.auth]
type = "mtls"
allow = ["edge-01", "edge-02"]
node_binding = "force"
``` ```
Without the `auth` block the listener accepts every certificate the CA issued.
With it, only `edge-01` and `edge-02` may ingest, and their entries are labelled
from their certificates rather than from whatever they declare.
### 3. Configure the dialer ### 3. Configure the dialer
```toml ```toml
@@ -130,15 +267,21 @@ ca_file = "/etc/logwisp/tls/ca.crt"
cert_file = "/etc/logwisp/tls/edge-01.crt" cert_file = "/etc/logwisp/tls/edge-01.crt"
key_file = "/etc/logwisp/tls/edge-01.key" key_file = "/etc/logwisp/tls/edge-01.key"
min_version = "1.3" min_version = "1.3"
[pipelines.plugin_sinks.config.auth]
type = "mtls"
allow = ["relay.internal"]
``` ```
### 4. Verify ### 4. Verify
Startup logs report both flags: Startup logs report the transport flags and the compiled policy:
``` ```
INFO msg="TCP chain source initialized" ... tls=true mtls=true INFO msg="TCP chain source initialized" ... tls=true mtls=true
auth="mtls identity=cn allow=[2 exact, 0 pattern(s)] node_binding=force"
INFO msg="TCP chain sink initialized" ... tls=true mtls=true INFO msg="TCP chain sink initialized" ... tls=true mtls=true
auth="mtls identity=cn allow=[1 exact, 0 pattern(s)] node_binding=none"
``` ```
A client that presents no certificate is refused during the handshake: A client that presents no certificate is refused during the handshake:
@@ -148,60 +291,65 @@ WARN msg="TLS handshake failed" component=tcp_chain_source
remote_addr=127.0.0.1:53840 error="tls: client didn't provide a certificate" remote_addr=127.0.0.1:53840 error="tls: client didn't provide a certificate"
``` ```
Handshake failures are counted in the `tls_handshake_errors` statistic on the A client whose certificate is valid but whose identity is not authorized gets
`tcp` sink and the `tcp_chain` source. past the handshake and is refused by the policy:
## What mTLS Currently Buys You ```
WARN msg="Connection rejected by auth policy" component=tcp_chain_source
remote_addr=127.0.0.1:33946 error="auth: identity \"edge-99\" is not allowed"
```
With `client_auth = true`, the transport enforces: Handshake failures are counted in `tls_handshake_errors`; policy rejections in
`auth_rejected`. Both appear in the status reporter and in the `http` sink's
status endpoint. Accepted peers are recorded in session metadata as
`auth_method` and `auth_identity`.
- the peer holds a certificate chaining to `client_ca_file` `test/mtls-chain-test.sh` builds a throwaway PKI and exercises the whole surface
- the certificate is within its validity window and not structurally broken end to end — run it with `--auto` to see each guarantee asserted.
- the peer holds the matching private key
That is a real membership check: an attacker without a CA-issued certificate ## What Each Layer Enforces
cannot connect at all.
## What It Does Not Buy You **`tls` with `client_auth = true`** — a membership check. The peer holds a
certificate chaining to `client_ca_file`, within its validity window, and holds
the matching private key. An attacker without a CA-issued certificate cannot
connect at all. What it does *not* decide is which CA-issued certificate: every
one is equivalent at this layer.
**Any** valid certificate from the configured CA is accepted. LogWisp extracts **`auth` with `type = "mtls"`** — an identity check, per listener:
the peer's Common Name into session metadata (`tls_peer_cn`) but never consults
it, so within one CA there is no way to express:
- "only `edge-01` and `edge-02` may connect to this ingest port" - only the identities you list may connect, so one CA can serve several trust
- "the node label `edge-01` may only be claimed by the holder of the `edge-01` domains and a single peer can be withdrawn without touching the others
certificate" - the chain `node` label is bound to the certificate, so a compromised edge
- "this certificate may connect but only at this rate" cannot attribute its entries to another host
- the `http` sink's stream and status endpoints stop being open to anyone who
can reach the port
Two consequences follow. **Revocation** is the allow-list, not a CRL. Remove the identity from `allow` /
`allow_patterns` and send `SIGHUP`: the reload rebuilds every pipeline, so the
change takes effect on the next connection and existing ones are dropped by the
rebuild. No network call on the handshake path, and no window between revocation
and the next CRL publication. See
[mtls-auth-plan.md](mtls-auth-plan.md#not-implemented) for what CRL support
would add.
1. **A compromised edge can impersonate any other edge.** With ## Surfaces Without Access Control
`trust_node = true` (the default) a peer declares its own node label. Any
certificate holder can claim `edge-99`, or `relay`, and downstream consumers
will attribute its entries accordingly. Setting `trust_node = false` replaces
the label with the remote address, which is coarse but not forgeable at the
application layer.
2. **Revocation is CA-wide.** With no CRL or OCSP checking and no per-identity
allow-list, withdrawing one node's access means re-issuing the CA or rotating
the CA bundle for every peer.
Closing both gaps is the subject of the An `auth` block closes each of these. Without one, bind them to a trusted
[mTLS authentication plan](mtls-auth-plan.md). interface or front them with an authenticating proxy.
## Unauthenticated Surfaces | Surface | Exposure when `auth.type = "none"` |
|---------|-----------------------------------|
These endpoints have no access control at all. Bind them to a trusted interface
or front them with an authenticating proxy.
| Surface | Exposure |
|---------|----------|
| `http` sink `stream_path` | Full log stream, with `Access-Control-Allow-Origin: *`, so any browser origin can read it | | `http` sink `stream_path` | Full log stream, with `Access-Control-Allow-Origin: *`, so any browser origin can read it |
| `http` sink `status_path` | Host, port, TLS flag, uptime, client counts, throughput counters | | `http` sink `status_path` | Host, port, TLS flag, uptime, client counts, throughput counters |
| `tcp` sink | Full log stream to any client that connects | | `tcp` sink | Full log stream to any client that connects |
| `tcp_chain` / `http_chain` source | Ingest from any peer the CA vouches for, under any node label it claims |
`max_connections` bounds concurrency on all three but does not distinguish `max_connections` bounds concurrency on all of them but does not distinguish
callers. callers.
Note that `auth` requires `client_auth = true`, which requires TLS. There is no
way to authenticate a plaintext listener.
## Operational Guidance ## Operational Guidance
**Certificates** **Certificates**
@@ -211,7 +359,10 @@ callers.
- Key files should be `0600` and owned by the service account. - Key files should be `0600` and owned by the service account.
- Rotation requires a reload (`SIGHUP`), because certificates are loaded once at - Rotation requires a reload (`SIGHUP`), because certificates are loaded once at
plugin construction; there is no on-disk watch for certificate files. plugin construction; there is no on-disk watch for certificate files.
- Check expiry: `openssl x509 -in relay.crt -noout -enddate`. - Check expiry yourself: `openssl x509 -in relay.crt -noout -enddate`. Nothing
warns before a certificate lapses; it surfaces as a handshake failure.
- Keep the identity field you authorize on stable across rotations. Reissuing a
leaf with a different CN silently drops the peer out of the allow list.
**Deployment** **Deployment**
@@ -220,8 +371,10 @@ callers.
- Never enable `insecure_skip_verify` outside a lab; it disables server - Never enable `insecure_skip_verify` outside a lab; it disables server
verification entirely and makes the connection trivially interceptable. verification entirely and makes the connection trivially interceptable.
- Bind listeners to specific interfaces rather than `0.0.0.0` where you can. - Bind listeners to specific interfaces rather than `0.0.0.0` where you can.
- Use `trust_node = false` on any ingest port reachable from a network you do - On any ingest port reachable from a network you do not fully control, set
not fully control. `auth.type = "mtls"` with an explicit `allow` list. `trust_node = false` is the
fallback when certificates are not an option; it is unforgeable but labels
entries by remote address, which is useless behind NAT or a load balancer.
- Run LogWisp as an unprivileged user with write access only to its own log and - Run LogWisp as an unprivileged user with write access only to its own log and
configuration directories. configuration directories.
+43 -13
View File
@@ -114,9 +114,15 @@ write_timeout_ms = 0
max_connections = 0 max_connections = 0
[pipelines.plugin_sinks.config.tls] [pipelines.plugin_sinks.config.tls]
enabled = true enabled = true
cert_file = "/etc/logwisp/tls/server.crt" cert_file = "/etc/logwisp/tls/server.crt"
key_file = "/etc/logwisp/tls/server.key" key_file = "/etc/logwisp/tls/server.key"
client_auth = true
client_ca_file = "/etc/logwisp/tls/client-ca.crt"
[pipelines.plugin_sinks.config.auth]
type = "mtls"
allow = ["viewer-01"]
``` ```
| Option | Type | Default | Description | | Option | Type | Default | Description |
@@ -130,10 +136,15 @@ key_file = "/etc/logwisp/tls/server.key"
| `write_timeout_ms` | int | `0` | Per-event write deadline; `0` = none | | `write_timeout_ms` | int | `0` | Per-event write deadline; `0` = none |
| `max_connections` | int | `0` | Concurrent stream cap; `0` = unlimited | | `max_connections` | int | `0` | Concurrent stream cap; `0` = unlimited |
| `tls` | table | — | Listener TLS; see [Security](security.md) | | `tls` | table | — | Listener TLS; see [Security](security.md) |
| `auth` | table | — | Client authorization; see [Security](security.md#the-auth-block) |
**Behaviour** **Behaviour**
- Only `GET` is routed to either path; anything else gets `405`. - Only `GET` is routed to either path; anything else gets `405`.
- With an `auth` block, one middleware gates **both** endpoints: an
unauthorized client gets `403` with no body detail, and the rejection is
logged at WARN and counted in `auth_rejected`. The authorized identity is
recorded in the client's session as `auth_method` / `auth_identity`.
- On connect the client receives an `event: connected` frame carrying its - On connect the client receives an `event: connected` frame carrying its
client id, session id, sink instance id, endpoint paths, and buffer size. client id, session id, sink instance id, endpoint paths, and buffer size.
- Payloads are framed per the SSE spec, one `data:` line per newline in the - Payloads are framed per the SSE spec, one `data:` line per newline in the
@@ -149,11 +160,13 @@ key_file = "/etc/logwisp/tls/server.key"
- 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,
active client count, buffer size, uptime, endpoint paths, and the the compiled auth policy, active client count, buffer size, uptime, endpoint
`total_processed` / `dropped_writes` / `rejected_clients` counters. paths, and the `total_processed` / `dropped_writes` / `rejected_clients` /
`auth_rejected` counters.
> Both endpoints are unauthenticated, and the stream response carries > Without an `auth` block both endpoints are unauthenticated, and the stream
> `Access-Control-Allow-Origin: *`, so any web origin can read it. Bind to a > response carries `Access-Control-Allow-Origin: *`, so any web origin can read
> it. Set `auth.type = "mtls"` (which requires `tls.client_auth`), bind to a
> trusted interface, or put an authenticating reverse proxy in front. > trusted interface, or put an authenticating reverse proxy in front.
--- ---
@@ -177,9 +190,15 @@ keep_alive_period_ms = 30000
max_connections = 0 max_connections = 0
[pipelines.plugin_sinks.config.tls] [pipelines.plugin_sinks.config.tls]
enabled = true enabled = true
cert_file = "/etc/logwisp/tls/server.crt" cert_file = "/etc/logwisp/tls/server.crt"
key_file = "/etc/logwisp/tls/server.key" key_file = "/etc/logwisp/tls/server.key"
client_auth = true
client_ca_file = "/etc/logwisp/tls/client-ca.crt"
[pipelines.plugin_sinks.config.auth]
type = "mtls"
allow = ["viewer-01"]
``` ```
| Option | Type | Default | Description | | Option | Type | Default | Description |
@@ -193,6 +212,7 @@ key_file = "/etc/logwisp/tls/server.key"
| `keep_alive_period_ms` | int | `30000` | Keep-alive idle period | | `keep_alive_period_ms` | int | `30000` | Keep-alive idle period |
| `max_connections` | int | `0` | Concurrent connection cap; `0` = unlimited | | `max_connections` | int | `0` | Concurrent connection cap; `0` = unlimited |
| `tls` | table | — | Listener TLS | | `tls` | table | — | Listener TLS |
| `auth` | table | — | Client authorization; see [Security](security.md#the-auth-block) |
**Behaviour** **Behaviour**
@@ -205,6 +225,10 @@ key_file = "/etc/logwisp/tls/server.key"
and stays connected. and stays connected.
- With TLS enabled the handshake runs under a 10 s bound *after* the - With TLS enabled the handshake runs under a 10 s bound *after* the
`max_connections` check, so concurrent handshakes are bounded too. `max_connections` check, so concurrent handshakes are bounded too.
- With an `auth` block, authorization runs after that handshake and *before*
registration, so an unauthorized client never enters the client map and never
receives a broadcast. Its connection is closed, the rejection logged at WARN,
and `rejected_conns` incremented.
--- ---
@@ -249,6 +273,7 @@ key_file = "/etc/logwisp/tls/client.key"
| `keep_alive` | bool | `true` | Enable TCP keep-alive | | `keep_alive` | bool | `true` | Enable TCP keep-alive |
| `keep_alive_period_ms` | int | `30000` | Keep-alive idle period | | `keep_alive_period_ms` | int | `30000` | Keep-alive idle period |
| `tls` | table | — | Dialer TLS; `cert_file`/`key_file` present a client identity | | `tls` | table | — | Dialer TLS; `cert_file`/`key_file` present a client identity |
| `auth` | table | — | Server identity pinning; see [Security](security.md#dialer-side-pinning) |
**Behaviour** **Behaviour**
@@ -265,7 +290,11 @@ key_file = "/etc/logwisp/tls/client.key"
- Events arriving without a structured entry are wrapped from the formatted - Events arriving without a structured entry are wrapped from the formatted
payload and counted in `synthesized`. payload and counted in `synthesized`.
**Statistics**: `target`, `node`, `tls`, `connected`, `reconnects`, An `auth` block on a dialer pins the server's identity: the policy runs as part
of the handshake, so a server it rejects is treated like any other connect
failure and retried under the normal backoff.
**Statistics**: `target`, `node`, `tls`, `auth`, `connected`, `reconnects`,
`write_errors`, `synthesized`. `write_errors`, `synthesized`.
--- ---
@@ -313,6 +342,7 @@ key_file = "/etc/logwisp/tls/client.key"
| `backoff_min_ms` | int | `500` | Retry backoff floor | | `backoff_min_ms` | int | `500` | Retry backoff floor |
| `backoff_max_ms` | int | `30000` | Retry backoff ceiling | | `backoff_max_ms` | int | `30000` | Retry backoff ceiling |
| `tls` | table | — | Dialer TLS; `cert_file`/`key_file` present a client identity | | `tls` | table | — | Dialer TLS; `cert_file`/`key_file` present a client identity |
| `auth` | table | — | Server identity pinning; see [Security](security.md#dialer-side-pinning) |
**Behaviour** **Behaviour**
@@ -324,8 +354,8 @@ key_file = "/etc/logwisp/tls/client.key"
- 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 single best-effort flush of the pending batch is attempted.
**Statistics**: `target`, `node`, `tls`, `batches_sent`, `request_errors`, **Statistics**: `target`, `node`, `tls`, `auth`, `batches_sent`,
`dropped_batches`, `synthesized`. `request_errors`, `dropped_batches`, `synthesized`.
--- ---
+35 -7
View File
@@ -157,6 +157,11 @@ key_file = "/etc/logwisp/tls/server.key"
client_auth = true client_auth = true
client_ca_file = "/etc/logwisp/tls/client-ca.crt" client_ca_file = "/etc/logwisp/tls/client-ca.crt"
min_version = "1.3" min_version = "1.3"
[pipelines.plugin_sources.config.auth]
type = "mtls"
allow = ["edge-01", "edge-02"]
node_binding = "force"
``` ```
| Option | Type | Default | Description | | Option | Type | Default | Description |
@@ -167,23 +172,34 @@ min_version = "1.3"
| `max_connections` | int | `0` | Concurrent connection cap; `0` = unlimited | | `max_connections` | int | `0` | Concurrent connection cap; `0` = unlimited |
| `read_timeout_ms` | int | `0` | Per-connection idle read deadline; `0` = none | | `read_timeout_ms` | int | `0` | Per-connection idle read deadline; `0` = none |
| `hello_timeout_ms` | int | `10000` | Deadline for the hello preamble | | `hello_timeout_ms` | int | `10000` | Deadline for the hello preamble |
| `trust_node` | bool | `true` | `false` overrides the sender's node label with its remote address | | `trust_node` | bool | `true` | `false` overrides the sender's node label with its remote address. Ignored when `auth.node_binding` is active |
| `tls` | table | — | Listener TLS; see [Security](security.md) | | `tls` | table | — | Listener TLS; see [Security](security.md) |
| `auth` | table | — | Peer authorization and node binding; see [Security](security.md#the-auth-block) |
**Behaviour** **Behaviour**
- TLS handshakes run explicitly with a 10 s bound before the preamble is read, - TLS handshakes run explicitly with a 10 s bound before the preamble is read,
after the `max_connections` admission check. after the `max_connections` admission check.
- Authorization runs between the handshake and the hello read, so an
unauthorized peer never gets a preamble parsed on its behalf. A rejection is
logged at WARN and counted in `rejected_conns`.
- A connection is rejected if the first line is not a valid hello with a - A connection is rejected if the first line is not a valid hello with a
matching protocol version. matching protocol version.
- The node label is then resolved: under `auth.node_binding` it comes from the
peer's certificate, otherwise `trust_node` governs. `force` also overrides the
`node` field on every individual entry; `assert` leaves per-entry labels to
`trust_node`, so a relay can forward other nodes' entries while proving its
own identity.
- Each accepted connection gets a session recording the remote address, node - Each accepted connection gets a session recording the remote address, node
label, and — under TLS — `tls` and `tls_peer_cn`. label, — under TLS — `tls` and `tls_peer_cn`, and — under auth —
`auth_method` and `auth_identity`.
- A malformed entry line increments `parse_errors` and is skipped; the - A malformed entry line increments `parse_errors` and is skipped; the
connection survives. A line over 1 MiB is a protocol violation and terminates connection survives. A line over 1 MiB is a protocol violation and terminates
the connection. the connection.
**Statistics**: `active_connections`, `rejected_conns`, `parse_errors`, **Statistics**: `active_connections`, `rejected_conns`, `parse_errors`,
`tls_handshake_errors`, `trust_node`. `tls_handshake_errors`, `trust_node`, `auth`, `auth_allowed`, `auth_rejected`,
`node_binding`.
--- ---
@@ -210,6 +226,11 @@ cert_file = "/etc/logwisp/tls/server.crt"
key_file = "/etc/logwisp/tls/server.key" key_file = "/etc/logwisp/tls/server.key"
client_auth = true client_auth = true
client_ca_file = "/etc/logwisp/tls/client-ca.crt" client_ca_file = "/etc/logwisp/tls/client-ca.crt"
[pipelines.plugin_sources.config.auth]
type = "mtls"
allow = ["edge-01", "edge-02"]
node_binding = "force"
``` ```
| Option | Type | Default | Description | | Option | Type | Default | Description |
@@ -220,13 +241,18 @@ client_ca_file = "/etc/logwisp/tls/client-ca.crt"
| `buffer_size` | int | `1000` | Subscriber channel depth | | `buffer_size` | int | `1000` | Subscriber channel depth |
| `max_body_bytes` | int | `8388608` | Per-request body cap (8 MiB) | | `max_body_bytes` | int | `8388608` | Per-request body cap (8 MiB) |
| `read_timeout_ms` | int | `30000` | Full request read deadline | | `read_timeout_ms` | int | `30000` | Full request read deadline |
| `trust_node` | bool | `true` | `false` overrides the sender's node label with its remote address | | `trust_node` | bool | `true` | `false` overrides the sender's node label with its remote address. Ignored when `auth.node_binding` is active |
| `tls` | table | — | Listener TLS | | `tls` | table | — | Listener TLS |
| `auth` | table | — | Peer authorization and node binding; see [Security](security.md#the-auth-block) |
**Behaviour** **Behaviour**
- Only `POST` to `ingest_path` is routed; other methods get `405` with an - Only `POST` to `ingest_path` is routed; other methods get `405` with an
`Allow` header, and other paths get `404`. `Allow` header, and other paths get `404`.
- Authorization runs before the body is read, so an unauthorized sender does not
get to stream `max_body_bytes` into the process. Both a policy rejection and a
node-binding failure answer `403`, distinct from the `400` used for protocol
errors, so a sender can tell "not allowed" from "malformed batch".
- A missing or mismatched `X-Logwisp-Protocol` header is rejected with `400`. - A missing or mismatched `X-Logwisp-Protocol` header is rejected with `400`.
- Batch acceptance is atomic: entries are published only after the body reads - Batch acceptance is atomic: entries are published only after the body reads
cleanly end to end. A transfer error rejects the whole batch (`400`, or `413` cleanly end to end. A transfer error rejects the whole batch (`400`, or `413`
@@ -234,11 +260,13 @@ client_ca_file = "/etc/logwisp/tls/client-ca.crt"
an otherwise clean transfer is skipped and counted in `parse_errors`. an otherwise clean transfer is skipped and counted in `parse_errors`.
- Success is `204 No Content` with `X-Logwisp-Accepted` set to the number of - Success is `204 No Content` with `X-Logwisp-Accepted` set to the number of
entries ingested. entries ingested.
- Sessions are cached per remote host + declared node and recreated after idle - Sessions are cached per remote host + node + authenticated identity, and
expiry. recreated after idle expiry. Including the identity in the key means two peers
sharing a remote address never share a session.
**Statistics**: `total_requests`, `rejected_requests`, `parse_errors`, **Statistics**: `total_requests`, `rejected_requests`, `parse_errors`,
`cached_sessions`, `trust_node`. `cached_sessions`, `trust_node`, `auth`, `auth_allowed`, `auth_rejected`,
`node_binding`.
--- ---
+318
View File
@@ -0,0 +1,318 @@
// Package authz turns a verified certificate into an authorization decision.
// It is the single seam between declarative auth config and the network
// plugins: each one compiles a Policy at construction and calls Authorize per
// connection (TCP) or per request (HTTP).
//
// New returns (nil, nil) when auth is disabled, mirroring tlsx.Server and
// tlsx.Client, and every method tolerates a nil receiver — so call sites read
// the same whether or not a policy is configured.
package authz
import (
"crypto/tls"
"fmt"
"regexp"
"strings"
"sync/atomic"
"logwisp/internal/config"
"logwisp/internal/tlsx"
)
// Authentication methods
const (
MethodNone = "none"
MethodMTLS = "mtls"
)
// Node label binding modes. See ResolveNode and TrustsEntryNode for the
// difference between assert and force.
const (
BindingNone = "none"
BindingAssert = "assert"
BindingForce = "force"
)
// Role selects the validation and behavior appropriate to the call site
type Role int
const (
// RoleListener authorizes client certificates on a plugin with no node
// concept: the tcp and http sinks
RoleListener Role = iota
// RoleChainListener authorizes client certificates and binds the node
// label a peer declares: the tcp_chain and http_chain sources
RoleChainListener
// RoleDialer pins the server's identity beyond hostname verification:
// the tcp_chain and http_chain sinks
RoleDialer
)
// Policy is the compiled form of config.AuthOptions
type Policy struct {
role Role
identity string
allow map[string]struct{}
patterns []*regexp.Regexp
binding string
// Statistics
allowed atomic.Uint64
rejected atomic.Uint64
}
// Identity is the outcome of a successful authorization. The zero value is
// what a disabled policy yields.
type Identity struct {
Name string // the selected certificate field
Method string // MethodMTLS
}
// Apply stamps an authenticated identity onto session metadata. A zero
// Identity (auth disabled) leaves the map untouched.
func (id Identity) Apply(meta map[string]any) {
if id.Name == "" {
return
}
meta["auth_method"] = id.Method
meta["auth_identity"] = id.Name
}
// New compiles an auth policy, returning (nil, nil) when auth is disabled.
// tlsOpts is the sibling `tls` block: an auth policy the transport cannot
// enforce is rejected here rather than silently accepted, which is the
// failure mode worth designing out.
func New(o *config.AuthOptions, tlsOpts *config.TLSOptions, role Role) (*Policy, error) {
if o == nil {
return nil, nil
}
switch o.Type {
case "", MethodNone:
return nil, nil
case MethodMTLS:
default:
return nil, fmt.Errorf("auth: type %q (valid: %q, %q)", o.Type, MethodNone, MethodMTLS)
}
if tlsOpts == nil || !tlsOpts.Enabled {
return nil, fmt.Errorf("auth: type %q requires tls.enabled", MethodMTLS)
}
if role == RoleDialer {
// Identity from an unverified chain is a claim, not a fact
if tlsOpts.InsecureSkipVerify {
return nil, fmt.Errorf("auth: type %q cannot pin an identity with tls.insecure_skip_verify", MethodMTLS)
}
} else if !tlsOpts.ClientAuth {
return nil, fmt.Errorf("auth: type %q requires tls.client_auth", MethodMTLS)
}
identity := o.Identity
if identity == "" {
identity = tlsx.IdentityCN
}
switch identity {
case tlsx.IdentityCN, tlsx.IdentitySANDNS, tlsx.IdentitySANURI, tlsx.IdentitySANEmail:
default:
return nil, fmt.Errorf("auth: identity %q (valid: %q, %q, %q, %q)",
identity, tlsx.IdentityCN, tlsx.IdentitySANDNS, tlsx.IdentitySANURI, tlsx.IdentitySANEmail)
}
binding := o.NodeBinding
if role == RoleChainListener {
if binding == "" {
// The only setting under which a misconfigured or hostile edge
// cannot mislabel its entries
binding = BindingForce
}
} else if binding != "" && binding != BindingNone {
return nil, fmt.Errorf("auth: node_binding %q applies only to chain sources", binding)
} else {
binding = BindingNone
}
switch binding {
case BindingNone, BindingAssert, BindingForce:
default:
return nil, fmt.Errorf("auth: node_binding %q (valid: %q, %q, %q)",
binding, BindingNone, BindingAssert, BindingForce)
}
p := &Policy{
role: role,
identity: identity,
binding: binding,
allow: make(map[string]struct{}, len(o.Allow)),
}
for _, a := range o.Allow {
if a = strings.TrimSpace(a); a != "" {
p.allow[a] = struct{}{}
}
}
for i, pat := range o.AllowPatterns {
re, err := regexp.Compile(pat)
if err != nil {
return nil, fmt.Errorf("auth: allow_patterns[%d] %q: %w", i, pat, err)
}
p.patterns = append(p.patterns, re)
}
return p, nil
}
// Authorize extracts and checks the peer identity from a completed handshake.
// A nil policy authorizes everything and yields the zero Identity, so callers
// need no branch on whether auth is configured.
func (p *Policy) Authorize(cs *tls.ConnectionState) (Identity, error) {
if p == nil {
return Identity{}, nil
}
if cs == nil {
p.rejected.Add(1)
return Identity{}, fmt.Errorf("auth: peer is not on a TLS connection")
}
name := tlsx.PeerIdentity(*cs, p.identity)
if name == "" {
// An unusable identity field is a rejection, not an empty match
p.rejected.Add(1)
return Identity{}, fmt.Errorf("auth: peer certificate carries no %s identity", p.identity)
}
if !p.permits(name) {
p.rejected.Add(1)
return Identity{}, fmt.Errorf("auth: identity %q is not allowed", name)
}
p.allowed.Add(1)
return Identity{Name: name, Method: MethodMTLS}, nil
}
// VerifyConnection is assignable to tls.Config.VerifyConnection on a dialer,
// so a server whose identity the policy rejects fails the handshake itself
// rather than after the first write. It runs after the standard chain and
// hostname checks, so the identity it reads is already verified.
func (p *Policy) VerifyConnection(cs tls.ConnectionState) error {
_, err := p.Authorize(&cs)
return err
}
// permits reports whether an identity satisfies the allow list. An empty list
// admits any identity the CA vouches for; that is the documented default, and
// constructors log it at startup rather than leaving it silent.
// Identities are not secrets, so ordinary comparison is fine.
func (p *Policy) permits(name string) bool {
if len(p.allow) == 0 && len(p.patterns) == 0 {
return true
}
if _, ok := p.allow[name]; ok {
return true
}
for _, re := range p.patterns {
if re.MatchString(name) {
return true
}
}
return false
}
// ResolveNode returns the node label for a connection. With no policy, or
// node_binding "none", trust_node governs as before: the declared label stands
// only when trusted and non-empty, otherwise fallback (the remote address) is
// used. Otherwise the label is bound to the authenticated identity.
func (p *Policy) ResolveNode(declared, fallback string, trustNode bool, id Identity) (string, error) {
if p == nil || p.binding == BindingNone {
if declared == "" || !trustNode {
return fallback, nil
}
return declared, nil
}
if id.Name == "" {
return "", fmt.Errorf("auth: node_binding %q requires an authenticated identity", p.binding)
}
if p.binding == BindingForce {
return id.Name, nil
}
// BindingAssert: a mismatch is loud rather than silently corrected
if declared == "" {
return "", fmt.Errorf("auth: node_binding %q: peer %q declared no node label", BindingAssert, id.Name)
}
if declared != id.Name {
return "", fmt.Errorf("auth: node_binding %q: declared node %q does not match identity %q",
BindingAssert, declared, id.Name)
}
return declared, nil
}
// TrustsEntryNode reports whether node labels carried by individual entries
// survive the policy. force relabels every entry, so an ingest boundary that
// does not trust its peer gets exact attribution; assert pins only the
// connection's own label, so a relay forwarding other nodes' entries proves
// who it is while preserving their origin.
func (p *Policy) TrustsEntryNode(trustNode bool) bool {
if p == nil {
return trustNode
}
if p.binding == BindingForce {
return false
}
return trustNode
}
// BindsNode reports whether the policy overrides trust_node
func (p *Policy) BindsNode() bool {
return p != nil && p.binding != BindingNone
}
// NodeBinding returns the effective binding mode
func (p *Policy) NodeBinding() string {
if p == nil {
return BindingNone
}
return p.binding
}
// Enabled reports whether a policy is in force
func (p *Policy) Enabled() bool { return p != nil }
// Unrestricted reports whether the policy admits any identity the CA vouches
// for. Constructors log this at startup: it is a deliberate default, and a
// silent one would be a footgun.
func (p *Policy) Unrestricted() bool {
return p != nil && len(p.allow) == 0 && len(p.patterns) == 0
}
// Describe renders the policy for a startup log line
func (p *Policy) Describe() string {
if p == nil {
return MethodNone
}
scope := fmt.Sprintf("%d exact, %d pattern(s)", len(p.allow), len(p.patterns))
if p.Unrestricted() {
scope = "any identity issued by the configured CA"
}
return fmt.Sprintf("%s identity=%s allow=[%s] node_binding=%s",
MethodMTLS, p.identity, scope, p.binding)
}
// Rejected returns the number of authorization failures
func (p *Policy) Rejected() uint64 {
if p == nil {
return 0
}
return p.rejected.Load()
}
// Stats reports policy state for a plugin's stats details map. Merge it in
// with maps.Copy so rejections surface in the status reporter and in the
// http sink's status endpoint.
func (p *Policy) Stats() map[string]any {
if p == nil {
return map[string]any{"auth": MethodNone}
}
d := map[string]any{
"auth": MethodMTLS,
"auth_identity": p.identity,
"auth_unrestricted": p.Unrestricted(),
"auth_allowed": p.allowed.Load(),
"auth_rejected": p.rejected.Load(),
}
if p.role == RoleChainListener {
d["node_binding"] = p.binding
}
return d
}
+298
View File
@@ -0,0 +1,298 @@
package authz
import (
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"net/url"
"testing"
"logwisp/internal/config"
"logwisp/internal/tlsx"
)
// peerState fakes a completed handshake. Only the leaf's identity fields are
// read: the chain is verified by crypto/tls before a policy ever sees it.
func peerState(leaf *x509.Certificate) *tls.ConnectionState {
return &tls.ConnectionState{PeerCertificates: []*x509.Certificate{leaf}}
}
func leafCN(cn string) *x509.Certificate {
return &x509.Certificate{Subject: pkix.Name{CommonName: cn}}
}
func mtlsListenerTLS() *config.TLSOptions {
return &config.TLSOptions{Enabled: true, ClientAuth: true}
}
func TestPeerIdentityModes(t *testing.T) {
uri, err := url.Parse("spiffe://example.org/edge-01")
if err != nil {
t.Fatalf("parse uri: %v", err)
}
leaf := &x509.Certificate{
Subject: pkix.Name{CommonName: "edge-01"},
DNSNames: []string{"edge-01.internal", "alt.internal"},
URIs: []*url.URL{uri},
EmailAddresses: []string{"ops@example.org"},
}
cs := peerState(leaf)
cases := map[string]string{
tlsx.IdentityCN: "edge-01",
tlsx.IdentitySANDNS: "edge-01.internal",
tlsx.IdentitySANURI: "spiffe://example.org/edge-01",
tlsx.IdentitySANEmail: "ops@example.org",
"": "edge-01", // empty mode defaults to CN
"nonsense": "",
}
for mode, want := range cases {
if got := tlsx.PeerIdentity(*cs, mode); got != want {
t.Errorf("PeerIdentity(%q) = %q, want %q", mode, got, want)
}
}
// A mode the certificate does not carry yields no identity
bare := peerState(leafCN("edge-01"))
if got := tlsx.PeerIdentity(*bare, tlsx.IdentitySANDNS); got != "" {
t.Errorf("PeerIdentity(san_dns) on bare cert = %q, want empty", got)
}
// No peer certificate at all
if got := tlsx.PeerIdentity(tls.ConnectionState{}, tlsx.IdentityCN); got != "" {
t.Errorf("PeerIdentity with no peer certs = %q, want empty", got)
}
}
func TestNewDisabled(t *testing.T) {
for _, o := range []*config.AuthOptions{nil, {}, {Type: MethodNone}} {
p, err := New(o, nil, RoleListener)
if err != nil {
t.Fatalf("New(%+v) error: %v", o, err)
}
if p != nil {
t.Fatalf("New(%+v) = %v, want nil policy", o, p)
}
}
}
// A nil policy must behave as if auth were never configured
func TestNilPolicyIsTransparent(t *testing.T) {
var p *Policy
id, err := p.Authorize(nil)
if err != nil || id.Name != "" {
t.Fatalf("nil Authorize = (%+v, %v), want (zero, nil)", id, err)
}
if p.Enabled() || p.BindsNode() || p.Unrestricted() {
t.Fatal("nil policy reports itself active")
}
if p.NodeBinding() != BindingNone {
t.Fatalf("nil NodeBinding = %q", p.NodeBinding())
}
if !p.TrustsEntryNode(true) || p.TrustsEntryNode(false) {
t.Fatal("nil policy must defer to trust_node")
}
// trust_node semantics are unchanged without a policy
node, err := p.ResolveNode("edge-01", "10.0.0.5", true, Identity{})
if err != nil || node != "edge-01" {
t.Fatalf("nil ResolveNode(trust) = (%q, %v), want edge-01", node, err)
}
node, err = p.ResolveNode("edge-01", "10.0.0.5", false, Identity{})
if err != nil || node != "10.0.0.5" {
t.Fatalf("nil ResolveNode(no trust) = (%q, %v), want 10.0.0.5", node, err)
}
node, err = p.ResolveNode("", "10.0.0.5", true, Identity{})
if err != nil || node != "10.0.0.5" {
t.Fatalf("nil ResolveNode(no label) = (%q, %v), want 10.0.0.5", node, err)
}
}
func TestNewValidation(t *testing.T) {
tests := []struct {
name string
auth *config.AuthOptions
tls *config.TLSOptions
role Role
}{
{"unknown type", &config.AuthOptions{Type: "kerberos"}, mtlsListenerTLS(), RoleListener},
{"no tls", &config.AuthOptions{Type: MethodMTLS}, nil, RoleListener},
{"tls disabled", &config.AuthOptions{Type: MethodMTLS}, &config.TLSOptions{}, RoleListener},
{"no client_auth", &config.AuthOptions{Type: MethodMTLS}, &config.TLSOptions{Enabled: true}, RoleListener},
{"unknown identity", &config.AuthOptions{Type: MethodMTLS, Identity: "serial"}, mtlsListenerTLS(), RoleListener},
{"bad pattern", &config.AuthOptions{Type: MethodMTLS, AllowPatterns: []string{"^edge-("}}, mtlsListenerTLS(), RoleListener},
{"unknown binding", &config.AuthOptions{Type: MethodMTLS, NodeBinding: "maybe"}, mtlsListenerTLS(), RoleChainListener},
{"binding on plain listener", &config.AuthOptions{Type: MethodMTLS, NodeBinding: BindingForce}, mtlsListenerTLS(), RoleListener},
{"binding on dialer", &config.AuthOptions{Type: MethodMTLS, NodeBinding: BindingForce}, &config.TLSOptions{Enabled: true}, RoleDialer},
{"dialer skips verify", &config.AuthOptions{Type: MethodMTLS}, &config.TLSOptions{Enabled: true, InsecureSkipVerify: true}, RoleDialer},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if _, err := New(tc.auth, tc.tls, tc.role); err == nil {
t.Fatal("expected an error, got nil")
}
})
}
// A dialer needs TLS but not client_auth: it pins the server's identity
if _, err := New(&config.AuthOptions{Type: MethodMTLS}, &config.TLSOptions{Enabled: true}, RoleDialer); err != nil {
t.Fatalf("dialer policy rejected: %v", err)
}
}
func TestAuthorizeMatching(t *testing.T) {
p, err := New(&config.AuthOptions{
Type: MethodMTLS,
Allow: []string{"edge-01", " edge-02 "},
AllowPatterns: []string{`^relay-\d{2}$`},
}, mtlsListenerTLS(), RoleListener)
if err != nil {
t.Fatalf("New: %v", err)
}
if p.Unrestricted() {
t.Fatal("policy with an allow list reports unrestricted")
}
allowed := []string{"edge-01", "edge-02", "relay-07"}
for _, cn := range allowed {
id, err := p.Authorize(peerState(leafCN(cn)))
if err != nil {
t.Errorf("Authorize(%q): %v", cn, err)
continue
}
if id.Name != cn || id.Method != MethodMTLS {
t.Errorf("Authorize(%q) = %+v", cn, id)
}
}
denied := []string{"edge-99", "relay-007", "prefix-relay-07", "", "EDGE-01"}
for _, cn := range denied {
if _, err := p.Authorize(peerState(leafCN(cn))); err == nil {
t.Errorf("Authorize(%q) allowed, want rejection", cn)
}
}
if got, want := p.Rejected(), uint64(len(denied)); got != want {
t.Errorf("Rejected = %d, want %d", got, want)
}
stats := p.Stats()
if stats["auth_allowed"].(uint64) != uint64(len(allowed)) {
t.Errorf("auth_allowed = %v, want %d", stats["auth_allowed"], len(allowed))
}
if _, ok := stats["node_binding"]; ok {
t.Error("plain listener stats report node_binding")
}
}
// Empty allow and allow_patterns admits any CA-vouched identity, but still
// records it and still refuses a certificate with no usable identity field
func TestAuthorizeUnrestricted(t *testing.T) {
p, err := New(&config.AuthOptions{Type: MethodMTLS}, mtlsListenerTLS(), RoleListener)
if err != nil {
t.Fatalf("New: %v", err)
}
if !p.Unrestricted() {
t.Fatal("empty allow list should be unrestricted")
}
id, err := p.Authorize(peerState(leafCN("anyone")))
if err != nil || id.Name != "anyone" {
t.Fatalf("Authorize = (%+v, %v)", id, err)
}
if _, err := p.Authorize(peerState(leafCN(""))); err == nil {
t.Error("certificate with no CN was authorized")
}
if _, err := p.Authorize(&tls.ConnectionState{}); err == nil {
t.Error("connection with no peer certificate was authorized")
}
if _, err := p.Authorize(nil); err == nil {
t.Error("non-TLS connection was authorized")
}
}
func TestResolveNodeBindings(t *testing.T) {
newChain := func(binding string) *Policy {
p, err := New(&config.AuthOptions{Type: MethodMTLS, NodeBinding: binding}, mtlsListenerTLS(), RoleChainListener)
if err != nil {
t.Fatalf("New(%q): %v", binding, err)
}
return p
}
id := Identity{Name: "edge-01", Method: MethodMTLS}
// Default under mtls is force
if got := newChain("").NodeBinding(); got != BindingForce {
t.Errorf("default node_binding = %q, want %q", got, BindingForce)
}
// force ignores the declared label, however it was spoofed
force := newChain(BindingForce)
for _, declared := range []string{"edge-99", "", "edge-01"} {
node, err := force.ResolveNode(declared, "10.0.0.5", true, id)
if err != nil || node != "edge-01" {
t.Errorf("force ResolveNode(%q) = (%q, %v), want edge-01", declared, node, err)
}
}
if force.TrustsEntryNode(true) {
t.Error("force must not trust per-entry node labels")
}
// assert rejects a mismatch and an omission, and leaves per-entry labels
// alone so a relay can forward other nodes' entries
assert := newChain(BindingAssert)
node, err := assert.ResolveNode("edge-01", "10.0.0.5", true, id)
if err != nil || node != "edge-01" {
t.Errorf("assert ResolveNode(match) = (%q, %v)", node, err)
}
if _, err := assert.ResolveNode("edge-99", "10.0.0.5", true, id); err == nil {
t.Error("assert accepted a mismatched node label")
}
if _, err := assert.ResolveNode("", "10.0.0.5", true, id); err == nil {
t.Error("assert accepted a missing node label")
}
if !assert.TrustsEntryNode(true) || assert.TrustsEntryNode(false) {
t.Error("assert must leave per-entry node labels to trust_node")
}
// none leaves trust_node governing entirely
none := newChain(BindingNone)
if none.BindsNode() {
t.Error("node_binding none should not bind")
}
node, err = none.ResolveNode("edge-99", "10.0.0.5", true, id)
if err != nil || node != "edge-99" {
t.Errorf("none ResolveNode = (%q, %v), want edge-99", node, err)
}
node, err = none.ResolveNode("edge-99", "10.0.0.5", false, id)
if err != nil || node != "10.0.0.5" {
t.Errorf("none ResolveNode(no trust) = (%q, %v), want 10.0.0.5", node, err)
}
// Binding without an authenticated identity is a refusal, not a fallback
if _, err := force.ResolveNode("edge-01", "10.0.0.5", true, Identity{}); err == nil {
t.Error("force resolved a node without an identity")
}
}
func TestIdentityApply(t *testing.T) {
meta := map[string]any{"type": "tcp_chain"}
Identity{}.Apply(meta)
if len(meta) != 1 {
t.Fatalf("zero identity stamped metadata: %v", meta)
}
Identity{Name: "edge-01", Method: MethodMTLS}.Apply(meta)
if meta["auth_identity"] != "edge-01" || meta["auth_method"] != MethodMTLS {
t.Fatalf("metadata = %v", meta)
}
}
func TestVerifyConnectionPinsServer(t *testing.T) {
p, err := New(&config.AuthOptions{Type: MethodMTLS, Allow: []string{"relay.internal"}},
&config.TLSOptions{Enabled: true}, RoleDialer)
if err != nil {
t.Fatalf("New: %v", err)
}
if err := p.VerifyConnection(*peerState(leafCN("relay.internal"))); err != nil {
t.Errorf("pinned server rejected: %v", err)
}
if err := p.VerifyConnection(*peerState(leafCN("impostor.internal"))); err == nil {
t.Error("unpinned server accepted")
}
}
+94 -57
View File
@@ -208,28 +208,30 @@ type ConsoleSourceOptions struct {
// TCPChainSourceOptions defines settings for a stdlib TCP listener ingesting // TCPChainSourceOptions defines settings for a stdlib TCP listener ingesting
// NDJSON entries from upstream logwisp tcp_chain sinks // NDJSON entries from upstream logwisp tcp_chain sinks
type TCPChainSourceOptions struct { type TCPChainSourceOptions struct {
TLS *TLSOptions `toml:"tls"` TLS *TLSOptions `toml:"tls"`
Host string `toml:"host"` Host string `toml:"host"`
Port int64 `toml:"port"` Port int64 `toml:"port"`
BufferSize int64 `toml:"buffer_size"` BufferSize int64 `toml:"buffer_size"`
MaxConnections int64 `toml:"max_connections"` // 0 = unlimited MaxConnections int64 `toml:"max_connections"` // 0 = unlimited
ReadTimeoutMS int64 `toml:"read_timeout_ms"` // per-connection idle deadline, 0 = none ReadTimeoutMS int64 `toml:"read_timeout_ms"` // per-connection idle deadline, 0 = none
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"`
// Future: password auth block // Future: password auth block
} }
// HTTPChainSourceOptions defines settings for a stdlib HTTP listener ingesting // HTTPChainSourceOptions defines settings for a stdlib HTTP listener ingesting
// NDJSON batches from upstream logwisp http_chain sinks // NDJSON batches from upstream logwisp http_chain sinks
type HTTPChainSourceOptions struct { type HTTPChainSourceOptions struct {
TLS *TLSOptions `toml:"tls"` TLS *TLSOptions `toml:"tls"`
Host string `toml:"host"` Host string `toml:"host"`
Port int64 `toml:"port"` Port int64 `toml:"port"`
IngestPath string `toml:"ingest_path"` IngestPath string `toml:"ingest_path"`
BufferSize int64 `toml:"buffer_size"` BufferSize int64 `toml:"buffer_size"`
MaxBodyBytes int64 `toml:"max_body_bytes"` // per-request cap MaxBodyBytes int64 `toml:"max_body_bytes"` // per-request cap
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"`
// Future: password auth block // Future: password auth block
} }
@@ -275,67 +277,102 @@ type FileSinkOptions struct {
// TCPSinkOptions defines settings for a TCP server sink // TCPSinkOptions defines settings for a TCP server sink
type TCPSinkOptions struct { type TCPSinkOptions struct {
TLS *TLSOptions `toml:"tls"` TLS *TLSOptions `toml:"tls"`
Host string `toml:"host"` Host string `toml:"host"`
Port int64 `toml:"port"` Port int64 `toml:"port"`
BufferSize int64 `toml:"buffer_size"` // sink input queue BufferSize int64 `toml:"buffer_size"` // sink input queue
ClientBufferSize int64 `toml:"client_buffer_size"` // per-client send queue ClientBufferSize int64 `toml:"client_buffer_size"` // per-client send queue
WriteTimeoutMS int64 `toml:"write_timeout_ms"` // per-write deadline WriteTimeoutMS int64 `toml:"write_timeout_ms"` // per-write deadline
KeepAlive bool `toml:"keep_alive"` KeepAlive bool `toml:"keep_alive"`
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"`
// Future: password auth block // Future: password auth block
} }
// HTTPSinkOptions defines settings for an HTTP SSE server sink // HTTPSinkOptions defines settings for an HTTP SSE server sink
type HTTPSinkOptions struct { type HTTPSinkOptions struct {
TLS *TLSOptions `toml:"tls"` TLS *TLSOptions `toml:"tls"`
Host string `toml:"host"` Host string `toml:"host"`
Port int64 `toml:"port"` Port int64 `toml:"port"`
StreamPath string `toml:"stream_path"` StreamPath string `toml:"stream_path"`
StatusPath string `toml:"status_path"` StatusPath string `toml:"status_path"`
BufferSize int64 `toml:"buffer_size"` // sink input queue BufferSize int64 `toml:"buffer_size"` // sink input queue
ClientBufferSize int64 `toml:"client_buffer_size"` // per-client send queue ClientBufferSize int64 `toml:"client_buffer_size"` // per-client send queue
WriteTimeoutMS int64 `toml:"write_timeout_ms"` // per-SSE-write deadline, 0 = none WriteTimeoutMS int64 `toml:"write_timeout_ms"` // per-SSE-write deadline, 0 = none
MaxConnections int64 `toml:"max_connections"` // 0 = unlimited MaxConnections int64 `toml:"max_connections"` // 0 = unlimited
Auth *AuthOptions `toml:"auth"`
// Future: password auth block // Future: password auth block
} }
// TCPChainSinkOptions defines settings for a stdlib TCP client forwarding // TCPChainSinkOptions defines settings for a stdlib TCP client forwarding
// entries to a downstream logwisp tcp_chain source // entries to a downstream logwisp tcp_chain source
type TCPChainSinkOptions struct { type TCPChainSinkOptions struct {
TLS *TLSOptions `toml:"tls"` TLS *TLSOptions `toml:"tls"`
Node string `toml:"node"` // origin label, default: os.Hostname() Node string `toml:"node"` // origin label, default: os.Hostname()
Host string `toml:"host"` Host string `toml:"host"`
Port int64 `toml:"port"` Port int64 `toml:"port"`
BufferSize int64 `toml:"buffer_size"` BufferSize int64 `toml:"buffer_size"`
DialTimeoutMS int64 `toml:"dial_timeout_ms"` DialTimeoutMS int64 `toml:"dial_timeout_ms"`
WriteTimeoutMS int64 `toml:"write_timeout_ms"` WriteTimeoutMS int64 `toml:"write_timeout_ms"`
BackoffMinMS int64 `toml:"backoff_min_ms"` BackoffMinMS int64 `toml:"backoff_min_ms"`
BackoffMaxMS int64 `toml:"backoff_max_ms"` BackoffMaxMS int64 `toml:"backoff_max_ms"`
KeepAlivePeriodMS int64 `toml:"keep_alive_period_ms"` KeepAlivePeriodMS int64 `toml:"keep_alive_period_ms"`
KeepAlive bool `toml:"keep_alive"` KeepAlive bool `toml:"keep_alive"`
Auth *AuthOptions `toml:"auth"`
// Future: password auth block // Future: password auth block
} }
// HTTPChainSinkOptions defines settings for a stdlib HTTP client posting // HTTPChainSinkOptions defines settings for a stdlib HTTP client posting
// NDJSON batches to a downstream logwisp http_chain source // NDJSON batches to a downstream logwisp http_chain source
type HTTPChainSinkOptions struct { type HTTPChainSinkOptions struct {
TLS *TLSOptions `toml:"tls"` TLS *TLSOptions `toml:"tls"`
Node string `toml:"node"` // origin label, default: os.Hostname() Node string `toml:"node"` // origin label, default: os.Hostname()
Host string `toml:"host"` Host string `toml:"host"`
Port int64 `toml:"port"` Port int64 `toml:"port"`
IngestPath string `toml:"ingest_path"` IngestPath string `toml:"ingest_path"`
BufferSize int64 `toml:"buffer_size"` BufferSize int64 `toml:"buffer_size"`
MaxBatchCount int64 `toml:"max_batch_count"` MaxBatchCount int64 `toml:"max_batch_count"`
MaxBatchBytes int64 `toml:"max_batch_bytes"` MaxBatchBytes int64 `toml:"max_batch_bytes"`
FlushIntervalMS int64 `toml:"flush_interval_ms"` FlushIntervalMS int64 `toml:"flush_interval_ms"`
RequestTimeoutMS int64 `toml:"request_timeout_ms"` // covers dial + write + response RequestTimeoutMS int64 `toml:"request_timeout_ms"` // covers dial + write + response
BackoffMinMS int64 `toml:"backoff_min_ms"` BackoffMinMS int64 `toml:"backoff_min_ms"`
BackoffMaxMS int64 `toml:"backoff_max_ms"` BackoffMaxMS int64 `toml:"backoff_max_ms"`
Auth *AuthOptions `toml:"auth"`
// Future: password auth block // Future: password auth block
} }
// --- Auth Options ---
// AuthOptions defines certificate-based authorization for network plugins.
// It sits beside `tls` rather than inside it: TLS answers "is this channel
// private and does the peer chain to a CA", auth answers "may *this* peer do
// *this*". One shape serves both roles:
// - Listeners (tcp/http sinks, tcp_chain/http_chain sources) authorize the
// peer's client certificate; type "mtls" requires tls.client_auth.
// - Dialers (tcp_chain/http_chain sinks) pin the server's identity beyond
// hostname verification.
type AuthOptions struct {
// Method: "none" (default, preserves pre-auth behavior) | "mtls"
Type string `toml:"type"`
// Certificate field carrying the identity:
// "cn" (default) | "san_dns" | "san_uri" | "san_email"
Identity string `toml:"identity"`
// Exact identity matches. Empty Allow *and* AllowPatterns means "any
// identity the CA vouches for" - today's behavior, but with the identity
// recorded and node binding available.
Allow []string `toml:"allow"`
// RE2 patterns matched against the identity; anchor them yourself
AllowPatterns []string `toml:"allow_patterns"`
// Chain sources only: "none" | "assert" | "force" (default "force" when
// Type is "mtls"). Overrides trust_node.
NodeBinding string `toml:"node_binding"`
}
// --- TLS Options --- // --- TLS Options ---
// TLSOptions defines transport security for network sources and sinks. // TLSOptions defines transport security for network sources and sinks.
+30 -2
View File
@@ -153,11 +153,16 @@ func (p *Pipeline) initializeComponents() error {
// initSourceCapabilities checks and injects optional capabilities // initSourceCapabilities checks and injects optional capabilities
func (p *Pipeline) initSourceCapabilities(s source.Source, cfg config.PluginSourceConfig) error { func (p *Pipeline) initSourceCapabilities(s source.Source, cfg config.PluginSourceConfig) error {
// Initiate and activate source capabilities // Initiate and activate source capabilities
var hasTLS, hasAuth bool
for _, c := range s.Capabilities() { for _, c := range s.Capabilities() {
switch c { switch c {
// Network capabilities // Network capabilities
case core.CapNetLimit, core.CapTLS, core.CapAuth: case core.CapNetLimit:
continue // No-op for now, placeholder continue // No-op for now, placeholder
case core.CapTLS:
hasTLS = true
case core.CapAuth:
hasAuth = true
// Session capabilities // Session capabilities
case core.CapSessionAware: case core.CapSessionAware:
@@ -169,17 +174,36 @@ func (p *Pipeline) initSourceCapabilities(s source.Source, cfg config.PluginSour
} }
} }
if err := checkAuthCapability(hasTLS, hasAuth); err != nil {
return fmt.Errorf("source %s: %w", cfg.ID, err)
}
return nil
}
// checkAuthCapability rejects a plugin that decides on peer identity without a
// transport that verifies one - the decision would rest on an unauthenticated
// claim
func checkAuthCapability(hasTLS, hasAuth bool) error {
if hasAuth && !hasTLS {
return fmt.Errorf("capability %q requires %q", core.CapAuth, core.CapTLS)
}
return nil return nil
} }
// initSinkCapabilities checks and injects optional capabilities // initSinkCapabilities checks and injects optional capabilities
func (p *Pipeline) initSinkCapabilities(s sink.Sink, cfg config.PluginSinkConfig) error { func (p *Pipeline) initSinkCapabilities(s sink.Sink, cfg config.PluginSinkConfig) error {
// Initiate and activate sink capabilities // Initiate and activate sink capabilities
var hasTLS, hasAuth bool
for _, c := range s.Capabilities() { for _, c := range s.Capabilities() {
switch c { switch c {
// Network capabilities // Network capabilities
case core.CapNetLimit, core.CapTLS, core.CapAuth: case core.CapNetLimit:
continue // No-op for now, placeholder continue // No-op for now, placeholder
case core.CapTLS:
hasTLS = true
case core.CapAuth:
hasAuth = true
// Session capabilities // Session capabilities
case core.CapSessionAware: case core.CapSessionAware:
@@ -191,6 +215,10 @@ func (p *Pipeline) initSinkCapabilities(s sink.Sink, cfg config.PluginSinkConfig
} }
} }
if err := checkAuthCapability(hasTLS, hasAuth); err != nil {
return fmt.Errorf("sink %s: %w", cfg.ID, err)
}
return nil return nil
} }
+72 -18
View File
@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"maps"
"net" "net"
"net/http" "net/http"
"strconv" "strconv"
@@ -14,6 +15,7 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"logwisp/internal/authz"
"logwisp/internal/config" "logwisp/internal/config"
"logwisp/internal/core" "logwisp/internal/core"
"logwisp/internal/plugin" "logwisp/internal/plugin"
@@ -70,6 +72,9 @@ type HTTPSink struct {
// TLS // TLS
tlsConfig *tls.Config tlsConfig *tls.Config
// Authorization
auth *authz.Policy
// Runtime // Runtime
done chan struct{} done chan struct{}
stopOnce sync.Once stopOnce sync.Once
@@ -130,6 +135,10 @@ func NewHTTPSinkPlugin(
if err != nil { if err != nil {
return nil, err return nil, err
} }
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleListener)
if err != nil {
return nil, err
}
h := &HTTPSink{ h := &HTTPSink{
id: id, id: id,
@@ -142,6 +151,7 @@ func NewHTTPSinkPlugin(
clients: make(map[uint64]*sseClient), clients: make(map[uint64]*sseClient),
writeTimeout: time.Duration(opts.WriteTimeoutMS) * time.Millisecond, writeTimeout: time.Duration(opts.WriteTimeoutMS) * time.Millisecond,
tlsConfig: tlsCfg, tlsConfig: tlsCfg,
auth: authPolicy,
} }
h.lastProcessed.Store(time.Time{}) h.lastProcessed.Store(time.Time{})
@@ -153,7 +163,14 @@ func NewHTTPSinkPlugin(
"stream_path", opts.StreamPath, "stream_path", opts.StreamPath,
"status_path", opts.StatusPath, "status_path", opts.StatusPath,
"tls", tlsCfg != nil, "tls", tlsCfg != nil,
"mtls", tlsCfg != nil && tlsCfg.ClientAuth == tls.RequireAndVerifyClientCert) "mtls", tlsCfg != nil && tlsCfg.ClientAuth == tls.RequireAndVerifyClientCert,
"auth", authPolicy.Describe())
if authPolicy.Unrestricted() {
logger.Warn("msg", "Auth policy admits any identity the configured CA vouches for",
"component", "http_sink",
"instance_id", id,
"hint", "set auth.allow or auth.allow_patterns to authorize named clients")
}
return h, nil return h, nil
} }
@@ -162,9 +179,9 @@ func (h *HTTPSink) Capabilities() []core.Capability {
caps := []core.Capability{core.CapSessionAware, core.CapMultiSession} caps := []core.Capability{core.CapSessionAware, core.CapMultiSession}
if h.tlsConfig != nil { if h.tlsConfig != nil {
caps = append(caps, core.CapTLS) caps = append(caps, core.CapTLS)
if h.tlsConfig.ClientAuth == tls.RequireAndVerifyClientCert { }
caps = append(caps, core.CapAuth) // mTLS is authentication if h.auth.Enabled() {
} caps = append(caps, core.CapAuth) // authorizes clients, not just the CA
} }
return caps return caps
} }
@@ -189,9 +206,12 @@ func (h *HTTPSink) Start(ctx context.Context) error {
mux.HandleFunc(http.MethodGet+" "+h.config.StreamPath, h.handleStream) mux.HandleFunc(http.MethodGet+" "+h.config.StreamPath, h.handleStream)
mux.HandleFunc(http.MethodGet+" "+h.config.StatusPath, h.handleStatus) mux.HandleFunc(http.MethodGet+" "+h.config.StatusPath, h.handleStatus)
// Auth extension point: wrap mux with auth middleware once credentials // One wrapper covers stream and status, and keeps the handlers themselves
// land, e.g. handler = authMiddleware(cfg)(handler) // unaware of authorization
var handler http.Handler = mux var handler http.Handler = mux
if h.auth.Enabled() {
handler = h.authMiddleware(handler)
}
h.server = &http.Server{ h.server = &http.Server{
Handler: handler, Handler: handler,
@@ -343,6 +363,9 @@ func (h *HTTPSink) handleStream(w http.ResponseWriter, r *http.Request) {
meta["tls_peer_cn"] = cn meta["tls_peer_cn"] = cn
} }
} }
// Set by authMiddleware; absent when auth is disabled
ident, _ := r.Context().Value(identityKey{}).(authz.Identity)
ident.Apply(meta)
sess := h.proxy.CreateSession(remote, meta) sess := h.proxy.CreateSession(remote, meta)
c := &sseClient{ c := &sseClient{
@@ -361,6 +384,7 @@ func (h *HTTPSink) handleStream(w http.ResponseWriter, r *http.Request) {
"remote_addr", remote, "remote_addr", remote,
"session_id", sess.ID, "session_id", sess.ID,
"client_id", id, "client_id", id,
"auth_identity", ident.Name,
"active_clients", count) "active_clients", count)
defer func() { defer func() {
@@ -432,6 +456,7 @@ func (h *HTTPSink) handleStatus(w http.ResponseWriter, r *http.Request) {
"host": h.config.Host, "host": h.config.Host,
"port": h.config.Port, "port": h.config.Port,
"tls": h.tlsConfig != nil, "tls": h.tlsConfig != nil,
"auth": h.auth.Describe(),
"active_clients": h.activeClients.Load(), "active_clients": h.activeClients.Load(),
"buffer_size": h.config.BufferSize, "buffer_size": h.config.BufferSize,
"uptime_seconds": int(time.Since(h.startTime).Seconds()), "uptime_seconds": int(time.Since(h.startTime).Seconds()),
@@ -444,6 +469,7 @@ func (h *HTTPSink) handleStatus(w http.ResponseWriter, r *http.Request) {
"total_processed": h.totalProcessed.Load(), "total_processed": h.totalProcessed.Load(),
"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(),
}, },
} }
@@ -454,6 +480,20 @@ func (h *HTTPSink) handleStatus(w http.ResponseWriter, r *http.Request) {
// GetStats returns sink statistics // GetStats returns sink statistics
func (h *HTTPSink) GetStats() sink.SinkStats { func (h *HTTPSink) GetStats() sink.SinkStats {
lastProc, _ := h.lastProcessed.Load().(time.Time) lastProc, _ := h.lastProcessed.Load().(time.Time)
details := map[string]any{
"host": h.config.Host,
"port": h.config.Port,
"buffer_size": h.config.BufferSize,
"tls": h.tlsConfig != nil,
"dropped_writes": h.droppedWrites.Load(),
"rejected_clients": h.rejectedClients.Load(),
"endpoints": map[string]string{
"stream": h.config.StreamPath,
"status": h.config.StatusPath,
},
}
maps.Copy(details, h.auth.Stats())
return sink.SinkStats{ return sink.SinkStats{
ID: h.id, ID: h.id,
Type: "http", Type: "http",
@@ -461,21 +501,35 @@ func (h *HTTPSink) GetStats() sink.SinkStats {
ActiveConnections: h.activeClients.Load(), ActiveConnections: h.activeClients.Load(),
StartTime: h.startTime, StartTime: h.startTime,
LastProcessed: lastProc, LastProcessed: lastProc,
Details: map[string]any{ Details: details,
"host": h.config.Host,
"port": h.config.Port,
"buffer_size": h.config.BufferSize,
"tls": h.tlsConfig != nil,
"dropped_writes": h.droppedWrites.Load(),
"rejected_clients": h.rejectedClients.Load(),
"endpoints": map[string]string{
"stream": h.config.StreamPath,
"status": h.config.StatusPath,
},
},
} }
} }
// identityKey carries the authorized identity from the middleware to the
// handlers; absent when auth is disabled
type identityKey struct{}
// authMiddleware gates every endpoint on the client certificate policy.
// The rejection carries no detail: the status endpoint already exposes host,
// port, and throughput counters, so a 403 should not add the shape of the
// policy on top of that.
func (h *HTTPSink) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ident, err := h.auth.Authorize(r.TLS)
if err != nil {
h.logger.Warn("msg", "Request rejected by auth policy",
"component", "http_sink",
"instance_id", h.id,
"remote_addr", r.RemoteAddr,
"path", r.URL.Path,
"error", err)
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), identityKey{}, ident)))
})
}
// writeSSE frames a payload per the W3C SSE spec (multi-line safe) // writeSSE frames a payload per the W3C SSE spec (multi-line safe)
func writeSSE(w http.ResponseWriter, payload []byte) error { func writeSSE(w http.ResponseWriter, payload []byte) error {
for _, line := range splitLines(payload) { for _, line := range splitLines(payload) {
+32 -13
View File
@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"maps"
"net" "net"
"net/http" "net/http"
"os" "os"
@@ -15,6 +16,7 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"logwisp/internal/authz"
"logwisp/internal/chain" "logwisp/internal/chain"
"logwisp/internal/config" "logwisp/internal/config"
"logwisp/internal/core" "logwisp/internal/core"
@@ -58,6 +60,9 @@ type HTTPChainSink struct {
tlsEnabled bool tlsEnabled bool
mtls bool mtls bool
// Authorization: pins the downstream server's identity
auth *authz.Policy
client *http.Client client *http.Client
input chan core.TransportEvent input chan core.TransportEvent
logger *log.Logger logger *log.Logger
@@ -136,6 +141,15 @@ func NewHTTPChainSinkPlugin(
if err != nil { if err != nil {
return nil, err return nil, err
} }
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleDialer)
if err != nil {
return nil, err
}
if authPolicy.Enabled() {
// Runs after the standard chain and hostname checks, so a server the
// policy rejects fails the handshake instead of the first request
tlsCfg.VerifyConnection = authPolicy.VerifyConnection
}
addr := net.JoinHostPort(opts.Host, strconv.FormatInt(opts.Port, 10)) addr := net.JoinHostPort(opts.Host, strconv.FormatInt(opts.Port, 10))
@@ -166,6 +180,7 @@ func NewHTTPChainSinkPlugin(
node: node, node: node,
tlsEnabled: tlsCfg != nil, tlsEnabled: tlsCfg != nil,
mtls: tlsCfg != nil && len(tlsCfg.Certificates) > 0, mtls: tlsCfg != nil && len(tlsCfg.Certificates) > 0,
auth: authPolicy,
url: scheme + "://" + addr + opts.IngestPath, url: scheme + "://" + addr + opts.IngestPath,
client: &http.Client{Transport: transport}, client: &http.Client{Transport: transport},
input: make(chan core.TransportEvent, opts.BufferSize), input: make(chan core.TransportEvent, opts.BufferSize),
@@ -191,7 +206,8 @@ func NewHTTPChainSinkPlugin(
"target", t.url, "target", t.url,
"node", node, "node", node,
"tls", t.tlsEnabled, "tls", t.tlsEnabled,
"mtls", t.mtls) "mtls", t.mtls,
"auth", authPolicy.Describe())
return t, nil return t, nil
} }
@@ -200,9 +216,9 @@ func (t *HTTPChainSink) Capabilities() []core.Capability {
caps := []core.Capability{core.CapSessionAware} caps := []core.Capability{core.CapSessionAware}
if t.tlsEnabled { if t.tlsEnabled {
caps = append(caps, core.CapTLS) caps = append(caps, core.CapTLS)
if t.mtls { }
caps = append(caps, core.CapAuth) // presents client identity (mTLS) if t.auth.Enabled() {
} caps = append(caps, core.CapAuth) // pins the server identity
} }
return caps return caps
} }
@@ -249,21 +265,24 @@ func (t *HTTPChainSink) Stop() {
// GetStats returns sink statistics // GetStats returns sink statistics
func (t *HTTPChainSink) GetStats() sink.SinkStats { func (t *HTTPChainSink) GetStats() sink.SinkStats {
lastProc, _ := t.lastProcessed.Load().(time.Time) lastProc, _ := t.lastProcessed.Load().(time.Time)
details := map[string]any{
"target": t.url,
"node": t.node,
"tls": t.tlsEnabled,
"batches_sent": t.batchesSent.Load(),
"request_errors": t.requestErrors.Load(),
"dropped_batches": t.droppedBatches.Load(),
"synthesized": t.synthesized.Load(),
}
maps.Copy(details, t.auth.Stats())
return sink.SinkStats{ return sink.SinkStats{
ID: t.id, ID: t.id,
Type: "http_chain", Type: "http_chain",
TotalProcessed: t.totalProcessed.Load(), TotalProcessed: t.totalProcessed.Load(),
StartTime: t.startTime, StartTime: t.startTime,
LastProcessed: lastProc, LastProcessed: lastProc,
Details: map[string]any{ Details: details,
"target": t.url,
"node": t.node,
"tls": t.tlsEnabled,
"batches_sent": t.batchesSent.Load(),
"request_errors": t.requestErrors.Load(),
"dropped_batches": t.droppedBatches.Load(),
"synthesized": t.synthesized.Load(),
},
} }
} }
+57 -17
View File
@@ -5,12 +5,14 @@ import (
"crypto/tls" "crypto/tls"
"errors" "errors"
"fmt" "fmt"
"maps"
"net" "net"
"strconv" "strconv"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
"logwisp/internal/authz"
"logwisp/internal/config" "logwisp/internal/config"
"logwisp/internal/core" "logwisp/internal/core"
"logwisp/internal/plugin" "logwisp/internal/plugin"
@@ -66,6 +68,9 @@ type TCPSink struct {
tlsConfig *tls.Config tlsConfig *tls.Config
tlsHandshakeErrors atomic.Uint64 tlsHandshakeErrors atomic.Uint64
// Authorization
auth *authz.Policy
// Runtime // Runtime
done chan struct{} done chan struct{}
stopOnce sync.Once stopOnce sync.Once
@@ -124,6 +129,10 @@ func NewTCPSinkPlugin(
if err != nil { if err != nil {
return nil, err return nil, err
} }
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleListener)
if err != nil {
return nil, err
}
t := &TCPSink{ t := &TCPSink{
id: id, id: id,
@@ -136,6 +145,7 @@ func NewTCPSinkPlugin(
clients: make(map[uint64]*tcpClient), clients: make(map[uint64]*tcpClient),
writeTimeout: time.Duration(opts.WriteTimeoutMS) * time.Millisecond, writeTimeout: time.Duration(opts.WriteTimeoutMS) * time.Millisecond,
tlsConfig: tlsCfg, tlsConfig: tlsCfg,
auth: authPolicy,
} }
t.lastProcessed.Store(time.Time{}) t.lastProcessed.Store(time.Time{})
@@ -145,7 +155,14 @@ func NewTCPSinkPlugin(
"host", opts.Host, "host", opts.Host,
"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())
if authPolicy.Unrestricted() {
logger.Warn("msg", "Auth policy admits any identity the configured CA vouches for",
"component", "tcp_sink",
"instance_id", id,
"hint", "set auth.allow or auth.allow_patterns to authorize named clients")
}
return t, nil return t, nil
} }
@@ -154,9 +171,9 @@ func (t *TCPSink) Capabilities() []core.Capability {
caps := []core.Capability{core.CapSessionAware, core.CapMultiSession} caps := []core.Capability{core.CapSessionAware, core.CapMultiSession}
if t.tlsConfig != nil { if t.tlsConfig != nil {
caps = append(caps, core.CapTLS) caps = append(caps, core.CapTLS)
if t.tlsConfig.ClientAuth == tls.RequireAndVerifyClientCert { }
caps = append(caps, core.CapAuth) // mTLS is authentication if t.auth.Enabled() {
} caps = append(caps, core.CapAuth) // authorizes clients, not just the CA
} }
return caps return caps
} }
@@ -270,8 +287,9 @@ func (t *TCPSink) acceptLoop() {
continue continue
} }
// Password-auth extension point: preamble verification runs in // Certificate authorization runs in handleConn post-handshake,
// handleConn post-handshake, pre-registration // pre-registration. Password-auth extension point: preamble
// verification belongs at the same place.
t.wg.Add(1) t.wg.Add(1)
go t.handleConn(conn) go t.handleConn(conn)
@@ -298,6 +316,7 @@ func (t *TCPSink) handleConn(conn net.Conn) {
"type": "tcp_client", "type": "tcp_client",
"remote_addr": remote, "remote_addr": remote,
} }
var tlsState *tls.ConnectionState
if tc, ok := conn.(*tls.Conn); ok { if tc, ok := conn.(*tls.Conn); ok {
hctx, cancel := context.WithTimeout(context.Background(), tlsx.HandshakeTimeout) hctx, cancel := context.WithTimeout(context.Background(), tlsx.HandshakeTimeout)
err := tc.HandshakeContext(hctx) err := tc.HandshakeContext(hctx)
@@ -311,12 +330,29 @@ func (t *TCPSink) handleConn(conn net.Conn) {
conn.Close() conn.Close()
return return
} }
cs := tc.ConnectionState()
tlsState = &cs
meta["tls"] = true meta["tls"] = true
if cn := tlsx.PeerCN(tc.ConnectionState()); cn != "" { if cn := tlsx.PeerCN(cs); cn != "" {
meta["tls_peer_cn"] = cn meta["tls_peer_cn"] = cn
} }
} }
// Authorize before registration, so an unauthorized peer never enters the
// client map and never receives a broadcast
ident, err := t.auth.Authorize(tlsState)
if err != nil {
t.rejectedConns.Add(1)
t.logger.Warn("msg", "Connection rejected by auth policy",
"component", "tcp_sink",
"instance_id", t.id,
"remote_addr", remote,
"error", err)
conn.Close()
return
}
ident.Apply(meta)
sess := t.proxy.CreateSession(remote, meta) sess := t.proxy.CreateSession(remote, meta)
c := &tcpClient{ c := &tcpClient{
conn: conn, conn: conn,
@@ -334,6 +370,7 @@ func (t *TCPSink) handleConn(conn net.Conn) {
"component", "tcp_sink", "component", "tcp_sink",
"remote_addr", remote, "remote_addr", remote,
"session_id", sess.ID, "session_id", sess.ID,
"auth_identity", ident.Name,
"active_connections", count) "active_connections", count)
defer func() { defer func() {
@@ -421,6 +458,18 @@ func (t *TCPSink) broadcastLoop(ctx context.Context) {
// GetStats returns sink statistics // GetStats returns sink statistics
func (t *TCPSink) GetStats() sink.SinkStats { func (t *TCPSink) GetStats() sink.SinkStats {
lastProc, _ := t.lastProcessed.Load().(time.Time) lastProc, _ := t.lastProcessed.Load().(time.Time)
details := map[string]any{
"host": t.config.Host,
"port": t.config.Port,
"buffer_size": t.config.BufferSize,
"write_errors": t.writeErrors.Load(),
"dropped_writes": t.droppedWrites.Load(),
"rejected_conns": t.rejectedConns.Load(),
"tls": t.tlsConfig != nil,
"tls_handshake_errors": t.tlsHandshakeErrors.Load(),
}
maps.Copy(details, t.auth.Stats())
return sink.SinkStats{ return sink.SinkStats{
ID: t.id, ID: t.id,
Type: "tcp", Type: "tcp",
@@ -428,15 +477,6 @@ func (t *TCPSink) GetStats() sink.SinkStats {
ActiveConnections: t.activeConns.Load(), ActiveConnections: t.activeConns.Load(),
StartTime: t.startTime, StartTime: t.startTime,
LastProcessed: lastProc, LastProcessed: lastProc,
Details: map[string]any{ Details: details,
"host": t.config.Host,
"port": t.config.Port,
"buffer_size": t.config.BufferSize,
"write_errors": t.writeErrors.Load(),
"dropped_writes": t.droppedWrites.Load(),
"rejected_conns": t.rejectedConns.Load(),
"tls": t.tlsConfig != nil,
"tls_handshake_errors": t.tlsHandshakeErrors.Load(),
},
} }
} }
+32 -13
View File
@@ -5,6 +5,7 @@ import (
"crypto/tls" "crypto/tls"
"encoding/json" "encoding/json"
"fmt" "fmt"
"maps"
"math/rand/v2" "math/rand/v2"
"net" "net"
"os" "os"
@@ -13,6 +14,7 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"logwisp/internal/authz"
"logwisp/internal/chain" "logwisp/internal/chain"
"logwisp/internal/config" "logwisp/internal/config"
"logwisp/internal/core" "logwisp/internal/core"
@@ -52,6 +54,9 @@ type TCPChainSink struct {
helloLine []byte helloLine []byte
tlsConfig *tls.Config tlsConfig *tls.Config
// Authorization: pins the downstream server's identity
auth *authz.Policy
input chan core.TransportEvent input chan core.TransportEvent
logger *log.Logger logger *log.Logger
@@ -129,6 +134,15 @@ func NewTCPChainSinkPlugin(
if err != nil { if err != nil {
return nil, err return nil, err
} }
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleDialer)
if err != nil {
return nil, err
}
if authPolicy.Enabled() {
// Runs after the standard chain and hostname checks, so a server the
// policy rejects fails the handshake instead of the first write
tlsCfg.VerifyConnection = authPolicy.VerifyConnection
}
t := &TCPChainSink{ t := &TCPChainSink{
id: id, id: id,
@@ -138,6 +152,7 @@ func NewTCPChainSinkPlugin(
addr: net.JoinHostPort(opts.Host, strconv.FormatInt(opts.Port, 10)), addr: net.JoinHostPort(opts.Host, strconv.FormatInt(opts.Port, 10)),
helloLine: helloLine, helloLine: helloLine,
tlsConfig: tlsCfg, tlsConfig: tlsCfg,
auth: authPolicy,
input: make(chan core.TransportEvent, opts.BufferSize), input: make(chan core.TransportEvent, opts.BufferSize),
done: make(chan struct{}), done: make(chan struct{}),
logger: logger, logger: logger,
@@ -162,7 +177,8 @@ func NewTCPChainSinkPlugin(
"target", t.addr, "target", t.addr,
"node", node, "node", node,
"tls", tlsCfg != nil, "tls", tlsCfg != nil,
"mtls", tlsCfg != nil && len(tlsCfg.Certificates) > 0) "mtls", tlsCfg != nil && len(tlsCfg.Certificates) > 0,
"auth", authPolicy.Describe())
return t, nil return t, nil
} }
@@ -171,9 +187,9 @@ func (t *TCPChainSink) Capabilities() []core.Capability {
caps := []core.Capability{core.CapSessionAware} caps := []core.Capability{core.CapSessionAware}
if t.tlsConfig != nil { if t.tlsConfig != nil {
caps = append(caps, core.CapTLS) caps = append(caps, core.CapTLS)
if len(t.tlsConfig.Certificates) > 0 { }
caps = append(caps, core.CapAuth) // presents client identity (mTLS) if t.auth.Enabled() {
} caps = append(caps, core.CapAuth) // pins the server identity
} }
return caps return caps
} }
@@ -224,6 +240,17 @@ func (t *TCPChainSink) GetStats() sink.SinkStats {
if t.connected.Load() { if t.connected.Load() {
active = 1 active = 1
} }
details := map[string]any{
"target": t.addr,
"node": t.node,
"tls": t.tlsConfig != nil,
"connected": t.connected.Load(),
"reconnects": t.reconnects.Load(),
"write_errors": t.writeErrors.Load(),
"synthesized": t.synthesized.Load(),
}
maps.Copy(details, t.auth.Stats())
return sink.SinkStats{ return sink.SinkStats{
ID: t.id, ID: t.id,
Type: "tcp_chain", Type: "tcp_chain",
@@ -231,15 +258,7 @@ func (t *TCPChainSink) GetStats() sink.SinkStats {
ActiveConnections: active, ActiveConnections: active,
StartTime: t.startTime, StartTime: t.startTime,
LastProcessed: lastProc, LastProcessed: lastProc,
Details: map[string]any{ Details: details,
"target": t.addr,
"node": t.node,
"tls": t.tlsConfig != nil,
"connected": t.connected.Load(),
"reconnects": t.reconnects.Load(),
"write_errors": t.writeErrors.Load(),
"synthesized": t.synthesized.Load(),
},
} }
} }
+82 -24
View File
@@ -6,6 +6,7 @@ import (
"crypto/tls" "crypto/tls"
"errors" "errors"
"fmt" "fmt"
"maps"
"net" "net"
"net/http" "net/http"
"strconv" "strconv"
@@ -14,6 +15,7 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"logwisp/internal/authz"
"logwisp/internal/chain" "logwisp/internal/chain"
"logwisp/internal/config" "logwisp/internal/config"
"logwisp/internal/core" "logwisp/internal/core"
@@ -54,7 +56,10 @@ type HTTPChainSource struct {
// TLS // TLS
tlsConfig *tls.Config tlsConfig *tls.Config
// Session cache: one session per remote host + declared node // Authorization
auth *authz.Policy
// Session cache: one session per remote host + node + authenticated identity
sessions map[string]string // key -> sessionID sessions map[string]string // key -> sessionID
sessionsMu sync.Mutex sessionsMu sync.Mutex
@@ -104,6 +109,10 @@ func NewHTTPChainSourcePlugin(
if err != nil { if err != nil {
return nil, err return nil, err
} }
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleChainListener)
if err != nil {
return nil, err
}
s := &HTTPChainSource{ s := &HTTPChainSource{
id: id, id: id,
@@ -113,6 +122,7 @@ func NewHTTPChainSourcePlugin(
sessions: make(map[string]string), sessions: make(map[string]string),
logger: logger, logger: logger,
tlsConfig: tlsCfg, tlsConfig: tlsCfg,
auth: authPolicy,
} }
s.lastEntryTime.Store(time.Time{}) s.lastEntryTime.Store(time.Time{})
@@ -123,7 +133,21 @@ func NewHTTPChainSourcePlugin(
"port", opts.Port, "port", opts.Port,
"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())
if authPolicy.Unrestricted() {
logger.Warn("msg", "Auth policy admits any identity the configured CA vouches for",
"component", "http_chain_source",
"instance_id", id,
"hint", "set auth.allow or auth.allow_patterns to authorize named peers")
}
if authPolicy.BindsNode() {
logger.Info("msg", "Node labels bound to peer identity; trust_node is ignored",
"component", "http_chain_source",
"instance_id", id,
"node_binding", authPolicy.NodeBinding(),
"trust_node", opts.TrustNode)
}
return s, nil return s, nil
} }
@@ -132,9 +156,9 @@ func (s *HTTPChainSource) Capabilities() []core.Capability {
caps := []core.Capability{core.CapSessionAware, core.CapMultiSession} caps := []core.Capability{core.CapSessionAware, core.CapMultiSession}
if s.tlsConfig != nil { if s.tlsConfig != nil {
caps = append(caps, core.CapTLS) caps = append(caps, core.CapTLS)
if s.tlsConfig.ClientAuth == tls.RequireAndVerifyClientCert { }
caps = append(caps, core.CapAuth) // mTLS is authentication if s.auth.Enabled() {
} caps = append(caps, core.CapAuth) // authorizes peers, not just the CA
} }
return caps return caps
} }
@@ -226,6 +250,19 @@ func (s *HTTPChainSource) GetStats() source.SourceStats {
cachedSessions := len(s.sessions) cachedSessions := len(s.sessions)
s.sessionsMu.Unlock() s.sessionsMu.Unlock()
details := map[string]any{
"host": s.config.Host,
"port": s.config.Port,
"ingest_path": s.config.IngestPath,
"tls": s.tlsConfig != nil,
"total_requests": s.totalRequests.Load(),
"rejected_requests": s.rejectedRequests.Load(),
"parse_errors": s.parseErrors.Load(),
"cached_sessions": cachedSessions,
"trust_node": s.config.TrustNode,
}
maps.Copy(details, s.auth.Stats())
return source.SourceStats{ return source.SourceStats{
ID: s.id, ID: s.id,
Type: "http_chain", Type: "http_chain",
@@ -233,17 +270,7 @@ func (s *HTTPChainSource) GetStats() source.SourceStats {
DroppedEntries: s.droppedEntries.Load(), DroppedEntries: s.droppedEntries.Load(),
StartTime: s.startTime, StartTime: s.startTime,
LastEntryTime: lastEntry, LastEntryTime: lastEntry,
Details: map[string]any{ Details: details,
"host": s.config.Host,
"port": s.config.Port,
"ingest_path": s.config.IngestPath,
"tls": s.tlsConfig != nil,
"total_requests": s.totalRequests.Load(),
"rejected_requests": s.rejectedRequests.Load(),
"parse_errors": s.parseErrors.Load(),
"cached_sessions": cachedSessions,
"trust_node": s.config.TrustNode,
},
} }
} }
@@ -252,6 +279,22 @@ func (s *HTTPChainSource) GetStats() source.SourceStats {
func (s *HTTPChainSource) handleIngest(w http.ResponseWriter, r *http.Request) { func (s *HTTPChainSource) handleIngest(w http.ResponseWriter, r *http.Request) {
s.totalRequests.Add(1) s.totalRequests.Add(1)
// Authorize before the body is read: an unauthorized sender should not get
// to stream max_body_bytes into the process. 403 is distinct from the 400
// used for protocol errors, so a sender can tell "not allowed" from
// "malformed batch".
ident, err := s.auth.Authorize(r.TLS)
if err != nil {
s.rejectedRequests.Add(1)
s.logger.Warn("msg", "Request rejected by auth policy",
"component", "http_chain_source",
"instance_id", s.id,
"remote_addr", r.RemoteAddr,
"error", err)
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if r.Header.Get(chain.HeaderProtocol) != strconv.Itoa(chain.ProtocolVersion) { if r.Header.Get(chain.HeaderProtocol) != strconv.Itoa(chain.ProtocolVersion) {
s.rejectedRequests.Add(1) s.rejectedRequests.Add(1)
http.Error(w, "unsupported protocol version", http.StatusBadRequest) http.Error(w, "unsupported protocol version", http.StatusBadRequest)
@@ -262,10 +305,22 @@ func (s *HTTPChainSource) handleIngest(w http.ResponseWriter, r *http.Request) {
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
remoteHost = host remoteHost = host
} }
connNode := r.Header.Get(chain.HeaderNode) declaredNode := r.Header.Get(chain.HeaderNode)
if connNode == "" || !s.config.TrustNode { connNode, err := s.auth.ResolveNode(declaredNode, remoteHost, s.config.TrustNode, ident)
connNode = remoteHost if err != nil {
s.rejectedRequests.Add(1)
s.logger.Warn("msg", "Request rejected by node binding",
"component", "http_chain_source",
"instance_id", s.id,
"remote_addr", r.RemoteAddr,
"declared_node", declaredNode,
"error", err)
http.Error(w, "forbidden", http.StatusForbidden)
return
} }
// force relabels every entry, so a sender cannot smuggle a foreign origin
// through the per-entry node field either
trustEntryNode := s.auth.TrustsEntryNode(s.config.TrustNode)
body := http.MaxBytesReader(w, r.Body, s.config.MaxBodyBytes) body := http.MaxBytesReader(w, r.Body, s.config.MaxBodyBytes)
scanner := bufio.NewScanner(body) scanner := bufio.NewScanner(body)
@@ -277,7 +332,7 @@ func (s *HTTPChainSource) handleIngest(w http.ResponseWriter, r *http.Request) {
if len(line) == 0 { if len(line) == 0 {
continue continue
} }
entry, err := chain.DecodeEntry(line, connNode, s.config.TrustNode) entry, err := chain.DecodeEntry(line, connNode, trustEntryNode)
if err != nil { if err != nil {
// Content error within a clean transfer: skip line, keep batch // Content error within a clean transfer: skip line, keep batch
s.parseErrors.Add(1) s.parseErrors.Add(1)
@@ -304,15 +359,17 @@ func (s *HTTPChainSource) handleIngest(w http.ResponseWriter, r *http.Request) {
for _, entry := range entries { for _, entry := range entries {
s.publish(entry) s.publish(entry)
} }
s.proxy.UpdateActivity(s.sessionFor(remoteHost, connNode, r.TLS)) s.proxy.UpdateActivity(s.sessionFor(remoteHost, connNode, r.TLS, ident))
w.Header().Set(chain.HeaderAccepted, strconv.Itoa(len(entries))) w.Header().Set(chain.HeaderAccepted, strconv.Itoa(len(entries)))
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// sessionFor returns the cached session for a remote+node, recreating after idle expiry // sessionFor returns the cached session for a remote+node+identity,
func (s *HTTPChainSource) sessionFor(remoteHost, node string, cs *tls.ConnectionState) string { // recreating after idle expiry. Identity is part of the key so two peers
key := remoteHost + "|" + node // sharing a remote address never share a session.
func (s *HTTPChainSource) sessionFor(remoteHost, node string, cs *tls.ConnectionState, ident authz.Identity) string {
key := remoteHost + "|" + node + "|" + ident.Name
s.sessionsMu.Lock() s.sessionsMu.Lock()
defer s.sessionsMu.Unlock() defer s.sessionsMu.Unlock()
@@ -331,6 +388,7 @@ func (s *HTTPChainSource) sessionFor(remoteHost, node string, cs *tls.Connection
meta["tls_peer_cn"] = cn meta["tls_peer_cn"] = cn
} }
} }
ident.Apply(meta)
sess := s.proxy.CreateSession(remoteHost, meta) sess := s.proxy.CreateSession(remoteHost, meta)
s.sessions[key] = sess.ID s.sessions[key] = sess.ID
return sess.ID return sess.ID
+74 -23
View File
@@ -6,12 +6,14 @@ import (
"crypto/tls" "crypto/tls"
"errors" "errors"
"fmt" "fmt"
"maps"
"net" "net"
"strconv" "strconv"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
"logwisp/internal/authz"
"logwisp/internal/chain" "logwisp/internal/chain"
"logwisp/internal/config" "logwisp/internal/config"
"logwisp/internal/core" "logwisp/internal/core"
@@ -50,6 +52,9 @@ type TCPChainSource struct {
tlsConfig *tls.Config tlsConfig *tls.Config
tlsHandshakeErrors atomic.Uint64 tlsHandshakeErrors atomic.Uint64
// Authorization
auth *authz.Policy
mu sync.RWMutex mu sync.RWMutex
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
@@ -91,6 +96,10 @@ func NewTCPChainSourcePlugin(
if err != nil { if err != nil {
return nil, err return nil, err
} }
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleChainListener)
if err != nil {
return nil, err
}
s := &TCPChainSource{ s := &TCPChainSource{
id: id, id: id,
@@ -100,6 +109,7 @@ func NewTCPChainSourcePlugin(
conns: make(map[net.Conn]struct{}), conns: make(map[net.Conn]struct{}),
logger: logger, logger: logger,
tlsConfig: tlsCfg, tlsConfig: tlsCfg,
auth: authPolicy,
} }
s.lastEntryTime.Store(time.Time{}) s.lastEntryTime.Store(time.Time{})
@@ -109,7 +119,21 @@ func NewTCPChainSourcePlugin(
"host", opts.Host, "host", opts.Host,
"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())
if authPolicy.Unrestricted() {
logger.Warn("msg", "Auth policy admits any identity the configured CA vouches for",
"component", "tcp_chain_source",
"instance_id", id,
"hint", "set auth.allow or auth.allow_patterns to authorize named peers")
}
if authPolicy.BindsNode() {
logger.Info("msg", "Node labels bound to peer identity; trust_node is ignored",
"component", "tcp_chain_source",
"instance_id", id,
"node_binding", authPolicy.NodeBinding(),
"trust_node", opts.TrustNode)
}
return s, nil return s, nil
} }
@@ -118,9 +142,9 @@ func (s *TCPChainSource) Capabilities() []core.Capability {
caps := []core.Capability{core.CapSessionAware, core.CapMultiSession} caps := []core.Capability{core.CapSessionAware, core.CapMultiSession}
if s.tlsConfig != nil { if s.tlsConfig != nil {
caps = append(caps, core.CapTLS) caps = append(caps, core.CapTLS)
if s.tlsConfig.ClientAuth == tls.RequireAndVerifyClientCert { }
caps = append(caps, core.CapAuth) // mTLS is authentication if s.auth.Enabled() {
} caps = append(caps, core.CapAuth) // authorizes peers, not just the CA
} }
return caps return caps
} }
@@ -191,6 +215,18 @@ func (s *TCPChainSource) Stop() {
// GetStats returns the source's statistics // GetStats returns the source's statistics
func (s *TCPChainSource) GetStats() source.SourceStats { func (s *TCPChainSource) GetStats() source.SourceStats {
lastEntry, _ := s.lastEntryTime.Load().(time.Time) lastEntry, _ := s.lastEntryTime.Load().(time.Time)
details := map[string]any{
"host": s.config.Host,
"port": s.config.Port,
"tls": s.tlsConfig != nil,
"tls_handshake_errors": s.tlsHandshakeErrors.Load(),
"active_connections": s.activeConns.Load(),
"rejected_conns": s.rejectedConns.Load(),
"parse_errors": s.parseErrors.Load(),
"trust_node": s.config.TrustNode,
}
maps.Copy(details, s.auth.Stats())
return source.SourceStats{ return source.SourceStats{
ID: s.id, ID: s.id,
Type: "tcp_chain", Type: "tcp_chain",
@@ -198,16 +234,7 @@ func (s *TCPChainSource) GetStats() source.SourceStats {
DroppedEntries: s.droppedEntries.Load(), DroppedEntries: s.droppedEntries.Load(),
StartTime: s.startTime, StartTime: s.startTime,
LastEntryTime: lastEntry, LastEntryTime: lastEntry,
Details: map[string]any{ Details: details,
"host": s.config.Host,
"port": s.config.Port,
"tls": s.tlsConfig != nil,
"tls_handshake_errors": s.tlsHandshakeErrors.Load(),
"active_connections": s.activeConns.Load(),
"rejected_conns": s.rejectedConns.Load(),
"parse_errors": s.parseErrors.Load(),
"trust_node": s.config.TrustNode,
},
} }
} }
@@ -276,6 +303,18 @@ func (s *TCPChainSource) handleConn(conn net.Conn) {
tlsState = &cs tlsState = &cs
} }
// Authorize before a preamble is parsed on an unauthorized peer's behalf
ident, err := s.auth.Authorize(tlsState)
if err != nil {
s.rejectedConns.Add(1)
s.logger.Warn("msg", "Connection rejected by auth policy",
"component", "tcp_chain_source",
"instance_id", s.id,
"remote_addr", remote,
"error", err)
return // deferred cleanup closes conn
}
scanner := bufio.NewScanner(conn) scanner := bufio.NewScanner(conn)
// Oversized line (> MaxLogEntryBytes) is a protocol violation; scanner is // Oversized line (> MaxLogEntryBytes) is a protocol violation; scanner is
// unrecoverable after ErrTooLong, connection terminates // unrecoverable after ErrTooLong, connection terminates
@@ -299,14 +338,24 @@ func (s *TCPChainSource) handleConn(conn net.Conn) {
return return
} }
connNode := hello.Node fallbackNode := remote
if connNode == "" || !s.config.TrustNode { if host, _, splitErr := net.SplitHostPort(remote); splitErr == nil {
if host, _, splitErr := net.SplitHostPort(remote); splitErr == nil { fallbackNode = host
connNode = host
} else {
connNode = remote
}
} }
connNode, err := s.auth.ResolveNode(hello.Node, fallbackNode, s.config.TrustNode, ident)
if err != nil {
s.rejectedConns.Add(1)
s.logger.Warn("msg", "Connection rejected by node binding",
"component", "tcp_chain_source",
"instance_id", s.id,
"remote_addr", remote,
"declared_node", hello.Node,
"error", err)
return
}
// force relabels every entry, so an edge cannot smuggle a foreign origin
// through the per-entry node field either
trustEntryNode := s.auth.TrustsEntryNode(s.config.TrustNode)
meta := map[string]any{ meta := map[string]any{
"type": "tcp_chain", "type": "tcp_chain",
@@ -318,13 +367,15 @@ func (s *TCPChainSource) handleConn(conn net.Conn) {
meta["tls_peer_cn"] = cn meta["tls_peer_cn"] = cn
} }
} }
ident.Apply(meta)
sess := s.proxy.CreateSession(remote, meta) sess := s.proxy.CreateSession(remote, meta)
sessID = sess.ID sessID = sess.ID
s.logger.Info("msg", "Chain connection established", s.logger.Info("msg", "Chain connection established",
"component", "tcp_chain_source", "component", "tcp_chain_source",
"remote_addr", remote, "remote_addr", remote,
"node", connNode) "node", connNode,
"auth_identity", ident.Name)
idle := time.Duration(s.config.ReadTimeoutMS) * time.Millisecond idle := time.Duration(s.config.ReadTimeoutMS) * time.Millisecond
for { for {
@@ -348,7 +399,7 @@ func (s *TCPChainSource) handleConn(conn net.Conn) {
} }
s.proxy.UpdateActivity(sessID) s.proxy.UpdateActivity(sessID)
entry, err := chain.DecodeEntry(line, connNode, s.config.TrustNode) entry, err := chain.DecodeEntry(line, connNode, trustEntryNode)
if err != nil { if err != nil {
s.parseErrors.Add(1) s.parseErrors.Add(1)
s.logger.Debug("msg", "Dropped malformed chain entry", s.logger.Debug("msg", "Dropped malformed chain entry",
+35 -1
View File
@@ -94,12 +94,46 @@ func Client(o *config.TLSOptions, host string) (*tls.Config, error) {
return cfg, nil return cfg, nil
} }
// Identity modes for PeerIdentity, mirroring the auth.identity config values.
// Validity is enforced at policy construction in internal/authz.
const (
IdentityCN = "cn"
IdentitySANDNS = "san_dns"
IdentitySANURI = "san_uri"
IdentitySANEmail = "san_email"
)
// PeerCN returns the subject CN of the verified peer leaf, "" if none // PeerCN returns the subject CN of the verified peer leaf, "" if none
func PeerCN(cs tls.ConnectionState) string { func PeerCN(cs tls.ConnectionState) string {
return PeerIdentity(cs, IdentityCN)
}
// PeerIdentity returns the field named by mode from the verified peer leaf,
// "" when the certificate does not carry it or mode is unknown. The chain,
// signature, and validity window are already checked by the handshake, so
// this is pure field selection.
func PeerIdentity(cs tls.ConnectionState, mode string) string {
if len(cs.PeerCertificates) == 0 { if len(cs.PeerCertificates) == 0 {
return "" return ""
} }
return cs.PeerCertificates[0].Subject.CommonName leaf := cs.PeerCertificates[0]
switch mode {
case "", IdentityCN:
return leaf.Subject.CommonName
case IdentitySANDNS:
if len(leaf.DNSNames) > 0 {
return leaf.DNSNames[0]
}
case IdentitySANURI:
if len(leaf.URIs) > 0 {
return leaf.URIs[0].String()
}
case IdentitySANEmail:
if len(leaf.EmailAddresses) > 0 {
return leaf.EmailAddresses[0]
}
}
return ""
} }
// HTTPErrorLog adapts the structured logger for http.Server.ErrorLog so TLS // HTTPErrorLog adapts the structured logger for http.Server.ErrorLog so TLS
+4 -4
View File
@@ -222,14 +222,14 @@ check() { # label condition_result
tcp_out="$(tcp_read "$PORT_TCP_SINK" 4)" tcp_out="$(tcp_read "$PORT_TCP_SINK" 4)"
#nt=$(grep -c '"node":"edge-tcp"' <<< "$tcp_out") #nt=$(grep -c '"node":"edge-tcp"' <<< "$tcp_out")
#nh=$(grep -c '"node":"edge-http"' <<< "$tcp_out") #nh=$(grep -c '"node":"edge-http"' <<< "$tcp_out")
nt=$(grep -c '"source":"edge-tcp/' <<< "$tcp_out") nt=$(grep -c 'edge-tcp/' <<< "$tcp_out")
nh=$(grep -c '"source":"edge-http/' <<< "$tcp_out") nh=$(grep -c 'edge-http/' <<< "$tcp_out")
check "tcp sink: aggregated edge-tcp ($nt) + edge-http ($nh)" $(( nt >= 1 && nh >= 1 )) check "tcp sink: aggregated edge-tcp ($nt) + edge-http ($nh)" $(( nt >= 1 && nh >= 1 ))
# 2. HTTP chain: edge-http -> relay -> SSE sink # 2. HTTP chain: edge-http -> relay -> SSE sink
sse_out="$(curl -sN --max-time 4 "http://127.0.0.1:$PORT_HTTP_SINK/stream" || true)" sse_out="$(curl -sN --max-time 4 "http://127.0.0.1:$PORT_HTTP_SINK/stream" || true)"
nt=$(grep -c '^data:.*"node":"edge-tcp"' <<< "$sse_out") nt=$(grep -c '^data:.*edge-tcp/' <<< "$sse_out")
nh=$(grep -c '^data:.*"node":"edge-http"' <<< "$sse_out") nh=$(grep -c '^data:.*edge-http/' <<< "$sse_out")
check "http sink: aggregated edge-tcp ($nt) + edge-http ($nh)" $(( nt >= 1 && nh >= 1 )) check "http sink: aggregated edge-tcp ($nt) + edge-http ($nh)" $(( nt >= 1 && nh >= 1 ))
# 3. HTTP sink status endpoint # 3. HTTP sink status endpoint
+2 -2
View File
@@ -224,12 +224,12 @@ check() { # label condition_result
# 1. TCP chain: edge-tcp -> relay -> tcp sink # 1. TCP chain: edge-tcp -> relay -> tcp sink
tcp_out="$(tcp_read "$PORT_TCP_SINK" 4)" tcp_out="$(tcp_read "$PORT_TCP_SINK" 4)"
#n=$(grep -c '"node":"edge-tcp"' <<< "$tcp_out") #n=$(grep -c '"node":"edge-tcp"' <<< "$tcp_out")
n=$(grep -c '"source":"edge-tcp/' <<< "$tcp_out") n=$(grep -c 'edge-tcp/' <<< "$tcp_out")
check "tcp path: entries on :$PORT_TCP_SINK with node=edge-tcp ($n lines)" $(( n >= 1 )) check "tcp path: entries on :$PORT_TCP_SINK with node=edge-tcp ($n lines)" $(( n >= 1 ))
# 2. HTTP chain: edge-http -> relay -> SSE sink # 2. HTTP chain: edge-http -> relay -> SSE sink
sse_out="$(curl -sN --max-time 4 "http://127.0.0.1:$PORT_HTTP_SINK/stream" || true)" sse_out="$(curl -sN --max-time 4 "http://127.0.0.1:$PORT_HTTP_SINK/stream" || true)"
n=$(grep -c '^data:.*"node":"edge-http"' <<< "$sse_out") n=$(grep -c '^data:.*edge-http/' <<< "$sse_out")
check "http path: SSE events on :$PORT_HTTP_SINK with node=edge-http ($n events)" $(( n >= 1 )) check "http path: SSE events on :$PORT_HTTP_SINK with node=edge-http ($n events)" $(( n >= 1 ))
# 3. HTTP sink status endpoint # 3. HTTP sink status endpoint
+513
View File
@@ -0,0 +1,513 @@
#!/usr/bin/env bash
# logwisp mTLS authentication test
#
# Scenario 1 — chained instances, client authenticates with mTLS:
# edge-01 cert --> tcp_chain sink --> :15811 tcp_chain src --> file sink
# edge-01 cert --> http_chain sink --> :15812 http_chain src --> file sink
# edge-99 cert --> tcp_chain sink --> :15811 rejected by the allow list
#
# Scenario 2 — a viewer client reads a streaming sink over mTLS:
# viewer-01 cert --> :15813 tcp sink (openssl s_client)
# viewer-01 cert --> :15814 http sink (curl, /stream and /status)
# rogue cert --> both, rejected by the allow list
#
# Also covers: node binding (a peer holding the edge-01 certificate cannot
# label its entries anything else), dialer-side server identity pinning, and
# a peer presenting no certificate at all.
#
# Usage:
# ./mtls-chain-test.sh manual mode: relay + edges up, guide printed
# ./mtls-chain-test.sh --auto automated checks and teardown
# ./mtls-chain-test.sh --keep (with --auto) skip teardown on success
#
# Requires: bash 5+, coreutils (timeout), openssl, curl. Linux dev host only.
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BIN="${LOGWISP_BIN:-$SCRIPT_DIR/../bin/logwisp}"
RUN="$SCRIPT_DIR/run-mtls"
CONF="$RUN/conf"
LOG="$RUN/log"
PKI="$RUN/pki"
OUT="$RUN/out"
PORT_TCP_CHAIN=15811
PORT_HTTP_CHAIN=15812
PORT_TCP_SINK=15813
PORT_HTTP_SINK=15814
AUTO=0; KEEP=0
for a in "$@"; do case "$a" in
--auto) AUTO=1 ;;
--keep) KEEP=1 ;;
*) echo "unknown arg: $a" >&2; exit 1 ;;
esac; done
PIDS=()
cleanup() {
local rc=$?
trap - EXIT INT TERM
if (( ${#PIDS[@]} )); then
echo "--- teardown: stopping ${#PIDS[@]} daemon(s)"
kill -TERM "${PIDS[@]}" 2>/dev/null
local deadline=$(( SECONDS + 10 ))
for pid in "${PIDS[@]}"; do
while kill -0 "$pid" 2>/dev/null && (( SECONDS < deadline )); do sleep 0.2; done
kill -KILL "$pid" 2>/dev/null
done
fi
exit "$rc"
}
trap cleanup EXIT INT TERM
port_open() { (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null && exec 3>&-; }
wait_port() { # port timeout_s
local i; for (( i=0; i < $2 * 10; i++ )); do
port_open "$1" && return 0
sleep 0.1
done
return 1
}
start_daemon() { # name conf
"$BIN" -c "$CONF/$2" > "$LOG/$1.out" 2>&1 &
PIDS+=($!)
echo "started $1 (pid $!)"
}
# --- Preflight ---
[[ -x "$BIN" ]] || { echo "binary not found: $BIN (build: go build -o bin/logwisp ./cmd/logwisp)" >&2; exit 1; }
command -v openssl >/dev/null || { echo "openssl not found" >&2; exit 1; }
command -v curl >/dev/null || { echo "curl not found" >&2; exit 1; }
for p in $PORT_TCP_CHAIN $PORT_HTTP_CHAIN $PORT_TCP_SINK $PORT_HTTP_SINK; do
port_open "$p" && { echo "port $p already in use" >&2; exit 1; }
done
rm -rf "$RUN"
mkdir -p "$CONF" "$LOG" "$PKI" "$OUT"
# --- PKI ---
# One CA for every peer: the point of the test is that CA membership alone is
# no longer sufficient, so the identities must all be issued by the same CA.
gen_key() { openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "$1" 2>/dev/null; }
gen_leaf() { # name CN eku [SAN]
local name=$1 cn=$2 eku=$3 san=${4:-}
gen_key "$PKI/$name.key"
openssl req -new -key "$PKI/$name.key" -out "$PKI/$name.csr" -subj "/CN=$cn" 2>/dev/null
local ext="extendedKeyUsage=$eku"
[[ -n $san ]] && ext+=$'\n'"subjectAltName=$san"
printf '%s\n' "$ext" > "$PKI/$name.ext"
openssl x509 -req -in "$PKI/$name.csr" -CA "$PKI/ca.crt" -CAkey "$PKI/ca.key" \
-CAcreateserial -out "$PKI/$name.crt" -days 2 -extfile "$PKI/$name.ext" 2>/dev/null
}
echo "--- generating test PKI in $PKI"
gen_key "$PKI/ca.key"
openssl req -x509 -new -key "$PKI/ca.key" -days 2 -out "$PKI/ca.crt" \
-subj "/CN=LogWisp Test CA" 2>/dev/null
gen_leaf relay relay.internal serverAuth "IP:127.0.0.1,DNS:relay.internal"
gen_leaf edge-01 edge-01 clientAuth
gen_leaf edge-99 edge-99 clientAuth
gen_leaf viewer-01 viewer-01 clientAuth
gen_leaf rogue rogue-viewer clientAuth
[[ -s "$PKI/rogue.crt" ]] || { echo "PKI generation failed" >&2; exit 1; }
# --- Config generation ---
# Relay: both ingest ports authorize edge-01 only and bind the node label to
# the certificate identity; both streaming sinks authorize viewer-01 only.
cat > "$CONF/relay.toml" <<EOF
status_reporter = false
[logging]
output = "stdout"
level = "info"
[[pipelines]]
name = "relay_tcp"
[pipelines.flow.format]
type = "json"
sanitizer_policy = "json"
[[pipelines.plugin_sources]]
id = "in_tcp"
type = "tcp_chain"
[pipelines.plugin_sources.config]
host = "127.0.0.1"
port = $PORT_TCP_CHAIN
[pipelines.plugin_sources.config.tls]
enabled = true
cert_file = "$PKI/relay.crt"
key_file = "$PKI/relay.key"
client_auth = true
client_ca_file = "$PKI/ca.crt"
[pipelines.plugin_sources.config.auth]
type = "mtls"
identity = "cn"
allow = ["edge-01"]
node_binding = "force"
[[pipelines.plugin_sinks]]
id = "file_tcp"
type = "file"
[pipelines.plugin_sinks.config]
directory = "$OUT"
name = "tcp_chain"
flush_interval_ms = 200
[[pipelines.plugin_sinks]]
id = "out_tcp"
type = "tcp"
[pipelines.plugin_sinks.config]
host = "127.0.0.1"
port = $PORT_TCP_SINK
[pipelines.plugin_sinks.config.tls]
enabled = true
cert_file = "$PKI/relay.crt"
key_file = "$PKI/relay.key"
client_auth = true
client_ca_file = "$PKI/ca.crt"
[pipelines.plugin_sinks.config.auth]
type = "mtls"
allow = ["viewer-01"]
[[pipelines]]
name = "relay_http"
[pipelines.flow.format]
type = "json"
sanitizer_policy = "json"
[[pipelines.plugin_sources]]
id = "in_http"
type = "http_chain"
[pipelines.plugin_sources.config]
host = "127.0.0.1"
port = $PORT_HTTP_CHAIN
[pipelines.plugin_sources.config.tls]
enabled = true
cert_file = "$PKI/relay.crt"
key_file = "$PKI/relay.key"
client_auth = true
client_ca_file = "$PKI/ca.crt"
[pipelines.plugin_sources.config.auth]
type = "mtls"
allow = ["edge-01"]
node_binding = "force"
[[pipelines.plugin_sinks]]
id = "file_http"
type = "file"
[pipelines.plugin_sinks.config]
directory = "$OUT"
name = "http_chain"
flush_interval_ms = 200
[[pipelines.plugin_sinks]]
id = "out_http"
type = "http"
[pipelines.plugin_sinks.config]
host = "127.0.0.1"
port = $PORT_HTTP_SINK
[pipelines.plugin_sinks.config.tls]
enabled = true
cert_file = "$PKI/relay.crt"
key_file = "$PKI/relay.key"
client_auth = true
client_ca_file = "$PKI/ca.crt"
[pipelines.plugin_sinks.config.auth]
type = "mtls"
allow = ["viewer-01"]
EOF
# edge_tcp holds the edge-01 certificate but declares node "edge-tcp":
# node_binding = "force" must relabel its entries to "edge-01".
cat > "$CONF/edge_tcp.toml" <<EOF
status_reporter = false
[logging]
output = "file"
level = "info"
[logging.file]
directory = "$LOG"
name = "edge_tcp"
[[pipelines]]
name = "edge_tcp"
[[pipelines.plugin_sources]]
id = "rand"
type = "random"
[pipelines.plugin_sources.config]
interval_ms = 200
format = "txt"
length = 24
[[pipelines.plugin_sinks]]
id = "to_relay"
type = "tcp_chain"
[pipelines.plugin_sinks.config]
host = "127.0.0.1"
port = $PORT_TCP_CHAIN
node = "edge-tcp"
[pipelines.plugin_sinks.config.tls]
enabled = true
ca_file = "$PKI/ca.crt"
cert_file = "$PKI/edge-01.crt"
key_file = "$PKI/edge-01.key"
[pipelines.plugin_sinks.config.auth]
type = "mtls"
allow = ["relay.internal"]
EOF
cat > "$CONF/edge_http.toml" <<EOF
status_reporter = false
[logging]
output = "file"
level = "info"
[logging.file]
directory = "$LOG"
name = "edge_http"
[[pipelines]]
name = "edge_http"
[[pipelines.plugin_sources]]
id = "rand"
type = "random"
[pipelines.plugin_sources.config]
interval_ms = 200
format = "txt"
length = 24
[[pipelines.plugin_sinks]]
id = "to_relay"
type = "http_chain"
[pipelines.plugin_sinks.config]
host = "127.0.0.1"
port = $PORT_HTTP_CHAIN
node = "edge-http"
flush_interval_ms = 500
[pipelines.plugin_sinks.config.tls]
enabled = true
ca_file = "$PKI/ca.crt"
cert_file = "$PKI/edge-01.crt"
key_file = "$PKI/edge-01.key"
[pipelines.plugin_sinks.config.auth]
type = "mtls"
allow = ["relay.internal"]
EOF
# edge_rogue holds a CA-issued certificate the relay does not authorize, and
# claims to be edge-01 on top of it.
cat > "$CONF/edge_rogue.toml" <<EOF
status_reporter = false
[logging]
output = "file"
level = "info"
[logging.file]
directory = "$LOG"
name = "edge_rogue"
[[pipelines]]
name = "edge_rogue"
[[pipelines.plugin_sources]]
id = "rand"
type = "random"
[pipelines.plugin_sources.config]
interval_ms = 200
format = "txt"
length = 24
[[pipelines.plugin_sinks]]
id = "to_relay"
type = "tcp_chain"
[pipelines.plugin_sinks.config]
host = "127.0.0.1"
port = $PORT_TCP_CHAIN
node = "edge-01"
[pipelines.plugin_sinks.config.tls]
enabled = true
ca_file = "$PKI/ca.crt"
cert_file = "$PKI/edge-99.crt"
key_file = "$PKI/edge-99.key"
EOF
# edge_pinfail pins a server identity the relay does not have: the dialer must
# refuse the handshake even though the certificate chains to the trusted CA.
cat > "$CONF/edge_pinfail.toml" <<EOF
status_reporter = false
[logging]
output = "stdout"
level = "debug"
[[pipelines]]
name = "edge_pinfail"
[[pipelines.plugin_sources]]
id = "rand"
type = "random"
[pipelines.plugin_sources.config]
interval_ms = 200
format = "txt"
length = 24
[[pipelines.plugin_sinks]]
id = "to_relay"
type = "tcp_chain"
[pipelines.plugin_sinks.config]
host = "127.0.0.1"
port = $PORT_TCP_CHAIN
node = "edge-pinfail"
backoff_max_ms = 1000
[pipelines.plugin_sinks.config.tls]
enabled = true
ca_file = "$PKI/ca.crt"
cert_file = "$PKI/edge-01.crt"
key_file = "$PKI/edge-01.key"
[pipelines.plugin_sinks.config.auth]
type = "mtls"
allow = ["some-other-relay.internal"]
EOF
# --- Guide ---
cat <<EOF
================================================================
logwisp mTLS auth test — port map
$PORT_TCP_CHAIN relay ingest (tcp_chain, mTLS, allow = edge-01)
$PORT_HTTP_CHAIN relay ingest (http_chain, mTLS, allow = edge-01)
$PORT_TCP_SINK TCP sink (mTLS, allow = viewer-01)
$PORT_HTTP_SINK HTTP sink (mTLS, allow = viewer-01)
Read the TCP sink as an authorized viewer:
openssl s_client -quiet -connect 127.0.0.1:$PORT_TCP_SINK \\
-CAfile $PKI/ca.crt -cert $PKI/viewer-01.crt -key $PKI/viewer-01.key
Read the HTTP sink:
curl -N --noproxy '*' --cacert $PKI/ca.crt \\
--cert $PKI/viewer-01.crt --key $PKI/viewer-01.key \\
https://127.0.0.1:$PORT_HTTP_SINK/stream
Swap in rogue.crt/rogue.key for either and the policy refuses it.
Ingested entries land in $OUT/ (node label forced to the certificate CN).
Logs: $LOG/
================================================================
EOF
# --- Startup ---
start_daemon relay relay.toml
for p in $PORT_TCP_CHAIN $PORT_HTTP_CHAIN $PORT_TCP_SINK $PORT_HTTP_SINK; do
wait_port "$p" 10 || { echo "FAIL: relay port $p not listening (see $LOG/relay.out)"; exit 1; }
done
start_daemon edge_tcp edge_tcp.toml
start_daemon edge_http edge_http.toml
start_daemon edge_rogue edge_rogue.toml
start_daemon edge_pinfail edge_pinfail.toml
if (( AUTO == 0 )); then
echo "--- daemons running; Ctrl-C to stop"
while :; do sleep 1; done
fi
echo "--- settling 4s (connect + first http_chain flush)"
sleep 4
fail=0
check() { # label condition_result
if (( $2 )); then echo "PASS: $1"; else echo "FAIL: $1"; fail=1; fi
}
# Viewer helpers
tcp_view() { # cert_basename secs
timeout "$2" openssl s_client -quiet \
-connect 127.0.0.1:$PORT_TCP_SINK -CAfile "$PKI/ca.crt" \
-cert "$PKI/$1.crt" -key "$PKI/$1.key" </dev/null 2>/dev/null || true
}
http_get() { # path cert_basename|"" -> "<http_code>|<body>"
local path=$1 name=${2:-}
local args=(-s -o /dev/null -w '%{http_code}' --max-time 5 --noproxy '*'
--cacert "$PKI/ca.crt")
[[ -n $name ]] && args+=(--cert "$PKI/$name.crt" --key "$PKI/$name.key")
curl "${args[@]}" "https://127.0.0.1:$PORT_HTTP_SINK$path" 2>/dev/null || true
}
relay_log="$LOG/relay.out"
ingested() { cat "$OUT"/${1}* 2>/dev/null; }
echo "=== Scenario 1: chained instances over mTLS ==="
# 1. An authorized edge delivers entries into the relay's file sink
tcp_file="$(ingested tcp_chain)"
n=$(grep -c 'edge-01/' <<< "$tcp_file")
check "tcp_chain: authorized edge-01 entries reached the file sink ($n lines)" $(( n >= 1 ))
http_file="$(ingested http_chain)"
n=$(grep -c 'edge-01/' <<< "$http_file")
check "http_chain: authorized edge-01 entries reached the file sink ($n lines)" $(( n >= 1 ))
# 2. node_binding = "force" overrode the label the sender configured
n=$(grep -c 'edge-tcp/' <<< "$tcp_file")
check "node binding: sender's own label \"edge-tcp\" was not honored ($n lines)" $(( n == 0 ))
n=$(grep -c 'edge-http/' <<< "$http_file")
check "node binding: sender's own label \"edge-http\" was not honored ($n lines)" $(( n == 0 ))
# 3. An identity outside the allow list is refused, even claiming to be edge-01
n=$(grep -c 'Connection rejected by auth policy' "$relay_log")
check "allow list: unauthorized edge-99 connection rejected ($n rejections)" $(( n >= 1 ))
n=$(grep -c 'edge-99' <<< "$tcp_file")
check "allow list: no edge-99 entry was ingested" $(( n == 0 ))
# 4. A peer with no certificate cannot complete the handshake
timeout 5 openssl s_client -connect 127.0.0.1:$PORT_TCP_CHAIN \
-CAfile "$PKI/ca.crt" </dev/null >/dev/null 2>&1
sleep 0.5
n=$(grep -c 'TLS handshake failed' "$relay_log")
check "client_auth: a peer with no certificate was refused ($n handshake errors)" $(( n >= 1 ))
# 5. Dialer-side pinning: the relay's identity is not the one edge_pinfail pins
n=$(grep -c 'is not allowed' "$LOG/edge_pinfail.out")
check "server pinning: dialer refused a CA-valid server it does not pin ($n refusals)" $(( n >= 1 ))
n=$(grep -c 'edge-pinfail' <<< "$tcp_file")
check "server pinning: pin-failing edge delivered nothing" $(( n == 0 ))
echo "=== Scenario 2: viewer clients on mTLS-gated sinks ==="
# 6. TCP sink: authorized viewer streams, rogue gets nothing
out="$(tcp_view viewer-01 4)"
n=$(grep -c 'edge-01/' <<< "$out")
check "tcp sink: viewer-01 streamed entries ($n lines)" $(( n >= 1 ))
out="$(tcp_view rogue 4)"
n=$(grep -c '"message"' <<< "$out")
check "tcp sink: rogue viewer received no entries" $(( n == 0 ))
# 7. HTTP sink: stream and status both gated
code="$(http_get /status viewer-01)"
check "http sink: /status served to viewer-01 (HTTP $code)" $([[ $code == 200 ]] && echo 1 || echo 0)
code="$(http_get /status rogue)"
check "http sink: /status refused to rogue viewer (HTTP $code)" $([[ $code == 403 ]] && echo 1 || echo 0)
code="$(http_get /stream rogue)"
check "http sink: /stream refused to rogue viewer (HTTP $code)" $([[ $code == 403 ]] && echo 1 || echo 0)
# curl reports 000 when the handshake itself fails, which is what a client
# with no certificate must hit
code="$(http_get /status)"
check "http sink: client with no certificate failed the handshake (curl $code)" \
$([[ $code == 000 ]] && echo 1 || echo 0)
sse="$(timeout 4 curl -sN --noproxy '*' --cacert "$PKI/ca.crt" \
--cert "$PKI/viewer-01.crt" --key "$PKI/viewer-01.key" \
"https://127.0.0.1:$PORT_HTTP_SINK/stream" 2>/dev/null || true)"
n=$(grep -c '^data:.*edge-01/' <<< "$sse")
check "http sink: viewer-01 received SSE events ($n events)" $(( n >= 1 ))
# 8. The status endpoint reports the policy and its rejection count
status="$(curl -s --max-time 5 --noproxy '*' --cacert "$PKI/ca.crt" \
--cert "$PKI/viewer-01.crt" --key "$PKI/viewer-01.key" \
"https://127.0.0.1:$PORT_HTTP_SINK/status" 2>/dev/null || true)"
n=$(grep -c 'mtls' <<< "$status")
check "http sink: status endpoint reports the auth policy" $(( n >= 1 ))
rej=$(grep -o '"auth_rejected"[ :]*[0-9]*' <<< "$status" | grep -o '[0-9]*$' || echo 0)
check "http sink: status endpoint counts auth rejections (auth_rejected=$rej)" $(( rej >= 1 ))
echo "================================================================"
if (( fail == 0 )); then
echo "RESULT: ALL PASS"
(( KEEP )) && { echo "--keep: daemons left running (pids: ${PIDS[*]})"; PIDS=(); }
else
echo "RESULT: FAILURES — inspect $LOG/*.out and $LOG/*.log"
fi
exit "$fail"