v0.17.0 mtls added to network chain, sinks, and sources
This commit is contained in:
@@ -14,3 +14,4 @@ build.sh
|
||||
catalog.txt
|
||||
combined.txt
|
||||
test/run/
|
||||
test/run-mtls/
|
||||
|
||||
@@ -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
|
||||
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
|
||||
- Mutual TLS: listeners can require and verify client certificates; dialers can
|
||||
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
|
||||
issued is accepted, and the peer's Common Name is recorded but not used for
|
||||
authorization. See [Security](doc/security.md) for the exact boundary and
|
||||
[the mTLS authentication plan](doc/mtls-auth-plan.md) for the proposed work.
|
||||
Password, token, and SCRAM authentication were removed during the restructure
|
||||
and are not currently available.
|
||||
See [Security](doc/security.md) for configuration and the exact boundary, and
|
||||
the [mTLS authentication design](doc/mtls-auth-plan.md) for the rationale and
|
||||
what is deliberately left out. Password, token, and SCRAM authentication were
|
||||
removed during the restructure and are not currently available.
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -86,8 +89,8 @@ and are not currently available.
|
||||
| [Formatters](doc/formatters.md) | Output shaping and sanitization |
|
||||
| [Chaining](doc/chaining.md) | Multi-node topologies and the chain wire protocol |
|
||||
| [Networking](doc/networking.md) | Listeners, dialers, timeouts, connection limits |
|
||||
| [Security](doc/security.md) | TLS and mTLS configuration, threat model, current limits |
|
||||
| [mTLS Authentication Plan](doc/mtls-auth-plan.md) | Design for certificate-based authorization |
|
||||
| [Security](doc/security.md) | TLS, mTLS, and peer authorization; threat model and current limits |
|
||||
| [mTLS Authentication](doc/mtls-auth-plan.md) | Design and rationale for certificate-based authorization |
|
||||
| [CLI](doc/cli.md) | Flags, signals, exit codes |
|
||||
| [Operations](doc/operations.md) | Running, monitoring, tuning, troubleshooting |
|
||||
|
||||
|
||||
+63
-4
@@ -6,10 +6,11 @@
|
||||
### Commented values are the built-in defaults unless marked "example".
|
||||
### 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
|
||||
### 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
|
||||
### (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)
|
||||
### 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
|
||||
### recorded but not used for authorization. See doc/security.md.
|
||||
### TLS alone is a CA membership check: ANY certificate the CA signed is
|
||||
### 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_ca_file = "/etc/logwisp/tls/client-ca.crt"
|
||||
# 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)
|
||||
# [[pipelines.plugin_sources]]
|
||||
@@ -185,6 +216,12 @@ check_interval_ms = 100 # Directory rescan interval (min 10)
|
||||
# key_file = "/etc/logwisp/tls/server.key"
|
||||
# client_auth = true
|
||||
# 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)
|
||||
@@ -237,6 +274,11 @@ target = "stdout" # stdout|stderr ("split" NOT supported)
|
||||
# key_file = "/etc/logwisp/tls/server.key"
|
||||
# client_auth = false
|
||||
# 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)
|
||||
# [[pipelines.plugin_sinks]]
|
||||
@@ -255,6 +297,13 @@ target = "stdout" # stdout|stderr ("split" NOT supported)
|
||||
# enabled = true # example
|
||||
# cert_file = "/etc/logwisp/tls/server.crt"
|
||||
# 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)
|
||||
## 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"
|
||||
# key_file = "/etc/logwisp/tls/client.key"
|
||||
# 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)
|
||||
# [[pipelines.plugin_sinks]]
|
||||
@@ -303,3 +357,8 @@ target = "stdout" # stdout|stderr ("split" NOT supported)
|
||||
# ca_file = "/etc/logwisp/tls/ca.crt"
|
||||
# cert_file = "/etc/logwisp/tls/client.crt"
|
||||
# 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
@@ -18,8 +18,8 @@ streams, or downstream LogWisp nodes.
|
||||
| [Formatters](formatters.md) | Output shaping and sanitization |
|
||||
| [Chaining](chaining.md) | Multi-node topologies and the chain wire protocol |
|
||||
| [Networking](networking.md) | Listeners, dialers, timeouts, connection limits |
|
||||
| [Security](security.md) | TLS and mTLS configuration, threat model, current limits |
|
||||
| [mTLS Authentication Plan](mtls-auth-plan.md) | Design for certificate-based authorization |
|
||||
| [Security](security.md) | TLS, mTLS, and peer authorization; threat model and current limits |
|
||||
| [mTLS Authentication](mtls-auth-plan.md) | Design and rationale for certificate-based authorization |
|
||||
| [CLI](cli.md) | Flags, signals, exit codes |
|
||||
| [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
|
||||
- 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
|
||||
- Mutual TLS: listeners can require and verify client certificates; dialers can
|
||||
present a client identity. See [Security](security.md) for what this does and
|
||||
does not currently give you.
|
||||
present a client identity
|
||||
- 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
|
||||
|
||||
|
||||
+7
-4
@@ -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`
|
||||
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
|
||||
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
|
||||
HTTP sink's broker treats a vanished session as an eviction signal and closes
|
||||
the corresponding SSE client.
|
||||
|
||||
Session metadata is currently bookkeeping only: nothing in the pipeline makes
|
||||
an authorization decision from it. Closing that gap is the subject of the
|
||||
[mTLS authentication plan](mtls-auth-plan.md).
|
||||
Authorization decisions do not read session metadata — they are made from the
|
||||
handshake by `internal/authz`, at the point of connection or request, and their
|
||||
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
|
||||
|
||||
|
||||
+30
-6
@@ -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
|
||||
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:
|
||||
`edge-01/app.log`. In JSON output the node therefore appears inside the source
|
||||
field, not as a separate top-level key.
|
||||
|
||||
> `trust_node = true` means an authenticated peer can claim **any** node label,
|
||||
> including one belonging to another host. On an untrusted network use
|
||||
> `trust_node = false`, or read the
|
||||
> [mTLS authentication plan](mtls-auth-plan.md), which proposes binding the
|
||||
> label to the peer's certificate identity.
|
||||
> `trust_node = true` with no `auth` block means any peer the CA vouches for can
|
||||
> claim **any** node label, including one belonging to another host. On an
|
||||
> untrusted network set `auth.type = "mtls"` with `node_binding = "force"`;
|
||||
> `trust_node = false` is the fallback when certificates are not an option.
|
||||
|
||||
## Wire Protocol
|
||||
|
||||
@@ -150,6 +164,9 @@ enabled = true
|
||||
ca_file = "/etc/logwisp/tls/ca.crt"
|
||||
cert_file = "/etc/logwisp/tls/edge-01.crt"
|
||||
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:
|
||||
@@ -172,13 +189,16 @@ type = "tcp_chain"
|
||||
[pipelines.plugin_sources.config]
|
||||
host = "0.0.0.0"
|
||||
port = 15801
|
||||
trust_node = true
|
||||
[pipelines.plugin_sources.config.tls]
|
||||
enabled = true
|
||||
cert_file = "/etc/logwisp/tls/relay.crt"
|
||||
key_file = "/etc/logwisp/tls/relay.key"
|
||||
client_auth = true
|
||||
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]]
|
||||
id = "archive"
|
||||
@@ -195,6 +215,10 @@ host = "127.0.0.1"
|
||||
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
|
||||
|
||||
- **Formatting is a relay decision.** Because chain links carry structured
|
||||
|
||||
+219
-214
@@ -1,46 +1,50 @@
|
||||
# Implementation Plan: mTLS as Authentication
|
||||
# mTLS as Authentication
|
||||
|
||||
**Status:** proposal, not implemented.
|
||||
**Scope:** turn the existing transport-level mutual TLS into a real
|
||||
authentication and authorization mechanism.
|
||||
**Status:** implemented. Phases 1–3 of the original proposal, plus dialer-side
|
||||
server identity pinning from phase 4, are in the tree and covered by
|
||||
`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
|
||||
|
||||
LogWisp already does mutual TLS at the transport layer. A listener with
|
||||
`client_auth = true` refuses any peer that cannot present a certificate
|
||||
chaining to `client_ca_file`, and `internal/tlsx` already extracts the peer's
|
||||
Common Name and stashes it in session metadata as `tls_peer_cn`.
|
||||
LogWisp already did mutual TLS at the transport layer. A listener with
|
||||
`client_auth = true` refused any peer that could not present a certificate
|
||||
chaining to `client_ca_file`, and `internal/tlsx` extracted the peer's Common
|
||||
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:
|
||||
|
||||
1. **No per-identity authorization.** Every certificate the CA issues is
|
||||
equivalent. There is no way to say "only `edge-01` and `edge-02` may write to
|
||||
this ingest port", so one CA cannot serve several trust domains, and
|
||||
withdrawing one peer means rotating the CA bundle for all of them.
|
||||
2. **Node labels are unauthenticated.** With `trust_node = true` (the default) a
|
||||
peer declares its own `node` label in the chain hello or the
|
||||
`X-Logwisp-Node` header. Any certificate holder can claim any label,
|
||||
including another host's, and every downstream consumer will attribute those
|
||||
entries accordingly. The only current defence, `trust_node = false`, replaces
|
||||
1. **No per-identity authorization.** Every certificate the CA issued was
|
||||
equivalent. There was no way to say "only `edge-01` and `edge-02` may write
|
||||
to this ingest port", so one CA could not serve several trust domains, and
|
||||
withdrawing one peer meant rotating the CA bundle for all of them.
|
||||
2. **Node labels were unauthenticated.** With `trust_node = true` (the default)
|
||||
a peer declares its own `node` label in the chain hello or the
|
||||
`X-Logwisp-Node` header. Any certificate holder could claim any label,
|
||||
including another host's, and every downstream consumer would attribute
|
||||
those entries accordingly. The only defence, `trust_node = false`, replaced
|
||||
the label with a remote address — unforgeable, but useless for identifying a
|
||||
host behind NAT or a load balancer.
|
||||
3. **The `http` sink has no authentication at all**, even with TLS on. Its
|
||||
stream and status endpoints are readable by anyone who can reach the port.
|
||||
3. **The `http` sink had no authentication at all**, even with TLS on. Its
|
||||
stream and status endpoints were readable by anyone who could reach the port.
|
||||
|
||||
Password, token, and SCRAM authentication were removed during the plugin/flow
|
||||
restructure and the move to standard-library networking. Certificates are the
|
||||
one credential the current transport already carries, which makes mTLS the
|
||||
cheapest path back to authenticated peers.
|
||||
one credential the transport already carries, which made mTLS the cheapest path
|
||||
back to authenticated peers.
|
||||
|
||||
## 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
|
||||
attribution is trustworthy.
|
||||
- Gate the `http` sink's endpoints on client certificates.
|
||||
- Make identity visible in sessions, statistics, and logs.
|
||||
- Change nothing for existing configurations that omit the new block.
|
||||
attribution is trustworthy. ✅
|
||||
- Gate the `http` sink's endpoints on client certificates. ✅
|
||||
- Make identity visible in sessions, statistics, and logs. ✅
|
||||
- Change nothing for existing configurations that omit the new block. ✅
|
||||
|
||||
## Non-Goals
|
||||
|
||||
@@ -50,7 +54,7 @@ cheapest path back to authenticated peers.
|
||||
structs) stay reserved.
|
||||
- IP allow/deny lists and per-peer rate limits. Related, but a separate feature
|
||||
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)
|
||||
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
|
||||
leaf certificate. Because `tls.RequireAndVerifyClientCert` has already validated
|
||||
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 |
|
||||
|-----------------|--------|-------|
|
||||
@@ -73,15 +77,15 @@ pure field selection.
|
||||
An empty identity is a rejection, not an empty match: a certificate with no
|
||||
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.
|
||||
|
||||
### Configuration
|
||||
|
||||
A new `auth` table sits beside `tls` in every network plugin's `config`. Keeping
|
||||
it separate from `tls` matters: TLS answers "is this channel private and is the
|
||||
peer chained to a CA", auth answers "may *this* peer do *this*", and a later
|
||||
non-certificate method should be able to reuse the block.
|
||||
An `auth` table sits beside `tls` in every network plugin's `config`. Keeping it
|
||||
separate from `tls` matters: TLS answers "is this channel private and does the
|
||||
peer chain to a CA", auth answers "may *this* peer do *this*", and a later
|
||||
non-certificate method can reuse the block.
|
||||
|
||||
```toml
|
||||
[pipelines.plugin_sources.config.auth]
|
||||
@@ -94,63 +98,77 @@ node_binding = "force" # none | assert | force
|
||||
|
||||
| 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 |
|
||||
| `allow` | []string | `[]` | Exact identity matches |
|
||||
| `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
|
||||
identity the CA vouches for" — that is, today's behaviour, but with the identity
|
||||
now recorded and node binding available. It is a deliberate, documented default
|
||||
rather than a silent deny-all, and startup logs say so plainly.
|
||||
identity the CA vouches for" — that is, the pre-auth behaviour, but with the
|
||||
identity now recorded and node binding available. It is a deliberate, documented
|
||||
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
|
||||
declared:
|
||||
declared. Setting it on any other plugin is a configuration error.
|
||||
|
||||
| Value | Behaviour |
|
||||
|-------|-----------|
|
||||
| `none` | `trust_node` governs, as today |
|
||||
| `assert` | The declared label must equal the identity; a mismatch is rejected |
|
||||
| `force` | The declared label is ignored and the identity is used |
|
||||
| Value | Connection label | Per-entry `node` field |
|
||||
|-------|------------------|------------------------|
|
||||
| `none` | `trust_node` governs, as before | `trust_node` governs |
|
||||
| `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 | Overwritten with the identity |
|
||||
|
||||
`force` is the default under `type = "mtls"` because it is the only setting
|
||||
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.
|
||||
The split between `assert` and `force` is what makes both worth having:
|
||||
|
||||
For the `tcp` and `http` sinks, which have no node concept, `node_binding` is
|
||||
ignored.
|
||||
- **`force`** is for an ingest boundary that does not trust its peer. Every
|
||||
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
|
||||
to pin the *server's* identity beyond hostname verification. This is deferred to
|
||||
phase 4 and does nothing before then.
|
||||
to pin the *server's* identity beyond hostname verification. There
|
||||
`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
|
||||
|
||||
At plugin construction, before anything binds:
|
||||
|
||||
- `type = "mtls"` on a listener requires `tls.enabled = true` and
|
||||
`tls.client_auth = true`. Silently accepting an auth policy the transport
|
||||
cannot enforce is the failure mode worth designing out.
|
||||
`tls.client_auth = true`; on a dialer it requires `tls.enabled = true` and
|
||||
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.
|
||||
- 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`.
|
||||
|
||||
### New package: `internal/authz`
|
||||
### The `internal/authz` package
|
||||
|
||||
```go
|
||||
package authz
|
||||
|
||||
// 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
|
||||
// the tlsx.Server / tlsx.Client convention so callers can nil-check.
|
||||
func New(o *config.AuthOptions) (*Policy, error)
|
||||
// the tlsx.Server / tlsx.Client convention. tlsOpts is the sibling `tls`
|
||||
// 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.
|
||||
type Identity struct {
|
||||
@@ -158,211 +176,198 @@ type Identity struct {
|
||||
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.
|
||||
func (p *Policy) Authorize(cs *tls.ConnectionState) (Identity, error)
|
||||
|
||||
// ResolveNode applies node_binding to a declared label.
|
||||
func (p *Policy) ResolveNode(declared string, id Identity) (string, error)
|
||||
// VerifyConnection is assignable to tls.Config.VerifyConnection on a dialer.
|
||||
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.
|
||||
func (p *Policy) Stats() map[string]any
|
||||
```
|
||||
|
||||
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
|
||||
disabled case so every call site is a nil check rather than a branch on config.
|
||||
declarative config and a cross-cutting concern. `New` returns `(nil, nil)` for
|
||||
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
|
||||
|
||||
Each plugin already has the right spot, and in two cases the code says so.
|
||||
|
||||
**`tcp_chain` source** (`internal/source/tcpchain/tcpchain.go`, `handleConn`)
|
||||
|
||||
Today: handshake → read hello → decode → resolve node from `trust_node` →
|
||||
create session. Insert authorization between the handshake and the hello read,
|
||||
so an unauthorized peer never gets a preamble parsed on its behalf, and replace
|
||||
the node resolution with `Policy.ResolveNode`.
|
||||
|
||||
```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)
|
||||
```
|
||||
Handshake → **authorize** → read hello → `ResolveNode` → create session. The
|
||||
authorization sits between the handshake and the hello read, so an unauthorized
|
||||
peer never gets a preamble parsed on its behalf. `chain.DecodeEntry` is then
|
||||
called with `auth.TrustsEntryNode(trust_node)` rather than `trust_node` itself.
|
||||
|
||||
**`http_chain` source** (`internal/source/httpchain/httpchain.go`, `handleIngest`)
|
||||
|
||||
Per request, from `r.TLS`, before the body is read — an unauthorized sender
|
||||
should not get to stream 8 MiB into the process. Rejection is `403`, distinct
|
||||
from the `400` used for protocol errors, so a sender can tell "you are not
|
||||
allowed" from "your batch was malformed". `ResolveNode` then governs the
|
||||
`X-Logwisp-Node` header exactly as it governs the TCP hello.
|
||||
Per request, from `r.TLS`, before the body is read — an unauthorized sender does
|
||||
not get to stream `max_body_bytes` into the process. Rejection is `403`,
|
||||
distinct from the `400` used for protocol errors, so a sender can tell "you are
|
||||
not allowed" from "your batch was malformed". `ResolveNode` then governs the
|
||||
`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`)
|
||||
|
||||
There is already a comment marking the place: *"Password-auth extension point:
|
||||
preamble verification runs in handleConn post-handshake, pre-registration."*
|
||||
Authorization goes precisely there — after the explicit handshake, before the
|
||||
session is created and the client is registered — so an unauthorized peer never
|
||||
appears in the client map and never receives a broadcast.
|
||||
After the explicit handshake, before the 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
|
||||
credentials land, e.g. handler = authMiddleware(cfg)(handler)."* A middleware
|
||||
around the mux covers both the stream and the status endpoint with one wrapper,
|
||||
and keeps the handlers themselves unaware of authorization.
|
||||
A middleware around the mux covers both the stream and the status endpoint with
|
||||
one wrapper and keeps the handlers themselves unaware of authorization. The
|
||||
authorized identity is passed down through the request context for session
|
||||
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
|
||||
var handler http.Handler = mux
|
||||
if h.authPolicy != nil {
|
||||
handler = authMiddleware(h.authPolicy, h.logger)(handler)
|
||||
}
|
||||
```
|
||||
**`tcp_chain` / `http_chain` sinks** (dialers)
|
||||
|
||||
The middleware rejects with `403` and no body detail — the status endpoint
|
||||
leaks host, port, and throughput counters, so a rejection should not leak policy
|
||||
shape on top of it.
|
||||
The policy is installed as `tls.Config.VerifyConnection`, which runs after the
|
||||
standard chain and hostname checks. A server whose identity the policy rejects
|
||||
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
|
||||
|
||||
`core.CapAuth` is currently derived from `tlsConfig.ClientAuth`. It should
|
||||
reflect the auth policy instead:
|
||||
`core.CapAuth` now means "this plugin authorizes peers" — it is derived from the
|
||||
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
|
||||
if s.authPolicy != nil {
|
||||
caps = append(caps, core.CapAuth)
|
||||
}
|
||||
```
|
||||
|
||||
`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.
|
||||
`Pipeline.initSourceCapabilities` and `initSinkCapabilities` treat this as a
|
||||
cross-cutting check: a plugin advertising `CapAuth` without `CapTLS` is a
|
||||
contradiction and fails pipeline construction rather than starting.
|
||||
|
||||
### 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.
|
||||
|
||||
- **Session metadata** gains `auth_method` and `auth_identity` alongside the
|
||||
existing `tls` and `tls_peer_cn`.
|
||||
- **Statistics** gain `auth_enabled`, `auth_rejected`, and `node_binding` in the
|
||||
`details` map of every affected source and sink, so rejections show up in the
|
||||
status reporter and the `http` sink's status endpoint.
|
||||
- **Logs** record a WARN per rejection with the remote address, the extracted
|
||||
identity (or the reason extraction failed), and the policy that rejected it.
|
||||
Accepted connections log the identity at INFO on the chain sources and at
|
||||
DEBUG on the sinks, matching each plugin's existing verbosity.
|
||||
- **Statistics** gain `auth`, `auth_identity` (the mode), `auth_unrestricted`,
|
||||
`auth_allowed`, `auth_rejected`, and — on chain sources — `node_binding`, in
|
||||
the `details` map of every affected source and sink. They surface in the
|
||||
status reporter and in the `http` sink's status endpoint.
|
||||
- **Logs** record a WARN per rejection with the remote address and the reason.
|
||||
The startup line carries a rendered policy summary
|
||||
(`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
|
||||
|
||||
Certificate revocation is deliberately handled by the allow-list rather than by
|
||||
CRL or OCSP:
|
||||
Certificate revocation is handled by the allow-list rather than by CRL or OCSP:
|
||||
|
||||
1. Remove the identity from `allow` / `allow_patterns`.
|
||||
2. `kill -HUP`.
|
||||
|
||||
The reload path already rebuilds every pipeline, so the policy takes effect on
|
||||
the next connection and existing connections are dropped by the rebuild itself.
|
||||
This is one moving part instead of three, it needs no network calls on the
|
||||
handshake path, and it is exact — no window between revocation and the next CRL
|
||||
The reload path rebuilds every pipeline, so the policy takes effect on the next
|
||||
connection and existing connections are dropped by the rebuild itself. This is
|
||||
one moving part instead of three, it needs no network calls on the handshake
|
||||
path, and it is exact — no window between revocation and the next CRL
|
||||
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
|
||||
|
||||
No configuration breaks. Omitting the `auth` block, or setting
|
||||
`type = "none"`, reproduces current behaviour byte for byte: `Policy` is nil,
|
||||
every call site short-circuits, and `trust_node` continues to govern node
|
||||
labels.
|
||||
No configuration breaks. Omitting the `auth` block, or setting `type = "none"`,
|
||||
reproduces the previous behaviour exactly: `Policy` is nil, every call site
|
||||
short-circuits, and `trust_node` continues to govern node labels.
|
||||
|
||||
The one behavioural note for adopters: turning on `type = "mtls"` defaults
|
||||
`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
|
||||
of the feature, but it will move data between labels in a dashboard, so the
|
||||
release note should say it in those terms.
|
||||
of the feature, but it moves data between labels in a dashboard, so plan for it.
|
||||
Use `node_binding = "assert"` on relay-to-relay hops where upstream origin
|
||||
labels must survive.
|
||||
|
||||
## Estimated Cost
|
||||
## Verification
|
||||
|
||||
| Phase | Files touched | Rough size |
|
||||
|-------|--------------|-----------|
|
||||
| 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 |
|
||||
`test/mtls-chain-test.sh` builds a full PKI with `openssl` and exercises both
|
||||
target topologies end to end:
|
||||
|
||||
Phases 1–2 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
|
||||
safer instinct, but it makes `type = "mtls"` with no list a footgun that
|
||||
silently drops all traffic. The proposal is allow-with-a-loud-startup-log;
|
||||
the alternative is requiring a non-empty list and erroring at construction,
|
||||
which is arguably better and costs one line.
|
||||
2. **Should `identity` accept a list of modes** (try `san_uri`, fall back to
|
||||
`cn`)? Simpler as a single mode; heterogeneous PKI is the argument against.
|
||||
3. **Per-identity rate limits.** The natural follow-on once identity exists, and
|
||||
the natural home for the per-IP limiting that was also removed. Deliberately
|
||||
out of scope here so this feature stays reviewable.
|
||||
4. **Whether `assert` should reject or warn-and-correct.** As proposed it
|
||||
rejects, which is unambiguous but turns a certificate/config mismatch into an
|
||||
outage. `force` is the forgiving option, and it is the default.
|
||||
- an authorized edge (`edge-01`) delivers entries through both the `tcp_chain`
|
||||
and `http_chain` ingest ports into a file sink
|
||||
- `node_binding = "force"` overrides the label the sender configured
|
||||
- an identity outside the allow list (`edge-99`) is refused, even while claiming
|
||||
to be `edge-01`
|
||||
- a peer presenting no certificate fails the handshake
|
||||
- a dialer that pins a server identity the relay does not hold refuses to
|
||||
connect, even though the server certificate chains to the trusted CA
|
||||
|
||||
Scenario 2 — a viewer client reading a streaming sink over mTLS:
|
||||
|
||||
- an authorized viewer streams from the `tcp` sink and from the `http` sink's
|
||||
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.
|
||||
|
||||
@@ -168,12 +168,35 @@ headers, and entry encoding.
|
||||
- Handshake failures appear as WARN with the remote address, and increment
|
||||
`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**
|
||||
- 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.
|
||||
- On `http_chain`, remember entries wait up to `flush_interval_ms` before a
|
||||
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**
|
||||
- The pipeline may be filtering everything out; check `flow.filters` stats.
|
||||
- The rate limiter may be dropping everything; check `rate_limiter` stats.
|
||||
|
||||
+8
-6
@@ -268,12 +268,14 @@ then `kill -HUP`. Automate the expiry check; nothing in LogWisp warns you.
|
||||
|
||||
**Access review**
|
||||
|
||||
With mTLS, any certificate signed by the configured `client_ca_file` is
|
||||
accepted — there is no per-identity allow-list, so "access review" means
|
||||
reviewing what your CA has issued. Peer Common Names are recorded in session
|
||||
metadata but are not surfaced in statistics and are not used for authorization.
|
||||
See [Security](security.md) and the
|
||||
[mTLS authentication plan](mtls-auth-plan.md).
|
||||
With `tls` alone, any certificate signed by the configured `client_ca_file` is
|
||||
accepted, so "access review" means reviewing what your CA has issued. Add an
|
||||
`auth` block with an explicit `allow` list and the review becomes the config
|
||||
file itself: the identities listed there are the ones that can connect, and
|
||||
removing one plus a `SIGHUP` is the revocation path. Authorized identities are
|
||||
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**
|
||||
|
||||
|
||||
+199
-46
@@ -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 |
|
||||
| Server certificate verification by dialers | Implemented |
|
||||
| Mutual TLS (client certificate required and verified) | Implemented at the transport layer |
|
||||
| Peer identity (certificate CN) recorded per session | Implemented |
|
||||
| Authorization from peer identity (CN allow-lists, node binding) | **Not implemented** — see [mtls-auth-plan.md](mtls-auth-plan.md) |
|
||||
| Peer identity recorded per session | Implemented |
|
||||
| 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 |
|
||||
| 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.
|
||||
Those were removed during the move to the plugin/flow architecture and the
|
||||
switch to standard-library networking. Only certificate-based transport
|
||||
security survived that transition.
|
||||
switch to standard-library networking. Certificates are the one credential the
|
||||
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
|
||||
|
||||
@@ -76,6 +80,130 @@ Misconfiguration fails at plugin construction, before the pipeline starts:
|
||||
certificates
|
||||
- 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
|
||||
|
||||
### 1. Generate a CA and certificates
|
||||
@@ -119,8 +247,17 @@ key_file = "/etc/logwisp/tls/relay.key"
|
||||
client_auth = true
|
||||
client_ca_file = "/etc/logwisp/tls/ca.crt"
|
||||
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
|
||||
|
||||
```toml
|
||||
@@ -130,15 +267,21 @@ ca_file = "/etc/logwisp/tls/ca.crt"
|
||||
cert_file = "/etc/logwisp/tls/edge-01.crt"
|
||||
key_file = "/etc/logwisp/tls/edge-01.key"
|
||||
min_version = "1.3"
|
||||
|
||||
[pipelines.plugin_sinks.config.auth]
|
||||
type = "mtls"
|
||||
allow = ["relay.internal"]
|
||||
```
|
||||
|
||||
### 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
|
||||
auth="mtls identity=cn allow=[2 exact, 0 pattern(s)] node_binding=force"
|
||||
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:
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
Handshake failures are counted in the `tls_handshake_errors` statistic on the
|
||||
`tcp` sink and the `tcp_chain` source.
|
||||
A client whose certificate is valid but whose identity is not authorized gets
|
||||
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`
|
||||
- the certificate is within its validity window and not structurally broken
|
||||
- the peer holds the matching private key
|
||||
`test/mtls-chain-test.sh` builds a throwaway PKI and exercises the whole surface
|
||||
end to end — run it with `--auto` to see each guarantee asserted.
|
||||
|
||||
That is a real membership check: an attacker without a CA-issued certificate
|
||||
cannot connect at all.
|
||||
## What Each Layer Enforces
|
||||
|
||||
## 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
|
||||
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:
|
||||
**`auth` with `type = "mtls"`** — an identity check, per listener:
|
||||
|
||||
- "only `edge-01` and `edge-02` may connect to this ingest port"
|
||||
- "the node label `edge-01` may only be claimed by the holder of the `edge-01`
|
||||
certificate"
|
||||
- "this certificate may connect but only at this rate"
|
||||
- only the identities you list may connect, so one CA can serve several trust
|
||||
domains and a single peer can be withdrawn without touching the others
|
||||
- the chain `node` label is bound to the certificate, so a compromised edge
|
||||
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
|
||||
`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.
|
||||
## Surfaces Without Access Control
|
||||
|
||||
Closing both gaps is the subject of the
|
||||
[mTLS authentication plan](mtls-auth-plan.md).
|
||||
An `auth` block closes each of these. Without one, bind them to a trusted
|
||||
interface or front them with an authenticating proxy.
|
||||
|
||||
## Unauthenticated Surfaces
|
||||
|
||||
These endpoints have no access control at all. Bind them to a trusted interface
|
||||
or front them with an authenticating proxy.
|
||||
|
||||
| Surface | Exposure |
|
||||
|---------|----------|
|
||||
| Surface | Exposure when `auth.type = "none"` |
|
||||
|---------|-----------------------------------|
|
||||
| `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 |
|
||||
| `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.
|
||||
|
||||
Note that `auth` requires `client_auth = true`, which requires TLS. There is no
|
||||
way to authenticate a plaintext listener.
|
||||
|
||||
## Operational Guidance
|
||||
|
||||
**Certificates**
|
||||
@@ -211,7 +359,10 @@ callers.
|
||||
- Key files should be `0600` and owned by the service account.
|
||||
- Rotation requires a reload (`SIGHUP`), because certificates are loaded once at
|
||||
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**
|
||||
|
||||
@@ -220,8 +371,10 @@ callers.
|
||||
- Never enable `insecure_skip_verify` outside a lab; it disables server
|
||||
verification entirely and makes the connection trivially interceptable.
|
||||
- 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
|
||||
not fully control.
|
||||
- On any ingest port reachable from a network you do not fully control, set
|
||||
`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
|
||||
configuration directories.
|
||||
|
||||
|
||||
+43
-13
@@ -114,9 +114,15 @@ write_timeout_ms = 0
|
||||
max_connections = 0
|
||||
|
||||
[pipelines.plugin_sinks.config.tls]
|
||||
enabled = true
|
||||
cert_file = "/etc/logwisp/tls/server.crt"
|
||||
key_file = "/etc/logwisp/tls/server.key"
|
||||
enabled = true
|
||||
cert_file = "/etc/logwisp/tls/server.crt"
|
||||
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 |
|
||||
@@ -130,10 +136,15 @@ key_file = "/etc/logwisp/tls/server.key"
|
||||
| `write_timeout_ms` | int | `0` | Per-event write deadline; `0` = none |
|
||||
| `max_connections` | int | `0` | Concurrent stream cap; `0` = unlimited |
|
||||
| `tls` | table | — | Listener TLS; see [Security](security.md) |
|
||||
| `auth` | table | — | Client authorization; see [Security](security.md#the-auth-block) |
|
||||
|
||||
**Behaviour**
|
||||
|
||||
- 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
|
||||
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
|
||||
@@ -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.
|
||||
|
||||
**Status endpoint** returns service and version identity, host, port, TLS flag,
|
||||
active client count, buffer size, uptime, endpoint paths, and the
|
||||
`total_processed` / `dropped_writes` / `rejected_clients` counters.
|
||||
the compiled auth policy, active client count, buffer size, uptime, endpoint
|
||||
paths, and the `total_processed` / `dropped_writes` / `rejected_clients` /
|
||||
`auth_rejected` counters.
|
||||
|
||||
> Both endpoints are unauthenticated, and the stream response carries
|
||||
> `Access-Control-Allow-Origin: *`, so any web origin can read it. Bind to a
|
||||
> Without an `auth` block both endpoints are unauthenticated, and the stream
|
||||
> 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.
|
||||
|
||||
---
|
||||
@@ -177,9 +190,15 @@ keep_alive_period_ms = 30000
|
||||
max_connections = 0
|
||||
|
||||
[pipelines.plugin_sinks.config.tls]
|
||||
enabled = true
|
||||
cert_file = "/etc/logwisp/tls/server.crt"
|
||||
key_file = "/etc/logwisp/tls/server.key"
|
||||
enabled = true
|
||||
cert_file = "/etc/logwisp/tls/server.crt"
|
||||
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 |
|
||||
@@ -193,6 +212,7 @@ key_file = "/etc/logwisp/tls/server.key"
|
||||
| `keep_alive_period_ms` | int | `30000` | Keep-alive idle period |
|
||||
| `max_connections` | int | `0` | Concurrent connection cap; `0` = unlimited |
|
||||
| `tls` | table | — | Listener TLS |
|
||||
| `auth` | table | — | Client authorization; see [Security](security.md#the-auth-block) |
|
||||
|
||||
**Behaviour**
|
||||
|
||||
@@ -205,6 +225,10 @@ key_file = "/etc/logwisp/tls/server.key"
|
||||
and stays connected.
|
||||
- With TLS enabled the handshake runs under a 10 s bound *after* the
|
||||
`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_period_ms` | int | `30000` | Keep-alive idle period |
|
||||
| `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**
|
||||
|
||||
@@ -265,7 +290,11 @@ key_file = "/etc/logwisp/tls/client.key"
|
||||
- Events arriving without a structured entry are wrapped from the formatted
|
||||
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`.
|
||||
|
||||
---
|
||||
@@ -313,6 +342,7 @@ key_file = "/etc/logwisp/tls/client.key"
|
||||
| `backoff_min_ms` | int | `500` | Retry backoff floor |
|
||||
| `backoff_max_ms` | int | `30000` | Retry backoff ceiling |
|
||||
| `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**
|
||||
|
||||
@@ -324,8 +354,8 @@ key_file = "/etc/logwisp/tls/client.key"
|
||||
- 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.
|
||||
|
||||
**Statistics**: `target`, `node`, `tls`, `batches_sent`, `request_errors`,
|
||||
`dropped_batches`, `synthesized`.
|
||||
**Statistics**: `target`, `node`, `tls`, `auth`, `batches_sent`,
|
||||
`request_errors`, `dropped_batches`, `synthesized`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+35
-7
@@ -157,6 +157,11 @@ key_file = "/etc/logwisp/tls/server.key"
|
||||
client_auth = true
|
||||
client_ca_file = "/etc/logwisp/tls/client-ca.crt"
|
||||
min_version = "1.3"
|
||||
|
||||
[pipelines.plugin_sources.config.auth]
|
||||
type = "mtls"
|
||||
allow = ["edge-01", "edge-02"]
|
||||
node_binding = "force"
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
@@ -167,23 +172,34 @@ min_version = "1.3"
|
||||
| `max_connections` | int | `0` | Concurrent connection cap; `0` = unlimited |
|
||||
| `read_timeout_ms` | int | `0` | Per-connection idle read deadline; `0` = none |
|
||||
| `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) |
|
||||
| `auth` | table | — | Peer authorization and node binding; see [Security](security.md#the-auth-block) |
|
||||
|
||||
**Behaviour**
|
||||
|
||||
- TLS handshakes run explicitly with a 10 s bound before the preamble is read,
|
||||
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
|
||||
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
|
||||
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
|
||||
connection survives. A line over 1 MiB is a protocol violation and terminates
|
||||
the connection.
|
||||
|
||||
**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"
|
||||
client_auth = true
|
||||
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 |
|
||||
@@ -220,13 +241,18 @@ client_ca_file = "/etc/logwisp/tls/client-ca.crt"
|
||||
| `buffer_size` | int | `1000` | Subscriber channel depth |
|
||||
| `max_body_bytes` | int | `8388608` | Per-request body cap (8 MiB) |
|
||||
| `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 |
|
||||
| `auth` | table | — | Peer authorization and node binding; see [Security](security.md#the-auth-block) |
|
||||
|
||||
**Behaviour**
|
||||
|
||||
- Only `POST` to `ingest_path` is routed; other methods get `405` with an
|
||||
`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`.
|
||||
- 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`
|
||||
@@ -234,11 +260,13 @@ client_ca_file = "/etc/logwisp/tls/client-ca.crt"
|
||||
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
|
||||
entries ingested.
|
||||
- Sessions are cached per remote host + declared node and recreated after idle
|
||||
expiry.
|
||||
- Sessions are cached per remote host + node + authenticated identity, and
|
||||
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`,
|
||||
`cached_sessions`, `trust_node`.
|
||||
`cached_sessions`, `trust_node`, `auth`, `auth_allowed`, `auth_rejected`,
|
||||
`node_binding`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -208,28 +208,30 @@ type ConsoleSourceOptions struct {
|
||||
// TCPChainSourceOptions defines settings for a stdlib TCP listener ingesting
|
||||
// NDJSON entries from upstream logwisp tcp_chain sinks
|
||||
type TCPChainSourceOptions struct {
|
||||
TLS *TLSOptions `toml:"tls"`
|
||||
Host string `toml:"host"`
|
||||
Port int64 `toml:"port"`
|
||||
BufferSize int64 `toml:"buffer_size"`
|
||||
MaxConnections int64 `toml:"max_connections"` // 0 = unlimited
|
||||
ReadTimeoutMS int64 `toml:"read_timeout_ms"` // per-connection idle deadline, 0 = none
|
||||
HelloTimeoutMS int64 `toml:"hello_timeout_ms"` // preamble deadline
|
||||
TrustNode bool `toml:"trust_node"` // false: force node label from remote address
|
||||
TLS *TLSOptions `toml:"tls"`
|
||||
Host string `toml:"host"`
|
||||
Port int64 `toml:"port"`
|
||||
BufferSize int64 `toml:"buffer_size"`
|
||||
MaxConnections int64 `toml:"max_connections"` // 0 = unlimited
|
||||
ReadTimeoutMS int64 `toml:"read_timeout_ms"` // per-connection idle deadline, 0 = none
|
||||
HelloTimeoutMS int64 `toml:"hello_timeout_ms"` // preamble deadline
|
||||
TrustNode bool `toml:"trust_node"` // false: force node label from remote address
|
||||
Auth *AuthOptions `toml:"auth"`
|
||||
// Future: password auth block
|
||||
}
|
||||
|
||||
// HTTPChainSourceOptions defines settings for a stdlib HTTP listener ingesting
|
||||
// NDJSON batches from upstream logwisp http_chain sinks
|
||||
type HTTPChainSourceOptions struct {
|
||||
TLS *TLSOptions `toml:"tls"`
|
||||
Host string `toml:"host"`
|
||||
Port int64 `toml:"port"`
|
||||
IngestPath string `toml:"ingest_path"`
|
||||
BufferSize int64 `toml:"buffer_size"`
|
||||
MaxBodyBytes int64 `toml:"max_body_bytes"` // per-request cap
|
||||
ReadTimeoutMS int64 `toml:"read_timeout_ms"` // full request read deadline
|
||||
TrustNode bool `toml:"trust_node"` // false: force node label from remote address
|
||||
TLS *TLSOptions `toml:"tls"`
|
||||
Host string `toml:"host"`
|
||||
Port int64 `toml:"port"`
|
||||
IngestPath string `toml:"ingest_path"`
|
||||
BufferSize int64 `toml:"buffer_size"`
|
||||
MaxBodyBytes int64 `toml:"max_body_bytes"` // per-request cap
|
||||
ReadTimeoutMS int64 `toml:"read_timeout_ms"` // full request read deadline
|
||||
TrustNode bool `toml:"trust_node"` // false: force node label from remote address
|
||||
Auth *AuthOptions `toml:"auth"`
|
||||
// Future: password auth block
|
||||
}
|
||||
|
||||
@@ -275,67 +277,102 @@ type FileSinkOptions struct {
|
||||
|
||||
// TCPSinkOptions defines settings for a TCP server sink
|
||||
type TCPSinkOptions struct {
|
||||
TLS *TLSOptions `toml:"tls"`
|
||||
Host string `toml:"host"`
|
||||
Port int64 `toml:"port"`
|
||||
BufferSize int64 `toml:"buffer_size"` // sink input queue
|
||||
ClientBufferSize int64 `toml:"client_buffer_size"` // per-client send queue
|
||||
WriteTimeoutMS int64 `toml:"write_timeout_ms"` // per-write deadline
|
||||
KeepAlive bool `toml:"keep_alive"`
|
||||
KeepAlivePeriodMS int64 `toml:"keep_alive_period_ms"`
|
||||
MaxConnections int64 `toml:"max_connections"` // 0 = unlimited
|
||||
TLS *TLSOptions `toml:"tls"`
|
||||
Host string `toml:"host"`
|
||||
Port int64 `toml:"port"`
|
||||
BufferSize int64 `toml:"buffer_size"` // sink input queue
|
||||
ClientBufferSize int64 `toml:"client_buffer_size"` // per-client send queue
|
||||
WriteTimeoutMS int64 `toml:"write_timeout_ms"` // per-write deadline
|
||||
KeepAlive bool `toml:"keep_alive"`
|
||||
KeepAlivePeriodMS int64 `toml:"keep_alive_period_ms"`
|
||||
MaxConnections int64 `toml:"max_connections"` // 0 = unlimited
|
||||
Auth *AuthOptions `toml:"auth"`
|
||||
// Future: password auth block
|
||||
}
|
||||
|
||||
// HTTPSinkOptions defines settings for an HTTP SSE server sink
|
||||
type HTTPSinkOptions struct {
|
||||
TLS *TLSOptions `toml:"tls"`
|
||||
Host string `toml:"host"`
|
||||
Port int64 `toml:"port"`
|
||||
StreamPath string `toml:"stream_path"`
|
||||
StatusPath string `toml:"status_path"`
|
||||
BufferSize int64 `toml:"buffer_size"` // sink input queue
|
||||
ClientBufferSize int64 `toml:"client_buffer_size"` // per-client send queue
|
||||
WriteTimeoutMS int64 `toml:"write_timeout_ms"` // per-SSE-write deadline, 0 = none
|
||||
MaxConnections int64 `toml:"max_connections"` // 0 = unlimited
|
||||
TLS *TLSOptions `toml:"tls"`
|
||||
Host string `toml:"host"`
|
||||
Port int64 `toml:"port"`
|
||||
StreamPath string `toml:"stream_path"`
|
||||
StatusPath string `toml:"status_path"`
|
||||
BufferSize int64 `toml:"buffer_size"` // sink input queue
|
||||
ClientBufferSize int64 `toml:"client_buffer_size"` // per-client send queue
|
||||
WriteTimeoutMS int64 `toml:"write_timeout_ms"` // per-SSE-write deadline, 0 = none
|
||||
MaxConnections int64 `toml:"max_connections"` // 0 = unlimited
|
||||
Auth *AuthOptions `toml:"auth"`
|
||||
// Future: password auth block
|
||||
}
|
||||
|
||||
// TCPChainSinkOptions defines settings for a stdlib TCP client forwarding
|
||||
// entries to a downstream logwisp tcp_chain source
|
||||
type TCPChainSinkOptions struct {
|
||||
TLS *TLSOptions `toml:"tls"`
|
||||
Node string `toml:"node"` // origin label, default: os.Hostname()
|
||||
Host string `toml:"host"`
|
||||
Port int64 `toml:"port"`
|
||||
BufferSize int64 `toml:"buffer_size"`
|
||||
DialTimeoutMS int64 `toml:"dial_timeout_ms"`
|
||||
WriteTimeoutMS int64 `toml:"write_timeout_ms"`
|
||||
BackoffMinMS int64 `toml:"backoff_min_ms"`
|
||||
BackoffMaxMS int64 `toml:"backoff_max_ms"`
|
||||
KeepAlivePeriodMS int64 `toml:"keep_alive_period_ms"`
|
||||
KeepAlive bool `toml:"keep_alive"`
|
||||
TLS *TLSOptions `toml:"tls"`
|
||||
Node string `toml:"node"` // origin label, default: os.Hostname()
|
||||
Host string `toml:"host"`
|
||||
Port int64 `toml:"port"`
|
||||
BufferSize int64 `toml:"buffer_size"`
|
||||
DialTimeoutMS int64 `toml:"dial_timeout_ms"`
|
||||
WriteTimeoutMS int64 `toml:"write_timeout_ms"`
|
||||
BackoffMinMS int64 `toml:"backoff_min_ms"`
|
||||
BackoffMaxMS int64 `toml:"backoff_max_ms"`
|
||||
KeepAlivePeriodMS int64 `toml:"keep_alive_period_ms"`
|
||||
KeepAlive bool `toml:"keep_alive"`
|
||||
Auth *AuthOptions `toml:"auth"`
|
||||
// Future: password auth block
|
||||
}
|
||||
|
||||
// HTTPChainSinkOptions defines settings for a stdlib HTTP client posting
|
||||
// NDJSON batches to a downstream logwisp http_chain source
|
||||
type HTTPChainSinkOptions struct {
|
||||
TLS *TLSOptions `toml:"tls"`
|
||||
Node string `toml:"node"` // origin label, default: os.Hostname()
|
||||
Host string `toml:"host"`
|
||||
Port int64 `toml:"port"`
|
||||
IngestPath string `toml:"ingest_path"`
|
||||
BufferSize int64 `toml:"buffer_size"`
|
||||
MaxBatchCount int64 `toml:"max_batch_count"`
|
||||
MaxBatchBytes int64 `toml:"max_batch_bytes"`
|
||||
FlushIntervalMS int64 `toml:"flush_interval_ms"`
|
||||
RequestTimeoutMS int64 `toml:"request_timeout_ms"` // covers dial + write + response
|
||||
BackoffMinMS int64 `toml:"backoff_min_ms"`
|
||||
BackoffMaxMS int64 `toml:"backoff_max_ms"`
|
||||
TLS *TLSOptions `toml:"tls"`
|
||||
Node string `toml:"node"` // origin label, default: os.Hostname()
|
||||
Host string `toml:"host"`
|
||||
Port int64 `toml:"port"`
|
||||
IngestPath string `toml:"ingest_path"`
|
||||
BufferSize int64 `toml:"buffer_size"`
|
||||
MaxBatchCount int64 `toml:"max_batch_count"`
|
||||
MaxBatchBytes int64 `toml:"max_batch_bytes"`
|
||||
FlushIntervalMS int64 `toml:"flush_interval_ms"`
|
||||
RequestTimeoutMS int64 `toml:"request_timeout_ms"` // covers dial + write + response
|
||||
BackoffMinMS int64 `toml:"backoff_min_ms"`
|
||||
BackoffMaxMS int64 `toml:"backoff_max_ms"`
|
||||
Auth *AuthOptions `toml:"auth"`
|
||||
// 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 ---
|
||||
|
||||
// TLSOptions defines transport security for network sources and sinks.
|
||||
|
||||
@@ -153,11 +153,16 @@ func (p *Pipeline) initializeComponents() error {
|
||||
// initSourceCapabilities checks and injects optional capabilities
|
||||
func (p *Pipeline) initSourceCapabilities(s source.Source, cfg config.PluginSourceConfig) error {
|
||||
// Initiate and activate source capabilities
|
||||
var hasTLS, hasAuth bool
|
||||
for _, c := range s.Capabilities() {
|
||||
switch c {
|
||||
// Network capabilities
|
||||
case core.CapNetLimit, core.CapTLS, core.CapAuth:
|
||||
case core.CapNetLimit:
|
||||
continue // No-op for now, placeholder
|
||||
case core.CapTLS:
|
||||
hasTLS = true
|
||||
case core.CapAuth:
|
||||
hasAuth = true
|
||||
|
||||
// Session capabilities
|
||||
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
|
||||
}
|
||||
|
||||
// initSinkCapabilities checks and injects optional capabilities
|
||||
func (p *Pipeline) initSinkCapabilities(s sink.Sink, cfg config.PluginSinkConfig) error {
|
||||
// Initiate and activate sink capabilities
|
||||
var hasTLS, hasAuth bool
|
||||
for _, c := range s.Capabilities() {
|
||||
switch c {
|
||||
// Network capabilities
|
||||
case core.CapNetLimit, core.CapTLS, core.CapAuth:
|
||||
case core.CapNetLimit:
|
||||
continue // No-op for now, placeholder
|
||||
case core.CapTLS:
|
||||
hasTLS = true
|
||||
case core.CapAuth:
|
||||
hasAuth = true
|
||||
|
||||
// Session capabilities
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
+72
-18
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"logwisp/internal/authz"
|
||||
"logwisp/internal/config"
|
||||
"logwisp/internal/core"
|
||||
"logwisp/internal/plugin"
|
||||
@@ -70,6 +72,9 @@ type HTTPSink struct {
|
||||
// TLS
|
||||
tlsConfig *tls.Config
|
||||
|
||||
// Authorization
|
||||
auth *authz.Policy
|
||||
|
||||
// Runtime
|
||||
done chan struct{}
|
||||
stopOnce sync.Once
|
||||
@@ -130,6 +135,10 @@ func NewHTTPSinkPlugin(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleListener)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h := &HTTPSink{
|
||||
id: id,
|
||||
@@ -142,6 +151,7 @@ func NewHTTPSinkPlugin(
|
||||
clients: make(map[uint64]*sseClient),
|
||||
writeTimeout: time.Duration(opts.WriteTimeoutMS) * time.Millisecond,
|
||||
tlsConfig: tlsCfg,
|
||||
auth: authPolicy,
|
||||
}
|
||||
h.lastProcessed.Store(time.Time{})
|
||||
|
||||
@@ -153,7 +163,14 @@ func NewHTTPSinkPlugin(
|
||||
"stream_path", opts.StreamPath,
|
||||
"status_path", opts.StatusPath,
|
||||
"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
|
||||
}
|
||||
|
||||
@@ -162,9 +179,9 @@ func (h *HTTPSink) Capabilities() []core.Capability {
|
||||
caps := []core.Capability{core.CapSessionAware, core.CapMultiSession}
|
||||
if h.tlsConfig != nil {
|
||||
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
|
||||
}
|
||||
@@ -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.StatusPath, h.handleStatus)
|
||||
|
||||
// Auth extension point: wrap mux with auth middleware once credentials
|
||||
// land, e.g. handler = authMiddleware(cfg)(handler)
|
||||
// One wrapper covers stream and status, and keeps the handlers themselves
|
||||
// unaware of authorization
|
||||
var handler http.Handler = mux
|
||||
if h.auth.Enabled() {
|
||||
handler = h.authMiddleware(handler)
|
||||
}
|
||||
|
||||
h.server = &http.Server{
|
||||
Handler: handler,
|
||||
@@ -343,6 +363,9 @@ func (h *HTTPSink) handleStream(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
|
||||
c := &sseClient{
|
||||
@@ -361,6 +384,7 @@ func (h *HTTPSink) handleStream(w http.ResponseWriter, r *http.Request) {
|
||||
"remote_addr", remote,
|
||||
"session_id", sess.ID,
|
||||
"client_id", id,
|
||||
"auth_identity", ident.Name,
|
||||
"active_clients", count)
|
||||
|
||||
defer func() {
|
||||
@@ -432,6 +456,7 @@ func (h *HTTPSink) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
"host": h.config.Host,
|
||||
"port": h.config.Port,
|
||||
"tls": h.tlsConfig != nil,
|
||||
"auth": h.auth.Describe(),
|
||||
"active_clients": h.activeClients.Load(),
|
||||
"buffer_size": h.config.BufferSize,
|
||||
"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(),
|
||||
"dropped_writes": h.droppedWrites.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
|
||||
func (h *HTTPSink) GetStats() sink.SinkStats {
|
||||
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{
|
||||
ID: h.id,
|
||||
Type: "http",
|
||||
@@ -461,21 +501,35 @@ func (h *HTTPSink) GetStats() sink.SinkStats {
|
||||
ActiveConnections: h.activeClients.Load(),
|
||||
StartTime: h.startTime,
|
||||
LastProcessed: lastProc,
|
||||
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,
|
||||
},
|
||||
},
|
||||
Details: details,
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
func writeSSE(w http.ResponseWriter, payload []byte) error {
|
||||
for _, line := range splitLines(payload) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"logwisp/internal/authz"
|
||||
"logwisp/internal/chain"
|
||||
"logwisp/internal/config"
|
||||
"logwisp/internal/core"
|
||||
@@ -58,6 +60,9 @@ type HTTPChainSink struct {
|
||||
tlsEnabled bool
|
||||
mtls bool
|
||||
|
||||
// Authorization: pins the downstream server's identity
|
||||
auth *authz.Policy
|
||||
|
||||
client *http.Client
|
||||
input chan core.TransportEvent
|
||||
logger *log.Logger
|
||||
@@ -136,6 +141,15 @@ func NewHTTPChainSinkPlugin(
|
||||
if err != nil {
|
||||
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))
|
||||
|
||||
@@ -166,6 +180,7 @@ func NewHTTPChainSinkPlugin(
|
||||
node: node,
|
||||
tlsEnabled: tlsCfg != nil,
|
||||
mtls: tlsCfg != nil && len(tlsCfg.Certificates) > 0,
|
||||
auth: authPolicy,
|
||||
url: scheme + "://" + addr + opts.IngestPath,
|
||||
client: &http.Client{Transport: transport},
|
||||
input: make(chan core.TransportEvent, opts.BufferSize),
|
||||
@@ -191,7 +206,8 @@ func NewHTTPChainSinkPlugin(
|
||||
"target", t.url,
|
||||
"node", node,
|
||||
"tls", t.tlsEnabled,
|
||||
"mtls", t.mtls)
|
||||
"mtls", t.mtls,
|
||||
"auth", authPolicy.Describe())
|
||||
return t, nil
|
||||
}
|
||||
|
||||
@@ -200,9 +216,9 @@ func (t *HTTPChainSink) Capabilities() []core.Capability {
|
||||
caps := []core.Capability{core.CapSessionAware}
|
||||
if t.tlsEnabled {
|
||||
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
|
||||
}
|
||||
@@ -249,21 +265,24 @@ func (t *HTTPChainSink) Stop() {
|
||||
// GetStats returns sink statistics
|
||||
func (t *HTTPChainSink) GetStats() sink.SinkStats {
|
||||
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{
|
||||
ID: t.id,
|
||||
Type: "http_chain",
|
||||
TotalProcessed: t.totalProcessed.Load(),
|
||||
StartTime: t.startTime,
|
||||
LastProcessed: lastProc,
|
||||
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(),
|
||||
},
|
||||
Details: details,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+57
-17
@@ -5,12 +5,14 @@ import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"logwisp/internal/authz"
|
||||
"logwisp/internal/config"
|
||||
"logwisp/internal/core"
|
||||
"logwisp/internal/plugin"
|
||||
@@ -66,6 +68,9 @@ type TCPSink struct {
|
||||
tlsConfig *tls.Config
|
||||
tlsHandshakeErrors atomic.Uint64
|
||||
|
||||
// Authorization
|
||||
auth *authz.Policy
|
||||
|
||||
// Runtime
|
||||
done chan struct{}
|
||||
stopOnce sync.Once
|
||||
@@ -124,6 +129,10 @@ func NewTCPSinkPlugin(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleListener)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
t := &TCPSink{
|
||||
id: id,
|
||||
@@ -136,6 +145,7 @@ func NewTCPSinkPlugin(
|
||||
clients: make(map[uint64]*tcpClient),
|
||||
writeTimeout: time.Duration(opts.WriteTimeoutMS) * time.Millisecond,
|
||||
tlsConfig: tlsCfg,
|
||||
auth: authPolicy,
|
||||
}
|
||||
t.lastProcessed.Store(time.Time{})
|
||||
|
||||
@@ -145,7 +155,14 @@ func NewTCPSinkPlugin(
|
||||
"host", opts.Host,
|
||||
"port", opts.Port,
|
||||
"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
|
||||
}
|
||||
|
||||
@@ -154,9 +171,9 @@ func (t *TCPSink) Capabilities() []core.Capability {
|
||||
caps := []core.Capability{core.CapSessionAware, core.CapMultiSession}
|
||||
if t.tlsConfig != nil {
|
||||
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
|
||||
}
|
||||
@@ -270,8 +287,9 @@ func (t *TCPSink) acceptLoop() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Password-auth extension point: preamble verification runs in
|
||||
// handleConn post-handshake, pre-registration
|
||||
// Certificate authorization runs in handleConn post-handshake,
|
||||
// pre-registration. Password-auth extension point: preamble
|
||||
// verification belongs at the same place.
|
||||
|
||||
t.wg.Add(1)
|
||||
go t.handleConn(conn)
|
||||
@@ -298,6 +316,7 @@ func (t *TCPSink) handleConn(conn net.Conn) {
|
||||
"type": "tcp_client",
|
||||
"remote_addr": remote,
|
||||
}
|
||||
var tlsState *tls.ConnectionState
|
||||
if tc, ok := conn.(*tls.Conn); ok {
|
||||
hctx, cancel := context.WithTimeout(context.Background(), tlsx.HandshakeTimeout)
|
||||
err := tc.HandshakeContext(hctx)
|
||||
@@ -311,12 +330,29 @@ func (t *TCPSink) handleConn(conn net.Conn) {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
cs := tc.ConnectionState()
|
||||
tlsState = &cs
|
||||
meta["tls"] = true
|
||||
if cn := tlsx.PeerCN(tc.ConnectionState()); cn != "" {
|
||||
if cn := tlsx.PeerCN(cs); 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)
|
||||
c := &tcpClient{
|
||||
conn: conn,
|
||||
@@ -334,6 +370,7 @@ func (t *TCPSink) handleConn(conn net.Conn) {
|
||||
"component", "tcp_sink",
|
||||
"remote_addr", remote,
|
||||
"session_id", sess.ID,
|
||||
"auth_identity", ident.Name,
|
||||
"active_connections", count)
|
||||
|
||||
defer func() {
|
||||
@@ -421,6 +458,18 @@ func (t *TCPSink) broadcastLoop(ctx context.Context) {
|
||||
// GetStats returns sink statistics
|
||||
func (t *TCPSink) GetStats() sink.SinkStats {
|
||||
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{
|
||||
ID: t.id,
|
||||
Type: "tcp",
|
||||
@@ -428,15 +477,6 @@ func (t *TCPSink) GetStats() sink.SinkStats {
|
||||
ActiveConnections: t.activeConns.Load(),
|
||||
StartTime: t.startTime,
|
||||
LastProcessed: lastProc,
|
||||
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(),
|
||||
},
|
||||
Details: details,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"os"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"logwisp/internal/authz"
|
||||
"logwisp/internal/chain"
|
||||
"logwisp/internal/config"
|
||||
"logwisp/internal/core"
|
||||
@@ -52,6 +54,9 @@ type TCPChainSink struct {
|
||||
helloLine []byte
|
||||
tlsConfig *tls.Config
|
||||
|
||||
// Authorization: pins the downstream server's identity
|
||||
auth *authz.Policy
|
||||
|
||||
input chan core.TransportEvent
|
||||
logger *log.Logger
|
||||
|
||||
@@ -129,6 +134,15 @@ func NewTCPChainSinkPlugin(
|
||||
if err != nil {
|
||||
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{
|
||||
id: id,
|
||||
@@ -138,6 +152,7 @@ func NewTCPChainSinkPlugin(
|
||||
addr: net.JoinHostPort(opts.Host, strconv.FormatInt(opts.Port, 10)),
|
||||
helloLine: helloLine,
|
||||
tlsConfig: tlsCfg,
|
||||
auth: authPolicy,
|
||||
input: make(chan core.TransportEvent, opts.BufferSize),
|
||||
done: make(chan struct{}),
|
||||
logger: logger,
|
||||
@@ -162,7 +177,8 @@ func NewTCPChainSinkPlugin(
|
||||
"target", t.addr,
|
||||
"node", node,
|
||||
"tls", tlsCfg != nil,
|
||||
"mtls", tlsCfg != nil && len(tlsCfg.Certificates) > 0)
|
||||
"mtls", tlsCfg != nil && len(tlsCfg.Certificates) > 0,
|
||||
"auth", authPolicy.Describe())
|
||||
return t, nil
|
||||
}
|
||||
|
||||
@@ -171,9 +187,9 @@ func (t *TCPChainSink) Capabilities() []core.Capability {
|
||||
caps := []core.Capability{core.CapSessionAware}
|
||||
if t.tlsConfig != nil {
|
||||
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
|
||||
}
|
||||
@@ -224,6 +240,17 @@ func (t *TCPChainSink) GetStats() sink.SinkStats {
|
||||
if t.connected.Load() {
|
||||
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{
|
||||
ID: t.id,
|
||||
Type: "tcp_chain",
|
||||
@@ -231,15 +258,7 @@ func (t *TCPChainSink) GetStats() sink.SinkStats {
|
||||
ActiveConnections: active,
|
||||
StartTime: t.startTime,
|
||||
LastProcessed: lastProc,
|
||||
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(),
|
||||
},
|
||||
Details: details,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"logwisp/internal/authz"
|
||||
"logwisp/internal/chain"
|
||||
"logwisp/internal/config"
|
||||
"logwisp/internal/core"
|
||||
@@ -54,7 +56,10 @@ type HTTPChainSource struct {
|
||||
// TLS
|
||||
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
|
||||
sessionsMu sync.Mutex
|
||||
|
||||
@@ -104,6 +109,10 @@ func NewHTTPChainSourcePlugin(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleChainListener)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &HTTPChainSource{
|
||||
id: id,
|
||||
@@ -113,6 +122,7 @@ func NewHTTPChainSourcePlugin(
|
||||
sessions: make(map[string]string),
|
||||
logger: logger,
|
||||
tlsConfig: tlsCfg,
|
||||
auth: authPolicy,
|
||||
}
|
||||
s.lastEntryTime.Store(time.Time{})
|
||||
|
||||
@@ -123,7 +133,21 @@ func NewHTTPChainSourcePlugin(
|
||||
"port", opts.Port,
|
||||
"ingest_path", opts.IngestPath,
|
||||
"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
|
||||
}
|
||||
|
||||
@@ -132,9 +156,9 @@ func (s *HTTPChainSource) Capabilities() []core.Capability {
|
||||
caps := []core.Capability{core.CapSessionAware, core.CapMultiSession}
|
||||
if s.tlsConfig != nil {
|
||||
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
|
||||
}
|
||||
@@ -226,6 +250,19 @@ func (s *HTTPChainSource) GetStats() source.SourceStats {
|
||||
cachedSessions := len(s.sessions)
|
||||
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{
|
||||
ID: s.id,
|
||||
Type: "http_chain",
|
||||
@@ -233,17 +270,7 @@ func (s *HTTPChainSource) GetStats() source.SourceStats {
|
||||
DroppedEntries: s.droppedEntries.Load(),
|
||||
StartTime: s.startTime,
|
||||
LastEntryTime: lastEntry,
|
||||
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,
|
||||
},
|
||||
Details: details,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +279,22 @@ func (s *HTTPChainSource) GetStats() source.SourceStats {
|
||||
func (s *HTTPChainSource) handleIngest(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
s.rejectedRequests.Add(1)
|
||||
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 {
|
||||
remoteHost = host
|
||||
}
|
||||
connNode := r.Header.Get(chain.HeaderNode)
|
||||
if connNode == "" || !s.config.TrustNode {
|
||||
connNode = remoteHost
|
||||
declaredNode := r.Header.Get(chain.HeaderNode)
|
||||
connNode, err := s.auth.ResolveNode(declaredNode, remoteHost, s.config.TrustNode, ident)
|
||||
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)
|
||||
scanner := bufio.NewScanner(body)
|
||||
@@ -277,7 +332,7 @@ func (s *HTTPChainSource) handleIngest(w http.ResponseWriter, r *http.Request) {
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
entry, err := chain.DecodeEntry(line, connNode, s.config.TrustNode)
|
||||
entry, err := chain.DecodeEntry(line, connNode, trustEntryNode)
|
||||
if err != nil {
|
||||
// Content error within a clean transfer: skip line, keep batch
|
||||
s.parseErrors.Add(1)
|
||||
@@ -304,15 +359,17 @@ func (s *HTTPChainSource) handleIngest(w http.ResponseWriter, r *http.Request) {
|
||||
for _, entry := range entries {
|
||||
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.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// sessionFor returns the cached session for a remote+node, recreating after idle expiry
|
||||
func (s *HTTPChainSource) sessionFor(remoteHost, node string, cs *tls.ConnectionState) string {
|
||||
key := remoteHost + "|" + node
|
||||
// sessionFor returns the cached session for a remote+node+identity,
|
||||
// recreating after idle expiry. Identity is part of the key so two peers
|
||||
// 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()
|
||||
defer s.sessionsMu.Unlock()
|
||||
|
||||
@@ -331,6 +388,7 @@ func (s *HTTPChainSource) sessionFor(remoteHost, node string, cs *tls.Connection
|
||||
meta["tls_peer_cn"] = cn
|
||||
}
|
||||
}
|
||||
ident.Apply(meta)
|
||||
sess := s.proxy.CreateSession(remoteHost, meta)
|
||||
s.sessions[key] = sess.ID
|
||||
return sess.ID
|
||||
|
||||
@@ -6,12 +6,14 @@ import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"logwisp/internal/authz"
|
||||
"logwisp/internal/chain"
|
||||
"logwisp/internal/config"
|
||||
"logwisp/internal/core"
|
||||
@@ -50,6 +52,9 @@ type TCPChainSource struct {
|
||||
tlsConfig *tls.Config
|
||||
tlsHandshakeErrors atomic.Uint64
|
||||
|
||||
// Authorization
|
||||
auth *authz.Policy
|
||||
|
||||
mu sync.RWMutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
@@ -91,6 +96,10 @@ func NewTCPChainSourcePlugin(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleChainListener)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &TCPChainSource{
|
||||
id: id,
|
||||
@@ -100,6 +109,7 @@ func NewTCPChainSourcePlugin(
|
||||
conns: make(map[net.Conn]struct{}),
|
||||
logger: logger,
|
||||
tlsConfig: tlsCfg,
|
||||
auth: authPolicy,
|
||||
}
|
||||
s.lastEntryTime.Store(time.Time{})
|
||||
|
||||
@@ -109,7 +119,21 @@ func NewTCPChainSourcePlugin(
|
||||
"host", opts.Host,
|
||||
"port", opts.Port,
|
||||
"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
|
||||
}
|
||||
|
||||
@@ -118,9 +142,9 @@ func (s *TCPChainSource) Capabilities() []core.Capability {
|
||||
caps := []core.Capability{core.CapSessionAware, core.CapMultiSession}
|
||||
if s.tlsConfig != nil {
|
||||
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
|
||||
}
|
||||
@@ -191,6 +215,18 @@ func (s *TCPChainSource) Stop() {
|
||||
// GetStats returns the source's statistics
|
||||
func (s *TCPChainSource) GetStats() source.SourceStats {
|
||||
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{
|
||||
ID: s.id,
|
||||
Type: "tcp_chain",
|
||||
@@ -198,16 +234,7 @@ func (s *TCPChainSource) GetStats() source.SourceStats {
|
||||
DroppedEntries: s.droppedEntries.Load(),
|
||||
StartTime: s.startTime,
|
||||
LastEntryTime: lastEntry,
|
||||
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,
|
||||
},
|
||||
Details: details,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,6 +303,18 @@ func (s *TCPChainSource) handleConn(conn net.Conn) {
|
||||
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)
|
||||
// Oversized line (> MaxLogEntryBytes) is a protocol violation; scanner is
|
||||
// unrecoverable after ErrTooLong, connection terminates
|
||||
@@ -299,14 +338,24 @@ func (s *TCPChainSource) handleConn(conn net.Conn) {
|
||||
return
|
||||
}
|
||||
|
||||
connNode := hello.Node
|
||||
if connNode == "" || !s.config.TrustNode {
|
||||
if host, _, splitErr := net.SplitHostPort(remote); splitErr == nil {
|
||||
connNode = host
|
||||
} else {
|
||||
connNode = remote
|
||||
}
|
||||
fallbackNode := remote
|
||||
if host, _, splitErr := net.SplitHostPort(remote); splitErr == nil {
|
||||
fallbackNode = host
|
||||
}
|
||||
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{
|
||||
"type": "tcp_chain",
|
||||
@@ -318,13 +367,15 @@ func (s *TCPChainSource) handleConn(conn net.Conn) {
|
||||
meta["tls_peer_cn"] = cn
|
||||
}
|
||||
}
|
||||
ident.Apply(meta)
|
||||
sess := s.proxy.CreateSession(remote, meta)
|
||||
sessID = sess.ID
|
||||
|
||||
s.logger.Info("msg", "Chain connection established",
|
||||
"component", "tcp_chain_source",
|
||||
"remote_addr", remote,
|
||||
"node", connNode)
|
||||
"node", connNode,
|
||||
"auth_identity", ident.Name)
|
||||
|
||||
idle := time.Duration(s.config.ReadTimeoutMS) * time.Millisecond
|
||||
for {
|
||||
@@ -348,7 +399,7 @@ func (s *TCPChainSource) handleConn(conn net.Conn) {
|
||||
}
|
||||
s.proxy.UpdateActivity(sessID)
|
||||
|
||||
entry, err := chain.DecodeEntry(line, connNode, s.config.TrustNode)
|
||||
entry, err := chain.DecodeEntry(line, connNode, trustEntryNode)
|
||||
if err != nil {
|
||||
s.parseErrors.Add(1)
|
||||
s.logger.Debug("msg", "Dropped malformed chain entry",
|
||||
|
||||
+35
-1
@@ -94,12 +94,46 @@ func Client(o *config.TLSOptions, host string) (*tls.Config, error) {
|
||||
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
|
||||
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 {
|
||||
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
|
||||
|
||||
@@ -222,14 +222,14 @@ check() { # label condition_result
|
||||
tcp_out="$(tcp_read "$PORT_TCP_SINK" 4)"
|
||||
#nt=$(grep -c '"node":"edge-tcp"' <<< "$tcp_out")
|
||||
#nh=$(grep -c '"node":"edge-http"' <<< "$tcp_out")
|
||||
nt=$(grep -c '"source":"edge-tcp/' <<< "$tcp_out")
|
||||
nh=$(grep -c '"source":"edge-http/' <<< "$tcp_out")
|
||||
nt=$(grep -c 'edge-tcp/' <<< "$tcp_out")
|
||||
nh=$(grep -c 'edge-http/' <<< "$tcp_out")
|
||||
check "tcp sink: aggregated edge-tcp ($nt) + edge-http ($nh)" $(( nt >= 1 && nh >= 1 ))
|
||||
|
||||
# 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)"
|
||||
nt=$(grep -c '^data:.*"node":"edge-tcp"' <<< "$sse_out")
|
||||
nh=$(grep -c '^data:.*"node":"edge-http"' <<< "$sse_out")
|
||||
nt=$(grep -c '^data:.*edge-tcp/' <<< "$sse_out")
|
||||
nh=$(grep -c '^data:.*edge-http/' <<< "$sse_out")
|
||||
check "http sink: aggregated edge-tcp ($nt) + edge-http ($nh)" $(( nt >= 1 && nh >= 1 ))
|
||||
|
||||
# 3. HTTP sink status endpoint
|
||||
|
||||
+2
-2
@@ -224,12 +224,12 @@ check() { # label condition_result
|
||||
# 1. TCP chain: edge-tcp -> relay -> tcp sink
|
||||
tcp_out="$(tcp_read "$PORT_TCP_SINK" 4)"
|
||||
#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 ))
|
||||
|
||||
# 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)"
|
||||
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 ))
|
||||
|
||||
# 3. HTTP sink status endpoint
|
||||
|
||||
Executable
+513
@@ -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"
|
||||
Reference in New Issue
Block a user