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

This commit is contained in:
2026-08-29 18:44:44 -04:00
parent 5fbd5c71cf
commit dd665bb339
26 changed files with 2293 additions and 488 deletions
+318
View File
@@ -0,0 +1,318 @@
// Package authz turns a verified certificate into an authorization decision.
// It is the single seam between declarative auth config and the network
// plugins: each one compiles a Policy at construction and calls Authorize per
// connection (TCP) or per request (HTTP).
//
// New returns (nil, nil) when auth is disabled, mirroring tlsx.Server and
// tlsx.Client, and every method tolerates a nil receiver — so call sites read
// the same whether or not a policy is configured.
package authz
import (
"crypto/tls"
"fmt"
"regexp"
"strings"
"sync/atomic"
"logwisp/internal/config"
"logwisp/internal/tlsx"
)
// Authentication methods
const (
MethodNone = "none"
MethodMTLS = "mtls"
)
// Node label binding modes. See ResolveNode and TrustsEntryNode for the
// difference between assert and force.
const (
BindingNone = "none"
BindingAssert = "assert"
BindingForce = "force"
)
// Role selects the validation and behavior appropriate to the call site
type Role int
const (
// RoleListener authorizes client certificates on a plugin with no node
// concept: the tcp and http sinks
RoleListener Role = iota
// RoleChainListener authorizes client certificates and binds the node
// label a peer declares: the tcp_chain and http_chain sources
RoleChainListener
// RoleDialer pins the server's identity beyond hostname verification:
// the tcp_chain and http_chain sinks
RoleDialer
)
// Policy is the compiled form of config.AuthOptions
type Policy struct {
role Role
identity string
allow map[string]struct{}
patterns []*regexp.Regexp
binding string
// Statistics
allowed atomic.Uint64
rejected atomic.Uint64
}
// Identity is the outcome of a successful authorization. The zero value is
// what a disabled policy yields.
type Identity struct {
Name string // the selected certificate field
Method string // MethodMTLS
}
// Apply stamps an authenticated identity onto session metadata. A zero
// Identity (auth disabled) leaves the map untouched.
func (id Identity) Apply(meta map[string]any) {
if id.Name == "" {
return
}
meta["auth_method"] = id.Method
meta["auth_identity"] = id.Name
}
// New compiles an auth policy, returning (nil, nil) when auth is disabled.
// tlsOpts is the sibling `tls` block: an auth policy the transport cannot
// enforce is rejected here rather than silently accepted, which is the
// failure mode worth designing out.
func New(o *config.AuthOptions, tlsOpts *config.TLSOptions, role Role) (*Policy, error) {
if o == nil {
return nil, nil
}
switch o.Type {
case "", MethodNone:
return nil, nil
case MethodMTLS:
default:
return nil, fmt.Errorf("auth: type %q (valid: %q, %q)", o.Type, MethodNone, MethodMTLS)
}
if tlsOpts == nil || !tlsOpts.Enabled {
return nil, fmt.Errorf("auth: type %q requires tls.enabled", MethodMTLS)
}
if role == RoleDialer {
// Identity from an unverified chain is a claim, not a fact
if tlsOpts.InsecureSkipVerify {
return nil, fmt.Errorf("auth: type %q cannot pin an identity with tls.insecure_skip_verify", MethodMTLS)
}
} else if !tlsOpts.ClientAuth {
return nil, fmt.Errorf("auth: type %q requires tls.client_auth", MethodMTLS)
}
identity := o.Identity
if identity == "" {
identity = tlsx.IdentityCN
}
switch identity {
case tlsx.IdentityCN, tlsx.IdentitySANDNS, tlsx.IdentitySANURI, tlsx.IdentitySANEmail:
default:
return nil, fmt.Errorf("auth: identity %q (valid: %q, %q, %q, %q)",
identity, tlsx.IdentityCN, tlsx.IdentitySANDNS, tlsx.IdentitySANURI, tlsx.IdentitySANEmail)
}
binding := o.NodeBinding
if role == RoleChainListener {
if binding == "" {
// The only setting under which a misconfigured or hostile edge
// cannot mislabel its entries
binding = BindingForce
}
} else if binding != "" && binding != BindingNone {
return nil, fmt.Errorf("auth: node_binding %q applies only to chain sources", binding)
} else {
binding = BindingNone
}
switch binding {
case BindingNone, BindingAssert, BindingForce:
default:
return nil, fmt.Errorf("auth: node_binding %q (valid: %q, %q, %q)",
binding, BindingNone, BindingAssert, BindingForce)
}
p := &Policy{
role: role,
identity: identity,
binding: binding,
allow: make(map[string]struct{}, len(o.Allow)),
}
for _, a := range o.Allow {
if a = strings.TrimSpace(a); a != "" {
p.allow[a] = struct{}{}
}
}
for i, pat := range o.AllowPatterns {
re, err := regexp.Compile(pat)
if err != nil {
return nil, fmt.Errorf("auth: allow_patterns[%d] %q: %w", i, pat, err)
}
p.patterns = append(p.patterns, re)
}
return p, nil
}
// Authorize extracts and checks the peer identity from a completed handshake.
// A nil policy authorizes everything and yields the zero Identity, so callers
// need no branch on whether auth is configured.
func (p *Policy) Authorize(cs *tls.ConnectionState) (Identity, error) {
if p == nil {
return Identity{}, nil
}
if cs == nil {
p.rejected.Add(1)
return Identity{}, fmt.Errorf("auth: peer is not on a TLS connection")
}
name := tlsx.PeerIdentity(*cs, p.identity)
if name == "" {
// An unusable identity field is a rejection, not an empty match
p.rejected.Add(1)
return Identity{}, fmt.Errorf("auth: peer certificate carries no %s identity", p.identity)
}
if !p.permits(name) {
p.rejected.Add(1)
return Identity{}, fmt.Errorf("auth: identity %q is not allowed", name)
}
p.allowed.Add(1)
return Identity{Name: name, Method: MethodMTLS}, nil
}
// VerifyConnection is assignable to tls.Config.VerifyConnection on a dialer,
// so a server whose identity the policy rejects fails the handshake itself
// rather than after the first write. It runs after the standard chain and
// hostname checks, so the identity it reads is already verified.
func (p *Policy) VerifyConnection(cs tls.ConnectionState) error {
_, err := p.Authorize(&cs)
return err
}
// permits reports whether an identity satisfies the allow list. An empty list
// admits any identity the CA vouches for; that is the documented default, and
// constructors log it at startup rather than leaving it silent.
// Identities are not secrets, so ordinary comparison is fine.
func (p *Policy) permits(name string) bool {
if len(p.allow) == 0 && len(p.patterns) == 0 {
return true
}
if _, ok := p.allow[name]; ok {
return true
}
for _, re := range p.patterns {
if re.MatchString(name) {
return true
}
}
return false
}
// ResolveNode returns the node label for a connection. With no policy, or
// node_binding "none", trust_node governs as before: the declared label stands
// only when trusted and non-empty, otherwise fallback (the remote address) is
// used. Otherwise the label is bound to the authenticated identity.
func (p *Policy) ResolveNode(declared, fallback string, trustNode bool, id Identity) (string, error) {
if p == nil || p.binding == BindingNone {
if declared == "" || !trustNode {
return fallback, nil
}
return declared, nil
}
if id.Name == "" {
return "", fmt.Errorf("auth: node_binding %q requires an authenticated identity", p.binding)
}
if p.binding == BindingForce {
return id.Name, nil
}
// BindingAssert: a mismatch is loud rather than silently corrected
if declared == "" {
return "", fmt.Errorf("auth: node_binding %q: peer %q declared no node label", BindingAssert, id.Name)
}
if declared != id.Name {
return "", fmt.Errorf("auth: node_binding %q: declared node %q does not match identity %q",
BindingAssert, declared, id.Name)
}
return declared, nil
}
// TrustsEntryNode reports whether node labels carried by individual entries
// survive the policy. force relabels every entry, so an ingest boundary that
// does not trust its peer gets exact attribution; assert pins only the
// connection's own label, so a relay forwarding other nodes' entries proves
// who it is while preserving their origin.
func (p *Policy) TrustsEntryNode(trustNode bool) bool {
if p == nil {
return trustNode
}
if p.binding == BindingForce {
return false
}
return trustNode
}
// BindsNode reports whether the policy overrides trust_node
func (p *Policy) BindsNode() bool {
return p != nil && p.binding != BindingNone
}
// NodeBinding returns the effective binding mode
func (p *Policy) NodeBinding() string {
if p == nil {
return BindingNone
}
return p.binding
}
// Enabled reports whether a policy is in force
func (p *Policy) Enabled() bool { return p != nil }
// Unrestricted reports whether the policy admits any identity the CA vouches
// for. Constructors log this at startup: it is a deliberate default, and a
// silent one would be a footgun.
func (p *Policy) Unrestricted() bool {
return p != nil && len(p.allow) == 0 && len(p.patterns) == 0
}
// Describe renders the policy for a startup log line
func (p *Policy) Describe() string {
if p == nil {
return MethodNone
}
scope := fmt.Sprintf("%d exact, %d pattern(s)", len(p.allow), len(p.patterns))
if p.Unrestricted() {
scope = "any identity issued by the configured CA"
}
return fmt.Sprintf("%s identity=%s allow=[%s] node_binding=%s",
MethodMTLS, p.identity, scope, p.binding)
}
// Rejected returns the number of authorization failures
func (p *Policy) Rejected() uint64 {
if p == nil {
return 0
}
return p.rejected.Load()
}
// Stats reports policy state for a plugin's stats details map. Merge it in
// with maps.Copy so rejections surface in the status reporter and in the
// http sink's status endpoint.
func (p *Policy) Stats() map[string]any {
if p == nil {
return map[string]any{"auth": MethodNone}
}
d := map[string]any{
"auth": MethodMTLS,
"auth_identity": p.identity,
"auth_unrestricted": p.Unrestricted(),
"auth_allowed": p.allowed.Load(),
"auth_rejected": p.rejected.Load(),
}
if p.role == RoleChainListener {
d["node_binding"] = p.binding
}
return d
}
+298
View File
@@ -0,0 +1,298 @@
package authz
import (
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"net/url"
"testing"
"logwisp/internal/config"
"logwisp/internal/tlsx"
)
// peerState fakes a completed handshake. Only the leaf's identity fields are
// read: the chain is verified by crypto/tls before a policy ever sees it.
func peerState(leaf *x509.Certificate) *tls.ConnectionState {
return &tls.ConnectionState{PeerCertificates: []*x509.Certificate{leaf}}
}
func leafCN(cn string) *x509.Certificate {
return &x509.Certificate{Subject: pkix.Name{CommonName: cn}}
}
func mtlsListenerTLS() *config.TLSOptions {
return &config.TLSOptions{Enabled: true, ClientAuth: true}
}
func TestPeerIdentityModes(t *testing.T) {
uri, err := url.Parse("spiffe://example.org/edge-01")
if err != nil {
t.Fatalf("parse uri: %v", err)
}
leaf := &x509.Certificate{
Subject: pkix.Name{CommonName: "edge-01"},
DNSNames: []string{"edge-01.internal", "alt.internal"},
URIs: []*url.URL{uri},
EmailAddresses: []string{"ops@example.org"},
}
cs := peerState(leaf)
cases := map[string]string{
tlsx.IdentityCN: "edge-01",
tlsx.IdentitySANDNS: "edge-01.internal",
tlsx.IdentitySANURI: "spiffe://example.org/edge-01",
tlsx.IdentitySANEmail: "ops@example.org",
"": "edge-01", // empty mode defaults to CN
"nonsense": "",
}
for mode, want := range cases {
if got := tlsx.PeerIdentity(*cs, mode); got != want {
t.Errorf("PeerIdentity(%q) = %q, want %q", mode, got, want)
}
}
// A mode the certificate does not carry yields no identity
bare := peerState(leafCN("edge-01"))
if got := tlsx.PeerIdentity(*bare, tlsx.IdentitySANDNS); got != "" {
t.Errorf("PeerIdentity(san_dns) on bare cert = %q, want empty", got)
}
// No peer certificate at all
if got := tlsx.PeerIdentity(tls.ConnectionState{}, tlsx.IdentityCN); got != "" {
t.Errorf("PeerIdentity with no peer certs = %q, want empty", got)
}
}
func TestNewDisabled(t *testing.T) {
for _, o := range []*config.AuthOptions{nil, {}, {Type: MethodNone}} {
p, err := New(o, nil, RoleListener)
if err != nil {
t.Fatalf("New(%+v) error: %v", o, err)
}
if p != nil {
t.Fatalf("New(%+v) = %v, want nil policy", o, p)
}
}
}
// A nil policy must behave as if auth were never configured
func TestNilPolicyIsTransparent(t *testing.T) {
var p *Policy
id, err := p.Authorize(nil)
if err != nil || id.Name != "" {
t.Fatalf("nil Authorize = (%+v, %v), want (zero, nil)", id, err)
}
if p.Enabled() || p.BindsNode() || p.Unrestricted() {
t.Fatal("nil policy reports itself active")
}
if p.NodeBinding() != BindingNone {
t.Fatalf("nil NodeBinding = %q", p.NodeBinding())
}
if !p.TrustsEntryNode(true) || p.TrustsEntryNode(false) {
t.Fatal("nil policy must defer to trust_node")
}
// trust_node semantics are unchanged without a policy
node, err := p.ResolveNode("edge-01", "10.0.0.5", true, Identity{})
if err != nil || node != "edge-01" {
t.Fatalf("nil ResolveNode(trust) = (%q, %v), want edge-01", node, err)
}
node, err = p.ResolveNode("edge-01", "10.0.0.5", false, Identity{})
if err != nil || node != "10.0.0.5" {
t.Fatalf("nil ResolveNode(no trust) = (%q, %v), want 10.0.0.5", node, err)
}
node, err = p.ResolveNode("", "10.0.0.5", true, Identity{})
if err != nil || node != "10.0.0.5" {
t.Fatalf("nil ResolveNode(no label) = (%q, %v), want 10.0.0.5", node, err)
}
}
func TestNewValidation(t *testing.T) {
tests := []struct {
name string
auth *config.AuthOptions
tls *config.TLSOptions
role Role
}{
{"unknown type", &config.AuthOptions{Type: "kerberos"}, mtlsListenerTLS(), RoleListener},
{"no tls", &config.AuthOptions{Type: MethodMTLS}, nil, RoleListener},
{"tls disabled", &config.AuthOptions{Type: MethodMTLS}, &config.TLSOptions{}, RoleListener},
{"no client_auth", &config.AuthOptions{Type: MethodMTLS}, &config.TLSOptions{Enabled: true}, RoleListener},
{"unknown identity", &config.AuthOptions{Type: MethodMTLS, Identity: "serial"}, mtlsListenerTLS(), RoleListener},
{"bad pattern", &config.AuthOptions{Type: MethodMTLS, AllowPatterns: []string{"^edge-("}}, mtlsListenerTLS(), RoleListener},
{"unknown binding", &config.AuthOptions{Type: MethodMTLS, NodeBinding: "maybe"}, mtlsListenerTLS(), RoleChainListener},
{"binding on plain listener", &config.AuthOptions{Type: MethodMTLS, NodeBinding: BindingForce}, mtlsListenerTLS(), RoleListener},
{"binding on dialer", &config.AuthOptions{Type: MethodMTLS, NodeBinding: BindingForce}, &config.TLSOptions{Enabled: true}, RoleDialer},
{"dialer skips verify", &config.AuthOptions{Type: MethodMTLS}, &config.TLSOptions{Enabled: true, InsecureSkipVerify: true}, RoleDialer},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if _, err := New(tc.auth, tc.tls, tc.role); err == nil {
t.Fatal("expected an error, got nil")
}
})
}
// A dialer needs TLS but not client_auth: it pins the server's identity
if _, err := New(&config.AuthOptions{Type: MethodMTLS}, &config.TLSOptions{Enabled: true}, RoleDialer); err != nil {
t.Fatalf("dialer policy rejected: %v", err)
}
}
func TestAuthorizeMatching(t *testing.T) {
p, err := New(&config.AuthOptions{
Type: MethodMTLS,
Allow: []string{"edge-01", " edge-02 "},
AllowPatterns: []string{`^relay-\d{2}$`},
}, mtlsListenerTLS(), RoleListener)
if err != nil {
t.Fatalf("New: %v", err)
}
if p.Unrestricted() {
t.Fatal("policy with an allow list reports unrestricted")
}
allowed := []string{"edge-01", "edge-02", "relay-07"}
for _, cn := range allowed {
id, err := p.Authorize(peerState(leafCN(cn)))
if err != nil {
t.Errorf("Authorize(%q): %v", cn, err)
continue
}
if id.Name != cn || id.Method != MethodMTLS {
t.Errorf("Authorize(%q) = %+v", cn, id)
}
}
denied := []string{"edge-99", "relay-007", "prefix-relay-07", "", "EDGE-01"}
for _, cn := range denied {
if _, err := p.Authorize(peerState(leafCN(cn))); err == nil {
t.Errorf("Authorize(%q) allowed, want rejection", cn)
}
}
if got, want := p.Rejected(), uint64(len(denied)); got != want {
t.Errorf("Rejected = %d, want %d", got, want)
}
stats := p.Stats()
if stats["auth_allowed"].(uint64) != uint64(len(allowed)) {
t.Errorf("auth_allowed = %v, want %d", stats["auth_allowed"], len(allowed))
}
if _, ok := stats["node_binding"]; ok {
t.Error("plain listener stats report node_binding")
}
}
// Empty allow and allow_patterns admits any CA-vouched identity, but still
// records it and still refuses a certificate with no usable identity field
func TestAuthorizeUnrestricted(t *testing.T) {
p, err := New(&config.AuthOptions{Type: MethodMTLS}, mtlsListenerTLS(), RoleListener)
if err != nil {
t.Fatalf("New: %v", err)
}
if !p.Unrestricted() {
t.Fatal("empty allow list should be unrestricted")
}
id, err := p.Authorize(peerState(leafCN("anyone")))
if err != nil || id.Name != "anyone" {
t.Fatalf("Authorize = (%+v, %v)", id, err)
}
if _, err := p.Authorize(peerState(leafCN(""))); err == nil {
t.Error("certificate with no CN was authorized")
}
if _, err := p.Authorize(&tls.ConnectionState{}); err == nil {
t.Error("connection with no peer certificate was authorized")
}
if _, err := p.Authorize(nil); err == nil {
t.Error("non-TLS connection was authorized")
}
}
func TestResolveNodeBindings(t *testing.T) {
newChain := func(binding string) *Policy {
p, err := New(&config.AuthOptions{Type: MethodMTLS, NodeBinding: binding}, mtlsListenerTLS(), RoleChainListener)
if err != nil {
t.Fatalf("New(%q): %v", binding, err)
}
return p
}
id := Identity{Name: "edge-01", Method: MethodMTLS}
// Default under mtls is force
if got := newChain("").NodeBinding(); got != BindingForce {
t.Errorf("default node_binding = %q, want %q", got, BindingForce)
}
// force ignores the declared label, however it was spoofed
force := newChain(BindingForce)
for _, declared := range []string{"edge-99", "", "edge-01"} {
node, err := force.ResolveNode(declared, "10.0.0.5", true, id)
if err != nil || node != "edge-01" {
t.Errorf("force ResolveNode(%q) = (%q, %v), want edge-01", declared, node, err)
}
}
if force.TrustsEntryNode(true) {
t.Error("force must not trust per-entry node labels")
}
// assert rejects a mismatch and an omission, and leaves per-entry labels
// alone so a relay can forward other nodes' entries
assert := newChain(BindingAssert)
node, err := assert.ResolveNode("edge-01", "10.0.0.5", true, id)
if err != nil || node != "edge-01" {
t.Errorf("assert ResolveNode(match) = (%q, %v)", node, err)
}
if _, err := assert.ResolveNode("edge-99", "10.0.0.5", true, id); err == nil {
t.Error("assert accepted a mismatched node label")
}
if _, err := assert.ResolveNode("", "10.0.0.5", true, id); err == nil {
t.Error("assert accepted a missing node label")
}
if !assert.TrustsEntryNode(true) || assert.TrustsEntryNode(false) {
t.Error("assert must leave per-entry node labels to trust_node")
}
// none leaves trust_node governing entirely
none := newChain(BindingNone)
if none.BindsNode() {
t.Error("node_binding none should not bind")
}
node, err = none.ResolveNode("edge-99", "10.0.0.5", true, id)
if err != nil || node != "edge-99" {
t.Errorf("none ResolveNode = (%q, %v), want edge-99", node, err)
}
node, err = none.ResolveNode("edge-99", "10.0.0.5", false, id)
if err != nil || node != "10.0.0.5" {
t.Errorf("none ResolveNode(no trust) = (%q, %v), want 10.0.0.5", node, err)
}
// Binding without an authenticated identity is a refusal, not a fallback
if _, err := force.ResolveNode("edge-01", "10.0.0.5", true, Identity{}); err == nil {
t.Error("force resolved a node without an identity")
}
}
func TestIdentityApply(t *testing.T) {
meta := map[string]any{"type": "tcp_chain"}
Identity{}.Apply(meta)
if len(meta) != 1 {
t.Fatalf("zero identity stamped metadata: %v", meta)
}
Identity{Name: "edge-01", Method: MethodMTLS}.Apply(meta)
if meta["auth_identity"] != "edge-01" || meta["auth_method"] != MethodMTLS {
t.Fatalf("metadata = %v", meta)
}
}
func TestVerifyConnectionPinsServer(t *testing.T) {
p, err := New(&config.AuthOptions{Type: MethodMTLS, Allow: []string{"relay.internal"}},
&config.TLSOptions{Enabled: true}, RoleDialer)
if err != nil {
t.Fatalf("New: %v", err)
}
if err := p.VerifyConnection(*peerState(leafCN("relay.internal"))); err != nil {
t.Errorf("pinned server rejected: %v", err)
}
if err := p.VerifyConnection(*peerState(leafCN("impostor.internal"))); err == nil {
t.Error("unpinned server accepted")
}
}
+94 -57
View File
@@ -208,28 +208,30 @@ type ConsoleSourceOptions struct {
// TCPChainSourceOptions defines settings for a stdlib TCP listener ingesting
// 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.
+30 -2
View File
@@ -153,11 +153,16 @@ func (p *Pipeline) initializeComponents() error {
// initSourceCapabilities checks and injects optional capabilities
func (p *Pipeline) initSourceCapabilities(s source.Source, cfg config.PluginSourceConfig) error {
// Initiate and activate source capabilities
var hasTLS, hasAuth bool
for _, c := range s.Capabilities() {
switch c {
// Network capabilities
case core.CapNetLimit, core.CapTLS, core.CapAuth:
case core.CapNetLimit:
continue // No-op for now, placeholder
case core.CapTLS:
hasTLS = true
case core.CapAuth:
hasAuth = true
// Session capabilities
case core.CapSessionAware:
@@ -169,17 +174,36 @@ func (p *Pipeline) initSourceCapabilities(s source.Source, cfg config.PluginSour
}
}
if err := checkAuthCapability(hasTLS, hasAuth); err != nil {
return fmt.Errorf("source %s: %w", cfg.ID, err)
}
return nil
}
// checkAuthCapability rejects a plugin that decides on peer identity without a
// transport that verifies one - the decision would rest on an unauthenticated
// claim
func checkAuthCapability(hasTLS, hasAuth bool) error {
if hasAuth && !hasTLS {
return fmt.Errorf("capability %q requires %q", core.CapAuth, core.CapTLS)
}
return nil
}
// initSinkCapabilities checks and injects optional capabilities
func (p *Pipeline) initSinkCapabilities(s sink.Sink, cfg config.PluginSinkConfig) error {
// Initiate and activate sink capabilities
var hasTLS, hasAuth bool
for _, c := range s.Capabilities() {
switch c {
// Network capabilities
case core.CapNetLimit, core.CapTLS, core.CapAuth:
case core.CapNetLimit:
continue // No-op for now, placeholder
case core.CapTLS:
hasTLS = true
case core.CapAuth:
hasAuth = true
// Session capabilities
case core.CapSessionAware:
@@ -191,6 +215,10 @@ func (p *Pipeline) initSinkCapabilities(s sink.Sink, cfg config.PluginSinkConfig
}
}
if err := checkAuthCapability(hasTLS, hasAuth); err != nil {
return fmt.Errorf("sink %s: %w", cfg.ID, err)
}
return nil
}
+72 -18
View File
@@ -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) {
+32 -13
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"io"
"maps"
"net"
"net/http"
"os"
@@ -15,6 +16,7 @@ import (
"sync/atomic"
"time"
"logwisp/internal/authz"
"logwisp/internal/chain"
"logwisp/internal/config"
"logwisp/internal/core"
@@ -58,6 +60,9 @@ type HTTPChainSink struct {
tlsEnabled bool
mtls bool
// Authorization: pins the downstream server's identity
auth *authz.Policy
client *http.Client
input chan core.TransportEvent
logger *log.Logger
@@ -136,6 +141,15 @@ func NewHTTPChainSinkPlugin(
if err != nil {
return nil, err
}
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleDialer)
if err != nil {
return nil, err
}
if authPolicy.Enabled() {
// Runs after the standard chain and hostname checks, so a server the
// policy rejects fails the handshake instead of the first request
tlsCfg.VerifyConnection = authPolicy.VerifyConnection
}
addr := net.JoinHostPort(opts.Host, strconv.FormatInt(opts.Port, 10))
@@ -166,6 +180,7 @@ func NewHTTPChainSinkPlugin(
node: node,
tlsEnabled: tlsCfg != nil,
mtls: tlsCfg != nil && len(tlsCfg.Certificates) > 0,
auth: authPolicy,
url: scheme + "://" + addr + opts.IngestPath,
client: &http.Client{Transport: transport},
input: make(chan core.TransportEvent, opts.BufferSize),
@@ -191,7 +206,8 @@ func NewHTTPChainSinkPlugin(
"target", t.url,
"node", node,
"tls", t.tlsEnabled,
"mtls", t.mtls)
"mtls", t.mtls,
"auth", authPolicy.Describe())
return t, nil
}
@@ -200,9 +216,9 @@ func (t *HTTPChainSink) Capabilities() []core.Capability {
caps := []core.Capability{core.CapSessionAware}
if t.tlsEnabled {
caps = append(caps, core.CapTLS)
if t.mtls {
caps = append(caps, core.CapAuth) // presents client identity (mTLS)
}
}
if t.auth.Enabled() {
caps = append(caps, core.CapAuth) // pins the server identity
}
return caps
}
@@ -249,21 +265,24 @@ func (t *HTTPChainSink) Stop() {
// GetStats returns sink statistics
func (t *HTTPChainSink) GetStats() sink.SinkStats {
lastProc, _ := t.lastProcessed.Load().(time.Time)
details := map[string]any{
"target": t.url,
"node": t.node,
"tls": t.tlsEnabled,
"batches_sent": t.batchesSent.Load(),
"request_errors": t.requestErrors.Load(),
"dropped_batches": t.droppedBatches.Load(),
"synthesized": t.synthesized.Load(),
}
maps.Copy(details, t.auth.Stats())
return sink.SinkStats{
ID: t.id,
Type: "http_chain",
TotalProcessed: t.totalProcessed.Load(),
StartTime: t.startTime,
LastProcessed: lastProc,
Details: map[string]any{
"target": t.url,
"node": t.node,
"tls": t.tlsEnabled,
"batches_sent": t.batchesSent.Load(),
"request_errors": t.requestErrors.Load(),
"dropped_batches": t.droppedBatches.Load(),
"synthesized": t.synthesized.Load(),
},
Details: details,
}
}
+57 -17
View File
@@ -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,
}
}
+32 -13
View File
@@ -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,
}
}
+82 -24
View File
@@ -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
+74 -23
View File
@@ -6,12 +6,14 @@ import (
"crypto/tls"
"errors"
"fmt"
"maps"
"net"
"strconv"
"sync"
"sync/atomic"
"time"
"logwisp/internal/authz"
"logwisp/internal/chain"
"logwisp/internal/config"
"logwisp/internal/core"
@@ -50,6 +52,9 @@ type TCPChainSource struct {
tlsConfig *tls.Config
tlsHandshakeErrors atomic.Uint64
// Authorization
auth *authz.Policy
mu sync.RWMutex
ctx context.Context
cancel context.CancelFunc
@@ -91,6 +96,10 @@ func NewTCPChainSourcePlugin(
if err != nil {
return nil, err
}
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleChainListener)
if err != nil {
return nil, err
}
s := &TCPChainSource{
id: id,
@@ -100,6 +109,7 @@ func NewTCPChainSourcePlugin(
conns: make(map[net.Conn]struct{}),
logger: logger,
tlsConfig: tlsCfg,
auth: authPolicy,
}
s.lastEntryTime.Store(time.Time{})
@@ -109,7 +119,21 @@ func NewTCPChainSourcePlugin(
"host", opts.Host,
"port", opts.Port,
"tls", tlsCfg != nil,
"mtls", tlsCfg != nil && tlsCfg.ClientAuth == tls.RequireAndVerifyClientCert)
"mtls", tlsCfg != nil && tlsCfg.ClientAuth == tls.RequireAndVerifyClientCert,
"auth", authPolicy.Describe())
if authPolicy.Unrestricted() {
logger.Warn("msg", "Auth policy admits any identity the configured CA vouches for",
"component", "tcp_chain_source",
"instance_id", id,
"hint", "set auth.allow or auth.allow_patterns to authorize named peers")
}
if authPolicy.BindsNode() {
logger.Info("msg", "Node labels bound to peer identity; trust_node is ignored",
"component", "tcp_chain_source",
"instance_id", id,
"node_binding", authPolicy.NodeBinding(),
"trust_node", opts.TrustNode)
}
return s, nil
}
@@ -118,9 +142,9 @@ func (s *TCPChainSource) Capabilities() []core.Capability {
caps := []core.Capability{core.CapSessionAware, core.CapMultiSession}
if s.tlsConfig != nil {
caps = append(caps, core.CapTLS)
if s.tlsConfig.ClientAuth == tls.RequireAndVerifyClientCert {
caps = append(caps, core.CapAuth) // mTLS is authentication
}
}
if s.auth.Enabled() {
caps = append(caps, core.CapAuth) // authorizes peers, not just the CA
}
return caps
}
@@ -191,6 +215,18 @@ func (s *TCPChainSource) Stop() {
// GetStats returns the source's statistics
func (s *TCPChainSource) GetStats() source.SourceStats {
lastEntry, _ := s.lastEntryTime.Load().(time.Time)
details := map[string]any{
"host": s.config.Host,
"port": s.config.Port,
"tls": s.tlsConfig != nil,
"tls_handshake_errors": s.tlsHandshakeErrors.Load(),
"active_connections": s.activeConns.Load(),
"rejected_conns": s.rejectedConns.Load(),
"parse_errors": s.parseErrors.Load(),
"trust_node": s.config.TrustNode,
}
maps.Copy(details, s.auth.Stats())
return source.SourceStats{
ID: s.id,
Type: "tcp_chain",
@@ -198,16 +234,7 @@ func (s *TCPChainSource) GetStats() source.SourceStats {
DroppedEntries: s.droppedEntries.Load(),
StartTime: s.startTime,
LastEntryTime: lastEntry,
Details: map[string]any{
"host": s.config.Host,
"port": s.config.Port,
"tls": s.tlsConfig != nil,
"tls_handshake_errors": s.tlsHandshakeErrors.Load(),
"active_connections": s.activeConns.Load(),
"rejected_conns": s.rejectedConns.Load(),
"parse_errors": s.parseErrors.Load(),
"trust_node": s.config.TrustNode,
},
Details: details,
}
}
@@ -276,6 +303,18 @@ func (s *TCPChainSource) handleConn(conn net.Conn) {
tlsState = &cs
}
// Authorize before a preamble is parsed on an unauthorized peer's behalf
ident, err := s.auth.Authorize(tlsState)
if err != nil {
s.rejectedConns.Add(1)
s.logger.Warn("msg", "Connection rejected by auth policy",
"component", "tcp_chain_source",
"instance_id", s.id,
"remote_addr", remote,
"error", err)
return // deferred cleanup closes conn
}
scanner := bufio.NewScanner(conn)
// Oversized line (> MaxLogEntryBytes) is a protocol violation; scanner is
// unrecoverable after ErrTooLong, connection terminates
@@ -299,14 +338,24 @@ func (s *TCPChainSource) handleConn(conn net.Conn) {
return
}
connNode := hello.Node
if connNode == "" || !s.config.TrustNode {
if host, _, splitErr := net.SplitHostPort(remote); splitErr == nil {
connNode = host
} else {
connNode = remote
}
fallbackNode := remote
if host, _, splitErr := net.SplitHostPort(remote); splitErr == nil {
fallbackNode = host
}
connNode, err := s.auth.ResolveNode(hello.Node, fallbackNode, s.config.TrustNode, ident)
if err != nil {
s.rejectedConns.Add(1)
s.logger.Warn("msg", "Connection rejected by node binding",
"component", "tcp_chain_source",
"instance_id", s.id,
"remote_addr", remote,
"declared_node", hello.Node,
"error", err)
return
}
// force relabels every entry, so an edge cannot smuggle a foreign origin
// through the per-entry node field either
trustEntryNode := s.auth.TrustsEntryNode(s.config.TrustNode)
meta := map[string]any{
"type": "tcp_chain",
@@ -318,13 +367,15 @@ func (s *TCPChainSource) handleConn(conn net.Conn) {
meta["tls_peer_cn"] = cn
}
}
ident.Apply(meta)
sess := s.proxy.CreateSession(remote, meta)
sessID = sess.ID
s.logger.Info("msg", "Chain connection established",
"component", "tcp_chain_source",
"remote_addr", remote,
"node", connNode)
"node", connNode,
"auth_identity", ident.Name)
idle := time.Duration(s.config.ReadTimeoutMS) * time.Millisecond
for {
@@ -348,7 +399,7 @@ func (s *TCPChainSource) handleConn(conn net.Conn) {
}
s.proxy.UpdateActivity(sessID)
entry, err := chain.DecodeEntry(line, connNode, s.config.TrustNode)
entry, err := chain.DecodeEntry(line, connNode, trustEntryNode)
if err != nil {
s.parseErrors.Add(1)
s.logger.Debug("msg", "Dropped malformed chain entry",
+35 -1
View File
@@ -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