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

+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)
}
}
}