From 80e001714026dad085530f54806928c5fc155d0b563166cf4c0af9cce2b33241 Mon Sep 17 00:00:00 2001 From: Lixen Wraith Date: Sat, 29 Aug 2026 18:39:14 -0400 Subject: [PATCH] v0.17.0 mtls added to network chain, sinks, and sources --- .gitignore | 1 + README.md | 21 +- config/logwisp.toml | 67 +++- doc/README.md | 15 +- doc/architecture.md | 11 +- doc/chaining.md | 36 +- doc/mtls-auth-plan.md | 433 ++++++++++----------- doc/networking.md | 23 ++ doc/operations.md | 14 +- doc/security.md | 245 +++++++++--- doc/sinks.md | 56 ++- doc/sources.md | 42 +- internal/authz/authz.go | 318 +++++++++++++++ internal/authz/authz_test.go | 298 ++++++++++++++ internal/config/config.go | 151 +++++--- internal/pipeline/pipeline.go | 32 +- internal/sink/http/http.go | 90 ++++- internal/sink/httpchain/httpchain.go | 45 ++- internal/sink/tcp/tcp.go | 74 +++- internal/sink/tcpchain/tcpchain.go | 45 ++- internal/source/httpchain/httpchain.go | 106 +++-- internal/source/tcpchain/tcpchain.go | 97 +++-- internal/tlsx/tlsx.go | 36 +- test/chain-aggregate-test.sh | 8 +- test/chain-test.sh | 4 +- test/mtls-chain-test.sh | 513 +++++++++++++++++++++++++ 26 files changed, 2293 insertions(+), 488 deletions(-) create mode 100644 internal/authz/authz.go create mode 100644 internal/authz/authz_test.go create mode 100755 test/mtls-chain-test.sh diff --git a/.gitignore b/.gitignore index ca0f6df..cf8dd90 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ build.sh catalog.txt combined.txt test/run/ +test/run-mtls/ diff --git a/README.md b/README.md index 48771da..c6f7817 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/config/logwisp.toml b/config/logwisp.toml index 37af9db..df7ca85 100644 --- a/config/logwisp.toml +++ b/config/logwisp.toml @@ -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 diff --git a/doc/README.md b/doc/README.md index 1fc90eb..9e67ce2 100644 --- a/doc/README.md +++ b/doc/README.md @@ -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 diff --git a/doc/architecture.md b/doc/architecture.md index 7353171..0ebb47d 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -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 diff --git a/doc/chaining.md b/doc/chaining.md index 49285d7..f3a839f 100644 --- a/doc/chaining.md +++ b/doc/chaining.md @@ -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 diff --git a/doc/mtls-auth-plan.md b/doc/mtls-auth-plan.md index 0940334..a44f2d9 100644 --- a/doc/mtls-auth-plan.md +++ b/doc/mtls-auth-plan.md @@ -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. diff --git a/doc/networking.md b/doc/networking.md index 060bbec..1e9e45a 100644 --- a/doc/networking.md +++ b/doc/networking.md @@ -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 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. diff --git a/doc/operations.md b/doc/operations.md index 0ec8d79..9e497b1 100644 --- a/doc/operations.md +++ b/doc/operations.md @@ -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** diff --git a/doc/security.md b/doc/security.md index f1e9b1d..dbc4f0a 100644 --- a/doc/security.md +++ b/doc/security.md @@ -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. diff --git a/doc/sinks.md b/doc/sinks.md index b0fcab6..7e3bbe8 100644 --- a/doc/sinks.md +++ b/doc/sinks.md @@ -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`. --- diff --git a/doc/sources.md b/doc/sources.md index 4a4256d..3d64a1f 100644 --- a/doc/sources.md +++ b/doc/sources.md @@ -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`. --- diff --git a/internal/authz/authz.go b/internal/authz/authz.go new file mode 100644 index 0000000..60f5c85 --- /dev/null +++ b/internal/authz/authz.go @@ -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 +} diff --git a/internal/authz/authz_test.go b/internal/authz/authz_test.go new file mode 100644 index 0000000..61fc620 --- /dev/null +++ b/internal/authz/authz_test.go @@ -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") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index e531f36..79b05dc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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. diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 9e5d1e4..1fabc32 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -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 } diff --git a/internal/sink/http/http.go b/internal/sink/http/http.go index d8274b1..bd55828 100644 --- a/internal/sink/http/http.go +++ b/internal/sink/http/http.go @@ -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) { diff --git a/internal/sink/httpchain/httpchain.go b/internal/sink/httpchain/httpchain.go index 7c57b6b..f69ddab 100644 --- a/internal/sink/httpchain/httpchain.go +++ b/internal/sink/httpchain/httpchain.go @@ -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, } } diff --git a/internal/sink/tcp/tcp.go b/internal/sink/tcp/tcp.go index 6b7db52..2e77427 100644 --- a/internal/sink/tcp/tcp.go +++ b/internal/sink/tcp/tcp.go @@ -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, } } diff --git a/internal/sink/tcpchain/tcpchain.go b/internal/sink/tcpchain/tcpchain.go index ea627ba..3f18f9f 100644 --- a/internal/sink/tcpchain/tcpchain.go +++ b/internal/sink/tcpchain/tcpchain.go @@ -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, } } diff --git a/internal/source/httpchain/httpchain.go b/internal/source/httpchain/httpchain.go index c232161..b8cf518 100644 --- a/internal/source/httpchain/httpchain.go +++ b/internal/source/httpchain/httpchain.go @@ -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 diff --git a/internal/source/tcpchain/tcpchain.go b/internal/source/tcpchain/tcpchain.go index e7cb54a..d2cdb9a 100644 --- a/internal/source/tcpchain/tcpchain.go +++ b/internal/source/tcpchain/tcpchain.go @@ -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", diff --git a/internal/tlsx/tlsx.go b/internal/tlsx/tlsx.go index 8dff831..9eb2d9e 100644 --- a/internal/tlsx/tlsx.go +++ b/internal/tlsx/tlsx.go @@ -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 diff --git a/test/chain-aggregate-test.sh b/test/chain-aggregate-test.sh index ba200bf..8586c38 100755 --- a/test/chain-aggregate-test.sh +++ b/test/chain-aggregate-test.sh @@ -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 diff --git a/test/chain-test.sh b/test/chain-test.sh index 254bff6..8a8db11 100755 --- a/test/chain-test.sh +++ b/test/chain-test.sh @@ -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 diff --git a/test/mtls-chain-test.sh b/test/mtls-chain-test.sh new file mode 100755 index 0000000..52106b7 --- /dev/null +++ b/test/mtls-chain-test.sh @@ -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" < "$CONF/edge_tcp.toml" < "$CONF/edge_http.toml" < "$CONF/edge_rogue.toml" < "$CONF/edge_pinfail.toml" </dev/null || true +} + +http_get() { # path cert_basename|"" -> "|" + 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 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"