v0.20.0 mtls hardening, fail-closed config, startup warnings, sink fixes
This commit is contained in:
1 parent
815ef345ea
commit
f192c26fa2
37 files changed
+1042
-152
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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{}),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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{},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user