v0.28.0 option schema in struct tags, schema replacing plugin factory metadata, tui prep
This commit is contained in:
1 parent
a1648ce17b
commit
e8e5f05e47
43 files changed
+1345
-1211
No files matched your search
+143
-190
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user