v0.28.0 option schema in struct tags, schema replacing plugin factory metadata, tui prep

This commit is contained in:
lixen committed 2026-10-09 14:43:44 -04:00
1 parent a1648ce17b
commit e8e5f05e47
43 files changed
+1345 -1211

No files matched your search

+143 -190
View File
@@ -6,9 +6,9 @@ package config
type Config struct {
Quiet bool `toml:"quiet"`
// Console sinks without their own color: "auto" (a terminal, NO_COLOR
// unset, TERM not dumb), "always", "never"
Color string `toml:"color"`
// Console sinks without their own color: "auto" is a terminal with
// NO_COLOR unset and TERM not dumb
Color string `toml:"color" lw:"enum=auto|always|never"`
// Runtime behavior flags
StatusReporter bool `toml:"status_reporter"`
@@ -26,17 +26,10 @@ type Config struct {
// LogConfig represents the logging configuration for the LogWisp application itself
type LogConfig struct {
// Output mode: "file", "stdout", "stderr", "split", "all", "none"
Output string `toml:"output"`
// Log level: "debug", "info", "warn", "error"
Level string `toml:"level"`
// Format: "raw", "txt", "json"
Format string `toml:"format"`
// Sanitization policy for console output
Sanitization string `toml:"sanitization"`
Output string `toml:"output" lw:"enum=file|stdout|stderr|split|all|none"`
Level string `toml:"level" lw:"enum=debug|info|warn|error"`
Format string `toml:"format" lw:"enum=raw|txt|json,zero=the log default"`
Sanitization string `toml:"sanitization" lw:"enum=raw|json|txt|shell,zero=the log default"` // console output
// File output settings (when Output includes "file" or "all")
File *LogFileConfig `toml:"file"`
@@ -65,8 +58,7 @@ type LogFileConfig struct {
// LogConsoleConfig defines settings for console-based application logging
type LogConsoleConfig struct {
// Target for console output: "stdout", "stderr"
Target string `toml:"target"`
Target string `toml:"target" lw:"enum=stdout|stderr|split"`
}
// --- Pipeline ---
@@ -90,29 +82,25 @@ type FlowConfig struct {
Format *FormatConfig `toml:"format"`
}
// --- Heartbeat Options ---
// --- Flow stages ---
// HeartbeatConfig defines settings for periodic keep-alive or status messages
type HeartbeatConfig struct {
Enabled bool `toml:"enabled"`
IntervalMS int64 `toml:"interval_ms"`
IncludeTimestamp bool `toml:"include_timestamp"`
IncludeStats bool `toml:"include_stats"`
Format string `toml:"format"`
Enabled bool `toml:"enabled" help:"send heartbeats"`
IntervalMS int64 `toml:"interval_ms" default:"1000" lw:"min=100" help:"milliseconds between heartbeats"`
IncludeTimestamp bool `toml:"include_timestamp" help:"stamp each heartbeat with its time"`
IncludeStats bool `toml:"include_stats" help:"add the pipeline's counters"`
Format string `toml:"format" default:"txt" lw:"enum=txt|json|raw" help:"heartbeat format"`
}
// --- Formatter Options ---
// FormatConfig is a polymorphic struct representing log entry formatting options
// FormatConfig is how a pipeline writes its entries
type FormatConfig struct {
Type string `toml:"type"` // "json", "txt", "raw"
Flags int64 `toml:"flags,omitempty"`
TimestampFormat string `toml:"timestamp_format,omitempty"`
SanitizerPolicy string `toml:"sanitizer_policy,omitempty"` // "raw", "json", "txt", "shell"
Type string `toml:"type" default:"raw" lw:"enum=raw|txt|text|json" help:"entry format; text is txt"`
Flags int64 `toml:"flags,omitempty" lw:"zero=by type" help:"formatter flags"`
TimestampFormat string `toml:"timestamp_format,omitempty" lw:"zero=RFC 3339" help:"Go time layout of timestamps"`
SanitizerPolicy string `toml:"sanitizer_policy,omitempty" lw:"enum=raw|json|txt|shell,zero=by type" help:"how control characters are escaped"`
}
// --- Rate Limit Options ---
// RateLimitPolicy defines the action to take when a rate limit is exceeded
type RateLimitPolicy int
@@ -125,26 +113,20 @@ const (
// RateLimitConfig defines the configuration for pipeline-level rate limiting
type RateLimitConfig struct {
// Rate is the number of log entries allowed per second. Default: 0 (disabled)
Rate float64 `toml:"rate"`
// Burst is the maximum number of log entries that can be sent in a short burst. Defaults to the Rate
Burst float64 `toml:"burst"`
// Policy defines the action to take when the limit is exceeded. "pass" or "drop"
Policy string `toml:"policy"`
// MaxEntrySizeBytes is the maximum allowed size for a single log entry. 0 = no limit
MaxEntrySizeBytes int64 `toml:"max_entry_size_bytes"`
Rate float64 `toml:"rate" lw:"min=0,zero=off" help:"entries a second"`
Burst float64 `toml:"burst" lw:"min=0,zero=the rate" help:"entries at once"`
Policy string `toml:"policy" default:"pass" lw:"enum=pass|drop" help:"drop entries over the rate, or pass them"`
MaxEntrySizeBytes int64 `toml:"max_entry_size_bytes" lw:"min=0,zero=unlimited" help:"drop larger entries"`
}
// --- Filter Options ---
// FilterType represents the filter's behavior (include or exclude)
type FilterType string
const (
// FilterTypeInclude specifies that only matching logs will pass
FilterTypeInclude FilterType = "include" // Whitelist - only matching logs pass
FilterTypeInclude FilterType = "include"
// FilterTypeExclude specifies that matching logs will be dropped
FilterTypeExclude FilterType = "exclude" // Blacklist - matching logs are dropped
FilterTypeExclude FilterType = "exclude"
)
// FilterLogic represents how multiple filter patterns are combined
@@ -152,19 +134,19 @@ type FilterLogic string
const (
// FilterLogicOr specifies that a match on any pattern is sufficient
FilterLogicOr FilterLogic = "or" // Match any pattern
FilterLogicOr FilterLogic = "or"
// FilterLogicAnd specifies that all patterns must match
FilterLogicAnd FilterLogic = "and" // Match all patterns
FilterLogicAnd FilterLogic = "and"
)
// FilterConfig represents the configuration for a single filter
type FilterConfig struct {
Type FilterType `toml:"type"`
Logic FilterLogic `toml:"logic"`
Patterns []string `toml:"patterns"`
Type FilterType `toml:"type" default:"include" lw:"enum=include|exclude" help:"pass only matching entries, or drop them"`
Logic FilterLogic `toml:"logic" default:"or" lw:"enum=or|and" help:"match any pattern, or all"`
Patterns []string `toml:"patterns" lw:"hint=regex" help:"RE2 patterns"`
}
// --- Source Options ---
// --- Sources ---
// PluginSourceConfig represents a source plugin instance configuration
type PluginSourceConfig struct {
@@ -179,38 +161,38 @@ type NullSourceOptions struct{}
// RandomSourceOptions defines settings for a random log generator source
type RandomSourceOptions struct {
IntervalMS int64 `toml:"interval_ms"`
JitterMS int64 `toml:"jitter_ms"`
Format string `toml:"format"`
Length int64 `toml:"length"`
Special bool `toml:"special"`
IntervalMS int64 `toml:"interval_ms" default:"500" lw:"min=1" help:"milliseconds between entries"`
JitterMS int64 `toml:"jitter_ms" lw:"min=0,zero=none" help:"random milliseconds added to the interval, at most the interval"`
Format string `toml:"format" default:"txt" lw:"enum=raw|txt|json" help:"entry format"`
Length int64 `toml:"length" default:"20" lw:"min=1" help:"message characters"`
Special bool `toml:"special" help:"mix in control characters"`
}
// FileSourceOptions defines settings for a file-based source
type FileSourceOptions struct {
Directory string `toml:"directory"`
Pattern string `toml:"pattern"` // glob pattern
CheckIntervalMS int64 `toml:"check_interval_ms"`
Raw bool `toml:"raw"` // keep the whole line as the message, never parse it
From string `toml:"from"` // "end" (default) or "start" of a newly discovered file
Directory string `toml:"directory" lw:"required,hint=dir" help:"directory whose files are followed"`
Pattern string `toml:"pattern" default:"*" lw:"hint=glob" help:"glob the file names match"`
CheckIntervalMS int64 `toml:"check_interval_ms" default:"100" lw:"min=10" help:"milliseconds between scans for files"`
Raw bool `toml:"raw" help:"keep the whole line as the message, never parse it"`
From string `toml:"from" default:"end" lw:"enum=start|end" help:"where reading a newly found file starts"`
}
// ConsoleSourceOptions defines settings for a stdin-based source
type ConsoleSourceOptions struct {
BufferSize int64 `toml:"buffer_size"`
BufferSize int64 `toml:"buffer_size" default:"1000" lw:"min=1" help:"entries queued"`
}
// TCPChainSourceOptions defines settings for a stdlib TCP listener ingesting
// NDJSON entries from upstream logwisp tcp_chain sinks
type TCPChainSourceOptions struct {
TLS *TLSOptions `toml:"tls"`
Host string `toml:"host"`
Port int64 `toml:"port"`
BufferSize int64 `toml:"buffer_size"`
MaxConnections int64 `toml:"max_connections"` // 0 = unlimited
ReadTimeoutMS int64 `toml:"read_timeout_ms"` // per-connection idle deadline, 0 = none
HelloTimeoutMS int64 `toml:"hello_timeout_ms"` // preamble deadline
TrustNode bool `toml:"trust_node"` // false: force node label from remote address
Host string `toml:"host" default:"0.0.0.0" lw:"hint=host" help:"address to listen on; :: for IPv6"`
Port int64 `toml:"port" lw:"required,min=1,max=65535" help:"port to listen on"`
BufferSize int64 `toml:"buffer_size" default:"1000" lw:"min=1" help:"entries queued"`
MaxConnections int64 `toml:"max_connections" lw:"min=0,zero=unlimited" help:"connections at once"`
ReadTimeoutMS int64 `toml:"read_timeout_ms" lw:"min=0,zero=none" help:"milliseconds a connection may idle"`
HelloTimeoutMS int64 `toml:"hello_timeout_ms" default:"10000" lw:"min=1" help:"milliseconds for a peer's hello"`
TrustNode bool `toml:"trust_node" default:"true" help:"keep the node label a peer declares; off, the label is its address"`
Auth *AuthOptions `toml:"auth"`
ACL *ACLOptions `toml:"acl"`
}
@@ -219,18 +201,18 @@ type TCPChainSourceOptions struct {
// NDJSON batches from upstream logwisp http_chain sinks
type HTTPChainSourceOptions struct {
TLS *TLSOptions `toml:"tls"`
Host string `toml:"host"`
Port int64 `toml:"port"`
IngestPath string `toml:"ingest_path"`
BufferSize int64 `toml:"buffer_size"`
MaxBodyBytes int64 `toml:"max_body_bytes"` // per-request cap
ReadTimeoutMS int64 `toml:"read_timeout_ms"` // full request read deadline
TrustNode bool `toml:"trust_node"` // false: force node label from remote address
Host string `toml:"host" default:"0.0.0.0" lw:"hint=host" help:"address to listen on; :: for IPv6"`
Port int64 `toml:"port" lw:"required,min=1,max=65535" help:"port to listen on"`
IngestPath string `toml:"ingest_path" default:"/ingest" lw:"hint=path" help:"path batches are posted to"`
BufferSize int64 `toml:"buffer_size" default:"1000" lw:"min=1" help:"entries queued"`
MaxBodyBytes int64 `toml:"max_body_bytes" default:"8388608" lw:"min=1" help:"largest batch accepted"`
ReadTimeoutMS int64 `toml:"read_timeout_ms" default:"30000" lw:"min=1" help:"milliseconds to read a request"`
TrustNode bool `toml:"trust_node" default:"true" help:"keep the node label a peer declares; off, the label is its address"`
Auth *AuthOptions `toml:"auth"`
ACL *ACLOptions `toml:"acl"`
}
// --- Sink Options ---
// --- Sinks ---
// PluginSinkConfig represents a sink plugin instance configuration
type PluginSinkConfig struct {
@@ -245,35 +227,35 @@ type NullSinkOptions struct{}
// ConsoleSinkOptions defines settings for a console-based sink
type ConsoleSinkOptions struct {
Target string `toml:"target"` // "stdout", "stderr"
BufferSize int64 `toml:"buffer_size"`
Escape string `toml:"escape"` // control characters as <hex>: "auto" (on a terminal), "always", "never"
Color string `toml:"color"` // level names in color: "auto", "always", "never"; default: the top-level color
Target string `toml:"target" default:"stdout" lw:"enum=stdout|stderr" help:"stream written to"`
BufferSize int64 `toml:"buffer_size" default:"1000" lw:"min=1" help:"entries queued"`
Escape string `toml:"escape" default:"auto" lw:"enum=auto|always|never" help:"write control characters as <hex>; auto on a terminal"`
Color string `toml:"color" default:"auto" lw:"enum=auto|always|never" help:"level names in color; unset, the top-level color"`
}
// FileSinkOptions defines settings for a file-based sink
type FileSinkOptions struct {
Directory string `toml:"directory"`
Name string `toml:"name"`
MaxSizeMB int64 `toml:"max_size_mb"`
MaxTotalSizeMB int64 `toml:"max_total_size_mb"`
MinDiskFreeMB int64 `toml:"min_disk_free_mb"`
RetentionHours float64 `toml:"retention_hours"`
BufferSize int64 `toml:"buffer_size"`
FlushIntervalMs int64 `toml:"flush_interval_ms"`
Directory string `toml:"directory" lw:"required,hint=dir" help:"directory written to"`
Name string `toml:"name" lw:"required" help:"base name of the files"`
MaxSizeMB int64 `toml:"max_size_mb" default:"100" lw:"min=1" help:"size at which a file rotates"`
MaxTotalSizeMB int64 `toml:"max_total_size_mb" default:"1000" lw:"min=1" help:"size of all files, beyond which the oldest go"`
MinDiskFreeMB int64 `toml:"min_disk_free_mb" lw:"zero=none" help:"free space kept on the disk; negative means 100"`
RetentionHours float64 `toml:"retention_hours" default:"168" lw:"min=0" help:"hours a file is kept"`
BufferSize int64 `toml:"buffer_size" default:"1000" lw:"min=1" help:"entries queued"`
FlushIntervalMs int64 `toml:"flush_interval_ms" default:"100" lw:"min=1" help:"milliseconds between flushes"`
}
// TCPSinkOptions defines settings for a TCP server sink
type TCPSinkOptions struct {
TLS *TLSOptions `toml:"tls"`
Host string `toml:"host"`
Port int64 `toml:"port"`
BufferSize int64 `toml:"buffer_size"` // sink input queue
ClientBufferSize int64 `toml:"client_buffer_size"` // per-client send queue
WriteTimeoutMS int64 `toml:"write_timeout_ms"` // per-write deadline
KeepAlive bool `toml:"keep_alive"`
KeepAlivePeriodMS int64 `toml:"keep_alive_period_ms"`
MaxConnections int64 `toml:"max_connections"` // 0 = unlimited
Host string `toml:"host" default:"0.0.0.0" lw:"hint=host" help:"address to listen on; :: for IPv6"`
Port int64 `toml:"port" lw:"required,min=1,max=65535" help:"port to listen on"`
BufferSize int64 `toml:"buffer_size" default:"1000" lw:"min=1" help:"entries queued for all clients"`
ClientBufferSize int64 `toml:"client_buffer_size" default:"256" lw:"min=1" help:"entries queued per client"`
WriteTimeoutMS int64 `toml:"write_timeout_ms" default:"5000" lw:"min=1" help:"milliseconds a write may take before the client is dropped"`
KeepAlive bool `toml:"keep_alive" default:"true" help:"TCP keep-alive"`
KeepAlivePeriodMS int64 `toml:"keep_alive_period_ms" default:"30000" lw:"min=1" help:"milliseconds idle before a keep-alive probe"`
MaxConnections int64 `toml:"max_connections" lw:"min=0,zero=unlimited" help:"clients at once"`
Auth *AuthOptions `toml:"auth"`
ACL *ACLOptions `toml:"acl"`
}
@@ -281,17 +263,17 @@ type TCPSinkOptions struct {
// HTTPSinkOptions defines settings for an HTTP SSE server sink
type HTTPSinkOptions struct {
TLS *TLSOptions `toml:"tls"`
Host string `toml:"host"`
Port int64 `toml:"port"`
StreamPath string `toml:"stream_path"`
StatusPath string `toml:"status_path"`
BufferSize int64 `toml:"buffer_size"` // sink input queue
ClientBufferSize int64 `toml:"client_buffer_size"` // per-client send queue
WriteTimeoutMS int64 `toml:"write_timeout_ms"` // per-SSE-write deadline, 0 = none
MaxConnections int64 `toml:"max_connections"` // 0 = unlimited
ReplayLines int64 `toml:"replay_lines"` // entries a new stream gets first, 0 = none
LoginPage bool `toml:"login_page"` // GET /auth/login, needs auth.trusted_proxies
ViewerPage bool `toml:"viewer_page"` // GET /auth/view, needs login_page
Host string `toml:"host" default:"0.0.0.0" lw:"hint=host" help:"address to listen on; :: for IPv6"`
Port int64 `toml:"port" lw:"required,min=1,max=65535" help:"port to listen on"`
StreamPath string `toml:"stream_path" default:"/stream" lw:"hint=path" help:"path of the event stream"`
StatusPath string `toml:"status_path" default:"/status" lw:"hint=path" help:"path of the status document"`
BufferSize int64 `toml:"buffer_size" default:"1000" lw:"min=1" help:"entries queued for all clients"`
ClientBufferSize int64 `toml:"client_buffer_size" default:"256" lw:"min=1" help:"entries queued per client"`
WriteTimeoutMS int64 `toml:"write_timeout_ms" lw:"min=0,zero=none" help:"milliseconds an event write may take"`
MaxConnections int64 `toml:"max_connections" lw:"min=0,zero=unlimited" help:"clients at once"`
ReplayLines int64 `toml:"replay_lines" lw:"min=0,zero=none" help:"recent entries a new stream gets first"`
LoginPage bool `toml:"login_page" help:"serve the login page; scram behind auth.trusted_proxies"`
ViewerPage bool `toml:"viewer_page" help:"serve the viewer to users logged in; needs login_page"`
Auth *AuthOptions `toml:"auth"`
ACL *ACLOptions `toml:"acl"`
}
@@ -300,16 +282,16 @@ type HTTPSinkOptions struct {
// entries to a downstream logwisp tcp_chain source
type TCPChainSinkOptions struct {
TLS *TLSOptions `toml:"tls"`
Node string `toml:"node"` // origin label, default: os.Hostname()
Host string `toml:"host"`
Port int64 `toml:"port"`
BufferSize int64 `toml:"buffer_size"`
DialTimeoutMS int64 `toml:"dial_timeout_ms"`
WriteTimeoutMS int64 `toml:"write_timeout_ms"`
BackoffMinMS int64 `toml:"backoff_min_ms"`
BackoffMaxMS int64 `toml:"backoff_max_ms"`
KeepAlivePeriodMS int64 `toml:"keep_alive_period_ms"`
KeepAlive bool `toml:"keep_alive"`
Node string `toml:"node" lw:"zero=the host name" help:"origin label of the entries"`
Host string `toml:"host" lw:"required,hint=host" help:"host of the tcp_chain source"`
Port int64 `toml:"port" lw:"required,min=1,max=65535" help:"port of the tcp_chain source"`
BufferSize int64 `toml:"buffer_size" default:"1000" lw:"min=1" help:"entries queued"`
DialTimeoutMS int64 `toml:"dial_timeout_ms" default:"5000" lw:"min=1" help:"milliseconds to connect"`
WriteTimeoutMS int64 `toml:"write_timeout_ms" default:"5000" lw:"min=1" help:"milliseconds a write may take"`
BackoffMinMS int64 `toml:"backoff_min_ms" default:"500" lw:"min=1" help:"first wait before reconnecting"`
BackoffMaxMS int64 `toml:"backoff_max_ms" default:"30000" lw:"min=1" help:"longest wait before reconnecting; below the first, the default"`
KeepAlivePeriodMS int64 `toml:"keep_alive_period_ms" default:"30000" lw:"min=1" help:"milliseconds idle before a keep-alive probe"`
KeepAlive bool `toml:"keep_alive" default:"true" help:"TCP keep-alive"`
Auth *AuthOptions `toml:"auth"`
}
@@ -317,17 +299,17 @@ type TCPChainSinkOptions struct {
// NDJSON batches to a downstream logwisp http_chain source
type HTTPChainSinkOptions struct {
TLS *TLSOptions `toml:"tls"`
Node string `toml:"node"` // origin label, default: os.Hostname()
Host string `toml:"host"`
Port int64 `toml:"port"`
IngestPath string `toml:"ingest_path"`
BufferSize int64 `toml:"buffer_size"`
MaxBatchCount int64 `toml:"max_batch_count"`
MaxBatchBytes int64 `toml:"max_batch_bytes"`
FlushIntervalMS int64 `toml:"flush_interval_ms"`
RequestTimeoutMS int64 `toml:"request_timeout_ms"` // covers dial + write + response
BackoffMinMS int64 `toml:"backoff_min_ms"`
BackoffMaxMS int64 `toml:"backoff_max_ms"`
Node string `toml:"node" lw:"zero=the host name" help:"origin label of the entries"`
Host string `toml:"host" lw:"required,hint=host" help:"host of the http_chain source"`
Port int64 `toml:"port" lw:"required,min=1,max=65535" help:"port of the http_chain source"`
IngestPath string `toml:"ingest_path" default:"/ingest" lw:"hint=path" help:"path batches are posted to"`
BufferSize int64 `toml:"buffer_size" default:"1000" lw:"min=1" help:"entries queued"`
MaxBatchCount int64 `toml:"max_batch_count" default:"100" lw:"min=1" help:"entries in a batch"`
MaxBatchBytes int64 `toml:"max_batch_bytes" default:"1048576" lw:"min=1" help:"bytes in a batch"`
FlushIntervalMS int64 `toml:"flush_interval_ms" default:"1000" lw:"min=1" help:"milliseconds before a partial batch is sent"`
RequestTimeoutMS int64 `toml:"request_timeout_ms" default:"10000" lw:"min=1" help:"milliseconds for a request: dial, write and response"`
BackoffMinMS int64 `toml:"backoff_min_ms" default:"500" lw:"min=1" help:"first wait before retrying"`
BackoffMaxMS int64 `toml:"backoff_max_ms" default:"30000" lw:"min=1" help:"longest wait before retrying; below the first, the default"`
Auth *AuthOptions `toml:"auth"`
}
@@ -337,50 +319,33 @@ type HTTPChainSinkOptions struct {
// beside `tls`: TLS answers "is this channel private", auth answers "may this
// peer do this". Listeners verify (mtls: certificate identity; scram: password
// via credentials_file); dialers prove themselves (scram) or pin the server
// (mtls). Validation is per role in internal/authz.
// (mtls). AuthOptions.Check holds the rules of each side.
type AuthOptions struct {
// Method: "none" (default) | "mtls" | "scram"
Type string `toml:"type"`
// Certificate field carrying the identity: "cn" (default) | "san_dns" |
// "san_uri" | "san_email". Under scram it binds the client certificate to
// the user: the field must equal the SCRAM username.
Identity string `toml:"identity"`
// mtls only: exact identities and RE2 patterns (anchor them yourself).
// Both empty admits any identity the CA vouches for.
Allow []string `toml:"allow"`
AllowPatterns []string `toml:"allow_patterns"`
// Chain sources only: "none" | "assert" | "force" (default "force").
// Overrides trust_node.
NodeBinding string `toml:"node_binding"`
// scram listeners: verifier file written by `lw auth add-user`, and
// the bearer token lifetime on HTTP listeners (default 15 minutes)
CredentialsFile string `toml:"credentials_file"`
TokenLifetimeMS int64 `toml:"token_lifetime_ms"`
// scram http sink: addresses or CIDRs of the reverse proxies that end the
// browsers' TLS; logins are then unbound and sessions may be cookies
TrustedProxies []string `toml:"trusted_proxies"`
// scram dialers: the identity presented and the file holding its password
Username string `toml:"username"`
PasswordFile string `toml:"password_file"`
Type string `toml:"type" default:"none" lw:"enum=none|mtls|scram" help:"how peers authenticate"`
Identity string `toml:"identity" lw:"enum=cn|san_dns|san_uri|san_email,zero=cn under mtls; no binding under scram" help:"certificate field naming the peer; under scram it must equal the user"`
Allow []string `toml:"allow" help:"mtls: identities admitted; with allow_patterns empty, any the CA issued"`
AllowPatterns []string `toml:"allow_patterns" lw:"hint=regex" help:"mtls: RE2 patterns of identities admitted; anchor them"`
NodeBinding string `toml:"node_binding" lw:"listener,enum=none|assert|force,zero=force" help:"chain sources: how a peer's identity binds its node label; overrides trust_node"`
CredentialsFile string `toml:"credentials_file" lw:"listener,hint=file" help:"scram: verifiers, from lw auth add-user"`
// exp has whole seconds, a sub-second expires_in reads as unknown, and 10 s
// leaves room for renewal ahead of expiry
TokenLifetimeMS int64 `toml:"token_lifetime_ms" lw:"listener,min=10000,max=86400000,zero=15 minutes" help:"scram on HTTP: bearer token lifetime"`
TrustedProxies []string `toml:"trusted_proxies" lw:"listener,hint=cidr" help:"scram on the http sink: proxies ending the browsers' TLS; logins are then unbound"`
Username string `toml:"username" lw:"dialer" help:"scram: the user this dialer logs in as"`
PasswordFile string `toml:"password_file" lw:"dialer,hint=file" help:"scram: file holding the user's password"`
}
// ACLOptions is a listener's address rules, entries being addresses or CIDRs:
// deny wins, then a set allow list admits only its entries. proxy_from lists
// the L4 proxies whose PROXY header names the client the rules then see; the
// per-client limits count that client. Validation is in internal/netacl.
// per-client limits count that client. ACLOptions.Check holds the rules.
type ACLOptions struct {
Allow []string `toml:"allow"`
Deny []string `toml:"deny"`
ProxyProtocol string `toml:"proxy_protocol"` // off, optional or required
ProxyFrom []string `toml:"proxy_from"`
MaxConnectionsPerClient int64 `toml:"max_connections_per_client"` // 0 = unlimited
RequestsPerSecondPerClient float64 `toml:"requests_per_second_per_client"` // HTTP listeners; 0 = unlimited
Allow []string `toml:"allow" lw:"hint=cidr" help:"addresses admitted; empty, all deny does not list"`
Deny []string `toml:"deny" lw:"hint=cidr" help:"addresses refused"`
ProxyProtocol string `toml:"proxy_protocol" default:"off" lw:"enum=off|optional|required" help:"read a PROXY header from proxy_from"`
ProxyFrom []string `toml:"proxy_from" lw:"hint=cidr" help:"proxies that may send a PROXY header"`
MaxConnectionsPerClient int64 `toml:"max_connections_per_client" lw:"min=0,zero=unlimited" help:"connections a client may hold"`
RequestsPerSecondPerClient float64 `toml:"requests_per_second_per_client" lw:"min=0,zero=unlimited" help:"HTTP: requests a client may make each second"`
}
// --- TLS Options ---
@@ -391,30 +356,18 @@ type ACLOptions struct {
// dialers verify the server with ca_file and server_name, or pin_sha256, and
// may present cert_file and key_file.
type TLSOptions struct {
Enabled bool `toml:"enabled"`
// Local identity: required for listeners, optional for dialers (mTLS)
CertFile string `toml:"cert_file"`
KeyFile string `toml:"key_file"`
// Listeners without cert_file: a certificate on a key made at startup,
// self-signed or signed by the issuer CA, for hosts plus the host option,
// os.Hostname(), localhost and the loopback addresses
SelfSigned bool `toml:"self_signed"`
IssuerCertFile string `toml:"issuer_cert_file"`
IssuerKeyFile string `toml:"issuer_key_file"`
Hosts []string `toml:"hosts"`
// Listener-side peer verification (mTLS)
ClientAuth bool `toml:"client_auth"`
ClientCAFile string `toml:"client_ca_file"`
// Dialer-side peer verification
CAFile string `toml:"ca_file"` // empty = system trust store
ServerName string `toml:"server_name"` // default: config host
InsecureSkipVerify bool `toml:"insecure_skip_verify"`
PinSHA256 string `toml:"pin_sha256"` // sha256//BASE64 of the server key, ';' between several
// Minimum protocol version: "1.2" | "1.3" (default "1.3")
MinVersion string `toml:"min_version"`
Enabled bool `toml:"enabled" help:"use TLS"`
CertFile string `toml:"cert_file" lw:"hint=file" help:"certificate presented; a dialer's for mTLS"`
KeyFile string `toml:"key_file" lw:"hint=file" help:"key of cert_file"`
SelfSigned bool `toml:"self_signed" lw:"listener" help:"a self-signed certificate on a key made at startup"`
IssuerCertFile string `toml:"issuer_cert_file" lw:"listener,hint=file" help:"CA certificate (lw tls ca) that issues the listener's at startup"`
IssuerKeyFile string `toml:"issuer_key_file" lw:"listener,hint=file" help:"key of issuer_cert_file"`
Hosts []string `toml:"hosts" lw:"listener" help:"names and addresses a made certificate carries beyond host, this machine's and loopback"`
ClientAuth bool `toml:"client_auth" lw:"listener" help:"require and verify client certificates"`
ClientCAFile string `toml:"client_ca_file" lw:"listener,hint=file" help:"CA bundle verifying client certificates"`
CAFile string `toml:"ca_file" lw:"dialer,hint=file,zero=the system roots" help:"CA bundle verifying the server"`
ServerName string `toml:"server_name" lw:"dialer,zero=the host" help:"name the server's certificate must carry"`
InsecureSkipVerify bool `toml:"insecure_skip_verify" lw:"dialer" help:"verify nothing of the server; never in production"`
PinSHA256 string `toml:"pin_sha256" lw:"dialer" help:"sha256//BASE64 of the server's key, ';' between several; replaces ca_file"`
MinVersion string `toml:"min_version" default:"1.3" lw:"enum=1.2|1.3" help:"lowest TLS version"`
}
+11 -18
View File
@@ -168,9 +168,8 @@ write_timeout_ms = 9007199254740993
[pipelines.plugin_sinks.config.tls]
enabled = false
server_name = "relay.example"
[pipelines.plugin_sinks.config.auth]
type = "none"
allow = ["one", "two"]
[pipelines.plugin_sinks.config.acl]
allow = ["10.0.0.1", "10.0.0.2"]
`)
m, err := Load(Args{File: "plugins.toml"})
if err != nil {
@@ -181,18 +180,18 @@ allow = ["one", "two"]
if err != nil {
t.Fatal(err)
}
opts := TCPSinkOptions{Host: "127.0.0.1", BufferSize: 1000}
if err := Scan(cfg.Pipelines[0].PluginSinks[0].Config, &opts); err != nil {
opts, err := Decode[TCPSinkOptions]("sink", "tcp", cfg.Pipelines[0].PluginSinks[0].Config)
if err != nil {
t.Fatal(err)
}
if opts.Host != "127.0.0.1" || opts.BufferSize != 1000 || opts.Port != 9000 || opts.WriteTimeoutMS != 9007199254740993 ||
opts.TLS == nil || opts.TLS.ServerName != "relay.example" || opts.Auth == nil || !reflect.DeepEqual(opts.Auth.Allow, []string{"one", "two"}) {
if opts.Host != "0.0.0.0" || opts.BufferSize != 1000 || !opts.KeepAlive || opts.Port != 9000 || opts.WriteTimeoutMS != 9007199254740993 ||
opts.TLS == nil || opts.TLS.ServerName != "relay.example" || opts.ACL == nil || !reflect.DeepEqual(opts.ACL.Allow, []string{"10.0.0.1", "10.0.0.2"}) {
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.
// A misspelled key, even inside a nested tls or auth table, fails the load
// instead of leaving the protection it named switched off.
func TestPluginConfigRejectsUnknownKeys(t *testing.T) {
isolateConfig(t)
for key, table := range map[string]string{
@@ -213,16 +212,10 @@ type = "tcp"
port = 9000
`+table+"\n")
m, err := Load(Args{File: "plugins.toml"})
if err != nil {
t.Fatal(err)
if err == nil {
m.Close()
}
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+`"`) {
if err == nil || !strings.Contains(err.Error(), `pipelines[0].plugin_sinks[out].config: unknown key "`+key+`"`) {
t.Errorf("unknown key %s: err = %v", key, err)
}
}
+370
View File
@@ -0,0 +1,370 @@
package config
import (
"errors"
"fmt"
"math"
"reflect"
"slices"
"strconv"
"strings"
lconfig "github.com/lixenwraith/config"
)
// Side is how a plugin meets the network, which decides the tls, auth and acl
// keys it takes
type Side int
const (
Local Side = iota // no network
Listener // the tcp and http sinks
ChainListener // the chain sources, which also bind the node label
Dialer // the chain sinks
)
// Plugin is a catalogue row: a source or sink type and its options table
type Plugin struct {
Role, Type string // "source" or "sink", and the type its config names
Side Side
HTTP bool // speaks HTTP: takes bearer tokens and request limits
Single bool // one instance per process
Summary string
options reflect.Type
}
var plugins = []Plugin{
{"source", "console", Local, false, true, "Standard input, line by line", reflect.TypeFor[ConsoleSourceOptions]()},
{"source", "file", Local, false, false, "Follow the files of a directory that match a pattern", reflect.TypeFor[FileSourceOptions]()},
{"source", "random", Local, false, false, "Random entries, for tests", reflect.TypeFor[RandomSourceOptions]()},
{"source", "null", Local, false, false, "Nothing, ever", reflect.TypeFor[NullSourceOptions]()},
{"source", "tcp_chain", ChainListener, false, false, "Entries from tcp_chain sinks of other instances", reflect.TypeFor[TCPChainSourceOptions]()},
{"source", "http_chain", ChainListener, true, false, "Entry batches from http_chain sinks of other instances", reflect.TypeFor[HTTPChainSourceOptions]()},
{"sink", "console", Local, false, false, "Standard output or standard error", reflect.TypeFor[ConsoleSinkOptions]()},
{"sink", "file", Local, false, false, "Rotating files", reflect.TypeFor[FileSinkOptions]()},
{"sink", "null", Local, false, false, "Discard", reflect.TypeFor[NullSinkOptions]()},
{"sink", "tcp", Listener, false, false, "Serve entries to TCP clients, one per line", reflect.TypeFor[TCPSinkOptions]()},
{"sink", "http", Listener, true, false, "Serve entries as server-sent events, with a browser viewer", reflect.TypeFor[HTTPSinkOptions]()},
{"sink", "tcp_chain", Dialer, false, false, "Forward entries to a tcp_chain source", reflect.TypeFor[TCPChainSinkOptions]()},
{"sink", "http_chain", Dialer, true, false, "Post entry batches to an http_chain source", reflect.TypeFor[HTTPChainSinkOptions]()},
}
// LookupPlugin finds the catalogue row of a source or sink type
func LookupPlugin(role, typ string) (Plugin, bool) {
i := slices.IndexFunc(plugins, func(p Plugin) bool { return p.Role == role && p.Type == typ })
if i < 0 {
return Plugin{}, false
}
return plugins[i], true
}
// KeyError is a value refused at one key, which callers prefix with the path
// of the table holding it
type KeyError struct {
Key string
Err error
}
func (e *KeyError) Error() string { return e.Key + ": " + e.Err.Error() }
func (e *KeyError) Unwrap() error { return e.Err }
// option is one key of an options table, from its field's tags: toml names
// it, default: holds the default as typed on a command line, help: says what
// it does, and lw: lists its rules: required, enum=a|b, min=N, max=N,
// zero=MEANING (zero is a value, not the default), hint=KIND, listener or dialer.
type option struct {
key, def, help string
required bool
enum []string
min, max *float64
zero string
hint, side string
index int
}
// options reads the keys of an options table; a malformed tag panics, as the
// tables are static and every run would fail alike
func options(t reflect.Type) []option {
var opts []option
for i := range t.NumField() {
f := t.Field(i)
key, _, _ := strings.Cut(f.Tag.Get("toml"), ",")
if key == "" || key == "-" {
continue
}
o := option{key: key, def: f.Tag.Get("default"), help: f.Tag.Get("help"), index: i}
for rule := range strings.SplitSeq(f.Tag.Get("lw"), ",") {
name, value, _ := strings.Cut(rule, "=")
switch name {
case "":
case "required":
o.required = true
case "listener", "dialer":
o.side = name
case "enum":
o.enum = strings.Split(value, "|")
case "min", "max":
n, err := strconv.ParseFloat(value, 64)
if err != nil {
panic(fmt.Sprintf("%s.%s: lw %s=%q", t.Name(), key, name, value))
}
if name == "min" {
o.min = &n
} else {
o.max = &n
}
case "zero":
o.zero = value
case "hint":
o.hint = value
default:
panic(fmt.Sprintf("%s.%s: unknown lw rule %q", t.Name(), key, rule))
}
}
opts = append(opts, o)
}
return opts
}
// Decode reads a plugin's config map into its options: unknown keys, kinds,
// the defaults, the tag rules, the tls, auth and acl rules of its side, then the
// options' own Check. It reads no file.
func Decode[T any](role, typ string, m map[string]any) (*T, error) {
v, err := decode(role, typ, m)
if err != nil {
return nil, err
}
return v.(*T), nil
}
func decode(role, typ string, m map[string]any) (any, error) {
p, ok := LookupPlugin(role, typ)
if !ok {
return nil, fmt.Errorf("unknown %s type %q", role, typ)
}
if err := checkKeys(m, p.options, ""); err != nil {
return nil, err
}
m = clone(m)
if err := coerce(p.options, m, ""); err != nil {
return nil, err // a file's wrong kind, named by its key
}
v := reflect.New(p.options)
preset(v.Elem(), m)
if err := lconfig.ScanMap(m, v.Interface()); err != nil {
return nil, err
}
if err := Settle(v.Interface()); err != nil {
return nil, err
}
if n, ok := v.Interface().(networked); ok {
if err := p.checkNetwork(n); err != nil {
return nil, err
}
}
return v.Interface(), nil
}
// Settle gives a decoded table its defaults, applies the tag rules, then its
// own Check: Decode's last steps, for the flow stages a file decodes typed
func Settle(v any) error {
rv := reflect.ValueOf(v).Elem()
settle(rv)
if err := checkTags(rv, ""); err != nil {
return err
}
if c, ok := v.(interface{ Check() error }); ok {
return c.Check()
}
return nil
}
// preset sets every default before decoding, so a switch that defaults on
// stays on unless set off; it does so in the tables m sets
func preset(v reflect.Value, m map[string]any) {
for _, o := range options(v.Type()) {
f := v.Field(o.index)
if sub, ok := m[o.key].(map[string]any); ok && f.Kind() == reflect.Pointer && f.Type().Elem().Kind() == reflect.Struct {
f.Set(reflect.New(f.Type().Elem()))
preset(f.Elem(), sub)
} else if o.def != "" {
setDefault(f, o)
}
}
}
// settle gives each zero key its default, unless zero is a value there or
// the key is a switch, whose off a default would override
func settle(v reflect.Value) {
for _, o := range options(v.Type()) {
f := v.Field(o.index)
switch {
case f.Kind() == reflect.Pointer && f.Type().Elem().Kind() == reflect.Struct:
if !f.IsNil() {
settle(f.Elem())
}
case o.def != "" && o.zero == "" && f.Kind() != reflect.Bool && f.IsZero():
setDefault(f, o)
}
}
}
func setDefault(f reflect.Value, o option) {
var err error
switch f.Kind() {
case reflect.String:
f.SetString(o.def)
case reflect.Int64:
var n int64
n, err = strconv.ParseInt(o.def, 10, 64)
f.SetInt(n)
case reflect.Float64:
var n float64
n, err = strconv.ParseFloat(o.def, 64)
f.SetFloat(n)
case reflect.Bool:
var b bool
b, err = strconv.ParseBool(o.def)
f.SetBool(b)
default:
err = errors.New("no default for this kind")
}
if err != nil {
panic(fmt.Sprintf("%s: default %q: %v", o.key, o.def, err))
}
}
// checkTags applies the tag rules to a table's own keys, naming each by
// prefix and key. A zero number is the default or its zero meaning, and
// meets the range unless required; an empty enum is one only by its zero
// or default.
func checkTags(v reflect.Value, prefix string) error {
for _, o := range options(v.Type()) {
f := v.Field(o.index)
var err error
switch {
case o.required && (f.IsZero() || f.Kind() == reflect.String && strings.TrimSpace(f.String()) == ""):
err = errors.New("must be set")
case f.Kind() == reflect.String && len(o.enum) > 0 && (f.String() != "" || o.zero == "" && o.def == "") && !slices.Contains(o.enum, f.String()):
err = fmt.Errorf("must be one of %s, got %q", strings.Join(o.enum, ", "), f.String())
case f.CanFloat() || f.CanInt():
if f.IsZero() && !o.required {
break
}
n := float64(0)
if f.CanInt() {
n = float64(f.Int())
} else {
n = f.Float()
}
switch {
case math.IsNaN(n) || math.IsInf(n, 0):
err = errors.New("must be a finite number")
case o.min != nil && o.max != nil && !(n >= *o.min && n <= *o.max):
err = fmt.Errorf("must be from %s to %s, got %s", num(*o.min), num(*o.max), num(n))
case o.min != nil && !(n >= *o.min):
err = fmt.Errorf("must be at least %s, got %s", num(*o.min), num(n))
case o.max != nil && !(n <= *o.max):
err = fmt.Errorf("must be at most %s, got %s", num(*o.max), num(n))
}
}
if err != nil {
return &KeyError{prefix + o.key, err}
}
}
return nil
}
func num(n float64) string { return strconv.FormatFloat(n, 'f', -1, 64) }
// sideKeys lists, in order, the keys of a table that apply to one side only
func sideKeys(t reflect.Type, side string) []string {
var keys []string
for _, o := range options(t) {
if o.side == side {
keys = append(keys, o.key)
}
}
return keys
}
// Coerce gives command-line and environment values, which are strings, and
// JSON's floats the kinds a plugin's options declare, so a dump prints them
// typed. Keys and types it does not know are Decode's to refuse.
func Coerce(role, typ string, m map[string]any) error {
p, ok := LookupPlugin(role, typ)
if !ok {
return nil
}
return coerce(p.options, m, "")
}
func coerce(t reflect.Type, m map[string]any, prefix string) error {
for _, o := range options(t) {
value, ok := m[o.key]
if !ok {
continue
}
ft := t.Field(o.index).Type
if ft.Kind() == reflect.Pointer {
ft = ft.Elem()
}
if sub, ok := value.(map[string]any); ok && ft.Kind() == reflect.Struct {
if err := coerce(ft, sub, prefix+o.key+"."); err != nil {
return err
}
continue
}
v, err := coerceValue(ft.Kind(), value)
if err != nil {
return &KeyError{prefix + o.key, err}
}
m[o.key] = v
}
return nil
}
// coerceValue converts text, and a JSON float for an integer; its errors
// never hold the value, which may be a misplaced secret
func coerceValue(kind reflect.Kind, value any) (any, error) {
s, text := value.(string)
switch {
case kind == reflect.Slice && text:
return []any{s}, nil
case kind == reflect.Int64 && text:
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
return n, nil
}
return nil, errors.New("not an integer")
case kind == reflect.Int64:
if f, ok := value.(float64); ok {
if f != math.Trunc(f) || math.Abs(f) > 1<<53 {
return nil, errors.New("not an integer within 2^53")
}
return int64(f), nil
}
case kind == reflect.Float64 && text:
if f, err := strconv.ParseFloat(s, 64); err == nil {
return f, nil
}
return nil, errors.New("not a number")
case kind == reflect.Bool && text:
if b, err := strconv.ParseBool(s); err == nil {
return b, nil
}
return nil, errors.New("not true or false")
}
return value, nil
}
// clone copies m and the tables in it, so decoding leaves the caller's map be
func clone(m map[string]any) map[string]any {
c := make(map[string]any, len(m))
for k, v := range m {
if sub, ok := v.(map[string]any); ok {
v = clone(sub)
}
c[k] = v
}
return c
}
+136
View File
@@ -0,0 +1,136 @@
package config
import (
"math"
"reflect"
"strings"
"testing"
)
// Every catalogue row decodes from its required keys alone: its tags parse,
// its defaults meet its own rules, and an enum's default is one of its values
func TestEveryRowDecodesFromItsRequiredKeys(t *testing.T) {
for _, p := range plugins {
m := map[string]any{}
for _, o := range options(p.options) {
if o.required {
m[o.key] = "1"
}
}
if err := Coerce(p.Role, p.Type, m); err != nil {
t.Fatalf("%s %s: %v", p.Type, p.Role, err)
}
if _, err := decode(p.Role, p.Type, m); err != nil {
t.Errorf("%s %s from %v: %v", p.Type, p.Role, m, err)
}
}
for _, stage := range []any{&HeartbeatConfig{}, &RateLimitConfig{}, &FormatConfig{}, &FilterConfig{}} {
if err := Settle(stage); err != nil {
t.Errorf("%T: %v", stage, err)
}
}
}
// A zero number takes the default unless zero means something there, a switch
// that defaults on stays off when set off, a negative number is refused, and
// what moved from the constructors keeps its meaning: min_disk_free_mb below
// zero is the default, a backoff maximum below the minimum the default maximum
func TestZeroDefaultsAndNegatives(t *testing.T) {
tcp, err := Decode[TCPSinkOptions]("sink", "tcp", map[string]any{"port": int64(1), "buffer_size": int64(0), "keep_alive": false})
if err != nil || tcp.BufferSize != 1000 || tcp.KeepAlive || tcp.Host != "0.0.0.0" {
t.Fatalf("tcp sink: %+v %v", tcp, err)
}
if _, err := Decode[TCPSinkOptions]("sink", "tcp", map[string]any{"port": int64(1), "buffer_size": int64(-1)}); err == nil ||
err.Error() != "buffer_size: must be at least 1, got -1" {
t.Fatalf("negative buffer_size: %v", err)
}
for in, want := range map[any]int64{nil: 0, int64(0): 0, int64(-1): 100, int64(7): 7} {
m := map[string]any{"directory": "d", "name": "n", "min_disk_free_mb": in}
if in == nil {
delete(m, "min_disk_free_mb")
}
file, err := Decode[FileSinkOptions]("sink", "file", m)
if err != nil || file.MinDiskFreeMB != want {
t.Errorf("min_disk_free_mb %v: %+v %v, want %d", in, file, err, want)
}
}
chain, err := Decode[TCPChainSinkOptions]("sink", "tcp_chain", map[string]any{"host": "h", "port": int64(1), "backoff_min_ms": int64(800), "backoff_max_ms": int64(700)})
if err != nil || chain.BackoffMaxMS != 30000 {
t.Fatalf("backoff maximum below the minimum: %+v %v", chain, err)
}
format := FormatConfig{Type: "text"}
if err := Settle(&format); err != nil {
t.Fatalf("format text: %v", err)
}
}
// Command-line text and JSON numbers become the kinds the options declare, so
// a dump prints them typed; a fraction for an integer is refused
func TestCoerceGivesTheDeclaredKinds(t *testing.T) {
m := map[string]any{"port": "8080", "max_connections": float64(3), "keep_alive": "false",
"acl": map[string]any{"allow": "10.0.0.1", "requests_per_second_per_client": "2.5"}, "unknown": "x"}
if err := Coerce("sink", "tcp", m); err != nil {
t.Fatal(err)
}
want := map[string]any{"port": int64(8080), "max_connections": int64(3), "keep_alive": false,
"acl": map[string]any{"allow": []any{"10.0.0.1"}, "requests_per_second_per_client": 2.5}, "unknown": "x"}
if !reflect.DeepEqual(m, want) {
t.Fatalf("coerced %v, want %v", m, want)
}
if err := Coerce("sink", "tcp", map[string]any{"port": 80.5}); err == nil || !strings.HasPrefix(err.Error(), "port: ") {
t.Fatalf("a fraction for an integer: %v", err)
}
}
// ValidateConfig decodes every plugin and flow stage without opening a file,
// and names the path of what it refuses
func TestValidateConfigNamesThePath(t *testing.T) {
valid := func() *Config {
return &Config{Color: "auto", Pipelines: []PipelineConfig{{
Name: "p",
Flow: &FlowConfig{},
PluginSources: []PluginSourceConfig{{ID: "in", Type: "null"}},
PluginSinks: []PluginSinkConfig{{ID: "out", Type: "http", Config: map[string]any{"port": int64(1),
"tls": map[string]any{"enabled": true, "cert_file": "/missing", "key_file": "/missing"}}}},
}}}
}
if err := ValidateConfig(valid()); err != nil {
t.Fatalf("valid configuration, its files missing: %v", err)
}
for want, change := range map[string]func(*Config){
"pipelines[0].plugin_sinks[out].config.port: must be from 1 to 65535, got 70000": func(c *Config) {
c.Pipelines[0].PluginSinks[0].Config["port"] = int64(70000)
},
"pipelines[0].plugin_sinks[out].config: tls: client_auth requires client_ca_file": func(c *Config) {
c.Pipelines[0].PluginSinks[0].Config["tls"].(map[string]any)["client_auth"] = true
},
"pipelines[0].plugin_sinks[out]: duplicate id": func(c *Config) {
c.Pipelines[0].PluginSinks = append(c.Pipelines[0].PluginSinks, PluginSinkConfig{ID: "out", Type: "null"})
},
`pipelines[0].plugin_sources[in].type: unknown source type "nil"`: func(c *Config) {
c.Pipelines[0].PluginSources[0].Type = "nil"
},
"pipelines[0].plugin_sinks[out].config.port: not an integer": func(c *Config) {
c.Pipelines[0].PluginSinks[0].Config["port"] = "http"
},
`pipelines[0].plugin_sinks[up].config: auth: type "mtls" cannot be used with tls.insecure_skip_verify`: func(c *Config) {
c.Pipelines[0].PluginSinks[0] = PluginSinkConfig{ID: "up", Type: "tcp_chain", Config: map[string]any{"host": "h", "port": int64(1),
"tls": map[string]any{"enabled": true, "insecure_skip_verify": true}, "auth": map[string]any{"type": "mtls"}}}
},
"pipelines[0].flow.rate_limit.rate: must be a finite number": func(c *Config) {
c.Pipelines[0].Flow.RateLimit = &RateLimitConfig{Rate: math.Inf(1)}
},
"color: must be one of auto, always, never, got \"\"": func(c *Config) {
c.Color = ""
},
"pipelines[0].flow.heartbeat.interval_ms: must be at least 100, got 10": func(c *Config) {
c.Pipelines[0].Flow.Heartbeat = &HeartbeatConfig{IntervalMS: 10}
},
} {
c := valid()
change(c)
if err := ValidateConfig(c); err == nil || err.Error() != want {
t.Errorf("err = %v, want %s", err, want)
}
}
}
+2 -2
View File
@@ -44,7 +44,7 @@ var specKinds = []specKind{
}
id, err := instanceID(typ, opts, ids)
p.PluginSources = append(p.PluginSources, PluginSourceConfig{ID: id, Type: typ, Config: opts})
return err
return cmp.Or(err, Coerce("source", typ, opts))
}},
{"sink", true, true, false, func(p *PipelineConfig, typ string, opts map[string]any) error {
ids := make([]string, len(p.PluginSinks))
@@ -53,7 +53,7 @@ var specKinds = []specKind{
}
id, err := instanceID(typ, opts, ids)
p.PluginSinks = append(p.PluginSinks, PluginSinkConfig{ID: id, Type: typ, Config: opts})
return err
return cmp.Or(err, Coerce("sink", typ, opts))
}},
{"filter", true, true, false, func(p *PipelineConfig, typ string, opts map[string]any) error {
opts["type"] = typ
+7 -6
View File
@@ -39,7 +39,7 @@ func TestPipelineSpecGrammar(t *testing.T) {
"source", `file,directory=/var/log/a\,b,pattern=*.log`,
"source", `file,id=app,directory=x\=y`,
"source", `file,directory=c:\\logs\\`,
"sink", "http,port=8080,auth.type=scram,auth.credentials_file=users.toml,tls.cert_file=c",
"sink", "http,port=8080,auth.type=scram,auth.credentials_file=users.toml,tls.enabled=true,tls.cert_file=c,tls.key_file=k",
"filter", "include,patterns=ERROR,patterns=WARN",
"filter", `exclude,patterns=\d{1\,3}`,
"format", `txt,timestamp_format=Jan 2\, 2006`,
@@ -59,9 +59,9 @@ func TestPipelineSpecGrammar(t *testing.T) {
{ID: "file_2", Type: "file", Config: map[string]any{"directory": `c:\logs\`}},
},
PluginSinks: []PluginSinkConfig{{ID: "http", Type: "http", Config: map[string]any{
"port": "8080",
"port": int64(8080),
"auth": map[string]any{"type": "scram", "credentials_file": "users.toml"},
"tls": map[string]any{"cert_file": "c"},
"tls": map[string]any{"enabled": true, "cert_file": "c", "key_file": "k"},
}}},
}, {
Name: "b",
@@ -95,7 +95,8 @@ func TestPipelineSpecGrammar(t *testing.T) {
func TestSpecValueIsOneListEntry(t *testing.T) {
isolateConfig(t)
sink := loadPipelines(t, "source", "null", "sink",
`tcp,auth.type=mtls,auth.allow_patterns=^edge-\d{1\,3}$,auth.allow=CN=a\,O=b,auth.allow=CN=c`)[0].PluginSinks[0]
`tcp,port=9000,tls.enabled=true,tls.self_signed=true,tls.client_auth=true,tls.client_ca_file=ca,`+
`auth.type=mtls,auth.allow_patterns=^edge-\d{1\,3}$,auth.allow=CN=a\,O=b,auth.allow=CN=c`)[0].PluginSinks[0]
var opts TCPSinkOptions
if err := Scan(sink.Config, &opts); err != nil {
t.Fatal(err)
@@ -179,7 +180,7 @@ func TestSpecPipelinesDefaultToStdio(t *testing.T) {
t.Fatalf("pipeline: %+v", p)
}
_, err := Load(Args{Specs: specs("pipeline", "a", "sink", "null", "pipeline", "b", "sink", "null")})
if err == nil || !strings.Contains(err.Error(), `console source already reads stdin in pipeline "a"`) {
if err == nil || !strings.Contains(err.Error(), `one console source may run, and pipeline "a" has it`) {
t.Fatalf("second stdin reader: %v", err)
}
}
@@ -202,7 +203,7 @@ func TestReloadKeepsSpecPipelines(t *testing.T) {
testutil.WriteFile(t, "logwisp.toml", "status_reporter = true\n[[pipelines]]\nname = \"file\"\n")
next, err := m.Reload()
if err != nil || !next.StatusReporter || len(next.Pipelines) != 1 || next.Pipelines[0].Name != "cli" ||
next.Pipelines[0].PluginSources[0].Config["special"] != "true" {
next.Pipelines[0].PluginSources[0].Config["special"] != true {
t.Fatalf("reload: %+v %v", next, err)
}
if err := os.Remove("logwisp.toml"); err != nil {
+442 -46
View File
@@ -1,21 +1,30 @@
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 validates top-level structure only
// Value range validation is delegated to component constructors
// 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, naming the path of a refused key. lw --check opens them.
func ValidateConfig(cfg *Config) error {
if cfg == nil {
return fmt.Errorf("config is nil")
@@ -34,15 +43,21 @@ func ValidateConfig(cfg *Config) error {
names[p.Name] = struct{}{}
}
if err := lconfig.OneOf("auto", "always", "never")(cfg.Color); err != nil {
return fmt.Errorf("color: %w", err)
if err := checkTags(reflect.ValueOf(cfg).Elem(), ""); err != nil {
return err
}
if l := cfg.Logging; l != nil {
if err := checkTags(reflect.ValueOf(l).Elem(), "logging."); err != nil {
return err
}
if l.Console != nil {
if err := checkTags(reflect.ValueOf(l.Console).Elem(), "logging.console."); err != nil {
return err
}
}
}
if err := validateLogConfig(cfg.Logging); err != nil {
return fmt.Errorf("logging: %w", err)
}
stdinReader := ""
single := map[string]string{} // role and type of a single-instance plugin: its pipeline
for i, p := range cfg.Pipelines {
if err := lconfig.NonEmpty(p.Name); err != nil {
return fmt.Errorf("pipeline[%d].name: %w", i, err)
@@ -53,60 +68,441 @@ func ValidateConfig(cfg *Config) error {
if len(p.PluginSinks) == 0 {
return fmt.Errorf("pipeline[%d]: no sinks defined", i)
}
for _, src := range p.PluginSources {
if src.Type != "console" {
continue
if err := checkFlow(p.Flow, fmt.Sprintf("pipelines[%d].flow", i)); err != nil {
return err
}
ids := map[string]bool{}
check := func(role, list, id, typ string, m map[string]any) error {
path := fmt.Sprintf("pipelines[%d].%s[%s]", i, list, id)
plugin, ok := LookupPlugin(role, typ)
switch {
case ids[list+id]:
return fmt.Errorf("%s: duplicate id", path)
case !ok:
return fmt.Errorf("%s.type: unknown %s type %q", path, role, typ)
case plugin.Single && single[role+typ] != "":
return fmt.Errorf("pipeline %q: one %s %s may run, and pipeline %q has it", p.Name, typ, role, single[role+typ])
}
if stdinReader != "" {
return fmt.Errorf("pipeline %q: a console source already reads stdin in pipeline %q", p.Name, stdinReader)
ids[list+id] = true
if plugin.Single {
single[role+typ] = p.Name
}
if _, err := decode(role, typ, m); err != nil {
return at(path+".config", err)
}
return nil
}
for _, s := range p.PluginSources {
if err := check("source", "plugin_sources", s.ID, s.Type, s.Config); err != nil {
return err
}
}
for _, s := range p.PluginSinks {
if err := check("sink", "plugin_sinks", s.ID, s.Type, s.Config); err != nil {
return err
}
stdinReader = p.Name
}
}
return nil
}
// validateLogConfig validates application logging settings
func validateLogConfig(cfg *LogConfig) error {
if cfg == nil {
// checkFlow settles copies of a pipeline's flow stages, which keep their
// defaults for the dump
func checkFlow(f *FlowConfig, path string) error {
if f == nil {
return nil
}
validateOutput := lconfig.OneOf("file", "stdout", "stderr", "split", "all", "none")
if err := validateOutput(cfg.Output); err != nil {
return fmt.Errorf("output: %w", err)
stages := map[string]any{}
if f.Heartbeat != nil {
stages["heartbeat"] = new(*f.Heartbeat)
}
validateLevel := lconfig.OneOf("debug", "info", "warn", "error")
if err := validateLevel(cfg.Level); err != nil {
return fmt.Errorf("level: %w", err)
if f.RateLimit != nil {
stages["rate_limit"] = new(*f.RateLimit)
}
if cfg.Format != "" {
if err := lconfig.OneOf("raw", "txt", "json")(cfg.Format); err != nil {
return fmt.Errorf("format: %w", err)
if f.Format != nil {
stages["format"] = new(*f.Format)
}
for i, filter := range f.Filters {
stages[fmt.Sprintf("filters[%d]", i)] = &filter
}
for _, key := range slices.Sorted(maps.Keys(stages)) {
if err := Settle(stages[key]); err != nil {
return at(path+"."+key, err)
}
}
if cfg.Sanitization != "" {
if err := lconfig.OneOf("raw", "json", "txt", "shell")(cfg.Sanitization); err != nil {
return fmt.Errorf("sanitization: %w", err)
}
}
if cfg.Console != nil {
validateTarget := lconfig.OneOf("stdout", "stderr", "split")
if err := validateTarget(cfg.Console.Target); err != nil {
return fmt.Errorf("console.target: %w", err)
}
}
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.
// at prefixes err with 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 fmt.Errorf("%s.%s: %w", path, ke.Key, ke.Err)
}
return fmt.Errorf("%s: %w", path, err)
}
// 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 err
}
if err := acl.Check(host, p.HTTP, a != nil && len(a.TrustedProxies) > 0); err != nil {
return err
}
return 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 credentials file is 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.Username != "" || o.PasswordFile != "":
return errors.New("auth: username and password_file apply only to dialers")
case o.CredentialsFile == "":
return fmt.Errorf("auth: type %q requires credentials_file", "scram")
case o.TokenLifetimeMS > 0 && !http:
return errors.New("auth: token_lifetime_ms applies only to HTTP listeners")
case len(o.TrustedProxies) > 0 && (side != Listener || !http):
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"
rps := o.RequestsPerSecondPerClient
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 rps > 0 && !http:
return errors.New("acl: requests_per_second_per_client applies only to HTTP listeners")
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
}
// 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{}, fmt.Errorf("neither an address nor a CIDR (and no zone)")
}
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(), network == "tcp6" && a.Is4():
return netip.Prefix{}, fmt.Errorf("not of the listener's family (%s)", network)
}
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]
}
// 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 || o.ViewerPage) && !behind:
return errors.New("login_page and viewer_page apply to scram behind auth.trusted_proxies, where browsers log in; without auth or under mtls the viewer is always served")
case o.ViewerPage && !o.LoginPage:
return errors.New("viewer_page 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.
func Scan(configMap map[string]any, target any) error {
if err := checkKeys(configMap, reflect.TypeOf(target), ""); err != nil {
return err