v0.20.0 mtls hardening, fail-closed config, startup warnings, sink fixes

This commit is contained in:
lixen committed 2026-10-02 05:00:33 -04:00
1 parent 815ef345ea
commit f192c26fa2
37 files changed
+1042 -152

No files matched your search

+70
View File
@@ -12,11 +12,14 @@ import (
"crypto/tls"
"fmt"
"regexp"
"regexp/syntax"
"strings"
"sync/atomic"
"logwisp/internal/config"
"logwisp/internal/tlsx"
"github.com/lixenwraith/log"
)
// Authentication methods
@@ -276,6 +279,73 @@ func (p *Policy) Unrestricted() bool {
return p != nil && len(p.allow) == 0 && len(p.patterns) == 0
}
// LogStartup reports, once per plugin construction, what a listener policy
// admits, how it labels nodes, and allow patterns that admit more than they
// appear to. trustNode matters only for chain sources.
func (p *Policy) LogStartup(l *log.Logger, component, id string, trustNode bool) {
if p == nil || p.role == RoleDialer {
return
}
if p.Unrestricted() {
l.Warn("msg", "Auth policy admits any identity the configured CA vouches for",
"component", component,
"instance_id", id,
"hint", "set auth.allow or auth.allow_patterns to authorize named peers")
}
if p.BindsNode() {
l.Info("msg", "Node labels bound to peer identity; trust_node is ignored",
"component", component,
"instance_id", id,
"node_binding", p.binding,
"trust_node", trustNode)
}
for _, re := range p.patterns {
if !anchored(re.String()) {
l.Warn("msg", "auth.allow_patterns entry is not anchored and matches any identity containing it",
"component", component,
"instance_id", id,
"pattern", re.String(),
"hint", "anchor every alternative, e.g. ^(edge-01|edge-02)$")
}
}
}
// anchored reports whether every match of pattern spans the whole identity:
// each alternative must start with ^ and end with $. "^a|b$" is the classic
// miss, admitting "a..." and "...b".
func anchored(pattern string) bool {
re, err := syntax.Parse(pattern, syntax.Perl)
if err != nil {
return false
}
re = re.Simplify()
return anchoredAt(re, syntax.OpBeginText) && anchoredAt(re, syntax.OpEndText)
}
// anchoredAt follows the matching edge of re (first or last element of a
// concatenation, every branch of an alternation) down to the anchor op.
func anchoredAt(re *syntax.Regexp, anchor syntax.Op) bool {
switch re.Op {
case anchor:
return true
case syntax.OpCapture:
return anchoredAt(re.Sub[0], anchor)
case syntax.OpConcat:
if anchor == syntax.OpBeginText {
return anchoredAt(re.Sub[0], anchor)
}
return anchoredAt(re.Sub[len(re.Sub)-1], anchor)
case syntax.OpAlternate:
for _, sub := range re.Sub {
if !anchoredAt(sub, anchor) {
return false
}
}
return true
}
return false
}
// Describe renders the policy for a startup log line
func (p *Policy) Describe() string {
if p == nil {
+19
View File
@@ -296,3 +296,22 @@ func TestVerifyConnectionPinsServer(t *testing.T) {
t.Error("unpinned server accepted")
}
}
// Only patterns whose every alternative is pinned at both ends stay silent at
// startup; "^a|b$" reads as anchored but admits "a..." and "...b".
func TestAllowPatternAnchoring(t *testing.T) {
for pattern, want := range map[string]bool{
`^edge-\d{2}$`: true,
`^(edge-01|edge-02)$`: true,
`^edge-a$|^edge-bc$`: true, // the parser factors this into ^edge-(?:a$|bc$)
`\Aedge\z`: true,
`edge-\d{2}`: false,
`^a|b$`: false,
`^ab$|^ac`: false,
`^a$|`: false,
} {
if got := anchored(pattern); got != want {
t.Errorf("anchored(%q) = %v, want %v", pattern, got, want)
}
}
}
+38 -3
View File
@@ -9,8 +9,6 @@ import (
"time"
"logwisp/internal/testutil"
lconfig "github.com/lixenwraith/config"
)
func TestResolveConfigArguments(t *testing.T) {
@@ -185,7 +183,7 @@ allow = ["one", "two"]
t.Fatal(err)
}
opts := TCPSinkOptions{Host: "127.0.0.1", BufferSize: 1000}
if err := lconfig.ScanMap(cfg.Pipelines[0].PluginSinks[0].Config, &opts); err != nil {
if err := Scan(cfg.Pipelines[0].PluginSinks[0].Config, &opts); err != nil {
t.Fatal(err)
}
if opts.Host != "127.0.0.1" || opts.BufferSize != 1000 || opts.Port != 9000 || opts.WriteTimeoutMS != 9007199254740993 ||
@@ -193,3 +191,40 @@ allow = ["one", "two"]
t.Fatalf("plugin defaults, nested tables or integer precision lost: %+v", opts)
}
}
// A misspelled key, even inside a nested tls or auth table, fails plugin
// construction instead of leaving the protection it named switched off.
func TestPluginConfigRejectsUnknownKeys(t *testing.T) {
isolateConfig(t)
for key, table := range map[string]string{
"bogus": "bogus = true",
"tls.enabeld": "[pipelines.plugin_sinks.config.tls]\nenabeld = true",
"auth.tpye": "[pipelines.plugin_sinks.config.auth]\ntpye = \"mtls\"",
} {
testutil.WriteFile(t, "plugins.toml", `
[[pipelines]]
name = "typo"
[[pipelines.plugin_sources]]
id = "in"
type = "null"
[[pipelines.plugin_sinks]]
id = "out"
type = "tcp"
[pipelines.plugin_sinks.config]
port = 9000
`+table+"\n")
m, err := Load([]string{"-c", "plugins.toml"})
if err != nil {
t.Fatal(err)
}
cfg, err := m.Snapshot()
m.Close()
if err != nil {
t.Fatal(err)
}
err = Scan(cfg.Pipelines[0].PluginSinks[0].Config, &TCPSinkOptions{})
if err == nil || !strings.Contains(err.Error(), `"`+key+`"`) {
t.Errorf("unknown key %s: err = %v", key, err)
}
}
}
+56
View File
@@ -2,6 +2,10 @@ package config
import (
"fmt"
"maps"
"reflect"
"slices"
"strings"
lconfig "github.com/lixenwraith/config"
)
@@ -81,3 +85,55 @@ func validateLogConfig(cfg *LogConfig) error {
return nil
}
// Scan decodes a plugin's config map into target, rejecting keys target does
// not declare: a misspelled tls or auth key must fail, not silently disable
// the protection it was meant to configure.
func Scan(configMap map[string]any, target any) error {
if err := unknownKeys(configMap, reflect.TypeOf(target), ""); err != nil {
return err
}
return lconfig.ScanMap(configMap, target)
}
// unknownKeys walks nested tables against the toml tags of t. Map-typed fields
// hold free-form keys and are not descended into.
func unknownKeys(m map[string]any, t reflect.Type, prefix string) error {
for t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return nil
}
fields := make(map[string]reflect.Type, t.NumField())
for f := range t.Fields() {
if name, _, _ := strings.Cut(f.Tag.Get("toml"), ","); name != "" && name != "-" {
fields[name] = f.Type
}
}
for _, key := range slices.Sorted(maps.Keys(m)) {
ft, ok := fields[key]
if !ok {
return fmt.Errorf("unknown key %q", prefix+key)
}
var tables []map[string]any
switch v := m[key].(type) {
case map[string]any:
tables = []map[string]any{v}
case []map[string]any:
tables = v
case []any:
for _, e := range v {
if table, ok := e.(map[string]any); ok {
tables = append(tables, table)
}
}
}
for _, table := range tables {
if err := unknownKeys(table, ft, prefix+key+"."); err != nil {
return err
}
}
}
return nil
}
+2 -2
View File
@@ -65,7 +65,7 @@ func NewConsoleSinkPlugin(
opts := &config.ConsoleSinkOptions{}
// Scan config map into struct
if err := lconfig.ScanMap(configMap, opts); err != nil {
if err := config.Scan(configMap, opts); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
@@ -206,4 +206,4 @@ func (cs *ConsoleSink) processLoop(ctx context.Context) {
return
}
}
}
}
+2 -2
View File
@@ -68,7 +68,7 @@ func NewFileSinkPlugin(
opts := &config.FileSinkOptions{}
// Scan config map into struct
if err := lconfig.ScanMap(configMap, opts); err != nil {
if err := config.Scan(configMap, opts); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
@@ -259,4 +259,4 @@ func (fs *FileSink) processLoop(ctx context.Context) {
return
}
}
}
}
+7 -8
View File
@@ -107,7 +107,7 @@ func NewHTTPSinkPlugin(
Host: DefaultHTTPHost,
WriteTimeoutMS: 0, // SSE indefinite streaming
}
if err := lconfig.ScanMap(configMap, opts); err != nil {
if err := config.Scan(configMap, opts); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
if err := lconfig.Port(opts.Port); err != nil {
@@ -167,12 +167,8 @@ func NewHTTPSinkPlugin(
"tls", tlsCfg != nil,
"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")
}
tlsx.LogWarnings(logger, "http_sink", id, opts.TLS, true)
authPolicy.LogStartup(logger, "http_sink", id, false)
return h, nil
}
@@ -407,7 +403,10 @@ func (h *HTTPSink) handleStream(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Access-Control-Allow-Origin", "*")
if !h.auth.Enabled() {
// An authenticated stream is not offered to every web origin
w.Header().Set("Access-Control-Allow-Origin", "*")
}
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
+50
View File
@@ -2,13 +2,18 @@ package http
import (
"bufio"
"crypto/tls"
"crypto/x509"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"logwisp/internal/sink"
"logwisp/internal/testutil"
)
func TestStatusReportsQueueAndConnectionBounds(t *testing.T) {
@@ -119,3 +124,48 @@ func TestHeadOnStreamPathIsRefused(t *testing.T) {
t.Errorf("sessions after HEAD = %d, want 0", n)
}
}
// A stream gated by an auth policy is not offered to every web origin; an
// open stream keeps the wildcard so browser dashboards still work.
func TestWildcardCORSOnlyWithoutAuth(t *testing.T) {
pki := testutil.NewPKI(t, "viewer-01")
caPEM, err := os.ReadFile(pki.CA)
if err != nil {
t.Fatal(err)
}
roots := x509.NewCertPool()
roots.AppendCertsFromPEM(caPEM)
clientCert, err := tls.LoadX509KeyPair(pki.ClientCert, pki.ClientKey)
if err != nil {
t.Fatal(err)
}
open, _ := newTestHTTPSink(t, nil)
gated, _ := newTestHTTPSink(t, map[string]any{
"tls": map[string]any{"enabled": true, "cert_file": pki.ServerCert, "key_file": pki.ServerKey,
"client_auth": true, "client_ca_file": pki.CA},
"auth": map[string]any{"type": "mtls", "allow": []any{"viewer-01"}},
})
for _, tc := range []struct {
name string
sink *HTTPSink
want string
}{{"open", open, "*"}, {"gated", gated, ""}} {
client, baseURL := serveTestHTTPSink(t, tc.sink)
if tc.sink.tlsConfig != nil {
client.Transport = &http.Transport{TLSClientConfig: &tls.Config{RootCAs: roots, Certificates: []tls.Certificate{clientCert}}}
baseURL = "https" + strings.TrimPrefix(baseURL, "http")
}
resp, err := client.Get(baseURL + "/stream")
if err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s: GET /stream = %d", tc.name, resp.StatusCode)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != tc.want {
t.Errorf("%s: Access-Control-Allow-Origin = %q, want %q", tc.name, got, tc.want)
}
}
}
+10 -4
View File
@@ -44,6 +44,7 @@ const (
DefaultHTTPChainSinkRequestTimeoutMS = 10000
DefaultHTTPChainSinkBackoffMinMS = 500
DefaultHTTPChainSinkBackoffMaxMS = 30000
maxResponseDrain = 64 * 1024
)
// HTTPChainSink batches structured entries and posts NDJSON to a downstream
@@ -92,7 +93,7 @@ func NewHTTPChainSinkPlugin(
proxy *session.Proxy,
) (sink.Sink, error) {
opts := &config.HTTPChainSinkOptions{}
if err := lconfig.ScanMap(configMap, opts); err != nil {
if err := config.Scan(configMap, opts); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
if err := lconfig.NonEmpty(opts.Host); err != nil {
@@ -182,7 +183,11 @@ func NewHTTPChainSinkPlugin(
mtls: tlsCfg != nil && len(tlsCfg.Certificates) > 0,
auth: authPolicy,
url: scheme + "://" + addr + opts.IngestPath,
client: &http.Client{Transport: transport},
// A redirect would resend the batch, even from https to plaintext http
client: &http.Client{
Transport: transport,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
},
input: make(chan core.TransportEvent, opts.BufferSize),
done: make(chan struct{}),
logger: logger,
@@ -208,6 +213,7 @@ func NewHTTPChainSinkPlugin(
"tls", t.tlsEnabled,
"mtls", t.mtls,
"auth", authPolicy.Describe())
tlsx.LogWarnings(logger, "http_chain_sink", id, opts.TLS, false)
return t, nil
}
@@ -436,8 +442,8 @@ func (t *HTTPChainSink) post(ctx context.Context, body []byte) (transient bool,
return true, err
}
defer resp.Body.Close()
// Drain for connection reuse
io.Copy(io.Discard, resp.Body)
// Drain for connection reuse, bounded so a hostile peer cannot stream forever
io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseDrain))
switch {
case resp.StatusCode >= 200 && resp.StatusCode < 300:
+47
View File
@@ -0,0 +1,47 @@
package httpchain
import (
"net"
"net/http"
"net/http/httptest"
"strconv"
"sync/atomic"
"testing"
"time"
"logwisp/internal/session"
"github.com/lixenwraith/log"
)
// A redirect is never followed: it would resend the batch wherever the
// response points, plaintext http included.
func TestRedirectIsNotFollowed(t *testing.T) {
var leaked atomic.Bool
target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { leaked.Store(true) }))
t.Cleanup(target.Close)
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL+r.URL.Path, http.StatusPermanentRedirect)
}))
t.Cleanup(redirector.Close)
host, port, err := net.SplitHostPort(redirector.Listener.Addr().String())
if err != nil {
t.Fatal(err)
}
p, _ := strconv.ParseInt(port, 10, 64)
manager := session.NewManager(time.Hour)
t.Cleanup(manager.Stop)
created, err := NewHTTPChainSinkPlugin("fwd", map[string]any{"host": host, "port": p},
log.NewLogger(), session.NewProxy(manager, "fwd"))
if err != nil {
t.Fatal(err)
}
transient, err := created.(*HTTPChainSink).post(t.Context(), []byte("{}\n"))
if err == nil || transient {
t.Fatalf("post = (transient %v, %v), want a permanent error", transient, err)
}
if leaked.Load() {
t.Fatal("the batch followed the redirect")
}
}
+5 -1
View File
@@ -6,6 +6,7 @@ import (
"sync/atomic"
"time"
"logwisp/internal/config"
"logwisp/internal/core"
"logwisp/internal/plugin"
"logwisp/internal/session"
@@ -49,6 +50,9 @@ func NewNullSinkPlugin(
logger *log.Logger,
proxy *session.Proxy,
) (sink.Sink, error) {
if err := config.Scan(configMap, &config.NullSinkOptions{}); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
ns := &NullSink{
id: id,
proxy: proxy,
@@ -143,4 +147,4 @@ func (ns *NullSink) processLoop(ctx context.Context) {
return
}
}
}
}
+25 -18
View File
@@ -58,8 +58,10 @@ type TCPSink struct {
input chan core.TransportEvent
logger *log.Logger
// Client registry
// Client registry. conns holds every accepted connection, so shutdown also
// reaches peers still in the handshake, before they become clients.
clients map[uint64]*tcpClient
conns map[net.Conn]struct{}
clientsMu sync.Mutex
nextClientID atomic.Uint64
writeTimeout time.Duration
@@ -86,11 +88,10 @@ type TCPSink struct {
lastProcessed atomic.Value // time.Time
}
// tcpClient pairs a connection with its bounded send queue.
// tcpClient is a registered connection's bounded send queue.
// send is written by the broadcast loop (non-blocking) and drained by the
// writer goroutine; closed signals reader-detected disconnect.
type tcpClient struct {
conn net.Conn
send chan []byte
sessionID string
closed chan struct{}
@@ -107,7 +108,7 @@ func NewTCPSinkPlugin(
Host: DefaultTCPHost,
KeepAlive: true,
}
if err := lconfig.ScanMap(configMap, opts); err != nil {
if err := config.Scan(configMap, opts); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
if err := lconfig.Port(opts.Port); err != nil {
@@ -143,6 +144,7 @@ func NewTCPSinkPlugin(
done: make(chan struct{}),
logger: logger,
clients: make(map[uint64]*tcpClient),
conns: make(map[net.Conn]struct{}),
writeTimeout: time.Duration(opts.WriteTimeoutMS) * time.Millisecond,
tlsConfig: tlsCfg,
auth: authPolicy,
@@ -157,12 +159,8 @@ func NewTCPSinkPlugin(
"tls", tlsCfg != nil,
"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")
}
tlsx.LogWarnings(logger, "tcp_sink", id, opts.TLS, true)
authPolicy.LogStartup(logger, "tcp_sink", id, false)
return t, nil
}
@@ -258,8 +256,8 @@ func (t *TCPSink) shutdown() {
t.listener.Close() // unblocks acceptLoop
}
t.clientsMu.Lock()
for _, c := range t.clients {
c.conn.Close() // unblocks per-connection readers
for conn := range t.conns {
conn.Close() // unblocks handshakes and per-connection readers
}
t.clientsMu.Unlock()
})
@@ -287,9 +285,17 @@ func (t *TCPSink) acceptLoop() {
continue
}
// Certificate authorization runs in handleConn post-handshake,
// pre-registration. Password-auth extension point: preamble
// verification belongs at the same place.
t.clientsMu.Lock()
select {
case <-t.done:
// shutdown already swept conns; this one would outlive it
t.clientsMu.Unlock()
conn.Close()
return
default:
}
t.conns[conn] = struct{}{}
t.clientsMu.Unlock()
t.wg.Add(1)
go t.handleConn(conn)
@@ -305,6 +311,10 @@ func (t *TCPSink) handleConn(conn net.Conn) {
// Counted from accept: max_connections bounds concurrent handshakes too
count := t.activeConns.Add(1)
defer func() {
conn.Close()
t.clientsMu.Lock()
delete(t.conns, conn)
t.clientsMu.Unlock()
newCount := t.activeConns.Add(-1)
t.logger.Debug("msg", "TCP connection closed",
"component", "tcp_sink",
@@ -327,7 +337,6 @@ func (t *TCPSink) handleConn(conn net.Conn) {
"component", "tcp_sink",
"remote_addr", remote,
"error", err)
conn.Close()
return
}
cs := tc.ConnectionState()
@@ -348,14 +357,12 @@ func (t *TCPSink) handleConn(conn net.Conn) {
"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,
send: make(chan []byte, t.config.ClientBufferSize),
sessionID: sess.ID,
closed: make(chan struct{}),
+59
View File
@@ -0,0 +1,59 @@
package tcp
import (
"net"
"strconv"
"testing"
"time"
"logwisp/internal/session"
"logwisp/internal/testutil"
"github.com/lixenwraith/log"
)
// A peer that connects and never completes the TLS handshake does not hold
// Stop for the handshake timeout: shutdown reaches connections before they
// become clients.
func TestStopDoesNotWaitForSilentHandshake(t *testing.T) {
pki := testutil.NewPKI(t, "viewer")
probe, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := probe.Addr().String()
probe.Close()
_, port, _ := net.SplitHostPort(addr)
p, _ := strconv.ParseInt(port, 10, 64)
manager := session.NewManager(time.Hour)
t.Cleanup(manager.Stop)
created, err := NewTCPSinkPlugin("out", map[string]any{
"host": "127.0.0.1",
"port": p,
"tls": map[string]any{"enabled": true, "cert_file": pki.ServerCert, "key_file": pki.ServerKey},
}, log.NewLogger(), session.NewProxy(manager, "out"))
if err != nil {
t.Fatal(err)
}
sink := created.(*TCPSink)
if err := sink.Start(t.Context()); err != nil {
t.Fatal(err)
}
conn, err := net.Dial("tcp4", addr)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { conn.Close() })
for deadline := time.Now().Add(2 * time.Second); sink.activeConns.Load() == 0; time.Sleep(10 * time.Millisecond) {
if time.Now().After(deadline) {
t.Fatal("connection never reached the handshake")
}
}
start := time.Now()
sink.Stop()
if d := time.Since(start); d > 2*time.Second {
t.Fatalf("Stop took %v with a silent peer in the handshake", d)
}
}
+2 -34
View File
@@ -6,7 +6,6 @@ import (
"encoding/json"
"fmt"
"maps"
"math/rand/v2"
"net"
"os"
"strconv"
@@ -88,7 +87,7 @@ func NewTCPChainSinkPlugin(
opts := &config.TCPChainSinkOptions{
KeepAlive: true,
}
if err := lconfig.ScanMap(configMap, opts); err != nil {
if err := config.Scan(configMap, opts); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
if err := lconfig.NonEmpty(opts.Host); err != nil {
@@ -179,6 +178,7 @@ func NewTCPChainSinkPlugin(
"tls", tlsCfg != nil,
"mtls", tlsCfg != nil && len(tlsCfg.Certificates) > 0,
"auth", authPolicy.Describe())
tlsx.LogWarnings(logger, "tcp_chain_sink", id, opts.TLS, false)
return t, nil
}
@@ -299,24 +299,6 @@ func (t *TCPChainSink) runLoop(ctx context.Context) {
}
}
// toEntry extracts the structured entry, stamping node identity at first hop
func (t *TCPChainSink) toEntry(event core.TransportEvent) core.LogEntry {
entry := event.Entry
if entry.Time.IsZero() {
// Defensive: event without structured entry, wrap formatted payload
t.synthesized.Add(1)
entry = core.LogEntry{
Time: event.Time,
Source: t.id,
Message: string(event.Payload),
}
}
if entry.Node == "" {
entry.Node = t.node
}
return entry
}
// deliver writes one line, holding it across reconnects until sent or shutdown.
// Backpressure during outage propagates to the pipeline dispatch drop counter.
func (t *TCPChainSink) deliver(ctx context.Context, line []byte) bool {
@@ -427,17 +409,3 @@ func (t *TCPChainSink) waitBackoff(ctx context.Context, failures int) bool {
return false
}
}
// backoffDelay computes exponential backoff with ±20% jitter
func (t *TCPChainSink) backoffDelay(failures int) time.Duration {
minD := time.Duration(t.config.BackoffMinMS) * time.Millisecond
maxD := time.Duration(t.config.BackoffMaxMS) * time.Millisecond
d := maxD
if failures < 63 {
if v := minD << uint(failures-1); v > 0 && v < maxD {
d = v
}
}
return d - d/5 + time.Duration(rand.Int64N(int64(2*d/5)+1))
}
+2 -3
View File
@@ -13,7 +13,6 @@ import (
"logwisp/internal/session"
"logwisp/internal/source"
lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log"
)
@@ -70,7 +69,7 @@ func NewConsoleSourcePlugin(
opts := &config.ConsoleSourceOptions{}
// Scan config map
if err := lconfig.ScanMap(configMap, opts); err != nil {
if err := config.Scan(configMap, opts); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
@@ -221,4 +220,4 @@ func (s *ConsoleSource) publish(entry core.LogEntry) {
"component", "console_source")
}
}
}
}
+1 -1
View File
@@ -75,7 +75,7 @@ func NewFileSourcePlugin(
opts := &config.FileSourceOptions{}
// Use lconfig to scan map into struct (overriding defaults)
if err := lconfig.ScanMap(configMap, opts); err != nil {
if err := config.Scan(configMap, opts); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
+3 -14
View File
@@ -85,7 +85,7 @@ func NewHTTPChainSourcePlugin(
Host: "0.0.0.0",
TrustNode: true,
}
if err := lconfig.ScanMap(configMap, opts); err != nil {
if err := config.Scan(configMap, opts); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
if err := lconfig.Port(opts.Port); err != nil {
@@ -135,19 +135,8 @@ func NewHTTPChainSourcePlugin(
"tls", tlsCfg != nil,
"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)
}
tlsx.LogWarnings(logger, "http_chain_source", id, opts.TLS, true)
authPolicy.LogStartup(logger, "http_chain_source", id, opts.TrustNode)
return s, nil
}
+5 -1
View File
@@ -5,6 +5,7 @@ import (
"sync/atomic"
"time"
"logwisp/internal/config"
"logwisp/internal/core"
"logwisp/internal/plugin"
"logwisp/internal/session"
@@ -47,6 +48,9 @@ func NewNullSourcePlugin(
logger *log.Logger,
proxy *session.Proxy,
) (source.Source, error) {
if err := config.Scan(configMap, &config.NullSourceOptions{}); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
ns := &NullSource{
id: id,
proxy: proxy,
@@ -122,4 +126,4 @@ func (ns *NullSource) GetStats() source.SourceStats {
LastEntryTime: lastEntry,
Details: map[string]any{},
}
}
}
+2 -2
View File
@@ -76,7 +76,7 @@ func NewRandomSourcePlugin(
}
// Scan config map
if err := lconfig.ScanMap(configMap, opts); err != nil {
if err := config.Scan(configMap, opts); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
@@ -355,4 +355,4 @@ func (rs *RandomSource) publish(entry core.LogEntry) {
rs.droppedEntries.Add(1)
}
}
}
}
+3 -14
View File
@@ -80,7 +80,7 @@ func NewTCPChainSourcePlugin(
Host: "0.0.0.0",
TrustNode: true,
}
if err := lconfig.ScanMap(configMap, opts); err != nil {
if err := config.Scan(configMap, opts); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
if err := lconfig.Port(opts.Port); err != nil {
@@ -121,19 +121,8 @@ func NewTCPChainSourcePlugin(
"tls", tlsCfg != nil,
"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)
}
tlsx.LogWarnings(logger, "tcp_chain_source", id, opts.TLS, true)
authPolicy.LogStartup(logger, "tcp_chain_source", id, opts.TrustNode)
return s, nil
}
+71
View File
@@ -3,9 +3,19 @@
package testutil
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// ClearEnvPrefix isolates overrides while preserving the caller's environment.
@@ -29,3 +39,64 @@ func WriteFile(t testing.TB, path, contents string) {
t.Fatalf("write fixture %q: %v", path, err)
}
}
// PKI is a throwaway CA with a server certificate for 127.0.0.1 and one client
// certificate, written as PEM files for plugins that load TLS from disk.
type PKI struct {
CA, ServerCert, ServerKey, ClientCert, ClientKey string
}
// NewPKI issues the PKI into a test directory; clientCN names the client.
func NewPKI(t testing.TB, clientCN string) PKI {
t.Helper()
dir := t.TempDir()
now := time.Now()
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
caTmpl := &x509.Certificate{
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "LogWisp Test CA"},
NotBefore: now.Add(-time.Hour), NotAfter: now.Add(24 * time.Hour),
IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign,
}
caDER, err := x509.CreateCertificate(rand.Reader, caTmpl, caTmpl, &caKey.PublicKey, caKey)
if err != nil {
t.Fatal(err)
}
ca, err := x509.ParseCertificate(caDER)
if err != nil {
t.Fatal(err)
}
pki := PKI{CA: writePEM(t, dir, "ca.crt", "CERTIFICATE", caDER)}
issue := func(name, cn string, serial int64, usage x509.ExtKeyUsage, ips []net.IP) (string, string) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(serial), Subject: pkix.Name{CommonName: cn},
NotBefore: now.Add(-time.Hour), NotAfter: now.Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{usage}, IPAddresses: ips,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, ca, &key.PublicKey, caKey)
if err != nil {
t.Fatal(err)
}
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatal(err)
}
return writePEM(t, dir, name+".crt", "CERTIFICATE", der), writePEM(t, dir, name+".key", "PRIVATE KEY", keyDER)
}
pki.ServerCert, pki.ServerKey = issue("server", "relay.internal", 2, x509.ExtKeyUsageServerAuth, []net.IP{net.IPv4(127, 0, 0, 1)})
pki.ClientCert, pki.ClientKey = issue("client", clientCN, 3, x509.ExtKeyUsageClientAuth, nil)
return pki
}
func writePEM(t testing.TB, dir, name, blockType string, der []byte) string {
t.Helper()
path := filepath.Join(dir, name)
WriteFile(t, path, string(pem.EncodeToMemory(&pem.Block{Type: blockType, Bytes: der})))
return path
}
+95 -2
View File
@@ -6,10 +6,12 @@ package tlsx
import (
"crypto/tls"
"crypto/x509"
"encoding/pem"
"fmt"
stdlog "log"
"os"
"strings"
"sync"
"time"
"logwisp/internal/config"
@@ -17,8 +19,12 @@ import (
"github.com/lixenwraith/log"
)
// HandshakeTimeout bounds TLS handshakes on both accept and dial paths
const HandshakeTimeout = 10 * time.Second
const (
// HandshakeTimeout bounds TLS handshakes on both accept and dial paths
HandshakeTimeout = 10 * time.Second
// expiryWindow is how far ahead startup announces certificate expiry
expiryWindow = 30 * 24 * time.Hour
)
// Server builds the *tls.Config for listener plugins
// (tcp/http sinks, tcp_chain/http_chain sources). Returns (nil, nil) when disabled.
@@ -136,6 +142,93 @@ func PeerIdentity(cs tls.ConnectionState, mode string) string {
return ""
}
// LogWarnings reports TLS settings that work but are likely mistakes, once per
// plugin construction, so each reload re-announces expiring certificates.
func LogWarnings(l *log.Logger, component, id string, o *config.TLSOptions, listener bool) {
if o == nil || !o.Enabled {
return
}
var warnings []string
if !listener && o.InsecureSkipVerify {
warnings = append(warnings, "tls.insecure_skip_verify disables server verification: any peer can impersonate the server")
}
files := []struct{ key, path string }{{"cert_file", o.CertFile}, {"ca_file", o.CAFile}}
if listener {
files[1] = struct{ key, path string }{"client_ca_file", o.ClientCAFile}
}
now := time.Now()
for _, f := range files {
for _, c := range readCerts(f.path) {
if w := expiryWarning(c, now); w != "" {
warnings = append(warnings, fmt.Sprintf("tls.%s %s: %s", f.key, f.path, w))
}
}
}
if w := SecretFileWarning("tls.key_file", o.KeyFile); w != "" {
warnings = append(warnings, w)
}
for _, w := range warnings {
l.Warn("msg", w, "component", component, "instance_id", id)
}
}
// expiryWarning describes a certificate outside, or about to leave, its
// validity window; a handshake failure is otherwise the first sign.
func expiryWarning(c *x509.Certificate, now time.Time) string {
name := c.Subject.CommonName
switch {
case now.After(c.NotAfter):
return fmt.Sprintf("certificate %q expired on %s", name, c.NotAfter.UTC().Format(time.DateOnly))
case now.Before(c.NotBefore):
return fmt.Sprintf("certificate %q is not valid until %s", name, c.NotBefore.UTC().Format(time.DateTime))
case c.NotAfter.Sub(now) < expiryWindow:
return fmt.Sprintf("certificate %q expires on %s", name, c.NotAfter.UTC().Format(time.DateOnly))
}
return ""
}
// readCerts parses every certificate in a PEM file. Constructors already
// loaded it successfully, so an unreadable file yields nothing here.
func readCerts(path string) []*x509.Certificate {
if path == "" {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
var certs []*x509.Certificate
for block, rest := pem.Decode(data); block != nil; block, rest = pem.Decode(rest) {
if block.Type != "CERTIFICATE" {
continue
}
if c, err := x509.ParseCertificate(block.Bytes); err == nil {
certs = append(certs, c)
}
}
return certs
}
// warnedSecrets keeps reloads from repeating a permission warning per path
var warnedSecrets sync.Map
// SecretFileWarning describes a secret file every local user can read, once
// per path per process. Group access is left alone: container secret mounts
// commonly grant it on purpose.
func SecretFileWarning(key, path string) string {
if path == "" {
return ""
}
fi, err := os.Stat(path)
if err != nil || fi.Mode().Perm()&0o004 == 0 {
return ""
}
if _, seen := warnedSecrets.LoadOrStore(path, struct{}{}); seen {
return ""
}
return fmt.Sprintf("%s %s is readable by all users (mode %04o)", key, path, fi.Mode().Perm())
}
// HTTPErrorLog adapts the structured logger for http.Server.ErrorLog so TLS
// handshake failures don't bypass log routing straight to stderr (which would
// violate the console sanitization policy).
+34
View File
@@ -0,0 +1,34 @@
package tlsx
import (
"crypto/x509"
"crypto/x509/pkix"
"strings"
"testing"
"time"
)
// Startup names a certificate outside its validity window or within 30 days
// of leaving it; a healthy one stays silent.
func TestExpiryWarning(t *testing.T) {
now := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)
day := 24 * time.Hour
cert := func(from, until time.Duration) *x509.Certificate {
return &x509.Certificate{Subject: pkix.Name{CommonName: "relay"}, NotBefore: now.Add(from), NotAfter: now.Add(until)}
}
for _, tc := range []struct {
name string
cert *x509.Certificate
want string
}{
{"valid", cert(-day, 90*day), ""},
{"expiring", cert(-day, 10*day), "expires on 2026-10-11"},
{"expired", cert(-90*day, -day), "expired on 2026-09-30"},
{"not yet valid", cert(day, 90*day), "not valid until"},
} {
got := expiryWarning(tc.cert, now)
if tc.want == "" && got != "" || !strings.Contains(got, tc.want) {
t.Errorf("%s: warning = %q, want %q", tc.name, got, tc.want)
}
}
}