Files
logwisp/internal/config/validate.go
T
lixen de87b9a008
Package / arch (push) Canceled after 0s
Package / user (push) Canceled after 0s
Package / deb (push) Canceled after 0s
Package / freebsd (push) Canceled after 0s
Package / image (push) Canceled after 0s
Test / test (push) Canceled after 0s
Test / e2e (push) Canceled after 0s
v0.35.2 lwconf, doc and deploy update
2026-10-13 14:23:37 -04:00

622 lines
21 KiB
Go

package config
import (
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"io/fs"
"maps"
"net/netip"
"os"
"path"
"reflect"
"regexp"
"slices"
"strings"
"github.com/lixenwraith/logwisp/internal/chain"
"github.com/lixenwraith/logwisp/internal/core"
lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/toml"
)
// ValidateConfig checks a configuration without reading a file it names:
// the settings, then each pipeline's flow stages, and its sources and sinks
// decoded in full. Its error joins every problem, one a part, each a
// *KeyError naming the path of the refused key. lw --check opens the files.
func ValidateConfig(cfg *Config) error {
if cfg == nil {
return fmt.Errorf("config is nil")
}
errs := []error{checkTags(reflect.ValueOf(cfg).Elem(), "")}
if l := cfg.Logging; l != nil {
errs = append(errs, checkTags(reflect.ValueOf(l).Elem(), "logging."))
if l.Console != nil {
errs = append(errs, checkTags(reflect.ValueOf(l.Console).Elem(), "logging.console."))
}
}
return errors.Join(append(errs, ValidatePipelines(cfg.Pipelines))...)
}
// ValidatePipelines is ValidateConfig's check of the pipelines
func ValidatePipelines(pipelines []PipelineConfig) error {
if len(pipelines) == 0 {
return fmt.Errorf("no pipelines configured")
}
var errs []error
at := func(path string, err error) { errs = append(errs, At(path, err)) }
names := map[string]bool{} // the service is keyed by name
single := map[string]string{} // role and type of a single-instance plugin: its pipeline
for i, p := range pipelines {
path := fmt.Sprintf("pipelines[%d]", i)
switch {
case strings.TrimSpace(p.Name) == "":
at(path+".name", errors.New("must be set"))
case names[p.Name]:
at(path+".name", fmt.Errorf("duplicate name %q", p.Name))
}
names[p.Name] = true
if len(p.PluginSources) == 0 {
at(path+".plugin_sources", errors.New("add a source; a pipeline needs at least one"))
}
if len(p.PluginSinks) == 0 {
at(path+".plugin_sinks", errors.New("add a sink; a pipeline needs at least one"))
}
if err := checkFlow(p.Flow, path+".flow"); err != nil {
errs = append(errs, err)
}
ids := map[string]bool{}
check := func(role, list, id, typ string, m map[string]any) {
path := fmt.Sprintf("%s.%s[%s]", path, list, id)
plugin, ok := LookupPlugin(role, typ)
switch {
case ids[list+id]:
at(path, errors.New("duplicate id"))
case !ok:
at(path+".type", fmt.Errorf("unknown %s type %q", role, typ))
case plugin.Single && single[role+typ] != "":
at(path, fmt.Errorf("one %s %s may run, and pipeline %q has it, as lw has one standard input; give this pipeline another %s", typ, role, single[role+typ], role))
default:
ids[list+id] = true
if plugin.Single {
single[role+typ] = p.Name
}
if _, err := decode(role, typ, m); err != nil {
at(path+".config", err)
}
}
}
for _, s := range p.PluginSources {
check("source", "plugin_sources", s.ID, s.Type, s.Config)
}
for _, s := range p.PluginSinks {
check("sink", "plugin_sinks", s.ID, s.Type, s.Config)
}
}
return errors.Join(errs...)
}
// checkFlow settles copies of a pipeline's flow stages, which keep their
// defaults for the dump: a problem a stage
func checkFlow(f *FlowConfig, path string) error {
if f == nil {
return nil
}
var errs []error
settle := func(key string, stage any) {
if err := Settle(stage); err != nil {
errs = append(errs, At(path+"."+key, err))
}
}
if f.RateLimit != nil {
settle("rate_limit", new(*f.RateLimit))
}
for i, filter := range f.Filters {
settle(fmt.Sprintf("filters[%d]", i), &filter)
}
if f.Format != nil {
settle("format", new(*f.Format))
}
if f.Heartbeat != nil {
settle("heartbeat", new(*f.Heartbeat))
}
return errors.Join(errs...)
}
// At addresses err to the path of the table it was found in
func At(path string, err error) error {
if ke, ok := errors.AsType[*KeyError](err); ok {
return &KeyError{path + "." + ke.Key, ke.Err}
}
return &KeyError{path, err}
}
// table addresses a rule between a table's keys, which its Check names by
// a "tls: " prefix, to the table
func table(key string, err error) error {
if _, ok := errors.AsType[*KeyError](err); err == nil || ok {
return err
}
return &KeyError{key, errors.New(strings.TrimPrefix(err.Error(), key+": "))}
}
// networked is the options of a plugin that listens or dials
type networked interface {
network() (host string, tls *TLSOptions, auth *AuthOptions, acl *ACLOptions)
}
func (o *TCPChainSourceOptions) network() (string, *TLSOptions, *AuthOptions, *ACLOptions) {
return o.Host, o.TLS, o.Auth, o.ACL
}
func (o *HTTPChainSourceOptions) network() (string, *TLSOptions, *AuthOptions, *ACLOptions) {
return o.Host, o.TLS, o.Auth, o.ACL
}
func (o *TCPSinkOptions) network() (string, *TLSOptions, *AuthOptions, *ACLOptions) {
return o.Host, o.TLS, o.Auth, o.ACL
}
func (o *HTTPSinkOptions) network() (string, *TLSOptions, *AuthOptions, *ACLOptions) {
return o.Host, o.TLS, o.Auth, o.ACL
}
func (o *TCPChainSinkOptions) network() (string, *TLSOptions, *AuthOptions, *ACLOptions) {
return o.Host, o.TLS, o.Auth, nil
}
func (o *HTTPChainSinkOptions) network() (string, *TLSOptions, *AuthOptions, *ACLOptions) {
return o.Host, o.TLS, o.Auth, nil
}
// checkNetwork applies the host, tls, acl and auth rules of the plugin's side
func (p Plugin) checkNetwork(n networked) error {
host, t, a, acl := n.network()
if _, err := core.Network(host); err != nil {
return &KeyError{"host", err}
}
if err := t.Check(p.Side != Dialer); err != nil {
return table("tls", err)
}
if err := acl.Check(host, p.HTTP, a != nil && len(a.TrustedProxies) > 0); err != nil {
return table("acl", err)
}
return table("auth", a.Check(p.Side, p.HTTP, t.State()))
}
// Check holds the rules between tls keys on a listener or a dialer; a table
// not enabled is ignored whole
func (o *TLSOptions) Check(listener bool) error {
if o == nil || !o.Enabled {
return nil
}
v := reflect.ValueOf(o).Elem()
if err := checkTags(v, "tls."); err != nil {
return err
}
other := "dialer"
if !listener {
other = "listener"
}
for _, opt := range options(v.Type()) {
if f := v.Field(opt.index); opt.Side == other && !f.IsZero() && (f.Kind() != reflect.Slice || f.Len() > 0) {
return fmt.Errorf("tls: %s apply to %ss", and(sideKeys(v.Type(), other)), other)
}
}
files, issuer := o.CertFile != "" || o.KeyFile != "", o.IssuerCertFile != "" || o.IssuerKeyFile != ""
switch {
case !listener && o.PinSHA256 != "" && (o.CAFile != "" || o.InsecureSkipVerify):
return fmt.Errorf("tls: pin_sha256 replaces ca_file and insecure_skip_verify: set one")
case !listener:
case files && (issuer || o.SelfSigned) || issuer && o.SelfSigned:
return fmt.Errorf("tls: set one of cert_file and key_file, self_signed, or issuer_cert_file and issuer_key_file")
case len(o.Hosts) > 0 && !issuer && !o.SelfSigned:
return fmt.Errorf("tls: hosts applies to self_signed and issuer certificates")
case issuer && (o.IssuerCertFile == "" || o.IssuerKeyFile == ""):
return fmt.Errorf("tls: issuer_cert_file and issuer_key_file must be set together")
case !files && !issuer && !o.SelfSigned:
return fmt.Errorf("tls: listeners need cert_file and key_file, self_signed, or issuer_cert_file and issuer_key_file")
case o.ClientAuth && o.ClientCAFile == "":
return fmt.Errorf("tls: client_auth requires client_ca_file")
}
if (o.CertFile == "") != (o.KeyFile == "") {
return fmt.Errorf("tls: cert_file and key_file must be set together")
}
if o.PinSHA256 != "" {
_, err := ParsePins(o.PinSHA256)
return err
}
return nil
}
// PinPrefix starts each pin_sha256 entry
const PinPrefix = "sha256//"
// ParsePins reads pin_sha256: SHA-256 hashes of server keys, ';' between several
func ParsePins(s string) ([][]byte, error) {
var pins [][]byte
for p := range strings.SplitSeq(s, ";") {
b64, ok := strings.CutPrefix(strings.TrimSpace(p), PinPrefix)
h, err := base64.StdEncoding.DecodeString(b64)
if !ok || err != nil || len(h) != sha256.Size {
return nil, fmt.Errorf("tls: pin_sha256 %q: want %sBASE64 of a SHA-256, ';' between several", p, PinPrefix)
}
pins = append(pins, h)
}
return pins, nil
}
// TLSState is what the auth rules ask of the tls table beside them
type TLSState struct {
On bool // enabled
ClientAuth bool // a listener verifies client certificates
Unverified bool // a dialer skips verifying the server, and pins nothing instead
}
func (o *TLSOptions) State() TLSState {
if o == nil || !o.Enabled {
return TLSState{}
}
return TLSState{On: true, ClientAuth: o.ClientAuth, Unverified: o.InsecureSkipVerify && o.PinSHA256 == ""}
}
// Check holds the auth rules of a side, http for a plugin that speaks it,
// beside a tls table in state t. It reads no credentials or passwords.
func (o *AuthOptions) Check(side Side, http bool, t TLSState) error {
if o == nil {
return nil
}
if err := checkTags(reflect.ValueOf(o).Elem(), "auth."); err != nil {
return err
}
if o.Type == "" || o.Type == "none" {
// Tuning keys are ignored with the table, but one that names peers or
// credentials means auth was intended and the type was forgotten
if key := o.intentKey(); key != "" {
return fmt.Errorf("auth: %s is set but auth.type is %q", key, "none")
}
return nil
}
switch {
case !t.On && (o.Type != "scram" || len(o.TrustedProxies) == 0):
return fmt.Errorf("auth: type %q requires tls.enabled", o.Type)
case side == Dialer && t.Unverified:
// An unverified server makes identities claims and exposes credentials
return fmt.Errorf("auth: type %q cannot be used with tls.insecure_skip_verify", o.Type)
case side != ChainListener && o.NodeBinding != "" && o.NodeBinding != "none":
return fmt.Errorf("auth: node_binding %q applies only to chain sources", o.NodeBinding)
case o.Type == "mtls" && o.scramKey() != "":
return fmt.Errorf("auth: %s applies only to type %q", o.scramKey(), "scram")
case o.Type == "mtls" && side != Dialer && !t.ClientAuth:
return fmt.Errorf("auth: type %q requires tls.client_auth", "mtls")
case o.Type == "mtls":
for i, pattern := range o.AllowPatterns {
if _, err := regexp.Compile(pattern); err != nil {
return fmt.Errorf("auth: allow_patterns[%d] %q: %w", i, pattern, err)
}
}
return nil
case len(o.Allow) > 0 || len(o.AllowPatterns) > 0:
return fmt.Errorf("auth: allow and allow_patterns apply only to type %q; the users are the allow list", "mtls")
case side == Dialer && (o.CredentialsFile != "" || o.TokenLifetimeMS != 0 || len(o.TrustedProxies) > 0):
return errors.New("auth: credentials_file, token_lifetime_ms and trusted_proxies apply only to listeners")
case side == Dialer && o.Identity != "":
return fmt.Errorf("auth: identity on a dialer pins the server and applies only to type %q", "mtls")
case side == Dialer && (o.Username == "" || o.PasswordFile == ""):
return fmt.Errorf("auth: type %q on a dialer requires username and password_file", "scram")
case side == Dialer:
return nil
case o.CredentialsFile != "" && (o.Username != "" || o.PasswordFile != ""):
return errors.New("auth: credentials_file, or username and password_file: not both")
case (o.Username == "") != (o.PasswordFile == ""):
return errors.New("auth: username and password_file go together")
case o.CredentialsFile == "" && o.Username == "":
return fmt.Errorf("auth: type %q requires credentials_file, or username and password_file", "scram")
case !http && httpKey(o) != "":
return fmt.Errorf("auth: %s applies only to HTTP listeners", httpKey(o))
case len(o.TrustedProxies) > 0 && side != Listener:
return errors.New("auth: trusted_proxies applies only to the http sink")
case len(o.TrustedProxies) > 0 && o.Identity != "":
return errors.New("auth: identity binds a client certificate, which a TLS-terminating proxy does not pass on; drop it or trusted_proxies")
case o.Identity != "" && !t.ClientAuth:
// Binds the certificate to the user: a peer needs its own of both
return fmt.Errorf("auth: identity under type %q binds the client certificate to the user and requires tls.client_auth", "scram")
}
for _, e := range o.TrustedProxies {
if _, err := ParsePrefix(e, "tcp"); err != nil {
return fmt.Errorf("auth: trusted_proxies entry %q: %w", e, err)
}
}
return nil
}
// intentKey names the first key that only makes sense with authentication on
func (o *AuthOptions) intentKey() string {
switch {
case len(o.Allow) > 0:
return "allow"
case len(o.AllowPatterns) > 0:
return "allow_patterns"
}
return o.scramKey()
}
func (o *AuthOptions) scramKey() string {
switch {
case o.CredentialsFile != "":
return "credentials_file"
case o.TokenLifetimeMS != 0:
return "token_lifetime_ms"
case o.Username != "":
return "username"
case o.PasswordFile != "":
return "password_file"
case len(o.TrustedProxies) > 0:
return "trusted_proxies"
}
return ""
}
// Check holds the acl rules of a listener on host, http for one serving
// requests, proxied for one behind auth.trusted_proxies, whose rules take
// clients of either family, as does a PROXY header
func (o *ACLOptions) Check(host string, http, proxied bool) error {
if o == nil {
return nil
}
if err := checkTags(reflect.ValueOf(o).Elem(), "acl."); err != nil {
return err
}
header := o.ProxyProtocol == "optional" || o.ProxyProtocol == "required"
switch {
case header && len(o.ProxyFrom) == 0:
return fmt.Errorf("acl: proxy_protocol %s needs proxy_from, the proxies that may send the header", o.ProxyProtocol)
case !header && len(o.ProxyFrom) > 0:
return errors.New("acl: proxy_from needs proxy_protocol optional or required")
case !http && httpKey(o) != "":
return fmt.Errorf("acl: %s applies only to HTTP listeners", httpKey(o))
case o.MaxConnectionsPerClient > 0 && proxied:
return errors.New("acl: max_connections_per_client counts connections, behind trusted_proxies the proxy's; cap clients at the proxy")
}
network, err := core.Network(host)
if err != nil {
return err
}
clients := network
if header || proxied {
clients = "tcp"
}
for _, list := range []struct {
key, network string
entries []string
}{{"proxy_from", network, o.ProxyFrom}, {"allow", clients, o.Allow}, {"deny", clients, o.Deny}} {
for _, e := range list.entries {
if _, err := ParsePrefix(e, list.network); err != nil {
return fmt.Errorf("acl: %s entry %q: %w", list.key, e, err)
}
}
}
return nil
}
// httpKey names the first key of table v tagged http that is set
func httpKey(v any) string {
rv := reflect.ValueOf(v).Elem()
for _, o := range options(rv.Type()) {
if o.HTTP && !rv.Field(o.index).IsZero() {
return o.Name
}
}
return ""
}
// ParsePrefix reads an address or CIDR of network's family ("tcp": either)
func ParsePrefix(entry, network string) (netip.Prefix, error) {
entry = strings.TrimSpace(entry)
prefix, err := netip.ParsePrefix(entry)
if err != nil {
addr, aerr := netip.ParseAddr(entry)
if aerr != nil || addr.Zone() != "" {
return netip.Prefix{}, errors.New("not an IP address or CIDR; host names and IPv6 zones are not accepted")
}
prefix = netip.PrefixFrom(addr, addr.BitLen())
}
switch a := prefix.Addr(); {
case a.Is4In6():
return netip.Prefix{}, fmt.Errorf("IPv4-mapped; write it as IPv4")
case network == "tcp4" && !a.Is4():
return netip.Prefix{}, errors.New("IPv6, and the listener's host is IPv4; write an IPv4 address or CIDR")
case network == "tcp6" && a.Is4():
return netip.Prefix{}, errors.New("IPv4, and the listener's host is IPv6; write an IPv6 address or CIDR")
}
return prefix, nil
}
// and joins keys as a sentence does: "a, b and c"
func and(keys []string) string {
if len(keys) < 2 {
return strings.Join(keys, "")
}
return strings.Join(keys[:len(keys)-1], ", ") + " and " + keys[len(keys)-1]
}
// pagesBehind is why a login or viewer page needs a proxy: only there do
// browsers log in, and elsewhere the viewer needs no page
const pagesBehind = "only for scram behind auth.trusted_proxies, where browsers log in; without auth or under mtls, the viewer is always at /"
// Check keeps a stream and a status path ServeMux and a URL take literally,
// apart, and off the authentication paths; a login page needs browsers that
// log in, behind a proxy
func (o *HTTPSinkOptions) Check() error {
for _, p := range []struct{ key, path string }{{"stream_path", o.StreamPath}, {"status_path", o.StatusPath}} {
c := path.Clean(p.path)
switch {
case !strings.HasPrefix(p.path, "/") || strings.ContainsAny(p.path, "{}%?#") || c != p.path && (c == "/" || c+"/" != p.path):
return &KeyError{p.key, fmt.Errorf("%q must start with '/', hold no '//', '.' or '..' segment, and none of '{', '}', '%%', '?', '#'", p.path)}
case p.path == chain.AuthPath || strings.HasPrefix(p.path, chain.AuthPath+"/"):
return &KeyError{p.key, fmt.Errorf("%s and the paths under it are reserved for authentication", chain.AuthPath)}
}
}
behind := o.Auth != nil && o.Auth.Type == "scram" && len(o.Auth.TrustedProxies) > 0
switch {
case o.StreamPath == o.StatusPath:
return fmt.Errorf("stream_path and status_path must differ")
case o.LoginPage && !behind:
return &KeyError{"login_page", errors.New(pagesBehind)}
case o.ViewerPage && !behind:
return &KeyError{"viewer_page", errors.New(pagesBehind)}
case o.ViewerPage && !o.LoginPage:
return &KeyError{"viewer_page", errors.New("needs login_page, where it sends a signed-out viewer")}
}
return nil
}
func (o *HTTPChainSourceOptions) Check() error { return checkIngestPath(o.IngestPath) }
func (o *HTTPChainSinkOptions) Check() error {
o.BackoffMaxMS = backoffMax(o.BackoffMinMS, o.BackoffMaxMS)
return checkIngestPath(o.IngestPath)
}
func checkIngestPath(p string) error {
switch {
case !strings.HasPrefix(p, "/"):
return &KeyError{"ingest_path", errors.New("must start with '/'")}
case p == chain.AuthPath:
return &KeyError{"ingest_path", fmt.Errorf("%s is reserved for authentication", chain.AuthPath)}
}
return nil
}
func (o *TCPChainSinkOptions) Check() error {
o.BackoffMaxMS = backoffMax(o.BackoffMinMS, o.BackoffMaxMS)
return nil
}
// backoffMax replaces a maximum below the minimum with the default maximum
func backoffMax(lo, hi int64) int64 {
if hi < lo {
return 30000
}
return hi
}
// Check reads a negative min_disk_free_mb as 100; 0, or none set, keeps no space free
func (o *FileSinkOptions) Check() error {
if o.MinDiskFreeMB < 0 {
o.MinDiskFreeMB = 100
}
return nil
}
// Check caps the jitter at the interval
func (o *RandomSourceOptions) Check() error {
o.JitterMS = min(o.JitterMS, o.IntervalMS)
return nil
}
// Check compiles the patterns
func (c *FilterConfig) Check() error {
for i, pattern := range c.Patterns {
if _, err := regexp.Compile(pattern); err != nil {
return &KeyError{fmt.Sprintf("patterns[%d]", i), fmt.Errorf("%q: %w", pattern, err)}
}
}
return nil
}
// Scan decodes a flow stage's command-line map into target, rejecting keys
// target does not declare: a misspelled key must fail, not be dropped. Its
// values take their kinds as a plugin's do, a wrong one refused at its key.
func Scan(configMap map[string]any, target any) error {
if err := checkKeys(configMap, reflect.TypeOf(target), ""); err != nil {
return err
}
if err := coerce(reflect.TypeOf(target).Elem(), configMap, ""); err != nil {
return err
}
return lconfig.ScanMap(configMap, target)
}
// checkKeys walks nested tables against the toml tags of t, refusing unknown
// keys. Map-typed fields hold free-form keys and are not descended into.
func checkKeys(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:
if err := checkKeys(v, ft, prefix+key+"."); err != nil {
return err
}
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 i, table := range tables {
if err := checkKeys(table, ft, fmt.Sprintf("%s%s[%d].", prefix, key, i)); err != nil {
return err
}
}
}
return nil
}
// checkFileKeys rejects keys the configuration file declares that Config does
// not: a misspelled table path such as plugin_sinks.confg.tls would otherwise
// drop the whole table. Plugin config maps are checked by Scan instead.
func checkFileKeys(path string) error {
data, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return err
}
root, err := fileTable(data)
if err != nil {
return err
}
return checkKeys(root, reflect.TypeOf(Config{}), "")
}
// ParseFile reads a configuration file's text, refusing the keys lw refuses
// there, and opens nothing it names
func ParseFile(data []byte) (*Config, error) {
root, err := fileTable(data)
if err != nil {
return nil, err
}
cfg := &Config{}
return cfg, Scan(root, cfg)
}
// fileTable is a file's root table without the keys lw ignores there
func fileTable(data []byte) (map[string]any, error) {
root, err := toml.NewParser(data).Parse()
if err != nil {
return nil, err
}
delete(root, "config_file") // runtime metadata, documented as ignored in the file
for _, key := range []string{"version", "check", "dump"} {
delete(root, key) // command-line switches, which earlier lw --dump output wrote
}
return root, nil
}